SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
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::AddComponent5returnedNothingfor 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:
F-10x3is 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.- 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:
- Measure the effect, not the return value.
AddComponent5lies about whether it worked, and it lies about where it put the component. Both were caught by asking the model rather than the API. - 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.
- 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_WRITESunset. 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:
- 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.
- Killing the local Python does not stop the batch.
swx-batchruns the whole call list inside one remote PowerShell loop on the desktop; it keeps driving SOLIDWORKS until the desktop-side timeout. Check that noadom-bridge … shell_executefor 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
- 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, thenswx_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, andsave_ascorrectly refused an existing destination.swx/repair2.pyhas the corrected sequence. - 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, orswx_reset_workspacefor the unsaved ones. - Build the five assemblies —
python3 swx/pipeline.py assemblies. 383 component instances, chunked 40 perswx_add_componentcall. The wood goes in first andswx_interferenceruns on wood only (fasteners are meant to be embedded, so an interference count taken after them means nothing). - BOM + exports —
python3 swx/pipeline.py finish:swx_bom {format:"csv"}tocrate-bom.csv, GLB, STEP,swx_verb_times,swx_action_ledgerand aswx_statescreenshot. Then pull the CSV back todocs/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_runreturnedNone→ no transforms written at all, while the response still saidsuccess:true, insertedCount:8. Every component sat whereAddComponent5dropped 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:
- Retry-on-reject at the COM layer. SOLIDWORKS is an STA server with no
message filter: mid-rebuild it REJECTS calls.
invokenow wrapsinvoke_onceand backs off 100ms × 2ⁿ (≤5s) onRPC_E_CALL_REJECTED/RPC_E_SERVERCALL_RETRYLATER. It wraps every call, soOpenDoc6gets it too. documents()never returns a contradicted empty — it cross-checksGetDocumentCount, retries, and raisesdocs_enumeration_failedwith the count instead of a plausible zero.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
- The interference numbers are not comparable across the five. For the first
three,
swx_interferenceran on the WOOD ONLY and returned 0 — the number that means something, since fasteners are meant to be embedded. ForASM-BASEandASM-CRATEthe 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-BASEreported 170, which is fastener embedment, andASM-CRATE's timed out entirely. Neither is evidence of a wood clash; neither is evidence against one. - No screenshots.
swx_state's capture needsADOM_DIRECT_API_URL, which Adom Bridge supplies when it spawns a bridge itself. This bridge was started by the dev loop'slaunch_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.
# 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_prop`s), 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.