Dynamo Built the Whole Tunnel and Never Read the Excel File
Part 1 of a series — a Dynamo tunnel geometry that was already finished, a 39-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.
A working Dynamo graph already existed. It produced the full 3D geometry of a Korean road tunnel project (NATM), 892 m of it, and it ran without errors. The problem was that nothing inside it knew where its numbers came from.
This post is about the two artifacts I inherited: a 617-node graph with zero external data references, and a 39-sheet workbook that is a complete, beautiful drawing set for a human being and an unlit maze for a machine. Everything below is measured from the files themselves — static parsing of the graph JSON and cell-level reads of the workbook.
What the original graph was actually building
The 892 m tunnel solid the original Dynamo graph already produced, shown in the viewer's 3D mirror. The geometry was never the problem — nothing in it was linked back to the workbook that defined it.
The pre-Dynamo QA screen. The workbook counters in the top-left are the whole brief in four numbers: 33 sections, 66 frames, 34 unique, 892 m of alignment.
Nobody started from a blank canvas. The starting point looked like this:
| Item | Count |
| Nodes | 617 |
| Connectors | 827 |
| Code Blocks | 159 (117 containing numeric literals) |
| Python Script nodes | 4 |
| Groups / annotations | 59 (34 still on the default untitled name) |
| External data references (Excel / CSV / JSON) | 0 |
That last row deserves its own sentence. A full-text search of the graph JSON for the strings Excel, excel, CSV, JSON, ImportExcel, ReadFromFile and FilePath returns zero hits for every one of them. This is not "the Excel link was stale." There was never a link.
The full function histogram
Break the node list down by function and the intent becomes obvious. Below is the complete histogram — every distinct function that appears in the graph, not a selection. 416 of the 617 nodes carry a function signature; the remaining 201 are 159 Code Blocks, 35 extension nodes (Watch, List Create, Revit selectors), a boolean input, a string input, a number input and a handful of others.
| Function | Count | Role |
Line.ByStartPointEndPoint | 28 | Straight segments |
List.Flatten | 22 | List rank housekeeping |
Curve.StartPoint / Curve.EndPoint | 19 / 19 | Curve endpoint extraction |
Geometry.Translate | 16 | Offsetting |
List.FirstItem / List.LastItem | 16 / 15 | Picking one end of a result list |
List.Transpose | 16 | List rank housekeeping |
List.SortByFunction | 13 | Ordering split fragments |
Geometry.Intersect | 12 | Intersection points |
Solid.ByLoft | 11 | Longitudinal extrusion |
Curve.Length | 10 | Alignment and segment lengths |
Geometry.Rotate | 9 | Guide-line rotation |
Vector.ZAxis | 8 | Rotation axes |
PolyCurve.ByJoinedCurves | 8 | Chaining profile curves |
Vector.XAxis / Vector.ByTwoPoints | 6 / 6 | Frame construction |
Point.Z | 6 | Level extraction |
Curve.PointAtParameter | 6 | Normalised sampling |
Geometry.Explode / Geometry.Split | 6 / 6 | Breaking curves at intersections |
List.GetItemAtIndex | 6 | Indexed extraction |
Point.ByCoordinates | 5 | Section seed points |
Surface.ByLoft | 5 | Surfacing |
Arc.ByThreePoints | 5 | Arch construction |
List.DropItems | 5 | Removing one circle before the envelope solve |
Plane.ByOriginNormal | 5 | Cutting planes |
GeometryColor.ByGeometryColor / Color.ByARGB | 5 / 5 | Preview colouring |
Line.ByBestFitThroughPoints | 4 | Section skeleton |
Vector.Reverse | 4 | Direction flips |
PolyCurve.ByGroupedCurves | 4 | Grouping perimeter curves |
Curve.ExtendEnd / Curve.ExtendStart | 4 / 2 | Over-extending curves before trimming |
Curve.PointAtSegmentLength | 4 | Distance-based sampling |
Line.ByStartPointDirectionLength | 4 | Ray construction |
CoordinateSystem.XAxis / YAxis | 4 / 3 | Frame axes |
CoordinateSystem.ByOriginVectors | 4 | Frame construction |
Curve.TangentAtParameter | 3 | Tangent vectors along the alignment |
List.TakeItems | 3 | Truncating lists |
Surface.PerimeterCurves | 3 | Surface back to curves |
Circle.ByCenterPointRadius / …RadiusNormal | 2 / 2 | Clearance and tangent construction circles |
Arc.ByStartPointEndPointStartTangent | 2 | Arch construction |
Arc.ByCenterPointStartPointEndPoint | 2 | Arch construction |
Curve.OffsetMany | 2 | Thickness offsets |
CoordinateSystem.Rotate / Translate | 2 / 2 | Frame placement |
CoordinateSystem.ZXPlane / YZPlane | 2 / 2 | Section planes |
Curve.Patch / Surface.ByPatch | 2 / 2 | Capping closed curves |
Curve.ParameterAtPoint | 2 | Locating a point on a curve |
List.FilterByBoolMask / List.Join | 2 / 2 | Filtering circles out of the transform output |
Object.Identity | 2 | Pass-through |
Element.Geometry / Element.Curves | 2 / 2 | Revit element to geometry |
FamilyInstance.ByCoordinateSystem | 2 | Family placement |
DirectShape.ByGeometry | 2 | Export to Revit |
| Singletons (one each) | 32 | Solid ops (Difference, DifferenceAll, ByUnion, BySweep, BySweep2Rails, SweepAsSolid), Geometry.Transform, Geometry.Scale, Rectangle.ByWidthLength, Sphere.ByCenterPointRadius, String.Contains, Material.ByName, ImportInstance.ByGeometries, and 19 more sampling/plane/vector helpers |
Two things stand out. First, list housekeeping is the third-largest category in the graph — the twelve List.* functions total 111 nodes, 18% of the graph. Second, there is exactly one Geometry.Transform in the whole file, and everything hinges on it. It is the boundary between "a section drawn flat at the origin" and "that section placed on the alignment."
The extension nodes
| Extension node | Count | What it means |
List Create | 23 | The section is assembled as nested lists, by hand |
Watch | 6 | Six things the author wanted to keep an eye on |
Family Types | 2 | Left and right utility-duct family selection |
Categories | 2 | Revit category for the DirectShape output |
Select Model Element | 1 | The only external input in the entire graph |
From Object | 1 | Object-to-string for the circle filter |
One Select Model Element. That single node — a human clicking the alignment curve in the Revit model — is the graph's whole interface with the outside world. Everything else is typed in.
The four Python nodes, one by one
Part 2 will come back to these, but their division of labour is the clearest statement of what the graph believed it was doing.
| Canvas X | Length | Role | Where its output goes |
| 1,264 | 1,215 chars | Enclosing-circle solver | List.LastItem / List.FirstItem — into production |
| 15,628 | 2,192 chars | Tangent-line solver (one side) | Watch only |
| 15,631 | 2,192 chars | Tangent-line solver (other side) | Watch only |
| 27,973 | 142 chars | Adjacent-profile pairing helper | Solid.ByLoft.crossSections |
The two tangent solvers are the same code twice. Both are 2,192 characters, and reading them side by side they are identical text: build the midpoint between a start point and the target circle's centre, make a helper circle through both, intersect it with the target circle to get the tangency points, shoot a ray, intersect the ray with a target line, return the segment. They sit at X = 15,628 and X = 15,631 — three units apart, effectively stacked on top of each other on the canvas. One of them is the left side and one is the right, and nothing in the file says which.
More importantly: the outputs of both tangent solvers go to a Watch node and nowhere else. Tracing the connectors, neither one feeds any geometry that survives into a solid. So the tangent-circle machinery — the most mathematically interesting thing in the file — was diagnostic. The upstream review note recorded this carefully: they "should be treated as diagnostic/helper evidence unless further Dynamo UI behaviour proves hidden use." The real side-wall geometry came from somewhere else entirely, which we get to below.
The enclosing-circle solver is the one that matters. It takes a list of clearance circles and a centre point, and for each frame computes:
d1 = center_point[i].DistanceTo(p1) req_r1 = d1 + r1 ... final_radius = max(req_r1, req_r2, req_r3)
That is: the smallest radius, about a given centre, that swallows all three clearance circles. Its two outputs (solution_circle, final_Rad) go straight into the R1 crown branch. R1 is a computed result. Part 2 explains why that single fact was the most expensive thing in the project.
The fourth Python node is 142 characters and does one thing:
for i in range(len(geom_list)-1):
out_geom.append([geom_list[i], geom_list[i+1]])
It pairs each profile with the next one so Solid.ByLoft can extrude between consecutive stations. A four-line node, and it is the entire longitudinal logic of the model.
One detail worth flagging now: the tangent solvers contain Line.ByStartPointDirectionLength(start_point[i], direction_vector, 99999). That 99999 is a hardcoded number, and it is invisible to any census of Code Block literals — it lives inside a Python string field in the JSON. Every count in this series of "how many hardcoded numbers were there" is therefore a floor, not a total.
The pipeline, read off the group titles
The group titles, read in order, are the pipeline:
import and rebuild the alignment -> 2D projection and verticals -> 2D tangent vectors (X axis, Y axis) -> 2D coordinate system: x-axis along travel, z-axis to true north -> 3D coordinate system (2D x,y carried over; z automatic) -> section construction and clearance envelope -> crown / crown inner clearance
The alignment enters through the single Select Model Element node picking the curve straight out of the Revit model. Coordinate systems are erected along it, a section is drawn on each one, and lofts sweep them longitudinally.
But that clean seven-step story is told by 15 of the 59 groups. The rest of the annotation layer looks like this:
| Group title | Count |
Title <Double click here to edit group title> | 34 |
| Empty string | 5 |
* / ** | 2 / 1 |
CoordinateSystem.Axis | 2 |
| Meaningful pipeline titles (the seven above, plus "sectional configuration and facility limits", "crown", "circle plane rotate check", "check", "skew", "Geometry", "X") | 15 |
39 of 59 groups carry no information at all. The author organised the canvas and then did not name two-thirds of the boxes. The group layer tells you where boundaries were drawn, not what they mean.
Where the section actually comes from, in screen coordinates
The graph has no declared workspace inputs and no declared workspace outputs. There is no entry point and no exit point; what you see depends on which terminal nodes have preview switched on. The only navigational structure is position on the canvas, which spans roughly 49,000 units left to right. The analysis note that mapped it reads:
| X range | Stage |
| −13,600 to −8,000 | Local section seed geometry — points, best-fit lines, closure lines, small circles, one rotated rectangle |
| −12,000 to −4,600 | Alignment extraction and coordinate-frame preparation |
| −1,700 to −1,300 | The single Geometry.Transform — local section meets alignment frames |
| 0 to 3,000 | Filtering the transform output for circles; the envelope solver; rotated guide lines |
| 9,000 to 12,000 | R1 crown arc, Curve.OffsetMany(300), surfacing, the R1 solid |
| 13,000 to 17,000 | R2 helper circles, the two tangent solvers, the two family-instance branches |
| 17,000 to 26,000 | R2 and closure curve assembly; the INSIDE and OUTSIDE labels |
| 27,000 onward | Solids, sweeps, differences, DirectShape, colouring |
How the sidewall was really built
Here is the part that no summary of the graph would predict. The left and right sidewall geometry does not come from arcs at all. It comes from Revit families:
Family Types: [utility duct, left]
-> FamilyInstance.ByCoordinateSystem
-> Element.Curves
-> PolyCurve.ByJoinedCurves
-> Geometry.Rotate(90)
-> Curve.StartPoint / Curve.EndPoint
-> Plane.ByOriginNormal
-> Geometry.Intersect
-> Geometry.Split
-> List.SortByFunction
-> Line.ByStartPointEndPoint
-> List.Transpose
and the identical chain again for the right side. The graph places a family instance in the Revit model, reads its curves back out, and uses those curves as section geometry. That is a legitimate technique — the duct profile is authored once as a family and reused — but it has a consequence that dominated the rest of the project: this branch cannot be reproduced by parsing a file. It depends on Revit session state, on which family is loaded, on what that family's type parameters currently are. A later review note put a hard rule on it:
Prefer native Dynamo nodes forFamily Types,FamilyInstance.ByCoordinateSystemandElement.Curves, because they depend on Revit family and session state. And the appearance of native function names as text inside a Python node is not the same as preserving the original native branch behaviour.
The important part is what is missing
Full-text search across the graph JSON:
| Keyword | Occurrences in graph |
| utility duct / crown / clearance envelope | 4 / 4 / 5 |
| lining, shotcrete, rockbolt, grouting, forepoling | 0 each |
| superelevation, pavement, barrier, sidewall, steel rib | 0 each |
So the graph built the inner arch, the clearance envelope and the section skeleton out to the utility ducts, plus its longitudinal extrusion. Lining thickness, shotcrete, rockbolt patterns, steel-pipe reinforcement grouting, forepoling: none of these existed in the graph, not even as a word. That is the exact scope of "the geometry was already done."
R1 / R2 / R3 — the shared vocabulary
The standard tunnel section as it lives in the Excel sheet. R1, R2, R3, dist_cr_ct and angle_R1_arc are written on the drawing itself — the vocabulary the rest of the series uses.
The section is a three-centred arch. If you have never drawn one, the construction is worth understanding, because every argument in the rest of this series is conducted in these three letters.
Start from the road. The road centreline is the datum: it is where the chainage is measured and where the surface levels are defined. The tunnel is not centred on the road — the carriageway is asymmetric (7.2 m of running lanes with a 2.505 m shoulder on one side and 1.005 m on the other), so the arch has to be shifted. That shift is dist_cr_ct, 4.35 m horizontally. The shifted point, raised or lowered by h_cr_ct (here −0.2295 m), is the tunnel centre, and it is the centre of R1.
- R1 — the crown arch, radius 6.98 m about the tunnel centre. It does not sweep a full semicircle; it covers
angle_R1_arc, given separately as 100°. Roughly 50° each side of vertical, then it stops. - R2 — where R1 stops, R2 takes over and curves the wall downward. Its centre is a different point, chosen so that R2 is tangent to R1 at the handover — the two arcs meet with no kink, which is what makes the section look like one continuous curve rather than two arcs bolted together.
- R3 — the same trick once more, lower down, tangent to R2, running to the foot of the wall where the sidewall meets the slab and the drainage detail.
- R2' / R3' — the right-hand counterparts. The prime means the other side, and the two sides are not equal.
| Parameter | Left | Right |
| R1 | 6.98 | (single value) |
| R2 / R2' | 3.959 | 3.756 |
| R3 / R3' | 4.609 | 4.414 |
| angle_R1_arc | 100 (deg) | — |
Note the shape of those numbers: R3 is larger than R2 on both sides, and each right-hand value is about 5% smaller than its left-hand twin. The asymmetry is not decorative. It exists because the shoulders are different widths, so the two walls have different amounts of room to get down to the same slab level. Any code that assumes the section is symmetric will produce something that looks completely correct and is wrong on one side by roughly 200 mm.
Offsetting these arcs outward by t_lining and t_shotcrete gives the lining and shotcrete outlines. The tangent-circle solvers in the original graph exist precisely to stitch R1–R2–R3 together tangentially — even though, as established above, their outputs were only ever wired to a Watch node.
Across all 33 section sheets, the values of R1, R2, R2', R3, R3', angle_R1_arc and t_lining are completely identical.
Comparing all 33 sheets, that seven-value combination has exactly one variant. The only thing that changes from section to section is t_shotcrete — seven values: 0.05, 0.06, 0.08, 0.09, 0.12, 0.16, 0.2. One arch skeleton for the whole tunnel; ground class changes the shotcrete thickness and the reinforcement pattern, nothing else.
So why was this "unmanaged"?
The road cross-section sheet. The dimension text is not numbers but variable names — shoulder_left, width_lane, t_pav, t_slab, t_filter. The drawing is already parametric on paper; nothing reads it that way.
Reason one: the graph's station split did not match reality
Exactly one Code Block handles longitudinal division, and this is all of it:
st..en..20000;
st is a constant 0. en is wired to Curve.Length of the alignment. In other words: uniform sampling of the entire alignment at 20 m intervals. Its output goes to List.Flatten and from there into the frame-generation chain.
The workbook, meanwhile, divides the tunnel into 33 sections, and those sections are not uniform at all.
| Section length | Number of sections |
| 8–15 m | 8 |
| 18–21 m | 9 |
| 24–42 m | 12 |
| 60 m | 1 |
| 96 m | 1 |
| 104 m | 2 |
Total length 892 m — the alignment runs from chainage 461 m to 1,353 m — and combining the start and end stations of 33 sections gives 34 unique boundaries. A 20 m uniform grid and 34 irregular boundaries were never going to line up. The graph assumed a tunnel may be sliced at even intervals; the actual design is sliced where the support pattern changes.
The ratio is the thing to look at. 892 m at 20 m spacing is 45 frames. The design needs 34. Those two numbers are close enough that nothing looks obviously wrong on screen, and far enough apart that almost every frame is in the wrong place. An 8 m section gets zero frames or one, depending on where the grid happens to fall. A 104 m section gets five frames it does not need, all with identical geometry. The error is invisible and total.
Reason two: the dimensions lived inside the graph
117 of the 159 Code Blocks carry numeric literals. There are 38 distinct values. They look like this:
3600+3600+2500-b; 725+200+4600; 725+625+200; -1000+b; b=655+250;
These are not injected from anywhere; they are sums typed by hand inside a node. And since the graph has zero nodes that read Excel, CSV or JSON, the workbook can change all it likes — the graph will never hear about it. Correcting one section dimension means hunting through 617 nodes for the right Code Block, and the group titles meant to help that hunt were still on their default name in 34 of 59 cases.
What this project was actually for
Not "model a tunnel in Dynamo." The geometry existed. The problem was that the geometry was disconnected from the design data. The working rule, written down on the first day of the reconstruction, became:
The workbook is the single source of truth. PDFs are secondary reference.
Build one shared section schema, and make the web viewer and the Dynamo graph both eat the same thing.
A source-priority order was fixed at the same time and did not change afterwards: (1) the original Dynamo graph, (2) Excel stations and dimensions, (3) CAD/DXF as optional audit only. That ordering is not obvious and it caused arguments later — it says that when the workbook and the original graph disagree about geometry, the graph wins, because the graph is what was actually built and reviewed. The workbook wins on dimensions. Untangling which of those two categories a given disagreement belonged to is, more or less, the subject of Part 3.
The unit of output is not a section but a frame. Give each of the 33 sections its own start and end frame and you get 33 × 2 = 66 frames; since one section's end coincides with the next section's start, the distinct station boundaries number 34. Those 66 frames became the atom shared by the viewer, the Dynamo graph and the Revit add-in.
It is worth dwelling on why 66 and not 34. Deduplicating to 34 would be tidier and would halve the data. It was rejected because a section boundary is two different things at once: the end of a support pattern and the start of the next one. At a boundary between a D-2 section and a P-1 section, the shotcrete thickness on the "end" frame and the "start" frame are different numbers at the same chainage. Collapse them and you have to decide which one wins, silently, forever. Keeping 66 frames means the model carries the discontinuity explicitly instead of resolving it by accident.
The workbook: 39 sheets
Deliberately the same picture as two figures ago. Sheet 10's tunnel section is the same PNG as sheet 1's — all 12 extracted TUNNEL_SECTION_REFERENCE images in the workbook, including the one on the '표준 Data' (standard data) sheet, hash to a single MD5. Many sheets, one picture.
| Type | Sheet | n | Role |
| Global definition | tunnel_info | 1 | Project and alignment data, coordinate ranges, default thicknesses, and the section split table (No / support Type / rock class / STA / distance from alignment start / Length) |
| Global definition | info | 1 | Source values for standard cross-sections per direction, R1/R2/R3, utility duct and barrier dimensions |
| Support pattern library | Def_pattern_Type | 1 | Reinforcement spec library, by Type |
| Section sheets | T_sec_1 … T_sec_33 | 33 | Per-section dimensions plus embedded drawings |
| Instructions | "standard data format ==>" | 1 | How to use the hidden template sheets |
| Hidden template | Def_pattern_Type standard data | 1 | Master copy of the pattern sheet |
| Hidden template | T_sec_1 standard data | 1 | Master copy of the section sheet |
The section split table on tunnel_info is the spine of the whole thing. Its columns — number, support type, rock class, station, distance from alignment start, length — are what turn "the ground is worse here" into "this stretch of tunnel is 18 m long and uses type D-2." Every frame in the model traces back to one row of that table. The first section sheet's start/end stations read 461 and 491; the thirty-third reads 1,323 and 1,353.
503 image anchors, 13–15 bitmaps
| Item | Count |
| Drawing items (total) | 606 |
| — embedded image anchors | 503 |
| — shapes | 103 |
| Sheets containing drawings | 37 / 39 |
| Unique bitmaps in the workbook | 13–15 |
This is the first trap. "503 images" counts anchor instances, not drawings. There are only 13–15 source bitmaps; a handful of pictures repeated across 33 sheets is what produces 503 anchors.
| Role | Anchors | Sources |
TUNNEL_SECTION_REFERENCE | 34 | 1 |
ROAD_CROSS_SECTION_REFERENCE | 34 | 1 |
UTILITY_LEFT / RIGHT_DETAIL_REFERENCE | 34 / 34 | 1 each |
BARRIER_LEFT_DETAIL_REFERENCE | 34 | 1 |
R1_1 / R1_2_CYCLE_REFERENCE | 34 / 34 | 1 each |
R2_R3_1 / 2_CYCLE_REFERENCE | 34 / 34 | 1 each |
R2P_R3P_1 / 2_CYCLE_REFERENCE | 34 / 34 | 1 each |
GROUTING_FOREPOLING_REFERENCE | 114 | 1 |
34 anchors per role, but there are only 33 sections. One too many. The hidden template sheet carries the exact same drawing and role layout as a live section sheet — so scraping sheets by the T_sec_* pattern quietly admits a 34th, fake section.
A cross-check from a later audit, counted a different way and on a different day, put the barrier reference at 34 files with 1 unique hash — thirty-four copies of a byte-identical crop — and the whole-workbook reference-image census at 355 (tunnel 34, road 34, utility 68, barrier 34, support cycle 115). The two counts differ because they scope "reference image" differently; both agree on the structural fact that matters, which is that the number of distinct pictures is in the low teens and everything else is repetition.
That repetition is not a filing quirk. It is the single assumption that produced most of Part 3. If a picture is stamped 34 times and the numbers beside it change 33 times, the picture cannot be a scale drawing of any one of them. It is a symbol. Every attempt to measure a dimension off those pixels was doomed before it started — and it took a long time to establish that, because the pixels are perfectly measurable and the measurements are perfectly stable.
Inside one section sheet
Everything extracted from a single section sheet — 13 embedded images, from the full section down to the support cycles. One sheet is a small drawing set, not one drawing.
The left utility detail. Every dimension line carries an Excel variable name (l_barr_y3, left_utility_w, left_drain_dia) instead of a value.
All 33 section sheets share the identical extent A1:AG183, and cross-checking the cell address of every major label across all 33 gave zero mismatches. The usual nightmare — cell addresses drifting sheet to sheet — does not exist here. A different one does: a single sheet holds three different representations at once.
| Region | Columns | Character |
| 1. Human-facing layout | B–L | Labels and values scattered between drawing images; portrait and landscape arrangements mixed |
| 2. Machine-facing flattened mirror | AA–AD | AA = index (0–156), AB = label, AC = [Start]/[Left]/[1-cycle], AD = [End]/[Right]/[2-cycle] |
| 3. Pattern label column | AF | Block item names, [R1_1-Cycle] through [grouting] |
Region 2 deserves a moment of appreciation. Someone on the design side built a machine-readable mirror of their own sheet, by hand, in columns AA to AD, 157 rows deep, with a start/end and left/right pair on each row. Nobody asked them to. It is the single most helpful thing in the workbook and it is invisible unless you scroll sideways past the drawings. It is also the reason the frame model has a start and an end value for every parameter: the mirror already assumed that structure.
The images sit in region 1, at fixed anchor ranges:
| Drawing | Anchor range |
| Road cross section | E24:M38 |
| Tunnel section | E41:M59 |
| Utility duct, left / right detail | C64:F79 / H64:L80 |
| Left barrier detail | B90:D102 |
| R1 1-cycle / 2-cycle | A107:E121 / G106:K121 |
| R2,R3 1-cycle / 2-cycle | B126:E141 / G126:K141 |
| Grouting and forepoling | A166:E181 |
The cell map
The dimensions for each drawing sit in the cells directly below or beside it. There are 27 such parameter groups. Here is the whole map, not a sample — this table is what a machine needs and what the workbook never states:
| # | Parameter | Label cell | Value cell(s) | Value | Meaning |
| 1 | t_pav | B11 | C11 / D11 | 0.03 | Pavement thickness |
| 2 | t_slab | B12 | C12 / D12 | 0.25 | Slab thickness |
| 3 | t_filter | B13 | C13 / D13 | 0.15 | Filter layer thickness |
| 4 | shoulder_left | D19 | D20 / D21 | 2.505 | Left shoulder |
| 5 | width_lane | E19 | E20 / E21 | 7.2 | Carriageway width |
| 6 | shoulder_right | F19 | F20 / F21 | 1.005 | Right shoulder |
| 7 | Superelevation [Main] | B25 | C25 / D25 | −0.03 | Main carriageway crossfall |
| 8 | Superelevation [R_Shoulder] | B26 | C26 / D26 | −0.02 | Opposite shoulder crossfall |
| 9 | dist_cr_ct | B42 | B43 | 4.35 | Road centre to tunnel centre, horizontal |
| 10 | h_cr_ct | B44 | B45 / B46 | −0.2295 | Road centre EL to R1 centre EL |
| 11 | R1 | B48 | C48 | 6.98 | Crown arch radius |
| 12 | R2 / R2' | B49 | C49 / D49 | 3.959 / 3.756 | Upper sidewall arch |
| 13 | R3 / R3' | B50 | C50 / D50 | 4.609 / 4.414 | Lower sidewall arch |
| 14 | angle_R1_arc | B54 | C54 | 100 | Arc angle covered by R1 |
| 15 | _lining | B57 | C57 / D57 | 0.3 / 0.02 | Lining thickness / overbreak |
| 16 | _shotcrete | B58 | C58 / D58 | 0.2 / 0.16 | Shotcrete thickness / overbreak |
| 17 | half_section | B60 | C60 | 1.04 | Half-section related dimension — meaning not established |
| 18 | left_utility_w | B81 | C81 | 0.3 | Left utility duct upper width |
| 19 | left_utility_h | B83 | C83 | 0.3 | Left utility duct height |
| 20 | right_utility_w | K81 | L81 | 0.2 | Right utility duct upper width |
| 21 | shoulder_left_1 | E81 | F81 | 1.75 | Left gutter-zone width |
| 22 | left_bottom_t | E82 | F82 | 0.487 | Left lower thickness |
| 23 | left_drain_dia | B86 | C86 | 0.3 | Left drain pipe diameter |
| 24 | barr_z4 … barr_z1 | E91–E94 | F91–F94 | 0.36 / 0.175 / 0.123 / 0 | Barrier step heights |
| 25 | barr_y5 … barr_y1 | G90–K90 | G91–K91 | 0 / 0 / 0.15 / 0.036 / 0.12 | Barrier step widths |
| 26 | Grouting, 4 specs | B182–E182 | B183–E183 | 1 / 0.1143 / 12 / 5 | Reinforcement dia, pipe dia, length, drilling angle |
| 27 | Forepoling, 3 specs | I182–K182 | I183–K183 | 0 / 0 / 0 | Not applied in this section |
Units are metres; angles are degrees. Two rows in that table were misread for weeks and only settled late, which is worth recording because the misreading was entirely reasonable:
- D57 and D58 are overbreak, not an auxiliary thickness. The column headers are
thickness(t_)andoverbreak(ob_), so D57 =ob_lining= 0.02 m and D58 =ob_shotcrete= 0.16 m. An earlier reading treated them as a second, auxiliary thickness. Independent references put typical NATM overbreak at 15–20 cm, which matches 0.16 m and does not match any plausible "auxiliary thickness"; the earlier interpretation is recorded as an error. half_section= 1.04 m is still unexplained. It exists in two places (C60 and the mirror at AC58) and is used zero times in the original graph. A later work plan put an explicit prohibition on it: do not execute any upper/lower bench split until the meaning ofhalf_sectionis established. Guessing at a 1.04 m value and cutting the tunnel in half on the strength of it would have been the single most expensive guess available.
And the values do not originate in the sheet. The content of C48 is not a number:
=IF($G$3="up-direction", info!$B$5, info!$J$5)
The section sheet is a view that pulls from the info and tunnel_info sheets depending on carriageway direction. The AA–AD mirror then references that view again (AC46 = "=C48"). Support type is a lookup too:
G10 = INDEX(tunnel_info!$C$39:$C$969, MATCH($G$9, tunnel_info!$B$39:$B$969, 0))
On the first section sheet: 558 formulas against 428 literals. More than half the sheet is a computed result. A parser that reads cached values gets the right answer; a parser that reads formulas gets a dependency graph; a parser that reads one and reports the other is lying without knowing it.
One cell contains a sentence rather than a number, and it turned out to matter more than most of the numbers. B23 carries a note explaining the superelevation convention: [Main] runs from the road centre toward the shoulder on the lane side, and [Shoulder] applies only to the shoulder on the opposite side. Both C25 and C26 already contain signed values (−0.03 and −0.02). The sign was negated in code at least once, and the correction note that fixed it carries an unusually blunt guardrail: do not negate the Excel slope cells again, and do not fit the sign to the raster result. That second clause — do not choose the sign that makes the picture match — is the whole of Part 3 in nine words.
The pattern library, and one gap
The R1 support-cycle sheet: rock bolts arrayed radially around the arch. This is the pattern library the workbook is built on.
The grouting / forepoling cycle — circles arrayed over the crown. Each pattern is a separate embedded image with its own geometry.
The R2/R3 cycle sheet, drawn with no bolt or grouting array at all. The pattern set is not uniform across support classes.
Def_pattern_Type is laid out as rows = items, columns = Type. There are 15 Types — A, B-1, B-2, C-1, C-2, D-1, D-2, E-1, P-1, P-2, RP-1, RP-2, RP-3, WL-2, WL-3 — and all 15 are actually assigned somewhere across the 33 sections.
Seven definition blocks:
| Block | Items defined | Rows |
[R1_1-Cycle] | diameter / length / transverse start offset / transverse end offset / transverse c-t-c / longitudinal start offset / longitudinal c-t-c | 7 |
[R1_2-Cycle] | same seven | 7 |
[R2,R3_1-Cycle] | same seven | 7 |
[R2,R3_2-Cycle] | same seven | 7 |
[R2'R3'_1-Cycle] | same seven | 7 |
[R2'R3'_2-Cycle] | same seven | 7 |
[grouting] | reinforcement dia / pipe dia / length / drilling angle / start-end discriminator / two transverse offsets / transverse c-t-c / shotcrete offset / longitudinal offset / longitudinal c-t-c | 11 |
| Total | 53 |
53 rows total, 53 × 15 = 795 cells, all populated. The six-block structure is three regions (R1 / R2,R3 / R2'R3') × two excavation cycles, so bolts can be staggered between cycles — the second cycle's bolts sit in the gaps left by the first, which is standard practice and is why the library needs two sets of offsets rather than one set and a spacing.
Representative values across six of the fifteen Types:
| Item | A | B-2 | C-1 | E-1 | P-2 | WL-3 |
| R1 rockbolt diameter | 0 | 0.025 | 0.025 | 0.025 | 0 | 0 |
| R1 rockbolt length | 0 | 3 | 4 | 4 | 0 | 0 |
| R1 longitudinal c-t-c | 0 | 7 | 6 | 24 | 0 | 0 |
| Grouting reinforcement dia | 0 | 0 | 0 | 0 | 1 | 1 |
| Grouting length | 0 | 0 | 0 | 0 | 12 | 12 |
| Grouting drilling angle | 0 | 0 | 0 | 0 | 5 | 5 |
Read that table across and the design logic is legible without a single word of explanatory text. Type A — the best rock class — is zero across every item: unsupported, no bolts, no grout. B-2 through E-1 bolt the crown, and the longitudinal spacing stretches from 3 m to 24 m as the rock improves, which is another way of saying "at E-1 we are barely bolting at all." The bolt diameter never changes — 25 mm throughout — only the length and the spacing. And only P-1, P-2, WL-2 and WL-3 — the portal and weak-zone types — carry grouting values at all: 12 m pipes at a 5° look-out angle, which is a forward umbrella, not a radial bolt pattern.
The block comment next to the rockbolt sections reads, in effect, if no rockbolt is applied, set length = 0. So zero is not missing data. Zero is a design decision, stated as a convention in a comment, in one place, in Korean. Any importer that treats 0 as null gets this exactly backwards.
And there is a hole. The workbook's own filename announces that forepoling was added, and the section sheets do contain three forepoling spec cells — but Def_pattern_Type has no forepoling block at all (zero hits across every cell of the sheet). Forepoling bypasses the Type library and is typed directly into the section sheet. Steel ribs, meanwhile, return zero hits across the workbook's entire shared-string table. They simply are not there.
Late in the project a check against independent references pinned down what the numbers that are there mean: crown coverage around 120°, look-out 6°, transverse spacing 400 mm, pipe length 12–15 m with about 4 m of overlap between umbrellas. The workbook's own values — pipe Ø114.3 mm, 500 mm centres, 12 m grouting length, 5 m forepoling length — line up with that. The point is not that the numbers were validated. It is that nothing in the workbook says what they are numbers of, and it took an external reference to be sure.
The hidden sheet problem
The unhide dialog. The '표준 Data' (standard data) sheets that hold the design basis were hidden in the workbook — the data existed, it just was not visible.
How they were found: right-click on a sheet tab, 숨기기 취소 (Unhide). Nothing about the file's normal appearance suggested the sheets were there.
Both template sheets are marked hidden, and the workbook explains itself:
These sheets are the standard data sheets for the structure …
1) When using standard data, copy them so that the standard data is preserved …
3) The standard data may be deleted once the workbook is complete.
So they are masters you copy when creating a new section sheet, and leftovers you may delete when done. For a human this is harmless. For a machine it is not. It fails in four separate ways, and each one has a different fix.
1. The name is T_sec_1 standard data. A naming collision, not a content problem. Any rule of the form "every sheet matching T_sec_*" picks it up as item 34 in a list of 33 — and it sorts adjacent to the sheet it was copied from, so a spot check of the first few sheets will not catch it. Any fix based on names is fragile in both directions.
2. The content is nearly identical to a live sheet. Cell-by-cell comparison across the whole extent: 5,872 of 6,039 cells identical, only 167 different — 97.2% the same. This is what makes the first failure dangerous rather than merely annoying. If the template were visibly a template — empty cells, placeholder text, a big red banner — a human reviewing the parsed output would spot the extra section immediately. Instead the 34th section looks exactly like a real one. Every drawing is there. Every label is there. Every value is plausible.
3. The values are stale. Of those 167 differing cells, one is R1: the template says 6.982, all 33 live sheets say 6.98. Two millimetres. Nothing in the model complains about a 2 mm radius difference — no gate catches it, no visual comparison shows it, and the resulting geometry lofts perfectly. You would simply have a 34th frame, at a chainage that does not exist, with an arch radius nobody ever specified, extruded into a solid. That is the exact profile of a defect that survives to construction.
4. Hidden means invisible to humans and, by default, invisible to parsers. This is the compounding factor. The engineer opening the workbook does not see the template at all, so the person best placed to ask "why is there a 34th section?" is structurally unable to notice. Meanwhile the hidden state lives in workbook.xml as state="hidden" on the sheet entry — walk the sheet XML without reading it and the sheet walks right in.
The fix that actually held was none of the above. It was to select sheets by tab colour. The engineer had been marking live section sheets green all along — a convention nobody wrote down, visible in the workbook as a theme index. The adapter's default option became station_tab_theme: "9", and the problem disappeared, because the rule now matched the practice instead of guessing at it. The template sheet is not green. It never was.
Four reasons this structure is hard for a machine
1. Values live in cells, shape lives in bitmaps, and 33 sections share the bitmaps. The utility duct detail has parameter names — left_utility_w, left_bottom_t, left_drain_dia, l_barr_y3 — written as text on the picture. Which dimension line corresponds to which cell can only be inferred from position within the image. Worse: shotcrete thickness varies across seven values, yet all 33 sheets embed the identical section bitmap. The drawing is a symbol, not the section's real geometry. Measuring scale off those pixels is wrong in principle, not just in practice.
2. Some dimensions exist only on the drawing and in no cell at all. The left utility duct detail carries printed numbers like 50, 60, 125, 100, 500, 300. The values corresponding to 50 (0.05), 60 (0.06) and 125 (0.125) appear nowhere in any cell of that section sheet. Read only the cells and the duct section does not close. Some dimensions are parameters and some are drawing literals — and that distinction is declared nowhere in the workbook. Part 3 documents what happened when an instruction said "make the utility dimensions match Excel" and three of the four labels involved had no Excel cell to match.
3. A parameter name is not contained in a single cell. For lining and shotcrete, the column headers are thickness(t_) / overbreak(ob_) and the row labels are _lining / _shotcrete; the real names t_lining and ob_shotcrete have to be composed from header plus row label. The barrier is a 2D grid — rows barr_z4…barr_z1, columns barr_y5…barr_y1 — so identifying one value needs both names. And left/right is encoded three different ways at once: as a prefix on drawing labels (l_barr_y3 / r_barr_y3), inside the name itself in the human layout (left_utility_w), and by column in the mirror (AC = [Left] / AD = [Right]). The same value goes by three names. Some items are not even numeric — the start/end discriminator is a string enum.
4. The same value appears in up to three places. info / tunnel_info (source) → section sheet columns B–L (human) → section sheet AA–AD (mirror). Happily these are bound by formula, so they never disagree — I checked all of them. But the reader has to decide which representation is the contract, and if that decision is not written down, the next person reads a different one.
Stack 1 through 3 on top of 4 and a single question — "what is the left utility duct width for this section?" — has one candidate location in an image and three in cells.
That is why connecting data to geometry was not a mapping exercise. The workbook is a finished drawing set to a human, and to a machine it is a pile of numbers without coordinates and a pile of pictures without scale.
Twelve days in June: what actually got built
The narrative above is the shape of the problem. This section is the shape of the response, reconstructed from disk evidence alone — file timestamps, server logs and report contents — because no conversation log survives for this stretch. What follows describes what was made and what was checked, not what anybody asked for. Where a claim is an inference it is marked as one.
The survey covered every file in the project tree with a modification time between 18 June and 1 July: 9,760 files, reduced to 3,732 after removing browser profile seeds, Python caches and later archive copies.
| Date | Files | Character | Server starts | HTTP requests | 500s |
| 06-18 | 54 | Dissect the original .dyn; extract reference images | 0 | 0 | 0 |
| 06-19 | 100 | Arc schema guard; first Dynamo CLI runtime attempt | 1 | 66 | 0 |
| 06-20 | 1,868 | Full Excel dissection + registry generation | 14 | 103 | 2 |
| 06-21 | 184 | Viewer↔Dynamo schema sync; visual verification begins | 11 | 321 | 4 |
| 06-22 | 42 | Logs only — zero artifacts | 21 | 200 | 0 |
| 06-23 – 06-25 | 0 | Gap | — | — | — |
| 06-26 | 131 | Vectorise the Excel images; user cutline introduced | 52 | 636 | 8 |
| 06-27 | 199 | Source cutline promoted to authority; structural trace role mapping | 31 | 334 | 11 |
| 06-28 | 586 | Validation gates introduced en masse (498 JSON reports) | 16 | 260 | 9 |
| 06-29 | 568 | Gates converge to a full PASS (442 JSON reports) | 18 | 199 | 7 |
06-18 — dissection, and the viewer's first contract
Everything created on the first day is reading and recording. Nothing is built: a geometry-flow analysis (10,095 B); a gap review (12,259 B); a 37,043-byte script that generates the next .dyn version programmatically; a node that reads the viewer's export back into Dynamo for comparison; three upstream dependency-tree dumps (35 KB / 36 KB / 24 KB); and the workbook's 15 embedded drawings, extracted.
The viewer notes written that day fix the contract that survived everything afterwards: the workbook as the data source, 33 T_sec_* sheets → 66 start/end frames, the three-tier source priority, and eleven named branches. The same document records three separate geometry rejections on that first day — a PDF-styled attempt, then a centre-based R123 attempt, then Excel-driven — and the conclusions it lands on are both negative: remove the synthetic Bezier side connection, and do not draw the outer arc as a forcibly closed profile. Two rejections before dinner on day one, both resolved by deleting something rather than adding something.
06-19 — the Dynamo runtime fails, and stays failed
An arc registry generated at 09:48 (129,413 B) extracts from the original graph: Arc 9, Intersect 12, Split 6, ByPatch 2, OffsetMany 2, ByLoft 11. Ten .dyn versions are saved between 08:14 and 11:21, and their names are the day's agenda — arc_source through runtime_sentinel, original_lineage_audit, lineage_watch_runtime_proof.
Then runtime verification fails. A CLI matrix with three variants and six load attempts leaves stdout files of about 82 bytes each. The verdict:
The Dynamo WPF CLI loaded v1_16 without stderr but did not evaluate it, and the expected sentinel JSON was never produced.
At 09:54 a validator is written and at 09:59 a manual execution checklist. That is the moment automated runtime proof is abandoned and handed to a human — and it is never recovered. Six weeks later the same gap is still open.
06-20 — the day the registry was built
1,868 files in one day — half of everything produced in June. Three phases.
Morning. Three .dyn versions saved simultaneously at 10:03; an oracle export of 35,991,540 bytes at 11:29; and at 11:52 a detail verifier whose check list is the first appearance of the dimensions that dominate Part 3 — Detail A stack 300 + 60 + 150 mm, lining-side offset 100 mm, D300 perforated pipe, PVC D100/D50 at 10.0 m centres, stage-6 fill 155 × 300 mm.
Its first run catches a viewer-side bug: a circle approximated with 54 samples missed the vertical start point, so the D300 pipe came out at 299.492 mm. Raising the sample count to 64 fixed it.
Midday. At 12:02:49 all 505 embedded images are extracted in one pass. At 12:19:52 the same 505 are re-placed under role names and the registry JSON is written: 1,677,115 bytes. Its headline counts are the ones quoted throughout this post — 39 sheets, 33 T_sec_*, 503 embedded images, 606 drawing items, 103 shapes — and it enforces five mandatory image roles per sheet. (The extractor counts 505 where the registry's headline says 503; the two anchors of difference are ones the registry does not classify. I have not reconciled them.)
The builder does not use a spreadsheet library: it opens the workbook as a zip and parses the package XML directly. That decision, made on the third working day, is why the same parsing logic could later be embedded inside a Python node running in Revit's bundled interpreter.
Afternoon and night.
| Version | Time | What it added |
| v1_25 | 12:34 | Node 00 reads the registry JSON |
| v1_26 | 14:25 | ROAD_SURFACE / ROAD_LAYERS / SLAB / SIDEWALL / BARRIER / REINFORCEMENT / GROUTING / FOREPOLING branches |
| v1_27 | 14:42 | The shared section schema file plus its two save endpoints |
| v1_28 | 15:07 | Support cycles (R1 left/right, R2R3 left/right, R2'R3' left/right) |
| v1_29 – v1_33, v1_36 | 15:41–17:24 | Slab lower contact, branch profile, Dynamo consumer contract, R23 slab contact anchor |
A forensic detail: the server log shows /api/section-schema returning 404 between 14:30 and 14:34 — the endpoint being called before it existed — and v1_27, which introduces it, is saved at 14:42. The order of events is visible in the logs even though nobody wrote it down.
The counts fixed on this day did not change again: 33 sections, 66 frames, 34 unique station boundaries, GROUTING 42, FOREPOLING 66, REINFORCEMENT 390, support cycles active on 32 of 33 sections.
06-21 — the visual loop starts, and the .dyn line stops
Between 00:01 and 05:11, rendered comparison versions v110 through v179 pour out, and the filenames are the rejection history: slab_side_clean → utility_opening_focus → cad_lineweight_clean → r23_contact_visible → support_barrier_audit.
| Schema summary | Time | Size | Branches |
| v169 | 04:38 | 44,958 B | 39 |
| v266 | 14:08 | 215,325 B | 61 |
| v271 | 15:14 | 226,461 B | 63 |
v266 is a structural change, not an increment: it introduces section-geometry QA, six required branch groups, a first-frame anchor audit and an explicit Dynamo consumer contract. That is the moment the viewer's obligation to Dynamo became a document rather than an understanding.
And then, at 14:33 on 06-21, v1_39 is saved and the .dyn lineage stops. It never resumes in June. The branch count goes on to 83 and then 87 over the following week; the Dynamo side that is supposed to consume those branches is never updated. That is a real, unclosed gap, and one reason a runtime proof was never obtained.
06-22 — a day that left only logs
All 42 surviving files are server logs. No screenshots, no reports, no schema, no .dyn. Twenty-one server starts, 200 requests, zero 500s. The only evidence of what was done is 44 verify tokens in the URLs — among them drain-cradle, cad-dim-no-tags, orthogonal-slab-utility-contact, production-lines-no-tags.
Two themes appear here for the first time and never go away: separating production lines from diagnostic and tag lines, and forcing orthogonality. Both become formal gates six days later. And note drain-cradle — the object at the centre of Part 3's first case appears in a URL on 22 June, weeks before anyone questioned what it was.
06-26 — turning the pictures into lines
The busiest server day of the month: 52 starts. At 17:48 a user cutline trace overlay — a human draws the outline by hand and it becomes data. At 18:17 the linework extractor turns the PNG into segments. Measured output: left image 753 × 643 px, 74 segments (39 long), 69 geometry candidates; right image 761 × 636 px, 72 segments (39 long), 63 candidates.
At 23:06 the structural trace classifies each side into 15 production lines plus 1 reference line, and its description states the rule Part 3 is entirely about:
Text and dimension extension lines are excluded from production geometry.
Note the date. That principle was written down on 26 June. Part 3 documents three separate occasions after that date on which the same confusion was made again anyway — including once by a filter written specifically to prevent it.
Shortly after midnight, a full model dump of 174,674,788 bytes — the largest single artifact of the month.
06-27 — the hand-drawn outline becomes the authority
About twenty captures between 02:02 and 03:22, and the filenames are an argument being made: source_cutline_only_fallback → cutline_exact_only → no_trim_proxy → no_proxy_point → schema_source_lock — substitutes deleted one at a time until only the source outline is left.
At 03:43 the source cutline relation audit passes 132 side checks across 66 frames; at 03:50 the utility body opening branch audit passes 132 / 132. The fact fixed here — the source cutline is 22 points — holds through every later comparison. By 07:52 the schema summary is at v272 with 83 branches, up 20 from six days earlier.
06-28 — twelve gates in one day
498 JSON reports. Twelve new report families are created, and their first results are a catalogue of what was actually broken:
| Time | Family | First result | First failure mode |
| 04:24 | parametric section relationships | ✗ 132 | Slab/R23 contact datum branch missing its lock lines |
| 08:01 | parametric dimension bindings | ✗ 66 | Frame dimension keys missing |
| 08:15 | dynamo consumer map | ✗ 112 | Required branch profile empty (FOREPOLING) |
| 08:30 | dynamo profile payload | ✗ 330 | Polyline too short — length 1 |
| 13:45 | production profile payload | ✗ 112 | Production profile empty (FOREPOLING) |
| 14:35 | excel source overlay contract | ✗ 924 | Missing dimension driver |
| 15:45 | barrier dimensions | ✗ 1,320 | Barrier active-Y value mismatch |
| 16:23 | production driver coverage | ✗ 396 | Production driver key missing |
| 20:47 | viewer production display contract | ✗ 14 | Forbidden reference branch inside the production display |
Most close the same night: driver coverage passes at 22:22, the barrier's 1,320 failures reach zero at 22:39, dimension bindings pass at 23:20. The scale explains why nothing here could be eyeballed — the first production-payload run covered 70 branches, 65,046 polylines, 444,450 points. At 17:04 the schema summary hits v424 with 87 branches and absorbs the results of fourteen validation families as fields inside itself. From that moment the viewer's state is the bundle of gate results.
06-29 — everything green, and one rule that outlived it
| Time | Family | First result | Core finding |
| 00:22 | source production shape fidelity | ✗ 528 | Cutline, body and pocket match exactly (delta 0.0), but the opening's raw source delta is 109.27 mm and the drain's raw trace delta 220.19 mm |
| 00:40 | utility control datum manifest | ✗ 264 | P18 / P19 / P20 / P21 formalised as control points |
| 01:55 | production axis linework | ✓ | 10 axis-aligned branches, 8,712 segments |
| 02:05 | excel source dimension conflicts | ✓ | Dimensions override the raw source |
| 03:04 | orthogonality check | ✓ | No diagonal segments permitted |
| 03:14 | slab tail R23 split | ✗ 132 | Missing source slab tail |
| 07:17 | utility road slab datum | ✓ | road_z_at(y) + t_pav + t_slab + bottom_t |
The 02:05 rule is the heaviest thing decided in June:
When a traced dimension from an embedded Excel image disagrees with the Excel numeric label, production geometry follows the Excel numeric dimension, and the raw trace delta is retained for audit and reference only.
The measured consequence, in the same report: raw disagreement peaks at 109.27 mm on opening height and 178.38 mm on drain width, while the production dimension error is 4.5 × 10⁻¹³ mm on the opening and 0.0 mm on the drain. Both numbers are true simultaneously. That pairing — a perfect dimensional match sitting beside a large visual disagreement, neither hidden — is the fact Part 3 is built on.
Between 09:32 and 11:34 a save-and-reload loop runs seven times, each iteration identical: save the section schema, save the production profiles, reload the model (37 MB) — a formalised procedure by then, not a habit. At 11:00 the parametric relationships gate closes with its check list grown from 7 items to 11, the four additions all discovered in the previous 48 hours. At 13:45 the last four reports record pass and June ends.
What the tools say, in the order they were written
| # | Created | Tool | Problem it was solving |
| 1 | 06-26 18:17 | Excel linework extractor | Turn a PNG into line segments at all |
| 2 | 06-28 12:49 | Dimension-binding validator | Cross-check that three containers' binding manifests agree with each other |
| 3 | 06-28 16:34 | Driver-coverage validator | Every non-empty branch must name its dimension drivers, and those keys must exist and be finite |
| 4 | 06-28 20:28 | Barrier dimension validator | Rebuild the barrier from Excel numbers to polyline coordinates and compare, 2.0 mm tolerance, 11-point stepped outline |
| 5 | 06-29 03:04 | Orthogonality validator | One question only: is any segment diagonal? Across 66 frames × 2 sides |
| 6 | 06-29 03:31 | Normalised trace validator | After normalisation, does the trace still contain the reference line, and is the drain pocket closed? |
| 7 | 06-29 11:33 | Display-contract renderer | Not a validator — a display contract. Names the eight refs to draw and what to exclude |
Read the order and you get the method: vectorise the picture (1) → prove dimensions are bound to geometry (2, 3) → rebuild members and compare (4) → enforce shape discipline (5, 6) → fix what may be shown at all (7). The last one is not a test. It is a decision about display, written as code, because by then it was clear that what appears on screen determines what gets believed.
What the server logs say
Eight days, 164 stderr logs = 164 server starts, 2,119 requests. GET /api/model accounts for 904 of them, mostly full rebuilds; the save endpoints total 132 calls, and that rhythm — change, save schema, save profiles, reload — is the skeleton of late June. Status codes: 200 = 1,840, 304 = 227, 500 = 41, 404 = 10. The 500s cluster on 06-27 (11), 06-28 (9), 06-26 (8) and 06-29 (7) — the server broke most often exactly while the source cutline was being made authoritative and the gates were being installed.
But the tracebacks are more interesting than the counts. Of 87 tracebacks, 82 are not code defects: 70 connection-aborted and 12 connection-reset, all of them the browser hanging up during a large /api/model response. The genuine code errors number four — three from getting the server's startup arguments in the wrong order, one unbound local, one undefined name. A month of gates and audits, and the actual crash log is four typos and a lot of impatient reloading. Meanwhile the cache-bust token in every log line matches its verify token: asset versioning was maintained without exception for the entire month.
What June fixed, and what it did not
| Decision | Fixed on |
| 33 sheets / 66 frames / 34 station boundaries | 06-18 – 06-20 |
| Source priority: original .dyn ① / Excel ② / CAD ③ | 06-18 |
| Excel origin registry (39 / 503 / 606 / 103, five mandatory roles) | 06-20 12:19 |
| Shared section schema + save API | 06-20 14:42 |
| Viewer → Dynamo consumer contract (6 branch groups) | 06-21 14:08 |
| Branches 39 → 61 → 63 → 83 → 87 | 06-21 → 06-28 |
| The hand-marked 22-point cutline is the source authority | 06-27 03:43 |
| Text and dimension lines excluded from production | 06-26 23:06 |
| Excel numbers override the raw trace | 06-29 02:05 |
| P18–P21 control points; drain pocket discipline (50 mm drop, 125 mm clearance) | 06-29 |
| 25 validation families all passing | 06-29 13:45 |
And the things June did not finish, which are more instructive:
- No Dynamo/Revit runtime proof. The runtime validation folder contains zero sentinel result files. Six manual checklists were written; no results exist for any of them. The documents end with the sentence Revit/Dynamo runtime execution is still required. Everything June produced is a web viewer and static validation. The actual Revit geometry was never verified, and this gap survives into August.
- The viewer's structural limits remain. It cannot execute Revit family instances, ProtoGeometry boolean or split operations, or DirectShape. Two branches are explicitly labelled Excel-dimension web proxies standing in for real family geometry.
- The structural trace comes from one section only. Two images from a single sheet, 15 production lines per side. The 66-frame gates apply that one section's trace to every frame.
- The .dyn line stopped at v1_39 on 06-21, while the branch count went on to 87 with nothing on the Dynamo side updated to consume it.
- v1_34 and v1_35 do not exist, and schema summaries are mostly missing (v169 → v266 → v267 → v271 → v272 → v373), so progress between 06-21 and 06-27 is not traceable. (Inference: numbers reserved, saves skipped. Not confirmed.)
- The barrier gate still contains an approximation. Its marker comparison uses a scale factor derived as barrier span divided by the sum of active Y values. It passes — but that scaling assumption is itself never tested.
What I could not verify
- Steel rib specifications — zero hits in the workbook's shared strings. Not in the Type library, not in the section sheets.
- Standard forepoling values — input cells exist but no definition block in
Def_pattern_Type. All values are 0 on the first section, so I have no representative figure to offer. - Exactly which stretch of sidewall R2 versus R3 covers — inferable only from label position on the drawing; no explicit definition in any cell or document.
- The original graph's runtime behaviour in Revit — static JSON analysis only. I never ran it. Nor did anyone else, in June or afterwards.
- The precise meaning of
half_section(1.04) — no cell comment, no drawing label, and zero uses in the original graph. Still open, and still blocking the bench-split work. - Which of the two identical tangent-solver Python nodes is the left side — they are the same text at nearly the same coordinates, and nothing distinguishes them.
- What was actually produced on 06-22 — 42 server logs and 44 verify tokens, and no captures, reports, schemas or .dyn files at all. What was decided and what was rejected that day cannot be recovered.
- Whether 06-23 to 06-25 and 06-30 were genuinely idle — only that this tree contains no files with those timestamps. Other drives were not checked.
- What anyone asked for, in June — no conversation log survives. Filenames and verify tokens are the only proxy evidence, and they show what was made, not what was requested.
- How the screen actually looked during the June gates — 06-28 produced 498 JSON reports and six PNGs. June's passes are JSON-based, and a wide stretch of them was never cross-checked against pixels.
What's next
Part 2 opens the graph itself: 159 code blocks, 137 numeric literals, 38 distinct values, and what it actually cost to find out where each one came from.
Tunnel automation series — nine parts.
Next: Dynamo Code Blocks and the Cost of 137 Hardcoded Values →
This is Part 1. Each part links to the next.
댓글
댓글 쓰기