725+625: What Hardcoded Numbers Cost

Part 2 of a series — a Dynamo tunnel geometry that was already finished, a 33-sheet Excel workbook it had never been wired to, the web viewer built to referee the two, and the Revit add-in at the end of it.

Somewhere in the original graph there is a code block that reads 725+625;. The author did not write 1350, and that choice tells you everything: 725 and 625 were two separate real dimensions, and they were trying to leave a trail.

Dynamo gave them arithmetic and nothing else. This post is about what that cost — measured across 32 .dyn files, 137 numeric literals and one dependency trace nine hops deep — and what the graph looked like when it came out the other side.

Hardcoding was the correct decision

The right utility detail, fully dimensioned. Given a drawing this complete and a deadline, writing the numbers straight into the graph is the rational first move.

Concede this first: typing numbers straight into a code block while prototyping geometry is not a bad habit. At that stage it is the most rational thing you can do.

Shaping a section in Dynamo goes like this. Place a point. Draw a circle from it. Move it some distance. Look at the screen. If it looks right, move on; if not, change the number and look again. The bottleneck in that loop is looking and deciding, not typing. Code blocks are built for it: one double-click to create, one line — 725+625; — to get a value, one wire to consume it. An Excel reference instead means resolving a workbook path, choosing a sheet, picking a cell, confirming metres versus millimetres, and defining empty-cell behaviour. Paying that before you know the geometry is right is not rational. The original graph's zero Excel references were not laziness; they fit the purpose exactly.

The trouble is what happens next. The moment the prototype is confirmed to work, the graph changes character — experiment to production tool — but the file does not change. The fact that the geometry came out right retroactively legitimises every number buried inside it. That is where the bottleneck is born: not because the numbers are wrong, but because nobody recorded where they came from.

The graph, dissected

ItemValue
Nodes617
Connectors827
Code Block nodes159
Code Blocks containing numbers117 (73.6%)
Numeric literals inside code blocks137 occurrences
Distinct values38
Python nodes4
ZeroTouch function nodes414
Annotations / groups59
Excel references0

59 groups over 617 nodes is not a neglected file. This is a well-built graph with no provenance.

The whole code-block census

Before looking at individual blocks, here is what all 159 of them actually contain, grouped by the text inside. This is the complete inventory, not a sample:

CategoryBlocksContents
Bare list-index extraction47a[0]; ×11, a[1]; ×12, a[2]; ×8, a[3]; ×5, a[4]; ×5, a[5]; ×5, a[6]; ×1
Bare integer index130; ×2, 1; ×3, 2; ×2, 5; ×2, 0.5; ×3, -1;, 6;, 7;, -2;, 0;\n1; ×4, 1;\n-1;
Pass-through variable names~25a; ×7, crv; ×4, b; ×2, circle; ×2, point; ×2, line; ×2, c;, v;, srf;, circleline;, plan_point;, xaxis;\n\nyaxis; ×3
Bare dimension literals251000; ×9, 90; ×7, 150; ×4, 300; ×2, 3000; ×2, 3600;, 4600;, -800;, 700;, 820;, 7000;, 5000;, 450;, 200;, 30;, -30;, 90;\n270;
Arithmetic expressions7a+2500;, -a;, -a-1000;, 725+625;, 725+200+4600;, 725+625+200;, 550+250;\n500;, 3600+3600+2500-b;, -1000+b;\nb=655+250;
Ranges50..1..#20;, 0..-5..#8;, 0..1..0.05;, 0..1..0.02;, st..en..20000;
Named geometry labels10R1;, r1;, r11;, R2L;\nR2R;, INSIDE;, X;\nOUTSIDE;, [geo];, plus three carrying Korean strings
Strings and empties4"Circle";, two empty blocks, one indexed Korean identifier

Three observations before the detail. Forty-seven blocks — 30% of all code blocks in a 617-node graph — exist solely to pull item n out of a list called a. That is what a heavily list-based Dynamo graph looks like from the inside. Second, a[6]; appears exactly once and has no downstream connection at all; a[0] through a[5] are used between 5 and 12 times each. That asymmetry is a direct fingerprint of a seven-item list whose last item was extracted and then abandoned — and it becomes important later. Third, the entire set of blocks that could plausibly own a design dimension is 32: 25 bare literals and 7 expressions.

Real code blocks

Everything below is lifted from the original. Downstream consumers were traced by following actual connectors from the block's output port to the receiving node's input port name. Only the interpretation of purpose is inference.

1. Constants making constants

3600;

feeds Point.ByCoordinates.x and simultaneously feeds input a of three more code blocks:

a+2500;      ->  Point.ByCoordinates.x
-a;
-a-1000;     ->  Point.ByCoordinates.x

3600 gets the name a, and derived values grow from it. There is a variable — but the variable's root is another constant. It has a name and no source. Whether a is a lane centreline offset or a clearance half-width is written nowhere. What we do know is where the family lands: three of the section's seed points, at X = 3600, X = 6100 and X = −4600, on the same horizontal line. That is a section skeleton being laid out by hand, one point at a time.

2. Dimensions summed as arithmetic

725+625;             ->  Circle.ByCenterPointRadius.radius
725+200+4600;        ->  Geometry.Translate.distance
725+625+200;         ->  Geometry.Translate.distance  (branches to 2 nodes)

Side by side, 725 is visibly a shared term — three different distances from the same datum. And the three blocks sit at canvas X = −6164, −5230 and −4849 respectively: within about 1,300 units of each other, close enough that a human scrolling past sees all three at once and reads the pattern. That is the entire provenance mechanism available. It works only while you are looking at that part of the canvas, and it is searchable from nowhere.

3600+3600+2500-b;    ->  CoordinateSystem.Translate.distance

The same 3600 reappears at X = 14512 — roughly 28,000 units from the block at X = −13645 that first defined it — and the two are not wired together. Fix one and the other stays wrong. Note also that it does not merely repeat 3600; it repeats it twice and subtracts b, so the expression encodes a chain of three real dimensions and one variable, none of which is named.

3. Variables that are also constants

-1000+b;
b=655+250;

Syntactically the best-written code block in the file: it states explicitly that b = 905 is shared by two calculations. Its output goes to CoordinateSystem.Translate.distance, and b itself is exported as a second output port and consumed by the 3600+3600+2500-b; block above. So the author did build a cross-block parameter, once, and wired it. The intent to parameterise is there; the unit of parameterisation is a single code block, and it was used exactly once out of 159 opportunities.

4. A rectangle written as three numbers

550+250;
500;                 ->  Rectangle.ByWidthLength.width
                     ->  Rectangle.ByWidthLength.length

One block, two output ports, feeding the width and length of the graph's single Rectangle.ByWidthLength. So the rectangle is 800 × 500, and the 800 is stored as 550+250 because 550 and 250 were two separate real things. This is the same trail-leaving instinct as 725+625, and it has the same problem: 250 also appears inside b=655+250 elsewhere in the graph, and nothing connects the two.

5. Translation distances scattered across the canvas

Code blockCanvas XDownstream consumer (measured)
-800;−9,721Geometry.Translate.distance × 2
1000;−9,752Geometry.Translate.distance × 2
820;−5,392Geometry.Translate.distance
700;−4,825Geometry.Translate.distance
7000;1,165Geometry.Translate.distance × 2
3000;26,370Geometry.Translate.distance
3000;35,272Geometry.Translate.distance

Seven blocks, one port name, six values, spread over 45,000 units of canvas. -800 and 1000 sit 31 units apart and are almost certainly a matched pair — an up and a down, or a left and a right. The two 3000s sit 8,900 apart and there is no way to tell whether they are the same 3000.

6. The same number, six different jobs — the 1000 investigation

Part of the original analysis noted that 1000 appears ten times and asked the obvious question: how many of those are 1000 for the same reason? Tracing every one of them downstream answers it. Nine blocks contain a bare 1000; two more carry -1000 inside expressions.

Canvas XDownstream portWhat that makes it
−12,147Geometry.Scale.amountA unit conversion — metres to millimetres on the alignment
−9,752Geometry.Translate.distance × 2A placement offset
9,918Curve.PointAtSegmentLength.segmentLengthA distance along a curve
9,937Curve.PointAtSegmentLength.segmentLengthA distance along a curve
13,468Curve.PointAtSegmentLength.segmentLength × 2A distance along a curve
15,413Curve.ExtendStart.distance + Curve.ExtendEnd.distanceOver-extension before trimming
17,680Curve.ExtendEnd.distanceOver-extension before trimming
17,697Curve.ExtendEnd.distanceOver-extension before trimming
27,550Curve.OffsetMany.signedDistanceA thickness
−13,305 (-a-1000;)Point.ByCoordinates.xA section seed coordinate
14,558 (-1000+b;)CoordinateSystem.Translate.distanceA frame offset

Six categorically different jobs. One of them is a unit conversion factor that has nothing to do with the tunnel at all. Three are curve extensions — a working technique where you deliberately overshoot and trim back, so the number means "definitely long enough," not any real dimension. Three are sampling distances. One is a thickness that does affect the built geometry. One is a coordinate. One is a frame offset.

So: if someone asks you to change 1000 to 1200, the correct answer is "which one, and please note that changing the wrong one silently rescales the entire alignment by 20%." You cannot answer that question without doing what I just did — eleven separate connector traces — and there is no way to make the graph answer it for you. That is the bottleneck, stated as concretely as it can be stated.

7. Where the magnitude heuristic breaks

Splitting literals by size — treat |x| > 50 as "dimension-like" and |x| ≤ 50 as "index-like" — is a good first cut and it is what the counting below uses. Tracing the actual consumers shows exactly where it fails:

Code blockCanvas XDownstream consumer (measured)
150;33,260Color.ByARGB.alpha + Color.ByARGB.red
150;33,263Color.ByARGB.alpha + Color.ByARGB.blue
150;33,286Color.ByARGB.alpha + Color.ByARGB.green
150;33,749Color.ByARGB.alpha + Color.ByARGB.red
200;33,267Color.ByARGB.alpha + Color.ByARGB.red

Every single occurrence of 150 in the graph — all four — is a colour channel. So is one of the three 200s. They are semi-transparent previews for the R1 and R2 solids, clustered together at the far right of the canvas past X = 33,000, exactly where a preview-colouring block belongs.

By magnitude they are dimension-like: 150 is a plausible member thickness and 200 is a plausible duct width. By consumer they are not dimensions at all. That is five of the 54 "dimension-like" occurrences — 9% — misclassified by size alone, and the only thing that reveals it is the port name on the receiving node. The heuristic was right about the shape of the problem and wrong about five specific numbers, and knowing which five required tracing all of them anyway.

8. Same number, genuinely different meaning

Code blockCanvas XDownstream consumer (measured)
450;12,935Circle.ByCenterPointRadiusNormal.radius × 2 — the tangent construction helper circles
300;−7,782Circle.ByCenterPointRadius.radiusa circle radius
300;9,682Curve.OffsetMany.signedDistancelining thickness
5000;13,489Curve.ExtendStart.distance, Curve.ExtendEnd.distance
90; (7 blocks)−8,219 … 16,800Geometry.Rotate.degrees
90;\n270;−5,580CoordinateSystem.Rotate.degrees
30; / -30;2,030 / 2,038Geometry.Rotate.degrees — a matched ±30° pair, 8 units apart

The two 300s are the cleanest illustration in the file. One is a circle radius at the far left, in the section-seed region. The other is at X = 9682, inside the R1 crown branch, feeding Curve.OffsetManythat is the 300 mm lining thickness, the single most-quoted dimension in the whole project. To the graph they are both just 300;. There is no way to global-search for "lining thickness."

9. Sample counts and step sizes as constants

0..1..#20;          (node name: "a point parameter")
  ->  List.Count.list
  ->  Curve.HorizontalFrameAtParameter.param

Normalised parameter 0–1 divided into 20. There is no justification for 20. It is whatever the preview could survive. Note that it feeds List.Count as well — the graph asks how many frames it just made, having itself decided how many to make.

0..1..0.05;    ->  Surface.PointAtParameter.u , Surface.NormalAtParameter.u
0..1..0.02;    ->  Surface.PointAtParameter.v , Surface.NormalAtParameter.v

Two more sampling densities, never mentioned in any summary of the graph, sitting at X ≈ 34,470. They sample a surface at 21 × 51 points for a normal-vector visualisation. The step values 0.05 and 0.02 are index-like by magnitude and have nothing to do with any design dimension — but they are the reason a preview-heavy branch takes as long as it does, and neither of them is labelled.

0..-5..#8;          (node name: "b skew")

No downstream connection at all — an orphan, and someone bothered to name it. When you inherit 617 nodes, deciding whether a node like this is an abandoned experiment or an essential element not yet wired costs real time. It is not the only orphan: a[6]; and INSIDE; are also unconnected, and INSIDE; is a named production label, which is a much more alarming thing to find dangling.

st..en..20000;      ->  List.Flatten.list

Upstream: st from a 0; code block, en from Curve.Length of the real Revit alignment. The alignment length is read from data; the slicing interval is baked into the graph. The actual design intent, found later, was not 20 m uniform division but the start/end stations defined by the Excel section sheets — 33 sections, 66 frame rows, 34 unique stations, chainage 461 m to 1,353 m. 20000 was an approximation of that intent, and the thing that buried it.

10. Labels, and what they were attached to

a;
"crown inner clearance";
section_construction_and_clearance;
[geo];        ->  Geometry.Transform.geometry
X;
OUTSIDE;      ->  Surface.ByPatch.closedCurve
INSIDE;       ->  (nothing)
R2L;
R2R;          ->  GeometryColor.ByGeometryColor.geometry  × 2
"Circle";     ->  String.Contains.searchFor

The author knew how to attach string labels and did. But only to geometry, never to dimensions. A label saying "crown inner clearance" tells you what a curve is; it says nothing about where the three points that made it came from.

Two of these deserve a note. [geo]; is the single most important code block in the graph — it is the assembled local section, and its one output goes into the one Geometry.Transform. Everything downstream is a derivative of that block's contents, and the block itself is three characters long.

And "Circle"; — a string literal feeding String.Contains — is how the graph finds the clearance circles after the transform. It converts the transformed geometry to text, searches the text for the word "Circle", and filters by the resulting boolean mask. It works. It is also a type check implemented by substring matching, which will silently include anything whose printed representation happens to contain that word.

The 38 values, split two ways

The named-value block as it ends up in the add-in: LINING_SURFACE carries angle_R1_arc_deg, lining_thickness_mm, R1/R2L/R2R; SLAB carries road_layer_sum_mm and t_slab_mm. Every one of these was once a literal.

Bucket the occurrences by magnitude — dimension-like (|x| > 50) versus index-like (|x| ≤ 50) — and they separate cleanly. Here is the complete frequency table, every distinct value, with the downstream role established by tracing.

Dimension-like — 54 occurrences / 23 distinct:

ValueOccurrencesWhat it actually drives
10009Scale factor, translations, segment lengths, curve extensions, one offset thickness
908Geometry.Rotate.degrees ×7, CoordinateSystem.Rotate.degrees ×1
1504Colour channels only
36003Section seed X, and the root of the a family
7253Shared term in three summed distances
2003Two summed distances + one colour channel
46002Translation distances
30002Translation distances (two unrelated blocks)
25002Section seed X offset; frame translation
-10002Section seed X; frame translation
6252Circle radius term; translation term
3002Lining thickness ×1, circle radius ×1
2502Rectangle width term; b term
70001Translation distance ×2 consumers
50001Curve extension both ends
8201Translation distance
-8001Translation distance ×2 consumers
7001Translation distance
6551b term
5501Rectangle width term
5001Rectangle length
4501Tangent helper circle radius
2701Coordinate system rotation

Index-like — 15 distinct: 1 · 0 · 2 · 5 · 3 · 4 · 0.5 · -1 · 6 · 7 · 11 · 30 · -30 · -5 · -2. Overwhelmingly list indices, with three rotation angles (30, −30) and a scattering of booleans and tolerances.

Four things fall out.

Repetition is heavy. 1000 appears nine times as a bare literal, 90 eight. Section 6 above worked through the nine and found six different jobs. That is the general case, not an outlier.

Indices dominate. Roughly 60% of all literal occurrences are list indices, booleans and rotation angles: wiring numbers. The values that genuinely need provenance are 54 occurrences / 23 distinct — and, after tracing, really 49 occurrences / 22 distinct, once the five colour channels are removed. Knowing that split from day one would have shrunk the job enormously. But the split only becomes knowable after the downstream tracing is finished.

Magnitude hints at meaning, and lies about 9% of the time. 3600, 4600, 7000, 5000 are large placement distances; 725, 625, 550, 450, 300, 250, 200 are member dimensions; 90, 270, 30 are angles; 150 is a colour. A person can guess most of this. A script cannot. That is why this work did not automate.

Every count here is a floor. The census covers Code Block text only. The two tangent-solver Python nodes each contain Line.ByStartPointDirectionLength(start_point[i], direction_vector, 99999) — a hardcoded ray length, twice, invisible to any code-block count. The same nodes carry 2.0 divisors for midpoint arithmetic. Nothing in this post's numbers includes them.

v1_6 to v1_39 — the whole lineage

Schema preview v58. The utility body is generated from schema coordinates only — blocky, and the slab/drain relation is still a straight red tie line.

The same view at v63: the sidewall curve is present and the drain pocket has a floor. Five preview revisions apart, and this is the whole visible difference.

v178 in the viewer, after the opening and core were split into separate branches. Branch counts, not pixels, are what changed.

v173, five revisions earlier. Note the right panel: the same counters (33 / 66 / 34 / 892 m) sit beside every one of these iterations, which is why they are hard to tell apart at a glance.

32 .dyn files survived in the working folder. I parsed all of them. The filenames alone give you the story.

#VersionSuffixWhat this version did
1v1_610m_section_samples10 m uniform sampling (temporary, to dodge performance)
2v1_7excel_station_boundariesSwitch to Excel station boundaries — the semantically correct moment
3v1_8original_split_excel_fusionMerge the original branch with the Excel branch
4v1_9original_dyn_arc_sourceEstablish the true source of the original arcs
5v1_10original_dyn_tangent_arc_reconstructionRebuild the tangent arcs
6v1_11original_dyn_runtime_watch_readyPlace runtime Watch nodes
7v1_12original_dyn_tangent_nodes_auditAudit the tangent nodes
8v1_13runtime_sentinelAdd runtime sentinel nodes
9v1_14automatic_runtime_sentinelAutomate the sentinels
10v1_15original_lineage_auditAudit the original lineage
11v1_16lineage_watch_runtime_proofRuntime proof of the lineage audit
12v1_17slab_r23_solid_viewerVerify slab / R2-R3 solids
13v1_18no_r23_loop_slab_tangentRemove the R2-R3 loop
14v1_19excel_utility_drainage_geometryDrive utility duct and drainage geometry from Excel
15v1_20excel_utility_drainage_cad_detailReflect the drainage CAD detail
16v1_21excel_utility_r23_slab_interfaceUtility duct to slab interface
17v1_22excel_utility_r23_detailAApply detail A
18v1_23excel_utility_detail_requirements_auditAudit detail requirements
19v1_24excel_utility_detail_runtime_proofRuntime proof of the details
20v1_25excel_origin_registry_schemaIntroduce the origin registry schema
21v1_26full_excel_section_schemaFull section schema
22v1_27shared_full_section_schemaShare the schema between nodes
23v1_28support_cycle_shared_schemaSupport pattern schema
24v1_29slab_lower_contact_shared_schemaSlab lower contact
25v1_30branch_profile_geometry_shared_schemaPer-branch profile geometry
26v1_31dynamo_profile_consumer_shared_schemaDynamo consumer contract
27v1_32r23_slab_contact_anchorR2-R3 to slab contact anchor
28v1_33slab_lower_r23_center_anchorSlab lower centre anchor
v1_34, v1_35(absent)Missing numbers
29v1_36original_r_center_offset_auditIdentical to v1_33 on every measure — an audit pass with no graph change
30v1_37viewer_schema_v169_syncViewer schema sync
31v1_38section_schema_v266_syncSection schema sync
32v1_39section_schema_v267_syncEnd of the lineage

Three phases:

  • v1_6–v1_18, investigation. Prefixes original_*, *_audit, *_proof. Not attaching Excel — working out what the original was doing. 6 of these 13 are audit or proof only.
  • v1_19–v1_24, application. Prefix excel_utility_*. Excel values actually reaching geometry.
  • v1_25–v1_39, contract. Prefixes *_schema, *_sync, *_anchor. Freezing the schema and the contract. 9 of 15 are schema work.

Only 6 of 32 versions were spent actually wiring Excel in. The rest was investigation and contract.

The canvas froze; the Python grew

The v497 comparison sheet: Excel reference with the generated vector overlaid on top, generated-only underneath. Work moved out of the canvas and into scripted compare passes like this one.

Here is every version measured the same way: node count, connector count, code blocks, code blocks containing a numeric literal, Python nodes, dimension-like literals inside code blocks, file size, and the character length of the Excel adapter's Python body. Nothing is interpolated — each row is a parse of the actual file.

VersionNodesConn.CBCB w/ numPyDim. literals in CBSize (KB)Adapter (chars)
Original (05-28)6178271591214541,140
Handoff (05-29)181227412966360
Handoff, transform connected181227412966356
Reference rebuild921522522048028,282
v1_696159252219054433,482
v1_796159252219054433,161
v1_8156236464022065933,161
v1_9157236464023066733,161
v1_10157236464023067033,161
v1_11157236464023067233,161
v1_12157236464023067433,161
v1_13160241474024068233,161
v1_14160241474024068333,161
v1_15160241474024068533,161
v1_16161242474024068833,161
v1_17161242474024069633,161
v1_18161242474024069033,161
v1_19161242474024070836,011
v1_20161242474024070836,011
v1_21161242474024070836,011
v1_22161242474024071236,011
v1_23161242474024071336,011
v1_24161242474024071436,011
v1_25161242474024072039,344
v1_26161242474024073241,509
v1_27161242474024074047,537
v1_28161242474024074650,303
v1_29161242474024074650,328
v1_30161242474024074750,343
v1_31161242474024099650,343
v1_32161242474024075850,294
v1_33161242474024076150,314
v1_36161242474024076150,314
v1_37161242474024075850,292
v1_38161242474024075850,295
v1_39161242474024075850,295

The table looks broken at first glance. From v1_16 through v1_39 — 24 versions — node count is pinned at 161 and connectors never move off 242, yet file size climbs from 688 KB to 758 KB and the adapter grows from 33 KB to 50 KB.

That is the honest shape of the work. The wiring was finished at v1_16. The following 24 versions changed the contents of Python nodes and nothing else. Read the adapter column and the plateaus are visible: 33,161 characters held constant across twelve versions (v1_7 to v1_18) while the graph was busy auditing the original; then a jump to 36,011 for the six excel_utility_* versions; then a climb through 39,344 → 41,509 → 47,537 → 50,303 as the schema work landed; then eight versions oscillating within 50 characters of each other.

Some individual rows say things a summary would hide:

  • v1_36 is v1_33. Same 161/242/47/40/24, same 761 KB, same 50,314-character adapter. The version exists because an audit was performed, not because anything changed. It is honest bookkeeping and it looks like waste in a version list.
  • v1_31 is 996 KB — 250 KB larger than its neighbours — with an identical node count and an identical adapter. Something transient was embedded and removed the next version. That is unexplained; I did not diff the JSON to find it.
  • The reference rebuild has no Python-script nodes by the same measure that finds 19 in v1_6, because it uses a different node implementation. Its 28,282-character adapter is real; the node-type census is what differs. This is exactly the sort of thing that makes cross-version tables lie if you do not say how you measured.

The dimension-literal column deserves its own note, because it is the actual result of the whole exercise:

Dimension-like numbers inside code blocks (lining thickness, radii, offsets)
Original           54 occurrences / 23 distinct
Handoff             6
Reference rebuild   0
v1_6 ... v1_39      0   (every single version)

From the reference rebuild onward, every version has exactly zero dimension-like numbers inside code blocks. The 47 code blocks in v1_39 contain: integer indices (0; 1; … selecting which output of a multi-output Python node to take), three file path strings, and one each of true;, false;, null;, "GenericModel";.

One caveat on that zero, and it matters. The three file-path strings contain digits — dates and folder names — and a naive literal scan counts them as large numbers. Excluding string contents gives zero; including them gives five to nine per version, all of them fragments of paths. The zero is real; it is a zero about design values, not a zero about characters.

That is the eight-week result. Code blocks survived; code blocks owning dimensions did not. The remaining 47 are wiring components, not design parameters.

Why it took so long

Redraw counts per element. This chart is the honest answer to 'why did it take so long' — the same regions were re-derived over and over.

From the outside this looks like "plug Excel cells into nodes," which should take a day. It took eight weeks. Nine analysis notes totalling 182,833 bytes survive in the working folder — not graphs, not code, purely a record of establishing what numbers were.

NoteSizeWhat it was trying to establish
v1_8 graph builder script37,043 BNot a note — a program that writes the next .dyn. Graph edits were made by generating JSON, not by dragging nodes, so that every change was reproducible and diffable
original_r2_upstream_tree.txt36,230 BEvery node upstream of the R2 label, as indented text — because the chain cannot be followed on screen
original_r123_upstream_tree.txt35,096 BThe same for the R1, r1 and R2L/R2R labels together
original_r2_detail_neighborhood.txt23,810 BA dump of everything near R2 on the canvas, because upstream tracing alone missed the family-instance branch
Mirror-oracle compare injector17,222 BInjects a node that reads the web viewer's export back into Dynamo and compares section counts, chainages, branch labels and R1 envelope samples — so the two implementations check each other
v1_7 gap review12,259 BEight enumerated ways the rebuild still failed to reproduce the original
Original geometry flow analysis10,095 BThe screen-position map, the Python node table, and the finding that R1 is computed
WORKING_CONTEXT.md7,680 BFile roles, SHA-256 hashes for all seven inputs, and resume rules — which file is the real one
v1_8 implementation notes3,398 BWhat changed, the new Watch list, and static verification counts

Two of these are worth pausing on because they are not what you expect an "analysis note" to be.

The working context file exists because there were seven candidate graphs with confusingly similar names in four folders, and the newest file timestamp belonged to the wrong one. It records, explicitly: v1_6 has a later timestamp than v1_7, but it is not the intended semantic direction; it is a temporary 10 m sampling version. Without that sentence written down, every resumed session would have picked v1_6 by mtime and re-inherited the sampling assumption the project was trying to escape. It also carries a SHA-256 for every input file, so "is this the same workbook?" is answerable in one command rather than by opening it and looking.

The mirror-oracle compare is the structural idea of the whole project in one node. Two independent implementations existed — a web viewer and a Dynamo graph — both fed from the same workbook. Rather than trust either, a node was added inside Dynamo that reads the viewer's output and reports disagreements as Watch issues. It does not change geometry. It just says where the two disagree. Everything in Part 3 about gates and evidence descends from that pattern.

Case 1 — nine hops from R1; to 0;

The R1/R2/R3 dependency dump is 147 lines and is organised by label. Its first section is headed FINAL_R1_LABEL R1; and walks upstream from a code block whose entire content is two characters:

ROOT -> Code Block :: code=R1;                    (32880, -13696)
  IN R1 <= Solid.ByLoft                           (11897, -18017)
    IN crossSections <= PolyCurve.ByGroupedCurves (11550, -18014)
      IN curves <= Surface.PerimeterCurves        (11221, -18012)
        IN surface <= Surface.ByLoft              (10919, -18010)
          IN crossSections <= List.Transpose      (10645, -18013)
            IN lists <= List Create               (10339, -18058)
              IN item0 <= Arc.ByThreePoints       ( 9662, -18102)
                IN firstPoint <= List.GetItemAtIndex  (3510, -19267)
                  IN index <= Code Block :: code=0;   (3180, -19256)

Nine hops upstream, and what I found was 0; — a list index, not a dimension. Nine hops to learn that this number was not a dimension at all.

The coordinates matter too, and now you can read them off the dump directly. The R1; label sits at X ≈ 32,880; the node that produced its value sits at X ≈ 3,510. Two points about 29,000 units apart on screen are one dependency chain. Following that by eye across a 617-node canvas is impossible, which is why the tree had to be dumped as text, which is why there is a 35 KB file.

Another branch of the same tree yields Curve.OffsetMany.signedDistance <= Code Block :: code=300; at (9682, −17690). That one is a real dimension — 300 mm of lining. But it sits in the same tree at a similar depth, mixed among the index code blocks: within the ten lines quoted above there are three List.GetItemAtIndex nodes fed by 0;, 1; and 2;, and the 300 hangs off the very next item of the same List Create. The only thing separating a design value from a wiring value is the port name on the downstream node: signedDistance means dimension, index means index. That is why this did not automate.

And one entry in that dump is empty. The section headed FINAL_R2_LABEL R2L; R2R; has no upstream chain at all — the trace returns nothing. The R2 label's actual inputs come through the family-instance branch, which is Revit session state, not graph structure. The dependency dumper could not see it because it does not exist in the file. That blank section is the reason a third note, the 23 KB neighbourhood dump, had to be written: when upstream tracing returns nothing, the fallback is to dump everything physically near the node and read it by hand.

Case 2 — R1 was not an input

The most expensive finding in the project, from the geometry flow analysis:

R1 is not a fixed-radius arc. It is derived from the transformed minimum clearance circles.

The actual chain, as recorded:

Geometry.Transform output
  -> String from Object
    -> String.Contains("Circle")
      -> List.FilterByBoolMask
        -> List.Flatten
          -> List.DropItems(-1)
            -> Python envelope solver
                 IN[0] = filtered transformed circles except one
                 IN[1] = transformed centre/reference item
                 OUT   = [solution_circle, final_Rad]
              -> List.FirstItem / List.LastItem
                -> Circle.CenterPoint / Geometry.Rotate / Sphere.ByCenterPointRadius
                  -> Geometry.Explode / Geometry.Intersect
                    -> Arc.ByThreePoints            (crown inner clearance)
                      -> Curve.OffsetMany(300)      (crown)
                        -> Surface / PolyCurve / Solid branch

And the solver itself:

d1 = center_point[i].DistanceTo(p1)
d2 = center_point[i].DistanceTo(p2)
d3 = center_point[i].DistanceTo(p3)

req_r1 = d1 + r1
req_r2 = d2 + r2
req_r3 = d3 + r3

final_radius = max(req_r1, req_r2, req_r3)

solution_circle.append(Circle.ByCenterPointRadius(center_point[i], final_radius))

R1 is the minimum radius enclosing all three clearance circles. It is a computed result, not an input. Read the last line and the intent is unambiguous: the crown is placed wherever it has to be so that nothing pokes through the clearance envelope. That is a constraint being satisfied, not a dimension being applied.

Three details in that chain are load-bearing and none of them is documented anywhere in the graph:

  • The circles are found by string matching. String.Contains("Circle") against the printed form of every transformed object.
  • List.DropItems(-1) removes the last one before solving. One circle is deliberately excluded from the envelope. Which one, and why, is not stated — and reproducing R1 without reproducing that exclusion gives a different radius.
  • The centre comes from a separate indexed item, not from the circles. So the solve is "smallest circle about this specific point containing those circles," which is a different problem from "smallest enclosing circle."

This was the most dangerous point in the project. The Excel section sheet has an R1 field, 6.98 m. Plug that cell in without thinking and the graph runs clean, the geometry looks plausible — and the lining violates the clearance envelope. Nothing errors. Nothing is visibly wrong. You would have to measure the gap between the built arch and the clearance profile to notice, and nobody measures a thing that looks right.

The review note recorded the trap explicitly:

The current production path bypasses the original's envelope-solver R1. The Excel R1 is to be retained for diagnostics and input comparison only, unless explicitly selected.

The core of the job was not "plug cells in." It was working out which cells must not be plugged in. And note what the resolution actually was: not "use the Excel value" and not "use the solver value," but keep both, mark which is which, and require an explicit choice. That shape — two candidates, both retained, neither silently promoted — recurs in every hard decision from here on.

Case 3 — one index off

The gap review lists eight items where v1_7 still failed to reproduce the original: the source geometry role contract, transform fidelity, the R1 crown envelope, the R2 side branch, the INSIDE/OUTSIDE closure, loft and segment semantics, the Revit output branch, and an implementation order for fixing them. The role contract was the expensive one.

The original's [geo] list is built by List.Create with nested sub-lists and then flattened. The review reconstructed the flattened order as seven entries:

0 clearance_profile
1 upper_left_or_right_circle
2 upper_right_or_left_circle
3 small/center circle
4 center_reference_point
5 bottom_box
6 bottom_box

while the working graph assumed eight:

0 clearance_profile
1 upper_left_circle
2 upper_right_circle
3 small_right_circle
4 small_left_circle
5 center_reference_point
6 bottom_right_box
7 bottom_left_box

The eight-item version is the one a person would design. It is symmetric, every item is named, left and right are explicit. The seven-item version is what the original actually produces, because item 3 is a single small/centre circle rather than a left/right pair, and everything after it shifts by one.

Recall the code-block census: a[0] through a[5] are extracted between 5 and 12 times each, and a[6] is extracted once and connected to nothing. There is no a[7] anywhere in the graph. The literal count of index blocks in the original file agrees with seven and rules out eight. That is the kind of evidence that ends an argument, and it was available the whole time in a table of code-block frequencies.

One index off and everything below it is off — and this kind of wrong does not raise an error. The geometry gets built, just in the wrong place. Circle 3 becomes circle 4's job, a bottom box becomes a centre reference, the envelope solver receives the wrong set of circles and returns a perfectly valid radius for the wrong problem. The document even labelled item 1 upper_left_or_right_circle, an honest admission that the handedness had not been settled.

v1_8 responded with a new node, 04_SPLIT_REGISTRY, forking the flow into twelve named branch groups — R1_ENVELOPE, R2L_FAMILY, R2R_FAMILY, INSIDE, OUTSIDE, PATCH, SPLIT, SURFACE, SOLID_DIFF, FINAL, DIRECTSHAPE_READY, LEGACY_DIAGNOSTIC — plus seventeen new Watch nodes, one per branch group and one each for the audit table, the issue list and the DirectShape state. The note reads:

Registry output is now authoritative. The Excel production loft remains as diagnostic/comparison geometry. Excel fallback geometry is no longer silently promoted to final.

"No longer silently promoted" summarises eight weeks in four words. The job was not inserting values; it was making where the values came from visible on screen. Seventeen Watch nodes is not instrumentation for its own sake — it is one visible readout per branch that could otherwise win by accident.

The same version added two more safeties worth copying. CREATE_DIRECTSHAPE was introduced as a boolean defaulting to false, with a gate node that, when false, passes preview geometry only and emits a result table instead of writing to the Revit model. And a static verification pass was run and recorded on the generated file: 156 nodes, 236 connectors, 2 annotations, 0 duplicate node IDs, 0 node/view mismatches, 0 bad connectors, 0 embedded Python compile errors. Because the graph was generated by a script rather than edited by hand, those checks could run every time.

Case 4 — the branch that could not be rewritten

One warning from the same review is worth repeating in full, because it defines the limit of the whole approach. The branch that places utility duct families via FamilyInstance.ByCoordinateSystem and pulls curves with Element.Curves was explicitly not to be rewritten in Python:

Prefer native Dynamo nodes for Family Types, FamilyInstance.ByCoordinateSystem and Element.Curves, because they depend on Revit family and session state.
The appearance of native function names as text inside the latest Python node is not the same as preserving the original native branch behaviour.

That second sentence is a rule about evidence, not about Dynamo. A Python node containing the string FamilyInstance.ByCoordinateSystem looks, to any text search, exactly like a graph that calls it. It is not. And the consequence stands unresolved: the rebuilt graph never regained a family-instance branch, so two of its branch groups (R2L_FAMILY, R2R_FAMILY) are named placeholders that report what family would be required rather than creating it, and the web viewer's equivalents are explicitly labelled Excel-dimension proxies. That is written down, in the right place, and it is still a hole.

The five questions per literal

For each of 137 literals:

  1. Dimension or index? → check the downstream port name
  2. If a dimension, is there a corresponding Excel cell? → search 33 sheets
  3. If there is a cell, may it be used? → not for computed values like R1
  4. If it may, is the unit m or mm? → workbook is metres, graph is millimetres
  5. What is used when the cell is empty? → define the fallback

Question 1 is semi-automatable by parsing the graph — that is what produced every consumer column in this post. Questions 2 through 5 are a human comparing against design documents. That judgement consumed most of the eight weeks. Question 3 alone accounts for the R1 trap; question 4 is the reason a single unwatched Geometry.Scale.amount = 1000 can quietly turn the whole model into the wrong size; question 5 is why the final adapter has a defaulting function around every read.

frame_mode — the project in miniature

Named lock branches. The right panel now reports source provenance as identifiers — source registry P 22/22, S 21/21, and cutline datums quoted as source point IDs rather than as bare numbers.

Excel-extracted linework (top) against the v59 generated schema (bottom). The parameter names visible in the top row are what the bottom row is supposed to be driven by.

The adapter Python node, 00_EXCEL_STATION_ADAPTER, existed from the start. Its contract:

IN[0] = xlsm/xlsx file path, or already-read workbook/sheet rows
IN[1] = options config dict
        - model_units_per_mm: default 0.001
        - station_sheet_prefix: default "T_sec_"
        - station_tab_theme:   default "9"
        - chainage_source / frame_mode
IN[2] = debug

OUT[0] = state
OUT[1] = station_sections       (one per green T_sec sheet)
OUT[2] = frame_rows             (station boundary rows per section)
OUT[3] = unique_frame_rows      (deduplicated by chainage)
OUT[4] = section_configs        (geometry config per frame row)
OUT[5] = sheet_summary_table
OUT[6] = dimension_table
OUT[7] = debug logs

Four things stand out.

Sheet selection is by tab colourstation_tab_theme: "9", green. Of the 39 sheets, some have section-like names but are not sections (the hidden T_sec_1 template, which is 97% identical to a live sheet and carries a stale R1 of 6.982). The engineer was distinguishing valid sheets by tab colour, and the adapter simply reads that convention: code fitted to practice. Every name-based rule anyone tried failed on the template; the colour rule never did.

It does not depend on a spreadsheet library — it imports zipfile and xml.etree.ElementTree and defines its own parser class, opening the .xlsm as a zip and parsing sharedStrings and sheet XML directly, because Revit's bundled IronPython may not have one available. This is the same parsing approach used by the standalone registry builder, which is why the two agree.

OUT[2] and OUT[3] are both exported — the 66 frame rows and the 34 deduplicated ones. Both, not one. The adapter refuses to decide for its consumers whether a section boundary is one thing or two, and hands over both readings labelled.

OUT[6] = dimension_table was there from day one — it exports not just values but a table of values. That output later grows into the origin registry.

Now watch one option line evolve:

Point in timeframe_mode defaultMeaningFrames for an 8 m section
Reference rebuildsection_endsStart and end frame per sheet2
v1_6section_samplesStart and end plus 10 m interior frames2
v1_7 – v1_39excel_station_boundariesExcel start/end station frames only2

section_ends — just the two ends of each section; modest, but pointed the right way. section_samples — adds 10 m interior samples, which is the original's st..en..20000; at half the spacing, inheriting the original's hardcoding mindset wholesale. excel_station_boundaries — abandons arbitrary sampling entirely and frames only the boundaries the workbook defines.

Look at the last column. For an 8 m section all three modes produce the same two frames, which is precisely why the wrong answer survived so long: on the short sections the sampling mode is invisible. It only diverges on the 60 m, 96 m and 104 m stretches — four sections out of 33 — where section_samples inserts interior frames carrying geometry identical to their neighbours. Extra frames that agree with each other are the hardest kind of wrong to see.

Step 2 to step 3 is the moment the hardcoding bottleneck actually broke. "How many metres between slices?" disappeared, replaced by "where did Excel divide the sections?" The answer did not change; the question did.

That option name never changed again across 33 versions from v1_7 to v1_39. Neither did any of the eight output ports. What changed was only the precision of what filled the contract — 33,161 characters of it at v1_7, 50,295 at v1_39.

Where it landed: cell addresses as parameters

Actual v1_39 adapter code:

sta_start   = f(cell(ws, "I9"),  0.0)
sta_end     = f(cell(ws, "I10"), sta_start)
length_m    = f(cell(ws, "K9"),  dist_end - dist_start)

width_lane_m      = f(cell(ws, "E20"), 7.2)
shoulder_left_m   = f(cell(ws, "D20"), 2.5)
direction         = text(cell(ws, "G3",  ""))
support_type      = text(cell(ws, "G10", ""))

dims["h_cr_ct_mm"] = mm_from_m(cell(ws, "B45" if side == "start" else "B46"))

This is where 725+625; ended up. The parameter changed from a number into a cell address. Across the adapter, cell(ws, "…") is called with a literal address 72 times, across 50 distinct addresses.

Read the last line closely, because it is doing something the original graph had no way to express. B45 or B46 depending on whether this frame is the start or the end of its section — one parameter, two cells, chosen by the frame's role. That is the 66-frames-not-34 decision from Part 1, implemented. In the original graph, h_cr_ct would have been a single number, and the discontinuity at a section boundary would simply not have existed.

The origin registry

Introduced at v1_27 — the same version, on the same afternoon, that introduced the shared section schema and its save endpoints — and the real destination:

specs = [
    ("grouting_reinforce_dia_mm", "B183", "mm",  "m_to_mm"),
    ("grouting_dia_mm",           "C183", "mm",  "m_to_mm"),
    ("grouting_length_mm",        "D183", "mm",  "m_to_mm"),
    ("grouting_angle_deg",        "E183", "deg", "float"),
    ...
]
rows = [["key", "cell", "value", "unit", "converter"]]
for key, addr, unit, converter in specs:
    raw = cell(ws, addr)
    val = mm_from_m(raw) if converter == "m_to_mm" else f(raw, 0.0)
    rows.append([key, addr, val, unit, converter])

The output table header is ["key", "cell", "value", "unit", "converter"]. Open it in a Watch node and a row reads:

keycellvalueunitconverter
grouting_dia_mmC18350.0mmm_to_mm

You do not just see the value. You see which cell it came from, its unit, and the conversion it went through, all on one line. Value looks wrong? Read the cell address and open the workbook. Unit looks wrong? Read the converter column.

Compare that one row against the original's equivalent. In the original, the same fact was expressed as a code block reading 300; somewhere on a 49,000-unit canvas, and answering "where did this come from" required the nine-hop trace above. The registry row carries five fields where the code block carried one, and four of the five are exactly the four questions people ask.

Note also the converter column carrying float for the angle. Degrees do not get multiplied by 1000. A conversion table that only had a units column would tempt you to derive the conversion from the unit; keeping the converter separate means the table records what was actually done, not what should have been done. Those differ exactly when there is a bug, which is the only time you are reading the table.

Why this is the right answer, in three parts. It stores relationships, not values. ("grouting_dia_mm", "C183", "mm", "m_to_mm") is a contract; when C183 changes, the graph uses the new value with no edits. It makes verification possible. Answering "is the lining 300?" in the original meant finding every 300; and tracing each downstream — and as Section 8 above showed, the two 300s in the file are different things, so the naive answer is wrong. Now you search the table by key, and the cell address next to it puts you two clicks from the design document. Fallbacks are explicit. In f(cell(ws, "E20"), 7.2) the 7.2 is still hardcoded — but it is a different kind of number: not a design value, a defence against an empty cell, and that status is expressed by its position in the function signature. The original's 725+625; had no such distinction. Every number had equal standing.

Honestly: v1_39 is not perfectly clean. Three code blocks still carry absolute file paths as strings, and fallback defaults remain inside the adapter. Two of those three paths point outside the project folder entirely, which means the graph will not open correctly on another machine without editing. But those are subordinate to the Excel cells and used only on failure — and their position in the code declares it.

Four things you can actually do

Only methods that work in Dynamo today.

1. Put a (value, source) pair in the code block instead of a value

The cheapest option, available immediately, changes not one wire.

Now:

725+625;

Instead:

v;
v = 725 + 625;
src = "section table C48 + C49 / mm / lining thickness + invert allowance";

Only the first line becomes an output port; src stays a comment variable with no port, so wiring is unaffected. But anyone who double-clicks that node knows the source immediately — and since .dyn is JSON, a global search for src = returns the provenance list for the whole graph. Had the original done this for just the 54 dimension-like occurrences, most of that nine-hop tracing would have been unnecessary.

When one value is used in several places, store a key rather than the value:

v;
v = 1000;
src = "PARAM: shotcrete_thickness_mm";

Now, even with 1000 appearing nine times, a search tells you which ones are 1000 for the same reason. Recall what that costs today: eleven connector traces to discover the nine bare 1000s are doing six different jobs, one of which is a unit conversion. Three lines of comment per block would have answered it instantly.

2. Build one "dimension registry" code block

Stop scattering values at their point of use. Collect them and pull them out by name.

lining_thk_mm;
invert_clear_mm;
shotcrete_thk_mm;
station_step_mm;

lining_thk_mm    = 300;
invert_clear_mm  = 625;
shotcrete_thk_mm = 1000;
station_step_mm  = 20000;

Yes, this adds wires and looks messy. That is exactly the point: visible wires are visible dependencies. If 1000 is used in nine places, nine wires fan out of one node, and the canvas alone tells you that changing it changes nine things. Equally useful in the other direction: the two 3000s at X = 26,370 and X = 35,272 would either connect to the same registry output — proving they are the same 3000 — or they would not, proving they are not.

Combine it with the v1_39 approach — make the registry an array of cell addresses:

addrs = ["C48", "C49", "B45", "I9"];
keys  = ["lining_thk_mm", "invert_clear_mm", "h_cr_ct_mm", "sta_start_m"];

Numbers remaining in the graph: zero. What remains is names and addresses.

3. Keep an audit table on screen next to the values

When a Python node computes and emits values, reserve one output port for an audit table and keep it permanently wired to a Watch node. Do not collapse it, do not hide it. This is exactly what OUT[6] = dimension_table was from the very first version of the adapter, and it is the port that grew into the origin registry.

Part of the effect is psychological: a table always on screen makes scanning values a habit, and an order-of-magnitude error — 7200 becoming 720 — jumps out of a table instantly while being invisible if you only watch the geometry. Minimum five columns: key / source / value / unit / converter.

4. Separate indices from dimensions visually

Half the original's problem was that 0; (an index) and 300; (lining thickness) are identical-looking code blocks — and the census shows how lopsided that is: 47 blocks doing a[n], 13 doing bare integers, versus 32 that could own a dimension. Sixty visually identical wiring blocks camouflaging thirty-two real ones.

  • Never rename index code blocks; always rename dimension code blocks. Node names are stored in NodeViews inside the .dyn, so they are searchable. The original already had named code blocks like a point parameter and b skewthere was just no rule. And note which two got names: a sampling count and an orphan. The 300 mm lining thickness did not.
  • Use node default inputs for indices instead of code blocks. Then "a code block is visible on the canvas" reliably means "this is a design value."
  • Group only the dimension code blocks, and title the group. The original had 59 groups, but none of them were organised around dimensions — and 39 of the 59 had no title at all.

And: fix the contract first, fill it in later

The adapter's eight outputs never changed once between the reference rebuild and v1_39. Contents grew from 28 KB to 50 KB; the contract held. Downstream nodes therefore needed no rewiring across 33 versions — which is exactly why node and connector counts sat at 161/242 for 24 straight versions.

The option dictionary held too. model_units_per_mm, station_sheet_prefix, station_tab_theme, chainage_source, frame_mode — five keys, unchanged, with only frame_mode's default moving. Changing a default is a one-line diff. Changing a port is a rewiring job across every consumer.

Define the output contract first and fill the inside afterwards, and refactoring never touches the wiring. That is the structural reason a 617-node graph could be brought under control in eight weeks without being thrown away.

What I could not verify

  1. No runtime verification. All static parsing of .dyn JSON; I never ran it in Dynamo/Revit. The claim here stops at "dimension-like hardcoding inside code blocks reached zero." Whether the resulting graph produces correct geometry in Revit is not established by anything in this post, and was still not established when the project ended.
  2. The meaning of dimension values is inference. "It is a radius / a translation distance / a colour channel" is fact, from the downstream port name. "A radius of what" needs design documents, which I did not check. The one exception is the 300 at X = 9682, which the geometry-flow analysis independently identifies as the lining offset.
  3. Which of the two identical tangent-solver Python nodes is the left side. Same 2,192 characters, three canvas units apart.
  4. Why v1_31 is 250 KB larger than its neighbours with identical node, connector and adapter measurements. I did not diff the JSON.
  5. What List.DropItems(-1) is dropping in the R1 envelope chain, and therefore whether any reimplementation of R1 is faithful.
  6. v1_1–v1_5 and v1_34–v1_35 are absent from the working folder. I did not look elsewhere.
  7. Effort below one-day granularity is unknown. mtime is "last saved," not time worked. "Eight weeks" is elapsed days between the first file and the final artifact, not working days. Worse, the whole lineage folder shares one copy timestamp, so per-version dates come from the surrounding records rather than from the files themselves.
  8. I did not read the whole adapter. It is about 1,270 lines; I read the docstring, branches, cell() call sites and specs table directly, and confirmed the rest by regex only.
  9. Literal counting basis. The 137/38 headline is "signs included, range-operator step excluded, string contents included." Excluding string contents and counting range endpoints gives 143 occurrences and the same 38 distinct values, with the dimension-like split landing on exactly 54 occurrences / 23 distinct either way. One row differs by one on sign attribution (−1000 versus 1000). Neither basis counts literals inside Python nodes, so the true hardcoding total is higher than both.

What's next

Part 3 turns to the harder half of the same problem: what happens when the drawing and the cell values disagree about the shape — geometry versus dimensions.

댓글

이 블로그의 인기 게시물

Structural Analysis Workflow with Dynamo and Robot

Dynamo with the Gemini Vision API test(Nano Banana)