Revit TessellatedShapeBuilder Returns a Mesh, Not a Solid

 Part 6 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.

For six weeks the web viewer said the geometry was fine. Then the same polylines went into a Revit add-in and three of the bands would not build at all. This is the last stretch: an add-in, thirteen versions in one afternoon, and a root cause that only appeared once I stopped looking at screenshots.

1. A viewer pass is not a Revit pass

The viewer's verdict on R1/R2/R3 closure: the lining band reads as clean and shut. Everything in Part 6 starts from the fact that this view is not evidence of a buildable loop.

The shared boundary between R3, the side detail and the slab, reviewed in the viewer. Contact looks exact here — Revit's kernel is the only thing that can actually adjudicate it.

Everything up to 07-23 was about fixing the section schema in the viewer. The output was production_profile_payload_latest.json — 35,786,750 bytes, 66 frames across 33 sheets, every gate green.

But "correct in the viewer" and "buildable in Revit" are different grades of evidence, and the gap between them is not a matter of degree. The viewer draws 2D polylines onto SVG or Canvas. A renderer's job is to put ink where the points say, and it does that faithfully whether or not the points describe a sane region — a polyline that crosses itself still renders as a perfectly ordinary shape, because the renderer has no opinion about what a shape is for.

Revit turns the same polyline into a CurveLoop and feeds it to GeometryCreationUtilities.CreateLoftGeometry(). There a self-intersection is not cosmetic. A loft between two profiles has to sweep a well-defined surface, and it cannot, because the region it is asked to sweep has no consistent inside. The result is an immediate InvalidOperationException. That is what consumed all of 07-31.

Three things forced the move to an add-in in the first place, and none of them was preference:

ReasonWhy the viewer could not do it
The contracted deliverable is RVT elementsA web page is not a deliverable; the client receives a model
Closure, self-intersection and volume must be adjudicatedOnly the Revit kernel adjudicates these. The viewer's closure check is a topology check on point lists, not a solid-modelling result
66 frames must be placed along an 892 m curved alignmentEntirely outside the viewer's coordinate system, which is section-local

2. What the add-in physically is

The add-in's payload import form. Station list on the left, part list beneath it, section preview in the middle, and the Excel-derived dimension list on the right.

The station navigator with a different section selected. All 33 sheets are addressed as stations along the alignment.

11,818 lines of C# across 19 service classes, 4 commands, 2 UI classes, 1 model file and an application entry point. Largest file Services\RevitGeometryBuilder.cs at 3,182 lines, then UI\PayloadImportForm.cs at 1,923 and Services\CrossPassageBuilder.cs at 1,552. The live NatmTunnelCreator.dll is 360,448 bytes, stamped 07-31 16:47, carrying AddinVersion = "v10.12 (2026-07-31)" in App.cs line 12.

Build folderDLLTimeCharacter
Revit202581,408 B07-10 13:50Previous generation
staged_260724321,024 B07-31 13:18Final of the 07-24 line
staged_260730199,168 B07-30 23:57S series complete
staged_260731199,680 B07-31 00:48T series
staged_260731_v10357,376 B07-31 16:11v10 line, up to v10.11
Revit2026 (live)360,448 B07-31 16:47v10.12

Two things read off that table. First, the size sequence is not monotonic — 321 KB on the 07-24 line, then 199 KB for the S and T series, then 357 KB. The middle two are not a regression; they are a different, narrower build target that was later merged forward. Second, and less comfortable: the live DLL's md5 differs from the last staged build's, so v10.12 was deployed without passing through staging, and the "staged matches live" comparison that every version from v10.1 to v10.9 recorded is missing for the final one. The handover package's copy was separately confirmed md5-identical to live, which is something, but not that.

From 81 KB to 360 KB is 4.4× in three weeks, and essentially all of that growth is contract tables, guards and diagnostics rather than geometry code.

3. S0–S8 — splitting the shapes

Part emphasis: LINING_SURFACE and SLAB highlighted while every other branch is greyed out. This is the shape-splitting work of S0-S8 made visible one branch at a time.

The clearance check with the governing radii and widths on the drawing — R1 6980, R2L 3959, R2R 3756, R3L 4609, R3R 4414, 12299 mm overall, clearance envelope h4600.

Before touching code I measured the payload — 65 branches × 66 frames, scanned exhaustively by script — and confirmed five defects with evidence rather than opening the code and looking for something to improve.

DefectMeasured evidence
SLAB and ROAD_LAYERS[1] byte-identicalBoth in the default group = duplicate solids in the same place
Dedup ran in the wrong direction*_SURFACE is the PRODUCTION variant and was the one being discarded; the surviving original bit 755 mm into the utility duct on each side
UTILITY_OPENING is a void, lofted as productionPoint-in-polygon had already confirmed the notch — the hole was being filled back in
No excavation-line branchGrouting at 3.75° equal angle × r7640 = ctc 500.0 exactly, back-solving the excavation radius as 7640
Support not at true scaleGrouting 12000 written as 900, rock bolt 8000 as 2500, forepoling 0 on every sheet

The fourth row is a small piece of detective work worth spelling out. There was no excavation-line branch anywhere in the payload, and no cell giving the excavation radius. But the grouting pipes were placed at a fixed 3.75° angular pitch, and the Excel spacing for grouting is 500 mm centre-to-centre. If the pipes sit on a circle of radius r, then r × 3.75° in radians must equal 500. Solving gives 7640, and it comes out exactly, not approximately. The excavation radius had been in the payload all along, encoded in the arrangement of something else.

S0 — fix the server, regenerate the payload

Problem46 points violating the clearance envelope; excavation offset short by up to 143 mm; no excavation branch in production at all
Cause(a) clearance width computed as 10710 using the wrong pair of shoulder values; (b) the outside_lines being offset were the lining outer face at 7280, not the shotcrete outer face; (c) the offset was computed as a scale about the tunnel centre, but the outside is really three separate arcs with three different centres
ActionWidth 10710 → 9200; offset amount = shotcrete + overbreak; Kasa circle fitting so each of the three outside arcs is offset radially about its own centre; new LINING_ARCH_BAND branch; excavation registered into finalProductionGroups
VerificationClearance violations 46 → 0. All 33 sheets at 0.000 mm crown-radius error and 0.0000 mm joint gap. Areas: arch 3,732,797 mm², left wall 1,693,731, right wall 1,918,258, mutual overlap zero

Cause (c) is the substantive one. Offsetting a multi-arc outline by scaling it about a single point is correct only if the outline is a single circle about that point. For a three-centred arch it is wrong everywhere except at the crown, and wrong by a smoothly varying amount — which is precisely the kind of error that looks fine on screen and shows up as "up to 143 mm" when measured.

The knock-on effect was larger than the fix. Because the excavation line now arrives inside production, the add-in no longer has to synthesise it. So plan step S3 was cancelled, not skipped, and the archive records which of the two it was. That distinction matters more than it sounds: a skipped step is an open hole; a cancelled step is a closed decision. Writing down which one it was is the difference between a plan and a to-do list.

S1 — the contract layer

Two new files, BranchRoleMap.cs and TunnelPartCatalog.cs, and the design intent is stated in their comments:

The payload ships consumerUsage, shapeRule and compositeSolidOwner per branch, but 43 of them carry the same generic shapeRule string, so role cannot be derived automatically. Therefore this static table is the authority, and the payload is used only as an audit gate.

That inversion is the single most important architectural decision in the add-in. The obvious design is to trust the payload's own metadata, because the payload is generated from the source of truth. The measurement says you cannot: 43 branches out of 65 declare the same rule string, which carries no information. So the add-in holds a hand-maintained table of nine roles — Solid, SolidTrimmed, Void, Component2D, SupportLine, ControlLine, Constraint, Duplicate, Unknown — and uses the payload only to check that the table still covers reality.

Re-measured on 2026-08-01 with an independent parser:

ItemMeasured
Keys registered in the role table77
Production branches in the payload73
Production branches missing from the table0
Table keys not present in production4 — DRAIN_CRADLE_*, BARRIER_ANNOTATION_*, both listed in excludedBranchNames as deliberate demotions

The S1 session log said "69 = 69". It reads 77:73 now because 07-31 added six support shells and split off four barrier-annotation branches. The number changed; the contract did not. At no point were there unregistered production branches, and that invariant — not the specific counts — is what the table exists to hold. Unknown is excluded from generation and printed at the top of the report, so a branch that appears without being classified becomes visible immediately instead of being built as something plausible.

TunnelPartCatalog.cs holds 22 TunnelPart records, 21 active in FiveWay mode, 20 of which create elements. The plan document's "16 parts" does not match; it probably refers to the 16 colour constants from the same step, but that is unconfirmed.

The measurement that changed the split

The decisive call of S1 came from measuring overlap membership rather than assuming it. The question was which shapes are inside which, and by how much:

ShapeSits insideConclusion
Opening21.2 % of the utility bodySubtract from body
Side drainage78.0 % left / 78.8 % rightSubtract from body
Cover45.9 % left / 44.7 % rightSubtract from body
Barrier24.2 % left / 31.6 % rightSubtract from body
Ø300 drain pipe100 % contained in the drain pocketSubtract from pocket
All of the above vs lining arch and walls0.7–0.9 %Unrelated — leave alone

Look at the spread: 21 %, 78 %, 45 %, 24 %, 100 %, and 0.8 %. Those are not ambiguous cases. The last row in particular settles a question that could otherwise have been argued indefinitely — the utility-duct family and the lining family are not materially related, and treating them as one composite would have been wrong. The 0.7–0.9 % residue is the thickness of a shared boundary line, not a shared volume.

Those numbers turned a planned three-way lining split into a five-way one, because without promoting the extra pieces the entire utility-duct material would have vanished into a subtraction. The code states its own justification: area sum 9,766,043 against LINING_SURFACE 9,756,186 with mutual overlap zero — so five pieces is a split, not a duplication, and the 9,857 mm² difference is boundary thickness. The promotion deliberately exceeded the plan, and the code declares that about itself in a comment. An agent exceeding its contract silently is a problem; an agent exceeding it and filing the receipt is a decision someone can review.

One small fix from the same step deserves mention because it made everything else auditable: the 16 branch colours were pinned to constants. The old BranchColor fell back to GetHashCode(), which is not stable across process runs, so colours changed between sessions and screen captures could not be compared to each other. A visual-verification project in which the colours move is a visual-verification project with no evidence.

S2 — one build path instead of four

ProblemFour different code paths each selected their own branches, so the preflight estimate and the actual build disagreed
CauseResolveBranchesForBuild plus five separate inline ProductionOnly checks scattered through the codebase
ActionAll replaced by TunnelPartCatalog.ResolveBuildSlices, with CollectFrameLoops / GroupBySheet unified, and SelectLoops(ClosedOnly) excluding the 13 two-point leader lines per frame in the barrier branch
Verification15 parts generated; zero cases of two parts using the same branch as material; zero uses of a forbidden role as material; 858 phantom "skipped" entries eliminated

The comment on the new function states the rule plainly: "the build path and the preflight estimate must see the same thing, so they live in one place." The 858 phantom skips are the tell — a report was announcing hundreds of skipped items that were never candidates in the first place, which is the kind of noise that trains a reader to stop reading the report.

S4 — support at true scale

ProblemThe payload's support lines are 2D drafting abbreviations — rock bolts fixed at 2500, grouting at 900 — which are meaningless in 3D
CauseThe values were drawing-notation lengths, never intended as physical lengths
ActionAdded XDir (the tangent) to PlacementFrame; start point on the excavation line as S = C + u × (r0 + shotcrete + overbreak); length from the Excel actual; 5° longitudinal inclination applied
VerificationGrouting sits at 3.75° equal pitch, and 7640 × 3.75° = 500.0 mm, exactly the Excel ctc — with the derivation pinned in a code comment

On 07-31 a further 140-line SupportSolidBuilder.cs allowed real extruded rods instead of lines, with diameters taken from Excel: rock bolt 30, grouting steel pipe 114.3, ground-improvement body 1000. The improvement body defaults to off, with the reason stated in the code — it is a zone of treated ground, not a steel member, and drawing it as a solid rod misrepresents what it is.

S5 — void subtraction

CollectVoidSolidsByPart and SubtractSolids, with one deliberate behaviour: on failure it keeps the original and reports the count rather than producing a partially-cut result. The scope limit is the important part — Generic Model family mode only. That restriction is quietly the backdrop to the entire v10.3 incident three sections from now.

S6 — the silent bug

This step was a UI reorganisation and it caught something that had been failing invisibly. The old mechanism pushed part keys into a branch CSV. Part keys are not branch names, so the lookup matched nothing and returned an empty list — with no error, no warning, and no report entry. The user selects a part, presses build, and zero elements are created, successfully. An empty result that is indistinguishable from a legitimate empty result is the worst possible failure shape, and this one had presumably been happening for as long as the UI had existed.

S7 — drawing-style support in 2D

Reading the excavation-sequence drawing properly showed that grouting is not drawn as line segments at all; it is drawn as ground-improvement circles, diameter 1000 at 500 spacing — meaning adjacent circles overlap by 50 % by design. The 2D canvas was changed to match. This is a small item that belongs to Part 3's theme: the drawing was already telling us what the thing was, in a notation nobody had decoded.

S8 — the audit report

BuildAuditReport.cs, 386 lines, and its core is not a check but an evidence-grade enum:

GradeDefinition, from the code comment
MeasuredA value obtained by actually reading Revit elements and counting
Computed2DComputed in 2D from payload polygons — independent of whether Revit ran at all
DeclaredA value the contract or table asserts
UnverifiedNot confirmed

At the head of the file sits the doctrine it implements: a claim and its evidence grade must match. "I put it in the code" is not "it works". "The gate passed" is not "the instruction was carried out." And the report is ordered accordingly — unverified items are printed at the top, not buried at the bottom under the passing ones. That single layout decision is what stops a report from functioning as reassurance.

Two errors in my own work, surfaced by one question

Both came from the engineer looking at the viewer and asking: isn't that yellow triangle a dimension?

It was. I had trusted the note attached to DRAIN_CRADLE — "closed drain cradle below the D300 pipe" — and registered it as a solid. But its four coordinates form a triangle: area 7,500 mm², base 300 (exactly the pipe diameter), height 50. A cradle under a pipe is not a triangle whose base equals the pipe's diameter and whose apex is a single point. The server code settled it:

drain_cradle_bed = closed_polyline([pocket_inner_bottom,
                                    pocket_outer_bottom,
                                    drain_pipe_bottom_point])

Two pocket-bottom points plus the lowest point of the pipe — a construction line proving a relationship, not a member. Its neighbour drain_cradle_axis is outright a vertical dimension line. Role demoted SolidControlLine, with the full account written into the comments at BranchRoleMap.cs:212–223 so the next reader sees why rather than just what.

Then, because the rule in this project is that a found defect triggers an exhaustive sweep rather than a point fix, sweeping the remaining branches found the second: EXCAVATION_UPPER and EXCAVATION_LOWER were entirely open lines with no closed loop anywhere. The add-in would have built zero elements from them — the same silent-empty failure as S6, in different clothing. A new closed_chain_from_lines brought closure failures across all 33 sheets to 0.

That day's deployment then failed anyway. Revit had the DLL locked, so the build that ran was the old one. Which is its own lesson, below.

4. Mirror in the data layer, not the render layer

The R123-to-slab connection reviewed left and right in the same coordinate system. Mirroring is done on the data before anything is drawn, so both sides are the same computation.

The same connection in drawing-style outline. If the mirror were applied at render time, only one of these two panels would be trustworthy.

T0 — an authority conflict about a hole

ProblemThe body's notch measured 217.7 mm on the left and 212.9 on the right, while the opening void was 300 and 200
CauseThe body outline is a raster trace of the Excel image and the opening comes from Excel cells. Nothing in the code had decided which was canonical — so both were, in different places
ActionKeep the detected notch's centre fixed and re-drive only its width from the Excel value
Verification66 frames × both sides: zero mismatches

The fix is worth noting as a pattern. Two sources disagree; one is authoritative for the dimension and the other for the placement. Rather than picking a winner outright, the resolution takes position from the trace and size from the cell — which is exactly the authority ladder from Part 4 applied at the level of a single feature. Images give topology and placement; numbers give dimensions.

T1 — the up line and the down line

The principle is the whole trick — mirror in the data layer, not the render layer — and MirrorForLine() is under ten lines:

if (line != TunnelLine.Down || polyline.Count == 0) return polyline;
foreach (var p in polyline) mirrored.Add(new ProfilePoint(-p.YMm, p.ZMm));

It flips section-local y to −y and nothing else. Placement is untouched, so each line follows its own alignment, curves included, and every element carries a LINE=UP|DOWN tag. The viewer does the same with a single mirrorFrame(f): flip frame() in one place and the Section view, every part view and the Drawing all become the down line simultaneously.

The alternative — mirroring at the render or placement layer — would have required every downstream consumer to know it was looking at a mirrored thing, and one of them would eventually have forgotten. Flipping the data once means nothing downstream needs to know at all.

Self-review then found one bug and stopped one accident, and both are instructive.

The bug: isMirrorableKey only looked for keys containing _y_mm and therefore missed keys written y_mm with no leading underscore. The consequence was that the down line's R1 centre was left un-mirrored, off by 8,700 mm — which is exactly 4,350 × 2, the centre offset doubled. The magnitude of the error identified its own cause, because a failure to negate produces an error of precisely twice the value.

The accident that did not happen: CleanPreviousRun deleted every add-in element in the document. So building the down line would have silently wiped out the up line you had just spent ten minutes building. It became DeletePreviousRunElements(doc, line), scoped per line.

Verification here was deliberately not a screenshot. MirrorForLine was called in the compiled DLL through reflection and its numeric output checked directly — testing the artefact that ships rather than the source that was written — and separately a pixel capture confirmed the asymmetric 713/843 dimensions swapping sides.

T2 — the cross passage

Ported from Python nodes in the engineer's own Dynamo graph (V6) into the Revit API: four new files plus a ribbon button. Where V6 and the drawings disagreed, the drawings won:

ItemV6DrawingAdopted — and why
Clear half-width1,1051,400Drawing
Springline2,5002,000Drawing. V6 appears to have taken the clearance envelope top (EL 2.500) for the springline
FlareProportional scalingParallel offsetDrawing — scaling stretches the 300 mm lining thickness by the same factor, so a 1.5× flare would produce 450 mm of lining

That last row is the one with consequences. A flare implemented as a scale is visually indistinguishable from a flare implemented as an offset at first glance, and produces a lining that thickens where it widens. A parallel offset keeps 300 mm everywhere, which is what a lining is.

CrossPassageAxis.cs (177 lines) draws the axis automatically — a feature that came directly out of the complaint "the line doesn't snap properly". Its algorithm is three steps: take the point at distance Station along the up line; cast a ray from there at Angle off the up line's tangent; intersect with the down line. At 90° it can use a direct projection and always succeeds. On failure, Axis is null and the reason goes into Messageit does not invent a position. A geometry helper that silently falls back to a plausible default is how a model acquires a cross passage in the wrong place.

And a warning is baked into the code: of the 121 Excel dimension keys, zero relate to the cross passage. Its dimensions are defaults taken from a reference tunnel's standard drawings, not confirmed values for this project.

T2 follow-up — recovering a principle from the original graph

A rewrite followed, prompted by an unambiguous complaint: the tunnel is skinny and the cross passage doesn't meet it — you read the graph in detail and this is what you produce?

The rewrite recovered something I had missed in V6. Its critical line is:

final_outer_solid = outer_solid.Difference(left_tunnel_solid)

V6 subtracts the tunnel from the cross passage, then keeps the largest resulting lump. That terminates the cross passage exactly at the mainline wall face. I had only done the inverse — subtracting the cross passage from the tunnel to make the hole — which produces the hole correctly and leaves the cross passage itself protruding 2,000 mm of penetration extension into the mainline bore. Both operations are needed and they are not symmetric. Fixed with CollectMainlineSolids() and LargestLump().

The same self-review found three more defects: Number(params.get(x)) returning 0 for a missing key, which killed the flare entirely and silently; two spacing inputs that were not linked to each other; and the y_mm mirror omission again, in a second location. The same bug appearing twice is a sign the fix was applied to an instance rather than to a rule.

5. v10 → v10.12 — thirteen versions, one question

The family-mode option set — DirectShape versus Generic Model family. Choosing between them is one of the things the v10 line kept revisiting.

The dimension list filtered to 'grouting'. Every value the builder uses can be traced back to a named Excel dimension in this panel.

The whole afternoon of 07-31 was a single question: why is the cross-passage opening not being cut? Thirteen builds shipped. Six of them got nowhere and the reason is structural, not a matter of effort.

v10 (the pivot) — stop cutting after the fact

The instruction was "first give me a feature that picks the alignment and creates a line at the cross-passage position". The rationale was empirical: working around the problem by subtracting after the fact had caused an accident three times out of three — the mass deletion, the model shift, and the protection inversion, all in section 7 below. So the design changed to three ribbon buttons — axis, mainline with the opening already reflected, cross passage — and one rule: nothing is deleted, rebuilt or moved. Built with zero errors. Never run.

v10 (the incident) — a patch that reported success

The response was "I thought you changed the feature?" with a screenshot of an unchanged ribbon. The cause is the cleanest example of false verification in the whole project: the patch script's replace operation for the axis button failed on an anchor mismatch, and I judged the patch successful from s != o alone — from "did the file change at all", which was true because the other replacements had succeeded. The fix was to insert it by hand; the correction to the method was to verify all 8 expected strings in the DLL binary. Verified 8/8. (Ribbon changes also require a Revit restart, which is a separate trap.)

v10.1 — "how is this different from before?"

Followed by, verbatim, "are you kidding me?" The complaint was correct. The point was to reflect the opening at build time; splitting that into three sequential buttons added procedure without changing anything about when the geometry was made. Collapsed into one button with consecutive picks. DLL strings 10/10.

v10.2 — "I can't tell what I'm clicking on"

The form closed itself and then chained PickObject calls with no indication of which selection was being requested. Four pick buttons with status labels, red when unselected and green when set, and the form reopening in its previous state after each pick. DLL strings 13/13, plus a pixel capture of the form.

v10.3 — everything was a Mesh

Submitted as a screenshot of the result window. Every part had been created as a Mesh, not a Solid. TessellatedShapeBuilderTarget.Solid was failing — a long thin tube built from stacked triangles frequently does not close into a valid volume — and the builder was falling back to Mesh. A Mesh cannot be an operand in a boolean operation. So there was nothing wrong with the subtraction logic; there was simply nothing subtractable. The fix: when Solid creation fails, an opening is present, and the part is not protected, rebuild that part only as a Solid by loft, then subtract.

v10.4 — two true answers to one question

"Why doesn't anything appear on the cross-passage side? Is this created along the alignment or not?" Both halves were correct simultaneously, which is why the symptom had been confusing. (a) Button 2 cut only the opening — the body was a different button — so running button 2 alone produced nothing visible. (b) Alignment placement defaulted to OFF, so geometry was built at the origin while the void existed in world coordinates, and the two never met. Fixed by building the body inside button 2, and by auto-setting _useAlignment.Checked = true whenever a line is picked. First real runtime: 132 frames, 1,584 elements, void 426.731 m³.

v10.5 — "1000000, 500000, 100000 — same place. 20 really is 20"

Two independent bugs, both invisible. (a) A numeric input declared as Num(0, 0, 100000) — a 100 m ceiling on a control positioning something along an 892 m alignment, so every value above 100000 clamped to the same point. The "20" observation was the giveaway: 20 meant 20 millimetres, and it correctly appeared near the start. (b) ModelCurve does not support Comments, so the axis marking was failing silently every time. Fixed with metres at 0.1 m steps, with the alignment picked first so the ceiling becomes the real length, and a dedicated NATM_CP_AXIS line style instead of a comment. Verified 15/15 strings, plus reverse verification — confirming the old millimetre labels were gone, not merely that new ones were present.

v10.6 — a fix for a cause that was not the cause

"It's not cutting R1, R2, R3 either — don't tell me you cut outside the tunnel and concluded there's no overlap?" The answer: it was not an overlap judgement at all. No solid had been produced, so there was nothing to subtract from. The hypothesis was that the whole 30 m sheet span being lofted as one lump was the problem, so BuildSegmentSolids() was added to split it into adjacent frame pairs. One version later this was shown to have had no effect whatsoever, for a reason a single log line would have revealed.

v10.7 ★ — the turn

No complaint prompted this one, and it added no geometry. It added crossPassage{...} and addinVersion fields to RunLogWriter. That is the entire change. The first real diagnostic log exists at 15:12.

Everything before this point was caption relay: a screenshot arrives, I guess a cause, ship a build, repeat. Six versions of that reached nothing. Three hours after this one, the root cause was confirmed.

v10.8 — the real cause

AddGroupFaces builds the side rings of a shell from resampled points, while the end caps use the original WorldLoops. Different point counts on the sides and the ends means the shell never closes — which is exactly why TessellatedShapeBuilderTarget.Solid had been failing back in v10.3. Replaced stacked triangles with Revit's own loft API. No log exists for this build, so its behaviour is inferred from the code and from what changed afterwards.

v10.9 — "it's been running for minutes, I think it's stuck"

v10.8 ran a loft on all 900 mesh fallbacks, across the full 892 m. Added GroupMayTouchOpenings(), a bounding-box pre-filter with a 1 m margin. The log at 15:33 shows it working: meshSkipped 900 → 26.

v10.10 — the catch that ate the answer

A catch block was swallowing the exception, so failures had no traceable cause. Added a LoftFailures list carrying the exception message. The code comment is not diplomatic: "catch swallowed the cause and I wandered for hours." Log at 15:44.

v10.11 — Revit says the reason out loud

With the message now in the log, Revit stated its own diagnosis: "profiles were not correctly ordered". The mechanism: ToCurveLoop filtered out edges shorter than 0.8 mm independently for each frame, so two frames of the same part could end up with different curve counts and the loft could not pair them. Added ToCurveLoopExact and FixedCount paths, and where point counts already match, uses the original points — because uniform resampling shaves corners, measured at 6.2 % area error on the barrier and 4.0 % on the utility duct. Four logs. Bands and lining still failing.

v10.12 — confirmed, and unrun

"Let's wrap up where we are." The confirmed cause: InvalidOperationException — the loft generated a self-intersection, with a perfect correlation across all nine parts — zero self-intersections means success, one or more means failure, with no exceptions. Added RemoveSelfIntersections() and a log field for it. Zero execution logs. Unverified.

The shape of that list

Six versions of caption relay got nowhere. v10.7 added no geometry at all and the root cause was confirmed three hours later. Here is the 16:04 log, with Revit naming the reason itself:

Selected cross-passage line: opening void 421.232 m3
  (length 26516 mm - clear half-width 1400 - lining 300 - flare 1500x1000)

UP/LINING_WALL_L/T_sec_26 (LINING_WALL_L): loft API and segments both produced 0 -
  frames: 2 - loops: 1 - first loop points: 132. Reason: loop 0:
  InvalidOperationException - Failed to generate the requested loft geometry.
  One possible reason was that the loft generated a self-intersection because
  the profiles were not correctly ordered...

And the matching line from 15:12, on v10.7:

UP/LINING_ARCH/T_sec_1 (LINING_ARCH): mesh, and segment loft produced 0 pieces - frames: 2.

frames: 2 was the decisive fact. It means the group was already at its minimum unit — the start and end of a single sheet — so v10.6's segment splitting had nothing left to split. An entire earlier version was retroactively invalidated by one field of one log line. Looking at screenshots, that fact would have taken days to reach, if it was ever reached at all.

The 16:04 log's notes array holds 53 entries, of which 22 are the same InvalidOperationException on different part/sheet combinations. The first-loop point counts vary widely across them — 132, 132, 23, 9, 244, 118 — which rules out a size-related cause and points at something structural in the loops themselves. And two lines in the same array record the successes:

UP/UTILITY_COVER/T_sec_26: opening subtraction 1.747 -> 1.665 m3
UP/EXC_SIDE_STRIP/T_sec_26: rebuilt as 1 loft solid instead of mesh,
                            subtraction 5.445 -> 5.267 m3

The second of those is v10.3's fix visibly working: a part that arrived as a Mesh, was rebuilt as a Solid specifically so it could be cut, and then was cut.

The self-intersection table

PartSelf-intersectionsLoftIntersecting loop area
UTILITY_COVER0Success
EXC_SIDE_STRIP0Success
LINING_ARCH0(does not touch the opening)
BARRIER1Fail0.000 %
LINING_WALL_R1Fail0.101 %
LINING_WALL_L1Fail3.56 %
SHOTCRETE2Fail21.50 %
OVERBREAK_FILL1Fail21.61 %
STEEL_RIB2Fail21.76 %

Note BARRIER: one self-intersection, intersecting area 0.000 %. The crossing is real but encloses essentially nothing — a hairline — and the loft rejects it just as firmly as it rejects the 21 % cases. Validity here is binary, not proportional. That is the property that makes self-intersection so dangerous in a viewer-first workflow: the failure has no visible magnitude.

The 21 % on those three bands is my own defect. In the server code, _inward_band() builds a band by stitching the outer loop to the reversed inner loop without aligning their start points, so the two seam lines cross each other. Three options existed:

OptionActionTrade
AApply v10.12's loop removal as-isCuts immediately — but keeps the 21 % distortion
B (recommended)Fix _inward_band() and regenerate the payloadGeometrically correct — but the viewer then needs re-verification
CExclude the three bands from subtractionShape preserved — nothing gets cut

B wins for one reason: a 21 % distortion goes straight into quantity takeoff. Option A produces a model that builds, cuts, renders correctly and prices wrongly — which is worse than one that visibly fails, because nothing downstream would ever question it.

"There are four overlapping excavation shapes"

A recurring complaint that deserved a measured answer rather than a reassurance. They are not duplicates — they are concentric bands. Crown radii measured on one sheet:

EXCAVATION_UPPER   7498.6
SHOTCRETE_BAND     7498.6   (starts at the excavation line, so the outer face is identical)
STEEL_RIB          7440.0
OVERBREAK_FILL     7360.0
LINING_ARCH_BAND   7280.0

The identical first two values are the point: shotcrete is sprayed onto the excavated face, so its outer surface is the excavation line. The basis is a 2023 national standard on tunnel overbreak and backfill quantities, cited twice in the code. Overlapping is correct, and the form can switch any of them off.

6. Reading the run logs

The production detail after the split, generated first from source lines. This is the state the run logs are describing.

Logs are written as run_logs\NATM_RUN_{time}_{mode}_{scope}.json. The field dictionary, in the order you should read them:

FieldHow to read it
addinVersionCheck first. If absent, the log predates v10.7 and cannot be diagnosed from at all
crossPassage.curveIdsEmpty array means no opening work happened in that run
crossPassage.useAlignmentfalse means geometry was built at the origin — the v10.4 indicator
crossPassage.cutGroupsMust be > 0 for anything to have been cut
crossPassage.protectedSkippedDeliberately skipped protected parts — a normal, non-alarming number
crossPassage.meshSkippedParts that could not be cut because they were Mesh. Over-counted irrelevant groups before v10.9
crossPassage.selfIntersectionsRemovedThe v10.12 field. Appears in no log at all
crossPassage.notesCapped at 400 entries; carries the verbatim failure reason
result.FrameCount132 means both lines; 66 means one

Here is the whole 07-31 lineage, re-parsed from the files rather than from the session log:

TimeVersioncurveIdscutGroupsprotected / meshSkippedsolid / meshnotesFramesFaces
09:45 ×3(absent)066308,584
12:45–13:53 ×9(absent)066785,632
14:10–15:05 ×5(absent)01321,571,264
15:12v10.714330 / 900641 / 943400 (capped)1321,571,264
15:33v10.91410 / 26641 / 943531321,571,264
15:44v10.101419 / 43641 / 943861321,571,264
15:55v10.11000 / 0335 / 457066785,632
15:56v10.11000 / 0527 / 10570132 (useAlignment=false)466,720
15:57v10.11000 / 0641 / 94301321,571,264
16:04v10.111410 / 26643 / 941531321,571,264

Four things read straight off it.

Seventeen of the runs have no version field at all. Everything before 15:12 is undiagnosable — the runs happened, produced elements, and left behind nothing that can be attributed to a build. That is a five-hour blind spot in the middle of the day the problem was being chased.

15:12 to 15:33 is the v10.9 bounding-box filter, visible as three numbers moving together: protectedSkipped 330 → 10, meshSkipped 900 → 26, and notes falling from a saturated 400 down to 53. A performance problem and a miscount fixed by one change — and the saturated 400 is worth noting on its own, because a capped log is a log that has silently discarded the entry you needed.

15:56 is the alignment flag off, and every number confirms it. solid/mesh reads 527/1057 against 641/943 in every other run, and FaceCount is 466,720 against 1,571,264. The state v10.4 diagnosed from a screenshot is here as a numeric fingerprint. Two runs of the same build, one minute apart, differing only in a checkbox, produce a 3.4× difference in face count.

The last of the runs is 16:04 on v10.11, while App.cs says v10.12. So v10.12 has never once been executed in Revit, and no log carries its version string or its selfIntersectionsRemoved field.

One correction to my own earlier account. The archive says 27 logs for 07-31. Re-counting the files gives 27 files but 24 distinct runs — three of them are md5-identical copies placed in the handover package (15:56, 15:57 and 16:04). I verified the hashes rather than assuming. It changes nothing about the conclusions, and it is exactly the kind of double-count that turns into a wrong number if nobody re-derives it.

7. Incident records

7-1. Mass deletion of mainline elements

Symptom: processing a single cross-passage location deleted mainline elements across more than ten sheets.

Cause one — substring matching on sheet names. ds.Name.IndexOf("T_sec_1") is true for T_sec_1, and also for T_sec_10 through T_sec_19. Ten sheets matched instead of one. This is the oldest string bug there is, and it is invisible until your identifiers reach double digits — which, here, meant it was invisible until sheet 10.

Cause two — delete-then-rebuild ordering. The affected span was deleted first and rebuilt after. Any failure in the rebuild leaves only the deletion, so a partial failure becomes a total loss.

Three layers guard it now. MatchesAnySheetExactly() splits ApplicationDataId on : and matches whole tokens only — there is no substring-matching code left. Deletions are collected into deleteCandidates and executed after the rebuild has finished. And a blast-radius check refuses to run at all:

if (allSheets > 0 && sheets.Count * 2 > allSheets)
    return ... "the affected sheet count N/33 is too broad; rebuild aborted (model protection)"

If an operation on one cross passage claims to affect more than half the model, the operation is wrong — regardless of why. That guard does not need to know the cause to be correct.

7-2. Model shift from a null alignment

Symptom: after cross-passage processing, the entire mainline was displaced. Cause: rebuilding a span with no alignment information falls back to the origin. The fallback is reasonable in isolation and catastrophic in context, because "no alignment" is not an unusual state — it is the state of every element built by an older version of the add-in.

Now: mainline elements carry an ALIGN=<ElementId> token in Comments; rebuilds restore the alignment curve from that token; and a failed restore is blocked and reported rather than defaulted:

Elements built by an older version have no ALIGN= tag… rebuilding without an alignment would reference the origin and shift the model, so this will not proceed.

There is a command-level guard as well, and v10.4 added prevention at the UI layer by auto-enabling alignment placement the moment a line is picked. Three layers for one failure, because the failure is silent and the damage is model-wide.

7-3. The protection predicate defined as its own inverse

Symptom: "when exactly are you going to cut these elements?" Nothing was ever cut.

public static bool IsPartProtectedFromCut(string? partKey) => !IsCuttablePart(partKey);

private static bool IsCuttablePart(string? partKey) => partKey switch
{
    ExcavationUpper => false, ExcavationLower => false,
    Pavement => false, Slab => false, Filter => false, Clearance => false,
    _ => true,     // unknown parts handled as before but recorded in the report
};

The predicate already negates. The call sites negated it again. The result was exactly backwards: every cuttable part was skipped, and only the excavation that should have been protected was rebuilt.

What makes this one interesting is that it produces no error and no warning anywhere. A double negation is a completely valid program that computes the opposite of what its name says, and the only observable symptom is an absence — nothing gets cut, which looks exactly like "the geometry doesn't overlap". That is why v10.6 spent a whole version chasing an overlap hypothesis. All four call sites now use it without the extra !, re-confirmed 08-01. The six protected parts — excavation upper and lower, pavement, slab, filter and clearance — are protected for a stated reason: they are the basis of quantity takeoff.

7-4. The smaller ones

IncidentCausePrevention
Minutes-long stallsLofting across the whole 892 mOpening bounding-box pre-filter (v10.9)
Deployed, but no effectRevit locks the DLL and loads it once at startupClose Revit fully → deploy → restart
Untraceable causescatch swallowing exceptionsException message written into the run log (v10.10)
Patches judged successful when they were notMulti-replace scripts not checking each replacement individuallyVerify the expected strings in the DLL binary, every version from v10.1

The verified-string count across versions ran 10 → 13 → 5 → 5 → 15, and v10.5 added reverse verification — confirming the old labels were gone rather than only that the new ones were present. Those two checks are not the same. A patch that adds the new string while failing to remove the old one leaves a binary containing both, and forward-only verification passes it.

The operational lesson from v10.5 is on record and it is about communication, not code: "if you bury a deployment failure at the end of your answer, the user keeps testing with the old DLL. Write deployment failures at the very top, in large letters." An accurate report that is structured so the important part is not read is not an accurate report.

8. Where this actually stands

The 3D branch mirror with the branch list on the left. Where this actually stands: the branches exist and are named, and that is what the add-in consumes.

Two of nine parts are cut successfullyUTILITY_COVER and EXC_SIDE_STRIP. Failing: BARRIER, LINING_WALL_L, LINING_WALL_R, SHOTCRETE, OVERBREAK_FILL, STEEL_RIB. The three bands at 21.50 / 21.61 / 21.76 % are the _inward_band() defect, and none of options A, B or C has been applied.

Runtime verification itemStatus
v10.12 executedZero times. None of the logs is v10.12
RemoveSelfIntersections()Unverified. No log carries the field
Three ribbon buttons actually visibleUnconfirmed — no ribbon capture exists after the v10 incident
Form re-display loop (v10.2)Unverified
Loft API producing a Solid for the liningStill failing as of the 16:04 log

Code evidence, DLL string verification and pixel captures of the form exist for every version. Evidence that the final geometry is correct inside Revit does not. The only runtime evidence in existence is the set of logs produced by the engineer running the tool, and that set stops at v10.11.

Still missing or unimplemented: forepoling is absent from the pattern table entirely; steel rib specifications and spacing are undocumented apart from a drawing note saying "install as approved by the supervisor", so the code uses code-standard defaults; rock bolt longitudinal CTC is still borrowed from the transverse value; confirmed cross-passage drawings were never obtained; connection reinforcement and expansion joints are drawn but not built; the viewer's support-placement-rule view still does not use the pattern table; and one forepoling Ø1500 figure on one sheet is suspected to be an Excel misreading and remains unchecked.

Also confirmed lost: a set of guide documents from the 07-24 stretch, versions 1 through 6.2, destroyed by same-filename overwrites with no commits behind them. Not recoverable. Part 9 is largely about why that particular sentence keeps appearing.

On the last day the standard support pattern table — treated for weeks as "not in the source material" — turned out to be inside the Excel workbook: 15 Types, rock bolts arranged as three zones × two staggered cycles, and longitudinal CTC values of 7.0, 8.0, 6.0 and 24.0 m where 2.0, 2.5, 1.8 and 1.5 m were in use, because longitudinal CTC is not transverse CTC (Part 5 has the full comparison). It now exists as support_pattern_types.json (19,513 B), read by SupportPatternTable.cs. The add-in reflects it. The viewer does not yet — so as of now the two consumers of the same payload disagree about support spacing, which is precisely the divergence the shared-schema architecture existed to prevent.

9. Five things this stretch taught me

  1. Without instrumentation, root-cause work degenerates into caption relay. Six versions of screenshot-and-guess reached nothing; v10.7 added log fields and v10.8–v10.12 reached a confirmed cause in three hours. One log field — frames: 2 — invalidated an entire earlier version. The cost of adding those fields was one build with no functional change, and I should have paid it on the first day rather than the seventh.
  2. A catch that swallows exceptions makes time disappear. Only once v10.10 put the message into the log did Revit tell me the answer itself — in a complete sentence, naming the cause. The information had been generated and discarded on every single previous run.
  3. Visible in the viewer is not buildable in Revit. Self-intersection does not render. That is how a 21 % defect survived six weeks of green gates, and it is a general property: every verification layer is blind to the failures that its representation cannot express.
  4. Verify a patch by "is that string in the binary", not by "did the file change". And verify in both directions — the new string present and the old string gone.
  5. A static contract table stops a changing server from quietly building the wrong thing. When the payload's own metadata cannot distinguish 43 branches from each other, the metadata is not a contract. Unknown is excluded from generation and printed at the top of the report, so an unclassified branch becomes a question instead of a shape.

What's next

That is what this stretch produced: a web viewer used as a controlled mirror, a raster gate that mostly proved its own failures were measurement artefacts, and a Revit add-in that gets two parts out of nine cut. What is unfinished is easy to list, because it was written down as it happened. The final build has never run in Revit, and the self-intersection removal it exists for is unverified. Three lining bands carry a defect I created in my own server code, and the recommended fix has not been applied — which would then require re-verifying the viewer too. Cross-passage dimensions rest on a reference tunnel's standard drawings, with none of the 121 Excel dimension keys behind them.

None of that becomes a finished model by being written up well. It is here because the alternative — a closing paragraph claiming the pipeline works — would be the one statement in this series that no log supports.

Three posts remain. Part 7 goes back through the 106 logged instructions that produced all of this, one category at a time. Part 8 takes apart the seven questions the AI wrote for itself on the night the guessing stopped. And Part 9 is about the 149 GB of snapshots this work left behind, what an investigation into deleting them actually found, and the discovery partway through that reversed the conclusion.


Tunnel automation series — nine parts.
← Previous: Validation Gates That Always Pass: Add a Negative Control (not published yet)
Next: Prompt Engineering for BIM: 101 Instructions, One Drawing (not published yet)
Start of the series: Dynamo Built the Whole Tunnel and Never Read the Excel File

댓글

이 블로그의 인기 게시물

Structural Analysis Workflow with Dynamo and Robot

Dynamo with the Gemini Vision API test(Nano Banana)