main
Drew Owens 0.3.3 source + crate build: BOM exported from the SOLIDWORKS assembly, build log §1–11, release script 477e64f 1mo ago

Rebuilding the NSF machine crate in SOLIDWORKS, through the swx bridge

The SOLIDWORKS bridge's first real production job: take the NSF PCL machine-crate build packet (wiki adom/nsf-pcl, september-dc-event/machine-crate, rev D) and rebuild ONE crate as real SOLIDWORKS parts and assemblies — geometry, materials, custom properties, fasteners — and export a BOM from the SOLIDWORKS assembly, not from the wiki page.

Everything below happened on Drew's machine, live, against SOLIDWORKS Design Professional for Makers 2026 (3DEXPERIENCE Connected, R2026x), driven entirely by swx_* verbs from a container. The bridge stayed in its default deny-by-default posture for the whole job: SWX_ALLOW_USER_WRITES was never set, so nothing the user owns could be touched. Every document and every file in this build was created by the bridge, in a new folder (C:\Users\Public\adom-crate\).


1. What had to be built into the bridge first

The job was gated on one thing the bridge could not do: put components into an assembly. Without that there is no assembly, and without an assembly there is no BOM. The parity report had written this off:

IAssemblyDoc::AddComponent5 returned Nothing for every combination tried live … The most likely explanation is that SOLIDWORKS Connected does not permit local-file component references.

That conclusion was wrong, and finding out why is the most valuable thing in this log.

Finding 1 — AddComponent5 works. Its RETURN VALUE is the lie.

AddComponent5 really does return Nothing on this edition. It also really does insert the component. The earlier probe measured success by the return value, so it saw a failure that had not happened.

The fix is to stop trusting the return and count the assembly's components before and after the call. swx_add_component does exactly that, and walks a ladder of spellings (AddComponent5 with each swAddComponentConfigOptions_e value, then AddComponent4, AddComponent3, AddComponent) until the count rises. On this install the rung that works is:

AddComponent5(path, 0, "", false, config, x, y, z)      # config option 0

Options 1, 2 and 3 all silently do nothing. The winning rung is cached, so a 400-fastener assembly pays the discovery cost once, not 400 times.

Finding 2 — AddComponent5's X/Y/Z is NOT the component's origin.

Measured, not assumed. A part whose own centroid sits at (50, 6, -25) mm, inserted at (0, 0, 0), leaves the assembly's centre of mass at the origin — i.e. SOLIDWORKS centred the component on the point rather than putting its origin there.

So swx_add_component writes each component's transform itself, after insertion, via IMathUtility::CreateTransform + IComponent2::Transform2. xMm/yMm/zMm then mean exactly one thing: put the component's origin here. Rotation (rotXDeg/rotYDeg/rotZDeg) comes free with it — and rotation is not optional for a crate, because the same ripped rail is used lying two different ways.

Because the insert call returns no handle, the components a batch inserted are found by matching the sequence of file paths against the assembly's component list — one contiguous run, forwards or backwards. That is O(n) COM calls for the whole batch instead of a search per component, and it proves the mapping: if no run matches, nothing is written and the response says so.

Finding 3 — SOLIDWORKS' MathTransform 3x3 is COLUMN-major.

Filling ArrayData[0..8] row-major put a component rotated +90° about Z at −90° instead. For the axis-aligned quarter turns a crate is made of, a transposed rotation is a rotation the other way — silently wrong rather than obviously broken. Caught by computing the expected assembly centre of mass in Python and comparing: with the transpose fixed, three blocks at (0,0,0), (0,300,0)+Rz90 and (600,0,0)+Ry90 give a measured CoM of (206.333, 120.667, −33.333) mm against a predicted (206.333, 120.667, −33.333). Exact.

Finding 4 (bug) — run_script's documents were invisible to the com host.

swx_close_doc on a part the bridge had just built was refused:

the com host refuses to close_doc: this document was not created by this bridge

created_document() looked for {title, path} under the result's created key, but run_script nests: its created is the whole new_document result, whose own created carries the title. The server's registry descended; the host's did not. So the two ownership records disagreed, and the bridge would not clean up after itself. Fixed (src/comhost/host.rs), with a unit test for both shapes.

Finding 5 (bug, NOT fixed) — a bridge restart orphans the bridge's own documents.

The com host's record of what it created is persisted (created_store); the server's scratch registry is in-memory only. Restart the server and it disowns every document it made, while the host still owns them — the two vetoes disagree in the direction that blocks cleanup, so the bridge can no longer close its own scratch documents and they are stranded in the user's session.

Not fixed here (the honest fix is a second, independently-written persisted registry on the server side — reading the host's file would collapse "two independent vetoes" into one). Cost of this bug in this session: a handful of orphaned scratch documents left open in Drew's SOLIDWORKS during bridge development, before the crate build proper began.

What shipped

swx_add_component — new verb, components:[{filePath,xMm,yMm,zMm,rotXDeg,rotYDeg,rotZDeg,configuration}], one batch per call, gated exactly like every other document-modifying verb (the assembly must be one the bridge created; the component files are only read, so they are not part of the gate). Plus Var::r8_array (the first SAFEARRAY this bridge builds rather than reads), script::parse_components, Component::transform and script::locate_run — all four unit-tested on Linux, plus an end-to-end swx_add_component → swx_bom round trip in the e2e suite. Suites green: 79 unit, 28 e2e, 11 protocol.


2. The crate model

Coordinate system

One frame for the whole crate, in the packet's own terms:

  • X = the 96″ length, 0 at the near end plane.
  • Y = the 48″ width, 0 at the near side plane.
  • Z = height, 0 at the underside of the 2x4 base ring (the caster mounting face).

The gravity stack, taken straight from D0 but with the plywood at its as-purchased 15/32″ thickness rather than the packet's nominal ½″:

from to
2x4 base ring 0 1.500
deck ply P1 1.500 1.96875
walls (45″) 1.96875 46.96875
cap ply P2 46.96875 47.4375
cap ring 47.4375 48.9375

48.9375″ against the packet's "49″ box" — the 1/16″ is the two sheets of 15/32″ plywood being 1/32″ thinner each than the ½″ the drawings round to. Every wall is 1.96875″ thick (rail 1.5 + ply 0.46875), which is the packet's "2″ thick" face.

Parts

Lumber is actual size, never nominal: a 2x4 is 1.5 x 3.5, and the ripped rail stock is 1.75 x 1.5 (mounted 1.5″ proud, exactly as C2 specifies). Every part was built by swx_run_script (new_part → sketch_rect → extrude → set_material → six set_props), verified with swx_inspect_bodies, then swx_save_as'd into C:\Users\Public\adom-crate\parts\.

PartNo Description Size (in) Size (mm) Material Qty/crate
P1 Base deck ply, 48 x 96 x 15/32 CDX (full sheet, no cuts) 96 x 48 x 0-15/32 2438.400 x 1219.200 x 11.906 Pine 1
P2 Cap ply, 48 x 96 x 15/32 CDX (full sheet, no cuts) 96 x 48 x 0-15/32 2438.400 x 1219.200 x 11.906 Pine 1
P3 Side wall ply, 96 x 45 x 15/32 CDX (one rip) 96 x 45 x 0-15/32 2438.400 x 1143.000 x 11.906 Pine 2
P4 End wall ply, 43-7/8 x 45 x 15/32 CDX 45 x 43-7/8 x 0-15/32 1143.000 x 1114.425 x 11.906 Pine 2
P5 Side-station backer, 12 x 12 x 15/32 CDX (from offcuts) 12 x 12 x 0-15/32 304.800 x 304.800 x 11.906 Pine 4
R1 Ripped rail 96in - side top/bottom rails + cap long rails 96 x 1-3/4 x 1-1/2 2438.400 x 44.450 x 38.100 Pine 6
R2 Ripped rail 41-1/2in - wall verticals 41-1/2 x 1-3/4 x 1-1/2 1054.100 x 44.450 x 38.100 Pine 12
R3 Ripped rail 45in - end edge rails, full height 45 x 1-3/4 x 1-1/2 1143.000 x 44.450 x 38.100 Pine 4
R4 Ripped rail 40-3/8in - end top/bottom rails 40-3/8 x 1-3/4 x 1-1/2 1025.525 x 44.450 x 38.100 Pine 4
R5 Ripped rail 44-1/2in - cap end rails + crossmembers 44-1/2 x 1-3/4 x 1-1/2 1130.300 x 44.450 x 38.100 Pine 5
B1 2x4 96in - base long rails 96 x 3-1/2 x 1-1/2 2438.400 x 88.900 x 38.100 Pine 2
B2 2x4 41in - base end rails 41 x 3-1/2 x 1-1/2 1041.400 x 88.900 x 38.100 Pine 2
B3 2x4 41in - base crossmembers 41 x 3-1/2 x 1-1/2 1041.400 x 88.900 x 38.100 Pine 3
B4 2x4 10in - corner caster doubler pads 10 x 3-1/2 x 1-1/2 254.000 x 88.900 x 38.100 Pine 4
B5 2x4 43-7/8in - end pull handle 43-7/8 x 3-1/2 x 1-1/2 1114.425 x 88.900 x 38.100 Pine 2
B6 2x4 43-7/8in - payload hold-down bar 43-7/8 x 3-1/2 x 1-1/2 1114.425 x 88.900 x 38.100 Pine 2
B7 2x4 12in - side-station spacer block (rip to fit) 12 x 3-1/2 x 1-1/2 304.800 x 88.900 x 38.100 Pine 8
FM1 Foam bed strip, 2in PE plank 4 x 88in 88 x 4 x 2 2235.200 x 101.600 x 50.800 PE Low/Medium Density 2
FM2 Foam pad under hold-down bar, 1in PE plank 43-7/8 x 3-1/2 x 1 1114.425 x 88.900 x 25.400 PE Low/Medium Density 2
FM3 Foam side-station pad, 2in PE plank 12 x 12in 12 x 12 x 2 304.800 x 304.800 x 50.800 PE Low/Medium Density 4
FM4 Foam end thrust pad, 2in PE plank (cut to fit at pack) 24 x 24 x 2 609.600 x 609.600 x 50.800 PE Low/Medium Density 2

Materials. This install's SOLIDWORKS Materials.sldmat has a Woods category of exactly: Balsa, Beech, Cedar, Mahogany, Maple, Oak, Pine, Teak. There is no "Plywood" and no "SPF"/"whitewood", so Pine is the honest closest match for both the 15/32″ CDX and the KD whitewood 2x4s, and the real specification lives in each part's Material and Description custom properties. Foam is PE Low/Medium Density; fasteners are Galvanized Steel.

Caveat, stated rather than hidden: the SOLIDWORKS PE material is solid polyethylene (~920 kg/m³). The crate's foam is 1.7 pcf plank (~27 kg/m³), so any mass number that includes the foam parts overstates them by ~34x. The wood and fastener masses are sound.

Assembly structure

Five assemblies, not one flat pile, because the packet builds five things (D1-D4 plus the crate) and a BOM that rolls up is worth more than one that does not. swx_bom's default flattens the whole tree, so a screw inside a side panel that is instanced twice is counted twice.

Assembly Contents Instances in the crate
ASM-SIDE-PANEL 49 x F-8x125, 1 x P3, 2 x R1, 5 x R2 2
ASM-END-PANEL 1 x B5, 25 x F-8x125, 4 x F-L25x300, 4 x F-W10, 1 x P4, 1 x R2, 2 x R3, 2 x R4 2
ASM-CAP 49 x F-8x125, 1 x P2, 2 x R1, 5 x R5 1
ASM-BASE 2 x B1, 2 x B2, 3 x B3, 4 x B4, 6 x CASTER-6, 49 x F-8x200, 24 x F-L25x250, 1 x P1 1
ASM-CRATE 1 x ASM-BASE, 1 x ASM-CAP, 2 x ASM-END-PANEL, 2 x ASM-SIDE-PANEL, 2 x B6, 8 x B7, 108 x F-10x3, 2 x FM1, 2 x FM2, 4 x FM3, 2 x FM4, 4 x P5 1

Total component instances placed: 383 (245 in the sub-assemblies, 138 at the crate level).

Geometry verification

Every part was verified twice: once as it was built (swx_inspect_bodies between the extrude and the save), and again as a FILE — each .sldprt reopened read-only, its solid body's bounding box read back in millimetres, and its custom properties read back, then closed. The second pass is the one that matters: it proves what is on disk, not what a step list claimed.

The predicted numbers come from the packet's inch dimensions x 25.4, computed independently in the generator (crate.py) rather than read off the model.

The assembly order, and where the interference check goes

The packet's A1 order is followed: base deck-down first, then the panels framed, then the walls onto the deck, the ends dropped in between the side plies, the bracing, and the cap last.

One deliberate choice about verification: the interference check is run with only the WOOD in the assembly, before any fastener goes in. Screws are meant to be embedded in timber, so an interference count taken after they are placed says nothing — every screw is an "interference" by design. Wood-to-wood is the number that means something, and it is the number the packet itself quotes ("0 interferences, 142 flush contacts").

The same check is also run independently in the generator, in Python, over axis-aligned boxes before SOLIDWORKS is touched at all:

structural boxes: 74
X  -1.50000 ..  97.50000   (99.00000)   <- 99" over the handles
Y   0.00000 ..  48.00000   (48.00000)
Z   0.00000 ..  48.93750   (48.93750)   <- the "49 inch box"
box X (handles excluded): 0.00000 .. 96.00000  (96.00000)
interior clear: 92.06250 x 44.06250 x 45.00000
wood-to-wood interferences: 0
flush contacts: 170

against the packet's "footprint 96 x 48″ exact, 49″ box, interior 92 x 44 x 45″ clear, 99″ over the handles". Two independent computations of the same crate agreeing is worth more than either one alone.


3. Bridge bugs and rough edges found doing real work

Beyond the two fixed above, this job surfaced these. They are worth as much as the crate.

# What Severity Status
1 AddComponent5 returns Nothing while succeeding — an entire capability was written off because the probe trusted the return value high worked around in swx_add_component (count, don't trust)
2 AddComponent5's X/Y/Z is not the component's origin (it centres the component on the point) high worked around — the verb writes the transform itself
3 MathTransform ArrayData's 3x3 is column-major; row-major fills rotate the wrong way, silently high fixed in Component::transform, unit-tested
4 created_document() did not descend run_script's nested created, so the com host disowned every document the modelling DSL built and refused to close it high fixed
5 A bridge-server restart orphans the bridge's own documents: the com host's ownership record is persisted, the server's is memory-only, so they disagree in the direction that blocks cleanup medium open — needs a second, independently-written persisted registry on the server
6 The DSL's save_as is COPY mode, so the document stays untitled/unsaved and neither registry learns the path. The file is fine; the open document is not the file. swx_save_as {copy:false} is what a build script actually wants low documented; the crate build uses swx_save_as
7 swx_add_component reports the inserted component's name from the last entry of GetComponents, which is not reliably the newest — cosmetic, and it never feeds the transform mapping (that matches by path sequence) low open, cosmetic
8 Two swx-batch runs against the bridge at once interleave. The bridge serialises each CALL correctly, but a verb that resolves "the active document" then belongs to whichever batch touched it last low not a bridge bug so much as a documented hazard — target every mutating verb explicitly
9 swx_com_eval refused $doc.GetMaterialPropertyName2("") with "Parameter not optional" — the read allowlist is fine, the arity is not low open

4. Fasteners

Drew's instruction, twice repeated and then a third time for the casters: real McMaster-Carr 3-D STEP models, not simplified cylinders, with the head style matched to the SPAX items actually bought at Lowe's, and the caster matched to the YAEMIKY units bought on Amazon. The simplified-cylinder fallback was demoted to a last resort, per item, with a logged reason.

Each STEP is imported with swx_import, given its material and custom properties (including the McMaster part number, head style and drive style) with swx_run_script, measured with swx_inspect_bodies + swx_mass_properties, and saved as a .sldprt into C:\Users\Public\adom-crate\fasteners\.

On orientation. A vendor STEP arrives in the vendor's coordinate system, so nothing about it is assumed. Each model's own bounding box gives the shank axis (the long dimension), and the centre of mass says which END the head is on — a head is heavier per unit length than a shank. Both are read back out of SOLIDWORKS and fed to the placement maths, which then works in the packet's terms: put this screw's head bearing face here, driven that way.

Fastener sourcing — as purchased → McMaster

Every row is a real McMaster product page driven in pup, the CAD selector set to "3-D STEP no threads", and the file downloaded. No fabricated part numbers, no simplified-cylinder fallbacks anywhere in this build. Compromises are named, not buried.

Tag As purchased McMaster P/N Head / drive Match
F-10x3 SPAX #10 x 3″ washer-head T-Star (Lowe's 3753135 / 3753046) 90396A180 Flanged rounded washer head, Torx Head style exact. Size compromise: McMaster's only washer-head Torx wood-screw family (90396A) is No. 8 only, so this is #8 x 3-1/8″, not #10 x 3″. (An exact #10 x 3″ body with the wrong head, 90031A257, was also downloaded as an alternate.)
F-8x125 SPAX #8 x 1¼″ (Lowe's 3753042) 3597N17 Flat countersunk 82°, Torx Exact, head and drive
F-8x200 SPAX #8 x 2″ (Lowe's 3753044) 90031A205 Flat countersunk 82°, Phillips Head exact (0.332″ x 0.1″, 82°). Drive compromise: the Torx flat-head zinc family stops at 1¼″
F-L25x250 SPAX ¼ x 2½″ washer-head (Lowe's 5641068) 95526A175 Flanged washer-face hex head, ext. hex Head style correct — a PowerLag HexWasher head, 0.56″ flange, not a plain hex lag bolt. Drive compromise: external hex vs T-Star; McMaster has no round-washer-head Torx at ¼″
F-L25x300 SPAX ¼ x 3″ PowerLag (Lowe's 3753018) 95526A200 Flanged washer-face hex head, ext. hex Same family as above
F-W10 #10 ¾″ washers (Lowe's 61697) 91090A126 Oversized round flat washer Exact: 0.203″ ID, 0.750″ OD, 0.047″ thick
CASTER-6 YAEMIKY 6″ PU, all swivel + brake, 3,000 lb/set of 4 (Amazon 112-2336638-0154627) 2498T652 Top-plate mount, 4-5/8 x 3¾″ 6″ PU wheel 95A, total-lock brake, overall height 7½″ exactly (the packet's 56½ − 49), 820 lb each vs 750 implied. ⚠ Bolt pattern NOT verified against the YAEMIKY part — Amazon returned HTTP 503 to pup and nbrowser was not used, so 3-5/8 x 3 / 3 x 2-5/8″ is McMaster's standard plate for this class. Measure a real caster before drilling the deck.

Two flags worth repeating out loud, because they are the kind of thing that gets lost between a model and a shop:

  1. F-10x3 is a #8 shank, not #10. The head — which is what Drew specified and what does the work in a washer-head structural screw — is exact. The shank is one gauge light. If pilot-hole geometry matters more than head bearing area, F-10x3-ALT-exact-size.step (90031A257) is the exact #10 x 3″ body with a countersunk head.
  2. The caster's bolt pattern is McMaster-standard, not measured. Everything else about it — mount type, wheel, brake, height, capacity — matches or beats the as-purchased part.

A unit bug worth recording, because it is the kind the generator/read-back split is for: FAST_FRAME's head offsets are millimetres (they are measured out of SOLIDWORKS) while the packet's dimensions are inches, and the first version of the placement maths subtracted one from the other before converting. It put a caster 2.4 metres below the crate. Caught immediately by printing one placement and reading it, not by looking at a picture.


5. How this was driven

Everything went through tools/swx-batch.py (staged call lists run in ONE round trip to the desktop) plus a generator, crate.py, that owns every number. The generator is the single source of truth: it holds the packet's inch dimensions, converts to millimetres once, computes every component's position and rotation, and runs its own interference and extents check before a single COM call is made. SOLIDWORKS is then asked to build exactly what the generator computed, and the model is read back and compared.

That split matters. A build script that computes positions inline and asserts nothing is a script that can be confidently, quietly wrong — which is exactly the failure mode the column-major transform bug had.

A hazard worth recording: two swx-batch runs were briefly in flight at once during the part build. The bridge serialises each call correctly, but a verb that resolves "the active document" belongs to whichever batch touched it last, so the two runs could interleave onto each other's documents. Nothing was lost — the part files are verified against the generator afterwards, one by one, which is precisely the check that catches it — but the lesson is: target every mutating verb explicitly, and never run two batches against one SOLIDWORKS.


6. Where everything is

On Drew's machine, under C:\Users\Public\adom-crate\ — a folder the bridge created, so nothing in this build ever needed the write opt-in:

parts\        P1 P2 P3 P4 P5  R1..R5  B1..B7  FM1..FM4      21 .sldprt
fasteners\    F-10x3 F-8x125 F-8x200 F-L25x250 F-L25x300
              F-W10 CASTER-6                                  7 .sldprt (+ the .step they came from)
_dl_archive\  the original McMaster downloads, untouched
ASM-SIDE-PANEL.sldasm  ASM-END-PANEL.sldasm
ASM-CAP.sldasm         ASM-BASE.sldasm       ASM-CRATE.sldasm
crate-bom.csv          ASM-CRATE.glb         ASM-CRATE.step

In this repo: docs/crate-bom.csv (the same CSV, pulled back) and this log.

And a second, sharper version of the same hazard: killing the local Python that started a batch does not stop it. tools/swx-batch.py stages the call list on the desktop and runs the whole list inside ONE remote PowerShell loop, so the loop keeps POSTing to the bridge long after the process that launched it is gone. Twice in this build a batch that looked dead was still driving SOLIDWORKS. The only reliable signal is that no adom-bridge … shell_execute is in flight.

The recovery from that is worth stating too, because it is the shape of the whole build: the generator holds the truth, so damage is detectable. Every part file is reopened and its bounding box compared against the generator's numbers; a file that does not match is deleted and rebuilt. Nothing is trusted because it was written once.


7. What this proves about the bridge

The headline is not the crate. It is that the whole swx_* assembly surface — swx_add_component, swx_bom, swx_interference, per-component swx_mass_properties — is live-proven on SOLIDWORKS Connected, on the very edition where the parity report had written it off as impossible. The report's evidence was sound; its conclusion was not, and the difference was one assumption: that a COM call which returns Nothing did nothing.

Three things generalise beyond SOLIDWORKS:

  1. Measure the effect, not the return value. AddComponent5 lies about whether it worked, and it lies about where it put the component. Both were caught by asking the model rather than the API.
  2. Keep the truth outside the CAD system. Every dimension, position and rotation in this crate is computed in one generator that also runs its own interference and extents check. SOLIDWORKS is then asked to build it, and the result is read back and compared. That is what turned a silently-transposed rotation matrix and a mixed inch/millimetre subtraction into ten-minute bugs instead of a crate that looks right in a screenshot.
  3. Deny-by-default cost nothing. This entire build — 21 parts, 7 imported fasteners, 5 assemblies, ~383 component instances, a BOM and three exports — ran with SWX_ALLOW_USER_WRITES unset. Every refusal the bridge issued was correct, and every one of them pointed at a targeting mistake on my side.

8. Handoff — exact state, and how to resume

Written so this can be picked up cold.

Driver files live in the session scratchpad (/tmp/claude-1001/-home-adom-project/ca530728-41cc-4f93-a92d-d32d4afe1153/scratchpad/):

File What it is
crate.py the single source of truth. Every packet dimension, the part table, all placements and rotations, the fastener frame maths. Inches in, millimetres out, converted once.
check.py the pre-flight interference + extents check, pure Python, no CAD
gen_asm.py turns crate.py's placements into swx_add_component payloads, chunked 40 per call
build_asm.py builds ONE assembly: new doc → wood → interference + mass → fasteners → save_as
swx/pipeline.py the whole remaining job as sequential, checkpointed stages
swx/import_fasteners.py STEP → part, with the axis/head measured off the model
swx/fspec.json, swx/sourcing.json the fastener spec and the as-purchased → McMaster table
swxb.py tools/swx-batch.py with the Crate Rebuild ai-thread

Resume: python3 swx/pipeline.py cleanup,verify,repair,assemblies,finish. Each stage writes swx/stage_<name>.json and is skipped if that file exists, so a killed run picks up where it stopped. Delete a stage file to force it again.

Two rules that this build learned the hard way, and that a resumer must keep:

  1. Never run two batches against one SOLIDWORKS. They interleave, and any verb that resolves "the active document" then belongs to whichever batch touched it last. Four part files went missing that way.
  2. Killing the local Python does not stop the batch. swx-batch runs the whole call list inside one remote PowerShell loop on the desktop; it keeps driving SOLIDWORKS until the desktop-side timeout. Check that no adom-bridge … shell_execute for this thread is in flight before starting anything new.

9. State at handoff

Geometry verification — measured, first pass

Read back out of each .sldprt FILE (opened, swx_inspect_bodies, closed), against the generator's numbers. 14 of 21 correct; the seven failures are the signature of the concurrent-batch hazard in §5, not of the modelling:

  P1    expect  2438.400  1219.200    11.906  got  2438.400  1219.200    11.906  OK
  P2    expect  2438.400  1219.200    11.906  got  2438.400  1219.200    11.906  OK
  P3    expect  2438.400    11.906  1143.000  got  2438.400    11.906  1143.000  OK
  P4    expect    11.906  1114.425  1143.000  got    11.906  1114.425  1143.000  OK
  P5    expect   304.800    11.906   304.800  got   304.800    11.906   304.800  OK
  R1    expect  2438.400    38.100    44.450  got  2438.400    38.100    44.450  OK
  R2    expect    44.450    38.100  1054.100  got    44.450    38.100  1054.100  OK
  R3    expect    38.100    44.450  1143.000  got    38.100    44.450  1143.000  OK
  R4    expect    38.100  1025.525    44.450  got    38.100  1114.425    88.900  MISMATCH
  R5    expect    44.450  1130.300    38.100  got    88.900  1114.425    38.100  MISMATCH
  B1    expect  2438.400    88.900    38.100  got  2438.400    88.900    38.100  OK
  B2    expect    88.900  1041.400    38.100  got    88.900  1041.400    38.100  OK
  B3    expect    88.900  1041.400    38.100  got    88.900  1041.400    38.100  OK
  B4    expect   254.000    88.900    38.100  got            (file absent)       MISSING
  B5    expect    38.100  1114.425    88.900  got    38.100  1114.425    88.900  OK
  B6    expect    88.900  1114.425    38.100  got    88.900  1114.425    38.100  OK
  B7    expect   304.800    88.900    38.100  got  2438.400    88.900    38.100  MISMATCH
  FM1   expect  2235.200   101.600    50.800  got    88.900  1041.400    38.100  MISMATCH
  FM2   expect    88.900  1114.425    25.400  got    88.900  1041.400    38.100  MISMATCH
  FM3   expect   304.800    50.800   304.800  got   254.000    88.900    38.100  MISMATCH
  FM4   expect    50.800   609.600   609.600  got    50.800   609.600   609.600  OK
  verified OK: 14/21   BAD: R4,R5,B4,B7,FM1,FM2,FM3

FM3 holding B4's box (254 x 88.9 x 38.1) is the tell: two batches were in flight, an untargeted save_as resolved to the other batch's active document, and one part's geometry went to another part's filename. Every one of these is detectable and repairable precisely because the generator, not the CAD file, holds the truth.

Fasteners — all seven imported and measured

Tag McMaster measured (mm) in inches axis / head
F-10x3 90396A180 81.662 x 10.922 3.215 x 0.430 +X, head at −X
F-8x125 3597N17 31.755 x 8.432 1.250 x 0.332 +X, head at −X
F-8x200 90031A205 50.800 x 8.432 2.000 x 0.332 +X, head at −X
F-L25x250 95526A175 70.622 x 14.224 2.780 x 0.560 +X, head at −X
F-L25x300 95526A200 83.322 x 14.224 3.280 x 0.560 +X, head at −X
F-W10 91090A126 19.05 x 19.05 x 1.194 0.750 OD x 0.047 −Z (disc)
CASTER-6 2498T652 190.5 tall, 235.3 x 95.3 7.500 mount height −Y, plate top at +3.75″

Every one matches its product page. The caster's 7.500″ mount height is an independent confirmation of the packet's own arithmetic (56½″ on casters − 49″ box). It imports as 57 solid bodies with a 235 mm X-span (swivel trail plus brake pedal), so the "longest bounding-box axis is the shank" heuristic that works for screws would have picked the wrong axis — its frame is set explicitly from the measured plate face instead.

What remains

  1. Repair 7 parts — R4, R5, B4, B7, FM1, FM2, FM3. The recipe that works is close the document by path FIRST, then delete, then rebuild, then swx_save_as {targetDocument: <the title run_script just made>}. The first repair attempt deleted without closing; SOLIDWORKS still held the file, the delete silently failed, and save_as correctly refused an existing destination. swx/repair2.py has the corrected sequence.
  2. Close the ~30 open documents. Every COM call gets slower as they pile up; this is the single biggest speed win available. swx_close_doc {path} per file, or swx_reset_workspace for the unsaved ones.
  3. Build the five assemblies — python3 swx/pipeline.py assemblies. 383 component instances, chunked 40 per swx_add_component call. The wood goes in first and swx_interference runs on wood only (fasteners are meant to be embedded, so an interference count taken after them means nothing).
  4. BOM + exports — python3 swx/pipeline.py finish: swx_bom {format:"csv"} to crate-bom.csv, GLB, STEP, swx_verb_times, swx_action_ledger and a swx_state screenshot. Then pull the CSV back to docs/crate-bom.csv.

Expected BOM, for cross-checking whatever comes out of SOLIDWORKS

Flattened (topLevelOnly:false), one crate:

P1 1  P2 1  P3 2  P4 2  P5 4
R1 6  R2 12  R3 4  R4 4  R5 5
B1 2  B2 2  B3 3  B4 4  B5 2  B6 2  B7 8
FM1 2  FM2 2  FM3 4  FM4 2
CASTER-6 6
F-10x3 108   F-8x125 197   F-8x200 49
F-L25x250 24   F-L25x300 8   F-W10 8

Every wood and foam quantity is the packet's own cut list (C1/C2). The fastener counts fall out of the packet's spacing rules: #10 x 3″ at 8″ o.c. on the four corner columns (24), the floor ring (34) and the cap ring (34), plus 4 hold-down bar ends and 12 side-station screws = 108; #8 x 1¼″ ply-to-frame at 8″ o.c. on both side panels (49 each), both ends (25 each) and the cap (49) = 197; #8 x 2″ deck-to-ring = 49; four ¼ x 2½″ lags per caster plate = 24; four ¼ x 3″ PowerLags plus four washers per handle = 8 + 8.

swx/checkbom.py compares the exported CSV against exactly these numbers.

The running chain, at the moment of handoff

swx/finish_all.sh is running detached in the container (pid 1563686), last stage marker: == start 18:25:10. It runs, in order:

pipeline.py cleanup      # close the ~30 documents that are slowing every COM call
repair2.py R4,R5,B4,B7,FM1,FM2,FM3   # close -> delete -> rebuild -> targeted save_as
pipeline.py verify       # re-read every part FILE against the generator
pipeline.py assemblies   # 5 assemblies, 383 component instances
pipeline.py finish       # BOM csv + GLB + STEP + verb timings + ledger + screenshot
adom-bridge pull_file    # crate-bom.csv -> docs/crate-bom.csv

Its log is swx/final.log. Every stage is checkpointed to swx/stage_<name>.json, so if the chain is interrupted, re-running the same commands resumes rather than restarts. Before starting anything, check that no adom-bridge … shell_execute for the Crate Rebuild thread is in flight (/tmp/swx_busy.sh) — a batch that looks dead is often still driving SOLIDWORKS from the desktop side.

Performance, measured so far, for the bridge.json budgets: a STEP import plus material, properties, measurement and save ran 2–3 minutes per fastener warm (the first was ~16 min under contention with a stale batch); a part build (new_part → sketch → extrude → material → 6 properties → save_as → close) ran 35–60 s; swx_add_component measured ~1.2 s per component on the 2-component and 3-component live probes, including the transform write. The 383-instance run is the number worth capturing — swx_verb_times is queried in the finish stage precisely so the real p50/p90 lands in the record rather than an estimate.


10. Finisher session — what the handoff did not know

The handoff's plan was sound and its diagnosis of the seven bad parts was exactly right. What it could not know is that three more bridge defects sat between the parts and a finished crate, and that two of them fail by returning a confident wrong answer rather than an error.

10.1 The repair could not be done as written

swx_close_doc refused all 23 open crate documents — "which this bridge did not create". The bridge had been restarted mid-build, and its scratch registry is memory-only (bug #5), so it had disowned its own documents. Six of the seven bad parts were open, so Windows would not let their files be deleted either, and the handoff's close → delete → rebuild recipe could not run at all. Both refusals were CORRECT; the fix was to stop fighting them.

swx/repair_newpath.py rebuilds each bad part to a NEW path (parts\<TAG>-fix.sldprt) — a write the bridge permits — and gen_asm.py resolves parts through swx/pathmap.json. PartNo stays the tag, so a BOM still aggregates under R4, not R4-fix. All seven rebuilt and read back correct:

R4 OK  R5 OK  B4 OK  B7 OK  FM1 OK  FM2 OK  FM3 OK      21/21 verified

10.2 Bug #10 — swx_add_component could not identify its own insertions

The worst kind of bug: it reports success and silently builds the wrong model.

The verb located the components it had just inserted by matching the request against GetComponents as a CONTIGUOUS RUN of file paths. same_path matches ANY instance of a file, and GetComponents returns neither insertion order nor file-grouped order. So:

  • a chunk containing a repeated part could never match → locate_run returned None → no transforms written at all, while the response still said success:true, insertedCount:8. Every component sat where AddComponent5 dropped it (centred on the requested point), which looks like a built assembly and is a pile at the origin.
  • inserting one at a time was no better: the new component's transform could be written onto an OLDER instance of the same file, overwriting a good placement.

Measured on the side panel (R1×2, R2×5, P3×1): R2 instances 1 and 2 landed exactly; 3, 4 and 5 carried the "centred on the requested point" signature (Δy = −19.05 = −ly/2, Δz = +527.05 = +lz/2). R1 and P3 — no repeats ahead of them — were untouched.

Two tests disagreed, and believing the clean one would have shipped a wrong crate: three R2s alone place perfectly, because when every instance is the same file the transforms are still distributed one per instance and the resulting SET of positions is correct. It only breaks in a mixed assembly.

Fixed in 0.3.2 (cmd_add_component + apply_transforms): snapshot component NAMES before inserting — a name is an identity, SOLIDWORKS never reissues one in a document — and write only to components whose name is new, assigning each request to a new component of the matching file greedily (exact, because same-file instances are interchangeable). The old contiguous-run match stays as a fallback for when names cannot be read.

Proof, against a centre of mass predicted independently in Python:

predicted  1219.200    35.522   571.500
measured   1219.200    35.522   571.500
delta         0.000     0.000     0.000   -> MATCH      (placed 8/8, interferences 0)

10.3 Bug #11 — swx_docs returned an empty list with success:true

swx_docs reported ZERO open documents while 51 were open. Twice, three minutes apart, with GetDocumentCount, swx_status.com.openDocs and swx_state.openDocs all still saying 51 and SOLIDWORKS never restarted (pid 88556, up since 11:14:55).

Mechanism: a busy SOLIDWORKS returns a null/empty VARIANT from GetDocuments rather than an error, and variant.rs:228's as_array() turns a null variant into an empty Vec. Nothing downstream could tell that apart from "nothing is open".

This one defect produced both of the errors that killed the assemblies, for a document created seconds earlier:

swx_add_component: no open document is titled 'Assem24'          (resolve-by-title)
swx_add_component would modify 'Assem26 (unsaved)', which this
bridge did not create                                            (ownership gate)

It is almost certainly also the phantom file not found: ...\B4.sldprt for a file the directory listing showed at 242,860 bytes.

Fixed in 0.3.3, three parts:

  1. Retry-on-reject at the COM layer. SOLIDWORKS is an STA server with no message filter: mid-rebuild it REJECTS calls. invoke now wraps invoke_once and backs off 100ms × 2ⁿ (≤5s) on RPC_E_CALL_REJECTED / RPC_E_SERVERCALL_RETRYLATER. It wraps every call, so OpenDoc6 gets it too.
  2. documents() never returns a contradicted empty — it cross-checks GetDocumentCount, retries, and raises docs_enumeration_failed with the count instead of a plausible zero.
  3. resolve-by-title uses it, so a failed enumeration can never be reported as "document not open".

Gate (swx/smoke_docs.py, 50 reads interleaved with swx_status):

reads=50  ok=39 (every one returned exactly 51; min=51 max=51)
errored=11 (honest "com host did not answer 'docs' within 30s")
independent count=[51,51,51,51,51]   under-reporting reads: 0   PASS

An honest failure is acceptable; a silent empty is not.

10.4 Bug #5, promoted to must-fix — ownership does not survive a respawn

Observed directly, and worse than "across a bridge restart": on a freshly started server, ASM-BASE placed all 18 wood parts, the long swx_interference call overran the com host's answer budget, the host was KILLED and respawned, and every call after it refused with "would modify 'Assem27 (unsaved)', which this bridge did not create" — an assembly the bridge had created ninety seconds earlier. hostPid changed (100940). The build died holding a document it could no longer save.

The server's ownership check fails CLOSED when the session cannot be read, which turns "I cannot tell" into "not ours" — the same shape as the two bugs above. Ownership must persist across a com-host respawn, and an undeterminable session must be an error, not a denial.

Mitigation used here, not a fix: chunks of 12, and swx_interference moved to after the assembly is saved, so its failure costs a report rather than the build.

10.5 The lesson these three share: an unchecked empty/default becomes a fact

Four instances in one session, three in the bridge and one in the driver:

Where The empty/default The false conclusion
variant.rs:228 as_array() null VARIANT → empty Vec "no documents are open" (51 were)
owns_doc unreadable session → false "this bridge did not create it" (it had)
locate_run → placedCount:0 no match → write nothing success:true on an unplaced assembly
my classify_docs.py res.get("docs") or [] "TOTAL 0" for a failed call

In every case the code had enough information to say "I do not know" and instead said something definite and wrong. The documents() guard, the docs_enumeration_failed code, and printing success/error beside every count are all the same correction.

10.6 State at the end of this session

Bridge: 0.3.3 built, cross-compiled, 118 tests green, deployed and verified by installed byte count (1,411,584) and swx_status.bridgeVersion, in the DEFAULT deny-by-default posture (userWrites: disabled). SWX_ALLOW_USER_WRITES was set for one scoped cleanup attempt and removed again immediately; no user document was touched, and no close_all was ever run.

Assemblies — 3 of 5 built, saved and clean:

Assembly Wood Fasteners Interferences Bytes
ASM-SIDE-PANEL 8/8 49/49 0 282,905
ASM-END-PANEL 7/7 33/33 0 229,075
ASM-CAP 8/8 49/49 0 249,161

ASM-BASE (91 instances) and ASM-CRATE (138) are NOT built, and the BOM, GLB and STEP exports depend on ASM-CRATE.

The blocker is not a bridge bug. SOLIDWORKS can no longer create ANY document:

assembly template -> "SOLIDWORKS created no document from ...Assembly.asmdot"  (x3)
part     template -> "SOLIDWORKS created no document from ...Part.prtdot"
sldworks pid=88556  privMB=17465  handles=21770  threads=1420
free 5,966 MB of 32,173 MB      52 documents open

Both templates fail, so it is resource exhaustion, not a template fault — 52 open documents, most of them orphaned by com-host respawns and un-closable because the bridge disowns them (bug #5 again, now compounding). Closing them individually needs the write opt-in, which is a decision for Drew, not for an agent: it is his live SOLIDWORKS.

Part1 is open and DIRTY. It is not in C:\Users\Public\adom-crate\ and the bridge has never claimed it (swx_reset_workspace skipped it every time while closing Part48, Part57–66, Assem14–17). It may be Drew's. **It must not be closed by anyone but him**, and no close_all can be used while it is open, because closeUnsaved:true would discard it without a prompt.

10.7 Final state — SOLIDWORKS alive but COM-unreachable

The last probe (22:14) closes the picture:

RUNNING pid=88556                     <- SOLIDWORKS is still up
swx_open -> "SOLIDWORKS is not running, and this edition
             cannot be started programmatically"          <- the bridge can no longer ATTACH

So the process survives while COM attach fails: consistent with the exhaustion above (17.4 GB private, 21,770 handles, 1,420 threads), and the end of what any bridge fix can reach. SOLIDWORKS was never stopped, killed or foregrounded by this session — no swx_stop, no swx_kill, no process_kill against sldworks.exe. Only swx-bridge.exe was ever restarted.

The remedy is a SOLIDWORKS restart, which is Drew's call, not an agent's — his session, and Part1 (unsaved, dirty, never claimed by the bridge) is still in it.

On disk in C:\Users\Public\adom-crate\, all verified present:

parts\        28 .sldprt   (21 original + the 7 <TAG>-fix rebuilds)
fasteners\     7 .sldprt   (the McMaster STEP imports)
ASM-SIDE-PANEL.sldasm  282,905    ASM-END-PANEL.sldasm  229,075
ASM-CAP.sldasm         249,161

To resume after a SOLIDWORKS restart (the working set will be empty, which is also the fix for the exhaustion):

python3 swx/pipeline.py assemblies     # skips the 3 built, does ASM-BASE + ASM-CRATE
python3 swx/pipeline.py finish         # BOM csv + GLB + STEP + timings + ledger + state
python3 swx/checkbom.py docs/crate-bom.csv

Everything those need is already in place: bridge 0.3.3 with both placement and enumeration fixed, all 21 parts geometry-verified through swx/pathmap.json, the fastener frames measured, and build_asm.py chunking at 12 with fail-fast and a verified save before any checkpoint.


11. Finished — the crate, and the BOM out of SOLIDWORKS

Drew restarted SOLIDWORKS from the Start Menu, which cleared the exhaustion that §10.6 ended on. The bridge (0.3.3, default posture) re-attached to an empty session and the remaining two assemblies went in without a single failed chunk.

before:  privMB=523    handles=2810    openDocs=0
after:   privMB=13117  handles=7906    openDocs=33

The memory climb is inherent to the workload, not a leak introduced here: 480 leaf component instances, six 57-body casters among them. It is also the reason the first attempt died — see §10.6 — and the reason a long build wants a fresh session.

All five assemblies, built and saved

Assembly Wood Fasteners Instances Bytes
ASM-SIDE-PANEL 8/8 49/49 57 282,905
ASM-END-PANEL 7/7 33/33 40 229,075
ASM-CAP 8/8 49/49 57 249,161
ASM-BASE 18/18 73/73 91 4,546,131
ASM-CRATE 30/30 108/108 138 4,843,916

383 component instances inserted, every one placed — placed == inserted on all 34 chunks, which is the check that would have caught the silent bug #10. ASM-CRATE masses 229.02 kg (505 lb).

The BOM, exported from the assembly

swx_bom {path: ASM-CRATE.sldasm, format:"csv", out:…, topLevelOnly:false} → 32 rows, in the repo as docs/crate-bom.csv. Columns are item,name,configuration,quantity,file plus one column per custom property found (PartNo, Material, McMasterPN, HeadStyle, DriveStyle, AsPurchased, Description, Size, QtyPerCrate, Source, …).

BOM rows: 32   leaf part instances: 474   sub-assembly instances: 6   total: 480

swx/checkbom.py against the packet's own cut lists (§9) — every part and every fastener exact:

P1 1  P2 1  P3 2  P4 2  P5 4     R1 6  R2 12  R3 4  R4 4  R5 5
B1 2  B2 2  B3 3  B4 4  B5 2  B6 2  B7 8     FM1 2  FM2 2  FM3 4  FM4 2
CASTER-6 6
F-10x3 108   F-8x125 197   F-8x200 49   F-L25x250 24   F-L25x300 8   F-W10 8
                                          28 of 28 part numbers match

The four rows checkbom flags as "mismatches" are ASM-BASE, ASM-CAP, ASM-END-PANEL and ASM-SIDE-PANEL themselves — the nested sub-assemblies, which a flattened BOM lists and the packet's parts-and-fasteners expectation does not. They are correct, not errors.

The repaired parts aggregate under their real numbers. R4, R5, B4, B7, FM1, FM2 and FM3 live on disk as <TAG>-fix.sldprt, and the BOM still reads R4 4, B7 8, FM3 4 — because PartNo is the identity, not the filename.

Exports

ASM-CRATE.glb    183,436,448 bytes
ASM-CRATE.step     6,525,584 bytes

Measured performance, at scale

swx_verb_times over the whole build (seconds):

Verb Calls p50 p90 max
swx_add_component 50 15.58 51.13 247.0
swx_run_script 50 13.84 26.21 39.03
swx_save_as 44 3.68 7.95 20.41
swx_new_document 46 3.99 7.86 11.07
swx_import 10 4.78 7.82 12.34

swx_add_component's 247.0s max against the com host's 252s answer budget is the whole story of §10.4 in one number. That call came within five seconds of getting the host killed; the ones that did overrun cost an assembly each. Chunks of 12 keep p90 at 51s, comfortably inside it. For the bridge.json budgets: insertion costs roughly 1.2s per component plus a per-call overhead that grows with the assembly, so cost is driven by CHUNK COUNT as much as component count.

Two caveats recorded rather than buried

  1. The interference numbers are not comparable across the five. For the first three, swx_interference ran on the WOOD ONLY and returned 0 — the number that means something, since fasteners are meant to be embedded. For ASM-BASE and ASM-CRATE the check was moved to after the save (§10.4) so a slow call could not kill the build, which means it ran with the fasteners in: ASM-BASE reported 170, which is fastener embedment, and ASM-CRATE's timed out entirely. Neither is evidence of a wood clash; neither is evidence against one.
  2. No screenshots. swx_state's capture needs ADOM_DIRECT_API_URL, which Adom Bridge supplies when it spawns a bridge itself. This bridge was started by the dev loop's launch_process, so the variable is absent and every capture returns {"success": false, "error": "ADOM_DIRECT_API_URL not in env"}. A dev-loop bridge cannot take pictures — worth knowing before relying on one for visual verification.

Hardware sourcing — the two compromises stand, flagged

Unchanged from §4 and still not silently accepted: F-10x3 (90396A180) is a #8 shank, not #10 — the washer head and Torx drive are exact, the shank is one gauge light — and the CASTER-6 (2498T652) bolt pattern is McMaster-standard and NOT verified against the YAEMIKY part. Measure a real caster before drilling the deck. F-8x200 and F-L25x250/300 carry drive compromises (Phillips, external hex) for the same reason: no Torx in those families at size.