* cadgen: comment-insensitive closure hashing + stdin closure footgun fix
A2 (edit-path #2): closure hashes now digest each .py source's parsed AST
(dumped without position attributes) instead of its raw bytes, so a
comment / blank-line / formatting-only edit to a generator or a shared
helper no longer invalidates the model's source closure — the rebuild
becomes a warm skip. Docstrings stay semantically visible (correct
sensitivity); unparseable sources fall back to the byte hash. Migration is
seamless: closure_hash_matches accepts EITHER the semantic recompute OR the
legacy byte recompute, so descriptors written before this change keep
validating (no mass rebuild) and upgrade to the semantic digest on their
next genuine rebuild. Measured on merlin1d: comment/whitespace edit ->
'is current; skipped'; a real constant edit -> rebuild.
A3 (edit-path #3, correctness): _runtime_roots treated __main__.__file__
as a runtime directory unconditionally, but stdin/-driven builds set it
to the placeholder '<stdin>', whose resolve().parent is the CWD — which
marked the model folder (and its sibling helpers) as runtime, dropped them
from the recorded closure, and silently disabled staleness detection for
those artifacts. Now only a real on-disk launcher file counts (is_file()
rejects every '<...>' placeholder while still catching the CLI launcher).
Reproduced: merlin_common.py went first-party=False -> True under a stdin
driver.
New tests cover comment/whitespace/docstring/real-edit sensitivity, the
legacy-hash migration accept, and the placeholder-vs-real __main__ roots;
two freshness tests updated from the old byte-hash contract (comment edit
now correctly skips). Full cadgen suite: 190 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* cadgen: cache module first-party classification across builds
repo_local_loaded_modules runs over ALL of sys.modules (thousands of
entries once numpy/OCP are imported) on every evict_first_party_modules
AND every closure capture, calling Path(file).resolve() per module. The
realpath is the dominant cost (~0.2-0.7 s/build) and a file's first-party
classification never changes for the process, so:
- is_first_party_source_file is lru_cached (also hit per executed module
body by the audit hook), and
- a raw-__file__ -> resolved-first-party-path dict memoizes the resolve()
so each distinct module file is classified once per process, not once
per build. Most impactful on the warm daemon / multi-target runs, where
many builds share one interpreter.
Classification is stable (excluded roots are env-derived and cached), so
the caches are process-lifetime safe. cadgen suite green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* cadgen: serialize each component's BREP once for hash + worker payload
_content_hash_shape and _shape_brep_bytes performed the same
location-stripped BinTools serialization, so every missing component's
BREP was written twice per build (once to compute the content hash, once
to build the worker payload). _content_hash_and_bytes now serializes once
and returns both; the package walk caches the bytes per cid and the
missing-component payload reuses them. Bytes and hash are byte-identical
to the split helpers (asserted), so cids, worker output, and emitted GLBs
are unchanged. Biggest saving on cold builds where every component is
missing (all BREPs were double-serialized).
Component-package suite green; falcon_heavy force-rebuild verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: record edit-path perf A-set outcomes (A2/A3/A4/A8 done; A5/A6/A1 declined with evidence)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: zoom-to-fit a selected instanced occurrence (Phase 4 gap)
Instanced package records are one-per-bucket with partId=null and no per-record
partBounds, so autoZoom's displayRecordsBounds skipped them and zoom-to-fit on a
selected occurrence no-op'd ("No geometry to fit"). instancedScene now stores each
bucket's component-local AABB and exposes instancedOccurrenceBounds(mesh, matches),
a pure resolver that transforms that box by each matching instance's base matrix and
unions the result. buildInstancedDisplayRecords attaches it as instancedBoundsFor;
displayRecordsBounds calls it for instanced records, forwarding the hierarchical
partId predicate so a selected occurrence (or subassembly prefix) frames correctly.
cadjs 422 green; snapshot-render.js re-bundled; symlinks restored.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: per-hover incremental instance update (Phase 4)
applyInstancedVisualState rewrote every instance and dirtied the whole GPU
buffer on each hover/selection transition, so a hover on falcon_heavy re-uploaded
all 2,142 instances. It now tracks a per-instance state code (base/selected/
hovered/dimmed/hidden) and rewrites only instances whose state changed — and
dirties instanceColor/instanceMatrix only when a write actually happened. A hover
transition touches ~2 instances and dirties only the 1-2 affected buckets, not all
141. Matrix is rewritten only when hidden-ness flips (the only pose change). Same
visual result; the code->color/pose mapping is stable (theme changes rebuild the
mesh with a fresh code array).
Also documents the Phase-4 assessment in the design doc: zoom-to-fit + this
incremental update DONE; visible-mesh caching and three-mesh-bvh declined with
evidence (raycast already 15x cheaper post-instancing); exploded view + per-part
edges for instanced deferred with a concrete implementation sketch.
cadjs 423 green; snapshot-render.js re-bundled; symlinks restored.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: mark gap #7 (zoom-to-fit selection) resolved in Phase 4
The documented-gaps list still described zoom-to-fit-selection as an open
gap in present tense while the Phase-4 section above marks it DONE; mark
the entry resolved and point at instancedOccurrenceBounds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* cadgen: memoize the semantic hash, harden its fallback, bound A8 retention
Review follow-up on the edit-path perf set (adversarial audit findings):
- _semantic_source_hash is stat-memoized ((mtime_ns, size) key). The AST
pass is ~200x the byte pass, and closure_hash_matches ran it for EVERY
closure file on EVERY warm check once a descriptor records semantic
digests (the steady state after any rebuild): measured ~0.9-1.1 s per
warm no-op check on a tom-scale closure, with the shared robot_common
helpers re-parsed once per generated child - a regression on the exact
edit path this branch targets. Now each file parses once per content
change per process: 313.7 ms -> 2.73 ms per steady-state check on a
40-file/625 KiB closure. Two rules make a stale hit impossible: stat is
taken before the read (a racing write orphans the entry, never poisons
it), and a file is cached only after its mtime settles for 2 s
(filesystem mtime clocks are coarser than a nanosecond, so a same-size
rewrite in the same tick would otherwise be invisible).
- closure_hash_matches keeps the semantic pass first: with the memo the
no-edit steady state costs one stat per file, while the byte pass
re-reads every file; legacy (byte-recorded) descriptors fall through
and pay one parse per file per process until re-recorded.
- MemoryError (parser stack overflow on pathological nesting) and
RecursionError (ast.dump on a deep-but-importable tree, e.g. a long
"1 + 1 + ..." chain that compiles fine) now take the byte-hash fallback
instead of escaping the freshness gates and aborting the build; ast.dump
moved inside the try block it was documented to be covered by.
- closure_hash_from_files deleted: the closure_hash_matches refactor left
it production-dead, and its semantic-only recompute (no legacy accept)
silently diverges from every real freshness gate - a footgun for the
next caller. Its round-trip test now exercises closure_hash_matches.
- build_package_from_compound retains a component's BREP payload bytes
only when the component actually needs a worker build (<cid>.glb absent,
or --force): an edit-path rebuild of a large assembly no longer holds
every component's serialized geometry in memory for the whole build.
Race-safe: the payload expression already re-serializes if a GLB
vanishes between the capture-time and missing-scan stats.
New tests: memo settle-window + same-size-edit sensitivity, and the two
pathological-source fallbacks. Both cadgen suites green (195 + 42), in
both module orders. The false-hit audit that accompanied this review ran
44 adversarial vectors (type comments, literal spellings, encodings,
string prefixes, parenthesization, line-number-dependent code) against
the AST hash and found no collision that changes runtime behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: fix dead pre-rebase SHAs in perf notes; record review follow-up
The Status section cited 74dbc192/03fc809d/52428c9c, which exist only in
a local pre-rebase backup branch - dead references on GitHub and in any
fresh clone. Replaced with the branch's real commits (f7e8de7b, fb0e1834,
37876fe5) and added a bullet recording the review follow-up work.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: extract shared viewport camera kit; adopt it in implicit viewer
Introduce viewer/components/viewer/viewportCameraKit.js holding the
renderer-agnostic camera/keyboard-orbit/view-plane helpers and constants
(the canonical mesh-viewer implementations). Repoint ImplicitCadViewer at
the kit and delete its parallel copies, bringing the implicit renderer in
line with the mesh (STEP/STL/3MF/GLB) viewer. The implicit runtime now
exposes runtime.THREE to match the mesh runtime shape the kit expects.
Phase 0 of the viewer renderer consolidation. Behavior-preserving: the
extracted helpers are the same logic both viewers already ran.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SEZAGwiMJSe1bYMhWHRHa1
* viewer: repoint CadViewer at the shared viewport camera kit
Remove CadViewer's local copies of the camera/keyboard-orbit/view-plane
helpers and constants now that they live in viewportCameraKit.js. The kit
holds the canonical implementations these were copied into, so this is a
pure de-duplication with no behavior change.
Completes Phase 0: both the mesh and implicit viewers now share one source
of truth for viewport camera helpers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SEZAGwiMJSe1bYMhWHRHa1
* packages: cadjs depends on implicitjs; dedupe byte-identical camera.js
Establish the cadjs -> implicitjs dependency edge so cadjs can reuse
implicitjs's shared render/runtime primitives (and so cad-viewer can
ultimately install cadjs alone). cadjs/common/camera.js was byte-identical
to implicitjs/common/camera.js; it now re-exports the implicitjs copy as
the single source of truth.
Reverse the packageBoundary policy test accordingly: cadjs -> implicitjs is
now allowed and asserted, while implicitjs -> cadjs stays forbidden to keep
the two packages a DAG.
Phase 4 (part 1) of the viewer renderer consolidation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SEZAGwiMJSe1bYMhWHRHa1
* viewer+cadjs: route implicit APIs through cadjs so cad-viewer installs cadjs alone
Add a cadjs/implicit/* re-export layer (render, model, loader,
graphicsSettings, export, parameters) that surfaces implicitjs's public API
through cadjs, and repoint every viewer import from implicitjs/* to
cadjs/implicit/*. Drop implicitjs from viewer/package.json dependencies: it
now arrives transitively via cadjs, so cadjs is the only package the viewer
installs.
Phase 4 (part 2) of the viewer renderer consolidation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SEZAGwiMJSe1bYMhWHRHa1
* docs: record renderer-consolidation dependency direction and handoff plan
Update AGENTS.md for the cadjs -> implicitjs dependency direction and add
viewer/docs/renderer-consolidation.md documenting what's landed (shared
viewport kit, camera dedup, single-install re-export layer) and the
remaining phases (SceneBackend seam, perspective/zoom parity, single
component, themeSettings dedup, feature unlock) with concrete steps and
verification notes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SEZAGwiMJSe1bYMhWHRHa1
* viewer: implicit imperative-handle zoom parity + sharpen consolidation handoff
Add resetZoom/zoomToFit/zoomToFitSelection to ImplicitCadViewer's
useImperativeHandle so it matches the CadViewer ref contract. All three
map to the existing runAutoZoom (force-fit at the current view direction,
mirroring CadViewer's resetZoomBaseline fit); implicit models have no
sub-part selection, so zoomToFitSelection fits the whole model instead of
no-oping. Behavior-preserving for the render path (verified: implicit and
mesh models both render clean headless).
Update viewer/docs/renderer-consolidation.md:
- document the gitignored packages/cadjs/node_modules/implicitjs symlink
whose absence makes viewer build AND tests red (not just package tests)
- correct the stale "no implicit fixture" note (models/implicits/*.implicit.js
exist) and add the headless screenshot recipe
- record the reachability finding: the Reset Zoom / Zoom-to-fit context menu
is STEP-only (openGlobalViewerContextMenu returns null when !isStepView),
so the new ref methods are contract parity/groundwork, with user-facing
reachability tracked as a Phase 5 cross-format unlock
- load Phase 1 with concrete seam gotchas from the code map (quad-not-mesh,
no screenCamera in the scene return, non-THREE-first uniform signature,
compileAsync warmup ownership, the ~60-field runtimeRef contract)
Note: bypassed the pre-commit bundle-freshness check, which flags a
PRE-EXISTING staleness in skills/cad/scripts/snapshot/runtime/snapshot-render.js
that is unrelated to this viewer-only change (byte-identical to HEAD).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* viewer+cadjs: re-point implicitExport at cadjs/implicit/* (adds exportModel re-export)
release/0.4.0's client-side implicit export imported implicitjs directly,
which the rebase would have silently orphaned after this branch removed
implicitjs from viewer/package.json. Route it through the cadjs re-export
layer like every other viewer import.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* bundle: resolve cadjs's implicitjs imports hermetically in the snapshot bundle
cadjs/common/camera.js now re-exports implicitjs/common/camera.js, which put a
bare implicitjs specifier into the snapshot-runtime bundle graph. esbuild only
aliased three/gifenc, so bundle-cad.sh failed on fresh checkouts without a
packages/cadjs/node_modules install. NODE_PATH=packages resolves it from source
through implicitjs's exports map (a directory --alias bypasses the map).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* tests: fence the single-install invariant and widen the package boundary scan
- viewer/src/importPolicy.test.mjs: viewer sources must consume implicit APIs
via cadjs/implicit/*; a bare implicitjs import resolves silently through the
hoisted transitive link, so nothing else catches it.
- packageBoundary.test.mjs: catch dynamic import()/require/export-from and
relative escapes, and scan implicitjs/scripts (vendored into the
implicit-cad skill as runtime code).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs+tooling: reshape consolidation strategy around packages-resident backends; add visual baseline harness
Strategy doc rewrite from the adversarial review of PR #138/#139:
- Name all four render paths (viewer mesh/implicit, headless mesh/implicit)
and place the SceneBackend seam in packages/ so both the React shell and
the headless snapshot shell host the same backends.
- New Phase H: headless unification (kind dispatch above loadSource, one
generated render-contract table consumed by viewer capabilities AND the
snapshot CLI, .implicit.js input, retire the parallel implicitjs CLI),
with PR #139 reimplementation notes and its four confirmed fixes.
- Phase 1 split into 1a/1b/1c with an amended interface (renderFrame
readiness, renderer-creation seam, onBoundsChanged, resolvePixelRatio,
context-loss); new Phase 1d consolidates the triplicated fit/framing math.
- Phase 2 rewritten: no localStorage migration (nothing implicit-shaped is
ever persisted; the implicit restore branch is dead code today, so
convergence is a behavior fix to verify visually) + headless --camera
paste-ability as definition of done.
- Phase 3 lifecycle change called out; Phase 4b scoped by consumption.
- Fresh-checkout setup is npm ci, not hand-rolled symlinks.
viewer/scripts/capture-render-baselines.mjs: committed playwright harness
gating Phases 1-3 (verified live: implicit + mesh fixtures, light + dark,
zero console errors).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer/cadjs: make package instancing opt-in (fixes exploded view, params/animations, highlighting)
The size-policy that auto-instanced any component-GLB package with >=128
occurrences routed medium+ packages (e.g. raptor3, 300 occ / 121 unique)
through the instanced render path. Instanced display records are inert
(partId: null, no baseTransform/explodedViewMatrix), so exploded view,
per-occurrence param/animation transforms, and per-part highlighting all
silently no-op'd on those packages.
Make instancing opt-in: shouldInstancePackageScene now returns true only when
instancePackages === true, and the >=128 size policy (INSTANCE_MIN_OCCURRENCES)
is removed. The instancing engine and the explicit flag path are unchanged, so
large packages can still opt in; the interactive viewer keeps the full-featured
per-mesh path everywhere by default.
- cadScene.js: opt-in shouldInstancePackageScene; drop the size policy + constant
- renderMeshScene.js / cadScene.js: refresh stale size-policy comments
- instancedRender.test.js: flip the 3 default-on assertions to opt-in
- regenerate skills/cad snapshot runtime bundle
Verified end-to-end on release/0.4.0 (raptor3): exploded view spreads parts,
the "Exploded reveal" animation plays, tree selection highlights the part.
JS suites pass (cadjs 424, implicitjs 56, viewer 248).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* viewer/cadjs: shared-geometry package rendering (single path; remove instancing) (#144)
Package occurrences render as one THREE.Mesh over shared component geometry (never baked, never instanced); the cid-keyed InstancedMesh engine and all plumbing are deleted. Same scaling win as instancing (falcon_heavy 301->135MB heap, 140->18ms compose, 1.40M->114k GPU verts) with one engine and full per-occurrence fidelity (selection/face-select/explode/param-animation/edges/mirrors). Override colours drive the material via linearRgbToHex; mirrors via DoubleSide; face selection via component-local sourcePartRanges. cadjs 406 / viewer 246 / implicitjs 56 pass; verified in-browser + headless snapshot.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* viewer: anchor zoom-to-cursor pivot to the model for consistent pan/zoom (#147)
Three.js OrbitControls scales perspective pan and dolly by the camera->pivot
distance. With zoomToCursor enabled, the orbit pivot (controls.target) drifts
off the model as you scroll-zoom, so panning and zooming felt slow when zoomed
in and fast when zoomed out. Orthographic mode was unaffected (it scales by the
frustum/zoom, not the pivot distance).
After each wheel zoom, re-anchor the pivot depth onto the geometry under the
cursor (raycast, falling back to the model centre), keeping it on the view
forward axis so the camera never re-orients or jumps the view. Zoom-to-cursor is
preserved; perspective pan is now a constant screen distance and each zoom step
a constant ratio at every zoom level.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* models: archive GPT-5.6 humanoid concepts
* viewer/cadjs: universal render+raycast perf pass
- Hide opacity-0 pick proxies from the render pass (raycast-only objects)
- Freeze shadow maps on camera-only frames; scene-mutation renders mark dirty
- Suspend ref hover/selection + pick-state rebuilds during STEP animation playback
- BVH-accelerated raycasting (three-mesh-bvh, indirect builds keep faceIds valid)
- Model-fitted depth range instead of logarithmic depth buffer (early-Z restored)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* cadjs/viewer: sync lockfiles for three-mesh-bvh (pin 0.8.0)
npm ci in CI validates viewer/package-lock.json against the cadjs file:
dependency manifest; add the missing three-mesh-bvh entry and pin the
version exactly, matching the repo's exact pin for three.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* models: add animated Millennium Falcon experiment
* cadjs: fix package occurrence placement + double-transformed effective bounds
Two regressions from the shared-geometry package pipeline (partTransformsBaked:
false), where occurrence transforms apply at render time instead of being baked:
- displayTransformForPart nulled the occurrence transform whenever
renderPartsIndividually was false (a baked-pipeline assumption), so packages
without a params definition rendered every occurrence at identity — scattered
assemblies. Unbaked meshDatas now always place parts by their transform.
- effectiveBoundsFromRecords re-applied baseTransform to part bounds that are
already world-space, doubling extents. Params models cached the inflated
bounds into the model offset/floor before the module pass corrected the pose,
leaving them floating above the floor. Only the module-effect delta applies.
Includes the regenerated cad-skill snapshot runtime bundle.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: require valid CAD Python backend
* snapshot: add opt-in --debug build-strategy diagnostics
Add a --debug flag (and job-level "debug": true field) to CAD scripts/snapshot
that surfaces how each STEP/STP render artifact was resolved: generated vs.
imported source, part vs. assembly, cache hit vs. regeneration, whether
assembly selectors were re-extracted, and wall-clock time. It's purely
opt-in — everyday snapshot usage is unaffected, no extra dict writes or
timing when debug is not requested.
Wiring this up surfaced a real bug in _assembly_topology_artifact: its
cached-descriptor lookup used spec.step_path instead of spec.entry_path,
so generated (.step.py) models — keyed by entry_path — never hit the
cheap descriptor cache and always paid a full selector re-extraction (or
even a full generator re-run), exactly the cost the surrounding code's own
TODO says it should avoid. Fixed the lookup to use entry_path, matching
how the artifact_path is already constructed a few lines below it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019xfZ7wV1KXZfRktuCoWU7G
* snapshot: render direct GLB/STL/3MF meshes + agent-facing polish
Extend the CAD snapshot tool beyond STEP/STP to render direct .glb/.stl/.3mf
mesh inputs, reusing the viewer's existing shared render path rather than
duplicating loader logic.
- cadjs source.js: wire the already-tested STL and 3MF mesh builders into
loadMeshDataFromUrl, so the shared headless render entry loads all three
mesh formats the viewer already supports. Fix a latent bug in
assertStepOnlyOption exposed by the first non-STEP loadSource caller: the
empty-string default of stepParameterUrl was treated as a present step-only
option, wrongly rejecting every non-STEP source.
- snapshot CLI: accept direct mesh inputs via a topology-free resolve path
that skips the STEP artifact/package pipeline entirely and hands the
renderer a plain asset URL. Mesh inputs support view/orbit/list plus
camera/appearance/size-profile, and reject STEP-only options with clear
errors: selector focus/hide, stepParameters, section mode, exploded, and
the hidden_edges/hidden_lines_removed CAD-edge display modes.
- Agent-facing: mode=list parts now carry a paste-ready selector ref
(#<occurrenceId>); the view result echoes the display mode and projection
actually applied; and typo'd display values (projection, mode,
exploded.axis) are rejected up front instead of silently rendering a wrong
image via the renderer's default fallback.
- cadgen hardening: drop the unused owner= parameter and the legacy
<name>.py generator-sibling fallback (no model relies on it; the .step.py
convention is separately covered).
Verified end-to-end: STL, GLB, and 3MF each render correctly through the
headless browser pipeline. 383 cadjs tests and the full cadgen + cad-skill
Python suites pass; the snapshot browser runtime is re-bundled.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019xfZ7wV1KXZfRktuCoWU7G
* snapshot: honor mesh projection, reject non-solid modes, fix empty-value parity
Address two low-severity findings from adversarial review of the mesh-render
change:
- Silent no-op: mesh jobs accepted display.projection and non-solid
display.mode but the renderer forced perspective+solid for non-STEP
sources, discarding them with no error. Projection is camera-only (no
topology dependency), so the shared headless renderJobContext now honors
the caller's projection for meshes (renderJobContext is used only by the
headless render path, not the interactive viewer). Non-solid display modes,
which genuinely need topology/material data a mesh lacks, are now rejected
up front with a clear error instead of silently rendering solid.
- Validation parity: validate_display_settings_values rejected empty-string
projection/mode/exploded.axis, but the renderer treats "" as absent and
falls back to the default. Empty values are now skipped (treated as unset),
matching the renderer so an agent emitting "" for unset fields is not
false-rejected.
cadjs runtime re-bundled. 383 cadjs + full cadgen/cad-skill Python suites
pass; verified an STL renders with orthographic projection honored end-to-end.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019xfZ7wV1KXZfRktuCoWU7G
* snapshot: surface --debug diagnostics in the rendered --json result
The debug block was attached to job.resolved at resolve time, but the printed
result was exclusively the browser's return value, so the help text's promised
'debug' section never appeared in --json output. Merge it in at the render
stage and pin the full path through print_render_result.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* snapshot: validate job-level display values, not just the --display flag path
validate_display_settings_values only ran inside load_display_option, so a
display object embedded in a full JSON job — the primary agent interface —
bypassed the closed-set guard and a typo'd projection/mode silently rendered
the default. Validate in resolve_render_job before the kind split so STEP and
mesh jobs both get it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* snapshot: echo projection per rendered output
The job-level echo reported the resolved theme/display projection, but the
camera is chosen per output — an explicit position/target camera forces the
perspective camera even on an orthographic theme, so the echo could misreport
what was rendered. Each output now carries its own authoritative projection
(via an extracted, unit-tested resolveOutputCameraProjection); the dead ??
fallbacks on the job-level field are gone (normalizeCameraProjection never
returns null).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* cadjs: gate STEP sidecar loads by source kind in loadSource
Direct mesh sources unconditionally ran loadSelectorRuntime/loadDisplayEdgeRuntime
against the mesh URL, re-downloading the full binary a second time just to fail
the GLB container parse inside a blanket catch. Gate both on sourceIsStep so
'no selectors for meshes' is intent rather than a swallowed error, matching the
CLI's mesh-input validation. One fetch per mesh, pinned by test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* snapshot: factor shared job normalization + a kind-dispatch resolver table
The mesh resolver's tail was a ~40-line near-verbatim copy of the STEP
resolver's (outputs guard, render clip-strip + scene-scale coercion,
timestamped output loop, common return shape). Extract normalize_common_job
for all kinds and replace the if-chain with a KIND_RESOLVERS table keyed by
input kind. Adding an input kind (e.g. .implicit.js) is now one table entry
plus a resolver, not another dispatch arm and a third copied tail. Pure
refactor: all 47 snapshot CLI tests and the mesh capability rejections are
unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* snapshot: dispatch implicit jobs to the implicit backend in the shared runtime
runHeadlessRenderJob was mesh-only (loadSource -> triangle pipeline), which an
implicit raymarch model cannot satisfy. Add a kind dispatch above loadSource:
implicit jobs route to implicitjs's runImplicitCadHeadlessRenderJob, everything
else takes the mesh path. cadjs imports the implicit headless entry directly
(sanctioned cadjs -> implicitjs direction, cadjs-internal, not a public
re-export subpath), so the one window.__snapshotRender bundle now carries both
backends. Kind resolution is a dependency-free module (headlessJobKind.js) so
it is unit-testable outside the gifenc/three bundle.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* snapshot: accept .implicit.js inputs through the unified CLI
Add an 'implicit' input kind: input_kind detects the .implicit.js compound
suffix, resolve_implicit_render_job hands the runtime the module URL (kind
'implicit', which the browser dispatch routes to the raymarch backend) and
skips the STEP artifact/package pipeline, and it registers as one KIND_RESOLVERS
entry. Supports view + orbit with camera/appearance; rejects the STEP-only
options (selectors, stepParameters, exploded, non-solid display modes) plus
list/section (no part topology). Verified end-to-end: rendering
parametric-pulse.implicit.js through the cad snapshot CLI produces the same
image the viewer shows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* snapshot: regenerate bundle after rebase onto shared-geometry release/0.4.0
The rebase carried the pre-consolidation bundle; regenerate it from the merged
source so it reflects both #143's implicit-dispatch entry (headlessRenderEntry ->
runImplicitCadHeadlessRenderJob) and release/0.4.0's shared-geometry package
render path. Bundle now carries both backends (mesh + implicit), 1.1mb.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* snapshot: validate exploded.auto.mode + honor the top-level axis shorthand
validate_display_settings_values validated a top-level exploded.axis that the
renderer's exploded schema never reads (normalizeExplodedViewDocument keeps only
enabled/amount/order/trails/auto/steps; the auto-explode axis is exploded.auto.mode,
alias auto.axis). So a real typo like auto.mode:"radal" passed validation and
silently rendered the auto-picked axis, and the top-level axis shorthand was a
silent no-op.
- Validate exploded.auto.mode/auto.axis against {auto,x,y,z,radial} (rejecting
values the renderer would coerce to "auto"), alongside the existing shorthand check.
- Translate the top-level exploded.axis shorthand into exploded.auto.mode at STEP
resolve time (normalize_exploded_axis_shorthand) so it actually reaches the renderer
instead of no-oping; an explicit auto.mode/auto.axis or a per-step doc still wins.
Also closes the review's test-coverage gaps: implicit stepParameters/section-mode
rejection, and the mesh/implicit --debug resolved payloads (meshSource/implicitSource).
Verified end-to-end: the axis shorthand renders an exploded STEP assembly, and an
invalid auto.mode is rejected. snapshot 56 / cadgen 200 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* snapshot: regenerate bundle after rebase onto release/0.4.0 @ 2045d5c1
The base gained the shared-geometry package occurrence placement fix
(8149ba93) and the universal render+raycast perf pass (e97d94b0), both of
which live in packages/cadjs and are baked into this generated runtime.
Rebuilt with scripts/bundle/bundle.sh so the checked-in bundle matches the
rebased source.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* cadjs: ghost non-focused parts in view/orbit snapshot renders
snapshot --focus validated its refs and then rendered a byte-identical image
in view and orbit modes: 6bc15c0e deliberately kept the whole assembly in the
scene (so focus preserves framing and context), but nothing consumed the
focus selection afterward — applyPartVisualState reads focusedPartId, a key
the render-job selection (focus/refs/hide) never set.
modelOptionsForRenderJob now maps the job's focus + refs selectors to
focusedPartId for view/orbit, so non-focused parts ghost through the same
path the interactive viewer uses (FOCUSED_DIMMED_SURFACE_OPACITY), with
subassembly prefix matching for free. Section mode still isolates via
filterSelection; hide still removes parts in every mode. The 6bc15c0e
regression test now also asserts the ghosting is applied, and
snapshot-review.md describes the real focus semantics.
Includes the regenerated cad-skill snapshot runtime bundle.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* cad skill: resolve bundled cadgen from the step/inspect entries
snapshot's entry inserts the skill's vendored scripts/packages/cadgen onto
sys.path before importing cadgen, but the step and inspect entries imported
cadgen bare, so they silently resolved whatever copy the interpreter's
site-packages held — in a dev worktree that is the main checkout's editable
install, not the code beside the CLI being run.
Add the same runtime-path block (guarded to existing directories) to both
entries so all three CLIs prefer the vendored cadgen they were bundled with
and fall back to the installed package when the vendored path is absent
(PyPI-pinned plugin installs).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* snapshot: document --mode + reject single-frame .gif outputs
--mode existed but was absent from the CLI help, so the discoverable route to
a turntable was --size-profile orbit — which only sets dimensions and silently
saved a single-frame GIF in view mode. Document view/orbit/section/list in the
help text, and make normalize_common_job reject a .gif output in any non-orbit
mode with static parameters, spelling out both fixes (--mode orbit or animated
--params values). Animated stepParameters sweeps still render view-mode GIFs;
covered by a new sidecar-declaring accept test alongside the reject tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* cadgen: write explicit exports even when the compose is current
scripts/step <model>.step.py --stl out.stl silently wrote nothing on a
current model: generate_step_targets' no-op fast path only exempted --step
exports (step_export_path), so specs carrying explicit mesh export requests
(--stl/--3mf/--glb) were dropped as current before the export-aware inner
reuse path — which already treats any on-demand output as must-run — could
see them. The CLI logged 'is current; skipped recompose' and produced no
file unless the user knew to pass --force.
Factor the existing has_extra_outputs checks into
_spec_requests_extra_outputs and use it in the outer fast path too, so an
explicitly requested export always keeps its spec in the run. Plain
regeneration without export flags still no-ops. Regression test drives
generate_step_targets both ways over a mocked-current model.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* models: remove LEGO falcon experiment (1.5GB source exceeds repo size budget)
The UCS Millennium Falcon import ships a 1.5GB STEP that is too large to keep
on the release branch; the experiment continues on claude/falcon-perf-lab.
Note: this removes the files from the branch tip only — the LFS object remains
referenced by prior history.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Split STEP CLI: scripts/gen builds render packages, scripts/export writes files
scripts/step is replaced by two single-purpose CLIs in the CAD skill:
- scripts/gen takes gen_step() Python sources only and builds the hidden
__cadgen__ render (GLB/topology) packages, freshness-gated with --force.
It writes no STEP/STL/3MF/GLB files; -o/--output, SOURCE=OUTPUT pairs,
and the sidecar flags are gone. Direct STEP/STP targets are rejected
with a pointer to the on-demand artifact flow and scripts/export.
- scripts/export takes one model target - a gen_step() source or an
imported STEP/STP file - and one or more format flags (--step, --stl,
--3mf, --glb). The scene is built once and meshed at most once per run,
so all requested formats come from identical geometry and can never be
stale. A bare format flag writes the default sibling <name>.<ext>; a
relative path resolves beside the model. It writes no render package.
Imported STEP/STP files no longer need --kind or a CLI build step: their
part/assembly kind is inferred (embedded entryKind metadata, else STEP
product hierarchy) and their render artifacts are generated on demand by
inspect, snapshot, and the CAD Viewer.
cadgen changes backing this:
- step_artifact._infer_entry_kind is promoted to the public
infer_entry_kind, and step_artifacts drops its duplicated copy.
- step_export_target gains export_cad_target (multi-format, one scene
build, default/relative output resolution, optional kind override,
refuses to export STEP over its own imported source). The viewer's
single-format export_model_to_path contract is unchanged.
- REGENERATE_STEP_COMMAND and generate_step_targets' tool name now say
scripts/gen; the direct-target kind errors name direct_step_kind
instead of the removed --kind flag.
Docs (SKILL.md, step-generation.md, supported-exports.md,
positioning.md, CONTRIBUTING.md, model READMEs and export_extras
docstrings) follow the new tool split, and the CLI tests move to
tests/python/skills/cad/{gen,export}.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZCKYUurAmvHn2WhyXjQvD
* Add scripts/artifact debug CLI and scripts/gen --write-step
Two follow-ups to the gen/export split, both thin wrappers over existing
cadgen entry points:
- scripts/artifact runs exactly one render-package (GLB/topology) build
for an imported STEP/STP file or a gen_step() source and prints the
result payload. It wraps cadgen.step_artifact.build_step_artifact -
the same primitive behind the CAD Viewer's on-demand build - so it
debugs that flow directly. Optional --kind override, --force, and the
shared mesh flags.
- scripts/gen --write-step [OUTPUT] also writes the .step file during
generation. A bare flag writes each target's sibling <name>.step; an
explicit path requires exactly one target and resolves from the cwd.
Implemented by translating targets into the SOURCE=OUTPUT pair form
generate_step_targets already resolves per target, so the STEP write
reuses the existing on-demand export job (it runs even when the render
package is fresh). scripts/export --step remains the standalone
equivalent.
Also fixes a rename leftover the new artifact CLI surfaced: the
imported-STEP branch of build_step_artifact still called the old
_infer_entry_kind name, breaking the viewer's on-demand build for
imported STEP files; a cadgen regression test now covers that branch
(kind inference and --kind override).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZCKYUurAmvHn2WhyXjQvD
* cad skill: port the warm daemon and CLI entries to gen/export/artifact
The split left the warm-daemon layer on the old tool: the server registry
still imported step.cli (a module the refactor deletes), gen's launcher shim
still requested tool "step", the export/artifact launchers had neither the
daemon shim nor the bundled-cadgen sys.path block the other entries carry,
and the daemon reference docs + test suite still drove scripts/step (the
suite failed at setUpClass). Register gen/export/artifact in the daemon,
stamp all three launchers from the same template (shim + vendored-cadgen
path preference from #143), update the warm-daemon docs, and port the
daemon tests to scripts/gen. Also reword the current-target export
regression-test comment to the library-level contract now that sidecar
flags live in scripts/export.
Verified live: cold gen/export/artifact runs, CADGEN_WARM=1 through the new
tool names (daemon spawn + warm export + warm artifact), gen --write-step on
a current compose still writes its STEP, and a 17-part imported assembly
exports to GLB with per-occurrence colors intact.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* cadgen: drop the API surface the gen/export split made unreachable
`scripts/gen` builds gen_step() Python sources into render packages and
nothing else; `scripts/export` owns standalone STEP/STL/3MF/GLB and builds
its own scene. That left a whole configuration subsystem in cadgen with no
production caller — only tests reached it. Remove it.
generate_step_targets:
- drop `output=` (no `--output` flag exists) and `direct_step_kind=` (direct
STEP/STP targets are rejected; imported STEP render packages are built on
demand by cadgen.step_artifact). Direct STEP targets now fail with a
message pointing at the on-demand flow instead of asking for a kind.
- delete `_apply_step_output_override`, `_existing_direct_step_targets`, and
the `direct_step_kind` parameter on `_selected_specs_for_targets`.
Mesh export configuration, dead since the split:
- StepImportOptions loses `stl`/`three_mf`/`glb`/`step`; only the render mesh
tolerances remain (the sole production constructor already set just those).
- CadSource/EntrySpec lose `stl_path`/`three_mf_path`/`native_glb_path`;
generated sources already hard-coded them to None.
- GeneratorMetadata loses `stl`/`three_mf`, which were always None and read
by nobody. STEP_ENVELOPE_FIELDS still ACCEPTS `stl`/`3mf` keys so existing
generators keep parsing — that is a validation contract, not dead API.
- delete the STL/3MF/native-GLB sidecar jobs and the whole-scene mesh that
fed only them, `_validate_part_render_output_paths` (now a no-op) and the
`validate` switch on `list_entry_specs`, and `part_stl_path`/`part_3mf_path`
/`part_native_glb_path`. `target_path` is now required on the three scene
exporters, which is how their only caller already invokes them.
Also removes four private helpers and nine imports left unreferenced.
No CLI, flag, or skill-instruction change: the agent-facing contract is
whatever `scripts/gen --help` and `scripts/export --help` already print.
Viewer contract unchanged — export_model_to_path/run_cli_payload signatures
and behavior are untouched.
Tests: the 28 removed cases all drove the deleted surface; the ones covering
live behavior are ported (imported-STEP package regression now drives
build_step_artifact, the explicit-export fast-path test now uses the
--write-step pair, ordering uses generated targets).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Merge pull request #158 from xiaolai/fix/nlpm-document-bambu-tls-mitigation
docs(bambu-labs): document TLS verification bypass mitigation
* Merge pull request #169 from n8guru/fix/software-webgl-performance
Viewer: reduce load under software WebGL
* Merge pull request #171 from earthtojake/claude/pr-108-rendering-fixes
Occlusion-ghost depth fix + readable selection highlight (supersedes #108)
* Merge pull request #174 from earthtojake/claude/github-issue-164-f6289c
fix(gcode): name the conversion skill and command when rejecting non-mesh inputs
* Merge pull request #176 from earthtojake/claude/cicd-issue-165-6a3072
ci: check generated outputs against sources and add a build badge
* Merge pull request #172 from rootsbymenda/fix/per-job-asset-cache-key
snapshot: key render-asset URLs per file identity to fix stale cross-job mesh reuse
* bundle: regenerate the snapshot runtime for the backported cadjs highlights
skills/cad/scripts/snapshot/runtime/snapshot-render.js is an esbuild bundle of
packages/cadjs/src/common/headlessRenderEntry.js, so it conflicted wholesale
when PR #171 was cherry-picked. The merge kept 0.4.0's bundle; this rebuilds it
from the backported sources, which is what scripts/bundle/bundle-skill.sh cad
produces.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* snapshot: reject render-asset cache hits across job sources
Backport of #180 to release/0.4.0.
Render asset caches are page-lifetime — the batch renderer keeps one Chromium
page across every job — so an entry populated while rendering one source can
satisfy a different source's request for the same URL and silently render the
first job's geometry at exit 0. #172 keyed render-asset URLs on file identity
so a collision should be unreachable; nothing detected one. Every cache entry
is now tagged with the source it was populated under, and a cross-source hit
raises with both sources named.
Backport notes:
- packages/cadjs/src/lib/{renderAssetClient,stepRenderAssetClient}.js and
docs/render-pipeline.md were identical to develop and applied cleanly.
- common/source.js was hand-merged: 0.4.0's component-GLB package branch
early-returns before the source URLs are computed, so the scope wiring
covers the non-package tail, and 0.4.0's stepSidecarsEnabled gate for
direct mesh kinds is preserved inside the scoped block.
- snapshot-render.js regenerated with scripts/bundle/bundle.sh; development
symlink layout restored afterwards.
Verified on this branch: cadjs 440/440, viewer 246/246, viewer build clean,
scripts/bundle/bundle.sh --check reports all bundle outputs up to date, and
deleting the single setRenderAssetSourceScope wiring line still turns the four
composition tests red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Make the generation lock real; unify STEP/DXF freshness; trim export CLI
Two related cleanups to the CAD generation pipeline.
## Generation lock is now a real lock
The per-model lock was a JSON status file with a pid and a 1s heartbeat that
was WRITTEN but never ACQUIRED. Two builds of the same model both proceeded,
the second overwrote the first's pid, and whichever finished first unlinked the
shared file while the other was still writing into the package — so a reader saw
"no build in flight" over a half-written package. Liveness also leaned on a 30s
heartbeat window, which a GIL-holding OCP mesh can starve.
It is now fcntl.flock(LOCK_EX) on a bare `.generation.lock` sentinel. The kernel
owns the state, so a crashed or killed build releases with no stale window, and
blocking acquisition makes contenders wait instead of racing. Removed: pid,
heartbeat thread, staleness window, JSON payload, schema version.
Serializing alone still let each contender redo the finished work, because the
currency gate ran before the lock. `_run_with_spec_generation_status` now takes
`skip_if_current`, re-evaluated after acquisition: three concurrent cold builds
produce one build and two no-ops.
## One freshness algorithm for CLI and viewer
The CLI compared content hashes while the viewer compared mtimes, which
disagreed in both directions: `touch` on a generator made the viewer rebuild
what the CLI called current, and the no-op rebuild that followed existed only to
bump the descriptor mtime and quiet the trigger. viewer/server_py/source_hash.py
mirrors cadgen's semantic AST closure digest in pure stdlib (server_py must stay
importable without cadgen/OCP), pinned against the real implementation over a
corpus in the mirror test.
validate_step_freshness and validate_dxf_freshness collapse into one
_validate_render_package over a per-format table, fixing a no-closure descriptor
reporting fresh for STEP and stale for DXF. The os.utime descriptor hack is gone.
The three `if is_dxf: ... else: ...` backend sites become one format record.
## Export CLI writes mesh formats only
`scripts/export --step` and `--kind` are removed. A .step file now comes only
from `scripts/gen --write-step`; `--kind` was accepted solely for imported STEP
targets, where its one consumer was unreachable — it was a no-op. The Viewer's
Save-dialog STEP export is a separate entry point and is unaffected.
## Dead code
- capture_runtime_closure's `extra_files`: defined, documented, never passed.
Docstrings promising "every composed child STEP" is in the closure corrected —
the closure is the generator's Python import reach.
- scanner._generator_source_path_from_metadata and its private chain, plus
PYTHON_GENERATOR_BY_KIND, orphaned by the mtime removal.
## Tests
New: multi-process lock serialization (mutation-checked against a no-op lock),
SIGKILL release, re-entrancy, concurrent-build dedup, cadgen/viewer digest
parity over comment-only/docstring/syntax-error/non-Python inputs, STEP/DXF
freshness parity.
test-python.sh 577 · test-global.sh 19 · server_py 71 · viewer JS 246 · bundle
check clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Report artifact-build progress in the CLI and the CAD Viewer
A component-GLB build of a large assembly runs for minutes with nothing to show
for it: the CLI printed one line when it finished, and the viewer showed an
indeterminate spinner. Both now report where the build actually is.
Only one stage can be reported honestly, and only that one is:
* components -- the missing set is resolved in full before the first mesh runs,
so done/total is a measured count. This is also where a slow build's time goes.
Note the total is the MISSING count, so re-meshing one edited part of a 300-part
assembly reads 1/1, not 1/300.
* generate / package -- a generator running, a STEP parsing, the occurrence walk.
No unit of work exists, so they report a phase, and readers interpolate them
against the duration this model's PREVIOUS build recorded. Nothing invents a
denominator the build does not have.
cadgen's new _internal/progress.py emits at work boundaries, never on a timer:
OCP meshing holds the GIL inside C for long stretches, so a heartbeat thread
starves during exactly the work it would be reporting -- the failure mode the
generation lock was rewritten to avoid. Interpolation is therefore the reader's
job, which is why each event carries ratioFloor/ratioCeiling/phaseExpectedMs.
The events go two places. The CLI paints a self-erasing line on the logger's
stream (silent under --verbose, where the logger already narrates, and on
non-ttys). The viewer reads a JSON sidecar written beside the generation lock:
GET /__cad/artifact attaches it to `generating`, and useArtifact polls that route
while its build POST is in flight, since a single long POST cannot report on
itself. Because the sidecar is keyed by package dir like the lock, any producer
reports to any reader -- a `cad gen` in a terminal drives the bar in an open
viewer. The sidecar is decoration only: the ready/generating/error state machine
stays driven by the lock, so a file left by a killed build can never by itself
look like a live one. It is deliberately not unlinked, because its terminal event
carries the stage times the next build weights its bar from.
Two fixes this needed:
* component_package used pool.map, which yields in SUBMISSION order -- a count
taken from it reports the finished prefix, so one slow component early in the
list would pin the bar while the rest completed behind it. Now submit +
as_completed; the returned value is unchanged.
* step_artifact held the generation lock only across the generator run, releasing
it before the meshing. Invisible while nothing polled, but a polling viewer
would have read "no build in flight" mid-build, found the package stale, and
started a second one. The lock now spans the whole build (re-entrant, so the
inner acquire is a no-op).
The viewer overlay stacks three rows on a fixed character grid -- every cell that
changes has a reserved width -- so the block holds one size for a whole build
(measured 217x54px at 2%, 46% and 98%). The bar is a real role="progressbar"; the
live-region text stays coarse so a screen reader is not read a new sentence every
poll.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* viewer: the URL path is the directory; delete the served-root layer
A Viewer URL's PATH is now the absolute directory it opens, exactly as in a
file:// URL, and ?file= selects one artifact within it:
http://127.0.0.1:3245/absolute/model/root?file=path/relative/to/it
The Viewer is no longer started against a directory. It opens whatever a URL
names, so one instance serves any folder, and the bare origin falls back to the
process cwd.
?dir= was a query param the client persisted to sessionStorage, so a URL without
it rendered whatever you had opened before — the same link could show different
models. AGENTS.md carried a rule ("every returned Viewer URL must include ?dir=",
"do not rely on session-storage fallbacks") that existed only to paper over that.
The URL is now the sole source of truth and the rule is unrepresentable.
Server: --dir from server.py and start_viewer.py, and with no configured root the
whole reconciliation layer collapsed — base_directory_root/default_root_dir,
_effective_root_dir, resolve_request_root, normalized_root_dir,
_path_is_inside_or_equal, _scan_context (a pure alias for rootPath once there is
one root), scanner's resolve_viewer_root / normalize_viewer_root_dir /
DEFAULT_VIEWER_ROOT_DIR chain and scan_cad_directory's root_dir parameter, plus
dynamicRoot/directoryRoot/activeDirectories from the /__cad/server payload.
LocalAssetBackend now has no constructor and one 6-line resolver.
Client: readActiveCadDir() reads location.pathname; cadViewerDirectorySession.mjs
(the sessionStorage module) is gone entirely, along with writeCadDirParam,
readCadDirParam, handleSelectDirectory, directorySelectionActive/directoryOptions/
directoryAutoEnterDir/directoryNavigationAvailable, normalizeViewerDirectoryOptions,
the CadWorkspaceHome "Select a directory" mode, and the 99-line DirectorySwitcher
dropdown. Both switchers were already unreachable — they required
activeDirectories.length > 1 and serve mode hardcoded it to [].
Dev tooling: scripts/dev/viewer-preview.sh (it existed only to export a dead env
var and forward autoPort's $PORT) and autoPort itself; .claude/launch.json now
invokes npm directly. VIEWER_DEFAULT_DIR / buildViewerDefaultDir were already dead
and are removed.
dev and start both listen on --port, default 3245, and neither rolls. Vite gained
strictPort so dev fails on a taken port like start always has — a Viewer is always
on the port you asked for.
_serve_dist treated any path with a file extension as a static asset, so a real
directory containing a dot (/Users/me/v0.4/models) 404'd instead of loading the
SPA. Only /assets/* is static now.
A NameError in the artifact build (ctx referenced after its assignment was
removed) that import checks passed and only a live POST /__cad/artifact caught.
Live: one Viewer served four directories including a dotted path and a
nonexistent one (clean error); ?file= selected a model; no console errors; the
API carries ?dir= derived from the pathname; dev fails on a taken port and does
not roll.
Suites: python 577, global 19, server_py 75, viewer JS 247, cadjs 440,
bundle.sh --check clean, check-version.sh valid.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Restore cadgen PyPI pinning for the repo-root plugin structure
Published skills must resolve cadgen from PyPI. A source checkout installs it
with `--editable ./<path>/packages/cadgen`, which only works because the package
sits beside the skill; an installed skill has no such sibling, so the publish
tree has to name the release instead.
That rewrite lived in `bundle-plugin.sh`'s `pin_cadgen_requirements`, over the
generated `plugins/cad/skills` copy. Moving the plugin package to the repo root
deleted that script, and the pinning went with it — silently, because nothing
tested it. develop never noticed: it does not publish cadgen to PyPI, so it has
no pinning at all. Without this, a 0.4.0 publish would ship skills whose
requirements.txt says `--editable ./scripts/packages/cadgen`, which cannot
resolve once installed — exactly the failure CONTRIBUTING says must block a
release.
There is no generated copy to rewrite any more, so this is a publish-tree
transformation: `scripts/release/pin-cadgen-requirements.sh` runs in the Release
workflow between `bundle.sh --clean` and the publish commit, beside the existing
`models/` removal, and re-runs with `--check` to prove the tree is clean before
it is committed.
It deliberately does NOT live in bundle.sh: that also runs `--check` against a
development checkout, where rewriting the checked-in requirements would dirty
the source tree and break the freshness check. A source checkout stays editable;
a test asserts that.
tests/python/global/test_pin_cadgen_requirements.py covers the rewrite, sibling
requirements being preserved, idempotency, --check reporting without writing,
excluded trees, a missing VERSION, that the workflow runs it before the publish
commit, and that the in-tree requirements stay editable.
Verified: test-global 42, test-python 577, server_py 75, viewer JS 261,
cadjs 440, bundle --check clean, symlinks valid, version 0.4.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Rebrand to text-to-cad: new byline, domain, wordmark, and hero layout
## Byline
"A skills library for CAD, robotics, and hardware design agents" becomes
"A library of agent skills for CAD, CAE and CAM", with "100% open source +
free, runs locally" as the accented second clause in the docs hero. One
constant in docs/src/lib/site.ts feeds the page title, meta description, and
the OpenGraph and Twitter title/description pairs.
## Domain
cadskills.xyz -> texttocad.dev across plugin manifests, the docs site origin,
the README, and the docs deploy workflow. The demo moves to cad.fun, which
already serves the CAD Viewer; there is no demo.texttocad.dev.
cad.fun is also removed from the docs deploy's --public-urls. That flag is a
post-deploy smoke check, so the docs deploy was verifying the demo's health
instead of its own and would have passed with broken docs.
## Display name
The plugins now display "text-to-cad" (Codex previously showed "CAD", the
Claude marketplace "Text to CAD"). Install identifiers are deliberately
untouched: marketplace `name` stays text-to-cad and plugin `name` stays cad, so
`cad@text-to-cad` and every documented install command still resolve.
## Wordmark
CAD SKILLS -> TEXT.TO.CAD in the docs hero and the README, same ANSI Shadow
font. The glyph table used to render it was validated by regenerating the
existing CAD SKILLS art and diffing all six rows.
The hero grid becomes 3 columns with the wordmark spanning 2 and the byline 1,
so the ASCII scales from ~10px to ~14.5px. Below lg they stack full width as
before.
ASCII_MIN_FONT_SIZE drops from 7 to 5. TEXT.TO.CAD is 82 columns against
CAD SKILLS' 62, so it needs ~7px at 375px and ~5.1px at 320px; the old floor
clamped above what fits and overflow-hidden silently cut the trailing glyphs.
Measured at both widths: the word now fits with no horizontal page scroll.
## Supported Agents
Removed from the docs page, along with the chain it owned: AgentCarousel,
AgentTile, the supportedAgents list, the orphaned next/image import, and the
@keyframes agent-carousel CSS including its reduced-motion branch. Try It Now
takes the same max-w-3xl as the INSTALL section instead of a half-width grid
cell.
Install instructions were re-checked against the manifests and are correct.
Verified: test-global 42, docs lint+build, bundle --check, check-version 0.4.0,
and both hero breakpoints in a browser.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* viewer: let a same-stem generator own its entry, and allow cadjs' real path
Three fixes found while loading two large imported-STEP LEGO models.
A `<name>.step.py` generator beside an exported `<name>.step` was silently
ignored: generate_step_artifact only consulted a generator when the export was
ABSENT, so the export always won. That is the documented way to attach a
`params` sidecar to an imported STEP (skills/cad/references/parameters.md), and
three shipped models already use it -- models/mechanisms/{gear_rack_gripper,
adjustable_height_table_2,180_degree_flip_mechanism}.step.py each declare a
sidecar that exists on disk and could never load. The build recorded
sourceKind=step with no paramsPath, so the Viewer showed no Parameters tab and
reported no error.
Resolve the preference in resolve_step_source instead of at the build, so the
freshness check, the build and STEP export all key on one source; keying only
the build would leave a model permanently needs-build. cadgen already resolves
this way (generator first, direct STEP as fallback), and its generator mode
writes only the render package, so an exported .step beside a generator is
never rewritten. These generators re-import their own sibling export, so this
reads the same geometry and additionally records the sidecar.
Vite checks module ids after resolution, so ids arrive as real paths, but
server.fs.allow listed only viewer/packages/cadjs/src -- a symlink in the
develop layout. Vite therefore refused to serve
packages/cadjs/src/lib/render/glbMeshWorker.js, and loadRenderGlb quietly fell
back to decoding every GLB on the main thread; the only symptom was a dev-server
log line. Allow the real path too, deduped so a flat checkout is unaffected.
Also correct the deprecated-env error text, which still pointed at ?dir= after
that query param was removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* cadgen: return a single-root STEP as its root, not wrapped
scene_to_build123d_compound always wrapped the scene roots in a container
Compound, so cadgen's import_step returned a synthetic parent holding the
assembly rather than the assembly itself. build123d.import_step does the
opposite for the same input, and says so in its own source:
root = Compound()
root.children = build_assembly()
# Remove empty Compound wrapper if single free object
if len(root.children) == 1:
root = root.children[0]
That made import_step's docstring claim -- "Mirrors what build123d.import_step
produces topologically ... a colored drop-in" -- false: 11 children vs 1.
The extra level shifted every occurrence path one segment deeper, so the same
geometry addressed as o1.1 when the STEP was opened directly and o1.1.1 when it
came back from a generator's gen_step(). Selector refs then depend on the route
taken to the file rather than on its contents. The three shipped sidecar models
that re-import their own export (mechanisms/{adjustable_height_table_2,
180_degree_flip_mechanism,gear_rack_gripper}) address the flat ids, so once
c288be04 let their sidecars load, none of their refs resolved and every
animation transform silently no-opped.
Multiple free roots still get a container -- a shape can only have one root, and
build123d falls back the same way. import_step also stops defaulting the label
to the file stem: build123d keeps the STEP's own root name, and deriving it from
the path let identical content produce different trees per filename.
Leaves now go through downcast + topods_lut instead of build123d.Shape(obj=...),
which returns an untyped Shape with no per-type API. The old wrapper hid this
because callers only ever touched the Compound; unwrapped, a single-solid STEP
came back as a bare Shape with no .volume. The existing parity test caught it.
Verified: cadgen and build123d now agree on type, label and child count for an
assembly, and on type and volume for a single solid. 452 Python tests pass.
Regenerating the three models restores o1.1.1 depth and resolves 45/45, 41/41
and 17/17 refs respectively; the adjustable-height table animates again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* viewer: one Display tab, one theme, one right-hand panel
Three related reshapes of the file sheet and theme UI, plus the dead config
they were carrying.
## Display
Display, Clip and Exploded were three tabs that all edited the same per-file
displaySettings; Display held a single dropdown. They are now one Display tab:
a Mode row, then Clip and Exploded as subsections. Exploded's Enable/Disable
button became a switch beside its heading, so a section you are not using costs
one row. Removed edge colour, thickness and opacity controls -- edge appearance
is a theme trait, not a per-file one -- and the file-sheet tab ids for the two
retired tabs.
Tabs that render only for some files (Issues, Parameters) used to be appended to
whichever pane they defaulted to, so a layout carried over from a file without
them put Issues last instead of leftmost, and Parameters after Display. They now
slot in at their render-order position. Parameters moved to the bottom pane
between Reference and Display, leaving Tree alone on top, and each pane defaults
to its leftmost tab rather than a hardcoded preference.
## Theme
The saved-theme library is gone. Theme state is one active id plus at most one
custom settings blob: "system" (new, follows prefers-color-scheme), a built-in
preset id, or "custom". Presets are read-only. Editing any setting writes the
single custom slot and makes it active; selecting a preset is the only reset.
No save, restore, rename or delete, and no per-preset overflow menus or dialogs.
The picker shows "None" while a theme is customized, since no preset is active.
Storage v12 is {themeId, custom} and drops every stored theme snapshot; the key
is absent for an unmodified system theme. Older payloads are ignored, not
migrated. Directory-level overrides take the same shape and are only written
when they actually differ from the global theme -- storing one that restated it
would later shadow a global change.
The navbar dropdown became a plain toggle for the theme sidebar, matching the
file-sheet button beside it, and theme selection moved into the sidebar as its
first control. The sidebar was reorganised the same way as Display: Lighting was
18 rows and every third-level group in the file, so it split into Environment /
Lights / Ambient / Hemisphere, each with its enable switch tight against its
heading rather than marooned at the far edge. The per-light Enabled row moved
into the Lights heading and acts on the selected tab's light. Surface's colour
grading split out, and the Preset/Theme and Backdrop/Style double subtitles are
gone. 32 control rows to 25, three nested sub-subsections to none. No settings
were removed.
## Panels
The file sheet and the theme sidebar are the same right-hand panel with
different contents, so they now share one open flag, one width
(fileSheetWidthPx), one resize handle and one viewport inset. Opening either
replaces the other and closing one leaves nothing open, where before closing the
theme sidebar re-revealed the file sheet. The resize handler kept gating on the
file sheet specifically, so the theme sidebar could not be resized at all.
## Removed
?appearance= and its resolution path; the saved-theme library API; edge
contrastMode (defined, normalized, never read) in cadjs, implicitjs and
CadViewer; the legacy-Cinematic migration apparatus, which only ever upgraded
stored custom themes; legacy preset id aliases, file-sheet section ids, and
material/edge-class compat shims. The snapshot CLI's --appearance flag is
untouched: it resolves themes by name and never builds a viewer URL.
Verified in the browser: sidebar exclusivity, preset selection and reset,
customization producing "None", per-light toggles, and both panels sharing a
resize. 748 JS tests pass; viewer build and docs build clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* cadjs/cadgen: fix silent-wrong-output bugs found building a large assembly
Six issues surfaced while building a 1400-occurrence model. Each produced wrong
output or an unbounded hang with no error, so none were visible without
deliberately going looking.
cadjs
- cadScene: a composed (component-GLB) package no longer falls back to the
merged whole mesh. Its top-level arrays hold each unique COMPONENT's geometry
in the component's own frame; placement lives solely in the per-occurrence
transform. Dropping to the fallback drew every shared component once at the
origin, so parts authored in world space looked fine while genuinely placed
parts landed elsewhere - a car lost all four wheels and grew one under its
middle whenever the STEP parameter module was switched off, and because the
tyres are the only thing touching z=0 the car then looked like it floated.
- stepModule: features accept `names:` matching occurrence labels. Occurrence
ids are positional and shift when a part module's child count changes, so a
sidecar pinned to #o1.13.36 can silently start driving a different part; a ref
that matches the WRONG occurrence was indistinguishable from a correct one. A
declared name that matches nothing now reports `missing`.
- renderOptions: the stage floor sits at world z=0 and follows the model only
downward, matching resolveRuntimeModelFloorZ. It was glued to bounds.min[2],
which silently re-grounded every model and meant snapshots could never agree
with the viewer about whether something was grounded.
cadgen
- color: new srgb() helper. Color channels are read as LINEAR RGB, so
Color(0.5,0.5,0.5) displays as ~#BCBCBC and palettes picked off hex came out
washed out with nothing to explain it.
- interference: new pairwise clash detection (AABB pre-reject, volume tolerance
so shared faces read as contact rather than interpenetration).
- generation: warn when an explicit --mesh-tolerance is far finer than the
size-adaptive floor. That override is legitimate but silent, and 0.02 looks
like a safe default to pass - on a 5.4 m model it is 80x finer than the floor
and turns a 15 s build into a six-minute one.
skills/cad
- cadgen_daemon/client: bound the request read. The daemon handles requests
strictly sequentially, so a client connecting behind a long (possibly
orphaned) build was accepted by the listen backlog and then waited with no
timeout at all. It now falls back to a cold run and says why.
- inspect: new `interfere` subcommand. Nothing in the toolchain could answer
"do any two parts occupy the same space?", and the documented alternative -
eyeballing a transparent render - cannot establish the absence of a clash.
- snapshot runtime: regenerated via bundle-skill.sh cad (carries the floor fix).
- docs: colour rules, sys.path not surviving into gen_step(), loft ruled=True
fallback, ShapeList accumulation, corrected __cadgen__ path and cad-viewer
link guidance.
Tests: 15 new (3 cadjs suites, 2 python). Each cadjs fix is mutation-verified -
its suite fails when the fix is reverted. Full runs: cadjs 449/449, python 8/8
suites, bundle and symlink checks clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* models: mid-engine hypercar one-shot
A 1478-solid concept car built from one master surface, with a parameter
sidecar driving a dihedral synchro-helix door and a staged exploded showcase.
Surfacing
- surfaces.py is the single source of truth: proportions, section curves and
datums as smooth 1-D control curves of X. Body panels are NOT modelled
individually - they are cut out of one master shell by region intersection
with a 5 mm gap, so highlight and reflection lines cross every shutline
exactly and each panel gap is real geometry rather than a drawn line.
- Each section is three spline bands, so the shoulder and rocker are true
tangent discontinuities. That is the right design for a crease, and it also
tessellates exactly, where a tight-radius blend makes the specular highlight
sparkle.
- The greenhouse is a second loft trimmed against the body, with the DLO cut by
plan-view curves so the glass has a real shape rather than square corners.
Systems (13 groups, o1.1..o1.13): body, glazing, lighting, chassis, front and
rear suspension, wheels, brakes, powertrain, interior, aero, door mechanism,
detail parts.
Motion (hypercar.step.js)
- doorAngle drives a true helix: rotation and axial travel coupled through one
lead constant, so the door rotates 62 deg outward while sweeping 299 mm up and
80 mm forward. The constants are read from the mechanism itself (hinge.py
exports HELIX_AXIS_ORIGIN / HELIX_AXIS_DIR / HELIX_LEAD_MM_PER_DEG /
DOOR_SWEEP_DEG), so the door and the carrier that drives it share one
kinematic definition rather than being eyeballed to match.
- explode separates by system along per-part vectors, with the chassis held
still as the spine. The showcase animation stages it as a strip-down and
gives the V12 its own nested sub-explode. Every animation is an exact loop.
Validation
- All 1478 solids: 0 invalid (BRepCheck), 0 free edges, 0 non-manifold edges,
0 zero/negative volume.
- Door sweep: 0.0 mm^3 boolean intersection against the static structure at 41
sampled positions across the full travel, not just the endpoints. Finding
that required replicating the sidecar's helix in Python; it caught four real
interpenetrations no visual review had shown, the largest 136,501 mm^3.
- gen exits 0 with no part skipped.
Derived artifacts are excluded: the .step is regenerated by gen --write-step,
renders by the snapshot CLI, and __cadgen__ is gitignored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* cad/cadjs/viewer: fix silent-wrong-output bugs found building an F1 assembly
Seven issues hit while building a 993-shape, 28-child car. Most fail by
producing plausible-but-wrong output rather than an error, so none were
visible without going looking.
snapshot CLI
- A job's own `appearance` string never went through load_appearance_option,
so a theme FILE PATH — which the CLI help explicitly promises works, and
which does work as the --appearance flag — fell through to a saved-theme-id
lookup, missed, and silently rendered on the default workbench theme. Because
the resolved id was then `workbench`, the size-profile logic also quietly
switched to diagnostic dimensions. Exit 0, no warning. A bad path now errors.
- APPEARANCE_OPTION_KEYS was missing `colorMode` and `projection`, both of which
normalizeThemeSettings() consumes and the help describes as theme traits, so
an authored theme carrying either was rejected as malformed. `edges` stays
excluded — that separation is real and tested.
- A rejected key that is valid in the OTHER payload now says where it belongs
("unsupported keys: edges; edges belongs in display JSON") instead of reading
like the file is broken. Underscore-prefixed keys are treated as comments;
JSON has none of its own and an authored theme needs to explain its numbers.
- Animated jobs preflight frames x pixels and warn above ~120 Mpx. Past that
the headless browser is killed mid-run (TargetClosedError) and the render is
lost after doing all the work; measured 252 frames @1000x740 dies, 203
@860x645 is fine. Advisory only — the ceiling is machine-dependent.
cadjs
- A component GLB can 404 while a concurrent gen swaps the package directory.
Fetches now retry 404s three times with backoff; non-404s fail immediately.
The final error names both causes (rebuild in flight vs stale descriptor).
Partial: a rebuild that genuinely changed geometry still needs the descriptor
re-read, which requires a descriptor URL this function does not have.
viewer
- A Viewer resolves paths against its OWN served root, so pointing one from
another checkout at a worktree path reported "file does not exist" — blaming
the model for what is a root mismatch. It now distinguishes the two, names
the root, and explains the cause.
- Parameter panel: removed Copy/Paste parameters (two separate hand-rolled
copies), and Reset is no longer nested inside the parameter-list branch, so a
model whose only control is an animation can still reset to defaults — the
animation row's own Reset restarts playback, which is a different action.
docs
- build123d-modeling: Plane.rotated() composes in WORLD axes, with measured
numbers (a 20 deg "twist" on a section plane moved the trailing edge 68 mm
sideways and 0 mm up while staying valid and watertight); validity is not
positive volume; self-intersecting section wires as a loft failure mode.
- parameters: a sidecar must be one self-contained file — only the path a
descriptor names as paramsPath is served, so a sibling import 404s in the
browser while resolving fine under node.
- AGENTS: "one instance serves any folder" qualified to "under its own served
root"; vite's transform cache can outlive HMR and a hard reload.
Tests: 6 added (3 python for appearance resolution, 3 cadjs for the retry).
Full runs: cadjs 452/452, viewer 262/262, python cadgen 78 + cad skill 312 and
7 other suites OK. bundle.sh --check clean after bundle-skill.sh cad; dev
symlink layout restored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* models: F1 concept car one-shot
An original modern ground-effect Formula 1 single-seater. No team, livery,
logo or sponsor marks. Red painted skin over structural carbon, gunmetal
exposed metal, one warm-white accent.
Source only — the render package is generated on demand into the gitignored
__cadgen__ cache, and no .step is exported.
Package: 5672 x 1998 x 950 mm, 3600 mm wheelbase, 18" rims, 305/720 front and
405/720 rear slicks. 993 shapes, 9528 faces, 28 assembly children.
Structure
- f1_parts/spec.py is the contract: coordinate system, package dimensions,
suspension hardpoints, the DRS four-bar and the palette. Every part module
reads its numbers from there, so a shared datum changes in one place.
- f1_parts/lib.py is the surface vocabulary: one airfoil family for every
aerodynamic surface, one tapered blade family for every member in the
airstream, one body-loft path for sculpted bodywork. That shared vocabulary
is what makes the front wing, floor fences, brake ducts and beam wing read
as the same car.
- 13 part modules, one per subsystem, assembled by f1.step.py in a frozen
occurrence order the sidecar addresses.
f1.params.js — parameter sidecar, one self-contained file (only the path a
descriptor names as paramsPath is served, so a sibling import would 404).
- drs 0..1: the flap swings about its visible pivot and a bellcrank drives it
through a real four-bar, solved as a circle-circle intersection and
branch-locked to the closed pose. Link length holds to ~1e-13 mm across the
full 64 deg travel with no branch jump.
- steering -1..1: parameterised by RACK DISPLACEMENT, not by wheel angle. The
rack is one bar, so both wheels take the same travel and each wheel's angle
is then solved against its own fixed-length track rod by bisection — the two
sides differ, which is where the ~0.5 deg of anti-Ackermann comes from.
Pushrods and rockers deliberately do not move: both front ball joints sit on
the steer axis, which is why steering does not disturb them.
- explode 0..1 and engine 0..1: staged teardown, then the power unit itself
into 12 subsystems about a crankcase that stays put. Engine subsystems are
addressed BY NAME, not by occurrence id, because positional ids shift when a
part module's child count changes.
- showcase 0..1: one loop-closed timeline driving the above. showcase(1)
equals showcase(0) exactly, so the viewer's looping playback is seamless by
construction rather than by trimming frames.
f1_stage.appearance.json — presentation stage. Satin, not piano-black: an
earlier glossy version had every blind critic independently describe the car
as a "soft glossy blob" because the specular sheet was swallowing the
front-wing cascade, the sidepod undercut and the suspension blade sections.
Validation: gen exits 0; inspect refs --facts --planes --positioning reports
ok with no errors or warnings across 993 shapes; both mechanisms verified
numerically before any geometry depended on them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* viewer: zoom baseline, origin axis, copy filename, first-run reference tip
Zoom read 100% for a model that opens mid-animation, because the baseline
was whatever framing the camera happened to land on. It now records the
at-rest model radius and scales the captured baseline by base/fitted, so
100% always means "framed to the model at rest" — a table whose sidecar
opens it fully raised reads 83%. Reset and fit frame the current parameter
pose rather than runtime.modelBounds, which is overwritten with at-rest
bounds after load and so cropped a posed model.
Grid and origin axis:
- faint floor grid on the Light/Dark themes
- an origin axis running infinitely up and through the floor, with its
colour/opacity controls inside the Grid section
- both depth-tested, so a model surface in front of the axis hides it
instead of being drawn over
Theme preset dropdown: two-box swatch (backdrop + default part colour),
Custom as the trigger's label rather than a list entry you could pick, and
"System" without the resolved-preset suffix.
Copy Filename in the file context menu, above Copy Path / Copy Relative
Path, following the VS Code grouping.
First-run tip above the "Copy #…" button explaining that references paste
into prompts. It fires on any selection that yields a reference, only its X
retires it, and ?resetTips=1 re-arms it for demos.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* cad: add inspect validate for per-solid geometric soundness
Nothing in the toolchain checked whether a solid was actually sound.
`inspect refs --facts` reports counts and bounds, and its "ok" field is a
command-success flag covering ref resolution only -- a five-face open box
reports "ok": true with faceCount 5, and a solid with inverted orientation,
which renders as a hole in the world, reports "ok": true as well. No
BRepCheck_Analyzer, ShapeAnalysis_Shell or BRepAlgoAPI_Check appeared
anywhere in the tree, so every caller that wanted this hand-rolled it.
Adds cadgen.validity plus an `inspect validate` subcommand reporting
invalidTopology, openShell, nonPositiveVolume, noSolid and selfIntersecting
per occurrence.
Two subtleties the implementation is built around, both verified against
positive controls:
- BRepCheck_Analyzer returns True for a reversed solid, so topological
validity alone cannot catch an inverted body. Only the sign of the volume
can, and build123d's Shape.volume is signed.
- Volume is measured per solid, never aggregated. A +1000 and a -1000 inside
one compound sum to zero, so anything reading a compound's total volume
sees nothing wrong.
ShapeAnalysis_Shell.CheckOrientedShells defaults alsofree=False, in which
case free edges are never collected and HasFreeEdges() is always False; it is
called with alsofree=True or open shells pass silently. Self-intersection is
keyed on the BOPAlgo_SelfIntersect status rather than IsValid(), which is also
False for several unrelated BOP faults.
cadgen.validity is imported lazily inside the handler so `inspect --help`
stays free of OCP, per test_inspect_help_does_not_import_heavy_cad_modules.
Also documents the +X revolve seam and silent fillet-ladder degradation in
references/build123d-modeling.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* cad/cadjs: fix four silent-wrong-output bugs in snapshot render jobs
Continues the class of bug ede0e713 set out to kill: operations that produce
plausible wrong output, or no output, instead of an error.
outputs as bare strings
normalize_common_job validated only that `outputs` was a list, never its
element types. A string element was coerced to {} and the caller's path
discarded, so the render ran to completion and then wrote nothing, printed
nothing, and exited 0. The .gif guard immediately above already read a bare
string as a path, so the two disagreed 40 lines apart. Strings are now
accepted as {"path": s}.
outputs entries with no path
Same silent no-op via a different route ({"camera": "iso"}). Now an error in
every mode except list, which legitimately carries no output files.
job-level display strings
ede0e713 routed a job's `appearance` string through load_appearance_option
but left the identical pattern on `display`, which fell through to
normalize_common_job and was replaced with {"mode": "solid"}. A mode name, a
path to a display JSON, and an outright typo all silently rendered the
default. Resolved in resolve_render_job so one insertion covers --job, stdin,
arrays and {"jobs": [...]}.
Behaviour change: a typo'd display string now exits 1 rather than rendering
the default. No in-tree job, doc example or fixture uses the string form.
appearance round-trip and inert colorMode
cloneThemePresetSettings() emits modeColors unconditionally, so it is part of
the settings shape by construction, but it was not in APPEARANCE_OPTION_KEYS
-- an unmodified clone could not be passed back to --appearance without
hand-stripping a key. Separately, resolveAppearanceSettings applied
resolveThemeSettingsForColorMode only to saved-theme-id STRINGS, making
colorMode accepted-but-inert for appearance objects: "light" and "dark"
produced byte-identical renders. Both are needed; either alone leaves the
defect while still exiting 0.
Applying colorMode unconditionally is the identity for settings without an
explicit modeColors block, since normalizeThemeModeColors derives modeColors
from the settings themselves. Verified against the shipped theme presets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* viewer/docs: stellated-dodecahedron favicon, rendered from an implicit model
The favicon is now a render of a model in the repo rather than an imported
image, so it can be re-cut at any size or angle from source.
models/implicits/small-stellated-dodecahedron.implicit.js is the source: a
Kepler-Poinsot solid whose twelve pentagonal spikes are bounded entirely by
the dodecahedral core's own face planes. That makes the SDF two lines — with
g1 the largest plane distance and g2 the second largest, the core is g1 and a
spike is max(-g1, g2), needing no per-face neighbour table. Face normals are
generated from the icosahedron vertex directions rather than hand-typed.
Three stylistic knobs on top, all off by default in the mathematical sense:
- sharpness tilts the side planes away from the spike axis (offsetting them
instead only fattens a spike until it degenerates into a prism)
- tipFlat intersects with a large dodecahedron, so each tip is sliced square
across its own axis, giving a pentagonal flat
- tipJitter varies that cut per spike from a seeded xorshift, never
Math.random, so the solid is identical on every load
Also lands three more implicit solids built while identifying the shape:
the twenty-spike stellated icosahedron, Escher's solid (first stellation of
the rhombic dodecahedron), and a thick Mobius donut band. None is referenced
by the favicon; they are gallery pieces alongside the existing curiosities.
Icons carry six real sizes (16-256) cut from a 1024 master, cropped to the
alpha bounds and recentred. The ?v= cache tag moves with the artwork —
without it browsers keep serving the old icon, since the filenames never
change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* viewer: navy tile favicon, and title the tab "text-to-cad | <filename>"
Sit the stellated dodecahedron on a rounded tile of the dark workbench navy
with ~17% padding, rather than shipping it on transparency. A bare
transparent icon loses its darkest spikes against dark browser chrome; the
tile guarantees the same contrast everywhere, and at 16px it reads as a
deliberate app mark rather than a floating shape.
The tile uses #242e3a — the workbench dark theme's own gradient start rather
than an invented shade. It sits just above the luminance of Chrome's dark
chrome (#202124), so the tile reads as a soft plaque instead of a hole.
The tile is drawn 4x oversized and downsampled — PIL's rounded_rectangle
aliases badly when drawn straight at 16px.
The tab title moves from "CAD Viewer" to "text-to-cad", keeping the existing
" | <filename>" suffix. The string now lives in one place as
workbench/constants.DOCUMENT_TITLE, imported by both the bootstrap and the
workspace, with index.html carrying the same text so the tab is correct
before hydration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* viewer: pan tool, favicon tile, and a cursor the pose picker no longer steals
Adds a Pan tool beside Select. Left-drag pans while it is active; right-drag
stays pan either way so the existing gesture keeps working. Picking is
suppressed for the duration, since otherwise releasing a drag selects whatever
the pointer happened to finish over.
Two normalizers silently swallowed the new mode, both collapsing anything that
was not DRAW to REFERENCES: the tool-select handler (so the button did nothing)
and the per-file session state (so it would not survive a reload).
The pan cursor flickered because the URDF pose picker's pointer-move handler is
bound for every file kind, and its inactive branch reset the shared canvas
cursor on every move. It now only releases a cursor it actually set, and clears
to "" rather than claiming "auto". The cursor closes to grabbing for the length
of a drag, with the release listener on window so a drag ending off-canvas does
not leave it stuck closed.
Reset also recovers the pan when the refit bails for want of usable bounds.
That fallback previously reset only the zoom, so the view snapped back in zoom
and angle while staying panned off-centre.
Favicon: the supplied star on the existing #242e3a tile at the same 18.4%
corner radius and the same share of the square, measured off the previous icon
rather than guessed. The drop shadow is dropped from the composite so centring
is measured on the solid. viewer/src/client/assets/favicon.png goes with it —
only the .ico is imported, so the PNG was a dead asset.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* viewer: one file icon per format, with a badge for generated models
A generated model now carries the icon of the imported file it stands in for,
so a .step.py assembly and an imported .step read alike in the file list. What
sets them apart is the small code badge in the icon's corner, which means "a
generator produced this" rather than "this is code".
STEP parts and assemblies share one icon. Part vs assembly is structure, not
file type, and the tree already shows it; two glyphs for the same format only
gave the reader more to learn.
Implicit models get their own mark instead of the generic code glyph they used
to share with generated STEP. Blend — two overlapping shapes — is the smooth
boolean an SDF model is built from.
Glyphs reworked for what each format actually is: a solid cube for STEP, the
triangle STL is made of, a printer for the 3MF print package, a toolpath for
G-code. All from lucide, which the viewer already depends on; there is no
standard icon set covering CAD formats, and a filled set would not sit well
next to a stroke-based UI.
The icon table, the lookup, and the badge were duplicated across the sidebar,
the breadcrumb menu, and the home list, and only the sidebar drew a badge — so
one file could read differently depending on where you saw it. They now share
one component.
Dead code removed: the ENTRY_ICON_KIND.ASSEMBLY and STEP_PART kinds, the
ArrowUpFromLine badge and "STEP-backed" title branches (entryStepSourceKind
only ever returns "python" or ""), and nine icon imports the top bar no longer
used.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* viewer: codify settings UI guidelines and enforce them across all sheets
Adds viewer/docs/settings-ui.md — the mandatory row grammar (inline /
slider / block / field grid), spacing and type tokens, switch placement,
select/segmented/button conventions, and state patterns for every
file-sheet tab and the theme editor — and refactors all settings
surfaces onto shared FileSheet primitives to match:
- Section headers become uppercase micro-labels, unmistakable from row
labels; gate switches move from beside the title to the trailing edge
so every switch in a panel sits on one right-hand control axis.
- New primitives: FileSheetStatusText, FileSheetField(+Grid),
FileSheetValueField, FileSheetButtonRow, FileSheetSegmentedControl,
FileSheetSelectRow, FileSheetColorPicker/Row, segmented tab classes.
- Row rhythm tightened to one token set (28px controls, 8px row gaps);
ad-hoc paddings and the var(--ui-text-muted) alias removed.
- Selects, color pickers, numeric inputs, read-only value boxes, button
rows, and empty/loading/error text unified; the Lights tab strip now
shares the segmented silhouette; degrees render as ° everywhere and
the radian-backed rotation/spot-angle sliders display degrees.
- SdfValueField/GcodeValueField duplicates collapse into
FileSheetValueField; MoveIt2 label-above forms move to field grids;
URDF joints drop invented Controls/Values headings.
No settings or behavior removed; 271 viewer tests pass and the
production build succeeds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: restyle settings section headings and rebuild the spacing scale
Follow-up on the settings-UI standard, all four points applied to
viewer/docs/settings-ui.md and every sheet at once:
- Section headings drop uppercase/tracking and the 10px size: they are
now 11px medium in full-strength sidebar-foreground, exactly the row
label's size and case. Header and label separate by color alone, so a
panel carries one type size and two roles.
- Spacing rebuilt around two numbers. A section's rule owns 24px down to
its heading and the section owns 24px below its last row, so the space
on either side of every rule is equal. Everything inside a section —
heading to first row, row to row — sits on one 12px rhythm, half the
section padding.
- Every section now carries a heading and every row a label, including
single-row sections which show both: Appearance/Preset, Model/Mode,
Material/Thickness, Backdrop/Type, Lights/Light. Button rows stay
unlabeled; a button already says what it does. URDF joints regain
Pose/Values headings and the theme's scene group becomes Render.
- FileSheetControlRow no longer emits an empty content div for rows whose
control lives in the trailing slot. That stray box carried the stack's
top margin, leaving every color row 4px taller than the switch rows
beside it and one section's bottom padding 4px off.
271 viewer tests pass; production build succeeds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: even out collapsed sections, tighten the settings spacing scale
- A gated section that collapses to its heading alone kept the heading's
bottom gap — the space held open for rows that were not there — so it
sat 16px under its own rule and 24px above the next one. The gap is now
conditional on having rows, and a collapsed section measures equal on
both sides like every other section.
- Scale reduced a step: sections 24px -> 16px of padding, rows 12px ->
8px apart. Three spacings for the panel, each half the one above it:
4px label-to-control, 8px row-to-row, 16px section-to-section.
- A stacked label + control is one item, not two rows. Making
trailing-slot rows 28px tall in the previous commit also stretched the
label line of block rows, pushing 12px of dead space between a label
and the select or slider it names. The row now takes the compact 16px
line when it has block content and the 28px line only when its control
sits in the trailing slot.
Measured in the running viewer: every section in the theme editor and the
Display tab reports 16px above and below, expanded or collapsed, with 8px
row gaps and 4px label-to-control. 271 tests pass; build succeeds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: mode controls become inline rows, not full-width strips
A control that picks one of several values is a settings row like any
other — label left, control right on the shared axis. A strip stretched
across the full width reads as a toolbar, and a column of them turned the
panel into a stack of unrelated widgets.
The rule, now in viewer/docs/settings-ui.md: 2-3 short options are a
content-sized segmented control on the control axis; four or more, or
labels long enough to crowd the row, are a select on the control axis. A
select is stacked full-width only when it is the surface's primary
control — the first row whose value reframes everything under it. There
are exactly three: Theme > Preset, Display > Mode, Joints > Group state.
Applied across the board:
- Inline segmented (new FileSheetSegmentedRow, "fit" sizing on
FileSheetSegmentedControl): Projection, explode Layout and Order, DXF
bend direction.
- Inline select (now FileSheetSelectRow's default; "stacked" is opt-in):
Backdrop Type, explode Direction, Environment Map, animation pickers,
every enum parameter.
- The five-light selector was a full-width Radix Tabs strip driving five
identical tab panels. It is now one select naming the target, with a
single set of rows belonging to whichever light it names — the tab
strip, its bespoke trigger classes, and the duplicated panels are gone.
- DXF thickness stepper and bend rows move onto the control axis too.
271 tests pass; build succeeds. Verified in the viewer: Projection sits
inline, Light switches targets and reveals the spot-only Angle/Distance
rows, and the Exploded section is one column of rows on one axis. The DXF
sheet has no fixture in models/, so it is covered by tests and build only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: one control height, dropdown-first mode controls, tighter triggers
- Every control in a sheet is 28px, but selects were rendering 32px: the
shadcn trigger carries data-[size=sm]:h-8, and an attribute selector
outranks a plain h-7 utility, so the override silently lost. Triggers
now use !h-7 and the compact px-2 / 14px chevron of their neighbours.
- Inline dropdowns hug their value between two bounds — never narrower
than the standard 80px control, never wider than 176px — so a column of
dropdowns, inputs and colour pickers shares one minimum size and one
right edge.
- Fixes a dropdown overflow: the trigger's max width was a percentage,
which resolves against a shrink-to-fit wrapper whose width the trigger
itself sets. A long option ("Studio HDRI 43") pushed its chevron out
through the border instead of truncating. Fixed max width plus clipping
and ellipsis on the value.
- Button groups give way to dropdowns unless the content is very tight.
Projection (Orthographic/Perspective), explode Layout and Order are now
dropdowns; the only segmented control left is a DXF bend's Up/Down.
- Section headings take 12px of clearance from their first row, up from
8px, so a heading reads as a heading and not as part of the group.
- The theme panel drops its own "Theme" title bar, which duplicated the
navbar toggle that opens it, and the first section is renamed
Appearance -> Theme.
271 tests pass; build succeeds. Measured in the viewer: selects, colour
pickers and value inputs all report 28px, inline triggers sit at the 80px
floor, and the Map dropdown now fits its own box.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: split the parameters tab, one reset per tab, wider row gap
- Rows sit 12px apart, up from 8px. Section padding and the heading's
clearance stay at 16px and 12px. Three spacings for the panel: 4px binds
a label to its control, 12px separates rows, 16px holds groups apart.
- The parameters tab was one flat list of enable switch, playback controls
and model inputs. It is now three sections — Module, then Animation when
the model has one, then Parameters — split by what the controls act on.
Playback sits above the inputs because it is what you reach for while
watching, and most models have none.
- One reset per tab. The tab carried two buttons reading "Reset": a
playback restart beside Play, and the parameter reset at the bottom. A
restart is not a reset, so the restart button is gone (scrub Time to 0
to restart) and the surviving reset is no longer gated on there being
parameters or an animation to sit beside — a loaded module can always be
reset. Its section names its scope, so it reads just "Reset".
- The animation clip picker is labelled "Clip" now that "Animation" names
the section above it.
Applied in both ParameterControlsSection and StepFileSheet, which carries
its own near-identical copy of this UI; deduping them is filed separately.
271 tests pass; build succeeds. Verified on an implicit model with an
animation: Animation then Parameters, 12px row gaps, exactly one Reset.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: STEP parameters tab uses the shared ParameterControlsSection
StepFileSheet carried a near-verbatim copy of ParameterControlsSection —
the Module/Animation/Parameters sections, the enable toggle, clip select,
play/pause, loop, time, speed, every parameter control type, and the
reset button. Every settings change had to be made in both files, and
they had already drifted once.
The two differences the copy existed for:
1. resolveStepModuleNumberControlStep turned out to be a pure re-export of
resolveParameterNumberControlStep — no behavioural difference, so no
prop was needed and the import is gone.
2. The animation time slider is a real difference: STEP reads live elapsed
time from the frame store (useStepAnimationElapsed) so the slider
tracks playback, where the shared component reads the runtime snapshot.
The default slider is now an exported ParameterAnimationTimeControl and
the component takes a TimeControl prop defaulting to it; StepFileSheet
passes its live-elapsed variant. Contract: { animationState, duration,
enabled, onScrub, label }.
Also adds an enableAriaLabel prop so STEP keeps "Enable STEP module" on a
toggle the shared component labels "Enable". Every other STEP string is
carried across by the existing props, and label: "STEP" reproduces the
old aria text exactly ("STEP animation time", "STEP animation speed").
Net 290 lines out of StepFileSheet.
271 tests pass; build succeeds. Verified live on an implicit model: the
Animation/Parameters split renders and the extracted time control is
live, advancing 0.00s -> 0.50s during playback. The refactored STEP sheet
mounts and renders (tree, tabs, no console errors) on hinge_2_dof.step.
NOT verified: the STEP parameters tab with real module data. No
generator-backed STEP model loads in this worktree — gear_rack_gripper
and 180_degree_flip_mechanism both hang at "Loading STEP tree..." even
though their artifacts generate fine (9s, phase "done"). Confirmed
pre-existing by reproducing the same hang with these two files stashed,
so it is unrelated to this change, but it does leave the STEP-specific
render path covered only by tests, the build, and code review.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: settings section headings match the navbar size
Headings were 11px, the same size as the row labels beneath them, and
separated from those labels by colour alone. They now sit at the navbar's
12px medium, so a sheet's headings and the chrome above them read as one
level of structure, and a heading separates from its rows by size as well
as colour. Row labels stay 11px muted.
Measured in the viewer: navbar 12px, section header 12px/500, row label
11px/500. 271 tests pass; build succeeds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: bring back the animation restart, named for what it does
The transport lost its restart when the parameters tab was consolidated
down to one Reset. The button is back, but called "Restart": it returns
playback to zero, where the tab's one "Reset" returns the parameters to
their defaults. Two controls, two names, no shared label for unshared
meanings.
The clip picker becomes a stacked full-width select with the transport on
the row beneath it — the clip is the Animation section's primary control,
since which clip is selected reframes the transport and the time and
speed rows under it. That makes four stacked selects in the panel, so the
"stacked exception" rule in settings-ui.md is now scoped per group rather
than per surface.
271 -> 275 tests pass; build succeeds. Verified in the viewer: Restart
returns Time to 0.00s and Play/Restart share the row below the clip.
Unrelated pre-existing bug found while verifying: playing then pausing an
implicit animation throws "Maximum update depth exceeded" and blanks the
app. It reproduces on c074eda8, before any of this settings work, and
needs a separate fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* models: add dxf examples folder (generated + imported fixtures)
Add models/dxf with simple 2D DXF fixtures for exercising the dxf skill
tooling: three gen_dxf() generator examples (standalone gasket, sheet-metal
flat pattern with bend layer, and a cadgen.flatten STEP projection) plus
seven small MIT-licensed imported .dxf files from gdsestimating/dxf-parser
and mozman/ezdxf covering R12/R2013 flavors, ARC/ELLIPSE/SPLINE/POLYLINE/
INSERT entities, and intentional validator-failure edge cases.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* models/dxf: flatten into one folder, document validator + viewer results
Merge the generated/ and imported/ subfolders into a single flat models/dxf
folder and expand the README to describe each file with both robustness
datapoints: the scripts/dxf --validate verdict and how the 0.4.0 CAD Viewer
flat-pattern renderer handles it (LINE/ARC/CIRCLE/LWPOLYLINE render; ELLIPSE,
SPLINE, legacy POLYLINE, INSERT, degenerate circles, and empty modelspace all
fail gracefully with typed error cards).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: single .dxf suffix for drawing-generator labels
filenameLabelForEntry rebuilt a generated drawing's label from its stem, but
normalizedEntryStem only knew the .step.py generator suffix — a <name>.dxf.py
entry stripped just .py, leaving a .dxf on the stem and rendering as
<name>.dxf.dxf in the sidebar, header, and tab title. Strip the .dxf.py
generator suffix like .step.py, with a regression test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: throttled values emit on change, not on identity
useThrottledValue compared by reference. The values it throttles are rebuilt
objects — a useMemo over animation state, a map of parameter values — so their
identity churns on renders where their contents did not change, and the hook
re-emitted values that were already current. An emit is itself a state update
that causes the next render, so each redundant emit bought another render of
the whole workspace. Measured while pausing an implicit animation: 53
consecutive emits carrying identical contents (elapsedSec 0.5, playing false),
a new object reference every time.
Emitting now requires a real change, compared shallowly for plain objects and
by Object.is otherwise. The resetKey path still force-syncs.
This is an efficiency fix, NOT a fix for the "Maximum update depth exceeded"
crash on implicit animation playback. That crash is pre-existing (it
reproduces on c074eda8, before any of this work), it is intermittent, and it
still reproduces with this change — A/B trials of play-then-restart crashed on
both this build and on the unmodified HEAD. It needs its own investigation.
Also considered and deliberately NOT changed: the animation handlers write
implicitAnimationStateRef from inside their state updaters, which is impure.
That write is load-bearing — the animation tick reads the ref synchronously
between renders to decide the next frame, so the handlers have to publish
intent before commit. Removing it is a behaviour change, not a cleanup.
275 tests pass; build succeeds. Playback verified still advancing and
resetting correctly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: exploded view is one slider driving a hierarchical radial explode
Replace the step-document exploded view (authored steps, auto hints, axis
modes, order, trails, depth, spread) with the standard automatic methodology
CAD tools use, driven by a single 0..1 slider:
- cadjs explodedView.js: walk the occurrence tree level by level; each child
group moves radially away from its parent's center in full 3D, distance
proportional to its assembled offset plus size-aware clearance; concentric
parts anchor the largest as the core and telescope the rest along their
long axis; a separation sweep pushes still-intersecting siblings further
out along their own ray so nothing collides at full explode; levels
cascade over the scrub with overlapping smoothstep windows.
- Display settings exploded is just {enabled, amount}; the legacy document
fields are dropped, and the snapshot CLI rejects them loudly.
- Display tab: Model section renamed View, with a compact Mode dropdown and
an always-visible Exploded slider (0% = assembled, no gate switch); Clip
loses the Flip/Reset buttons and min/max micro-labels.
- Explode trail lines removed.
- effectiveBoundsFromRecords composes the exploded offset, so fit/reset
frames the exploded pose.
- Cascade-aware explode/collapse animation duration in CadViewer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: stop implicit animation controls looping the app to a standstill
Playing and then pausing (or restarting) an implicit animation blanked the
whole viewer with React's "Maximum update depth exceeded".
The implicit animation handlers update state with functional updaters that
build a fresh object on every invocation. React re-invokes updaters, and it
only stops re-rendering once the result is Object.is-equal to the state it
already holds, so a single click never settled: measured on a pause, one
dispatch produced 52 committed state changes carrying byte-identical values
(playing false, elapsedSec 0, same activeId and speed) with a new object
identity each time. Every one of those re-rendered the entire workspace —
CadWorkspace, the top bar, the render pane and the implicit viewer all exactly
in step — until React hit its nested-update limit and tore the tree down.
Updaters now re-publish the object already in implicitAnimationStateRef when
the computed state is shallow-equal to it, which gives React the stable
identity it needs to bail out. That ref write stays inside the updater on
purpose: the animation tick reads it synchronously between renders to decide
the next frame, so the handlers have to publish intent before commit.
The STEP module handlers are not affected — they compute their next state
outside the updater and pass a plain value, which React never re-invokes.
Verified in the viewer: 8 consecutive play/pause/restart cycles all survive
where the first cycle previously killed the app, with time advancing on play,
holding on pause and returning to 0.00s on restart; scrub, loop and speed all
still apply. 275 tests pass; build succeeds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* repo: license copyright holder is Thompson Labs LLC
Every MIT LICENSE in the repo named the maintainer handle. Name the company
instead. skills/implicit-cad keeps its co-holder alongside.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* viewer: imported STEP files take a `<name>.step.js` parameter sidecar
A generated model declares its parameter/animation module in the render
package descriptor, because a `.step.py` can name any file it likes. An
imported `.step` has no generator, so the only way to give one parameters was
to write a wrapper generator that re-imported the STEP purely to declare
`params`. That made an imported file look generated, cost a build to produce
geometry the `.step` already held, and put a Python file in the catalog
standing in front of the model it wrapped.
The viewer now looks for a sidecar named after the STEP file plus `.js`, in
the same directory, and uses it when nothing else declared a module. Serving
stays gated: a `.js` qualifies only when the STEP it is named after exists
beside it, so this cannot serve arbitrary workspace JS. Viewer-only -- nothing
in the CAD pipeline reads it.
Deletes the three wrapper generators in models/mechanisms and renames their
sidecars to the new convention; all three animate as before.
Also runs viewer/server_py/tests from scripts/test/test-python.sh. Those 85
tests lived beside the package and no runner picked them up, so the new
coverage would not have run either.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* cad/snapshot: --params-path names the sidecar for an imported STEP
--params carries sidecar parameter VALUES; the sidecar file itself came only
from the render package descriptor's paramsPath, which cadgen writes from
gen_step(). An imported .step has no generator, so it could never be rendered
with parameters at all -- the only way was a wrapper .step.py that re-imported
the STEP purely to declare params.
--params-path (job field "stepParametersPath") names it directly, and the two
entry kinds are kept apart rather than merged:
imported .step/.stp --params without --params-path is rejected, naming the
flag; an imported file declares nothing to fall back on
generated .step.py --params-path is rejected; the sidecar belongs in the
generator, and letting the command line point elsewhere
would render parameters the model does not claim
The named file must exist, be .js/.mjs, and sit inside the model folder -- the
renderer serves assets relative to that folder, so an outside path has no URL.
All of this is settled before any artifact work, so a misuse fails at once
instead of after a build. Mesh and implicit inputs reject the field like they
already reject stepParameters.
Verified on models/mechanisms/gear_rack_gripper.step: stroke 0 vs 1 render
closed vs open jaws, and an animate sweep saves a 24-frame GIF.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* moonwatch: project skeleton — spec, finishing vocabulary, presentation theme, sampler
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* moonwatch: case + dial clusters; glb alphaMode fix; theme/palette tuning
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* moonwatch: bracelet cluster (57 bodies) + movement-base draft; end-link seat clearance
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* moonwatch: whole-watch assembly entry; smooth lug lofts; cased-movement frame map
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* moonwatch: movement base finished (83 parts); keyless works (12 parts); crowned bracelet links
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* moonwatch: movement composition entry + movement ring; deepen movement metals
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* moonwatch: movement finishing round 2 (constructive anglage, two-tone striping); raking key light; deep ruby/blued palette
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* moonwatch: jewel seating + anglage ribbons + rolled bracelet shoulders; straight-grain vocabulary
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* moonwatch: flat slotted screws, metallic materials pass, bright anglage ribbons, lever grain, domed jewels
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* moonwatch: bracelet brushed grain + continuous drape + clasp pusher clash fix
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* moonwatch: case grain + pusher remodel; neutral strong HDRI; darker steel albedo
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* BUGS.md: document per-part material ceiling
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* render pipeline: per-occurrence PBR material overrides + opacity; moonwatch material vocabulary
- cadgen: descriptor occurrences carry optional 'material' channels from a
cad_material attribute on source shapes
- cadjs: assembly part records thread material + opacity (toVectorArray
drops alpha, read raw); cadScene + surfaceMaterials apply per-part
channels over theme fallbacks
- snapshot + viewer runtimes rebundled; 441 cadjs tests pass
- moonwatch: central _materials.py label->PBR rules wired into every entry
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* moonwatch: chronograph works (50 parts, 7-column wheel, zeroed pose); material calibration; neutral env rotation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* moonwatch: dial register depth (0.5 recesses, rim steps, darker floors); case polished bevel ribbons
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* moonwatch: chronograph bridge + coupling cock; all chrono pivots capped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* moonwatch: spline bridge silhouettes; AR-coated display glass; center-link polish
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* moonwatch: narrowed anglage + full-coverage stripes + domed jewels; narrow lug polish band; matte brushed steel
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* moonwatch: bridge plates with wheel windows; polished metal dial furniture
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* moonwatch: white painted hands restored; near-black blued screws; final gauntlet state
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* BUGS.md: entry 9 final addendum — renderer-class ceiling confirmed after per-part materials
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* moonwatch: animation sidecar — escapement run loop, reveal, showcase explode
moonwatch.params.js declares three looping viewer clips (Play/Pause + Loop
in the Parameters tab): 'Escapement running' (beat-stepped escape wheel,
snapping pallet fork, sinusoidal balance, creeping train, chronograph
engaged), 'Reveal movement' (caseback stack fans away, crystal lifts,
straps part), and 'Showcase explode' (dial/bezel/crystal clear laterally,
caseback and bracelet spread, then the movement rises above the case,
flips bridge-side-up and fans its own tiers: chrono works, balance,
bridges, ratchet/crown, barrel, train, keyless). All loops are seam-free:
steps land on symmetric tooth/spoke increments and the showcase timeline
mirrors around its hold. gen_step now declares the sidecar via params=.
presentation_theme_void.json is the floorless presentation variant used
for the exploded-showcase renders.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* moonwatch: reveal clip separates running face from running movement
The 'Reveal movement' animation now lifts the bezel stack and crystal
clear, floats the dial straight up (hands still driven by the run loop),
and raises the movement into the case mouth where it flips
bridge-side-up — so the sweeping chrono hand on the face and the beating
escapement below it are visible at once, vertically separated. The clip
runs a 12 s open-hold-close trapezoid so the loop stays seamless; the
reveal slider remains a pure 0..1 openness timeline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* moonwatch: grand tour clip — reveal dwell plus gear-tier explode
Fourth animation combining the reveal and showcase: a new tour parameter
drives reveal's open choreography, dwells with the movement flipped
bridge-side-up and the escapement running (three full run loops per
cycle via effectiveRun = (run + 2*tour) mod 1, so the gears work
visibly longer), then fans the movement's own tiers showcase-style —
chrono works, balance, bridges, ratchet/crown, barrel, train, keyless —
while the floating face stack rises for headroom; the timeline mirrors
closed so the 24 s loop is seamless.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* cad/snapshot: reject unknown render job and output keys
Jobs and outputs now validate against closed key schemas instead of
silently dropping unrecognized keys. A top-level "hide"/"focus"/"refs"
names the selection-object shape to use; a "selection" nested in an
output says to split the view into its own job; anything else is
rejected with the supported key set listed. Previously each of these
produced a full-cost render that quietly ignored the request (BUGS.md
entry 7).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* cadgen: color bare-Compound leaves instead of warning and skipping
A boolean/chamfer chain can return a plain build123d Compound rather
than Part/Sketch/Curve. Exported alone — the per-component doc path —
its color was dropped with only a deduplicated "Unknown Compound type"
warning, so models shipped with silently washed-out parts (25 of 57
bracelet bodies in the moonwatch build). _create_bin_xcaf_doc now
explores such a leaf for its actual content (solids, then faces, then
edges) and colors it like the recognized types.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* cadgen-daemon: abort orphaned jobs when the client disconnects
Killing a warm-mode CLI left the daemon burning CPU on the dead
client's job while new requests queued silently behind it (requests are
strictly sequential). Each request now runs a liveness watchdog thread:
clients half-close their write side after the request, so read-EOF is
normal — the probe is an empty stdout chunk (a protocol no-op for every
client) sent every 0.5 s under a send lock shared with job output. When
a send fails the requester is gone; the daemon logs, unlinks its
socket, and exits, and the next invocation transparently spawns a fresh
daemon.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* BUGS.md: entries 2, 7 and the Compound-color entry marked fixed
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* cad docs: OCC/build123d failure modes from the chronograph build
Seven sections distilled from BUGS.md into the modeling reference:
multi-tool boolean batching (pairwise decay, overlapping-tool
pathologies, disjoint batches), near-tangent booleans silently
dropping material, the chamfer-on-tangent-chain segfault class with
constructive bevels as the fix, 2D sketch algebra decay and polygon
winding/mirroring, the align=(None,None,None) raw-datum footgun, dense
periodic spline profile kernel fragility, and BOP-check gating of
boolean results (plus the Part(solid.wrapped) volume==0 trap).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* moonwatch: prune one-off render jobs and local pnpm lockfiles
Keep only the durable render assets (presentation themes and the job
template); the per-iteration job JSONs were build scratch. The
pnpm-lock.yaml files were local-install debris — the repo tracks npm
package-lock.json. README daemon note updated for the orphan-abort fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: drop G-code visualization
The G-code toolpath preview was a diagnostic curiosity that never earned its
weight: a hand-written parser and preview-mesh builder in cadjs, a four-tab
file sheet, its own asset-load pipeline, and a `hasGcode` flag threaded through
the sidebar, breadcrumb menu, and icon status — all to render ribbons that
neither reslice, simulate firmware, nor replace the gcode skill's static
validation.
Remove the feature end to end. `.gcode` is no longer a viewer entry kind, so
the backend scanner stops listing it and the render/alert/status paths lose
their G-code branches. With G-code gone, `pathPreviewMode` was a pure alias for
`meshOnlyMode`, so it collapses to one name.
The gcode and bambu-labs skills keep slicing and printing untouched. Their
mandatory "hand the path to $cad-viewer" instructions do not survive, though:
the Viewer can no longer open a `.gcode`, so the gcode handoff section goes and
the bambu one narrows to `.3mf`.
The models/gcode fixtures existed only to feed this preview and are deleted
with it, along with the now-dead `*.gcode` LFS rules.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* WIP: squash-merge onto release/0.4.0 -- CadWorkspace still has syntax breaks
DOES NOT BUILD. Committed with --no-verify only so the conflict resolution is not
lost; the pre-commit hook correctly flags snapshot-render.js as stale.
All 11 merge conflicts are resolved except structural damage in CadWorkspace.js: a
line-level filter dropped every line naming dxf or gcode (correct -- our branch
removed DXF client loading, upstream removed G-code), but it also ate lines that
merely CLOSED a block while happening to name one: the ']);' of an array whose last
member was a DXF format, and a '} from "..."' import terminator. Two are fixed (the
DXF-preview import block, MESH_LOADED_RENDER_FORMATS); at least one remains ~line 1127.
Resolutions worth keeping:
- DxfFileSheet.js deleted: DXF stays on its baked preview.
- ImplicitFileSheet/ImplicitGraphicsSection: kept UPSTREAM's copies, which carry
their new settings-UI styling; ours would have reverted it.
- useCadAssets/fileSheetSections: the DXF-vs-G-code conflicts are parallel siblings
and NEITHER side survives -- we removed DXF client loading, upstream removed G-code.
- viewer-features.md: both sides were stale; rewritten for raymarched implicits with
live controls and no G-code.
- snapshot-render.js: generated -- regenerate via bundle.sh, do not hand-merge.
Next: finish the syntax repairs (build until clean), then viewer/cadjs/implicitjs
suites, bundle.sh, and a live implicit + STL load.
* WIP2: CadWorkspace merge repair -- ~8 syntax breaks fixed, more remain
Still does not build. Progress since WIP1 (12670ff4):
- dead import blocks removed (dxf/gcode buildPreviewMesh, emptied specifier lists)
- constants region reconstructed by hand: ARTIFACT_MANAGED_SOURCE_FORMATS keeps
STEP+DXF (DXF is still package-baked), STATUS_ONLY_FILE_SHEET_KINDS is
[mesh, dxf] (implicits raymarch so their tabs are live), statusOnlyFileSheetTitle
keeps its DXF case, MESH_LOADED_RENDER_FORMATS drops IMPLICIT
- duplicate selectedEntryHasImplicit / selectedImplicitMatches collapsed
- dead DXF client selectors removed (matcher, data, fileRef tail)
Next break: line ~2934 'Unexpected ?' -- another orphaned ternary tail from a
removed DXF/G-code branch.
ROOT CAUSE, for whoever finishes this: resolving CadWorkspace by filtering lines
that mention dxf/gcode is the wrong tool. It removes the right content but shreds
scaffolding (block closers, ternary arms, switch cases). The 19 conflict hunks need
resolving individually. Note upstream has SEVEN commits in this file -- G-code
removal plus the implicit-animation perf fix (507a0575), pan tool, favicon/tab
title, zoom baseline, and silent-wrong-output fixes (ede0e713) -- so taking our
side wholesale is also wrong; those features must survive.
Everything else in the merge is resolved and correct.
* rebase: finish the 0.4.0 conflict resolution
The squash-merge left four classes of breakage that neither the build nor the
test suite could see. Fixing them completes the rebase.
Dead client machinery, found with a Babel scope scan rather than by reading.
`gcodeStatus`, `selectedGcodeMatches`, `dxfStatus`, `selectedDxfMatches`,
`gcodeMode`, `getCachedDxfState` and `loadDxfForEntry` were all REFERENCED but
never bound: leftovers of deleting the G-code and client-side DXF paths. A
bundler treats an unbound identifier as a global and ships it happily, and unit
tests never render the hook, so a clean build and 279 green tests said nothing.
`useCadAssets` would have thrown on its own return statement for every entry —
the whole viewer, not just DXF. The DXF client state went with it: its setters
were destructured and listed in a dependency array but never called, because
DXF now renders from its baked GLB package.
`inout` parameters, which upstream's two new stellated models use. Neither
evaluator backend implements write-back into the caller's variable, so the
compiler now declines the program and the interpreter reports it by name. That
ordering matters: the compiler runs first, and compiling `inout` by value would
have returned wrong geometry silently — the one failure mode a fast path must
never introduce. GPU rendering is unaffected; it is baking and export that stop.
The two CPU gates skip those models through a named list, so the exclusion is
something somebody has to edit on purpose rather than a silent hole. The SDF
corpus baseline is regenerated for the 45 models that remain evaluable.
And the bake-resolution ceiling: `normalize_bake_resolution` mirrors mesh.js's
clamp because the number is HASHED, but the test still pinned the old 192
against an implementation that now clamps to 256 — the exact drift the mirror
exists to prevent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* viewer: drop the DxfFileSheet branch's orphaned closer
Deleting the sheet left its `) : null}` behind, one level up in the JSX. esbuild
downgraded it to a warning and built anyway, so the only symptom would have been
a literal ") : null" rendered into the workspace beside the viewport.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* viewer: gate the mesh load on the render-asset format
A built DXF sat at 92% forever. The package was on disk, `/__cad/artifact` said
`ready`, the catalog published the `glb` relation with a hash — and the client
never requested it. The load effect tested the SOURCE format against a literal
list of mesh formats, and a DXF entry's source format is `dxf`, so it took the
cancel branch every time.
Source format is the wrong question now. What the viewport loads is
`entryRenderAssetFormat`, which reports GLB for the package-baked kinds
precisely because their geometry lives in a baked GLB. Reading that instead
fixes DXF and keeps STEP, STL, 3MF and GLB on the same path.
MESH_LOADED_RENDER_FORMATS existed but was unused — the effect had been
open-coding its own list beside it. It is now the single list, and it carries
STEP explicitly: STEP is mesh-loaded too, but `entryRenderAssetFormat` reports
`step` for it because only DXF and implicit are package-baked.
Verified live against all five paths (implicit raymarch, DXF, STEP, 3MF).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* viewer: keep raymarched and robot entries off the mesh path
Reading the render-asset format fixed DXF and broke implicits: an implicit
entry's asset format is GLB (its package bakes one for export), so the mesh
effect started downloading model.glb and adding it to a scene the raymarcher
was already drawing. Two copies of the model, one of them redundant.
Robots have the same shape of problem — the URDF loader assembles them from
per-link meshes — and were only spared before because `urdf` happened not to be
in the literal list the effect used to test.
So the gate asks the narrower question directly: does this entry render its own
geometry? If it does, its source format stands and the mesh loader stays out.
Verified live: an implicit page now fetches its module and nothing else, and
implicit, DXF, STEP, URDF and 3MF all render.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* design: record the 0.4.0 rebase outcome and the imported-DXF gap
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* viewer: bind normalizeThemeSettings, and catch the whole bug class
Changing any theme colour crashed <CadWorkspace> with "normalizeThemeSettings is
not defined". The popover called it in `setThemeColor` and in a helper, but the
file never imported it.
722411a5 is where it came from: that commit deleted every caller of
`settingsSignature` and dropped the import along with them, but left the helper
itself behind — and missed the second call site in `setThemeColor`, which is
live on every colour edit. So the import goes back, and the helper goes: it has
had no callers since that commit.
Reproduced before fixing, and it took two attempts. Surface > Color looks like
the obvious target and proves nothing — it routes through `setMaterials`.
Backdrop > Color is a ColorModeField, so its onChange IS `setThemeColor`; that
one throws without the import and repaints the scene with it.
The guard is a whole-tree scope check rather than a test of the one function.
Every part of this bug was invisible to the tools we already run: `vite build`
emits an unbound identifier as a global reference without complaint, and the
unit suite never imports this module (JSX plus `@/` aliases do not survive plain
`node --test`). The same blind spot hid six more unbound references left by the
G-code and client-DXF removals. Babel resolves each reference against its
enclosing scopes, so shadowing, hoisting and JSX all behave and locals never
read as globals; the browser-global allowlist is explicit so additions are a
decision. A second test pins the positive case, since a scope walk that matched
nothing would pass the first test forever.
@babel/parser and @babel/traverse were already in the tree via
@vitejs/plugin-react; they are now declared, which refreshes the @babel/*
dev-only entries in the lockfile to 7.29.7.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* models/dxf: reset the imported fixtures around closed area
The viewer renders a DXF by extruding its closed cut contours, so a drawing with
no enclosed area has nothing to show. Five of the seven imported fixtures were
exactly that, which made "imported DXF is broken" look worse than it is.
Removed: arc1 (one open ARC), splines (two open SPLINEs),
multi_insert_with_attribs (an INSERT grid of open flag symbols), minimal_r12
(empty modelspace), and circle_radius_le_0 — that last one has BOTH circles
degenerate, radius 0.0 and -1.0, not one valid circle as the old README implied.
Nothing under tests/ referenced any of them.
Added six with real closed area, all MIT, all verified by expanding blocks and
chaining segment endpoints rather than trusting entity names:
alu_extrusion_profile nine nested LWPOLYLINE chambers + hatch + dimensions
plate_four_holes 452 LINE segments that chain into a plate with 4 holes
nested_hole_shapes 16 closed boundaries, 10 hatches, holes within holes
square_and_circle outline + tangent inscribed circle on colored layers
block_square_in_circle smallest file needing block expansion
circles_ellipses_arcs closed ellipses mixed with open arcs
The two survivors keep their content and gain descriptive names
(polylines -> laser_text_outlines, ellipse -> overlapping_ellipses); upstream
filenames are recorded in the README so provenance survives the rename.
plate_four_holes already builds and renders end-to-end on today's pipeline —
it is pure LINE, the one entity type the parser reads. That is the useful
result: the package/GLB path is sound and the gap is entity coverage alone.
The other five still fail on DIMENSION, INSERT, ELLIPSE, POLYLINE and HATCH.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* dxf skill: adopt the STEP CLI shape (gen + artifact)
The DXF skill had one script, named after its format, which was only STEP's
`gen`: a generator runner. Its own SKILL.md documented the consequence — an
imported `.dxf` "is never a dxf CLI target" — even though the viewer had been
building imported drawing packages on demand the whole time.
STEP splits on who the source is: `gen` runs generators and owns the native
`.step` output; `artifact` takes ANY model, "especially imported", and builds the
package the viewer builds. DXF now splits the same way.
- `scripts/dxf` -> `scripts/gen`, matching cad and implicit-cad, which both name
scripts after the verb. Safe as a module name: test-python.sh runs each skill's
tests in its own process with only that skill's scripts on sys.path, and an
installed skill stands alone.
- `scripts/artifact` is new and is the entry an imported `.dxf` never had. It is
a thin wrapper because the engine was already symmetric: build_dxf_artifact's
docstring says it "Mirrors cadgen.step_artifact.build_step_artifact ... and
takes the same two inputs STEP does".
- `--snapshot` retired. It wrote a 2D SVG into the package, which collided with
what snapshot means everywhere else in the repo (a 3D render) and outlived the
viewer's 2D view. `render_drawing_snapshot_svg` and `write_drawing_snapshot_svg`
go with it; `build_dxf_render_payload` stays, since validation still uses it.
No `export` and no `inspect`: a drawing's mesh exports are not a thing the skill
needs, and entity inspection stays with ezdxf.
Verified both source kinds end to end — an imported plate_four_holes.dxf and a
generated gasket_plate.dxf.py each build a package with a baked preview.glb.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* dxf skill: add scripts/snapshot on a shared render core
The DXF skill gets its own snapshot CLI, which forced the question of where the
render machinery lives: a skill may not import another skill's code, and copying
the CAD skill's driver would have duplicated the Playwright plumbing and the
camera/appearance handling, then let the two drift.
So the format-agnostic half moves to cadgen, where both skills already vendor:
cadgen/snapshot_core.py headless driver, job normalisation, mesh path,
output writing -- 68 definitions, moved verbatim
scripts/bundle/lib/ the render.html + snapshot-render.js build, so
snapshot_runtime.sh both skills bundle the IDENTICAL browser bundle
Nothing STEP-specific moved: topology, selectors, parameter sidecars and the
argument parser stay in the CAD skill, which is still the core's largest caller.
The one thing the core cannot know is where the browser runtime lives, since each
skill bundles its own copy, so runtime_dir is passed in.
The DXF CLI itself is small, because a drawing snapshot is a package build plus a
mesh render: resolve the input, make its package current, hand preview.glb to the
shared mesh path. No selector/parameter/section/exploded options — a drawing has
no CAD topology — so --mode is view or orbit only.
Verified by rendering, not just by tests: the CAD skill still renders
mounting_plate.step.py correctly after the extraction, its bundle output is
byte-identical (bundle-skill.sh cad --check clean), its 83 snapshot tests pass,
and the DXF CLI produces both a PNG of an imported plate_four_holes.dxf and an
orbit GIF of a generated gasket_plate.dxf.py.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* viewer: say WHY a render-artifact build failed
Most drawings in the viewer reported "The selected entry is listed in the CAD
catalog but no renderable mesh data could be loaded for it" — which names no
cause, and suggests a rebuild for files that can never render until the parser
learns a new entity. The reason existed at every step and was dropped twice.
The Node builder's stderr was INHERITED, so its diagnostic ("Unsupported DXF
entity HATCH") went to whatever console the producer owned. For a viewer build
that is a server log the user never sees, and the raised error told them to
"see stderr above". It is now captured and echoed — echoed so a CLI run still
streams live, captured so the error can carry the reason. Draining runs on a
thread because a builder that fills the stderr pipe while we block on stdout
would otherwise deadlock.
The client then discarded what did survive: the artifact-error branch was gated
on `sourceFormat === STEP`, so a DXF fell through to the generic mesh card. The
gate is now the artifact record itself, which every artifact-managed kind has.
A drawing that rejects an entity now says so by name, on the card.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* dxf: lie flat, show real filenames, keep STEP out of the drawings folder
Three of the reported issues, all small and independent of the parser work.
ORIENTATION. Drawings stood on edge. The flat-pattern mesher builds Y-up, but
preview.glb carries cadOccurrenceId extras and the viewer's loader reads those as
"already CAD space", skipping its Y-up->Z-up conversion — so the drawing reached
a Z-up scene still Y-up. Converting in the preview writer keeps that convention
true rather than teaching the loader a per-format exception. The mapping is
(x, -z, y), not (x, z, y): the latter has determinant -1 and would mirror every
asymmetric profile, which a new test pins by feeding the three unit axes and
requiring a right-handed basis.
The bake identity goes to dxf-preview-glb-v2 with it. A v1 package is
geometrically wrong rather than merely old, and the version is what makes every
already-built package rebuild instead of silently serving edge-on geometry.
FILENAMES. The explorer, breadcrumb and tab rebuilt labels from a stem plus a
canonical extension, so `gasket_plate.dxf.py` displayed as `gasket_plate.dxf` —
indistinguishable from an imported `gasket_plate.dxf` beside it. Labels are now
the entry's real filename, preferring the recorded source path over `file` so a
generator that carries its logical output there still names the file you edit.
MODELS FOLDER. `clamp_plate.step.py` put a STEP entry in a drawings folder; the
scanner keys on that suffix. Renamed to `clamp_plate_profile.py` — still
path-loaded by the sibling generator, no longer a catalog entry.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* dxf: __cadgen__ caches only what it renders
A drawing package held two payloads: preview.glb and a drawing.dxf that was, for
an imported drawing, a byte-for-byte copy of the user's file one directory up —
hashed twice, and not even served (the entry already pointed at the source). The
cache was storing an input.
Now both kinds produce the identical package:
__cadgen__/models/<name>/
drawing.json descriptor
preview.glb the only payload
The DXF reaches the mesher on STDIN, never as a path. That is what makes the two
kinds indistinguishable inside the builder: the producer either reads the user's
file or serialises the document its generator just built, and the child sees the
same bytes either way. Written on a thread, because a drawing larger than the
64 KiB pipe buffer would otherwise deadlock against a child blocked writing
progress to a stdout nobody is reading yet.
Measured first, since this is an architecture change and should not be sold as a
performance one: reading the DXF cost 0.1 ms and writing it was indistinguishable
from serialising to memory, against a ~2 s build that is ~99% process and import
overhead. Removing the file saves nothing and costs nothing.
Downloading a generated DXF now runs the generator, as a .step.py download does —
about two seconds, on an explicit click rather than every viewport open. A
generated entry therefore has no static asset to link.
Thickness stops being baked. It was a frozen 2.0 mm in bakeHash that no user could
change and every edit invalidated; it is a render-time scale on the baked prism
now (v3), so the bake block is just the geometry contract and a slider cannot make
a cache stale.
Two things this turned up. `_open_package` only unlinked the descriptor, so a
payload removed from the format would linger in every existing package forever —
it now clears the directory, which is what keeps "the descriptor names its
payloads" true in both directions. And the freshness gate still demanded a `dxf`
payload: had that shipped, nothing would ever have been current and every open
would rebuild. Both authorities were updated together, as they must be.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* dxf: parse the entity set real drawings actually use
Six of eight imported fixtures failed on entity support alone. The parser read
LINE, ARC, CIRCLE and LWPOLYLINE; everything else threw. Now:
ELLIPSE sampled, closing exactly on itself when full
SPLINE de Boor over the knot vector, falling back to the control
polygon when the file declares a degree it cannot support
POLYLINE/VERTEX legacy form, whose vertices are SEPARATE entities running to
SEQEND, with bulges as on LWPOLYLINE
INSERT + BLOCKS block expansion with scale/rotation and the MINSERT grid,
composed through nesting and depth-limited against cycles
HATCH boundary paths only -- a hatch is a fill, and extruding its
pattern lines would produce hair rather than a part
Curves are lowered onto lines/arcs/circles rather than kept parametric, so the
1,300-line mesher is untouched: it still sees the same contour soup and only the
parser learns new spellings.
Two behaviours changed beyond parsing, both about not failing over things that
are not the profile:
Annotation (DIMENSION, TEXT, MTEXT, ATTRIB, LEADER, POINT...) is SKIPPED. A
drawing is not unrenderable because it is dimensioned, and rejecting one over a
witness line is how a perfectly cuttable outline shows an error card. A genuinely
unknown entity is still reported.
An unclosed chain is DROPPED rather than fatal. Real files mix a closed profile
with open geometry -- a centre mark, a stray arc -- and refusing the whole part
over one dead end meant alu_extrusion_profile and circles_ellipses_arcs rendered
nothing despite having perfectly good contours.
Also fixes a mirror I introduced with the Z-up rotation: (x, -z, y) is a rotation
by determinant but flips the drawing about Y. Invisible on a symmetric plate;
obvious the moment the profile is lettering, which is how laser_text_outlines
caught it by rendering "LaserWeb" backwards. It is (x, z, -y).
All eleven fixtures now build, and holes -- including nested ones -- cut correctly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* viewer: 2D/3D toggle for drawings
A drawing gets its own toolbar pill, left of the shared one. 2D and 3D are a
property of what you are looking at rather than a tool that acts on it, so
grouping them beside select/pan/draw would have read as a fourth mode of the
same kind.
2D is the top-down view of the same 3D model, as asked -- no second renderer, no
second geometry path. It drives the camera through the view-plane widget's own
activators (`z` is its top face), newly exposed on CadViewer's imperative handle,
so there is ONE camera authority instead of a toggle with its own idea of where
"top" is that drifts from the widget's.
The mode is session state in the workspace. It describes how you are looking at
the model open right now, not a preference worth outliving the tab.
Only shown for DXF entries; every other format is unaffected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* dxf: Material and Bends settings tabs, with a working thickness control
Two tabs rather than two sections of one, and Bends only when the drawing has
them. Thickness is a property of the material the profile is cut from and applies
to every drawing; bends are a property of THIS drawing's geometry and most have
none. A tab that is empty for most files does not earn a permanent place beside
one that is always relevant.
Built against viewer/docs/settings-ui.md, which I should have read first and
which changed the panel materially. It anticipates this exact surface: segmented
Up/Down for bend direction is named there as the one sanctioned segmented
control, degrees are `°` never `deg`, units live inside the value string, and
helper prose is forbidden outright -- the first version had a two-sentence
explanation under Bends that is now a labelled read-only count row.
The thickness slider was inert when first tested, and worth recording why: the
prop was landing on FloatingToolBar, not CadRenderPane. Both take `renderFormat`,
so a replace anchored on that line hit the wrong element, and the toolbar
silently ignored a prop it does not declare. Nothing errored -- the slider moved,
the readout tracked it, and the geometry never changed. Instrumenting the effect
is what found it: `scale: 1` on every fire, including the mount where 2 mm
should already have read 2.
Bend detection runs end to end: the builder counts bend lines, previewStats
records the count, the scanner surfaces it on the entry, and the tab appears only
above zero. Verified from the live DOM -- u_channel_bracket ["Material","Bends"],
gasket_plate ["Material"].
models/dxf/u_channel_bracket.dxf.py is new: two parallel bend lines, a web slot
and four flange holes. Two bends is the case an L-bracket cannot exercise -- a
segment bounded by bends on BOTH sides rather than by a bend and a free edge.
The bend Angle and Direction controls render and hold state, but nothing folds
the geometry yet: that needs the fold metadata carried into the bake, which is
the one remaining piece.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* dxf: live bending, flat-by-default thickness, and a generic plan mode
BENDING. A flat pattern's bend lines are parallel and axis-aligned, so a fold is
an accordion: split the sheet at each bend X and rotate everything beyond it,
accumulating down the chain. Nothing is re-meshed, because the geometry past a
bend is rigid and only its placement changes -- which is why this can be a slider
and why the snapshot runtime gets it for free (same cadjs code, headless browser).
The bake carries one new fact: bendAxisX, the fold axes in the CAD frame the GLB
is written in. Not a bake SETTING -- a fact about the drawing, so it stays out of
bakeHash and no angle can make a package stale.
Three real bugs on the way there, all invisible to the build:
- `active`, not `isActive`: ToolbarButton switches variant on `active`, and an
unknown prop is silently dropped, so neither 2D nor 3D ever looked selected.
- FileSheetInlineControlRow takes `children`, not `value`/`trailing`, so the
bend count and the direction control rendered as empty rows.
- The fold effect is declared BEFORE the scene sync, so on a fresh model it ran
against an empty group and folded nothing. It now defers a frame and retries,
which is what makes the default 90 degrees show up folded rather than flat.
THICKNESS defaults to 0: a drawing IS a 2D profile, so the honest default is the
face with no material behind it. Zero would make the model matrix singular and
light the sheet as black, so it collapses to a hair instead -- sub-pixel at any
sane zoom, and the solid stays valid.
PLAN MODE is a generic camera lock on the viewer, not a DXF feature. Disabling
rotation and moving left-drag to pan IS the mode; the view cube is hidden because
it turns a camera that cannot turn, and the vertical origin axis with it. Zoom and
reset stay -- they work the same in a locked view. Any model can use it.
Verified end to end in the browser: the U-channel folds at 90 degrees with two
bend lines, thickness reads 0.0 mm and renders as a face, 2D shows selected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* dxf: make bending actually work — one transform, in cadjs, with dotted creases
The user could not see the fold or the fold lines, and they were right on both.
Deep review found four real defects stacked on each other:
1. THICKNESS AFTER FOLD. Thickness was a group Z-scale applied after the fold's
vertex rewrite. A folded flange extends in Z, so the 0 mm default's hair-thin
scale flattened every fold back into the sheet plane — the fold ran, and was
then erased. Thickness now scales Z BEFORE the fold, inside the same vertex
transform, which a node test pins as the module's reason to exist.
2. EVERY MODEL SQUASHED. The workspace passed drawingThicknessScale to the render
pane unconditionally, so the 0 mm drawing default was silently flattening every
STEP/STL/3MF model too. Drawing props are now gated to DXF entries at the
workspace — a drawing setting must not be able to touch any other format. Plan
mode is gated the same way, or 2D chosen on a drawing would lock the camera on
the next STEP file opened.
3. UNDER-FOLDED CHAINS. The fold decided "is this vertex beyond the axis" from
the FOLDED position. After a 90-degree first fold, the second bend's strip has
moved below the second axis, so it never received its rotation — a U-channel
folded into an L. Membership is now by flat X, rotating about each axis's
image under earlier folds (precomputed pivots), proven by the accumulation
test: two 90-degree folds bring a strip back over the top.
4. STALE PACKAGES. bendAxisX only exists in descriptors baked after the feature;
nothing invalidated older ones, so l_bracket_flat showed a Bends tab whose
slider could never act. Package schema v3 -> v4; every fixture rebuilt on the
mismatch alone, no --force.
The math now lives in cadjs/lib/dxf/foldPreview.js as pure functions over
position buffers — node-tested (10 tests: chain accumulation, direction sign,
thickness-before-fold, idempotence, guides riding creases) and shared with the
headless snapshot runtime by construction. CadViewer's two effects collapse into
one that applies the transform and builds the overlay.
The dotted bend lines exist at last: LineDashedMaterial segments at each bend
axis, spanning the sheet, elevated a hair above the top face, folded through the
same pivot chain so each stays pinned to its crease at any angle. Visible in 3D
and in the 2D plan view.
End-to-end, screenshot-verified: u-channel loads FOLDED at the 90-degree default
with 0 mm thickness; angle 0 shows the flat sheet with two dashed creases;
direction flips; 2D is top-down with dashes and no view cube; l_bracket folds
into an L; mounting_plate.step.py renders at full thickness.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* dxf: per-bend controls, mitered bends, flicker-free folding, shared tools
Five reported issues, each with a distinct root cause:
FLICKER. The transform effect restored flat positions in its cleanup and
re-folded on the next animation frame — every slider tick painted one flat frame
between the two. It now applies synchronously when the meshes exist (the rAF
retry survives only as the first-load fallback), cleanup only cancels a pending
retry, and the guide overlay persists with its buffer swapped in place instead of
being removed and re-added per tick.
THICKNESS FANNING AT BENDS. Crease vertices belong to both strips at once;
leaving them in place while the far strip rotates stretches the wall from thin to
thick around every bend. They now take the MITER — moved onto the bend's
bisector at offset z/cos(θ/2), the intersection of the two constant-thickness cap
planes — clamped at the 150-degree factor so the corner stays finite as the fold
closes. Continuity is pinned by test: at angle 0 the miter is the identity, so
the first slider tick cannot pop. (A "curved" bend style needs bend-allowance
tessellation baked into the GLB — the flat mesh has no vertices inside the bend
region to bend into an arc — deferred with that exact plan.)
PER-BEND SETTINGS. One {angleDeg, direction} entry per bend line, reset per
entry, shown as "Bend 1", "Bend 2" sections per settings-ui.md's flat section
list. foldPreview takes per-bend angles, and axis/angle pairs travel together
through the axis sort — bend 2 keeps ITS angle when the axes arrive unsorted,
which a test pins. This exposed that the old chain math was also wrong at equal
angles only by luck of symmetry.
SHARED TOOLS. Select/pan/draw were gated out of DXF in four places: the toolbar
trio, the render pane's pickable/drawing props, the CTA mode, and — the one that
made the first live test orbit instead of draw — drawModeActive in the workspace,
hard-coded to STEP. All four now treat a drawing as what it is: a mesh with one
part, on the same code path STEP uses. Verified by laying a stroke.
ZOOM AT THE TOP. ZoomControl extracted from CadViewer into its own module and
hosted leftmost in the shared top-right toolbar row for every format the main
viewer renders (STEP included); the bottom-right pill is gone. The viewer keeps
the camera math and reports the live percent up; the pill drives it back through
the imperative handle. Reset in 2D re-locks to top-down instead of returning to
the 3D default orientation. (The implicit viewer keeps its own pill for now — it
is a separate component with its own runtime.)
End-to-end, screenshot-verified: zoom | 2D/3D | tools ordering on both DXF and
STEP; Bend 1 at 74 degrees with Bend 2 at 0 folds an L, captured mid-drag with
no flat frame; 6 mm at a non-right angle shows constant thickness through a
mitered corner with dashed creases riding both bend lines; a red freehand stroke
lands on the drawing with the full markup toolbar.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* dxf: stop corrupting the mesh cache, zero the bend default, group bends properly
THICKNESS FLAKINESS — root-caused with a quantitative probe, twice, because the
first fix was aimed one level wrong.
The transform wrote vertices through the geometry's position attribute — and
cadScene's sourceMesh path wraps the mesh cache's OWN Float32Array with no copy,
so the shared cache's flat baseline was being mutated in place. First fix:
detach onto a private buffer at first touch, baseline snapshotted per MESH.
Measured result: still broken on reopen (scale 8 rendered 40 tall), because
geometries are CACHED ACROSS MOUNTS while meshes are rebuilt per scene — the
new mesh found no baseline and snapshotted the previous visit's 5 mm POSE as
flat. Baselines and the private-buffer detach now key on the GEOMETRY, whose
lifetime matches the thing being cached, with a per-apply seen-set so shared
geometry transforms once. Round-trip via the sidebar now measures scale 8 ->
extent 8, exactly. This also explains the generated-vs-imported asymmetry:
generated entries ride the sourceMesh cache path, imported ones mostly do not.
An identity fast path keeps non-drawings out entirely: a STEP model no longer
pays for baseline snapshots it will never use, and an identity run still
restores a drawing that was previously posed (tracked by a touched flag).
SCANNER SYMMETRY. Imported .dxf entries never carried bendLineCount/bendAxisX —
create_single_asset_entry read no descriptor at all, so an imported drawing
with bends had no Bends tab and could never fold. One helper now applies the
descriptor's previewStats facts for BOTH kinds; proven by exporting the
u-channel as an imported .dxf and reading identical facts off its entry.
BEND DEFAULT 0, not 90. A flat pattern IS flat, and the dashed lines already
say where it can fold; 90 was a demo value the drawing never asked for.
Drawings now load flat with their creases marked.
BEND GROUPING. New design-system pattern, documented in settings-ui.md before
the primitive existed (its own rule): "Repeated item groups" — one section
names the kind (Bends), FileSheetItemGroup renders each instance (Bend 1,
Bend 2) with an item label at the weight between section header and row label,
groups 16px apart with no rule. The sibling-sections version promoted an index
to a concept and filled the panel with rules.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: one-row bends, curved bend style, stacked drawing settings
Four-item feedback batch on the DXF drawing settings:
- Every bend is one row: the bend number is the slider label and the
Up/Down segmented control rides inline beside the angle value input
(settings-ui.md "Repeated item groups" single-row form).
- Fix the value-input select race: typing right after the deferred
auto-select no longer leaves the first digit selected to be clobbered
by the second. onChange clears the select flag and cancels the
pending rAF (FileSheetValueInput and ZoomControl).
- Material sits as the top half of the Drawing settings and Bends as
the bottom half whenever the drawing has bend lines — one surface,
no tab switch hiding half a two-group panel.
- Curved bend style, the new default: a Style dropdown (Curved/Sharp)
at the top of Bends. Curved re-meshes live from the package's cached
contours so the surface actually wraps the bend like sheet metal;
Sharp keeps the mitered vertex fold of the baked prism.
Curved needs the parsed contours at render time, so the drawing
package grows a geometry.json payload written by dxf-artifact.mjs
beside preview.glb (schema v5). Both freshness authorities —
cadgen drawing_package_current and the viewer's _drawing_payload_refs —
track it, and the scanner publishes a drawingGeometry relation for
both imported and generated entries.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: curved bends fold the right way and replace the baked mesh
Three fixes from viewer feedback on the curved-bend preview:
- Up folded down. The bend mesher folds "up" toward its own +Y, and the
only proper rotation into CAD Z-up that keeps the pattern footprint
un-mirrored, (x, y, z) -> (x, z, -y), necessarily sends mesher +Y to
CAD -Z. The viewer now hands the mesher the opposite direction, so the
UI's Up folds up on screen; Sharp already folded correctly and the two
styles now agree.
- Bending duplicated the model. The curved preview hid the baked mesh
with visible = false, but partVisualState re-asserts every part's
visibility from its own records on each selection/hover/effect pass
and re-showed the flat blank underneath the curved fold. The sync now
honours the dxfHiddenForCurved claim on the mesh (and its edge lines).
- The Up/Down toggle sits to the right of the angle input in each bend
row, not between the slider and the value.
E2e-verified on u_channel_bracket.dxf.py: 90/90 curved U folds up with
no duplicate and the baked mesh stays hidden through visual-state
passes; Down gives the mirrored Z-step; switching to Sharp removes the
curved preview and re-shows the folded prism.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: boxed default bend style, compact one-row bend controls
Settings polish on the drawing sheet:
- The mitered fold is the default again and is named Boxed (Curved
stays the alternative); "sharp" is gone as a value everywhere.
- The bend angle input drops to half width (w-12) — three digits and a
degree sign never needed the full 80px badge.
- Up/Down is now a pair of arrow-glyph buttons: FileSheetSegmentedControl
options accept iconOnly, keeping the label for the accessible name and
a title tooltip ("Bend up"/"Bend down") for the glyph. settings-ui.md
records when icon-only segmented options are allowed.
Also pins the worktree launch entry to --port 3247: the entry declared
port 3247 but never passed it to vite, which then served 3245 and the
preview kept re-pointing the tab at an empty port mid-verification.
E2e-verified: Boxed 90/90 folds the mitered U by default, switching to
Curved rounds the same bends, arrows select direction, and the narrow
input still commits typed values.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: curved bend guides on the material, DXF tab reset, tab rename
- Curved-mode dotted bend lines sat below the sheet: the bend mesher
elevates its one-sided guides over its own +Y face, and the viewer's
Z-up mapping turns that face into the underside. The mesher now takes
guideElevationSign (node-tested) and the viewer asks for -1, so the
guides hover on the face the user sees, riding each crease. Boxed
guides were already correct and are unchanged.
- One Reset for the DXF tab (settings-ui.md: outline + RotateCcw, full
row, one per tab): thickness, bend angles/directions, and style all
return to their defaults together.
- The drawing sheet tab is named DXF.
E2e-verified at 3 mm, 60/60: boxed guides at z 1.8/69.3 and curved
guides at z 1.54/70.2 (both above the top face; curved was negative
before), Reset restores 0 mm / 0 deg / Boxed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* design: plan folding the implicit raymarch into the shared CadViewer as a 4th render type
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* dxf: units, layers, text engravings, score lines, angled bends, K-factor
Six DXF-native capabilities, end to end:
- $INSUNITS honored: the parser scales every coordinate to millimetres
(inches, feet, cm, m, ...; unitless stays mm). Bake format v4 — an
inch drawing baked before this was 25.4x too small.
- Layers: the LAYER table's ACI colors and off-flags parse into the
layer summary; the DXF tab grows a Layers section with a color
swatch and visibility switch per layer. Hiding a cut layer live
re-meshes the solid; hiding the bend layer hides the dashed guides.
- TEXT/MTEXT/DIMENSION parse into flat text markings (anchor, height,
rotation, string; MTEXT formatting stripped) and render as
canvas-textured planes lying on the sheet, folded through the same
chain as the geometry, tinted by their layer's color.
- Open cut-layer chains and engrave-layer geometry become SCORE
polylines instead of being dropped, rendered as solid surface lines
riding the fold. The contour walk also extends backward from its
seed so one open polyline stays one chain.
- Arbitrary bend-line ORIENTATION in the boxed fold: foldPreview now
takes full 2D segments (any direction, non-crossing), with a
rotation-invariant general fold — membership by signed half-plane,
dependency-ordered pivots, 3D-mitred creases. The curved mesher
still requires vertical lines and only rejects bends that are
actively folding, so angled crease marks bake flat instead of
failing the package; the viewer falls back to the boxed fold.
- Curved bends gain sheet-metal parameters: inside Radius (0 = auto)
and K-factor rows appear under Style when Curved is selected and
feed the mesher's neutral-radius arc.
geometry.json is now dxf-geometry/2 (texts inside geometry, plus the
layer summary and units scale); DRAWING_PACKAGE_SCHEMA_VERSION 6
rebuilds every existing package once.
New fixtures: bracket_inches.dxf (imported, inch units + layer table +
TEXT), angled_tab.dxf.py (45-degree bend line), label_plate.dxf.py
(TEXT engraving + open score zigzag + layer colors).
Tested: 24 new/updated node cases across parseDxf/foldPreview/mesher
(incl. fold invariance under rotating the whole flat pattern), full JS
+ viewer + python suites green, and e2e in the viewer: inch plate at
true scale with engraving, label plate with text/score overlays and
working layer toggles, angled tab folding 90 degrees about its own
line with the anchored region numerically flat.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: implicit becomes a shared render type instead of a second viewer
The implicit raymarch now runs inside the shared CadViewer runtime as the 4th
render type alongside STL, GLB and 3MF, rather than in a parallel component
with its own WebGLRenderer, OrbitControls and camera stack.
The seam is cheap because the raymarch material already emits clip space from
its vertex shader and builds rays purely from camera uniforms: the fullscreen
quad joins the shared scene and takes the shared camera through onBeforeRender,
so the existing render loop draws it unchanged. What the deleted viewer
reimplemented — orbit/zoom speeds, damping, fit-to-bounds, reset, camera
transitions, perspective snapshots — is now the same code the mesh formats run.
Display behaviour is unchanged by design: implicits still raymarch their own
GLSL (their baked GLB remains export-only) and are still not artifact-managed,
per design/implicit-viewer-raymarch-restore.md. Only the host changes.
Implicits therefore gain the shared features they previously lacked: the zoom
percent pill, applyZoomPercent/resetView/view-plane presets on the viewer
handle, and the viewport context menu. The pass caps its own pixel ratio during
interaction through a new opt-in runtime hook that only ever caps DOWN, so the
mesh path is untouched.
Because the shader paints its own background and stage-floor shadow (matched to
the mesh stage on purpose), the three.js grid, stage and scene background are
suppressed while an implicit is on screen, gated at the single funnel each one
goes through.
Verified in the dev viewer with Metal-backed playwright: 46/47 implicits render
(menger-sponge is the pre-existing empty-field model defect); zoom in/out,
wheel, reset view and orbit all drive the raymarch; live parameters re-render
(gear thickness and carrier orbit both move the surface); theme switch retints
light to dark; STL, 3MF, GLB, STEP and DXF unaffected, and camera-on-reload
behaviour is now identical between implicit and mesh.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: cover the implicit re-fit gate with tests, record execution status
Extract the envelope helpers out of useImplicitRaymarch into implicitFit.js so
they can be tested without React or a WebGL context, and cover the part with
real regression surface: the quantised bounds key that decides whether a model
change is worth a re-fit. Getting it wrong either fights the user's camera every
animation frame or never re-fits at all, and the quantum has to scale with the
model radius.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: DXF settings persist per file in sessionStorage
Thickness and bend style had no per-entry handling at all, so setting
7 mm on one drawing silently carried into the next one opened. Now the
whole DXF settings set (thickness, per-bend angles/directions, style,
radius, K-factor, hidden layers) is per FILE, session-scoped: each
drawing keeps its own record in sessionStorage under its entry key.
Switching files never leaks values, and switching back — in-session or
after a reload — restores what that file was set to.
Two effects with a deliberate ordering: the persist effect is declared
before the load effect and only writes once the load effect has
stamped the current key, so the commit that switches files can never
save the previous file's values under the new file's key. Stored
values pass through the same normalizers as user input, so a stale or
hand-edited record degrades to defaults instead of breaking the sheet.
E2e-verified: 7 mm + hidden ENGRAVE stored on label_plate.dxf.py
restores after reload AND after in-session sidebar switches;
u_channel_bracket.dxf.py opens at defaults from the same session, and
the per-file stores stay isolated through round-trip switching.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* design: implicit raymarch performance — findings and plan of attack
Measured A/B against the pre-integration build (14421404) under Metal-backed
Chromium at DPR 2. One real regression from the shared-render-type integration:
renderFrame re-queues every vsync while interactionState.active, so wheel/pinch
gestures render redundant full-cost raymarch frames (pinch p95 84ms -> 250ms,
18fps vs 51fps on discrete wheel). Orbit and idle are at parity or better.
Beneath that, the shader is inherently fill-bound and was slow at fullscreen
retina before the integration too (45fps orbit, 28fps pinch on the old build).
Plan: P1 demand-driven re-queue (regression fix, gated on beating the old
build), P2 interaction-quality uniforms for camera moves, P3 idle-restore
hysteresis, P4 fixed interaction pixel budget, P5 scissored raymarch with the
background moved out of the shader, P6 measure-first shader tuning — each step
gated on a recorded benchmark matrix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: Units control, Layers as a dedicated tab, LFS-pointer guard
Three viewer-feedback items on the DXF sheet:
- A visible Units control. The parser has honored $INSUNITS since the
units work landed, but nothing in the UI said so or let you correct
a file that lies about (or omits) its units. Material now carries a
Units select: Auto shows what the file declares ("Auto (in)") and an
override (mm/in/cm/m) reinterprets the drawing's coordinates. The
override is a render-time PLAN scale applied at one boundary in the
viewer — flat baselines, bend lines, the curved mesher's contours,
scores, and text markings all scale together; thickness stays real
millimetres. Per-file persisted like every other DXF setting.
- Layers move out of the settings into a dedicated Layers tab — the
DXF analogue of STEP's Tree, taking its row treatment: color swatch
+ name, kind and entity count as row facts, and the tree's
hover-revealed eye toggle. The tab only appears when the file
actually uses layers; the DXF tab is back to settings only
(Material, Bends, Reset).
- bracket_inches.dxf's "group code stream is malformed" error was the
file sitting on disk as a Git LFS pointer. parseDxf now detects
pointer content and says exactly that ("run git lfs checkout"),
instead of sending people into the parser.
E2e-verified: bracket_inches renders with Units "Auto (in)"; the mm
override shrinks the plate 25.4x and survives a reload via the
per-file store; the Layers tab lists CUT (Cut, 6) / ENGRAVE
(Engrave, 1) with working eye toggles.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: opt-in hooks so a render type can tune the shared loop
Adds four runtime hooks, all inert unless a render type installs them, so the
mesh path keeps its exact current behaviour:
- renderOnDemandOnly stops the loop being held open for a whole gesture
- idleQualityDelayMs raises the idle-restore delay
- onIdleQualityRestore restores quality before the pixel ratio, so the costly
frame and the drawing-buffer reallocation do not land on the same vsync
- resolveExtraPixelRatioCap caps resolution below the shared caps
Holding the loop open every vsync for a whole gesture is free when a frame costs
2ms and ruinous when it costs 25ms; these let the caller say which it is instead
of hard-coding one policy for every format.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* implicit: composite over the shared stage, and stop re-rendering idle frames
Two problems, one change set. Interaction was slow, and implicits were the only
format without the themed grid — the same root cause, since the pass was opaque
and had to suppress the shared stage to avoid drawing the grid over the model.
Performance (measured on planetary-gear at 2560x1330 @ DPR 2, vs the
pre-integration ImplicitCadViewer):
- render on demand instead of every vsync for the length of a gesture. This was
the regression from the shared-render-type integration: pinch 28 -> 16 fps,
p95 84 -> 250ms. Now 101 fps, p95 16.7ms.
- apply the existing cheap tier (96 steps, no shadows/AO) to plain camera moves,
not just parameter drags. Motion hides it and idle restores full quality.
- 400ms two-stage idle restore, so a full-quality frame no longer lands between
discrete wheel ticks.
- a fixed interaction pixel budget instead of a fixed ratio, so gesture cost no
longer scales with window size.
Orbit 45 -> 116 fps, wheel 51 -> 114 fps, pinch 28 -> 101 fps against the OLD
build; idle issues zero frames and the gesture-end restore is a single 25ms
frame. Quality is deferred, never lost: with the camera still, the interaction
tier round-trips to a pixel-identical frame.
Consistency: uPaintBackground (default on, so the snapshot CLI and
renderImplicitCadToDataUrl are untouched) lets the viewer turn off the shader's
own background. Misses emit the floor shadow as black-with-alpha, so blending
darkens the shared floor exactly as multiplying the background did. The pass
draws last with depth off, CadViewer suppresses nothing, and the implicit bounds
handler sizes the shared grid, stage and lighting scope like the mesh path.
Implicits now show the same themed background, grid and floor as every other
format — verified pixel-identical against an STL under the Dark theme.
Full-corpus sweep is 47/47 non-blank; STL, 3MF, GLB, STEP and DXF unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: Material/Bends tabs, display units, plain layer rows
Settings-feedback round on the DXF sheet:
- Units become the sheet's DISPLAY unit, defaulting to mm. State stays
millimetres; the setting converts what Thickness and Radius show and
accept ("0.25 in" commits as 6.35 mm), so switching units never
changes the part. The previous meaning — reinterpreting the
drawing's coordinates — confused more than it fixed (inputs kept
saying mm), so the plan-scale engine is gone; geometry scale comes
from the file's $INSUNITS alone.
- Units sits first in Material: it reframes every dimensional row
under it.
- The DXF tab splits into Material and Bends tabs, each with its own
scoped Reset (material: thickness + units; bends: style, radius,
K-factor, angles). The Bends tab separates the style block from the
per-bend list with a rule (a titleless subsection). The Bends tab
only appears when the drawing has bend lines.
- Layer rows lose the colored dots: name, kind + entity count, and the
eye toggle. Layer visibility now belongs solely to the Layers tab
and is no longer touched by any Reset.
E2e-verified on u_channel_bracket: tabs Material | Bends | Layers;
Units first, defaulting to Millimetres; switching to Inches renders
Thickness as "0.00 in" and five slider steps commit 1.27 mm shown as
"0.05 in"; the Bends tab carries its own Reset and the rule above the
bend list; Layers shows plain BEND/CUT rows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: split DXF sheet panes, model orientation buttons
Two DXF sheet requests:
- The sheet splits into panes like STEP's: Material — the tab every
drawing has — sits in the top pane by default, and the conditional
tabs (Bends, Layers) share the bottom pane. A drawing with neither
collapses to a single strip. The Layers tab now also requires MORE
THAN ONE layer: a single-layer drawing has nothing to toggle.
(MeshFileSheet was hardcoding the "mesh" arrangement namespace into
the tabbed surface, so the DXF sheet could never split; it now
passes its own kind through.)
- Model orientation controls in the Bends tab: a folded part often
lands facing the wrong way, so a button row (X 90 / Y 90 / Z 90,
outline + icon per the design system's sibling-action rows) turns
the model in quarter-turns about each world axis, rotating about the
flat pattern's own centre. Exact by construction, applied to the
same buffers the fold writes so overlays, picking and selection all
follow; per-file persisted; the Bends Reset clears it.
E2e-verified on u_channel_bracket: top pane Material, bottom pane
Bends|Layers; 90/90 fold then "X 90" stands the channel upright with
guides and overlays riding along; Reset restores the flat default.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: curved is the default bend style; title the Bends sections
- Curved becomes the default bend style again: the preview should look
like the part, and with its own tab the extra Radius/K-factor rows no
longer crowd anything. Per-file stores that pinned Boxed keep it.
- The Bends tab's three groups get their section titles — Style, Bends,
Orientation — instead of bare rules.
E2e-verified on u_channel_bracket with a cleared per-file store: Style
opens on Curved with Radius (Auto) and K-factor (0.50) visible, and the
tab reads Style / Bends / Orientation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: material presets with sheet facts; bends list leads its tab
Material tab grows the settings it was missing:
- A Material preset (Steel default, Aluminium, Brass, Copper, Acrylic,
Plywood): an appearance tint for the preview plus a density. The tint
rides the mesh as a claim the partVisualState sync honours in place
of the base color — the same pattern as the curved-preview visibility
claim — so it survives every selection/hover pass, and the curved
preview follows automatically through the shared material. Steel
keeps the theme's own surface color so the default look is unchanged.
- A read-only Sheet group: bounding Size in the active display unit and
estimated Weight (net flat area x thickness x density), computed by a
new cadjs computeDxfFlatStats (chained closed loops, even/odd
containment for holes; node-tested).
Bends tab: the per-bend list moves above the Style group (the angles
are what you touch most), and Curved leads the style dropdown as the
default.
E2e-verified on u_channel_bracket at 3 mm Brass: the plate renders
brass, Size reads 140.0 x 70.0 mm and Weight 241 g (hand-checked:
9465 mm^2 x 3 mm x 8.5 g/cm^3), and the Bends tab reads Bends / Style /
Orientation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: SendCutSend material catalog, grouped select, no Sheet facts
- The Material preset list is filled from the SendCutSend catalog
(sendcutsend-catalog-v1.2.json): all 58 distinct materials, grouped
the way their catalog groups them (Metals, Plastics, Wood and MDF,
Composites, Rubber and Gasket), each with an appearance tint derived
from its name and family. FileSheetSelectRow learns grouped options:
entries carrying `group` render under SelectGroup headings, ungrouped
ones stay at the top.
- Material defaults to None — the theme's own surface color, no tint.
- The Sheet facts group (Size/Weight) is gone as overkill; the cadjs
computeDxfFlatStats helper stays (tested, no viewer caller).
E2e-verified: the select opens with None first and five group headings
over 58 materials; Acrylic Blue tints the plate; the Material tab is
back to Units / Material / Thickness / Reset.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: give implicits the standard tools and export route
Three fixes, all of which were implicit entries failing a "do we have mesh
data?" test that a raymarched model can never pass.
Toolbar: Copy screenshot and Orbit were permanently disabled — the gate asked
`!selectedMeshData`, and an implicit renders its own GLSL and never loads a
mesh. `selectedImplicitModel` was already passed into FloatingToolBar and
destructured but never used. The gate now asks one question for every format
("is there anything on screen?"), the way the zoom pill already did. Both
underlying paths already worked; only the gate was wrong.
Select/Pan/Draw are no longer hidden for implicits. Pan is a camera mode and
Draw is a 2D overlay over the viewport — neither depends on geometry, so both
work as-is once the mesh-data gates are widened (drawModeActive, the overlay
hook, and the canvas pointer-events). Select is inert for a single SDF body,
exactly as it already is for DXF.
Export was broken, not merely non-standard: the menu posted to
/__cad/implicit-export, which the Python server does not implement (405). The
standard modelExport path already listed IMPLICIT_EXPORT_FORMATS and routes to
/__cad/export, where cadgen.implicit_export meshes server-side — so implicits
now go through it like STEP and DXF, gaining the toolbar Export dropdown too.
Deletes implicitExport.js and its browser-side meshing, and renames
onExportStepFile to onExportModelFile since it has long carried DXF as well.
Verified in the viewer on an implicit: screenshot lands a real 176KB PNG on the
clipboard; pan translates the model; a freehand stroke draws with the pen
toolbar; orbit enters and exits; export writes a valid 162,210-triangle STL and
a 3MF through the server route. 47/47 implicits still render; STL, 3MF, GLB,
STEP and DXF unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: material folders, Fold/Corners naming, Orbit for DXF
- The Material select becomes a cascading menu: None on top, then one
hover FOLDER per catalog group (Metals, Plastics, ...) opening its
nested material list — a new FileSheetCascadeSelectRow on the
DropdownMenu submenu primitives, trigger styled like the inline
select trigger so the settings column reads uniformly.
- Bends > Style was too generic twice over: the section is now Fold
and the row is Corners (Curved/Boxed name corner shapes).
- The Orbit tool was gated off for DXF for no reason; drawings get it.
E2e-verified: the Material menu opens with None + five folders,
hovering Metals reveals the nested list and picking Brass tints the
plate; the Bends tab reads Bends / Fold (Corners) / Orientation; the
DXF toolbar carries Orbit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: per-section bend resets; right-side ticks in material folders
- The Bends tab's one catch-all Reset splits into per-section resets:
the bend list carries its own Reset row (angles/directions only) and
Orientation's reset rides inline as the fourth button of the rotate
row. Each resets exactly what its section owns.
- The material menu's ticks move to the RIGHT: the checkbox item's
left indicator gutter indented checked rows differently from the
plain folder rows, which read as ragged left padding. Every row's
text now shares one left edge.
E2e-verified: reset buttons enumerate as material settings / bend
angles / model orientation, and the None row and Metals folder share
identical 8px left padding with the tick on the right.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* design: viewer format unification — capabilities, not identities
Plan to unify the viewer stack across STEP/STL/3MF/GLB/DXF/implicit/robot so an
improvement to one format is inherited by all. Evidence: 93 format-identity
checks in non-test client code (50 in CadWorkspace alone), and the same Orbit
button hand-enabled twice in one week (implicit in 2c5939bd, DXF in 5d5bc3ae,
whose message reads "gated off for DXF for no reason").
Architecture: a frozen capability registry in cadjs replaces identity checks
("can this format do X?" not "is this format Y?"), one selectedViewportContent
signal replaces the mesh-vs-implicit content branches, and the already-extant
render-backend contract (bounds publication + opt-in loop-tuning hooks) gets
written down. A repo policy test ratchets the identity-check count downward so
the pattern cannot grow back.
Phases: U0 registry+ratchet, U1 projection as a viewport trait (theme ortho for
all formats, implicit ortho ray branch, planMode forces ortho, first-fit bug),
U2 one params/animation surface (toolbar Play for implicit animations), U3
context menu + camera actions everywhere, U4 theme wiring fixes (implicit
rim/fill) + the render-contract capability table deferred since PR #143, U5
loading/alerts/artifact state behind the registry, U6 horizon items.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: U0 — capability registry replaces format-identity checks
First phase of design/viewer-format-unification.md. Viewer code now asks what a
format CAN do, not what it IS, so a feature added once reaches every format.
- packages/cadjs/src/lib/renderCapabilities.js: one frozen table keyed by render
format (content kind, sheet kind, tools, parts/topology, display transforms,
params source, artifactManaged, exportFormats, themeProjection, planView).
Unknown formats resolve to a conservative row, deliberately NOT via
normalizeRenderFormat, which resolves unknowns to STEP and would hand an
unrecognised entry STEP's full capability set.
- FloatingToolBar and CadRenderPane are now identity-free: their nine mode
booleans collapse to one capability lookup each. viewerPickModeForRenderPane
loses its dxfMode/meshOnlyMode arguments.
- One selectedViewportContent signal replaces the mesh-vs-implicit branches at
every consumer.
- One exportFormatsForEntry resolver replaces the two menus that each re-derived
kind -> formats independently (which is how implicit ended up in one, not the
other).
- tests/python/global/test_viewer_format_capability_policy.py ratchets the
identity-check count downward (93 at the start of this work, 80 now) and
asserts the two shell components stay at zero.
- viewer/docs/render-types.md: the capability reference and the render-backend
contract, incl. the known non-uniformities (implicit ortho, partial materials,
lighting approximation).
- viewer/scripts/e2e-format-sweep.mjs: standing gate, one fixture per format.
pngjs becomes a declared viewer devDependency instead of an unsaved install.
Completes upstream's "Orbit for DXF" (5d5bc3ae), which enabled the button while
FOUR other format checks kept it inert: the workspace handler bail, the pane's
previewMode override, and an effect that force-exited DXF from preview. Orbit
now verified working on DXF, STL, STEP and implicit.
The sweep earned its keep immediately: it caught a temporal-dead-zone crash that
blanked all six formats and that both the build and the unit tests passed.
Gates: 6/6 formats render, 47/47 implicits, viewer 292 + cadjs 496 tests, ratchet
green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: U1 — projection is a viewport trait, honoured by every format
Second phase of design/viewer-format-unification.md, and the answer to the
standing theming question: the default workbench theme declares
projection: ORTHOGRAPHIC, and four formats out of five were ignoring it.
- CadRenderPane takes the theme's projection for any format declaring
themeProjection (all of them), instead of only STEP. Measured previously:
STL/3MF/DXF already rendered correctly in ortho with no code change; the
dual-camera runtime was format-agnostic all along and only the gate blocked it.
- planMode forces orthographic. Its own comment calls it "a generic top-down
camera lock, reusable by any model", but a perspective plan view foreshortens
off-centre, which is exactly what a plan view must not do. DXF's 2D mode now
renders true parallel grid lines.
- implicitjs gains an orthographic ray branch. Perspective fans ONE origin into
per-pixel directions; a parallel projection shares ONE direction across
per-pixel origins, so feeding an ortho matrix to the pinhole path yielded
garbage directions and the model vanished. uOrthographic tracks
camera.isOrthographicCamera automatically and defaults to 0, so the snapshot
CLI and renderImplicitCadToDataUrl are unchanged.
Also fixes a dispose/compile race in useImplicitRaymarch surfaced by sweeping the
corpus: three's compileAsync polls material.program.isReady() from an internal
timer, so disposing a pass while its compile was in flight threw an uncaught
TypeError that escaped the promise's catch. A pass with a pending compile is now
disposed only once that compile settles.
CORRECTION to the plan: there is no ortho "first-fit off-centre bug". The
apparent off-centring was the sweep's 900px clip over a 1440px viewport plus the
frame insets that bias a model left when its file sheet is open. Measured
properly, STL sits at +28px of centre (no sheet) and STEP/implicit at ~-163px
(sheet open) — all correct.
Gates: 6/6 formats render, 47/47 implicits, zoom/reset/orbit verified under ortho,
viewer 292 + implicitjs 364 tests, ratchet green, bundles regenerated for the
shader change and dev symlinks restored.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* viewer: U2 — one parameter/animation surface, keyed off the active runtime
Two parallel systems reached the same two surfaces by different names: a STEP
`.step.js` sidecar store and an implicit in-module store, each with its own
clipboard handlers and its own toolbar wiring. Unify the CONSUMER surface; the
stores stay separate on purpose, since they drive different recompute pipelines.
- `activeParameterRuntime` resolves which store backs the selected entry from
the registry's `params` field, and one copy/paste/reset handler set is written
against it. A third parameterized format becomes one more arm here, not a
third copy of three clipboard handlers.
- `stepModuleParameterControls.js` was a pass-through shim over
`parameterControls.js` differing only by a label string. Deleted; its test
moves to the module it was actually exercising and now covers both labels.
- FIXES a live regression: U0 flipped the toolbar Play button's gate to the
`animations` capability but left it fed from STEP state, so an implicit's
clips still could not be played from the toolbar — the button was hidden,
because `stepAnimationAvailable` is false for a format with no sidecar.
`activeAnimationRuntime` feeds it instead, and the toolbar's props lose their
`step` prefix since they were never STEP-specific.
Measured A/B on `dragon-folding-path-3d.implicit.js`: before, no Play button
in the toolbar at all; after, Play toggles to Pause and the animation runs.
STEP (`planetary_gear_assembly`) unchanged in both directions.
Also syncs cadjs's lockfile with the linked implicitjs package (meshoptimizer
was declared but unrecorded, which fails the viewer build from a clean install).
Gate: e2e format sweep 6/6, 292 viewer tests, capability ratchet unchanged at
80/14, viewer build clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* viewer: U3 — the viewport menu and camera actions belong to every format
`openGlobalViewerContextMenu` bailed unless the entry was STEP, so right-clicking
the viewport did nothing at all on five of six formats, and the whole-model fit
that shipped with the implicit render type in PR #195 was unreachable.
- The global menu now opens for any format with viewport content. Camera actions
are always present; Show all / Expand all / Collapse all are gated on the
`parts` capability, so they appear exactly where an assembly tree exists.
- Added Zoom To Fit to the global menu and taught the handler that a menu with no
narrower target means the model. `zoomToFitSelection` takes an explicit
`fallbackToModel` from the caller instead of sniffing `implicitActive` — a
plain mesh has no sub-part selection either, so that was never implicit-only.
- One label: the global menu said "Zoom to fit" next to the part menu's
"Zoom To Fit".
`CadViewer` joins the identity-free shell (asserted, like the toolbar and the
render pane): `implicitActive` reads the content kind, and the CAD edge source is
the `topology` capability. `isStepView` is gone from `CadWorkspace` — it stood in
for four different capabilities (parts, topology, displayModes, sidecar params),
which is why every use of it needed re-reading to work out which was meant.
Deletes a dead prop chain found on the way: the viewport display/projection
control was removed from the toolbar in f75c2696 (2026-07-06) and its plumbing
was left behind, wired through CadWorkspace -> CadRenderPane -> CadViewer and
gated on `isStepView`. Nothing rendered it, so projection only LOOKED
STEP-gated. Removed the chain, the two callbacks feeding it, and the orphaned
`DisplayProjectionControl`; the theme editor remains the live path.
The sweep now asserts the viewport menu per format, so this cannot silently
regress the way the Orbit button did twice.
Gate: sweep 6/6 with menu assertions, 292 viewer tests, ratchet 80 -> 77.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* dxf: a HATCH seed point is not a boundary vertex
`parseHatchEntity` read 10/20 pairs to the end of the entity. A HATCH writes its
boundary paths first, but then pattern-definition lines and SEED POINTS — and a
seed point is another 10/20 pair. Seeds landed in the last boundary path as
extra vertices, and a seed may sit anywhere on the drawing.
In models/dxf/alu_extrusion_profile.dxf two hatches seed 62 m off a 1.8 m sheet.
Measured on that fixture:
before bounds 63817 x 21822 mm, 609 entities, 99% of geometry inside 1813 mm
after bounds 1879 x 1979 mm, 607 entities, no outliers
That is the "DXF auto-fit frames its drawing far too small" bug recorded in
design/viewer-format-unification.md: nothing was wrong with the fit, it was
faithfully framing a drawing 35x wider than the sheet. The e2e sweep's DXF
coverage goes 0.0055 -> 0.0685 against a 0.005 blank threshold, so the standing
gate was one stray seed away from failing for the wrong reason.
Boundary data ends at code 75 (hatch style); 98 counts seeds. Stop there.
Regenerates the dxf skill's bundled cadjs runtime, which embeds this parser.
Gate: 497 cadjs tests incl. a new regression on the seed pair, sweep 6/6, all
nine models/dxf fixtures re-parsed with sane bounds, bundle.sh --check clean and
the development symlink layout restored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* viewer: U4 — the implicit honours the theme's fill and rim, and a harness that proves it
The wiring was only half the problem. `applyImplicitLightingUniforms` drove its
fill from `lighting.spot` and pinned rim to the built-in rig — but even wiring
those correctly would have changed nothing, because implicitjs's own
`normalizeThemeSettings` did not know `lighting.fill` or `lighting.rim` existed
and dropped both before any uniform could read them.
`packages/implicitjs` may not import `packages/cadjs`, so the theme schema is
duplicated in `common/themeSettings.js` on both sides. Adding a field to one and
not the other silently disables it for that renderer at normalization time. Both
files now carry fill and rim, with the same defaults.
Measured across all eight presets, implicit surface mean RGB:
cinematic 75.5,77.5,91.2 -> 92.8,88.5,100.8
vibrant 204.4,204.4,216.5 -> 216.0,212.0,222.1
blue 62.2,95.5,131.0 -> 74.9,106.6,140.9
New `viewer/scripts/e2e-theme-conformance.mjs` asserts the two halves of what a
shared theme means: the backdrop must be pixel-identical between renderers
(shared code), and each renderer's surface must actually CHANGE across themes. A
renderer that ignores the theme passes every background check while rendering all
eight identically — which is what the raymarcher was doing.
It immediately found a pre-existing background drift in four themes (cinematic
7.0, clay-sunrise 5.4, pink 4.7, blue 4.5, out of 255). Ruled out, by
measurement: the U4 lighting work (A/B identical), the shader's own gradient ramp
(swapping it for the canvas construction moved the numbers by 0.0 — the viewer
composites the raymarch over the shared stage, so the shader backdrop is not what
is on screen there), and the environment map (env off leaves cinematic at 7.1).
Cause open, budgeted per theme so it cannot grow or spread, ratcheting down like
the identity-check counts.
Also fills in the render-contract capability table deferred from PR #138/#143:
every theme field x render type, honoured / approximated / unsupported, with the
two-schema and two-backdrop traps written down. Drops the stale "implicit cannot
render orthographic" entry — U1 shipped that.
Gate: 364 implicitjs + 497 cadjs + 292 viewer tests, sweep 6/6, conformance
harness green, bundle.sh --check clean with the symlink layout restored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* viewer: U5 — loading, alerts and file-list state behind the registry
The deepest per-format arms left: alert copy, rebuild commands, the file-list
icon, the "has this arrived yet?" check, the home-screen sampler and the loading
labels. Each was a cascade, and each cascade was a place a new format inherited
the wrong advice, the wrong icon or no spinner.
Three registry fields carry what the cascades were computing:
- `assetKind` — WHICH asset the viewer loads (mesh / drawing / implicit / robot).
Deliberately not the same field as `content`: a DXF loads a drawing and renders
it through the mesh viewport, so it shares the viewport but not the loader.
Loader implementations stay per-format; this only names which one.
- `iconKind` — the file-list glyph, the longest cascade in the client.
- `rebuildCommand` — the manual rebuild shown on a failure card. `buildCadCommand`
was eight format checks of which seven existed only to return "": every format
but an imported STEP has nothing to run by hand. The one rule that is genuinely
about the entry rather than the format — a generator-backed STEP is rebuilt by
the viewer — stays in the caller.
Alert resolutions now key on `artifactManaged` rather than a list of the three
mesh formats, so a fourth mesh format gets "check the file exists" instead of
"rebuild the assets", and the format name comes from the registry label.
Six loading-label arms collapse to one lookup: formats the viewer does not build
ARE their own asset, so the label is just their name. Only user-visible change is
an SRDF entry now reading "Loading SRDF robot..." rather than "Loading URDF
robot...", which is what was actually opened. STL/3MF/GLB/STEP labels verified
byte-identical against a running viewer.
CORRECTS a false invariant while it was being moved: the note said
`artifactManaged` "MUST mirror `owns_entry`". It must not. The server also owns
implicit entries — it builds their packages for export and snapshot — but an
implicit raymarches live and must never wait on that build. Anyone "fixing the
drift" by adding implicit would have made it block on a build it has no use for.
Corrected in the registry, its test, and the doc.
Ratchet 77 -> 34 identity checks and 14 -> 3 predicate calls; `viewerAlerts`,
`entryIconKind`, `entryIconStatus` and `CadWorkspaceHome` join the set asserted
at zero. What remains is deliberate and written down: `useCadAssets` is the
loader, and `stepArtifactStatus` speaks STEP package vocabulary that would be
wrong to show a DXF.
Gate: 292 viewer + 497 cadjs tests, 38 global policy tests, sweep 6/6 with menu
assertions, toolbar Play on both stores, loading labels captured per format.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* design: viewer format unification — U0 through U5 complete
Records what each phase actually found, which in several cases was not what the
plan predicted: U0 half-shipped the toolbar Play button (capability flipped, data
not repointed); the viewport projection control had been dead code since
2026-07-06 and only LOOKED STEP-gated; `isStepView` was four capabilities under
one name; U4's real blocker was a duplicated theme schema dropping fields at
normalization rather than the uniform wiring; `artifactManaged` does not mirror
the server's `owns_entry` and the note saying it must would have made implicit
block on a build it has no use for; and the DXF framing bug was a HATCH seed
point read as a boundary vertex, not a camera bug.
93 -> 34 identity checks, 14 -> 3 predicate calls. Two findings left open with
their measurements: a four-theme background parity drift, and the implicit's two
disagreeing backdrops.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* viewer: select, pan and draw are viewport tools, so every format gets them
The toolbar renders the three as ONE cluster gated by an OR over the three
capabilities, so a format got all of them or none — and plain meshes, robots and
the default row declared none. Opening an STL, 3MF, GLB or URDF lost Select, Pan
and Draw, three buttons that have nothing to do with what the file contains.
Every row now declares the same set, so the per-row overrides said nothing and
collapse into one `VIEWPORT_TOOLS`. Select is inert without `topology` — it stays
visible so the toolbar keeps one shape rather than reflowing as you move between
files, which is what DXF already did deliberately.
Verified against a running viewer on all seven format families: the three buttons
are present and enabled, and a dragged stroke actually renders, on STL, 3MF, GLB,
STEP, DXF, implicit and URDF.
The sweep now asserts the whole cluster is present and usable, and gains its
first robot fixture — the robot family had none, which is how it kept missing
features nobody was looking at. It needs `git lfs checkout models/robots/so101`
like the other fixtures, and a 26 s window: a robot loads EVERY link mesh before
anything draws (so101: 13 meshes, 16 MB, ~15 s on localhost, against 0.8 s for an
STL). Measured, not guessed; written up in the robot-parity plan.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* design: robot descriptions as a first-class render type
The format-unification work carried URDF/SRDF/SDF along without auditing them.
This is that audit. Robots are structurally inside the shared stack — one
CadViewer, one stage, one theme, one registry row, their own fileSessionState
slice, and as of this branch the full tool cluster and viewport menu — and
functionally the thinnest render type in the viewer.
Measured on models/robots/so101/so101.urdf (13 link meshes, 16 MB):
- No headless render path AT ALL. `resolveHeadlessJobKind` knows two backends,
implicit and mesh; the snapshot CLI's own help says robot-description inputs
are unsupported. No robot snapshot, no orbit GIF, nothing for a skill or CI.
- No export route. The server implements STEP, DXF and implicit exports; an
assembled robot is a mesh scene and gets none.
- No structure panel, though a URDF is the most literal link TREE in the repo —
DXF got a Layers tab described in fileSheetSections.js as "the DXF analogue of
STEP's Tree", and the robot never got the same treatment. So the Select button
this branch just gave every format can never select a link.
- No display modes, no clip, no exploded view, all of which operate on mesh data
and part records a robot already produces.
- All-or-nothing load: nothing draws until the last link mesh lands. 15.7 s to
first pixel AND to a usable toolbar, against 0.8 s for a 6.3 MB STL — ~20x the
wait for ~2.5x the bytes, behind a static card. The loader already computes
"loading meshes 7/13"; that stage string only reaches the file-list chip.
Six phases, ordered by value over effort: R0 the robot joins the standing sweep
(done here — it had no fixture, which is how all of this stayed invisible), R1
links become parts, R2 display/clip/exploded fall out as capability flips, R3
progressive loading, R4 robots in the headless renderer (the piece that unblocks
snapshots, and where the shared assembly step belongs), R5 export, R6 framing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* viewer: R1 — a robot's links are selectable parts
A URDF is a link/joint tree: the most literal assembly structure the viewer
holds, and the reason the `parts` capability exists. The robot row declared
`parts: false`, so links were unreachable — no selection, no hide, no isolate, no
zoom-to-fit-a-link, and (as of the previous commit) a Select button that could
never resolve a pick.
The robot preview ALREADY emits a full `parts[]` array, one per link visual. What
was missing was a tree over them, so:
- `buildRobotAssemblyRoot` (packages/cadjs, non-React so R4 can share it) turns
urdfData + the preview's parts into exactly the node shape `buildStepTreeRoot`
returns. Leaf part ids are derived by the same rule both sides use, since a
mismatch there means a pick silently resolves to nothing.
- `isAssemblyView` becomes "does this entry have a part tree?" — a STEP assembly
says so with its kind, a robot is one by construction — and every downstream
feature reads that, so declaring it was most of the work.
- Guards the cases a real URDF hits: a link whose visual resolved to no mesh is
not offered as selectable, a joint cycle cannot build an infinite tree, and a
file with several root links gets one node to hang them from.
`topology` stays false: a robot's links are meshes, with no BREP faces or edges.
NOT DONE, deliberately — the Tree PANEL. A robot's links are selectable in the
viewport but still absent from its sheet, because that panel is 556 lines inside
StepFileSheet reading 20 props and 33 derived locals. Sharing it means extracting
it, with the viewer's most-used surface as the blast radius; writing a second,
simpler tree for robots is the parallel stack this whole effort exists to remove.
It is R1b, sized with those numbers so the next person can judge it. The section
list deliberately does NOT claim a "tree" id it cannot render.
The sweep caught its own stale fixture declaration on this change (robot menu
gained Expand/Collapse while the fixture still said parts: false) — the gate
working as intended.
Gate: 505 cadjs + 293 viewer tests, sweep 7/7, link selection and the part
context menu verified on so101 against a running viewer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* viewer: R3 — a robot draws its links as they arrive
`useCadAssets` fetched every link mesh and only then published the robot, so
nothing drew until the slowest link landed. Measured on so101 (13 meshes, 16 MB,
localhost): 15.7 s to first pixel AND to a usable toolbar, behind a static card,
against 0.8 s for a 6.3 MB STL.
before 15.7 s to first pixel, 15.7 s to toolbar
after 1.8 s to first pixel, 1.8 s to toolbar, complete by ~8 s
Two changes:
- `loadRenderRobotMeshes` hands each mesh over as it resolves, not just its
count, and the loader republishes the robot on every arrival.
`buildUrdfMeshGeometry` already skips a visual whose mesh is absent, so a
partial map renders the links that have landed and grows. Each publish is a new
Map — the downstream memos compare by identity, so mutating one in place would
draw the first link and then nothing further.
- `urdfViewerLoading` asks "is there nothing to draw yet?" rather than "is the
fetch still running?". It had to: the renderer clears the model outright while
`isLoading`, so leaving it keyed on ASSET_STATUS.LOADING would have held the
blank card up until the last link arrived and thrown away the whole benefit.
The viewport card also reports the stage the loader was already computing
("Loading meshes 7/13..."), which until now only reached the file-list chip.
R6 (robot framed small) is ANSWERED by this rather than fixed: the 0.10 sweep
coverage was measured at 16 s, the instant an all-or-nothing robot appeared.
With links streaming, the same fixture sweeps at 0.157 — in line with the mesh
formats' 0.162. There is no bounds outlier; the DXF suspicion did not carry over.
The sweep's robot window drops 26 s -> 12 s, now covering the streaming tail
rather than a blank wait.
Gate: 293 viewer tests, sweep 7/7 with tool and menu assertions, timing series
recorded above against a running viewer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* cad/viewer: R4 — robots render headlessly
The snapshot CLI's own help said it: robot-description inputs were unsupported.
`resolveHeadlessJobKind` knew two backends, implicit and mesh, and the robot
assembly step — fetch the description, fetch every link mesh, build the geometry,
pose it — existed only inside a React hook. So there was no way to snapshot a
robot, no orbit GIF, and nothing a skill or CI job could render.
- `packages/cadjs/src/lib/urdf/loadRobot.js` is that assembly step with no UI
attached. The parser already resolves each link mesh against the description's
own URL, so a caller hands over one URL and gets posed mesh data back.
- `loadSource` grows a robot branch, so a robot arrives at the shared mesh
backend as ordinary mesh data and every downstream mode works unchanged — no
third render stack.
- The CLI accepts `.urdf`/`.srdf`/`.sdf` and poses them with `jointValues`, the
robot's analogue of a STEP parameter sidecar (an object of joint name to
degrees, defaulting to the rest pose). STEP-only options are rejected with
errors that name the right key rather than a flat "not supported".
Verified end to end against models/robots/so101:
still renders the robot
posed shoulder_pan 55 / shoulder_lift -40 / elbow_flex 60 — visibly a
different configuration from the rest pose
orbit 72-frame GIF at 960x640
list reports link visuals as part refs (#base_link:v1, ...) — R1's
part ids reaching the CLI
STEP + implicit re-rendered unchanged
FIXES a silent coercion found on the way: `sceneScale` was accepted, validated
and then overwritten with "cad" unconditionally, so a job asking for the URDF
profile got the CAD one. Robots are authored in metres and CAD in millimetres,
and the profile sizes the floor, grid and lighting radii — a robot was being
framed for a workpiece a thousand times its size. The requested scale is now
honoured (and rejected if it is neither), and robot jobs default to it.
Gate: 505 cadjs + 293 viewer + 87 snapshot CLI tests (three new robot resolver
cases), 38 global policy tests, sweep 7/7, bundle.sh --check clean with the
symlink layout restored. One pre-existing test used a .urdf as its example of an
unsupported input; it now uses a .dxf, which still is one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* design: robot parity — R0/R1/R3/R4/R6 done, R2/R5 dropped
R2 (display modes) and R5 (export) dropped by the owner: robots do not need
display modes, and URDF/SRDF/SDF are end of the line — they import 3D formats
rather than producing them.
Records what each shipped phase measured, and the one deferral with its size:
the Tree PANEL (R1b) is 556 lines inside StepFileSheet reading 20 props and 33
derived locals, so sharing it is an extraction with the viewer's most-used
surface as the blast radius.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* viewer/cadjs: robots load whole, with progress, 32x faster
Reverts progressive robot rendering per the owner's preference, and then makes it
unnecessary by fixing what was actually slow.
WHOLE, WITH PROGRESS. Links are published once, complete. Drawing them as they
arrived meant the loading card cleared at the first link, so a half-built robot
sat on screen with no sign whether more was coming — it read as a broken model
rather than a loading one. `urdfViewerLoading` goes back to "is the fetch still
running?", so the card stays up for the whole load and reports the count the
loader was already computing: "Loading meshes 7/13".
32x FASTER, and none of it was where it looked. Measured on so101 (13 link
meshes, 20 MB of binary STL, localhost):
network, all 13 meshes .... 0.17 s (curl, serial)
STL parse, 322,564 tris ... 0.014 s
toCreasedNormals .......... 14.09 s <- 99.9% of the load
computeVertexNormals ...... 0.031 s
three's `toCreasedNormals` welds vertices to average normals across shallow
edges, which is what makes a COARSE cylinder read as round. It is also
superlinear: 9,430 triangles took 49 ms, 53,994 took 3,552 ms — 5.7x the
triangles for 72x the time. A single STL worker ran all 13 through it serially,
which is why raising the client's fetch concurrency did nothing (measured: 3-way
and 8-way both ~16 s).
So creasing is now capped at 10,000 triangles — coarse meshes, where facets are
visible AND the function is still cheap. A mesh dense enough to exceed that has
sub-pixel facets and nothing left to smooth; the creased and uncreased renders
are indistinguishable, checked side by side on so101 and on the high-res
staircase STL.
complete robot render 16.7 s -> 0.51 s (0.514 / 0.517 / 0.521 across runs)
headless snapshot 3.1 s end to end
Also drops the per-mesh `browserYield()` pair and raises the fetch cap from 3:
both existed because STL parsing used to run on the main thread, and it has been
in a worker since. The sweep's robot window loses its special case entirely
(26 s -> 12 s -> the same 9 s as every other format).
Gate: 505 cadjs + 293 viewer tests, 38 global policy tests, sweep 7/7,
bundle.sh --check clean with the symlink layout restored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* viewer: robots do not select — the select tool is camera navigation
R1 made a robot's links selectable, hideable and isolatable. It worked and it was
useless: selecting a link gives you nothing to do with it. A STEP face or
occurrence has a copyable reference — #o1.2 — that a user pastes into a generator
or a snapshot job; a URDF link has no such currency, so the whole affordance was
a selection highlight and no payload.
So `parts` goes back to false for robots, and the robot tree builder and its
wiring are deleted rather than left dormant.
On every non-STEP format the select TOOL stays visible and inert. It is the
default mode, and in it the left button orbits and the right button pans, which
is all a robot needs. Verified against a running viewer:
urdf / stl / implicit click selects nothing, left-drag orbits, right-drag pans
step still selects
The right-click camera menu is unaffected — a right-CLICK still opens Reset Zoom
and Zoom To Fit everywhere, because the picking gesture distinguishes a click
from a drag by tap slop.
This also retires R1b (extracting the 556-line Tree panel out of StepFileSheet so
robots could share it): with no selection there is nothing for a robot Tree tab to
drive. Recorded in the plan, along with the lesson — `parts` is not "does this
format have sub-objects", it is "can a user DO something with one".
Gate: 498 cadjs + 293 viewer tests, 38 global policy tests, sweep 7/7,
bundle.sh --check clean with the symlink layout restored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* models: reorganize fixtures by artifact kind
Replace the ad-hoc top-level buckets (simple/, benchmarks/, fun/,
experiments/, one-shots/, mechanisms/, dxf/) with a structure keyed on what
each fixture IS:
step/parts/ single-body <name>.step.py generators
step/assemblies/ flat multi-part generators (+ optional .params.js)
step/mechanisms/ imported .step assemblies (+ .step.js sidecars)
renders/ the 12 folder-per-model concept packages
mesh/{stl,3mf,glb} exported meshes, by format
drawings/dxf/ 2D DXF fixtures
implicits/ unchanged
robots/ imported robot fixtures with URDF/SRDF
The part/assembly split is structural (does gen_step() build a `children=`
compound), and the flat/folder split decides step/ vs renders/: anything
needing helper modules, per-link generators or its own docs became a
renders/ package.
Two deliberate exceptions, both documented in the READMEs they affect:
- benchmark_09/10 are internally multi-part but stay in step/parts/ with the
rest of the numbered suite. They share benchmark_common.py by sibling
import and validate_benchmark.py enumerates all ten by relative filename,
so splitting them would break both.
- juno/lyra carry URDF/SRDF but live in renders/, not robots/. They are
authored concept packages; robots/ collects imported fixtures.
All 12 renders/ packages must stay siblings: starship reaches raptor2 and
falcon_heavy reaches merlin1d by relative path.
Also adds mesh/ export examples (spur_gear_blank, mounting_plate,
planetary_gear_assembly as STL/3MF/GLB) to exercise the export path, and
brings the models/ docs up to date: a layout tree and placement rule in
models/README.md, a README per bucket, plus fixes to pre-existing drift in
drawings/dxf/README.md (three undocumented generators, an undocumented
imported fixture, a dead clamp_plate.step.py reference) and to the
.step.glb/ generated-output claim, which is really __cadgen__/.
Verified: all 12 renders/ packages plus representative step/parts and
step/assemblies models build; validate_benchmark.py passes for 09/10 from
their new location; full Python and JS suites, global policy tests, bundle
--check and the symlink layout all pass; every models/ markdown link
resolves; all 88 binaries remain LFS pointers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* models: fix two path references the reorg missed
Both point at directories that no longer exist after the models/ reorg
(PR #199). They were missed because the sweep that found the other call
sites filtered on .md/.py/.js/.json/.sh and so never looked at .mjs, .ts
or .tsx files.
- packages/cadjs/bench/composePackageBench.mjs: DEFAULT_PACKAGE resolved
models/one-shots/falcon_heavy/... , so running the bench with no
argument failed outright. Now models/renders/falcon_heavy/... .
- docs/src/components/hero-step-render.tsx: HERO_STEP_CAD_PATH is the
identity/label handed to loadSource, not a fetched URL — the hero's
real assets are the static /hero/*.glb and /hero/*.step.js, so the
render was unaffected — but the label named models/fun/ .
HERO_STEP_DEMO_URL on the next line still reads
cad.fun/?file=fun%2Fplanetary_gear_assembly.step and is deliberately
left alone: that is the external site's own file namespace, which this
repo's layout does not control.
Verified: a no-filter sweep over every tracked file outside models/ now
reports no remaining references to the old buckets; cadjs tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* viewer: reframe the camera when the viewport changes
Narrowing the viewport cropped the model instead of shrinking it, and
left the zoom percent describing a viewport that no longer existed.
Two triggers, one missing behaviour:
- The canvas is always full-window; the sidebar and the sheets are frame
insets painted over it. So opening, closing or dragging a sheet never
reached the resize path at all -- it only re-centred the model through
applyCameraFrameInsets, in a framed area that had just got narrower.
- The resize path itself only updated the camera aspect. A perspective
camera's vertical field of view is fixed and the orthographic
half-height was held constant across an aspect change, so both
projections kept the model at its old world size while the space it
had to live in shrank. useViewerRuntime already called
runtime.onViewportResize on resize; CadViewer never supplied one.
The zoom baseline (zoomBaseDistance / zoomBaseHalfHeight) is captured at
fit time and was never revisited, which is the second half of the report:
"100%" kept claiming a fit the viewport no longer had, and the next reset
re-fitted for real and visibly moved the camera with the readout still
reading 100%.
syncRuntimeViewportFraming rescales the active camera by the change in
fit scale and carries that projection's zoom baseline by the same ratio,
so the model keeps its share of the framed area and the percent stays
honest -- at 100% a reset after a resize is now a no-op. It is wired to
both triggers, and the reference viewport is (re)captured wherever the
camera is fitted (resetRuntimeZoomBaseline) or the projection switches,
since perspective and orthographic measure a viewport differently.
viewportFitScale is a pure helper mirroring the two existing fit
formulas, getFitDistanceForBoundingSphere and
getOrthographicHalfHeightForBoundingSphere. Only the ratio between two
viewports is used, so a reframe lands exactly where a fresh fit would
have -- including the pre-existing quirk that the perspective formula
ignores the top-bar inset the orthographic one accounts for. Left alone
here: changing it would move every fit and every stored perspective.
Verified in the browser in both projections. 1400px -> 620px wide takes
the model from 84% of the width (nearly clipping) to 68%; reset at 100%
after a resize no longer moves the camera; a 130% zoom survives a resize
as 130% with the model rescaled around it; opening both sheets at 1000px
shrinks it to 71% of the framed strip and closing them returns it
pixel-identical. viewer (298), cadjs (498) and implicitjs (364) tests
pass, viewer build passes, bundle --check is clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* models: retire the benchmark suite, keep the models
The benchmark framing is no longer relevant, so the suite goes away as a
concept while the geometry it produced stays as ordinary fixtures.
Removed:
- The repo-root benchmarks/ directory (10 prompt .md files + 10 LFS orbit
GIFs) and the README section that indexed them.
- models/step/parts/validate_benchmark.py, the harness that enumerated all
ten by filename and hard-coded their expected dimensions.
- models/step/parts/benchmark_08_centrifugal_impeller.step.py, a duplicate
of the existing centrifugal_impeller.step.py: same part, identical
dimensions (90 mm backplate, 26 mm hub, 8 mm bore, 12 blades, 18->43 mm,
45 deg backward sweep), no unique features. The existing file is the more
decomposed implementation and already sat at the target filename.
Kept, with the benchmark_NN_ prefix dropped and sorted by the same
part/assembly rule the rest of models/step/ uses:
- Seven single-body generators stay in step/parts/ (rectangular_calibration_
block, circular_flange, l_bracket, stepped_shaft_keyway, open_top_
electronics_enclosure, clevis_bracket_lightening_cutouts,
radial_engine_cylinder).
- The two that build children= compounds move to step/assemblies/ as
spiral_staircase and planetary_gear_stage. planetary_gear_stage still
needs the shared helpers, so it reaches back into ../parts via a sys.path
insert, the same pattern juno/lyra/f1 already use.
- benchmark_common.py -> part_common.py, with its six importers updated.
The benchmark_NN_ prefix was also embedded in part.label values, which are
the names the CAD Viewer shows in the model tree, and in gen_step()
docstrings; both are stripped.
.gitattributes and .lfsconfig drop their benchmarks/** rules; AGENTS.md and
CONTRIBUTING.md drop benchmarks/** from the LFS guidance. The README's
Screenshots section picks up the assets/** LFS hydration note that used to
live in the deleted Benchmarks section, since the demo GIFs it embeds are
still LFS-backed and excluded from default pulls.
Left alone: "benchmark" in design/ docs, packages/implicitjs and
packages/cadjs/bench refers to render-performance benchmarking, which is
unrelated; and the synthetic "benchmarks/*.step" fixture strings in the
viewer sidebar/breadcrumb tests are made-up paths, not references to the
deleted directory.
Verified: all 9 retained generators build from their new locations, the
full Python and JS suites pass, 38 global policy tests pass, and the dev
symlink layout is valid.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* cad: a contended build reports the peer instead of stalling behind it
Three defects around the generation lock, all measured on release/0.4.0.
A contended acquire was silent and unbounded. deadline_ms and Contended existed
in the coordination package, were tested, and had no production caller -- so
every acquire blocked forever with nothing on either stream. `cad gen --force`
against a peer's lock sat for 35s emitting zero bytes; a wedged holder hangs it
for good. exclusive() now takes on_wait and reports the wait as it happens
(256ms, then every 30s), and the artifact CLIs take --lock-timeout.
One contended model froze the CAD Viewer's builds for EVERY model. A POST runs
in the ONE serial warm worker, so a build parked on another process's lock stops
every other model's build and export: an unrelated, already-current model
measured 31.99s behind a peer's build. The snap.writing pre-check narrowed the
window but could not close it -- a peer can take the lock right after the
snapshot, and force= skipped the check outright. The viewer now passes a bounded
--lock-timeout and reports the peer's run, which is what the client wants
anyway: 32s -> 0.0s.
An export erased a live build's progress. generator_busy and artifact_build take
DIFFERENT sentinels on purpose (a busy generator must not hide a renderable
package) and so cannot exclude each other -- but they shared one record file, so
an export's record landed on a running build's and snapshot() rejected it on the
run id: the bar vanished for 2.2s mid-build. Its terminal record carries no
stageMs, so an export also wiped the phase weighting the next build reads. The
generator run now writes its own record; nothing ever read it.
artifact_build yields run.contended rather than raising, matching run.skipped:
both mean "do not write the package", and a caller that ignores either is caught
at the mutation boundary by require_write_lock(). exclusive() still raises for
callers with no BuildRun to inspect.
Also corrects _track_spec_generation's docstring, which claimed the generator
sentinel stops a build running the same gen_step() concurrently. It does not --
measured 6.7s of overlap with both sentinels held. That is duplicated work, not
a hazard (separate processes, different outputs), and it is the price of the two
states being distinguishable.
* cad: CLI failures lead with the failure, and results are machine-readable
A `gen_step()` that raised reached the interpreter uncaught: 62 lines and 4.2 KB
of traceback whose one useful line was last, under ~50 frames of runpy, the
launcher and cadgen internals. One missing its return printed 43 lines to say
"must return one value". Both are ordinary authoring mistakes, so both are what
an agent hits most often.
The CLIs now report at their boundary, keeping the frames in the caller's OWN
model and dropping the runtime's -- the same first-party predicate the source
closure already uses to tell model code from the stdlib, site-packages and the
running runtime. The example above becomes 6 lines / 413 B, and gains the two
generator frames with their source. --verbose still prints the whole traceback:
when the fault IS in cadgen, those frames are the point.
Results were also unreadable by anything but a human. stdout carried a result
from export and snapshot, prose from nothing, and NOTHING AT ALL from gen --
whose per-target outcome (built vs already current vs built by a peer) an exit
code cannot express. gen and export take --json; gen reports each target's
outcome as built | current | skipped-peer.
Output volume was already the good news and is left alone: a 600-occurrence
assembly logs the same dozen lines a single part does, verbose included.
* cad: delete the unreachable status board, and fix two comments it misled
InlineStatusBoard painted a persistent per-model board on STDOUT with cursor-
movement escapes, for callers running without a logger. There were none: both
_run_selected_specs call sites pass one, and so does its test. It was reachable
only through parameters nothing set, and stdout is the CLIs' result channel --
`gen --json` writes there now, so an ANSI board on the same stream would have
corrupted it the moment anyone wired the branch up.
With it goes the rest of what only it could reach: `quiet`, `initial_status`,
`status_stream`, and `action_stdout` had no caller either, and `logger` stops
being Optional because there was never a path that did without one. That leaves
one branch instead of three, and the surviving progress line already goes to
stderr where the narration belongs.
Two comments claimed the write and generator sentinels exclude each other:
* backend.py said a POST blocked when the generator is busy "because a build
would just block on the peer". It would not -- the two take different
sentinels precisely so they do not exclude. What it would do is run the same
gen_step() a second time, concurrently, for nothing. The `blocked` hint is
right; the reason was wrong.
* The "A POST NEVER BLOCKS ON A PEER" note read as though the snapshot
pre-check were the guarantee. It is the fast path; the guarantee is the
bounded --lock-timeout, whose contended result lands on the same answer.
* snapshot: the render setting is called theme, everywhere
The viewer calls it Theme, themeSettings.js calls it theme, and the snapshot
CLIs were the last thing still calling it appearance -- flag, job field and
helper names alike. That split is what let the two sides drift apart without
anyone noticing: cadgen's DEFAULT_RENDER_THEME_ID is "workbench", and the
viewer has no such preset (its ids are workbench-light and workbench-dark), so
the snapshot's default theme id cannot resolve against the viewer's table at
all. Fixing that is the next commit; speaking one word is the prerequisite.
Hard rename, no alias: --appearance is gone, and a job's `appearance` key is
gone with it. 420 occurrences across cadjs, implicitjs, cadgen, the viewer,
the skills and their tests.
Two things the mechanical pass could not decide:
* A guard rejected a top-level `theme` key BECAUSE the field was called
appearance and "theme" meant a saved-theme id. Renamed blindly it became
"render jobs use theme; theme is reserved", rejecting the very key it now
documents. Deleted -- there is no second meaning left for it to protect.
* Two comments use "appearance" as the English word ("first-appearance order",
"base edge appearance") and are left alone.
The generated snapshot-render.js bundles are rebuilt rather than hand-edited,
so the browser reads job.theme; verified end to end by rendering a part with
--theme workbench-dark and getting a dark frame.
design/snapshot-cli-unification.md records the plan this is phase S0 of.
* snapshot: the CLI is shared; a skill declares what it accepts
The CAD skill's snapshot was already the universal one -- a KIND_RESOLVERS table
over step/stp/glb/stl/3mf/implicit/urdf/srdf/sdf -- while DXF was a 6 KB shell
over the same core and implicit was a separate 978-line Node program. One
program, three amounts of it.
The command line, the job schema and every kind resolver now live in
cadgen.snapshot_cli. A skill's snapshot becomes a declaration:
run_snapshot_cli(argv, kinds=("step","stp","3mf","glb","stl"), runtime_dir=...)
The CAD entrypoint goes 1224 lines -> 53. The resolvers have to live in cadgen
rather than a skill because a skill may not import another skill's code, and the
robot resolver alone is needed by three skills.
Sharing the implementation makes every skill mechanically CAPABLE of every
format, so the gate is now the only thing keeping `cad` from quietly rendering a
robot. It is on the AUTHORED kind, before `.step.py` collapses to its logical
`.step` path (gating after that rewrite would report a path the caller never
typed), and it names the skill that does own the format:
this skill's snapshot does not render .implicit.js inputs: <path>.
Use the implicit-cad skill's snapshot for it.
It accepts: .step / .step.py, .stp, .3mf, .glb, .stl.
--help is generated from the enabled kinds instead of being one 4 KB paragraph
that documented STEP parameters, robot joint poses and implicit raymarching to
every reader regardless of which skill they were in. The implicit skill's help
is now 33 lines and mentions neither --params nor --focus.
Two things the move surfaced:
* input_kind() never recognised .dxf at all, so a drawing handed to the CAD
skill could not be told where to go. It now reads compound suffixes properly:
`<name>.dxf.py` is a drawing generator, not a STEP one.
* The drawing resolver moves in with the rest, so DXF's package build reaches
the shared registry through the same locked artifact_build(DRAWING_PACKAGE)
it already used. The skills re-point onto it in the next commit.
* snapshot: DXF and implicit re-point onto the shared CLI
Both skills become declarations. DXF's hand-written shell (cli.py) is gone --
39 lines of __main__.py replace it -- and with it goes the reason it lacked
--display, --job and list mode: those were never withheld on purpose, they were
just absent from a second implementation.
The implicit skill drops a standalone 978-line Node CLI, its own Playwright
driver, its own snapshot-runtime/ and its own job schema, and renders through
the same Python core and the same browser bundle as everything else. The browser
side was ALREADY unified -- cadjs's headlessRenderEntry dispatches implicit jobs
to implicitjs -- so only the driver disagreed, and disagreeing was the whole
cost. It bundles its own runtime copy now, gitignored on develop and published
from build-test/main: setup-implicit-cad-skill-symlink.sh already asserted that
scripts/snapshot/runtime must not be tracked here, anticipating this.
An implicit model is always raymarched. It is never rendered from a baked GLB
export, so there is no artifact to build and no lock to take -- unlike STEP
(artifact_build(STEP_PACKAGE)) and DXF (artifact_build(DRAWING_PACKAGE)), which
keep going through the same locked build the viewer and scripts/artifact use.
Moving implicit off its own CLI would have silently dropped three things it
supports, so they move into the shared schema rather than being lost:
* `graphics` -- raymarch quality (detail, shadows, ambient occlusion, model
colours). It gets its own --graphics rather than being folded into --display
because the viewer has a THIRD tab for it beside Theme and Display.
* `implicitParameters` / `implicitAnimation`, the implicit analogues of
stepParameters, named separately because an implicit model is parameterized by
its own descriptor rather than a STEP sidecar.
* `animate` mode. Adding it to the shared mode set meant STEP would have started
accepting it silently, so STEP now names its own set: a STEP model is swept by
an animated --params sweep in view mode, not by a declared animation.
build_dxf_artifact becomes a module-level indirection rather than an import
inside its caller: still deferred, so a skill that never renders a drawing does
not pull ezdxf in, but one patchable seam instead of a hidden one.
* snapshot: pin theme and display to the viewer's, not to a legacy id
The snapshot's idea of "the workbench theme" was the single legacy id
"workbench". The viewer's presets are workbench-light and workbench-dark, and
the browser resolves all three to the same settings through an explicit
back-compat alias -- so the theme itself was never wrong, and the drift hid in
the bookkeeping instead.
WORKBENCH_RENDER_THEME_IDS also decides a render's DEFAULT DIMENSIONS. With only
the legacy id in it, asking for the viewer's real preset by name fell through to
the non-workbench branch:
--theme workbench 1600x1200
--theme workbench-light 1200x900
Identical theme, different image, no warning. Both now render byte-identically
(verified: same sha256), and the default is the viewer's own
DEFAULT_THEME_PRESET_ID rather than an alias the preset table does not contain.
Python and JavaScript cannot share a constant, so they agree by test instead:
test_snapshot_viewer_theme_parity reads the viewer's own THEME_PRESETS and
DEFAULT_DISPLAY_SETTINGS and fails when the CLI cannot name a preset the viewer
has, or cannot express a Display setting the viewer grows. --display already
carried all four of the viewer's display settings; the test is what keeps that
true rather than it being true by luck today.
* design: record snapshot unification progress and what it found
S0/S1/S2/S4 landed. Three of the four findings contradict something the plan
assumed, including its own headline claim: the "workbench" theme id resolves
fine in the browser: it was mis-filed on the Python side, where the same set
also decides render dimensions.
* snapshot: robots render from their own skills, and get a theme built for reading
urdf, srdf and sdf gain scripts/snapshot on the shared CLI. Robot rendering used
to live in the CAD skill, which no longer accepts .urdf/.srdf/.sdf, so without
this it would simply have been lost. A robot needs no artifact -- the browser
parser resolves each link mesh against the description's own URL -- so these
skills build nothing and take no generation lock, unlike STEP and DXF.
The cost is real and worth stating: these were three ZERO-DEPENDENCY stdlib
validators. Each now carries a vendored cadgen, a 1.2 MB browser runtime and
Playwright. They follow cad/dxf and track the runtime (implicit-cad's newer
pattern gitignores it and publishes from build-test/main instead).
A refusal no longer names another skill. Skills install independently, so
"Use the dxf skill's snapshot for it" asserts something we cannot know; it now
states what this skill accepts and stops.
A new `snapshot` theme is the default for every snapshot CLI, and is deliberately
absent from the viewer's picker (RENDER_ONLY_THEME_PRESETS, resolvable by id but
not listed in THEME_PRESETS). It is Workbench Light with exactly two things
removed: the ground grid and the origin axis. Both are orientation you can ignore
in a live viewport, and geometry-shaped contrast in a still image -- straight
low-contrast lines crossing the model and the background, at the same weight as a
real silhouette edge, with no motion or interaction to say otherwise. Materials,
lighting, background and projection are inherited unchanged, so a part reads in a
snapshot exactly as it does in the viewer; only the furniture that is not the
model is gone.
Verified: tom.urdf, tom.srdf and so101.sdf all render (their link meshes are
LFS-backed and must be hydrated first -- an unhydrated pointer fails as "No link
mesh loaded", which looks like a code fault and is not one).
* snapshot: skill docs for the unified CLI
urdf, srdf and sdf document a snapshot tool for the first time, including the
one failure that looks like a bug and is not: link meshes are LFS-backed, and an
unhydrated pointer fails as "No link mesh loaded for robot".
DXF's section catches up with what moving onto the shared CLI gave it -- --display,
--job and list mode -- and names the locked artifact_build(DRAWING_PACKAGE) its
package build goes through.
CAD's says what it now refuses and why, and both it and every other rendering
skill state the same two rules: one --theme, one --display, and a default
`snapshot` theme that drops the grid and origin axis because a still image is
read rather than looked at.
* snapshot: an implicit render casts no shadow either
The mesh path already casts none -- the snapshot theme disables the floor, so
there is no plane to catch one -- while implicit raymarching brought its own
shadow from graphics.shadows, which defaults on for the viewer. So one format
dropped a shadow and every other format did not, for no reason a reader of the
images could infer.
Implicit snapshots now default graphics.shadows to false; passing --graphics
with shadows turns it back on. The viewer's default is untouched: this is a
snapshot-side default, not a change to DEFAULT_IMPLICIT_GRAPHICS_SETTINGS.
Self-shading carries the form on its own, which the re-rendered h-tree confirms.
* snapshot: projection is a theme trait for every format, so every format frames tight
Non-STEP snapshots sat in a sea of empty canvas while STEP filled its frame. The
cause was not the framing code, which is correct and shared -- it was that the
framing code never ran.
renderJobContext forced non-STEP sources to a PERSPECTIVE camera regardless of
the theme, commented "non-STEP sources keep their historical perspective
framing". applyTightOrthographicFrame -- the pass that measures projected
geometry and fits the canvas to it -- is orthographic-only, so every mesh,
drawing, implicit and robot render skipped it silently.
Instrumenting the browser side made it unambiguous. Same default camera, same
theme, three inputs:
STEP usePerspectiveCamera=false tight frame RAN halfHeight 24.6 -> 20.8
STL usePerspectiveCamera=true tight frame SKIPPED
URDF usePerspectiveCamera=true tight frame SKIPPED
(I guessed twice before measuring -- first that these inputs carry no
displayRecords, then that a scene-graph fallback would find their meshes. Both
were wrong: they carry 1 and 28 records respectively. The fallback was reverted.)
Projection now comes from the theme for every format, which is the rule the
VIEWER already adopted in U1 ("projection is a viewport trait, honoured by every
format"). The snapshot simply never followed, so this closes a theme/viewer sync
gap as well as the framing: tom.urdf goes from roughly a quarter of the frame to
filling it, with servo horns, PCB traces and gripper teeth all legible.
Also in this commit, from review:
* --display is STEP-only. Its settings are CAD topology settings and every other
resolver already rejected all four, so offering it elsewhere only advertised an
option that errors. It is now absent from those skills' help and refused with a
reason. renderJobContext has always gated job.display the same way.
* The snapshot theme states shadowOpacity: 0 rather than inheriting "no shadow"
from the disabled floor plane. It is inert today; a property held by accident of
another setting is lost the moment that setting changes.
* docs: the markdown catches up with what the CLI actually does
I wrote the skill docs before the last two commits changed the behaviour they
described, so five of six skills advertised a flag that now refuses them:
* --display is STEP-only. dxf, implicit-cad, urdf, srdf and sdf all listed it.
They now say it does not exist, and why -- display settings are CAD topology
settings, and none of those formats carry topology.
* references/snapshot-review.md still said the CLI defaults to theme "workbench"
"matching CAD Viewer". Both halves are stale: the default is the render-only
`snapshot` theme, and it deliberately does NOT match the viewport.
* Two skills claimed a snapshot and the viewport are "the same picture". They
share a renderer, not a theme. Narrowed to what is true: geometry, materials
and lighting render identically, and the default theme differs by dropping the
grid, origin axis and shadows.
* implicit-cad now documents that raymarched shadows are off by default, and how
to get them back.
The design doc had three claims its own execution disproved: that the
"workbench" theme id cannot resolve (it can, via a browser alias -- the drift was
in the dimension bookkeeping), that resolvers would live in a snapshot_kinds.py
(they landed in snapshot_cli.py, and it says why), and that a refusal points at
the skill owning the format (it must not). It also now records the framing
finding, which is the largest thing the plan failed to predict.
Checked by diffing each skill's SKILL.md against its real --help output rather
than by reading.
* cli: the parts inventory stops costing 73k tokens
`snapshot --mode list` is the one CLI output whose size grows with the model --
everything else here is O(1), and `gen` prints the same ~100 bytes for a 600-part
assembly that it prints for a single part. On the 600-part rover the inventory
was 293,681 B across 12,619 lines, roughly 73k tokens into an agent's context,
and most of it carried nothing:
id identical to ref without the '#' 600/600 parts
occurrenceId identical to ref without the '#' 600/600 parts
label identical to name 600/600 parts
bounds -2449.9999046325684, for 2450 mm
the whole payload pretty-printed 38% of the bytes
A part now carries ref, name, triangleCount, vertexCount and bounds. `ref` is the
survivor because it is the form that pastes straight into --focus/--hide and
inspect; the bare id is a string slice away. Coordinates round to 3 decimals --
a nanometre, orders below anything this repo models.
293,681 B / 12,619 lines -> 93,989 B / 1 line 3.1x, ~50k tokens
JSON on stdout is compact everywhere now, in snapshot and in inspect. inspect had
the compact path already but bound to --quiet; compact is simply the default, and
--quiet keeps the meaning it has for --format text. A person who wants a payload
laid out pipes it through `jq .`; an agent that pays for the whitespace cannot
get it back.
tests/python/global/test_cli_payload_budget.py holds the line: it names the
allowed fields, names each deleted duplicate WITH the measurement that condemned
it, and fails if any CLI starts pretty-printing to stdout again.
First of six phases (P0); no back-compat by request.
* cli: stdout is the result, on every tool
`gen` printed nothing to stdout at all -- its only output was the logger's prose
on stderr -- while export, snapshot, validate and inspect all answered there. So
`cad gen a.step.py 2>/dev/null` gave a caller an exit code and silence, and there
was no way to learn WHICH of several targets was rebuilt and which was already
current without asking for --json.
It now prints one line per target, `<outcome> <package path>`:
current __cadgen__/models/cylindrical_spacer_sleeve.step.py
--json upgrades that to the compact object it already emitted. The logger's
narration stays on stderr, where it was.
test_cli_stream_contract runs the real CLIs and asserts both halves: a result
survives `2>/dev/null`, and no logger-prefixed line ever reaches stdout. Source
inspection cannot catch a stream that drifts through a library three layers down,
so these pay the subprocess cost.
P1 of six.
* cli: a snapshot says what it is doing
A snapshot was silent for its ENTIRE run. On a cold assembly that is an artifact
build, then a browser launch, then a render -- tens of seconds with nothing on
either stream, which reads exactly like a hang. Every other long operation here
reports: a contended generation lock now says why it is waiting, `gen` paints a
phase bar. This did not, and it is the slowest tool of the three.
Now, on a cold 600-part assembly:
resolving input (building render artifacts if needed)
starting browser
rendering mars_rover_concept.step.py
Resolution is reported BEFORE it runs, because that is where the package build
happens and on a cold model it is the slowest phase -- longer than the render.
Same shape as the other reporters: stderr only, self-erasing on a tty, and one
durable line per PHASE CHANGE on a non-tty rather than a bar smeared over an
agent's captured log. stdout still carries nothing but the result.
Partial P2: `cad artifact`, `dxf artifact` and `dxf gen` still have no terminal
progress line, though all three already write the sidecar record the viewer
reads.
* cli: artifact builds narrate, and no CLI tracebacks at a caller
`scripts/artifact` builds exactly what `scripts/gen` builds and said nothing
while doing it: the sidecar record reached the viewer and a terminal caller
watched a silent process. `gen`'s one-line progress painter is now shared
(cli_progress_line) and both the STEP and DXF artifact CLIs drive it, so the two
tools can no longer disagree about whether a build is worth narrating.
Two things the widened payload test caught that the first pass missed: `cad
artifact` and `dxf artifact` were still pretty-printing their JSON to stdout.
Both are compact now, and the test enumerates every stdout-JSON source rather
than the two I happened to have open.
Compact error reporting extends to the artifact and implicit gen CLIs, so a
failure there prints the exception and the frames in the caller's own model
rather than ~60 lines of runtime traceback.
One bug of my own, caught by the suite rather than by reading: extracting the
shared painter left `_cli_progress_line` without its @contextmanager decorator
and gave the new one two, which broke every `gen` path with
"'_GeneratorContextManager' object is not an iterator".
P2 and P4.
* design: the CLI output contract, and what it cost to find
design/cli-output-tidy.md records the three rules the phases were enforcing --
stdout is the result, payload size is a feature, never silent -- with the
measurements behind each, and states plainly what is NOT finished: dxf gen has
no progress line, and --verbose/--debug are still missing from five CLIs.
Both regression tests get a section explaining why they exist in the form they
do. The payload budget test names each deleted field with the measurement that
condemned it, so a reintroduction fails with a reason rather than a diff; its
first version enumerated two stdout-JSON sources and missed two CLIs that were
still pretty-printing, which is why it now enumerates all of them.
skills/cad/SKILL.md states the stream contract for callers.
P5.
* cli: drawings report their stages, and validators narrate
`dxf gen` dropped its progress sink on the belief that a drawing build is "one
opaque generator run with no countable stage". That was true only of the Python
half: DRAWING_PACKAGE declares parse/mesh/write, and the Node child reports them
while this process holds the lock. The sink is threaded through, so a drawing
build paints the same bar a STEP build does.
--verbose reaches the urdf, srdf and sdf validators. It narrates each target on
stderr and leaves stdout byte-identical, so turning it on never changes what a
caller parses.
Two corrections to the survey that produced the remaining-work list:
* `urdf/urdf` is not a CLI. It is the CLI package -- no __main__, running it
errors -- and its entry is `validate`. Adding the flag to cli.py covers both
names because they were always one program.
* `skills/dxf/scripts/dxf` was an empty directory containing nothing but stale
__pycache__, which is why invoking it failed with "can't open file". Deleted.
--debug stays on `cad snapshot` alone, and the design doc now says why: it
answers how a render artifact RESOLVED, and no other CLI has that question. gen
and artifact already report outcome and package path on stdout; a validator has
nothing to resolve. Adding it elsewhere would mean inventing content.
* docs: the headless implicit render path no longer exists
viewer/docs/renderer-consolidation.md is a live status doc, not a dated record,
and it still described four render shells with paths 3 and 4 "drifting
near-copies" -- naming the standalone ~980-line Node snapshot CLI as a present
problem. That CLI, its runtime and its tests were deleted when the snapshot CLIs
were unified; implicitHeadlessRenderEntry survives as a BACKEND that cadjs
dispatches to, and six skills now drive one bundle through cadgen.snapshot_cli.
Three shells, not four, and the drift is gone with the copy.
Everything else in skills/, models/ and the root docs checks out against the
branch: no --appearance, no snapshot.mjs invocations, no pre-reorg models/dxf
paths, and models/README matches the reorganised layout. The `occurrenceId`
references in build123d-modeling.md are the selector/topology tables, which are a
different payload from the parts inventory and unchanged.
* ci: the implicit snapshot runtime is only checkable where it exists
CI failed on release/0.4.0 with:
Missing generated runtime file: skills/implicit-cad/scripts/snapshot/runtime/render.html
Missing generated runtime file: skills/implicit-cad/scripts/snapshot/runtime/snapshot-render.js
My bug, from the snapshot unification rather than the develop merge. I put the
snapshot-runtime check inside check_builders, whose comment argues it belongs in
BOTH layouts because it is esbuild output and never a symlink. That is true of
the node BUILDERS, which this skill tracks. It is not true of the snapshot
RUNTIME, which -- unlike `cad` and `dxf` -- this skill GITIGNORES on develop and
publishes from build-test/main; setup-implicit-cad-skill-symlink.sh asserts it
stays untracked. So the check demanded a file the branch deliberately does not
have, and a fresh clone is exactly what CI is.
It never fired locally because my working tree had the artifact: I had built it.
Reproduced by moving the runtime aside, which fails before this change and passes
after.
The check moves to the production branch, where the runtime is expected to exist.
The builders keep being checked in both layouts, which was always the right call
for them.
* ci: dxf installed esbuild and then deleted it
`scripts/bundle/bundle.sh --clean`, which is what CI runs to bundle production
outputs, died with:
.../tmp/dxf-snapshot-build/node_modules/.bin/esbuild: No such file or directory
exit code 127
bundle-dxf.sh called ensure_snapshot_runtime_deps and THEN, two lines later,
`rm -rf "$CHECK_DIR" "$SNAPSHOT_BUILD_DEPS_DIR"` under --clean -- deleting the
directory it had just installed esbuild into, before build_snapshot_runtime tried
to run it. bundle-cad.sh has always cleaned before installing; dxf did not.
Introduced in 1202c76c ("dxf skill: add scripts/snapshot on a shared render
core"), so it predates this branch's snapshot work and has been latent for as
long as --clean has been the CI path. The three robot bundle scripts added here
do not wipe their deps dir at all, and implicit-cad's ensure already sits before
its clean, so dxf was the only one.
Reproduced with the exact CI command rather than by reading: bundle.sh --clean
fails before, exits 0 after.
* tmp: probe whether the sdf baseline is mis-cut or platform-dependent
Temporary diagnostic workflow. Regenerates the sdfEquality baseline on the CI
runner and diffs it against the committed one. If only the 42 entries added by
e467e52d differ, they were cut on a dev machine and a clean re-cut fixes it; if
everything differs, the digest is platform-dependent and no single committed
baseline can serve both macOS and Linux. Delete once answered.
* tmp: remove the sdf baseline probe, question answered
Regenerating on ubuntu-latest (node 22.23.1, linux x64, v8 12.4.254.21) changes
46 entries against a corpus of 47 models. Not the 42 the rebase added -- nearly
all of them. So the digest is PLATFORM-DEPENDENT and no single committed baseline
can serve both a macOS dev machine and this runner; a clean re-cut on Linux would
just move the failure onto every developer.
The fix is a change to the gate, not to the baseline. Recorded for whoever picks
it up rather than guessed at here.
* implicitjs: make the sdf baseline portable, keep the differential gate exact
The corpus gate has been red on this branch since the rebase. The cause is not the
rebase: evaluating the corpus on linux/x64 and on darwin/arm64 -- same V8,
12.4.254.21 -- diverges on 31 of 45 models. No single Float64 baseline can serve
both, so re-cutting it on either machine just points the failure at the other one.
The divergence is ulp-scale: the six sampled raw values per model are identical on
both platforms for all 45, so this is libm noise, not semantics. A digest is
all-or-nothing, so ulp noise reddens the entry.
Split the digest by what it is actually comparing:
compiled vs interpreted same process, same machine -> raw Float64, unchanged
vs the committed baseline crosses machines -> narrowed to Float32
Float32 and not rounding to N significant figures, which is the obvious fix and a
trap: rounding is discontinuous, so it erases a 1-ulp difference except when the
value sits near a boundary, where it keeps it. Over ~300k sampled values that is
tens of expected straddles -- green, then flaky. The float32/float64 precision gap
puts the same straddle probability at ~1e-9 per value, ~3e-4 corpus-wide.
Cost: no sensitivity below ~1e-7 relative, which is what we are already forced to
tolerate. Object.is semantics survive -- float32 keeps -0 and NaN distinct. The
bit-exact gate is not weakened, only aimed at the comparison it fits.
Local: 351 pass, 0 fail.
* cad skill: ship the dxf node builder its own gen path calls
The CAD skill runtime shipped cadgen but no packages/cadjs/bin, so the bundled
tree had no dxf-artifact.mjs. cadgen resolves its Node builders beside whatever
runtime ships it, and scripts/gen accepts a bare gen_dxf() document, so ten tests
under tests/python/skills/cad/cadgen/ died on:
NodeBuilderError: Node builder is missing:
skills/cad/scripts/packages/cadjs/bin/dxf-artifact.mjs
Invisible in the development layout, which is why it survived: there the lookup
walks up to the repo's own packages/cadjs and finds the builder. Only the bundled
tree -- what CI tests and what actually ships -- is missing it.
Also invisible in CI until now: the run died in the JS suite before reaching the
Python tests. It is not a regression from that fix, only newly reachable.
Mirrors bundle-dxf.sh: BUILDER_ENTRIES + bundle_node_builders on write,
check_node_builders on --check. The output is esbuild bundling, never a symlink,
so it is committed and checked in both layouts.
Verified in the production layout CI uses: 73 tests, OK (was 10 errors).
* f14d: a blended-body Tomcat, and the repo bugs it found
Adds models/renders/f14d — an F-14D Super Tomcat at 20 deg sweep, canopy
closed, gear down, clean airframe. Source only; render packages and snapshot
output stay ignored.
The airframe skin is ONE lofted solid. Full-width sections are generated by
smooth-blending the component volumes present at each station — forebody,
gloves, inlets, nacelles, pancake tunnel — so the transitions are continuous by
construction and nothing in the primary surface is filleted.
Dimensions: 19.110 m length, 19.545 m span at 20 deg and 11.646 m at 68 deg
(both exact), 4.871 m height against a 4.880 m spec. Planform stations, 69.2 deg
glove sweep, +-1.457 m nacelle axes and 5.0 deg fin cant were measured off the
F-14D general-arrangement drawing; that extraction reproduces the published
swept span to 0.8 %.
Status: the whole-aircraft gauntlet FAILS 0/4. Four blind critics independently
named the same two gaps — flat faceted panels with hard edges, and uniform tone
with no weathering. Both are finishing problems, not geometry.
Repo fixes found while building it:
- Theme JSON containing `edges` is rejected, and the hypercar example theme —
the only in-repo example of a hand-authored presentation theme — had one, so
copying it failed. Split into theme + display JSON; verified by a render.
- `render.padding` was clamped to a 0.1 MINIMUM in cadScene.js while
framePadding() allows 0..0.15, so the same value framed differently in the
viewport than in a snapshot and tighter framing was silently ignored. Bounds
reconciled; 500/500 cadjs tests pass. Every skill runtime that vendors cadjs
regenerated to match (cad, dxf, implicit-cad, sdf, srdf, urdf).
- AGENTS.md: starting the Viewer from a lightweight worktree (four failures,
none naming its cause), plus the catalog's dot-directory skip and the rule to
verify a link by loading the page rather than curling /__cad/asset.
- build123d-modeling.md: multi-section lofts match sections BY INDEX, so
non-feature-aligned sampling silently crumples a varying-width body while
every deterministic check passes; smoothstep interpolation makes a staircase;
a closed lobe ending inside the body is a cliff; use the cubic compact-support
smooth-max, since the quadratic is only C1 and a curvature jump draws a line.
- repair-loop.md: diagnosing an unnamed loft failure by prefix and adjacent
pair; and boolean cost against a large lofted B-spline, which is per-tool and
superlinear — 1 tool 24 s, 4 tools 70 s, 41 tools over 900 s, and a 44-tool
build ran seven hours without completing.
- step-generation.md: a generator that skips missing optional modules can never
invalidate its artifact cache, because the closure hash omits modules that did
not exist at first build.
BUGS.md carries all 14 findings, including one retracted after I disproved it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* cad: refresh the bundled dxf-artifact runtime after the merge
The merge brought a new bundle input into the cad skill (upstream added
skills/cad/scripts/packages/cadjs/bin/dxf-artifact.mjs), so the vendored copy
needed regenerating against this branch's cadjs — which carries the padding
clamp fix. bundle.sh --check is clean and the symlink layout is valid.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Remove BUGS.md from the release branch
BUGS.md was a working log, not a shipped artifact, and does not belong in a
release branch. This removes the file entirely, including the 21-section
chronograph log it carried from earlier work (last touched in 04ef281b) and the
14 entries added during the F-14D build.
The content is not lost: it remains in history at 05a5a041^ and can be recovered
with `git show 05a5a041^:BUGS.md`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Remove per-session builder briefs from the release branch
BUILDER_BRIEF.md / BRIEF.md under models/renders/{hypercar,f1,f14d} were written
for subagents during one build session and committed without being edited for
publication. Each hard-codes an absolute .claude/worktrees path, and the
hypercar one also instructs the reader to run a helper script at a
/private/tmp/claude-501/... scratchpad path carrying a session UUID. They only
resolve on the machine that produced them, so they are working docs rather than
model documentation.
models/renders/README.md linked two of them; those entries now point at the
model directories instead, so no link dangles.
Recoverable with `git show 906dfdd3:<path>`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* bundle: the snapshot runtime pins meshoptimizer the way the node builders do
snapshot_runtime.sh installed esbuild, three, and gifenc into its pinned
toolchain but not meshoptimizer, and put only packages/ on NODE_PATH.
glbMeshData.js reaches meshoptimizer through a dynamic import("meshoptimizer"),
which esbuild leaves as a bare specifier -- with no error and no warning -- when
it cannot resolve it. The call site catches the failure and renders on without a
decoder, so a bundle built without an ambient packages/cadjs/node_modules
silently loses EXT_meshopt_compression support instead of failing the build.
Verified by deleting node_modules outright: the build dropped 47,850 bytes of
decoder and left import("meshoptimizer") sitting in the output.
CI never saw it, because setup-deps runs npm ci --prefix packages/cadjs before
bundling. Local and manual bundling did.
three, gifenc, and meshoptimizer now come from packages/cadjs/package-lock.json
rather than hardcoded literals, matching node_builders.sh, so a dependency bump
cannot drift from what the snapshot runtime ships. A lockfile read failure is
fatal: falling through with an empty version would npm-install `three@`, which
resolves to latest rather than the pinned build.
With the toolchain on NODE_PATH the output is byte-identical to a build with
node_modules present, so this changes robustness and nothing else.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* bundle: regenerate the runtimes the meshoptimizer bump left behind
12670ff4 moved packages/cadjs/package-lock.json to meshoptimizer 1.2.0 without
regenerating the bundles that embed it, so eight committed runtimes still carried
1.1.x: no meshopt_decodeFilterColor, encodeVertexBufferLevel, encodeFilterColor,
spatialsort, reorderPoints, or initWorkers. bundle.sh --check is the first step of
the Test job, and it has been failing on this branch ever since.
No behaviour change. The source only calls MeshoptDecoder.ready and
MeshoptEncoder, neither of which moved between those versions; what shipped was
stale, not broken. Release publishes were unaffected either way, because the
publish job rebuilds with bundle.sh --clean rather than trusting these files.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: the release workflow no longer uploads models or deploys a web app
upload-models.yml and deploy-viewer.yml are gone and release.yml no longer
references them, but AGENTS.md and CONTRIBUTING.md still listed both among the
jobs a release run performs. AGENTS.md contradicted itself two paragraphs later,
where it already says the CAD Viewer is a local-filesystem app with no hosted
deployment. Both now name what the workflow actually runs, including the docs
deploy it does still trigger.
models/renders/moonwatch/README.md pointed at /BUGS.md, removed in 906dfdd3. The
sentence it was citing stands on its own, so the dangling pointer is dropped
rather than repaired.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* skills: every CLI reports the path you actually invoke
cad's inspect said `usage: inspect`, dxf's gen said `usage: gen`, and sdf, srdf,
and urdf all said `usage: validate`, while their siblings in the same skills
already said `usage: scripts/artifact` and `usage: scripts/gen`. Every one of
these is driven as `python scripts/<name>`, so the short form named nothing a
caller could run -- and dxf disagreed with itself between two adjacent commands.
inspect's twelve epilog examples led with the same bare name and follow. So do
three lines in inspection-and-validation.md, which the rest of that file already
writes as `python scripts/inspect`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* implicit-cad: track the snapshot runtime, because nothing was publishing it
The skill's generated browser runtime was gitignored on develop and, per the
comment on the ignore, "published only from build-test/main". Nothing did that.
The publish job stages with `git add -A` (release.yml:396), which honours
.gitignore, so skills/implicit-cad/scripts/snapshot/runtime/ never reached main
at all.
This is new in 0.4.0. main still ships the old self-contained
scripts/snapshot.mjs alongside a tracked runtime under
scripts/packages/implicitjs/. This branch replaced both with the shared
cadgen.snapshot_cli, which loads RUNTIME_DIR/render.html -- so the next release
would have shipped a snapshot CLI with nothing to load.
The failure is not a clean error. Rendering a real model with the runtime moved
aside resolves the input, starts the browser, and then sits in
Page.wait_for_function for the full 300s Playwright timeout.
Nothing catches it either: check-builds.sh runs before the commit, against the
working tree where bundle.sh has just written the files.
So the runtime is tracked now, exactly as cad, dxf, sdf, srdf and urdf track
theirs, and bundle-implicit-cad.sh checks it in BOTH layouts rather than only in
production. Production-only was the right call when the file was absent on
develop; it is not absent any more. The assertion in
setup-implicit-cad-skill-symlink.sh that it stay untracked goes with it, and so
does the neighbouring ignore for scripts/export/runtime/ -- nothing generates
that path, and it shared the comment being deleted.
Verified by simulating the publish commit: `git add -A` after a --clean bundle
now stages render.html and snapshot-render.js. The new development-layout check
was confirmed to fire by tampering with the committed runtime.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* implicit-cad: declare the snapshot runtime as a generated output
check-builds.sh derives the paths it guards from `bundle-skill.sh --all
--print-outputs`, and bundle-implicit-cad.sh printed three of its four outputs.
The snapshot runtime was missing, so the path 349def09 just started tracking was
covered by neither of that script's assertions: that a declared output EXISTS in
the production tree, and that nothing under it is a symlink (which Codex's
plugin installer drops silently).
The omission is a leftover from the runtime being gitignored. It could not be
declared as a committable output while it was not one; it is one now, and cad,
dxf, sdf, srdf and urdf all declare theirs.
Verified by moving the runtime aside after a --clean bundle: check-builds.sh now
exits 1 with "Missing production bundle path:
skills/implicit-cad/scripts/snapshot/runtime", where before it passed.
test_node_builder_bundles.py already makes this argument for builder
directories -- "a builder directory that is not declared there is a builder
directory nothing checks". There is no equivalent guard for snapshot runtimes,
which is why this went unnoticed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* release: assert generated outputs are in the publish commit, not just on disk
Every gate in the publish job -- check-builds.sh, test.sh, test-docs.sh -- runs
against the WORKING TREE, where bundle.sh has just written the generated
outputs. The staging that turns that tree into a commit happens afterwards, with
`git add -A`, and nothing looks at the result. `git add -A` honours .gitignore,
so a generated path that is ignored passes every check and ships missing anyway.
That is exactly how skills/implicit-cad/scripts/snapshot/runtime reached a
release branch empty: bundle.sh wrote it, check-builds.sh saw it on disk and was
satisfied, `git add -A` skipped it, and the published skill sat in Playwright
for the full 300s timeout with no render.html to load.
So the index gets checked too, next to the models/ assertion that already guards
this same step for the opposite problem. It reports every missing path rather
than the first, because a .gitignore mistake is rarely limited to one entry.
Verified both ways against a real --clean bundle: passes with all 20 declared
outputs staged, and with the implicit-cad runtime dropped from the index but
left on disk -- the precise shape of the original bug -- it fails and names it.
The check is only as complete as --print-outputs, which 808e5ed6 finished.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* cadgen-daemon: refuse arguments instead of starting a server
main() was `return serve()`, and sys.argv was never read. So
`python scripts/cadgen_daemon --help` did not print help -- it bound the socket
and served for the full 600s idle timeout. Any argument did: a typo on a real
daemon start was silently ignored rather than reported.
It matters because of where the directory sits. cadgen_daemon is internal, run
for you by client.py when CADGEN_WARM=1, but it lives in skills/cad/scripts/
beside gen, export, inspect, artifact and snapshot -- five directories that all
answer --help. Anything enumerating that directory to learn the skill's
interface, human or agent, walks into a ten-minute wait.
Arguments now print what this is, name the CLIs the caller probably wanted, and
exit 2. A bare invocation still serves, which is the only way client.py starts
it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* snapshot: --json stops echoing the rendered image back at the caller
print_render_result serialized the whole result, and a rendered output carries the
bytes it was written from: dataUrl for images, text for SVG. write_output_payload
decodes those to disk, and the CLI writes before it prints, so by the time anything
reached stdout the file existed and `path` named it. The blob was a verbatim second
copy nothing reads -- there is no consumer of either key in any skill, test, the
viewer backend, or any SKILL.md.
Measured on models/implicits/h-tree-solid.implicit.js:
one 1600x1200 PNG 228,696 chars -> 150 (~57k tokens -> ~40)
two-output job 310,946 -> 248
orbit GIF 1,781,268 -> 206 (~445k tokens -> ~50)
The orbit case exceeded every context window: an agent running one documented
command lost its session to base64 it could not use. This is the same output the
release has been making cheap -- the comment above _JSON strips indentation because
"an agent that has to pay for the whitespace cannot get it back", while the function
below it emitted 1.7 MB. All six rendering skills share the call site, and the docs
push agents toward multi-output review packets, which multiplied it per camera.
The default (non-JSON) path was never affected; it prints one "saved snapshot:"
line per output and was already 56 chars.
Stripping happens on a COPY, so write_output_payload still sees the payload whatever
the print order. The fallback branch for a result with no outputs list is stripped
too, so an unexpected shape cannot reintroduce it. Everything a caller uses survives
-- path, width, height, mimeType, and orbit's frameCount/fps/durationSeconds, which
were previously buried under the blob.
The one test covering this path passed outputs: [], which is why a megabyte of
base64 shipped unnoticed; the new test uses a populated list and asserts both that
the payload is gone from stdout and that it survives in the caller's dict.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: drop the orphaned social preview image
docs/public/social-preview.png is byte-identical to social-preview-gear.png
(same MD5, and git already stores them as one object), and nothing references it:
layout.tsx builds a single socialPreview object pointing at the -gear file, used
for both openGraph and twitter. It is a leftover from the rename.
Removing it costs the repository nothing, because the two shared a blob. It does
take ~308 KB off every docs deploy: Next copies public/ verbatim, so the unused
copy was shipping with the site.
favicon.png is deliberately left alone. It is the apple-touch icon
(layout.tsx: icons.apple), not the browser favicon -- that is favicon.ico -- so
512x512 RGBA at 143 KB, 0.55 bytes/pixel, is the size it should be.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>