Skip to content

Soft Jaw Generation

Home / Engineering / System Design / Soft Jaw Generation

Status

Working prototype — accurate to the code as of 2026-06-11.

This is the single reference for the soft-jaw generation feature. It covers what it produces and why (§1), the code map (§2), the geometric model (§3), the pipeline end to end (§4–6), the design decisions and their rationale (§7), the tunables (§8), known limitations (§9), and how it's verified (§10). §1, §3, and §7 are the high-level read; §4–6 are the details.


1. What this is and why it exists

A machined part is typically made in two setups:

  • Op1: a raw billet is clamped in a vise and everything reachable from above is machined. The result is the part plus leftover material ("stock") wherever op1's tool couldn't reach — typically lower side walls, regions shadowed by the op1 grip, and webs between features.
  • Op2: the workpiece is flipped/re-gripped and the remaining stock is machined.

Op2's problem is workholding: the op1 output is no longer a rectangular billet, so plain vise jaws can't grip it well, and gripping an unfinished (still-proud) surface positions the part wrong. Soft jaws are custom vise jaws with a pocket that conforms to the part, gripping it on finished faces while leaving all remaining stock exposed above the jaw for the op2 spindle.

This pipeline generates a left/right soft-jaw pair automatically from two inputs and exports them as STLs. The jaws are 3D printed, not milled — this decision shapes several design choices (§7.5, §7.6).

inputs:   <part>.step          final part        (design intent, loaded via Parasolid)
          <part>_op1.stl       post-op1 workpiece (CAM stock simulation)
outputs:  out/<part>_right.stl  +X jaw
          out/<part>_left.stl   -X jaw

Part with the op1 workpiece overlaid The "Show Op1 Workpiece" overlay (green) on the loaded part (gray): the final part (design intent) and the post-op1 workpiece superimposed. The op2 problem is everything the workpiece still has that the part doesn't — the stock to be removed, green where it stands proud — plus how to grip what's already finished. The panel reads back the bounds, the placement (0 mm above Z=0, 70 mm gripped below), and the derived cut height.

The hard problem is not the pocket — it's deciding the cut height: the horizontal plane that splits "gripped below" from "exposed above". Everything op2 still has to machine must end up above it; everything the jaw touches below it must be finished surface. The pipeline derives this plane purely geometrically by comparing the workpiece mesh against the final part mesh ("label-free" — §7.1).

2. Code map

Layer Files Contents
App (GPU/UI/orchestration) source/app_soft_jaw.cpp Background load worker, registration driver, placement, rasterization, UI, export, headless harness. Unity-built via mach_apps.h (app index 0, "Soft Jaw").
Pure decision math source/machen/mach_softjaw.{h,cpp} RegisterWorkpieceOffsetSeed, RefineRegistrationOffset, ValidateRegistration, SilhouetteStockZs, RobustStockFloorZ, ApplyFitClearance, FillToPartingPlane, WorkpieceStlPathForPart. No GPU/Parasolid — unit-tested in machen_tests.
Mesh utilities source/machen/mach_mesh.{h,cpp} ReadStl/WriteBinaryStl, ClosestDistanceToMesh/IsWithinDistanceOfMesh (+ trimesh_dist_grid accelerated overloads), SubdivideLongEdges, WeldVertices, BuildHeightfieldPrism. Unit-tested.
GPU rasterizer source/machen/mach_depth_map.{h,cpp} + assets/shaders/depth_capture.slang Orthographic depth maps (shared with other apps).

Tests: source/tests/test_mach_softjaw.cpp, source/tests/test_mach_mesh.cpp.

3. The geometric model

All units are millimeters (matching mach_cad's STEP output). All frames are right-handed. The part's CAD frame is the jaw frame — we never rotate, only translate:

  • +Z is the op2 spindle axis. Stock must be reachable from +Z.
  • ±X is the clamp axis. The right jaw approaches from +X, the left from −X. The vise closes along X.
  • World Z = 0 is the cut plane. The jaws exist entirely below it; nothing of the jaw ever rises above Z = 0.
  • The parting plane is the vertical X plane where the two closed jaws meet — the X-center of the placed part bounds (not the workpiece bounds, which sit asymmetrically when stock margins differ), which lands at X ≈ 0 because placement centers the part.

The only transform applied to the part is a translation PartTranslation (SoftJawComposePartXf): X/Y center the part AABB on the origin; Z places the derived cut height at world Z = 0. One source of truth feeds the 3D preview, the cavity computation, and the export, so they cannot drift apart.

Each jaw is a heightfield prism: a solid block whose inner face is a depth map of the workpiece viewed along the clamp axis, opened by a fit clearance, with all non-part area filled solid to the parting plane. By construction a heightfield has no undercuts along X, which is exactly the printable/insertable geometry class we want (§7.6).

A jaw gripping the part at the cut plane The right jaw (blue) closed onto the part with its op1 workpiece overlaid (green). Everything below the Z = 0 cut plane is held by the conforming pocket; everything above stays exposed for the op2 spindle.

4. Pipeline overview

                      ┌─ background load worker (per part) ──────────────────┐
 STEP ──Parasolid──►  │ part mesh (BodyMesh)                                 │
 _op1.stl ──ReadStl─► │ workpiece mesh                                       │
                      │   1. registration: workpiece → part frame  (§5.1)    │
                      │   2. stock flags: bounded-pitch sampling    (§5.2)   │
                      │   3. silhouette filter: jaw-relevant stock  (§5.3)   │
                      └──────────────────────────────────────────────────────┘
                      ┌─ main thread, on adopt (copy cost only) ─────────────┐
                      │   4. cut height: robust stock floor         (§5.4)   │
                      │   5. placement: PartTranslation             (§5.5)   │
                      └──────────────────────────────────────────────────────┘
                      ┌─ "Recompute Cavities" (or auto) ─────────────────────┐
                      │   6. depth-map rasterization per side       (§6.1)   │
                      │   7. fit clearance + fill to parting plane  (§6.2)   │
                      │   8. heightfield prism mesh                 (§6.3)   │
                      │   9. export binary STLs                              │
                      └──────────────────────────────────────────────────────┘

Steps 1–3 are expensive, so they all run once per load in a worker thread — the only thing that crosses to the main thread is the silhouette Z list (plus the meshes for display). Steps 4–5 are trivial and run on adopt; 6–9 run on demand and use the GPU. The dense sampling copy from step 2 never leaves the worker: display, rasterization, and export all keep the original CAM mesh.

5. Cut-height derivation (the decision pipeline)

5.1 Registration: workpiece → part frame

The op1 STL comes from our own pipeline in the part's frame, but CAM exports generally sit in a translated machine frame. We register by translation only (§7.2):

  1. Seed (RegisterWorkpieceOffsetSeed): op2 only removes material, so the part AABB must fit inside the workpiece AABB (within ContainTol = 2 mm). If containment already holds, the seed is zero — a co-registered workpiece must not be moved (center-aligning would shift it when stock margins are asymmetric). Otherwise try aligning AABB centers. If containment still fails, the workpiece is dropped entirely and no jaws are generated (§5.5).
  2. Refinement (RefineRegistrationOffset): coordinate descent minimizes the median distance of ~128 strided workpiece-vertex samples to the part mesh: three rounds of per-axis greedy walks (±direction, keep stepping while improving, up to Span = 20 mm travel), with step shrinking 1 → 0.2 → 0.04 mm and span 20 → 8 → 3.2 mm. Final resolution 0.04 mm vs the 0.05 mm finish tolerance. The median objective is robust as long as ≥ 50 % of sampled workpiece vertices lie on finished surfaces; the residual gate below catches the rest (§9.2).
  3. Validation (ValidateRegistration): a locked-on registration has its finished faces ON the part, so the final median residual must be ≤ ResidualTol (0.2 mm) and the part AABB must still sit inside the moved workpiece's AABB (the walk can travel ±20 mm). Either failure rejects the workpiece with the reason logged; the residual is always logged (real parts lock on at ~0.007–0.017 mm).

5.2 Stock detection: bounded-pitch surface sampling

Op2 only removes material, so the workpiece surface is on-or-outside the part surface everywhere. The unsigned distance from a workpiece point to the part surface is the local stock allowance:

  • First, the workpiece mesh is midpoint-subdivided (mesh::SubdivideLongEdges, 4-way, until no edge exceeds SampleEdge = 5 mm). A per-vertex test sees nothing between the vertices of huge CAM triangles — a proud slab whose corners all touch finished surfaces flags zero vertices — so subdivision turns the per-vertex loop into a ≤ 5 mm-pitch sample of the whole surface. It's then welded (mesh::WeldVertices, exact-bits merge — STL corners and subdivision midpoints are bit-deterministic copies): unwelded duplicates would multiply distance queries and make every sorted silhouette Z a "dense pair", silently disabling the stray trim (§5.4). The subdivided copy is worker-local; only the silhouette Zs leave the worker, so display/rasterization/export keep the ~30× lighter original CAM mesh.
  • For every workpiece vertex, "is it within FinishTol of the part surface?" via mesh::IsWithinDistanceOfMesh on a mesh::trimesh_dist_grid built once over the part mesh (§7.8): only the 1–8 grid cells within FinishTol of the point are inspected, then exact point-to-triangle distance (Ericson Voronoi-region test) with an early return at the first triangle within tolerance — O(1) per query.
  • d > FinishTol (0.05 mm) ⇒ the vertex is stock; else finished. FinishTol must exceed the STL chord error plus the part-meshing deflection (0.01), hence 0.05.

This produces a per-vertex stock flag array parallel to the subdivided workpiece vertices. The sampling pitch bounds what can hide: a proud feature must span less than SampleEdge in every surface direction to escape sampling entirely. (The green in the §1 overlay is exactly these proud regions; the cut height must land below the lowest green a jaw could trap — §5.3–5.4.)

5.3 Silhouette filter: which stock can a jaw wall in?

The jaws wrap the workpiece from ±X below the cut plane, so only stock on the ±X silhouette can be trapped by a jaw. Stock inside pockets or on top faces is reached by the +Z spindle from above at any cut height and must not drag the grip line down.

SilhouetteStockZs approximates occlusion on a (y, z) grid (cell 0.5 mm, capped at 2048² — the cap coarsens cells on parts larger than ~1 m):

  1. Every triangle splats its x-extent into every cell its (y, z) bounding box touches (so large flat walls with vertices only at corners still occlude the cells behind them).
  2. A stock vertex counts as silhouette when its x is within SkinTol (1 mm) of its cell's extreme on either side — or when its cell is empty (conservative: lone geometry counts).

Output: the Z values of silhouette stock vertices. The cell size is the smallest stock step height the test can resolve: a proud step shorter than one cell sitting just below taller outboard geometry (e.g. a billet wall above the cut) shares its cell, the wall's splat owns the cell extreme, and the step is shadowed — so the cell must stay below any stock feature that must end up above the cut.

5.4 Cut height: robust stock floor

Every silhouette stock vertex must end up above the cut, so the cut goes at the lowest silhouette stock Z (RobustStockFloorZ), with a density-aware bottom trim so rim-misclassified strays can't drag the cut to the part bottom. Real stock is a surface patch, so its Zs cluster tightly; strays are lone points. Walking up from the sorted bottom, a value is trimmed only while (a) the trim budget allows — max(3, OutlierFrac·N), floored because strays don't scale with sample count and a pure 1 % fraction silently disables the trim below 100 samples — and (b) the next value above is more than StrayGap (7.5 mm) away (isolated). The walk stops at the first dense pair, so a real low stock patch survives even when the count budget would have allowed deleting it. Wrongly keeping a stray only moves the cut down (safe). The app then subtracts CutMargin (0.01 mm) so remaining stock stands slightly proud of the jaw's top face rather than flush. (Residual limitation: a real stock patch represented by a single isolated vertex is indistinguishable from a stray and gets trimmed.)

This replaced an earlier "top stock band" descent that stopped at the first Z gap > 12 mm; that buried a dense second stock band below the top one on a real part. Robust-min's failure direction is the safe one — a dense cluster of misclassified low verts cuts too low (weaker grip, stock still accessible) instead of burying real stock (scrap part).

5.5 Placement — no fallback jaws

SoftJawRecenterPart: T.x/y = −part-AABB-center, T.z = −CutZ, and PlacementOk becomes true. Jaws exist if and only if the auto cut height was derived — when any of the following hold, PlacementOk stays false, "Cannot generate jaws: " is surfaced in the panel (PlacementStatus) and on stdout, the Recompute button is disabled, SOFTJAW_AUTO prints failed, and nothing is exported:

  • no paired workpiece STL / STL-only load / registration rejected (§5.1),
  • no proud vertices (workpiece == part: nothing left to machine),
  • no silhouette stock (all remaining stock is interior/top-facing),
  • the stock floor reaches the part bottom (no finished band to grip).

The part itself stays loaded and viewable (placed top-at-Z=0, display only). Rationale: a cavity molded on anything but a registered op1 workpiece at a derived cut height either misfits the real clamped lump or buries stock — both worse than refusing (§7.9). The placed part's own AABB is kept separately (PartBoundsMin/Max) for the panel readout and the parting plane; BoundsMin/Max tracks the (larger) cavity source for the raster window.

6. Jaw geometry generation

6.1 Depth-map rasterization

For each side, SoftJawPlaneForSide builds an orthographic raster plane:

  • Right jaw: origin at (+X bound + 5 mm margin), U sweeps −Y, V sweeps +Z, normal −X. Left jaw is the mirror (normal +X). Both keep V along +Z so image-vertical = Z; Y appears mirrored between sides, which is physically correct (you're looking from opposite sides).
  • MaxZ is pinned at 0: nothing above the cut plane is ever rasterized, so no jaw geometry can rise above it. Workpiece geometry above Z = 0 clips out at the image edge.
  • The rasterizer requires a square image (|U| = |V|), so the shorter axis is padded — Y symmetrically, Z downward only (never raising MaxZ); the padding region is filled solid later anyway. Consequence: jaw block dimensions are an artifact of this squaring, not real jaw blank stock (§9.6).

geom::ComputeDepthMap renders the original workpiece mesh on the GPU: orthographic projection, near plane at the raster plane, fragment shader writes the analytic forward distance, depth test less_or_equal keeps the nearest surface, cull off. Because placement is a pure translation, the raster plane is shifted by −T into the workpiece's frame and the depth map re-framed onto the world plane afterwards — a translation changes neither the plane axes nor the depths. Pixels with no geometry hold the sentinel 1e30 (anything ≥ 1e29 is treated as "no part here"). Default resolution 512×512; pixel size = plane extent / 512 (≈ 0.28 mm on a 145 mm plane). Power-of-two sizes only (asserted). If every pixel is the sentinel (no part below Z = 0 at all), the jaw build is skipped and flagged rather than exporting a degenerate flat slab.

Right-side raw depth map Left-side raw depth map The right (top) and left (bottom) raw depth captures — each a heightfield of the workpiece viewed along its clamp axis, color-mapped by forward distance to the raster plane (warm = nearer, cool = farther; black = the no-part sentinel). Y is mirrored between the two sides because you're looking from opposite directions. These become the conforming inner faces of the two jaws.

6.2 Heightfield post-ops (pure, unit-tested)

Order matters; both operate on a CPU copy of the raw map:

  1. ApplyFitClearance: every finite pixel recedes toward the raster plane by FitClearance (default 0.3 mm), clamped at 0. This opens a uniform gap along the clamp axis only (§7.5). Sentinels stay untouched.
  2. FillToPartingPlane: every sentinel pixel becomes solid out to the parting plane — fill depth is the travel from the raster plane to x = PartingX along the view normal. Result: the jaw is a solid block whose only recess is the conforming pocket; the two jaws' filled regions meet exactly at the parting plane when closed.

6.3 Heightfield prism (mesh::BuildHeightfieldPrism)

Builds a closed, oriented-manifold triangle mesh from the N×N heightfield:

  • N² inner vertices at pixel centers — pixel (i,j) → (i+0.5)/N of the plane, matching where the GPU rasterizer actually sampled each depth (a corner-convention i/(N−1) mapping stretched every jaw by N/(N−1) and shifted it half a pixel) — plus a 4(N−1)-vertex back boundary ring at −SweepLength (jaw thickness, 40 mm), one behind every inner boundary vertex, plus one back-center vertex.
  • 2(N−1)² inner triangles; each side face is a ruled strip of 2(N−1) triangles between the inner boundary row and its matching back edge; the back face is a fan of 4(N−1) triangles from the center vertex over the perimeter ring. (Ruled strips, not corner fans: a fan is only correct for boundary profiles star-shaped from its apex, which a pocket-mouth profile is not; and a center back-apex avoids the zero-area facets a corner apex emits when it's collinear with the ring.)
  • Winding invariant: every directed edge appears exactly once, and its reverse exactly once — verified per-mesh by the test helper CountNonManifoldDirectedEdges. Note this is purely combinatorial: it catches neither geometric self-intersection nor zero-area facets, which have their own test assertions.
  • Any sentinel still present clamps to depth 0 (fill runs first in this pipeline, so this only matters for raw-map debugging paths).

Vertex/triangle count is resolution-driven, not content-driven: 512 → ~264 k verts / ~528 k tris / ~25 MB STL per jaw. 512 is the default and current recommendation (§9.7). Normals for display are area-weighted vertex averages; export carries geometry only.

Generated left and right jaw pair The exported jaw pair. Each block is solid behind the conforming pocket and flat on its back and sides; the pocket meets the parting plane where the two close together.

Right jaw, close-up The right jaw alone. The recessed inner face conforms to the workpiece's +X silhouette below the cut plane; everything else is solid fill out to the parting plane.

7. Subjective decisions and their rationale

These are the judgment calls. Each could be revisited; this is why they stand where they do.

7.1 Label-free (geometry-derived) instead of CAM labels

An earlier iteration tagged faces as finished/unfinished with labels carried through the toolchain. It was removed: labels don't survive STL round-trips, depend on the CAM package, and can lie. The replacement insight: op2 only removes material, so workpiece-to-part distance objectively is the local stock allowance. The cost is that we inherit every sampling/registration problem in §9 — the labels were unreliable but explicit; geometry is reliable but must be measured correctly.

7.2 Translation-only registration

Rotated machine frames are rejected, not solved. Rationale: our own op1 exports share the part's axes; solving rotation (ICP-style) adds failure modes for a case we haven't seen. The AABB containment check catches gross frame mismatch; the workpiece is then rejected and no jaws are generated (§5.5).

7.3 Median-distance refinement objective, with a residual gate

Median (not mean, not trimmed-mean) of subsampled vertex distances: robust as long as

50 % of the workpiece surface samples are finished. A mostly-rough workpiece violates the assumption — the post-refinement residual check (≤ ResidualTol = 0.2 mm, plus a containment re-check after the move) turns that from a silent mis-registration into a loud rejection (§9.2). The residual is logged on every load as proof of lock-on.

7.4 Robust-min stock floor (not band-walk, not plain min)

Plain min: one stray misclassified vertex scraps the grip. Band-walk: buried real stock (§5.4 history). Robust-min with a density-aware bottom trim is the only one of the three whose failure mode degrades safely: a kept stray cuts too low (safe); a trimmed real band can't happen because the trim stops at the first dense pair. The trim fraction (1 %) is a guess validated on two parts; StrayGap (7.5 mm) is derived — it must exceed the sampling pitch (consecutive real samples on a connected surface are at most one SampleEdge apart in Z), so 1.5× the 5 mm pitch.

7.5 Fit clearance along the clamp axis only

ApplyFitClearance recedes along X. Lateral (Y/Z) pocket walls get no explicit clearance — only raster quantization (±½ pixel ≈ ±0.14 mm at 512) and printing process slop. Acceptable because the jaws are 3D printed (the process itself adds tolerance) and the vise preload comes along X. An earlier milled-jaw design ran a GPU clearance-map (a circular min-filter) that did dilate laterally; that step was dropped in the print pivot. The lateral dilation ops (ComputeFlatEndmillClearanceMap / ComputeInverseClearanceMap) still exist in mach_depth_map if printed parts bind.

TODO: the print process tolerance has not been characterized. The 0.3 mm default clearance and the "printing adds slop" assumption are uncalibrated — print a test jaw, measure pocket fit against a known part, and revise with real numbers.

7.6 Heightfield-per-side representation

A depth map per clamp direction structurally cannot represent X-undercuts — which is a feature: the part must be insertable/removable along the clamp opening, and the jaw must be printable without supports in that orientation. The cost: the pocket conforms to the ±X-visible surface only; Y-facing geometry is captured as terraces at pixel resolution. No machinability constraints (corner radii) are imposed — irrelevant for printing.

7.7 Conservative-everywhere silhouette policy

A vertex with nothing else in its grid cell counts as silhouette; lone vertices count; both cell extremes count (either jaw side). False positives pull the cut down (safe direction); false negatives wall stock in (scrap). The one known false-negative path is the bbox splat on slanted walls (§9.3).

7.8 Uniform grid over the part triangles (not a BVH)

Point-distance queries run through mesh::trimesh_dist_grid, built once per load over the part mesh. A grid was chosen over a BVH deliberately: part meshes are compact and evenly tessellated, and the dominant query is tolerance-bounded (FinishTol = 0.05 mm), which a grid answers from the 1–8 cells around the point with no tree descent — O(1) regardless of verdict. The opaque accel struct means the internals could swap to a BVH later without touching call sites. Brute-force overloads remain for tests and small-mesh queries.

7.9 Refusal over fallback, and every refusal reports why

Every "couldn't do the smart thing" path writes why into a panel status (PlacementStatus, ComputeStatus, StockNote) and stdout. A failed auto cut height doesn't degrade to approximate jaws; it refuses to produce them. An export that exists is therefore always one whose cut height was genuinely derived. Per-load the effective tolerances are also logged, so any result is traceable to its settings.

8. Tunables

Constants live at the top of app_soft_jaw.cpp; env vars override for experiments (read once at use via SoftJawTol, atof, no validation — debug knobs, not a supported interface).

Constant Default Env override Meaning
SoftJawFinishTol 0.05 mm SOFTJAW_FINISH_TOL proud-distance threshold: finished vs stock
SoftJawOutlierFrac 0.01 SOFTJAW_OUTLIER_FRAC max bottom fraction trimmable in the stock floor
SoftJawStrayGap 7.5 mm SOFTJAW_STRAY_GAP min isolation below the next Z to count as a stray; must exceed the sampling pitch (1.5× SampleEdge) — shrink them together
SoftJawContainTol 2.0 mm SOFTJAW_CONTAIN_TOL AABB containment slack in registration
SoftJawResidualTol 0.2 mm SOFTJAW_RESIDUAL_TOL max post-refinement median residual to accept a registration
SoftJawRefineSpan 20 mm SOFTJAW_REFINE_SPAN max refinement travel per axis per round
SoftJawRefineStep 1.0 mm SOFTJAW_REFINE_STEP initial refinement probe step
SoftJawSilhouetteCell 0.5 mm SOFTJAW_SIL_CELL (y,z) occlusion grid cell = smallest resolvable stock step height
SoftJawSilhouetteSkin 1.0 mm SOFTJAW_SIL_SKIN skin thickness for the silhouette test
SoftJawSampleEdge 5.0 mm SOFTJAW_SAMPLE_EDGE max workpiece edge for the stock test (subdivision pitch); env values clamp up to 0.5 mm min
SoftJawCutMargin 0.01 mm cut plane sits this far below the stock floor
Params.FitClearance 0.3 mm — (UI slider) clamp-axis pocket clearance
Params.PlaneMargin 5.0 mm raster-plane margin around the bounds
Params.SweepLength 40 mm — (UI, "Advanced") jaw thickness behind the raster plane
Heightfield px 512 — (UI, "Advanced") raster resolution {256…8192}, power of 2

Harness env vars: SOFTJAW_AUTO=<relpath> (headless load→compute→export, §10), SOFTJAW_DUMP_DIST=1 (write the debug dumps at the end of a headless run — interactive sessions use the "Write Debug Dumps" button instead), MACHEN_APP=0 (start in this app).

9. Known limitations

Each lists the failure direction — "safe" failures cut too low / grip less; "unsafe" failures wall stock into the jaw (op2 can't finish → scrap).

  • 9.1 Sub-sample stock features (bounded, safe-side). Stock detection samples the workpiece surface at the SampleEdge (5 mm) pitch after subdivision; the silhouette occlusion grid resolves down to its cell (0.5 mm). A proud feature smaller than the pitch in every surface direction, or shorter than one cell while shadowed by taller outboard geometry, can still be missed; the detected bottom of a proud band resolves only to ~the local sample spacing. Shrink SOFTJAW_SAMPLE_EDGE / SOFTJAW_SIL_CELL to tighten, at quadratic cost in samples. The "Show Op1 Workpiece" overlay is a worthwhile pre-print eyeball. (This class of bug — a vertex-only stock test that missed a bridged slab, plus a 2 mm occlusion cell that shadowed a 1.5 mm wedge on part 25-10-07 — was the original motivation for the subdivision + 0.5 mm cell; both are pinned by regression tests.)
  • 9.2 Mostly-rough workpiece (guarded: loud, not silent). If > 50 % of the workpiece surface is still stock, the median refinement objective can converge wrong. The residual gate (≤ ResidualTol, plus a containment re-check) rejects it with the residual in the message instead of producing mis-registered jaws. The threshold is a guess validated on two parts (real lock-ons measure 0.007–0.017 mm).
  • 9.3 Silhouette grid resolution (largely defused, still bounded). A slanted triangle splats its full x-extent into every cell its bbox touches, inflating cell extremes (now bounded by one ≤ 5 mm subdivided triangle, not a whole wall). Cell shadowing of sub-cell steps is bounded by the 0.5 mm cell. Failure direction is mixed — splat inflation pulls the cut down (safe); shadowing walls a step in (unsafe), which is why the cell is small.
  • 9.4 No lateral fit clearance (annoyance, not scrap). §7.5. Tight Y/Z walls rely on raster quantization and print tolerance; the lateral dilation ops still exist in mach_depth_map if a part binds.
  • 9.5 Greedy coordinate descent (safe-ish). Per-axis walks can stall on diagonal valleys; three shrinking rounds mitigate. Span (20 mm) bounds the recoverable seed error after center-alignment.
  • 9.6 Jaw block dimensions are raster artifacts (future work). The square-image requirement sets the block size; a tall part makes a wide jaw. The real target hardware lives in assets/soft-jaws/: jaw-blank-{left,right}.step (≈ 150 × 48 × 57 mm incl. mounting geometry) and 48419-125.x_t (the vise part). The intended end state carves the generated pocket out of these blanks instead of emitting a synthetic raster-sized block — that integration doesn't exist yet. Gotcha: the blank STEPs are authored in meters while the pipeline assumes millimeters; scale on import or the registration/containment checks reject everything.
  • 9.7 Resolution / memory scaling (operational). Vertex count is N² regardless of content. 512 ≈ 25 MB STL per jaw (fine); 2048 ≈ 400 MB; 8192 ≈ 6.7 GB. No decimation pass exists. Stay at 512 unless a feature is provably lost.
  • 9.8 Assorted sharp edges. Everything assumes mm — a meters-unit STEP/STL registers garbage. Non-finite STL vertices are dropped by SubdivideLongEdges. Degenerate (zero-area) triangles produce NaN in the closest-point test and are skipped by design (NaN loses all comparisons — don't "fix" the comparison direction). A hole-y workpiece STL along the ±X silhouette leaks sentinels into the cavity (fill walls where the pocket should be). The _op1 suffix is the pairing mechanism: <part>_op1.stl next to the part (the _op1 files are hidden from the part picker); renaming breaks pairing → "no post-op1 workpiece STL".

Constraints this satisfies (first-principles)

A soft jaw must: (C1) contain the part, (C2) release it as the vise opens, (C3) grip conformally, (C4) locate it in the non-clamped axes, (C5) stay clear of the op2 tool, (C6) be manufacturable. The heightfield prism satisfies C1–C3 by construction (single-valued along X ⇒ no overhang to hook the part, always releases; conforms across the entire ±X-facing surface). C4 is partly handled by the conforming pocket and the solid fill below the part; explicit back-wall/floor datums and dowel pins are future work. C5 (op2 clearance) and C6 (mounting features / wall-thickness checks) are out of current scope.

Future work

Explicit Y/Z datums + dowel pins (C4); op2 toolpath clearance subtraction (C5); mounting/bolt features + min-wall checks (C6); carving the pocket out of the real vise blanks (§9.6); 3MF / multi-material output. Anything needing true 3D CSG (e.g. subtracting a real vise-blank mesh) would add a mesh CSG library (e.g. manifold) — not voxel/SDF.

10. Verification

  • Unit tests (./test.sh): all the pure functions.
  • mach_softjaw: ApplyFitClearance (recede / clamp / sentinel / mixed), FillToPartingPlane (both view normals), RobustStockFloorZ (lowest-Z floor, a dense low band — built from the real 25-10-07 distribution — surviving however far down, isolated-stray trimming, dense-low-pair survival, near-band stray kept, tiny-input clamp, pitch-scaled-gap survival), SilhouetteStockZs (occluded pocket / wall vertex / lone vertex / top-face interior / a short proud step below a tall wall needing sub-step cells), RegisterWorkpieceOffsetSeed (zero offset on containment / center-aligned machine frame / undersized-workpiece rejection), ValidateRegistration (accept / residual-too-large / NaN / part-outside), RefineRegistrationOffset (pulls shifted samples back onto a slanted face), WorkpieceStlPathForPart.
  • mach_mesh: BuildHeightfieldPrism (watertight on constant/sloped fields, side faces stay inside a non-star-shaped boundary profile, infinity depths clamp to the plane, vertices sit at pixel centers not corners), SubdivideLongEdges (edge cap + area preservation, pass-through, exposing interior-proud stock invisible to vertex sampling, dropping non-finite triangles instead of looping forever), IsWithinDistanceOfMesh (agrees with ClosestDistanceToMesh across the boundary, on-surface, collinear-triangle), ClosestDistancePointTriangle / ClosestDistanceToMesh / trimesh_dist_grid (incl. exact agreement with brute force on a soup), WeldVertices (bit-identical merge, subdivision midpoints weld exactly).
  • (There is no STL write→read round-trip test; the STL path is exercised indirectly via the prism and distance tests.)
  • Headless end-to-end: SOFTJAW_AUTO=parts/.../<part>.step MACHEN_APP=0 ./run.sh drives load → recompute → export and prints every decision (registration offset and seed, proud counts, silhouette count, cut height, raster stats). It fails fast and loudly on load or compute failure — safe for CI-ish scripting. Real paired parts live under assets/parts/verified_features/milled/ (000001, 000002, each with a _op1.stl).
  • Debug dumps (on demand): the "Write Debug Dumps" button (Advanced) writes, from the current state, out/dist_dump.csv (x, y, z, proud-distance per workpiece vertex, part frame), out/sil_zs.csv (silhouette stock Zs), out/part_mesh_dump.stl (the meshed STEP reference), and out/depth_raw_{right,left}.bin (raw 512² f32 depth images). Headless runs trigger the same dump via SOFTJAW_DUMP_DIST=1. Normal loads write nothing, so out/ never accumulates stale dumps and the load path never pays the exact-distance cost.
  • Interactive: the "Show Op1 Workpiece" overlay (green) on the part (gray) shows proud regions directly; the raw depth views show each side's capture. A pre-print eyeball against the overlay is cheap insurance.

Worked example — part 25-10-07 earned its place in the test set: it exposed four distinct pipeline bugs in sequence (the band-walk floor, the vertex-only stock miss, the silhouette cell shadowing, and the side-face fan triangulation). Its registration locks on at 0.017 mm residual; the cut lands at z ≈ 0.607, exposing a 1.5 mm proud wedge while the jaw grips the real wall below it. Keep it regressed.