What We Shipped

Changelog

Every commit. No marketing spin.

1–30 of 371 commits
1 / 13
Pull the OCCT mesh/edge readback (extractMeshData + face/edge helpers + mesh/edge
constants) out of evaluator-worker.js into a new pure module, mesh-extract.js, so it's
importable by node/vitest and by the future native path. Worker now delegates to it.

Readback micro-opts: skip the per-node gp_Pnt.Transformed() when the face location is
identity (the ~100% common case), and always release the Node(i) wrapper (previously
leaked -> per-vertex GC churn). Preallocated typed-array combine.

Add dev-only _meshTrace ring (meshMs/readbackMs/combineMs/nodes/tris), exposed to the
jc3-debug bridge as `meshTrace`, plus a golden-mesh vitest (box: 24 nodes/12 tris/bbox/
signed-volume winding; cylinder classification). Mesh verified byte-identical.

Measurement finding (see local plans/occt-performance.md): native OCCT meshing dominates
readback ~4.6:1 and is the hard ceiling; real parts are face-count-bound not node-bound,
so readback micro-opts and deflection tuning are in the noise on prismatic parts. The real
lever is parallel native meshing in a self-built OCCT WASM (scoped separately). Phase 1
ships for its leak fix + curved-part value + carry-forward to the native readback contract.

Docs: evaluator.md documents the module split + perf finding. CLAUDE.md: make MCP-first
(mcp__jc3-debug__*) a CRITICAL rule, CLI is fallback-only.
Modeled threads sat at "Computing…" forever: the old path fit the helix spine with
GeomAPI_PointsToBSpline (wavy, inexact) and swept a sharp-V triangle, then booleaned
that against the exact shaft — OCCT's boolean never converged, and since OCCT runs
synchronously it wedged the whole worker.

- Exact-geometry builder (FreeCAD parity) in new Drafting/thread-builder.js:
  buildExactHelixWire (edge on Geom_CylindricalSurface / Geom_ConicalSurface),
  buildISOThreadProfile (truncated ISO trapezoid, optional rounded crest),
  makeThreadSolid (PipeShell -> Simulate caps -> Sew -> MakeSolid -> orient). case
  'thread' builds at origin, places onto the axis via gp_Trsf, one boolean.
- Guards: pitch/turns clamp, degenerate bailouts throw -> surface a feature error.
- Worker watchdog (evaluator.js): per-request timeout that terminates + respawns a
  wedged worker and rejects the request, so synchronous OCCT can never hang the UI.
- Helix feature ported to the same exact spine (parity-verified, with fallback).
- NPT tapered pipe threads (conical helix) + rounded-crest option + tool UI/data.
- Fixes from live testing: Thread tool read the removed face.positions/indices, so
  hole depth defaulted to 1" (threads hung out the bottom) and internal-detection
  defaulted true — now read threeGeom position/normal. Internal threads now CUT the
  bore wall instead of fusing a floating ridge.
- Tests: Drafting/thread-builder.test.js (10) — valid/closed/oriented solids,
  rounded/tapered, exact-helix parity, boolean terminates, internal cut removes
  material. Docs: docs/Drafting/threads.md. Release notes: v2.3.6.

Note: internal-thread visual correctness is still WIP (tracked for follow-up).
Right-click a Sheet Metal Base → Flat Pattern → name the sketch, and the
body (base + all flanges/bends) is developed into a static sketch: outline
+ holes on the default layer, fold centrelines on a "Bend Lines" layer.

Implements unfoldShape() — the flatten/2D-extraction stage that was stubbed:
rigidly places each reachable flat region into the root plane, replaces each
bend with a bend-allowance-wide strip (inner radius from the coaxial cylinder
pair, K-factor from the base feature), and extracts the outline (edges on
exactly one skin face) + connecting strip sides + one bend line per fold.
Also fixes findBends so a bend cylinder only binds to skin faces (normal ⟂
axis) — through-thickness end-caps and drilled-hole bores no longer bridge
the two skins or read as bends.

Wiring: 'unfold-body' worker RPC (evaluator-worker) + unfoldBody() + response
dispatch (evaluator), Flat Pattern context-menu item (browser-panel), and
_createFlatPattern() (Drafting/main) — prompts, runs the unfold, builds the
sketch. unfold.test.js gains flat-pattern coverage (bracket develop length,
two-bend U-channel, hole preservation, flat plate); docs updated.
Feature-edit parity: SheetMetalBase, Flange, and Revolve gain the standard
edit flow (_pendingEditSetup → _setupForEdit → in-place commit with cutoff),
joining the tools that already had it. The type→tool map moves to a shared
feature-edit-registry.js consulted by both onEditFeature and the browser
panel, which now hides Edit on unsupported feature types. Flange restores
its dialog via a pure flangeStateFromParams(); Base edits preserve the
feature id so body lineage and flange targets survive, and thickness edits
propagate to every bend; Revolve edits reuse stored profile/axis params
verbatim ("frozen" mode) so a hidden/deleted sketch can't block the edit.

Sheet metal interop: fix FlangeTool._context falling back to doc.root
(wrong default thickness for sheet bodies in nested components); add a
through-containment Cut inference (infer-op.js) so extrude/revolve
through-cuts on thin stock stop auto-suggesting Join; Revolve's
intersection scan now skips stale/hidden bodies like Extrude's.

Collateral: deleting a root feature with dependent features now confirms
and cascade-deletes them instead of leaving broken features; clearer error
when a target body's base feature is disabled.

Tests: context resolution invariance, flange params round-trip, infer-op
spans, and OCCT mappedName survival through base→cut→flange chains.
Docs: sheet-metal.md editing/interop sections, evaluator.md edit-cutoff
contract, release notes.
The previous miter cut each wall at the corner's bisector plane, which bevels
the edge THROUGH the sheet thickness — an edge a flat-cut-then-bent part
cannot have — and welded/interfered geometry that no single sheet produces.

New construction, faithful to the flat pattern:

- Only the FLAT-WALL sub-prism extends past a shared corner; the bend zone
  and overlap end square at the edge span (a bend line only spans its
  physical edge), leaving the real corner notch between the two bend zones.
- The miter cut plane CONTAINS the wall's own thickness direction (square
  edge) and follows the straight flat-pattern line where this wall's slab
  would first touch the partner wall's INNER skin, minus the gap:
  s(w) = −u_inner(w)·cot(Φ/2) + gap/(2·sin(Φ/2)) — linear in wall depth, so
  the locus is a plane spanned by t̂ and ŵ − (du_i/dw)·cot(Φ/2)·d̂. At
  90°/90° it degenerates to the classic square wall end just short of the
  partner's inner face; at non-90° bends the two edges track each other down
  the lean and meet as closely as square edges physically can.
- Provably zero wall↔wall interpenetration outside the sheet (each wall
  stays entirely out of the other's slab). The harness test's airCommon
  check shows un-mitered obtuse walls DO collide — the honest reason the
  cut exists — while mitered walls never do, at 90° or 120°.
- _buildWallMiterCut replaces _buildMiterHalfSpace; miterExtension is no
  longer used by the worker (kept in flange-corners.js for its tests);
  keep-lines are emitted at the base-span bend caps for all ends again.

Tests: square-through-thickness probes at both skins (the old bisector cut
provably fails them), depth-independent 90° end, span-limited bend zone,
airCommon interpenetration checks at 90°/120°, corner relief unchanged.
94/94 sheet-metal vitest, production build clean.
The miter was cutting both walls exactly AT the bisector plane, so they met
coincident and the fuse joined them into one welded corner — zero clearance,
which a single bent sheet can't produce and Unfold can't separate.

Each wall's miter cut plane is now offset by half a clearance gap along the
bisector normal, so the two cut faces run parallel with a uniform air gap
between them. Because both planes are offsets of the same bisector, the
clearance holds across every Bend Position, bend angle, and Height Datum.

- evaluator-worker.js: _buildMiterHalfSpace takes gapHalf and shifts the cut
  plane by keep·gapHalf·m; the flange case resolves miterGap (param, null →
  0.25·thickness — the usual press-brake gap-ratio rule) and threads it into
  each mitered end.
- Flange tool: "Miter gap" input shown while Miter corners is checked
  (empty → the 0.25·t default). Param: miterGap (null → default).
- flange-miter.test.js: harness cuts now model the offset planes; new
  clearance assertions — a probe ON the bisector is air at 90° and 120°
  while symmetric probes just outside the gap band are material (no V-gap,
  no weld), single valid solid throughout.

91/91 sheet-metal vitest, production build clean.
Adds the togglable arc relief at flange corners:

- Flange tool: "Corner relief" checkbox (default OFF, opt-in per feature) +
  "Relief radius" input shown when enabled (empty → the feature's bend
  radius). Params: cornerRelief, cornerReliefRadius (null → bend radius).
- flange-corners.js: cornerReliefCenter() — the relief axis point is the
  intersection of the two walls' bend-START tangent lines (the corner point
  itself at the Adjacent position; shifted bend positions move it inboard
  with the bend), solved in the sheet plane.
- evaluator-worker.js: corners are now detected independent of the miter
  toggle; per corner a cylinder along the sheet normal (through the full
  sheet + bend + wall extent) is cut AFTER the wall fuses, before refine,
  via _cutShapesEM's new opCode parameter — putting the long-reserved
  OP.RELIEF ('RLF') element-map code to use. The plain-boolean fallback
  applies the same cuts.
- Tests: cornerReliefCenter at Adjacent and shifted positions (pure);
  node-harness relief on a mitered 90° two-wall corner — material at the
  corner removed, walls elsewhere intact, single valid solid, and a
  cylindrical face on the sheet-normal axis exists.

91/91 sheet-metal vitest, production build clean.
Where two walls of one Flange share a reference-edge endpoint, each is now
internally extended past the corner and cut back at the corner's bisector
plane, so the walls meet exactly — at ANY bend angle and any corner angle.
Previously the corner column was simply left open, and non-90° walls could
never meet at all.

- flange-corners.js (new, pure): findSharedCorners() — endpoint-coincidence
  corner detection with the bisector normal m = normalize(dA − dB) (contains
  the sheet normal by construction; m points to wall A's keep side);
  collinear continuations and >2-wall pairings are skipped. miterExtension()
  sizes the internal extension (outboard reach ÷ tan(φ/2), floored against
  near-collinear blowup; inboard-curling obtuse walls need only the margin —
  the cut eats inward instead).
- evaluator-worker.js: the shared cross-section profile is now built once per
  feature (it is identical for every wall) with its max outboard reach; a
  mitered end replaces that end's user gap/extend with the miter extension
  and cuts the wall with a bisector half-space box (_buildMiterHalfSpace).
  The two walls meet exactly ON the plane, so the interface dissolves in the
  body fuse — mitered ends therefore emit no tangent keep-lines (their caps
  are gone). miterCorners defaults ON, including for old docs (absent → on):
  at 90° the miter strictly improves the old open-notch butt, everywhere
  else the old behavior was an unfillable gap; OFF reproduces the plain
  full-span square-ended walls (wanted when bending opposite directions off
  a shared corner).
- Flange tool: "Miter corners" checkbox (default checked).
- Tests: flange-corners.test.js (pairings incl. origin/far-end, collinear
  skip, 60° cosine, extension sizing); flange-miter.test.js node harness —
  90°: miter fills the previously-open corner column (classifier probes both
  sides of the plane), single valid solid; 120°: walls meet at the bisector
  with no V-gap where un-mitered walls provably leave it open.

87/87 sheet-metal vitest, production build clean.
Fixes the documented known limitation (and the user-reported missing face
split at the bend radius endpoints): a wall's side end-cap was a single prism
face bounded by both bend arcs, so there was no flat sub-face to select for a
flange folded around an already-bent corner.

- flange-profile.js: buildFlangeProfile() additionally returns `sections`
  (the same profile as three closed loops — sheet-overlap / bend sector /
  straight wall — sharing the two bend-tangent segments) and `tangents`.
- evaluator-worker.js: _buildFlangePrism builds the three sub-prisms and
  fuses them into one wall solid — the fuse dissolves the internal shared
  faces but leaves the side caps pre-split at the tangent lines; the bend
  faces remain the same true cylinders so Unfold detection is unaffected.
  The four tangent segments are lifted to 3D keep-lines, and the new
  _keepTangentSeams KeepShape()s any refine edge lying on one (endpoint +
  midpoint on-segment match, robust to the body fuse splitting an edge) —
  otherwise the ∥-sheet heal / band↔band sliver rules merge exactly the
  imprinted faces (they demonstrably do for r < ¾t). The band↔overlap-cap
  seam is intentionally not kept, so the fuse-overlap cap still heals into
  the parent perimeter face.
- flange-frame.js: upHint sign flip now requires a clearly anti-parallel
  hint (dot < −0.1) — a near-perpendicular hint (picking a face on an
  already-folded wall) no longer flips the fold direction on tessellation
  noise.
- Tests: profile section closure/shared-tangent/area-tiling; node-harness
  imprint coverage in flange-refine.test.js (exactly 3 cap-plane faces incl.
  the t×W wall face, ∥-sheet heals stay, small-radius dissolve documented
  without keep-lines, single valid solid); frame tests for the inclined
  135° wall cap rectangle and the noise no-flip.

74/74 sheet-metal vitest, production build clean.
Replace the FreeCAD-style lengthSpec with Fusion 360's Height Datum — the
flange Height is measured to Inner Faces (W = H − r·tan(θ/2)), Outer Faces
(W = H − (r+t)·tan(θ/2)), or Tangent To Bend (W = H − (r+t), θ-independent):

- flange-profile.js: wallLengthForDatum() replaces wallLengthForSpec(); a
  height the bend fully consumes (W ≤ 0) now throws a clear feature error
  instead of silently building a degenerate zero-length wall.
- flange-params.js: HEIGHT_DATUMS enums + normalizeHeightDatum(); legacy
  lengthSpec maps at evaluate time (outer-sharp→outer, inner-sharp→inner —
  identical formulas — and leg/tangential→ evaluate-only 'leg' where H is the
  flat length itself), so saved docs keep their exact geometry.
- Flange tool: "Height Datum" combo (Inner Faces / Outer Faces / Tangent To
  Bend); the length input is now labeled "Height".
- Tests: modern↔legacy numeric equivalence, tangent θ-independence and
  tangent≡outer at 90°, too-small-height throw; node-harness extent checks
  (tangent datum at 90° drops exactly H below the sheet top; legacy leg pin).

65/65 sheet-metal vitest, production build clean.
Replace the FreeCAD-style bendType enum (only Material Outside worked) with
Fusion 360's Bend Position — Adjacent / Inside / Outside / Tangent:

- flange-params.js (new): canonical enums + evaluate-time normalization of
  legacy bendType/offset params, so saved docs load unchanged
  (material-outside→adjacent is geometry-identical; the other three were
  broken and now produce corrected geometry).
- flange-profile.js: bendPositionShift() — adjacent 0, inside −(r+t)·tan(θ/2),
  outside −r·tan(θ/2), tangent −(r+t); Inside/Outside reject angles past 174°
  (setback → ∞); radius must be > 0; profile now returns uShift.
- evaluator-worker.js: positions that shift the bend into the sheet now CUT a
  rectangular trim prism out of the parent (per wall, over the wall's span,
  overshooting both sheet faces) BEFORE any wall fuse — a fuse alone left the
  parent's square edge filling the inside of the bend, which is why every
  non-default bend type produced junk. Trims thread the element map via the
  existing OP.CUT path; the plain-boolean fallback applies them too.
- Flange tool: "Bend position" combo (Adjacent/Inside/Outside/Tangent);
  the never-working Offset mode and its input are gone.
- Tests: bendPositionShift formulas + legacy mapping (flange-profile.test.js);
  new flange-position.test.js builds all four positions as REAL solids in the
  OCCT node harness at 90° and 135° and probes wall placement with a solid
  classifier, including the trim-cut regression (the old fuse-only pipeline
  demonstrably leaves parent material inside the bend).

Verified: 60/60 sheet-metal vitest, production build clean. Live visual A/B
against Fusion pending a dev-server restart (Vite's ?worker_file transform is
cached while JC3_NO_HMR=1, so the running app still executes the old worker).
The previous keep-seam heuristic (short + normal ⟂ sheet) couldn't tell the
genuine sheet-perimeter↔wall-end-cap seam from the phantom
sheet-perimeter↔bend-tangent-sliver seam — both are short and ⟂ the sheet — so
it kept both, leaving a ~0.015" sliver that splits the face you click for the
next flange.

Replace the length+orientation test with the real distinguisher: each face's
extent along the sheet normal. Keep a coplanar side seam only when the two
faces' sheet-normal extents differ by > 3/4 thickness (thin sheet-band ↔ tall
wall = real end-cap → keep, box stays finishable). Equal extents (band ↔ band)
is the phantom sliver → heal. ∥-sheet strips still heal.

Verified live via the jc3-debug worker eval (sliver = face area 0.0009) and
reproduced in the node OCCT harness (raw fuse yields [0.0009, 0.2391] at
min-X; fix heals to a single 0.24 face). flange-refine.test.js gains sliver
coverage. Documents issue #2 (no tangent edge to fold a flange around an
already-bent corner) as a known limitation needing tangent-line imprinting.
_keepPerimeterSeams protects short coplanar side seams (perimeter/end-cap
joins, ⟂ the sheet normal) from UnifySameDomain before Build(), so folding a
wall off one edge no longer dissolves the neighbouring perimeter faces a
later flange needs. Bend-tangent/flat-split seams (∥ the sheet) still heal.

Adds dev:stable (JC3_NO_HMR=1) so agents can edit source mid-session without
HMR wiping live document state, plus flange-refine regression test and docs.
Flange now accepts multiple thin edge faces in one feature (params.walls[]),
building a wall per face and fusing them into the body. A removable Wall list
(click ✕, click a selected face again, or Clear faces) lets a mis-pick be undone
before commit. Every wall's fold-direction axis (uy) is oriented to the body's
sheet normal so all walls fold the same way.

Booleans are refined with ShapeUpgrade_UnifySameDomain (_refineShapeEM, OP.REFINE)
so flanges no longer leave phantom seam lines near the bend or across the parent
face; results come back as clean single solids.

Corners close by wall overlap + fuse (butt joint). Angled miters / clearance gap /
round relief were prototyped and reverted pending a stable approach.
Adds a Sheet Metal toolset to the Drafting workspace (new toolbar group):

- Sheet Metal Base: thicken a sketch into a sheet body carrying material +
  thickness + default bend radius + K-factor mode (single source of truth for
  all downstream bends; injected into feature params at evaluate time).
- Flange / Add Wall: pick the thin perimeter FACE (only faces highlight) and
  grow a wall with a real radiused bend. Frame derived from the face via a
  minimum-area bounding box (robust to tessellation). Full param model:
  bend type, length spec, angle, invert, gaps, extends.
- Evaluator: new sheet-metal-base + flange cases; flange registered as a direct
  modifier so it fuses into the body and threads the element map (OP.FLANGE).
- K-factor engine: material presets + piecewise-interpolated bend allowance
  BA = (r + K*t)*angle, ANSI/DIN, engineering mode.
- Unfold foundation (not yet user-facing): face classification, coplanar
  region grouping, tangent bend graph, spanning-tree plan. Flatten stage next.

Fully unit-tested (kfactor, flange profile, flange->OCCT solid, face frame,
unfold graph) plus contract tests; production build clean. Proven live: a
90-degree flange yields a valid solid with real cylindrical bend faces.
Refactor the shared Toolbar (src/renderer/src/toolbar.js) so all five CAD/CAM
workspaces (Design, Drafting, Plasma, Laser, Router) plus the Drafting Sketch
sub-toolbar inherit three capabilities at once:

- Groups: bucket the left row by each descriptor's `group` into delimited
  `.toolbar-group` clusters with optional labels/order via a new `opts.groups`.
- Multi-line wrap: opt-in `toolbar-wrap` mode (gated on `opts.persistKey`) so the
  tool row wraps onto extra rows instead of cutting tools off; groups never split
  mid-cluster. Right-slot overflow popover is untouched.
- Drag-reorder: a four-dot handle per group (native HTML5 DnD) reorders groups;
  order persists per workspace via new toolbar-prefs.js (localStorage, frozen key
  registry, normalized reads) under jetcad3.toolbar.<workspace>.groupOrder.

persistKey is an explicit literal per workspace, never workspaceName (which
mangles under RELEASE_BUILD minification).

Adds toolbar-prefs.test.js, docs/toolbar.md (linked from docs/README.md), and a
release-notes entry.
The Extrude tool inferred Cut vs Join from axis-aligned bounding-box
volume overlap, so a small outward extrude whose bbox landed inside a
large/concave body's bbox falsely read as Cut — even when dragging away
from material into empty space.

Replace the bbox heuristic with the FreeCAD-parity material signal: the
support face's orientation-aware outward normal. A new
faceOutwardNormal() in plane-attach.js classifies a point +/-1e-4 off the
face plane against the owning solid via BRepClass3d_SolidClassifier and
returns the normal flipped to point away from material (orientation-
agnostic, correct for concave bodies). resolvePlaneFrame returns it as
frame.outwardNormal; the evaluator stores plane._outwardNormal; getPlaneById
passes it through. Extrude's new _inferOperation dots the drag vector
against it: into material -> Cut, away -> Join, with the bbox heuristic
kept only as a fallback when no outward normal is available.

Tests: 4 new faceOutwardNormal cases in plane-attach.test.js (top/bottom
face, sign-flip robustness, through-plane -> null). Docs updated.
Sketching on a face produced by a downstream feature (e.g. a boss added by
a joined union extrude) detached the plane on Finish — it snapped onto a
same-normal base face. Both creation sites anchored the plane to the body's
_sourceFeatureId (its create feature), so resolvePlaneFrame ran against a
snapshot lacking the picked face; the mapped-name match missed and the
geometric fallback landed on the wrong coplanar face.

Anchor instead to the body's tip feature at creation (bodyTipFeatureId): the
last enabled feature contributing to the body, which owns the picked face and
is always upstream of any sketch/extrude later drawn on the plane — so no
plane<->sketch<->extrude feedback loop returns.

- plane-attach.js: add pure bodyTipFeatureId(component, bodyId).
- Sketch/Plane tools: anchor _faceRef.featureId to the tip; Plane tool now
  also stores the face mappedName for the primary (mapped) resolve.
- evaluator-worker.js: snapshot each union/cut extrude step when a plane
  anchors to it (was only fillet/chamfer/transform/thread) — otherwise the
  plane resolved to 'not-evaluated' and froze.
- Tests: bodyTipFeatureId cases + end-to-end fused-boss face resolution.
- docs/Drafting/face-attached-planes.md updated.
The agent's per-project auto-memory (fact-per-file .md notes + MEMORY.md index)
is paramount to work here and should travel with the repo instead of living
only in machine-local ~/.claude state.

- Move the 33 memory files into tracked .claude/memory/. The live path
  ~/.claude/projects/<slug>/memory is now a symlink to it, so Claude keeps
  reading/writing there while the real files live in git.
- scripts/link-claude-memory.sh recreates that symlink on any machine/clone
  (idempotent; computes the machine-specific slug from the repo path; imports
  and backs up any pre-existing memory before linking).
- Gitignore machine-local Claude state (settings.local.json, scheduled_tasks.lock,
  *.bak.*).
- docs/claude-memory.md + docs index entry explain the setup.
Add a `screenshot` command to the jc3dbg CLI + jc3-debug MCP server so an
agent can see the running dev renderer (not just eval numeric state).

- cdp.mjs: screenshot() core via CDP Page.captureScreenshot; full-frame by
  default, optional clip {x,y,w,h} + scale to zoom into a region.
- CLI: `screenshot [--out P] [--clip x,y,w,h] [--scale N] [--format png|jpeg]`
  writes a PNG (default temp file) and prints its path to Read back.
- MCP: `screenshot` tool returns an inline image content block so the capture
  renders directly to the agent.
- Docs + CLAUDE.md updated.
A construction plane placed on a body face, with a joined extrude built on it,
entered an infinite plane->sketch->extrude jitter: _reparameterizePlanes re-derived
the plane frame from the FINAL fused body's trimmed top-face mesh, but that face is
reshaped by the very extrude drawn on the plane (which takes its position from the
plane corners). Negative feedback with gain > 1 never settled inside the EPS guard.

Adopt FreeCAD's attachment convention: a face-attached plane's placement is a pure
function of its SOURCE feature's own output shape, read from the face's BRep
Geom_Plane (trimming-independent), never the final tip or the mesh. That makes the
dependency graph acyclic, so downstream features can't perturb the plane.

- New plane-attach.js (pure, oc-injected, unit-tested): facePlaneFrame,
  frameToCorners (preserves authored ux/uy + d so no sketch shifts; re-aligns a
  sign-flipped BRep normal so sketches never mirror), resolvePlaneFrame
  (mapped-name match first, BRep normal+distance fallback).
- Worker: snapshot per-source-feature shapes/maps before downstream features
  overwrite them (cached alongside _cacheMap for the coarse->fine fast path);
  return planePlacements on the result message.
- Renderer: _send ships planes on full/non-cutoff/non-lightweight passes;
  _applyPlanePlacements replaces the mesh-based _reparameterizePlanes.
- Converges in <=1 extra eval; editing a downstream consumer never moves the plane.
- plane-attach.test.js: 9 tests incl. the invariance-under-fuse guarantee.
- docs/Drafting/face-attached-planes.md + README + element-mapping cross-ref;
  release notes v2.3.5.
Dev-only CDP bridge so an agent can read/eval the running `npm run dev` app —
including inside the OCCT WASM worker pool — instead of the log/teardown/repeat
loop for 3D and feature-history bugs. Stripped from release builds.

- Main: open CDP port 9222 on 127.0.0.1 when !app.isPackaged.
- Worker: dev-only __jc3dbg-eval handler (oc + live shape caches + summarize/
  explode/checkValid helpers in scope) plus occtTrace/featureTrace ring buffers
  capturing in/out of fuse/cut/fillet/chamfer/thread and every _evalFeature.
- Evaluator: debugEvalAll() broadcasts eval to all pool workers, returns a
  per-worker result array (component->worker affinity).
- Renderer: window.__jc3debug (status/listBodies/activeComponent/dumpShape/
  occtLog/featureIO/evalInWorker), installed under import.meta.env.DEV.
- Tooling: scripts/jc3dbg.mjs CLI + scripts/jc3dbg/cdp.mjs core + MCP server
  (mcp__jc3-debug__*). Renderer-context conditional breakpoints (`break`).
- Docs + agent guidance (docs/live-debug-bridge.md, CLAUDE.md section).
- Add chrome-remote-interface + @modelcontextprotocol/sdk as devDependencies.
Adds a FreeCAD-style persistent-naming subsystem for the OCCT evaluator so
face/edge selections carry a stable, history-derived name instead of being
re-matched by geometry each rebuild.

New:
- element-map.js — ElementMap, enumerateSubshapes (HashCode+IsSame; OCCT's
  IndexedMapOfShape isn't bound in opencascade.js), seedNames, mapHistory
  (Generated/Modified/IsDeleted + IsSame passthrough for unchanged subshapes),
  resolveMapped. Pure module (takes injected oc) → unit-testable.
- occt-node-harness.js — load OCCT WASM under node/vitest.
- element-map.test.js — 11 tests incl. coplanar disambiguation + topology-change
  robustness, run against real OCCT.

Evaluator worker:
- Element maps threaded through the rebuild pass loop and cached alongside shapes
  (_cacheEMMap, _evalCache[].em). Booleans (_fuseShapesEM/_cutShapesEM) and
  fillet/chamfer derive names from the OCCT builder history; base bodies fall back
  to deterministic index seeding. mappedName shipped on faces + per-face edges.
- Phase 0a: thread tool now booleans the groove into the body (cut external /
  fuse internal) instead of the debug `return grooves` leak.
- Phase 0b: _buildFace/_buildWireFromChain heal via ShapeFix_Wire and throw
  descriptive errors instead of returning null silently.

Main thread + tools:
- Face.mappedName; _reparameterizePlanes resolves by mapped name first (falls back
  to the legacy normal/distance search); Sketch _faceRef, Fillet/Chamfer edge refs
  store mappedName. Persisted via existing JSON (Option B — maps regenerate on load).

All element-map paths are guarded (fall back to seeding / legacy matching), so
geometry is never affected. Docs: docs/Drafting/element-mapping.md.

KNOWN ISSUE (WIP): sketch-on-face still exhibits a plane feedback loop
(planesChanged → markDirty → re-eval) on a body whose face is modified by the
sketch's own downstream feature — suspected causes: (1) _reparameterizePlanes
rebuilds the plane frame from the tracked face's mesh centroid/bbox, which drifts
when that face's boundary depends on the plane; (2) _bodyEM seeds every body with a
fixed op/tag, so seed names collide across bodies after a fuse. To be diagnosed
with the incoming CDP live-debugging tool before fixing.
Undo/redo snapshots are captured synchronously on markDirty, but constraint
applies solve asynchronously afterward, so a redo restored the constraint entity
paired with pre-solve geometry (e.g. "equal" circles stayed different sizes) and
nothing re-solved it. Sketch faces went stale the same way.

- refreshAfterUndo now runs a full solveConstraints on the restored sketch so
  geometry satisfies its restored constraints (a consistent snapshot solves to
  itself; only violated constraints move) and the DOF readout refreshes.
- It also calls _doLiveFaceRebuild so sketch.faces are recomputed — the live-face
  dirty listener is keyed on the old _selectedComponent at undo time and skipped
  the restored sketch, leaving stale face regions until the next edit.
- runConstraintToggle now markDirty AFTER the solve so the undo snapshot captures
  solved geometry and dependent 3D features rebuild from the solved sketch.

Release notes: v2.3.5.
Add a shared geometric-constraint engine (constraint-actions.js) driving three
surfaces that stay in sync:
- On-bar toggle group on the Sketch edit sub-tool bar showing only the
  constraints applicable to the current selection; pressed = already applied,
  click to remove (constraint-bar.js).
- Shift-modified hotkeys for every constraint, registered in HOTKEY_DEFS and
  user-editable under Settings -> Keyboard (picking.js _tryConstraintHotkey).
- Right-click Constrain menu rebuilt on the same engine: every relationship is
  now a true toggle with a check, incl. previously-hidden collinear /
  concentric center-align / fix-pin (constrain.js).

Also add a selection-change notifier at the interactive selection choke points
(click / box / chain / context-menu select) so the bar refreshes live.

Icons: crisp line-art SVG set (parallel is two angled lines, distinct from
equal). The same icons render on the toolbar buttons and the in-scene badges;
dim-renderer draws a badge beside each participating entity's midpoint and uses
the padlock icon for pinned geometry.

Docs: docs/Drafting/sketch-constraints.md (+ README link). Tests:
constraint-actions.test.js (19). Release notes: v2.3.5.
The previous fix only hooked Workspace.setActiveTool/deactivateActiveTool, but
Sketch sub-tools (Line, Arc, Fillet, Rect, …) self-deactivate through the base
Tool._finishSelf() and never touch the workspace method — so pressing Escape in a
sub-tool still left the badge on screen until the next mousemove. Call
refreshCursorToolBadge() from _finishSelf() (where the active sub-tool ref is
cleared) so it clears immediately there too.
The cursor tool badge only re-resolved the active tool on mousemove, so pressing
Escape to deactivate a tool left the badge on screen (with a stale icon) until
the next cursor movement. Add CursorToolBadge.refresh() (exported as
refreshCursorToolBadge) which re-resolves at once — hiding when no eligible tool
is active, or re-showing at the last known cursor position otherwise — and call
it from Workspace.setActiveTool / deactivateActiveTool so the badge tracks tool
state instantly instead of lagging a mouse move.
Slot commits now wire the geometry that makes a slot rigid so a single driven
dimension resizes it without distortion: equal-radius across the two caps and
tangency between each cap and every side it meets. Applies to all modes
(straight/curved/rail) via a `role` tag on slot-geometry primitives; mouse
commits get constraints only, Tab commits keep the driven width/length dims.

Inter-entity constraints are now right-click-deletable: glyphs carry their
constraint id + 2D position, DimRenderer.hitTestConstraint picks them, and
onContextMenu opens a Delete menu that splices the constraint and re-solves
measure-only (frees DOF without moving geometry). This is the first direct way
to remove a tangent — previously they could only be dropped by deleting an entity.

Tangent glyphs now anchor at the true tangent point (the coincident rim junction
between the two entities via new sharedEndpoint helper) instead of entity1's
midpoint.
Adds a global cursor-following overlay that shows the active tool's toolbar
icon as bare, semi-transparent SVG vectors up-and-right of the mouse, across
all workspaces (Drafting main tools + Sketch sub-tools, Plasma, Laser, Router).

- New utils/cursor-tool-badge.js singleton: polls getActiveTool() on mousemove
  over #viewport-wrap, falls through to the Sketch parent's _activeSubTool, and
  hides for the Select/Sketch mode tools.
- Settings → Mouse gains a "Show active tool badge at cursor" toggle
  (settings.mouse.toolBadge, default on) using the shared makeToggle switch;
  the Mouse tab's other checkboxes were also converted to toggles.
- main.js wires setEnabled() on startup and settings.onChanged.
- Docs (workspace-system, mouse-navigation) and v2.3.5 release notes updated.
The True-Shape frame tessellated each contour's arcs into many tiny G53 G0
rapids; the short segments outran the FluidNC lookahead planner (jerky/
overshooting motion). G53 was the only reason arcs had to be linearized —
G53 is illegal with G2/G3 (GRBL/FluidNC: "G53 only works with G0 and G1").

Since framing runs against a loaded program whose work zero is already set,
drop G53 entirely and trace in the ACTIVE work coordinate system with plain
G90 moves — straight segments as G1, arcs as native G2/G3. buildFrameShapes
now returns per-shape bulge polylines instead of tessellated points (bulge
kept; loopPoints/tessellateArc removed); the emitter converts bulge to I/J
via bulgeToArc (reused from common/geometry/primitives.js). Moves run at the
machine's max feed (_maxFeedrate); the operator throttles live with the FEED
override (continuous, unlike G0 rapid-override's 25/50/100 steps). The bbox
fallback is converted back to the work frame from getCutBoundsXY. Guard:
refuse to frame if the active WCS isn't the program's (would trace off-part).

Arc math verified against the circle fixture — emitted I/J round-trip exactly
to the source G-code. frame-shapes.test.js updated for the bulge-polyline
return shape.
1–30 of 371 commits
1 / 13