# ParCAD: open source CAD MCP server for Claude, ChatGPT and Cursor > Open source CAD your AI drives over MCP. Add mcp.parcad.dev/mcp to Claude, ChatGPT or Cursor, or run it on your own machine. Parts are JavaScript on an OpenCASCADE kernel, measured and checked, and exported as STEP, 3MF or STL for 3D printing. Experimental, made by Maging Studio. Units are millimetres. AGPL-3.0 or later; the parts you make are yours. Source: https://github.com/ierehon1905/parcad ## Get it - Your AI · nothing to install: `https://mcp.parcad.dev/mcp` - macOS · Linux: `brew tap ierehon1905/parcad && brew install parcad` - Desktop app · Windows, macOS, Linux: [Download from Releases](https://github.com/ierehon1905/parcad/releases) - Browser · nothing to install: [Open ParCAD web](https://parcad.dev/app/) ## Connect your AI The hosted MCP server is https://mcp.parcad.dev/mcp. Sign in with GitHub or Google when your client asks. - Claude (web and desktop): Customize, then Connectors, then Add custom connector. Paste the URL and sign in with GitHub or Google. - Claude Code: One line, then sign in when /mcp asks. ```bash claude mcp add --transport http parcad https://mcp.parcad.dev/mcp ``` - Codex: Add it, then sign in. ```bash codex mcp add parcad --url https://mcp.parcad.dev/mcp && codex mcp login parcad ``` - ChatGPT, Cursor and other clients: Add a remote MCP server with the URL. The client opens the sign-in page itself. ## The case for it ### Select edges by description Write what you mean: the circular rims that open onto the top face, the seam where the arm meets the hub. The description keeps matching after you move a hole or add three more. ### A drifted selector stops the build Add .expect({ count: 4 }) to a selector. If an edit changes what it matches, the build stops and lists every edge it found, with its length and position, so you know what to change. ```text expected 3 edge(s), but matched 4. The model's topology changed; inspect the current edges and update the selector or expectation. The edges matched, shortest first: 18.84 mm arc at (-18.00, -20.00, 4.00), convex 90° 18.84 mm arc at (-18.00, 20.00, 4.00), convex 90° 18.84 mm arc at (18.00, -20.00, 4.00), convex 90° 18.84 mm arc at (18.00, 20.00, 4.00), convex 90° ``` ### One OpenCASCADE model Your script builds a small graph, and OpenCASCADE turns it into faces and edges. The viewport, the measurements and the STEP file all come from that one solid. ### Every number is measured Reports read volume, wall thickness, whether two bores meet and the gap between a lid and its base off the built solid. The jar and cap in the picture sit 0.25 mm apart on the thread. ### See inside the part Cut any part on a plane to check that bores meet and walls keep their thickness. An agent gets the same section views over MCP. ## In code ### Selectors `bracket.js`: ```js const plate = box(80, 60, 8).tag("plate"); const wall = box(8, 60, 40).at(-36, 0, 20).tag("wall"); const body = union(plate, wall, { blend: 6 }); // the blend is the fillet const drilled = body.cut( ...grid(2, 2, 36, 40).map(([x, y]) => cylinder(3, 32).at(x, y)), ); return drilled .edges({ curve: "circle", role: "hole", adjacentTo: { faceNormal: "+z" }, }) .expect({ count: 4 }) // four rims, or the build stops .fillet(0.8); ``` - Size: 80 × 60 × 44 mm - Volume: 55 231.168 mm³ - Bodies: 1 - Watertight: yes The selector matches the four hole rims on the top face. Move a hole and it matches them again; lose one and the build stops at .expect(). ### Two bodies `spur-gears.js`: ```js const m = 2; const pinionTeeth = 20; const wheelTeeth = 30; const width = 8; const backlash = 0.1; const centres = (m * (pinionTeeth + wheelTeeth)) / 2; const pinion = extrude( spurGearOutline({ module: m, teeth: pinionTeeth, backlash }), width, ) .cut(cylinder(5 / 2, width * 2)) .tag("pinion"); const wheel = extrude( spurGearOutline({ module: m, teeth: wheelTeeth, backlash }), width, ) .cut(cylinder(8 / 2, width * 2)) .rotate("z", 180 / wheelTeeth) .at(centres, 0, 0) .tag("wheel"); return { pinion, wheel }; ``` - Pinion ↔ wheel: clear - Clearance: 0.094 mm - Bodies: 2 - Volume: 31 375.868 mm³ 0.1 mm of backlash predicts 0.1 · cos 20° = 0.094 mm between the flanks. The kernel measures 0.094 mm. ### Sections `tools/call · evaluate_part`: ```json { "name": "evaluate_part", "arguments": { "script": "…examples/manifold-block.js…", "views": ["iso"], "section": { "axis": "y" } } } ``` - Size: 80 × 50 × 40 mm - Volume: 145 263.889 mm³ - Cut: y, through the middle - Watertight: yes Cut the block on a plane to check that the drilled galleries meet. The cut applies to the picture only. ### Tags `bracket.js`: ```js const plate = box(w, d, t).tag("plate"); const wall = box(t, d, wallH) .at(-(w - t) / 2, 0, (wallH + t) / 2 - t / 2) .tag("wall"); const drilled = body .cut(...grid(2, 2, 36, 40).map(([x, y]) => hole.at(x, y))) .tag("mount_holes"); return chamferedBase .vertices(">X and >Y and # parcad DSL reference The parcad modelling language. - Millimetres. Every primitive is centred on the origin; place it with `.at(x, y, z)`. - Shapes are values: reusing one reuses the node. - A script returns one shape, or named bodies, `return { base, lid }`, which are measured apart and against each other and never fused. Selectors, tags and treatments work inside one body. - `checks: [...]` beside the bodies is a list of rules the build must hold, judged on every build and reported first; see `Check`. - A shape may be a *surface*: faces with no inside and free edges where it ends. It reports area and free edges instead of a volume; booleans, fillets, wall thickness and STL refuse it until `.thicken(t)` makes it a solid. See `surfaceLoft`. const hole = cylinder(3, 40); return box(60, 30, 6).cut(hole.at(20, 0, 0), hole.at(-20, 0, 0)); A script may name its own variables after anything below; a `const box` hides the primitive for the whole script, above the declaration too. What the language cannot do, and what to write instead, is the `gaps` topic. ## Functions ### brief `brief(declared: Brief): void` What this part is for: the requirement it is judged against on every build. Every reply carries a `brief` key — with none declared it says the part's size and volume are measured against nothing, which is the whole nudge. A brief never refuses a save; it is a sentence, in the place a reader meets first. - `envelope` is `[x, y, z]` in mm and is judged in the best of the six axis orientations, so the order does not matter. - `budgetCm3` is plastic in cm³; `printer` is a bed name from `BEDS`. - `holds`, `gesture` and `material` are prose, echoed and never judged. - Call it once, anywhere in the script, before or after the geometry. @example brief({ envelope: [95, 70, 16], budgetCm3: 12, holds: ["3 × 2€"] }); return box(90, 60, 12).cut(box(80, 50, 10).at(0, 0, 2)); ### sheet `sheet(thickness: number, options: SheetOptions = {}): Process` A sheet a body is cut from — plywood, MDF, acrylic — for `.made()`, so the print check holds that body to a saw instead of to a printer. - `thickness` is in mm; `material` is prose the reply echoes. - Give each piece of the sheet its own body: one body is one piece. - A sheet body gets no bed fit, overhang or thin-wall flags. It still fails for a wall under 0.3 mm or an edge sharper than a saw's 45° bevel, is flagged when it is in several pieces, and stays in `between_bodies`. @example const ply = sheet(18, { material: "birch plywood" }); const side = box(18, 300, 400).at(-141, 0, 200).made(ply); const top = box(300, 300, 18).at(0, 0, 409).made(ply); return { side, top }; ### note `note(label: string, value: number | string | number[]): void` Report a value the script computed. It appears in the reply's `notes`, marked "from the script, not measured", and changes nothing about the part. - `value` is a number, a string or a list of numbers; `label` names it. - A note is what the script asked for or worked out, never what was built: quote a measurement for anything that can be measured. - At most 40 notes and 2000 characters; the rest are counted as `dropped`. @example const mouth = 23.25 + 0.5; note("mouth 2€", mouth); return box(mouth + 4, 30, 10).cut(box(mouth, 30, 8).at(0, 0, 1)); ### partParams `partParams>(defaults: T): T` Declare the values this part takes when another part imports it, and read them. - Returns `defaults` with what an `importPart` call passed laid over them; run on its own, the part gets `defaults` unchanged. - An importer may pass only keys in `defaults`, each of its default's type; anything else is refused, naming the keys this part takes. - Call it once per part. @example const p = partParams({ od: 8, bore: 3.4, height: 10 }); return cylinder(p.od / 2, p.height).cut(cylinder(p.bore / 2, p.height + 1)); ### importPart `importPart(name: string, params: Record = {}): ImportedPart` Build another saved part inside this one, with its parameters set. - `name` is the part's name in the parts list, e.g. `"Fasteners/spacer"`; its saved `part.js` runs, not an unsaved edit of it. - `params` overrides what that part declares with `partParams`. - Returns what that part returns: a shape, placed like any other with `.at()`, or a multi-body part's object of bodies. - Its tags come along; tag the import itself to tell two copies apart. @example const spacer = importPart("spacer", { height: 12 }); return box(40, 20, 4).union(spacer.at(0, 0, 8)); ### box `box(x: number, y: number, z: number): Shape` A rectangular block with the given full extents, centred on the origin. ### sphere `sphere(r: number): Shape` A ball of radius `r` — a radius, like every other primitive here. ### cylinder `cylinder(r: number, h: number): Shape` A cylinder along Z with the given radius and full height. Centred like every primitive, in Z as well: `cylinder(3, 20)` runs from z = -10 to z = +10, so a hole through a part that stands on z = 0 is placed at the middle of its own length, not at the face it enters. `holeFor()` does that arithmetic, and overshoots both ends. ### torus `torus(major: number, minor: number, options: { sweep?: number } = {}): Shape` A ring: a circle of radius `minor` swept round Z at radius `major`, with `minor < major`. Both are radii: a 2 mm cord O-ring on a 20 mm ID is `torus(20 / 2 + 2 / 2, 2 / 2)`. `sweep` in degrees, from +X anticlockwise, makes part of a ring. ### revolve `revolve(profile: SectionEntry[], options: { wall?: WallSpec } = {}): Shape` A closed section in the (radius, z) half-plane, revolved a full turn about Z: a turned part, drawn as the shape a lathe tool leaves. The section is a list of `SectionEntry`; a radiused shoulder is `{ at: [r, z], round: 1 }`. - Radius >= 0 everywhere on the boundary, control points included. - A stepped (re-entrant) section is fine; a self-crossing one is not. - `wall: t` hollows it to a turned wall `t` thick, open at both ends: a bell. `wall: { thickness, bottom: "closed" }` keeps a floor, `top: "closed"` a lid. An end that opens is a straight edge from the axis. @example revolve([[0, 0], [10, 0], [10, 4], { at: [6, 4], round: 1 }, [6, 12], [0, 12]]) @example revolve([[0, 0], [10, 0], [10, 20], [0, 20]], { wall: { thickness: 1, bottom: "closed" } }) // a cup ### cone `cone(r1: number, r2: number, h: number): Shape` A cone or truncated cone along Z, centred on the origin like every other primitive: `r1` at the bottom, `r2` at the top. A cone *is* a revolved triangle, which is why this is four lines rather than a kernel primitive. `cone(r, 0, h)` is a point; `cone(r, r, h)` is a cylinder built the long way round, and `cylinder()` is the one to use for that. ### countersink `countersink(head: number | string, includedAngle = 90): Shape` A countersink cutter for a screw head: the frustum a drill leaves, sized by the head diameter and the included angle. 90° is the ISO metric countersink and the default here; 82° is the imperial one. The cutter is returned positioned so its wide end sits at z = 0 — cut it where the hole breaks out, `.at(x, y, faceZ)`. ### tapDrill `tapDrill(thread: string): number` Drill diameter for a coarse-pitch tapped hole: `tapDrill("M6")` is 5.0. ### clearance `clearance(thread: string, fit: Fit = "normal"): number` Clearance hole diameter, ISO 273: `clearance("M6")` is 6.6, close fit 6.4. ### counterbore `counterbore(thread: string): { diameter: number; depth: number }` Counterbore for a socket head cap screw: the diameter to bore and how deep. The bore is the head diameter plus 1 mm of drill clearance, and the depth is the head height, which for these screws is the thread diameter itself. ### holeFor `holeFor(thread: string, depth: number, options: { fit?: Fit; tapped?: boolean; through?: boolean } = {}): Shape` A hole cutter along Z for a named fastener, entering at z = 0 going down: place it at the face it enters. `holeFor("M6", 12)` is a blind clearance hole 12 mm deep; `tapped` drills for a thread a machinist will tap; `through` also clears the far face. It already overshoots by 0.5 mm. A printed or modelled thread is `threadedHole`. @example box(60, 30, 6).cut(holeFor("M5", 6, { through: true }).at(20, 0, 3)) // entering the top face ### threadedRod `threadedRod(size: ThreadSize, length: number, options: ThreadOptions = {}): Shape` An external ISO 68-1 thread along Z, centred on the origin like `cylinder`, squared off at both ends; union a head or shank onto it. `size` is `"M8"` (coarse pitch) or `{ diameter, pitch }`. - The tooth crosses +X at z = 0 of the rod's own frame. A rod and a `threadedHole` of the same size mate only when their frames are a whole number of pitches apart along Z. - A nut reading **interfering** is out of phase (move it a whole pitch) or its hole stops short (place the hole's frame on the face it enters). @example const bolt = threadedRod("M6", 20, { clearance: 0.2 }); // frame at z = 0 const nut = box(10, 10, 5).at(0, 0, 2.5) // z 0 to 5 .cut(threadedHole("M6", 5, { through: true, clearance: 0.2 }).at(0, 0, 5)); return { bolt, nut }; // 5 mm = 5 pitches: in phase ### threadedHole `threadedHole(size: ThreadSize, depth: number, options: ThreadOptions & { through?: boolean } = {}): Shape` The cutter for an internal screw thread along Z, entering at z = 0 going down like `holeFor`: a nut, a cap, a threaded boss. Cut it, do not union it. Its tooth crosses +X at the entry face, so a `threadedRod` mates a whole number of pitches away along Z. A hole a machinist will tap is `holeFor(size, depth, { tapped: true })`. @example box(20, 20, 10).cut(threadedHole("M8", 10, { through: true }).at(0, 0, 5)) // a nut ### vesaPattern `vesaPattern(size: 75 | 100 | 200): [number, number][]` The square VESA mounting patterns, as four points for `repeat()`: MIS-D at 75 and 100 mm on M4, MIS-E/F at 200 mm on M6. Centred on the origin, the way a monitor arm's plate is; pick rows off the list with `filter` when a bracket only reaches one of them. ### line2d `line2d(from: [number, number], to: [number, number]): Line2d` A line through two points, or from a point in a direction at an angle in degrees. ### line2d `line2d(from: [number, number], angleDegrees: number): Line2d` ### inset `inset(outline: SectionEntry[], by: number): SectionEntry[]` An outline stepped inward by `by` mm, as a section: the inside of a wall. Any section will do: corners, arcs, curves, one closed `{ fit }`. - An arc stays an arc and a fitted curve one curve; never offset the points yourself. - Where the outline turns inward (an L, a notch, a step) the inset keeps the corner, `by / cos(turn / 2)` from the outline's: the wall thickens into the valley. - An inset that splits, vanishes or comes nearer than `by` is refused with the distance that fits. Inset once by the sum, never an inset of an inset. - A lofted wall is `loft`'s `wall` and a turned wall `revolve`'s, not a loft or revolve of insets. @example // a 60 × 40 tray, 1.6 mm walls and a 2 mm floor const outline = [{ at: [-30, -20], round: 6 }, { at: [30, -20], round: 6 }, { at: [30, 20], round: 6 }, { at: [-30, 20], round: 6 }]; return extrude(outline, 20).cut(extrude(inset(outline, 1.6), 20).at(0, 0, 2)); ### spurGearOutline `spurGearOutline(options: { module: number; teeth: number; pressureAngle?: number; profileShift?: number; addendum?: number; dedendum?: number; backlash?: number; tolerance?: number; }): SectionEntry[]` The outline of an involute spur gear for `extrude`: centred on the origin, a tooth on +X, every flank certified to lie within `tolerance` mm of the true involute. - `module` (mm) and `teeth` are required. Defaults: `pressureAngle` 20°, `addendum` `module`, `dedendum` `1.25 * module`. - The part reports the flanks' proven bound as `curve_bound_mm`, with `curve_bound: "certified"`; `tolerance` (default 0.0001) is only a cap. - `backlash` thins each tooth by that many mm at the pitch circle. - Under 17 teeth at 20° is refused as undercut unless `profileShift` (x, in modules) is large enough; the refusal names the least. @example // module 2, 20 teeth, 10 mm thick, 6 mm bore return extrude(spurGearOutline({ module: 2, teeth: 20 }), 10).cut(cylinder(3, 12).at(0, 0, 5)); @example extrude(spurGearOutline({ module: 2, teeth: 12, profileShift: 0.3 }), 8) // a 12-tooth pinion, shifted ### spurGearPair `spurGearPair(options: { module: number; teeth: [number, number]; profileShift?: [number, number]; pressureAngle?: number; backlash?: number; tolerance?: number; }): { centres: number; pressureAngle: number; turn: number; outlines: [SectionEntry[], SectionEntry[]] }` Two full-depth involute spur gears that mesh, and where to put them. - `teeth` is `[first, second]`, `profileShift` their shifts (default `[0, 0]`); `module`, `pressureAngle` and `tolerance` are as in `spurGearOutline`. - `backlash` is the pair's play in mm: built as returned, the flanks are `backlash / 2` apart in `between_bodies`. - Returns `{ centres, pressureAngle, turn, outlines }`: `centres` mm between the axes, `pressureAngle` the working one in degrees, `turn` the degrees to rotate the second gear before placing it at `[centres, 0]`. - Undercut, interference and a contact ratio under 1 are refused. @example // 12 and 30 teeth, module 2, the pinion shifted to avoid undercut const pair = spurGearPair({ module: 2, teeth: [12, 30], profileShift: [0.3, 0], backlash: 0.1 }); const pinion = extrude(pair.outlines[0], 8); const wheel = extrude(pair.outlines[1], 8).rotate("z", pair.turn).at(pair.centres, 0, 0); return { pinion, wheel }; ### hull `hull(points: [number, number][]): [number, number][]` The convex outline through a set of points, anticlockwise, ready for `extrude`. What a fan, a flare or a gusset is: "the shape that joins these corners", with the convexity `extrude` demands guaranteed by construction rather than checked after. Points inside the hull are dropped; three distinct points that are not collinear are the least it accepts. ### extrude `extrude(profile: SectionEntry[], height: number, options: { draft?: number } = {}): Shape` A closed outline in XY (a list of `SectionEntry`), given a thickness along Z. Like every primitive it is centred on the origin in Z, from `-height / 2` to `+height / 2`; the outline places it in X and Y. - `draft` leans the walls in by that many degrees going up, and needs a convex outline of straight edges: fillet the vertical edges afterwards. @example extrude([[-5, -5], [5, -5], { through: [10, 0] }, [5, 5], [-5, 5], { through: [-10, 0] }], 3) // 20 × 10 slot ### ngon `ngon(sides: number, size: number, height: number, options: { across?: "corners" | "flats"; draft?: number } = {}): Shape` A regular polygon prism along Z, first corner on +X: hex stock, a square drive. `size` is across the corners unless `{ across: "flats" }`, which is how hex bar and spanners are sized. ### pipe `pipe(points: PathPoint[] | HelixPath | SplinePath, diameter: number, options: SweepOptions = {}): Shape` A round tube of `diameter` along a path of points, a `HelixPath` or a `SplinePath`: hydraulic line, hose, wire, spring. Another profile is `sweep`. - `bend` is the centreline bend radius at every corner. Without it corners are filled with a ball: fine for clearance, not a makeable tube. - `taper` along a path of points needs a `bend`. @example pipe([[0, 0, 0], [40, 0, 0], [40, 30, 0]], 6, { bend: 10 }) ### loft `loft(sections: LoftSection[], options: { smooth?: boolean; wall?: WallSpec; } = {}): Shape` Skin a solid through two or more `LoftSection`s at rising `z`. Walls are ruled (straight between sections) unless `smooth: true`. - Walls pair section edges by index, so every outline needs the same edge count: a straight edge, arc or curve is one, a rounded corner adds one. A circle lofted to a square is four arcs between four corners. - Listing an outline rotated twists the wall; the kernel never untwists it. - `wall: t` makes a shell `t` mm thick inward from `{ fit }` sections: a vase, not a loft minus a loft. `wall: { thickness, bottom: "closed" }` gives it a floor, `top: "closed"` a lid. - List each `{ fit }` section's points from the same start, same way round. @example loft([{ z: 0, outline: [[-10, -10], [10, -10], [10, 10], [-10, 10]] }, { z: 30, point: [0, 0] }]) // a pyramid @example // a vase 80 tall with a 1.6 mm wall and a floor const ring = (r) => Array.from({ length: 120 }, (_, i) => [r * Math.cos(i * Math.PI / 60), r * Math.sin(i * Math.PI / 60)]); return loft([0, 40, 80].map((z) => ({ z, outline: [{ fit: ring(30 + z / 4), tolerance: 0.01 }] })), { smooth: true, wall: { thickness: 1.6, bottom: "closed" } }); ### sweep `sweep(profile: SectionEntry[], path: PathPoint[] | HelixPath | SplinePath, options: SweepOptions = {}): Shape` Sweep a `SectionEntry` profile along a path of points, a `HelixPath` or a `SplinePath`: `pipe` with any section. A round one stays a `pipe`. - The profile starts perpendicular to the path, its +Y as near global +Z as the path allows; on a helix its +X points away from the axis. - A path of points that turns needs `bend`, which must fit the legs and clear the profile's own extent. @example sweep([[-2, -1], [2, -1], [2, 1], [-2, 1]], [[0, 0, 0], [0, 0, 20], [30, 0, 20]], { bend: 8 }) ### surfaceExtrude `surfaceExtrude(curve: SectionEntry[], height: number, options: CurveOptions = {}): Shape` A surface: a curve in the XY plane extruded `height` mm along Z, centred on z = 0. Fusion's surface Extrude. - The curve is open unless `closed: true` (see `CurveOptions`). - The normal is to the right of the curve's travel, seen from +Z. - A surface has area and free edges, no volume; `.thicken(t)` makes it a solid. @example // an L-shaped sheet, 30 + 20 long and 40 tall: 2000 mm² return surfaceExtrude([[0, 0], [30, 0], [30, 20]], 40); @example surfaceExtrude([[0, 0], { spline: [[10, 6], [20, -6]] }, [30, 0]], 40) // a wavy sheet ### surfaceRevolve `surfaceRevolve(curve: SectionEntry[], options: CurveOptions & { degrees?: number } = {}): Shape` A surface: a curve in the (radius, z) plane revolved about Z. Fusion's surface Revolve: a dome from a quarter arc, a shade from a line. - Points are `[radius, z]`, radius >= 0. The curve is open unless `closed: true` (see `CurveOptions`). - `degrees` (default 360) revolves part of a turn, anticlockwise from +X. - The normal is to the right of the curve's travel: a curve drawn upward faces outward. @example // a radius-40 dome, open at its rim: 2π · 40² = 10,053 mm² return surfaceRevolve([[40, 0], { through: [40 * Math.SQRT1_2, 40 * Math.SQRT1_2] }, [0, 40]]); ### surfaceLoft `surfaceLoft(sections: LoftCurve[], options: CurveOptions & { smooth?: boolean } = {}): Shape` A surface through two or more `LoftCurve`s at rising `z`: Fusion's surface Loft, for a sheet, blade or shade that stays open. Ruled between curves unless `smooth: true`. - Curves are open unless `closed: true`, which makes a tube open at both ends (see `CurveOptions`). - Pieces pair by index, as in `loft`: every curve needs the same count. - For curves from points, give each curve one `{ fit }` over the same number of points, listed from the same end (or, closed, the same start and the same way round). Point `i` then lies on one line of the surface. - A surface has no volume; `.thicken(t)` makes it a printable solid. @example // a 60° strip of a radius-40 cylinder, 40 tall: 40 · π/3 · 40 = 1676 mm² const arc = (r) => Array.from({ length: 12 }, (_, i) => { const a = (Math.PI / 3) * (i / 11) - Math.PI / 6; return [r * Math.cos(a), r * Math.sin(a)]; }); return surfaceLoft([ { z: 0, curve: [{ fit: arc(40), tolerance: 0.01 }] }, { z: 40, curve: [{ fit: arc(40), tolerance: 0.01 }] }, ], { smooth: true }); @example surfaceLoft([{ z: 0, curve: [[-20, 0], [20, 0]] }, { z: 30, curve: [[-20, 0], [20, 0]] }]).thicken(2) // 2400 mm³ ### surfaceSweep `surfaceSweep(curve: SectionEntry[], path: PathPoint[] | HelixPath | SplinePath, options: CurveOptions & { bend?: number } = {}): Shape` A surface: a curve swept along a path. Fusion's surface Sweep. - The path is what `sweep` takes: points (`bend` rounds every corner), `{ helix: { radius, pitch, turns } }` or `{ spline: [[x, y, z], ...] }`. - The curve is drawn in the plane square to the path's start, as `sweep` draws its profile, and is open unless `closed: true`. - The normal is to the right of the curve's travel in that plane. @example // a ribbon 10 wide along a path 30 + π/2 · 10 + 20 long: 657 mm² return surfaceSweep([[-5, 0], [5, 0]], [[0, 0, 0], [0, 0, 40], [30, 0, 40]], { bend: 10 }); ### stitchSurfaces `stitchSurfaces(...args: (Shape | { tolerance?: number; solid?: boolean })[]): Shape` Surfaces sewn into one along the edges they share: Fusion's Stitch. A result with no free edge left is a solid. - The report's `kind` says `solid` only when the shell measured closed; otherwise `kind: "surface"`, its opening in `surface.free_edge_length_mm`. - Edges closer than `tolerance` (default 0.01 mm, at most 0.5) are joined. - `solid: true` refuses a result that does not close, listing its free edges: the way to insist on a watertight part. @example // four sides and two patches: a closed 20 mm cube, 8000 mm³ const sides = surfaceExtrude([[-10, -10], [10, -10], [10, 10], [-10, 10]], 20, { closed: true }); const top = sides.edges({ role: "boundary", at: { z: "max" } }).patch(); const bottom = sides.edges({ role: "boundary", at: { z: "min" } }).patch(); return stitchSurfaces(sides, top, bottom, { solid: true }); ### union `union(...args: (Shape | BoolOptions)[]): Shape` Fuse every shape given into one solid. `union(a, b, { blend: 3 })` rounds the seam the join creates by 3 mm, which is a fillet in the exact backend. Two solids that meet *exactly* on a face abort the kernel when the union is blended, at any radius — overlap them instead, and see the `gotchas` document before reaching for a blend. ### intersect `intersect(...args: (Shape | BoolOptions)[]): Shape` Keep only the volume every one of these shapes occupies. ### repeat `repeat(shape: Shape, points: [number, number, number?][]): Shape` Copies of `shape` at every point, unioned together. ### grid `grid(cols: number, rows: number, dx: number, dy: number): [number, number][]` A centred `cols` x `rows` grid of points. `dx` and `dy` are the centre-to-centre pitch, not the span: `grid(3, 1, 20, 0)` is x = -20, 0, 20. ### polar `polar(count: number, radius: number, options: PolarOptions = {}): [number, number][]` `count` points spaced evenly around a circle of `radius`, as `[x, y]` for `repeat`: a bolt circle, `repeat(holeFor("M6", 8, { through: true }), polar(6, 40))`. ### around `around(shape: Shape, count: number, axis: "x" | "y" | "z" = "z"): Shape` `count` copies of `shape` spun evenly about an axis through the origin and unioned: for a feature that must turn with its position, like a flute round a knob. Place the shape once at its radius, then `around(flute, 12)`. ### scriptBudget `scriptBudget(multiple: number): void` Let this script do `multiple` times the default work: the first line of a part that runs a simulation, a growth or a search. - `multiple` is a whole number from 1 to 10; calling again only raises it. - Work is counted in interpreter steps, not timed: the default is 600 million, far more than a drawn part uses. A refusal says how much was allowed. `evaluate_part`'s `timeout_s` does not raise it. @example scriptBudget(8); return box(10, 10, 10); ### simulateReactionDiffusion `simulateReactionDiffusion(options: ReactionDiffusionOptions): { a: number[]; b: number[] }` Run a reaction-diffusion field to the pattern it settles into (Turing spots, stripes, lobes) and return both fields, `{ a, b }`, one value per cell: what a generative part grows its outline from. - `size: n` is a ring of cells, `size: [w, h]` a grid that wraps both ways; see `ReactionDiffusionOptions`. - A `dt` too long to stay stable is refused naming the longest that is; a run that diverges is refused too. - Seed the starting noise deterministically, never with `Math.random`. @example const cells = 100; const noise = Array.from({ length: cells }, (_, i) => 1 + 0.01 * Math.sin(i * 7.3)); const { a } = simulateReactionDiffusion({ model: "gierer-meinhardt", size: cells, a: noise, b: noise.map(() => 1), diffusion: [0.3, 60], dt: 0.2 / 60, steps: 12000, kappa: 0.05, decay: [1, 1.2], source: [0.01, 0], }); // a ring whose radius swells where the activator peaks const ring = a.map((v, i) => [(20 + v) * Math.cos((2 * Math.PI * i) / cells), (20 + v) * Math.sin((2 * Math.PI * i) / cells)]); return extrude([{ fit: ring, tolerance: 0.05 }], 5); ### outlineCrossings `outlineCrossings(points: [number, number][]): [number, number][]` Where a closed outline of points crosses or touches itself: every pair `[i, j]`, `i < j`, of segments that meet without being neighbours, sorted. - Segment `i` runs from point `i` to point `i + 1`, the last back to the first. - Empty for a clean loop: the check a growth or offset loop makes after every move. @example // a bow tie: its two diagonals, segments 1 and 3, cross at [5, 5] const bow = [[0, 0], [10, 0], [0, 10], [10, 10]]; const pairs = outlineCrossings(bow); // [[1, 3]] return box(10, 10, 2 * pairs.length); ### outlineGaps `outlineGaps(points: [number, number][], options: { ignoreWithin: number; upTo?: number }): number[]` For each point of a closed outline of points, the width of the gap it faces: the distance to the nearest part of the outline that is at least `ignoreWithin` mm away along the outline. - `ignoreWithin` leaves out the point's own stretch of curve; set it to about the narrowest gap you care about. - Gaps wider than `upTo` (default: no limit), and points with nothing far enough along, come back as `Infinity`. @example // a U: its two arms face each other across a 4 mm slot const u = [[0, 0], [14, 0], [14, 20], [9, 20], [9, 5], [5, 5], [5, 20], [0, 20]]; const narrowest = Math.min(...outlineGaps(u, { ignoreWithin: 6, upTo: 10 })); // 4, at [9, 20] and [5, 20] return extrude(u, narrowest); ### build `build(root: Part, treatments?: TreatmentSource[], stacks?: (string | undefined)[]): Doc` Flatten a shape, or an object of named bodies, into the JSON graph. A script does not need it: returning the part is enough. ## Shape methods ### Shape.tag `tag(name: string): Shape` Name this shape's faces, for selectors (`on`, `between`) and for `tag_extents` in the report. Changes this shape in place and returns it; a second tag replaces the first everywhere the shape is used. ### Shape.reference `reference(): Shape` Mark this body as a reference: a real object the part is checked against — a stack of coins, a tipped coin at a mouth — that is not the part. Call it last, on the shape the returned object names. - Built, drawn in blue, measured alone (`named_bodies`, `reference: true`) and against every body (`between_bodies`), so a `clear` or `interferes` check can name it. - Never exported, probed, or counted: not in `bodies`, `volume_mm3`, `size` or `stands_on`. - At least one body must not be a reference. @example const plate = box(60, 40, 3).at(0, 0, 1.5).tag("plate"); const stack = cylinder(12, 20).at(0, 0, 13.5).reference(); return { plate, stack, checks: [{ clear: ["plate", "stack"], atLeast: 0.2 }] }; ### Shape.printedUp `printedUp(up: PrintAxis | [number, number, number]): Shape` Which way a body drawn in its assembled position prints, when printed on its own: the axis that points up on the printer. Call it last, on the shape the returned object names, like `.reference()`. - `"+z"` is as drawn, the default; `"-z"` prints it upside down; `"x"`, `"-x"`, `"y"`, `"-y"` stand it on a side; `[x, y, z]` is any direction. - `print_check` measures overhang and bed contact this way up; the export is as drawn. - Bodies printed together — a print-in-place hinge, a drawer in its frame — share one orientation and take none of this: draw them as they print and let a `turns` or `slides` check move them (TurnCheck). - A reference body has none. @example // a pin drawn lying in its bracket, printed standing on its end const bracket = box(30, 12, 12).at(0, 0, 6).cut(cylinder(2.1, 14).rotate("y", 90).at(0, 0, 8)); const pin = cylinder(2, 36).rotate("y", 90).at(0, 0, 8).printedUp("x"); return { bracket, pin }; ### Shape.made `made(process: Process): Shape` Say this body is made another way than printing: cut from a sheet. The print check then holds it to that process instead of to a printer. Call it last, on the shape the returned object names, like `.reference()`; it changes the shape in place. - A body is one of: printed (the default), made, bought (Shape.bought), or a reference. - A sheet body is one piece of the sheet; give each piece its own body. @example const ply = sheet(18, { material: "birch plywood" }); const shelf = box(600, 250, 18).at(0, 0, 9).made(ply); const bracket = box(40, 30, 4).at(0, -100, -2); return { shelf, bracket }; ### Shape.bought `bought(name: string): Shape` Say this body is bought, not made — a bearing, a magnet, an LED strip — so its shape stands in for the real part and the print check leaves it out. Call it last, on the shape the returned object names; it changes the shape in place. - `name` is what to buy, in your own words; the reply echoes it. - It is measured and in `between_bodies` like any body, so a check can hold its fit: `{ clear: ["magnet", "holder"], atLeast: 0.1 }`. @example const holder = cylinder(8, 6).at(0, 0, 3).cut(cylinder(5.1, 3.2).at(0, 0, 4.5)); const magnet = cylinder(5, 3).at(0, 0, 4.5).bought("10 x 3 mm neodymium magnet"); return { holder, magnet }; ### Shape.material `material(material: Material): Shape` How this body looks in the window. Visual only: nothing measured reads it, and an agent's render shows it only with `materials: true`. Changes this shape in place and returns it. - One material per solid: a body wears the outermost one in it, a union the first operand's, a cutter's never. Two colours are two bodies. @example return { base: box(40, 40, 10).material({ color: "#8a9099", metalness: 0.9 }), lid: box(40, 40, 2).at(0, 0, 7) } ### Shape.translate `translate(x: number, y: number, z = 0): Shape` Move by `x`, `y`, `z` millimetres from where the shape currently sits. ### Shape.at `at(x: number, y: number, z = 0): Shape` Alias for translate that reads better when placing a feature. ### Shape.rotate `rotate(axis: Vec3 | "x" | "y" | "z", degrees: number): Shape` Rotate about an axis through the origin, in degrees. Positive turns anticlockwise seen from the axis's + end: `.rotate("z", 90)` takes +X to +Y, and `.rotate("x", 90)` takes +Z to -Y, so a Z cylinder lies along Y. ### Shape.mirror `mirror(axis: Vec3 | "x" | "y" | "z"): Shape` Reflect in the plane through the origin whose normal is `axis`: `.mirror("x")` flips X, across the YZ plane. - A symmetric part is `union(half, half.mirror("x"))`. - Mirror only a half. A body spanning the plane refills a hole cut on one side from its own uncut reflection; mirror the features, or cut after the union. ### Shape.scale `scale(x: number, y = x, z = x): Shape` Resize about the origin: one factor uniformly, three per axis. `sphere(10).scale(2, 1, 0.5)` is an ellipsoid with semi-axes 20, 10, 5. - Factors must be positive; a reflection is `mirror`. - Stretching turns circles into ellipses, which `curve: "circle"` no longer finds. Fillet after stretching. ### Shape.offset `offset(distance: number): Shape` Move the surface outward by `distance` (or inward, if negative). Growing also rounds every convex edge by `distance`, which is the cheapest way to break sharp corners on a printed part. ### Shape.shell `shell(thickness: number, options: { corners?: "round" | "sharp" } = {}): Shape` Hollow this out, leaving a wall of `thickness` inside the current surface. - `corners` is what the cavity does at an inside corner of the part: `"round"` (default) rounds it by `thickness`, so the wall is `thickness` everywhere; `"sharp"` extends the walls until they meet, so the wall thickens into the corner, as Fusion's Shell does. - A step or lobe narrower than the wall leaves a round no room; it shells only with `corners: "sharp"`. - A turned part hollows exactly with `revolve`'s `wall`; a lofted one with `loft`'s. @example extrude([[0, 0], [20, 0], [20, 5], [5, 5], [5, 20], [0, 20]], 10).shell(1, { corners: "sharp" }) ### Shape.thicken `thicken(thickness: number, options: { side?: "both" | "out" | "in" } = {}): Shape` This surface made a solid `thickness` mm thick, measured square to the surface: Fusion's Thicken. The way to print, fillet or cut a surface. - `side`: `"both"` (default) centres the wall on the surface, `"out"` grows it on the normal side, `"in"` on the other. - The normal is to the right of the curve's direction of travel (seen from +Z for an extrude or a loft): outward for an anticlockwise curve. - Reports the measured thickness as `thickened_mm: { min, max }`; more than 1 % off anywhere is refused. - Refused where the surface bends tighter than `thickness` on the growing side (the radius is in the message), and at a crease between faces. @example // a radius-40 dome, 2 mm thick inward: 2/3 · π · (40³ − 38³) = 19,117 mm³ return surfaceRevolve([[40, 0], { through: [40 * Math.SQRT1_2, 40 * Math.SQRT1_2] }, [0, 40]]).thicken(2, { side: "in" }); ### Shape.trim `trim(tool: Shape | { plane: { point: PathPoint; normal: PathPoint } }, options: { keep: TrimKeep }): Shape` This surface cut by a tool, keeping the pieces on one side: Fusion's Trim. `keep` depends on the tool (see `TrimKeep`). - A solid tool: `keep: "inside"` or `"outside"`. - A surface tool: `"front"` (its normal side) or `"back"`. It must reach right across this surface. - A plane, `{ plane: { point: [x, y, z], normal: [x, y, z] } }`: `"above"` (where the normal points) or `"below"`. - To trim by a curve, trim by `surfaceExtrude(curve, h)` or an extruded solid. - A tool that misses, or keeps all or none of the surface, is refused. To keep every piece, use `.split(tool)`. @example // a 40 × 40 sheet on y = 0, less the strip a radius-10 rod passes: 800 mm² return surfaceExtrude([[-20, 0], [20, 0]], 40).trim(cylinder(10, 100), { keep: "outside" }); @example surfaceExtrude([[-20, 0], [20, 0]], 40).trim({ plane: { point: [0, 0, 10], normal: [0, 0, 1] } }, { keep: "above" }) // 40 × 10 ### Shape.split `split(tool: Shape | { plane: { point: PathPoint; normal: PathPoint } }): Shape` This surface cut along a tool, keeping every piece: Fusion's Split Face. - The tool is a solid, a surface or a plane, as `trim` takes. - The pieces stay joined along the cut, as separate faces that a selector or a later trim can tell apart. @example // one 40 × 40 sheet as two 800 mm² faces, split at x = 0 return surfaceExtrude([[-20, 0], [20, 0]], 40).split({ plane: { point: [0, 0, 0], normal: [1, 0, 0] } }); ### Shape.offsetSurface `offsetSurface(distance: number): Shape` A new surface `distance` mm from this one along its normal: Fusion's Offset Surface. - Negative `distance` moves against the normal (see `thicken` for which way the normal points). - Reports the measured distance as `offset_mm: { min, max }`. - Refused where the surface bends tighter than `distance` on the side it moves toward. @example // a radius-40 dome moved 5 mm out: 2π · 45² = 12,723 mm² return surfaceRevolve([[40, 0], { through: [40 * Math.SQRT1_2, 40 * Math.SQRT1_2] }, [0, 40]]).offsetSurface(5); ### Shape.edges `edges(selector: EdgeSelector): EdgeSelection` The edges a selector matches, for a fillet, chamfer or patch to act on. - The selector is required: `.edges()` alone is refused, not read as every edge. `{ dihedral: "convex" }` is every outside edge. - A string measures against the whole part: `>Z` is the edges furthest in +Z, `|Z` the straight edges parallel to Z, joined by `and`. - An `EdgeQuery` object narrows by feature (`on`, `between`), corner angle, curve kind or length. - `.expect({ count })` turns a change in how many edges match into a build error. @example // a 20 mm cube with all 12 edges rounded 2 mm: 16³ + 6·16²·2 + 3π·2²·16 + (4/3)π·2³ = 7,805 mm³ return box(20, 20, 20).edges({ dihedral: "convex" }).expect({ count: 12 }).fillet(2); ### Shape.vertices `vertices(selector: VertexSelector): VertexSelection` The corners a selector matches, for a treatment of every edge meeting there. - The selector is required: `.vertices()` alone is refused, not read as every corner. To round every outside edge, use `.edges({ dihedral: "convex" })`. - `>X and >Y and >Z` is the corner furthest in +X, +Y and +Z. Vertex strings take only `>` and `<` terms. @example // one corner of a 20 mm cube rounded 2 mm: 8000 − 3·18·(4 − π) − (8 − (4/3)π) = 7,950 mm³ return box(20, 20, 20).vertices(">X and >Y and >Z").fillet(2); ### Shape.fillet `fillet(radius: number, selector: EdgeSelector, expectation?: EdgeExpectation, options?: FilletOptions, source?: SourceLocation): Shape` Round the edges matched by an authored selector. Either order: `.fillet(">Z", 2)` is `.fillet(2, ">Z")`. ### Shape.fillet `fillet(selector: EdgeSelector, radius: number, expectation?: EdgeExpectation, options?: FilletOptions, source?: SourceLocation): Shape` ### Shape.chamfer `chamfer(distance: number, selector: EdgeSelector, expectation?: EdgeExpectation, options?: ChamferOptions, source?: SourceLocation): Shape` Bevel the edges matched by an authored selector. Either order: `.chamfer(">Z", 1)` is `.chamfer(1, ">Z")`. ### Shape.chamfer `chamfer(selector: EdgeSelector, distance: number, expectation?: EdgeExpectation, options?: ChamferOptions, source?: SourceLocation): Shape` ### Shape.expect `expect(expectation: EdgeExpectation): Shape` Check the fillet or chamfer just applied, as `.edges(sel).expect({ count })` before it would: a build where its selector matches a different number of edges fails aloud. - Only straight after `.fillet`, `.chamfer`, `.smooth` or `.squircle`; anywhere else it is refused. - `count` is `edges` on that treatment in an evaluate_part reply. - One expectation per treatment: not here and on `.edges(...)` too. @example // a 40 × 20 × 10 block with its 4 upright edges rounded 2 mm: 8000 − 4·10·(2² − π·2²/4) = 7,966 mm³ return box(40, 20, 10).fillet(2, "|Z").expect({ count: 4 }); ### Shape.union `union(...rest: (Shape | BoolOptions)[]): Shape` Fuse this shape with the others; `{ blend: r }` rounds where they meet. ### Shape.cut `cut(...rest: (Shape | BoolOptions)[]): Shape` Subtract each of `tools` from this shape, as one cut: the order they are listed in does not matter. A cutter must pass through, not end on, the faces it opens: make it at least 0.5 mm longer at each open end, or the part keeps a sliver or fails to close. ### Shape.intersect `intersect(...rest: (Shape | BoolOptions)[]): Shape` Keep only what this shape and the others all occupy. ## EdgeSelection methods What `.edges(...)` returns. ### EdgeSelection.expect `expect(expectation: EdgeExpectation): EdgeSelection` Require this selector to resolve to exactly `count` edges. This turns a topology edit that changes the target set into a clear build error instead of silently filleting a different number of edges. ### EdgeSelection.fillet `fillet(radius: number, options?: FilletOptions): Shape` Round the selected logical edges by `radius` millimetres. - A fillet whose stripe runs over a hole cut before it is built before that cut, and the cut made again through it: the hole stays through. The reply's treatment lists the cuts as `before_cuts`. - The default recipe is tangent, rolling-ball. Other valid recipes are kept in the intent graph but the exact backend refuses them until it can produce that geometry exactly. ### EdgeSelection.chamfer `chamfer(distance: number, options?: ChamferOptions): Shape` Bevel the selected edges by an equal distance in millimetres. - A chamfer that runs over a hole cut before it is built before that cut, and the cut made again through it, as a fillet is. ### EdgeSelection.smooth `smooth(radius: number, options?: Omit): Shape` Round the selected edges with a blend that is curvature-continuous (G2) to the faces it joins: no crease and no change in curvature where it meets them, where a `fillet()` leaves a kink in the reflection. - It meets the faces along the same lines as `fillet(radius)`, at the same depth, so the two are interchangeable. - Every edge must run face to face: three or more selected edges meeting at a corner are refused, naming the corner. `fillet()` those. - The reply's `curvature_jump_per_mm` is the step in curvature measured where the blend meets the part: 0 here, 1/radius for a fillet. @example return box(40, 30, 10).edges("|Z").expect({ count: 4 }).smooth(4); ### EdgeSelection.squircle `squircle(radius: number, options?: Omit): Shape` The same blend as smooth, under the name a "squircle" corner is asked for; a true squircle is a 2D superellipse, this is a 3D blend. @example return box(40, 30, 10).edges("|Z").squircle(4); ### EdgeSelection.patch `patch(options: { tangent?: boolean } = {}): Shape` A new surface filling the closed loop of free edges selected: Fusion's Patch. - Select with `{ role: "boundary" }`, narrowed by `at` or `on`. Every selected edge must be a free edge, and together they must close. - Returns the patch alone. `stitchSurfaces(surface, patch)` joins it. - `tangent: true` makes the fill meet its neighbours smoothly. - A fill more than 0.01 mm off its edges, or 1° off tangent, is refused. @example // a radius-20 tube 30 tall, patched at both ends: a 37,699 mm³ cylinder const tube = surfaceExtrude([[20, 0], { through: [0, 20] }, [-20, 0], { through: [0, -20] }], 30, { closed: true }); const lid = tube.edges({ role: "boundary", at: { z: "max" } }).patch(); const floor = tube.edges({ role: "boundary", at: { z: "min" } }).patch(); return stitchSurfaces(tube, lid, floor); ## VertexSelection methods What `.vertices(...)` returns. ### VertexSelection.expect `expect(expectation: EdgeExpectation): VertexSelection` Require this selector to resolve to exactly `count` corner vertices. ### VertexSelection.fillet `fillet(radius: number, options?: FilletOptions): Shape` Round every incident edge at the selected corner vertices. ### VertexSelection.chamfer `chamfer(distance: number, options?: ChamferOptions): Shape` Bevel every incident edge at the selected corner vertices. ## Line2d methods What `line2d(...)` returns. ### Line2d.direction `direction(): [number, number]` Unit direction from `from` toward `to`. ### Line2d.normal `normal(): [number, number]` Unit normal, the direction turned a quarter turn anticlockwise: left of travel. ### Line2d.pointAt `pointAt(distance: number): [number, number]` The point `distance` mm along the line from `from`; negative goes back. ### Line2d.length `length(): number` Length of the segment `from` to `to`. ### Line2d.offset `offset(distance: number): Line2d` The same line moved sideways by `distance`, to the left of its travel; negative moves it right. A wall's inner face from its outer one, the edge of a band from its centreline. ### Line2d.meet `meet(other: Line2d): [number, number]` Where this line crosses `other`, extended as far as needed in both directions. Throws when they are parallel, which is the answer in that case rather than a point far away. ### Line2d.yAt `yAt(x: number): number` The line's y at a given x, for a line that is not vertical. ### Line2d.xAt `xAt(y: number): number` The line's x at a given y, for a line that is not horizontal. ## Values ### METRIC_FASTENERS `const METRIC_FASTENERS: Record< string, { pitch: number; tap: number; close: number; normal: number; free: number; head: number; csink: number } >` ISO metric coarse fasteners by designation, M2 to M20 (`M2_5` is M2.5), in mm: `pitch` (ISO 261 coarse), `tap` drill, `close`/`normal`/`free` clearance (ISO 273), `head` of a socket head cap screw (ISO 4762), `csink` of a 90° countersunk screw (ISO 10642). A size not listed is refused, never guessed. ## Types ### Vec3 `type Vec3 = { x: number; y: number; z: number }` ### BoolOptions ```ts export interface BoolOptions { /** * Radius of the rounding applied where the shapes meet. Zero is a hard corner. * * Worth knowing: a blend pushes the surface outward by up to `blend / 4` near * the seam, so blending two shapes with coincident faces makes the part very * slightly larger than nominal on those faces. */ blend?: number; } ``` Options shared by the boolean operations. ### AxisDirection `type AxisDirection = "+x" | "-x" | "+y" | "-y" | "+z" | "-z"` The outward normal of a face, as `adjacentTo` spells it. ### EdgeQuery ```ts export interface EdgeQuery { /** Match edges created by this named Boolean operation. */ generatedBy?: string; /** * `"line"`, `"circle"` (any circular arc) or `"spline"` (anything else: * section curves, and the ellipses and intersection curves booleans leave). */ curve?: "line" | "circle" | "spline"; /** * `"hole"`: a circular hole rim, not the rim of an outside boss. * `"boundary"`: a free edge, where a surface ends (one face on it); what * `.patch()` fills and what a surface report measures as open. A closed * solid has none. */ role?: "hole" | "boundary"; /** Match an edge touching a face with this outward normal. */ adjacentTo?: { faceNormal: AxisDirection }; /** Match edge centres at the requested document extrema. */ at?: Partial>; /** * `convex` (an outside corner), `concave` (inside) or `smooth` (no corner: * a fillet's boundary). Fillets and chamfers skip smooth edges unless asked * for them. */ dihedral?: "convex" | "concave" | "smooth"; /** A straight edge parallel to this axis: the object form of `|Z`. */ parallel?: "x" | "y" | "z"; /** Only edges at least this long, in mm: what keeps a sliver out of a cosmetic pass. */ longerThan?: number; /** * Only edges of these tagged features. `{ on: "lip", at: { z: "max" } }` is * the lip's own top rim: `at` is then measured among the lip's edges. Tags * survive booleans, fillets, chamfers and moves, not offset, shell or * intersect. */ on?: string | string[]; /** Only edges with one face from each of two features: the seam where one meets the other. */ between?: [string, string]; /** * Every edge the rest of this query matches, except those this sub-query * matches. `{ dihedral: "convex", not: { parallel: "z" } }` is every * outside edge but the upright ones. * * - Taken away before `at` picks extrema, so an extremum is the highest of * what is left rather than the highest edge if it survives. * - One level: a `not` inside a `not` is refused, and so is an empty one. * - Prefer a positive term where there is one — `{ dihedral: "convex" }` * says more than `{ not: { dihedral: "concave" } }`. */ not?: EdgeQuery; } ``` A topology-aware alternative to the compact directional selector string. Any other key is refused, naming the one it most likely meant. ### EdgeSelector `type EdgeSelector = string | EdgeQuery` A string query or an `EdgeQuery`, resolved anew on every build. `>Z` is furthest in +Z, `Z and >Y and |X` is the top edge at +Y running along X. ### VertexQuery ```ts export interface VertexQuery { /** Match vertices at the requested document extrema. */ at?: Partial>; } ``` A positional query over B-rep vertices for a corner treatment. ### VertexSelector `type VertexSelector = string | VertexQuery` A compact vertex selector such as `>X and >Y and >Z`, or a vertex query. ### EdgeExpectation ```ts export interface EdgeExpectation { /** The exact number of selected edges or vertices the selector must match. */ count?: number; /** The fewest the selector may match. */ atLeast?: number; /** The most the selector may match. */ atMost?: number; } ``` What a selector must resolve to, checked on the shape the treatment runs against, so a selector that drifts fails aloud instead of treating other edges. At least one field. - `count` is the number `edges` in an evaluate_part reply's `treatments` reports for that treatment: paste it from a reply, never count by hand. - `atLeast` and `atMost` are for an expectation written before the count is known: `{ atLeast: 1 }` says the selector must find something. @example box(40, 20, 10).edges("|Z").expect({ count: 4 }).fillet(2) ### Material ```ts export interface Material { /** `#rrggbb` or `#rgb`. */ color: string; /** 0 mirror-smooth to 1 fully diffuse. Defaults to 0.5. */ roughness?: number; /** 0 plastic or paint to 1 bare metal. Defaults to 0. */ metalness?: number; /** 1 solid, towards 0 see-through; above 0. Defaults to 1. Window only. */ opacity?: number; /** A colour the surface glows with, `#rrggbb` or `#rgb`. Defaults to none. Window only. */ emissive?: string; /** A clear glossy layer over the surface, 0 to 1. Defaults to 0. Window only. */ clearcoat?: number; } ``` A surface appearance for `Shape.material`, in glTF's terms. Glossy is low `roughness`. @example box(20, 10, 2).material({ color: "#c9ccd1", metalness: 1, roughness: 0.35 }) // aluminium ### FilletOptions ```ts export interface FilletOptions { /** Tangent (G1) is `fillet()`; curvature (G2) is `smooth()`, which sets it. */ continuity?: "tangent" | "curvature"; /** Rolling-ball is available now; setback is reserved for the exact backend. */ corner?: "rollingBall" | "setback"; } ``` How a constant-radius edge fillet meets its neighbouring faces. ### ChamferOptions ```ts export interface ChamferOptions { /** Planar chamfer is available now; miter and blend are reserved for the exact backend. */ corner?: "chamfer" | "miter" | "blend"; } ``` How equal-distance chamfers meet where selected edges share a corner. ### SheetOptions ```ts export interface SheetOptions { /** The stock, in your own words: "poplar plywood B/BB". Echoed, never judged. */ material?: string; } ``` What sheet takes besides the thickness. ### Process ```ts export interface Process { kind: "sheet"; /** mm. */ thickness: number; material?: string; } ``` How a body is made when it is not printed; sheet makes one. ### Brief ```ts export interface Brief { /** The box the part must fit inside, mm, in any of the six axis orientations. */ envelope?: [number, number, number]; /** The plastic it may use, cm³. */ budgetCm3?: number; /** What it holds, in your own words. Prose: the measurement is a `Check`. */ holds?: string[]; /** How it is handled — "one hand, thumb only". Prose. */ gesture?: string; /** The machine it is for, a bed name from `BEDS`. */ printer?: string; /** The filament it is for. Prose. */ material?: string; } ``` What brief declares. Every key is optional; an empty brief is refused. ### ImportedPart `type ImportedPart = Shape & { [body: string]: any }` What a part returns, as `importPart` hands it back: a shape, or a multi-body part's object of bodies. ### EdgeSelection `class EdgeSelection` A selected edge set, ready for an edge-specific operation. ### VertexSelection `class VertexSelection` A selected corner-vertex set, expanded to incident edges by the exact backend. ### Shape `class Shape` A solid, or a step on the way to one. Every method returns a new shape, so a shape can be placed twice or kept as a tool, except `tag` and `material`, which change this one. ### SectionPoint `type SectionPoint = [number, number]` A point in a revolved section: `[radius, z]`, with radius measured off +Z. ### SectionEntry ```ts export type SectionEntry = /** A corner, `[x, y]` (`[radius, z]` in a revolve). */ | [number, number] /** * A corner rounded by a tangent arc of radius `round`, between two straight * edges. `at` is the sharp corner where the edges would meet, not where the * arc starts; the round trims both edges. A 40 × 20 plate with 5 mm corners * is exactly four entries, `{ at: [±20, ±10], round: 5 }`, anticlockwise, * with no plain corners. */ | { at: [number, number]; round: number } /** A circular arc through this point, which lies on it. A full circle is two arcs between two corners. */ | { through: [number, number] } /** The shorter arc of this radius, at least half the chord. Positive bulges out of the section, negative bends in. */ | { radius: number } /** * A smooth curve through the points; `start` and `end` are its end * directions. `[{ spline: points }]` alone is one closed smooth curve. */ | { spline: [number, number][]; start?: [number, number]; end?: [number, number] } /** Bézier control points: one is quadratic, two cubic. The curve does not pass through them. */ | { bezier: [number, number][] } /** * A clamped B-spline with the corners as its end poles: how a STEP export's * poles copy in. `knots`, if given, is the full vector. */ | { bspline: [number, number][]; degree?: number; knots?: number[] } /** * A smooth curve within `tolerance` mm (0.01 to 0.1) of every sampled * point: the entry for points that are data. The measured worst distance * is reported as `deviation_mm`. Alone, it is one closed smooth loop. */ | { fit: [number, number][]; tolerance: number } /** A curve given by a formula, with its own ends: see `CurveEntry`. */ | CurveEntry /** An outline stepped inward; write it as `inset(outline, by)`. */ | { inset: SectionEntry[]; by: number }; ``` One entry of a section, the outline `extrude`, `revolve`, `loft` and `sweep` take: a list, anticlockwise, closing itself (never repeat the first corner). - One boundary that does not cross itself; a hole is a second shape cut out. - Two corners in a row are a straight edge; between two corners at most one entry draws the stretch (after the last, the stretch back to the first). - Arcs are exact circles and curves exact B-splines: never approximate a curve with short lines. @example // 60 × 30 plate, 4 mm corners: four rounded corners and nothing else const plate = [{ at: [-30, -15], round: 4 }, { at: [30, -15], round: 4 }, { at: [30, 15], round: 4 }, { at: [-30, 15], round: 4 }]; // slot 24 long, 8 wide: straight sides end at x = ±8, each half circle // passes through its tip at x = ±12 (the chord's midpoint moved out by the radius) const slot = [[-8, -4], [8, -4], { through: [12, 0] }, [8, 4], [-8, 4], { through: [-12, 0] }]; return extrude(plate, 3).cut(extrude(slot, 5)); ### CurveEntry ```ts export type CurveEntry = { curve: (t: number) => [number, number]; /** Where `t` starts; `from > to` draws the curve backwards. */ from: number; to: number; /** The most the drawn curve may be from the function anywhere, in mm. */ tolerance: number; /** The exact derivative of `curve`, `(t) => [dx, dy]`; checked against `curve`, and refused when it disagrees. */ derivative?: (t: number) => [number, number]; /** `(a, b) => m`: at least the length of the fourth derivative anywhere in `[a, b]`; checked against the error the script sees. */ fourth?: (a: number, b: number) => number; }; ``` A section entry drawn from a formula, `curve(t)` for `t` from `from` to `to`: an involute, a cam law, a spiral. - Its ends are `curve(from)` and `curve(to)`; it needs no corners round it, and a corner beside it is joined to that end by a straight edge. - The drawn curve is within `tolerance` mm (0.001 is usual) of the function everywhere. The part reports that bound as `curve_bound_mm`; `curve_bound` is `certified` when `derivative` and `fourth` are given, else `estimated`. `deviation_mm` is a check at points, not a bound. - `curve` must be smooth on the range: a cusp is two entries meeting at a corner. @example // a quarter circle of radius 10, certified: its fourth derivative has length 10 const arc = { curve: (t) => [10 * Math.cos(t), 10 * Math.sin(t)], derivative: (t) => [-10 * Math.sin(t), 10 * Math.cos(t)], fourth: (a, b) => 10, from: 0, to: Math.PI / 2, tolerance: 0.001 }; return extrude([[0, 0], arc], 5); ### Fit `type Fit = "close" | "normal" | "free"` How loosely a clearance hole is drilled, per ISO 273. ### ThreadSize `type ThreadSize = string | { diameter: number; pitch: number }` A thread named from `METRIC_FASTENERS` ("M8", coarse pitch) or given outright. ### ThreadOptions ```ts export interface ThreadOptions { /** `"right"` (the default, and nearly every screw) or `"left"`. */ hand?: "right" | "left"; /** * Radial allowance in mm, default 0 (the ISO basic profile). A rod shrinks * and a hole grows by it. A printed pair gives it to both: `c` on each sits * `c` apart across the flanks and `2c` at crest and root. */ clearance?: number; /** A fine pitch in place of the coarse one a named size carries: `{ pitch: 1 }` on "M8". */ pitch?: number; } ``` Options shared by `threadedRod` and `threadedHole`. ### Line2d `class Line2d` A straight line in the XY plane through two points, with a direction. ### OutlinePoint `type OutlinePoint = [number, number]` A point in an extruded outline: `[x, y]`, in the plane the shape is drawn on. ### PathPoint `type PathPoint = [number, number, number]` A point on a routed path: `[x, y, z]`. ### HelixPath ```ts export interface HelixPath { helix: { radius: number; pitch: number; turns?: number; height?: number; endRadius?: number; hand?: "right" | "left"; }; } ``` A helical path for `pipe` and `sweep`: a spring, a coil, a spiral horn. Axis +Z, centred on the origin: it starts at `[radius, 0, -height / 2]`. - Give `turns` or `height` (= `pitch * turns`), not both. - `endRadius` varies the radius linearly (a conical helix); `hand: "left"` reverses the default right-handed winding. - A screw thread is `threadedRod` or `threadedHole`, not a sweep. @example pipe({ helix: { radius: 10, pitch: 4, turns: 5 } }, 1.5) // a spring ### SplinePath ```ts export interface SplinePath { spline: PathPoint[]; } ``` A smooth path for `pipe` and `sweep` through at least three points, in order: a hose, a cable, a handle. The section starts perpendicular to it at the first point. A bend tighter than the section is refused, naming where: spread the points there. ### SweepOptions ```ts export interface SweepOptions { /** Centreline bend radius at every corner of a path of points. */ bend?: number; /** * The section's size at the end relative to the start, above 0: `0.2` ends * at a fifth. For a point, end on something small like `0.05`. */ taper?: number; } ``` Options shared by `pipe` and `sweep`. ### LoftSection ```ts export interface LoftSection { /** Height of the plane this section lies in. */ z: number; /** Corners and curve entries, anticlockwise, first corner not repeated. */ outline?: SectionEntry[]; /** An apex in place of an outline, first or last section only. */ point?: [number, number]; } ``` One loft section, lying flat at height `z`: an `outline` (a list of `SectionEntry` — corners, arcs, curves), or, for the first or last section only, a single `point: [x, y]` the wall closes onto — the tip of a vase or a spire. ### WallSpec `type WallSpec = number | { thickness: number; bottom?: "open" | "closed"; top?: "open" | "closed" }` A wall for `loft` and `revolve`: a thickness, or one with each end open (the default) or closed. ### TrimKeep ```ts export type TrimKeep = /** Inside a solid tool. */ | "inside" /** Outside a solid tool. */ | "outside" /** The side of a plane its normal points to. */ | "above" /** The side of a plane its normal points away from. */ | "below" /** A surface tool's normal side. */ | "front" /** The other side of a surface tool. */ | "back"; ``` Which pieces a `trim` keeps; the tool decides which two words apply. ### CurveOptions ```ts export interface CurveOptions { /** `true`: the curve closes back to its start. Default `false`. */ closed?: boolean; } ``` Options every surface-making function takes. The curve is a list of `SectionEntry`, open unless `closed: true`. - An open curve runs from its first entry to its last, and both are corners `[x, y]`: `[[0, 0], { through: [10, 5] }, [20, 0]]`. - Or it is one `{ fit }` or `{ spline }` alone, which then runs open through its points from the first to the last. - A closed curve is a section, as `extrude` takes, and makes a tube open at both ends. A full circle is two arcs between two corners. - `inset` is refused in an open curve. ### LoftCurve ```ts export interface LoftCurve { /** Height of the plane the curve lies in. */ z: number; /** The curve, open unless the loft is `closed` (see `CurveOptions`). */ curve: SectionEntry[]; } ``` One curve of a `surfaceLoft`, lying flat at height `z`. ### PolarOptions ```ts export interface PolarOptions { /** Angle of the first point, in degrees anticlockwise from +X. Default 0. */ start?: number; /** * Turn the circle half a step so no point sits on a centreline, as flange * standards (ASME B16.5) place bolt holes. */ straddle?: boolean; } ``` How a bolt circle is placed relative to the centrelines. ### ReactionDiffusionOptions ```ts export interface ReactionDiffusionOptions { /** * `"gierer-meinhardt"`: `a` is the activator and `b` the inhibitor, * `a' = Da∇²a + rho·a²/(b·(1 + kappa·a²)) − decay[0]·a + source[0]` and * `b' = Db∇²b + rho·a² − decay[1]·b + source[1]`. * `"gray-scott"`: `a` is the substrate and `b` the reactant, * `a' = Da∇²a − a·b² + feed·(1 − a)` and `b' = Db∇²b + a·b² − (feed + kill)·b`. */ model: "gierer-meinhardt" | "gray-scott"; /** `n` cells around a ring, or `[width, height]` cells on a grid that wraps both ways, stored row by row. */ size: number | [number, number]; /** Starting values, one per cell. Seed the noise with the script's own seeded generator. */ a: number[]; b: number[]; /** `[Da, Db]`, in cells² per unit time. */ diffusion: [number, number]; /** The explicit time step; refused when it is too long for the diffusion to stay stable. */ dt: number; steps: number; /** Gierer–Meinhardt only: production, default 1. */ rho?: number; /** Gierer–Meinhardt only: activator saturation, default 0; larger widens the peaks. */ kappa?: number; /** Gierer–Meinhardt only: `[decay of a, decay of b]`, default `[1, 1]`. */ decay?: [number, number]; /** Gierer–Meinhardt only: `[basal production of a, of b]`, default `[0, 0]`. */ source?: [number, number]; /** Gray–Scott only: the feed rate. */ feed?: number; /** Gray–Scott only: the kill rate. */ kill?: number; } ``` A two-species reaction-diffusion run: see simulateReactionDiffusion. ### PrintAxis `type PrintAxis = "+x" | "x" | "-x" | "+y" | "y" | "-y" | "+z" | "z" | "-z"` An axis that points up on the printer; see Shape.printedUp. ### Part `type Part = Shape | Record` What a script may return: one shape, or an object naming each body of a part that stays in several — `return { base, lid }` — where two keys are not bodies: `checks`, a list of Checks, and `mechanism`, a Mechanism. ### Check ```ts export interface Check { /** The two never touch, by `atLeast` mm. */ clear?: [string, string]; /** The two overlap: a catch that has to catch. */ interferes?: [string, string]; /** The two are flush: neither gap nor overlap. */ touching?: [string, string]; turns?: TurnCheck; slides?: SlideCheck; /** For a motion: bodies to stay clear of; default all others. */ clearOf?: string | string[]; /** For a motion: the body to bite into. */ catches?: string; /** Over the {@link Mechanism}, these bodies or "all" stay `atLeast` mm clear. */ moves?: "all" | string[]; /** Over the {@link Mechanism}, the two stay `within` mm of contact. */ pushes?: [string, string]; /** For `pushes`, mm of gap or overlap. */ within?: number; /** Nothing is thinner than `min` mm. */ wall?: { min: number }; /** The part is `is` [x, y, z] mm, or fits in `max`. */ size?: { is?: number[]; max?: number[] }; /** Round holes of Ø`diameter` along `axis` (default z): `count`, `through`, one per `at` [x, y]. */ holes?: { diameter: number; count?: number; through?: boolean; at?: number[][]; axis?: "x" | "y" | "z" }; /** The faces tagged `tag` span `size` centred at `center`; tag the cutter. */ feature?: { tag: string; size?: number[]; center?: number[] }; /** This fraction of the footprint reaches the bed, 0 to 1. */ standsOn?: { atLeast: number }; /** Exactly this many free-standing pieces. */ bodies?: number; /** The mesh closes; `true` only. */ watertight?: true; /** For `clear` the least gap, mm; for `interferes` the least shared mm³. */ atLeast?: number; /** For `interferes`, `catches`: how deep the two must overlap, mm. */ deeperThan?: number; /** For `touching`: the least shared surface, mm²; a graze is 0. */ contactAtLeast?: number; /** For `wall`: tags left out, or `"feather"`/`"edge"` for that reading. */ ignore?: string[]; /** For `wall`: only material on these tags counts. */ on?: string[]; /** Free text, echoed when the check fails. */ why?: string; } ``` One rule the built part must hold, written as `checks: [...]` beside the bodies. Judged on every build and reported first: a verdict, then each failure with what differs, what was measured and where. - Before modelling, write the request's numbers as checks — size, holes, where features sit — so the verdict says the part is what was asked. - One head key per check: `size`, `holes`, `feature`, `clear`, `interferes`, `touching`, `turns`, `slides`, `moves`, `pushes`, `wall`, `standsOn`, `bodies`, `watertight`; the rest qualify it. Held to 0.01 mm. - evaluate_part reports a failure; export_part and save_project refuse without `allow_failing: ""`. @example // "80 × 50 × 6, four Ø6 holes 10 mm in, a 10 × 8 notch on a long edge" const at = [[-30, -15], [30, -15], [-30, 15], [30, 15]]; const notch = box(10, 10, 10).at(0, 22, 0).tag("notch"); let plate = box(80, 50, 6).cut(notch); for (const [x, y] of at) plate = plate.cut(cylinder(3, 10).at(x, y, 0)); return { plate, checks: [ { size: { is: [80, 50, 6] } }, { holes: { diameter: 6, through: true, at } }, { feature: { tag: "notch", size: [10, 8, 6], center: [0, 21, 0] } }, ] }; ### TurnCheck ```ts export interface TurnCheck { /** The named body that turns. */ body: string; /** The axis: `"x"`, `"-y"`, `"+z"` or a direction `[x, y, z]`. */ about: PrintAxis | [number, number, number]; /** A point the axis passes through, mm. */ at: [number, number, number]; /** From and to, degrees, in the order the motion runs; 0 is as drawn. */ degrees: [number, number]; } ``` A body turned about an axis, for a `turns` check: a print-in-place hinge proven clear at every angle of its swing on the exact solids. - `about` is the axis, `at` a point on it (the pin); right-handed, the sign `.rotate("x", d)` gives, 0° as drawn, at most one revolution. - Draw the hinge open and flat, the pin of one body through the other's knuckles with a 0.4 mm gap, and let the check fold it; never draw the lid shut and `.printedUp()` it. - A lid is two checks: `clearOf` up to where the latch bites, `catches` with `deeperThan` over the last degrees. - The reply's `checks.motions` has the least clearance and its pose, or where the bodies first meet. @example // a box with a lid on a print-in-place hinge, drawn open and flat const AY = -28; // the hinge axis const AZ = 10.2; const base = box(60, 40, 10).at(0, 0, 5).cut(box(56, 36, 9).at(0, 0, 6)) .union(cylinder(4, 20).rotate("y", 90).at(0, AY, AZ).cut(cylinder(1.9, 22).rotate("y", 90).at(0, AY, AZ))); const lid = box(60, 40, 10).at(0, -56, 5).cut(box(56, 36, 9).at(0, -56, 6)) .union(cylinder(4, 18).rotate("y", 90).at(-19.5, AY, AZ), cylinder(4, 18).rotate("y", 90).at(19.5, AY, AZ)) .union(cylinder(1.5, 58).rotate("y", 90).at(0, AY, AZ)); return { base, lid, checks: [ { turns: { body: "lid", about: "x", at: [0, AY, AZ], degrees: [0, -180] }, clearOf: ["base"], atLeast: 0.3 }, ] }; ### SlideCheck ```ts export interface SlideCheck { /** The named body that slides. */ body: string; /** The direction: `"x"`, `"-y"`, `"+z"` or `[x, y, z]`. */ along: PrintAxis | [number, number, number]; /** From and to, mm along `along`, in the order the motion runs; 0 is as drawn. */ mm: [number, number]; } ``` A body slid along a direction, for a `slides` check: a drawer in its frame, a phone into its sleeve, checked over the whole travel. - `along` is the direction, `mm` the travel from the body as drawn (0). - A `.reference()` body may be the one that slides: "does the phone go in" is `slides: { body: "phone", ... }, clearOf: ["sleeve"]`. - The reply's `checks.motions` carries the least clearance and where it is, or where the bodies first meet. @example const frame = box(60, 80, 40).at(0, 0, 20).cut(box(50, 82, 30).at(0, 0, 20)); const drawer = box(49.4, 70, 29.4).at(0, 0, 20).cut(box(45, 66, 26).at(0, 0, 22)); return { frame, drawer, checks: [ { slides: { body: "drawer", along: "y", mm: [0, 60] }, clearOf: ["frame"], atLeast: 0.25 }, ] }; ### Mechanism ```ts export interface Mechanism { /** The drive's range, which every law reads; default [0, 1]. */ drive?: [number, number]; [body: string]: Joint | [number, number] | undefined; } ``` Bodies that move together on one drive, as `mechanism: {...}` beside the bodies: a lever pushing a slider, a rack and its pinion. One slider plays it in the chat. - `drive: [from, to]` is the one input, default [0, 1]. - Each other key is a moving body: `turns: { about, at, degrees }` or `slides: { along, mm }`, as in a turns/slides check without `body`. - A law is `[from, to]`, straight in the drive, or a function `t => value`. - `on: "b"` beside a joint moves the body on b; `{ on: "b" }` alone rides b. 0 is the body as drawn. - Check it with `{ moves: "all", atLeast }` and `{ pushes: [a, b], within }`. @example // a lever on a pin pushes a slider along x as it drops 18° const PIN = [0, 0, 40]; const base = box(90, 30, 4).at(0, 0, 2); const lever = box(6, 10, 30).at(0, 0, 25); const slider = box(20, 10, 8).at(13.1, 0, 14); // how far the lever's lower corner, 3 mm right of the pin and 30 below, has moved in x const push = (t) => { const a = (18 * t * Math.PI) / 180; return 30 * Math.sin(a) + 3 * (Math.cos(a) - 1); }; return { base, lever, slider, mechanism: { lever: { turns: { about: "y", at: PIN, degrees: [0, -18] } }, slider: { slides: { along: "x", mm: push } }, }, checks: [{ moves: "all", atLeast: 0.3 }, { pushes: ["lever", "slider"], within: 0.15 }] }; ### Joint ```ts export interface Joint { /** A turn about an axis through `at`, `degrees` from as drawn. */ turns?: { about: PrintAxis | [number, number, number]; at: [number, number, number]; degrees: Law }; /** A slide along a direction, `mm` from as drawn. */ slides?: { along: PrintAxis | [number, number, number]; mm: Law }; /** The body this one is carried by: its joint moves with that body's. */ on?: string; } ``` How one body of a Mechanism moves with the drive. ### Law `type Law = [number, number] | ((t: number) => number)` A joint's value against the drive: from and to, straight, or a function of the drive. --- # DSL gaps What writing `examples/` ran into, modelling twelve standard mechanical parts (flange, pillow block, motor mount, manifold, tee, pulley, extrusion, V-block, coupler, standoff, heat sink, knob). **§0 is what the language cannot express at all** — parts that were wanted and are absent because no operation produces the shape. **§1 onward is friction**: things that model correctly but cost more than they should. All twelve parts build exactly and are measured in `eval/cases/`, so nothing from §1 on is a blocker. Entries since fixed are kept and marked, with what the fix was: a closed gap is the most useful record when judging whether the next one is real. --- ## 0. Not expressible: the feature is missing **Corrected once already.** This section said a cone was out of reach because `opencascade-sys` binds no `BRepPrimAPI_MakeCone`. Wrong layer: a cone is a revolved triangle, and `BRepPrimAPI_MakeRevol` *is* bound — as are `MakePrism` and `ThruSections`, with `Edge`, `Wire` and `Face` to build a section from. The kernel was never the constraint; our graph was. Before recording something as impossible, check the layer that would implement it, not the layer above. ### Since fixed, by the same route - **a revolved section** — `Op::Revolve`; `cone()` and `countersink()` came with it. - **extrude an authored outline** — `Op::Extrude`, as `extrude(profile, h)` and `ngon(sides, size, h, { across })`. Convex outlines only, the same rule as a revolve section; `hex-prism` checks it against the closed form for a hexagon. - **mirror** — `Op::Mirror`, as `.mirror("x")`. A composition rather than a binding: `gp_Trsf::SetMirror` is bound only for an *axis*, a half turn about a line rather than a reflection in a plane, and the reflection is that half turn composed with a point inversion — both bound, both exact. `mirrored-hand` is a chiral part whose bounding box tells the two apart, because their volumes cannot. - **section / cut-away for inspection** — a view concern rather than a geometry one: `view.rs`'s `Section`, the viewport plane control, and `section` on `evaluate_part`, with the part untouched and every measurement still of the whole solid. OP_ROADMAP §8, docs/PERCEPTION.md §7. - **a helix, and a taper along a sweep** — `pipe({ helix: { radius, pitch, turns, endRadius, hand } }, d, { taper })` and the same path and option on `sweep(profile, …)`. Asked for by a model building a unicorn over MCP, which faked a mane, a tail and a spiral horn out of stacked primitives. The helix is a line in a cylinder's (or, with `endRadius`, a cone's) parameter space, its fitted 3D curve measured within 1e-8 mm of the exact helix and refused past 1e-4; the sweep is `BRepOffsetAPI_MakePipeShell` in Frenet mode, which on a helix is its screw motion. Five cases hold it to closed forms at 1e-6 of BRepGProp — `helical-spring`, `square-coil`, `left-hand-hook`, `tapered-strand`, `spiral-horn` — and `refuse-coil-through-itself` holds the pitch check. The taper is linear in *length* along runs and bends but in *turn angle* on a helix, measured: `spiral-horn` reads 811.62 mm³, where length would give 614.81. A helical groove cut through the cylinder it wraps was recorded here as opening past two turns. That was parcad's seam-pcurve pass damaging the cylinder's side, not the boolean, and a guard added on main for another part had already fixed it when the helix landed (docs/GOTCHAS.md, "A helix cut through its own cylinder"); `helical-groove` holds it to a closed form. - **a screw thread** — `threadedRod(size, length, { clearance, hand, pitch })` and `threadedHole(size, depth, { through, clearance, hand, pitch })`, lowered to `Op::Thread`: the ISO 68-1 basic 60° profile, a size from `METRIC_FASTENERS` (which now carries the ISO 261 coarse pitch) or `{ diameter, pitch }` for anything else of that profile — a 1/4"-20 tripod screw, an M40 × 3 jar neck. The tooth is swept in the axial plane along a helix of one edge per turn, unioned with a core of exactly the swept height and squared off by cutting two boxes; every build is measured against the slab closed form and refused past 2e-5. Five cases hold it — M8 at 3, 8 and 20 turns in both hands, a bolt and nut pair at a 0.2 mm clearance read back as 0.200 between them, and the seeded `examples/screw-top-jar.js`. Not here: rounded roots (ISO's d3), tolerance classes (6g/6H are a clearance here), end chamfers or a thread run-out, tapered pipe threads, and any profile but 60°. `holeFor(size, depth, { tapped: true })` stays the drawing of a hole a machinist will tap. - **several bodies that stay several** — `return { base, lid }`, an object of named shapes in place of one, lowered to a root-only `Op::Bodies`. The cheap end of the multi-body row, and deliberately only that: the bodies are built, measured and exported together and never fused, each is measured alone (`named_bodies`, with its own `pieces` count for the accidental split the part-level `bodies` could not name), and every pair is measured on the exact solids (`between_bodies`: the `check_fit` verdict, clearance and shared volume). STEP writes a solid per body; STL one file, or one body by name. `examples/lidded-box.js` is the seeded one; `split-halves` in the corpus is the two printable halves §9 asked for, held to the closed form of the kerf. Not here, by decision: joints, mates, constraints, assembly hierarchy, instancing across parts — a body sits where its script put it, and the fit between two is measured, not solved. Motion is a *check*, not a placement: `{ turns: { body, about, at, degrees }, clearOf, atLeast }` and `{ slides: { body, along, mm }, ... }` certify a body's clearance over its whole travel on the exact solids, and `catches` with `deeperThan` says a latch bites; the part is still drawn as it prints, and nothing in the graph moves (`Check` in `read_docs dsl`, with the hinged-box recipe under `TurnCheck`). Nothing selects across bodies: a tag lives in the body that made it, and a treatment cannot take the group as its child. - **one part inside another** — `importPart(name, params)` runs a saved part's `part.js` and returns what it returns, and `partParams(defaults)` in that part declares what it takes; an importer passing a key it does not declare, or a value of another type, is refused with the keys it does. Not an op: the imported nodes enter the graph as if written inline, so the kernel and every measurement are unchanged. Each runner hands the DSL a reader — `projects::read` in the sandbox, the host's `read_project` in the window, the script's own folder or `--parts` in `tools/run.ts`. `examples/` seeds `templates/standoff` and `plate-on-standoffs`, which the corpus holds to its closed form. docs/GOTCHAS.md, "An imported part is the saved file", has what it does not do. - **a thin wall lofted through fitted sections** — `loft(sections, { wall: t })` for sections that are each one `{ fit }`: the kernel makes the inside itself, stepped `t` along the outside's surface normal at any lean, on the outside's parameters, and reports the wall it measured between the two skins as `loft_wall_mm`; `bottom`/`top: "closed"` give an end a floor. A lampshade used to be a loft minus a loft of hand-stepped points, which pinched to 0.001 mm between smooth sections and was 1.21 mm thick for 1.6 asked between ruled ones. `walled-cone`, `walled-sleeve`, `walled-bowl`, `walled-dome` and `walled-shallow-shade` hold it to closed forms, the last three at 9–11° off flat. Not here: a wall on corner or arc sections (cut `loft` of `inset`s instead), or a wall whose profile bends tighter than its thickness. What a mainstream tool has that this still does not, with the cost of each here, is in [OP_ROADMAP.md](OP_ROADMAP.md). - **arcs and splines in a section, re-entrant sections, spline paths** — one section type for `extrude`, `revolve`, `loft` and `sweep`: corners, `{ at, round }`, `{ through }` and `{ radius }` arcs, `{ spline }`, `{ bezier }` and `{ bspline }` curves, a loft that ends on `{ z, point }`, and `{ spline }` as a `pipe`/`sweep` path. Resolved in `parcad-core/src/section.rs` into exact arcs and B-spline poles, not polygons; OP_ROADMAP §2 has the prior art, the twelve closed forms and what it did and did not move. A re-entrant section builds, because the refusal's reason ("no exact distance field", then "vertex pairing is a silent guess") went with the implicit kernel and the loft's compatibility pass; `re-entrant-loft` holds the pairing to a frustum's closed form. `examples/hydraulic-line.js` draws its gland as the catalogue section now. - **curves given by a formula, and involute spur gears** — `{ curve: (t) => [x, y], from, to, tolerance }` in any section, and `spurGearOutline({ module, teeth })` built on it. The kernel cannot run a script's function, so the script draws it: cubic Hermite pieces on the function's own points and directions, halved until each is within the tolerance, sent as a C1 B-spline with the bound beside it. Given the exact `derivative` and a bound `fourth` on the fourth derivative the bound is *certified* (the Hermite remainder, √2·m·h⁴/384 a piece); a bare function gets an *estimated* one, read off the function between the pieces, and the report says which as `curve_bound`. The kernel measures the built curve against points of the function the pieces were not drawn through (`deviation_mm`) and refuses a graph whose curve contradicts its bound. Module 2, 20 teeth, 20°: every flank certified within 7.1e-6 mm in eight pieces, read back from the exported STEP 2.9e-6 mm from the analytic involute; the routes it replaces were a `spline` through ten samples (1.8e-3 mm, built, nothing stated), a `fit` through twenty (4.5e-4 mm, `deviation_mm` against the samples only), an arc per flank (0.037 mm) and the pulley's cylindrical grooves (0.8 mm). `spur-gears` holds a meshed pair to the 0.094 mm flank gap 0.1 mm of backlash predicts; `involute-gear` holds a flank to points on the involute and 0.01 mm either side. - **profile-shifted gears, and a pair that meshes** — `profileShift` on `spurGearOutline` moves tip and root out by x·module and thickens the tooth by 2x·module·tan α, which is how a pinion under 17 teeth avoids undercut; the undercut refusal names the least shift, `1 − (z/2)·sin²α`. `spurGearPair({ module, teeth: [z1, z2], profileShift, backlash })` returns both outlines, the centre distance from the working pressure angle (`inv αw = inv α + 2 tan α (x1 + x2)/(z1 + z2)`), the tip shortening that keeps 0.25·module of root clearance, and the turn that faces a space to a tooth — `180/z2` only for an even count, which the old advice got wrong for odd ones. It refuses a pair that jams below a base circle or whose contact ratio is under 1. `shifted-pinion` holds a 12-tooth, x = 0.3 pinion's tip, root, base-circle tangency and reference-circle thickness to the formulas; `shifted-gear-pair` and its turned twin read the 0.050 mm backlash/2 gap at the 42.572 mm centres a shifted 12/30 pair needs. A simulated hob — a basic rack with a 0.38·module tip radius rolled through a blank in 0.25° steps — leaves that pinion's whole flank on its surface to 0.1 µm and the radial line below the base circle up to 0.39 mm inside its material, so the outline keeps nothing a hob removes; unshifted, the same hob cuts the 12-tooth flank away from the base circle up to r = 11.33 mm, which is what the refusal is for. ### Still missing | wanted | needed for | what it takes | |---|---|---| | **thread forms past the basic 60° profile** | a trapezoidal lead screw, a buttress or bottle-cap thread, a tapered pipe thread, a rounded root | `Op::Thread` sweeps one trapezoid; another profile is another tooth and its own closed form, a taper a conical core and helix | | **gear forms past the shifted spur** | an undercut or fillet-rooted hobbed gear, a helical, internal or bevel gear, a rack; a real GT2 flank (`timing-pulley.js` approximates it and says so) | the involute and the profile shift are drawn and certified (above). Below the base circle `spurGearOutline` runs the flank straight in, inside the trochoid fillet a hob leaves (measured), so a strength-critical root is thinner than the part a hob cuts; an undercut gear is refused, because its flank is the trochoid of the rack's tip radius — an offset of an extended involute, certifiable like the involute, meeting the involute at a point found by root-finding. A rack and an internal gear are other `{ curve }` entries with their own closed forms; a helical gear is a twisted loft or sweep of the outline; GT2 is missing its numbers, not a curve type | | **hollowing a part with faces left open** (Fusion's Shell with faces picked) | a bell blended to three tubes, one wall thick, open at every mouth | `shell` shrinks the whole solid, and OpenCASCADE's offset aborts on a blended union with either join (docs/GOTCHAS.md, "A sharp shell"). A turned part hollows with `revolve`'s `wall`, its ends open; a part with features through the wall is its outside minus a cavity built the same way, the tubes `2t` thinner and the seams blended `t` larger, and the mouths come out open because the cavity's parts run past the outside's (the steam top in NEXT.md, "Found recreating the Fusion steam top") | | **draft on a curved or re-entrant outline** | a moulded boss with rounded corners in one op | the drafted top is a half-plane inset, which only a convex polygon has; draft the polygon and fillet its vertical edges | | **variable-radius and unequal-distance treatments** | a casting fillet that tapers, an asymmetric chamfer for a weld prep | `Fillet`/`Chamfer` take one scalar | | **assembly: joints, mates, constraints** | a pillow block *and* its bearing, placed by a fit rather than by coordinates | the bodies exist (above) and the fit between them is measured; nothing yet *places* one against another — a solver, which is the wide reading of NEXT.md §2 | Deliberately absent and staying absent: anything that lets a part be *approximately* right. A "thread" that is a stack of tori, or a cone faked from a scaled cylinder, is the silent approximation the project refuses. ### What twenty-one real Fusion 360 designs actually needed The list above came from parts *we* chose to model, which selects for what the language can already do. Twenty-one Fusion documents from the reference corpus were exported and measured instead; `examples/fusion360/` carries the ones worth recreating, with the measured targets. Designs counted once each, not per use. | feature | designs | parcad has it | |---|---|---| | `Sketch` | 19 | n/a — implicit in our ops | | `Fillet` | 15 | yes | | `ConstructionPlane` | 15 | n/a — no sketch planes | | `Extrude` | 15 | yes | | `CircularPattern` | 9 | yes (`polar`) | | `Combine` | 7 | yes (booleans) | | `Revolve` | 6 | yes | | `Loft` | 6 | yes | | `Mirror` | 6 | yes (`.mirror()`) | | `Sweep` | 6 | yes — a round profile is `pipe()` | | `Sphere` | 5 | yes | | **`SplitBody`** | **5** | **no** | | `Move` | 5 | yes (`.at()`) | | `Shell` | 4 | yes | | **`Thicken`** | **4** | **no** | | **`Form`** (T-spline) | **4** | **no, and should stay no** | | **`Remove`** (delete face) | **4** | **no** | | `Pipe` | 3 | yes (`pipe()`) | | **`Stitch`** (surfaces) | **3** | **no** | **The wall was "parcad only makes analytic surfaces", and it has moved.** 13 of the 21 have NURBS faces; in the worst (`v10`) it is 575 of 585. `Loft` and `Sweep` tied at six designs each, each in more designs than `Revolve`, and that count lifted the hold on both (OP_ROADMAP §3–4). What the counts could not say, and the recreation targets did, is that the ops alone were not the wall: every still-blocked target fails on *spline sketch geometry in the section or path*, which the profile type could not hold — until it could, and one of the four moved (`untitled2-v1`); the probe showed the other three were blocked on a different fit, an ornament and a mismatched export (eval/targets/fusion360/README.md). (UnTriangle v3 looked blocked the same way and was not: its "NURBS" walls probe as ruled patches, and the real obstacle was loft's vertex pairing being silently normalised. Recreated — see `examples/fusion360/README.md`.) `Thicken`, `Stitch` and `Patch` are the surface-modelling side of that wall, out by decision. **15 of the 21 are multi-solid.** Some is assemblies proper and some is construction bodies that never get combined, so the row is softer than 15/21 sounds — but it is not 1-in-20 either. The graph can now hold several solids (`return { base, lid }`, above); what it cannot do is the assembly half, where a body is *placed* by a joint rather than by its own coordinates. In proportion: one author, 21 documents, skewed toward decorative and 3D-printed work rather than the machined fittings `examples/` covers. Evidence about priorities, not a specification, and reproducible from `reference/fusion/*/measurements.json` if that folder is present. ### What `Revolve` cost **`role: "hole"` does not recognise a conical opening.** A countersink rim is an inner boundary of the top face by any reading, and the term drops it, so `cover-plate.js` selects on `curve` and position instead. Same cause as §6. ## 1. No polar array. Every round part rewrites the same loop — **FIXED** `polar(count, radius, { straddle })` and `around(shape, count, axis)` sit beside `grid()` in `dsl.ts`, with unit tests in `dsl.test.ts`. `grid()` gave rectangular patterns only, so a bolt circle was authored arithmetic — `((i + 0.5) / bolts) * Math.PI * 2` — repeated in `flange.js`, `knurled-knob.js` and `timing-pulley.js` with only the count and radius differing. That is where the standards knowledge hid: the `+ 0.5` is what makes ASME bolt holes straddle the centrelines. Two shapes were needed, not one. `polar()` returns *points* for `repeat()`, the way `grid()` does; `around()` spins a whole *shape*, for a feature that is not rotationally symmetric — a T-slot on each face of an extrusion, a flute around a knob — and emits the first copy untransformed rather than a rotation by zero, so it produces exactly the graph the hand-written version did. Worth keeping: those two parts place cutters *exactly* on a surface, and moving from `cos`/`sin` to a rotation changed their face and edge counts (72 faces to 61 on the knob) while leaving volume and area alone. The solid is the same; its B-rep partitioning is not. ## 2. Selecting "the outermost" edges needs one selector per corner `extrusion-2020.js` wants the four outside corners of the profile rounded. `"|Z"` matches **37** edges, because every T-slot contributes edges running along the extrusion too, and there is no way to say "of these, the ones on the outside", so the script enumerates `[">X and >Y", ">X and Y", " [x, 0, z + h])`. - **A DSL export was a reserved word inside a script — FIXED 2026-11-09.** A part now runs inside a block of its own, so `const clearance = 0.6` shadows the table; reading parcad's `clearance` above that line is a ReferenceError the runners explain. What follows is how it was. Scripts ran as `new Function(...names, source)`, so `const hole = cylinder(3, 40)` collides with an export called `hole` and the script fails to parse. Adding `hole()` and `tapDrill()` broke four scripts in this repo at once, which is how the hazard was found: the cutter helper is called `holeFor` for that reason, and `hex-standoff.js` and `clevis.js` take their drill sizes from the table rather than shadowing it. The cost lands on parts already saved in someone's project folder, not only on the ones here. **Fix:** the places that compile a script (`tools/run.ts`, `app/src/engine.ts`, `crates/parcad-host/src/script.rs`) should catch the redeclaration and name the builtin that was shadowed. "`hole` is a parcad builtin — rename your local" is a one-line fix for the reader; "Cannot declare a const variable twice" is not. **Second instance, 2026-09-18.** The coin-holder session (docs/COIN_HOLDER_REVIEW.md, L1) wrote `const clearance = 0.6` on its first build, eleven calls after reading the rule in read_docs `dsl`, and got QuickJS's "invalid redefinition of parameter name", which names nothing; it went to read_docs about an unrelated function and resent the part. The fix above was specified here and never built. **Built, the same day:** `__parcadShadowedBuiltin` in `dsl.ts` proves which name collided by recompiling without each declared builtin (never a regex guess), and all three compilers say "`clearance` is one of the N names parcad puts in every script, so it cannot be declared again; rename it". Measured by `eval/field/which-name-is-taken.md`. **The rule now shapes new features.** The part's own checks (docs/COIN_HOLDER_REVIEW.md, B2) wanted `assert`, `check` or `clear` as a global, and every one would have broken saved parts (`clearance` was already taken by the fastener table). So they added no global at all: a script already returns an object of named bodies, and one key of it, `checks`, is data rather than a body. The only thing lost is a body named `checks`, refused in one sentence. `note()` did become an export, because a reporting call has to be one; the name was chosen for being one no part in `examples/` or the corpus had used as a local. ## 8. One Fusion feature costs a third of a part, because the language has no tool `examples/fusion360/retainer-v1.js`, the strongest evidence here because both sides can be counted. In Fusion the bayonet slot is three ordinary steps: sketch a slot, extrude-cut it, chamfer the mouth. Here it is 49 of the file's 146 lines — 36 for the slot and 13 for its lead-in chamfers, against 36 for the plate, disc, blend, bore, thread and rim chamfer together (39 more are measured constants, 22 the header). And the scaffolding leaks into the constants: `TURN_CX`, `TURN_CY`, `POCKET_X_END` and `INNER_R` are not dimensions of the part, they exist to decompose a shape by hand. **A slot is a swept centreline, not an outline.** The sketch dimensions it the way a machinist would — width 10.20, turn radius R12.00, and where the centreline runs: a cutter of a given diameter following a path, whose round end is the cutter's own end and whose corner radii are what a constant offset leaves. The proof is in the file's own constants, `TURN_R = 12` and `INNER_R = 1.8`, carried as two independent numbers when `12 − 1.8 = 10.2 = SLOT_W`. They were never two dimensions: they are one centreline bend radius of 6.9 with the tool's half-width added outside and subtracted inside, and the relationship was lost on the way in because the language could not say "one bend radius". `pipe(points, diameter, { bend })` is this idea one dimension up, exact for the same reason: a straight run offset by a constant is straight, a circular bend offset by a constant is circular. **A finish is a later op, not part of the cutter.** The lead-ins are 45° reliefs at the slot mouth, chamfered *after* the cut in Fusion; here they are triangular prisms unioned into the cutter, so they must exist *before* it. The cause is §2 and §3 — the selector grammar reaches document extrema, so there is no term for "the two vertical edges at the slot mouth" that does not also name the rest, and that is why the corner radii are baked into cutters too. The mechanisms that would make a part like this fast to write, ordered by how much of the retainer each removes — not a plan, a list to pick from knowingly: - **Say the tool, not the shape.** An end mill of diameter *D* along a path gives round ends, both corner radii and the bend relationship for free; ~36 lines here, and every future keyway, pocket lane and bayonet. `holeFor` is the precedent. - **Let a dimension stay one dimension** — the 12/1.8 split. Structural, not authoring discipline: JS can compute `INNER_R = TURN_R - SLOT_W`, but nothing makes the author do it, and the recreation did not. - **Name a feature, then refer to what it made.** `tag()` names a node; nothing names the edges or faces that node *created*. The general form of §2, §3 and the lead-ins. - **Ops in the order you would perform them** — cut, then finish; currently forced by what can be selected instead. - **Place against geometry, not coordinates.** Every position in the retainer is arithmetic: `.at(R_DISC, PLATE_L, H_DISC / 2)`. - **A shop vocabulary, not a kernel one** — `slot`, `pocket`, `boss`, `rib`, `counterbore`, `keyway`, each one thing a machinist says. - **A plane to work on.** `extrude` takes a 2D outline; siting it is 3D arithmetic. Not asked for: a constraint solver — see OP_ROADMAP, "What is deliberately not on this list". The gap is not that dimensions cannot be related, it is that the *operations* are kernel-shaped rather than shop-shaped, so the author translates before they can start relating them. ## 9. What a laptop holder cost (2026-09-12) A VESA-mounted V tray for a 16" MacBook Pro, built by a model over the CLI in one session: a disc hub with the 75 and 100 patterns, two tapered arms, an L-shaped lip at each front corner carved by cutting the laptop's own shape out of a block, split at the centreline for a 256 mm bed. It builds, measured. The part is `v-holder.parcad` in the project folder of the machine it was made on. What it cost, worst first, each one reproduced on a small shape before being recorded here: - **Selecting edges is the structural problem.** Every failed evaluation but one was a selector reaching more than the intent named, and the language has no way to name less than the whole solid after a boolean. Argued in [SELECTORS.md](SELECTORS.md) rather than here; the short form is that queries need a scope, edges need to know their dihedral angle, and errors need to name the edge. All three landed the same day: `dihedral`, `parallel`, `longerThan`, smooth edges skipped by treatments, failures listing their edges, and then `on` and `between` over faces that keep their tag through booleans, treatments and rigid motions, with `at` measured within the feature. What remains is in [SELECTORS.md](SELECTORS.md) §3. - **`.offset()` of a filleted body was unusable by any later boolean — FIXED.** The natural way to wrap a real object is to grow it by the clearance and cut it out: `holder.cut(laptop.offset(1))`. With a fillet anywhere on the body the cut, and a union, returned "the result has no faces", which blamed the boolean. The offset alone measured right, so the bounding-box post-condition could not see it: the thick-solid builder returns the grown body inside out. The lowering now closes the grown body into a solid and turns it outward (`facing_outward`, docs/VALIDITY_CHECKS.md); `eval/cases/offset-filleted-cutter` holds the pocket against its closed form. GOTCHAS, "`offset_surface` lies". - **A cut that removed nothing was silent — FIXED.** Four VESA 100 holes fell outside a Ø120 hub and vanished; the face count said so one evaluation later. A subtract that makes no edge and takes no face now refuses, naming both extents (`refuse-missed-cut`). - **An empty intersection was an error, not a zero — FIXED, twice.** The refusal now says 0 mm³ with both extents (`refuse-empty-intersection`), and the question it stood in for has its own tool: `check_fit` (MCP), `--fit` (CLI) lays a reference body against the part on the exact solids and reports `clear`, `touching` or `interfering`, the shared volume, and the clearance with the two points it is measured between. The V holder at rest: touching; lifted 0.5 mm: clear by 0.500; sunk 1 mm: 27 653 mm³ shared, which is the floor area the report already gave, times one. A second solid that is measured against and never joined is exactly what this is. - **`sweep` refused every bent sheet — FIXED.** A 200 × 3 strip could not bend at 6 mm because `Op::sweep_spine` measured the profile's reach as the hypot of every point, 100 mm. On a planar path the profile keeps one axis in the plane, so only its extent toward the inside of the bend is in the way: the check now measures that, in the backend's own frame, and falls back to the full reach off-plane. `bent-sheet` measures the strip against Pappus to 4 mm³ in 141 000. - **Every position is arithmetic about geometry that already exists.** The chevron's point is placed by iterating where two arm centrelines must meet for their inner edges to cross at a chosen spot; the cups are placed from a unit vector, its normal, a point on the arm's outer edge and where that edge crosses the laptop's side. About 25 lines of the script derive points that are already on some shape, and two of the session's four authoring mistakes were in those lines. Wanted: points and lines *from* entities — a corner of the laptop, an edge of the arm as a line, `pointAt`, `meet`, `offset` — and placement relative to one; `hull(points)` for a convex outline. The shape-side half of what Fusion does with a sketch on a face, which the reference corpus used in 15 of 21 designs. §8's "place against geometry, not coordinates", with the cost measured. **Landed, the arithmetic half:** `line2d(from, to)` with `pointAt`, `offset`, `meet`, `yAt`/`xAt`, and `hull(points)` for a convex outline that `extrude` accepts by construction. The V holder's fan is now five named points read off two lines, and measures the same 302 406 mm³. **Not landed, and not landable as the language stands:** a line taken *from a built edge*. A script runs before the kernel does, so nothing in it can ask where an edge ended up; `list_entities` and `check_fit` are the measured route, one evaluation later. A sketch on a face would need the graph itself to carry construction geometry, which is OP_ROADMAP's "construction plane" row. - **Real objects are guesses — OPEN, and moved out of the language.** The laptop's plan corner radius and bottom edge radius were assumed at 12 and 5 mm in a script. A `DEVICES` table of four MacBooks and `device(name, { clearance })` sat in `dsl.ts` for a while and were removed on 2026-10-13: objects a part holds are an extension, not the core language, and belong in the parts library (NEXT.md, queue item 4), as saved parts imported with `importPart` (§0). Until that library exists a part draws the object itself and says where its numbers came from. `vesaPattern(size)` stays: it is a published standard. - **Two printable halves need a flag and two evaluations — FIXED**, from the cheap end of the multi-body row of §0: `return { left, right: left.mirror("x") }` is one evaluation, measured per half and between them. `eval/cases/split-halves.json` holds exactly that shape — a bored block split with a 0.5 mm kerf — to the closed form of each half and to the kerf as the measured clearance. Not the tool's fault, and worth keeping: the hub was drawn too small for the 100 pattern, and the report's "stands on … 1 patch" line plus the face count caught it without a picture. Both drafts were checked at both ends of the bounding box before delivery. ## What is genuinely good Worth recording so nobody "fixes" it: - **Shapes as values.** `heat-sink.js` places one fin nine times and the graph holds one node. Nothing had to be said to make that happen. - **`.expect({ count })` earns its keep immediately.** Seven of the twelve parts had a selector matching a different number of edges than intended, and every one failed at the selector with a readable message rather than producing a quietly wrong solid. - **Failures name the fix.** "expected 5 edge(s), but matched 4" plus the full query is enough to act on without opening a viewport. - **Centred primitives placed by `.at()`** made the "bury the boss in the base" workarounds trivial. --- # Gotchas Written for maintainers and coding agents already working in this repository. Things that cost real time. Each one was silent — everything "worked". ## Build & tooling ### OpenCASCADE compiles at `-O0` unless you stop it The single largest performance bug in the project so far. The `cmake` crate resolves `CMAKE_BUILD_TYPE` to `Debug` for `occt-sys` *even under `cargo build --release`*: the Rust half is optimised, the geometry kernel underneath it is not, and nothing says so. Enclosure build time was 203 ms; it should be 43. Fixed by `cmake/occt-toolchain.cmake`, wired in via `CMAKE_TOOLCHAIN_FILE` in `.cargo/config.toml`. Two traps inside the fix: - **`CXXFLAGS=-O2` does nothing.** cmake-rs's `skip_arg` strips every `-O`/`/O`/ `-g` argument from forwarded compiler flags *on purpose*. A toolchain file is the one hook it passes through untouched. - **We force `-O2` inside the Debug configuration rather than switching to Release.** OCCT's Release config defines `-DNo_Exception`, which turns `Standard_Failure` raises into no-ops — it converts our catchable geometry errors into undefined behaviour. Never enable it. `tools/build-worker.sh` greps the generated `flags.make` and warns loudly if the optimisation didn't take. Trust that check, not the config file. Measured (median of 7): | | `-O0` | `-O2` | `-O3` | |---|---|---|---| | enclosure build / mesh | 203 / 22 ms | 43 / 4 ms | 44 / 4 ms | | bracket build / mesh | 197 / 181 ms | 39 / 28 ms | 39 / 28 ms | | process floor | 62 ms | 11 ms | 11 ms | | worker binary | 34.3 MB | 25.5 MB | 26.7 MB | `-O2` is chosen: identical speed to `-O3`, 1.2 MB smaller. All 11 regression cases produced byte-identical geometry after the change. ### `cargo test --workspace` compiled OpenCASCADE a second time A cold `tools/check.sh` built OCCT twice, in two `occt-sys-*` directories with different hashes — 7 GB and five minutes each. Same features; different *unit*. `occt-sys` reaches the worker build only as a `[build-dependencies]` entry of `opencascade` and `opencascade-sys`, compiled for the host under the build-override profile; `--workspace` also selects it as a member in its own right, a target-side unit whose build script is the whole OpenCASCADE compile. The crate has no tests, so the gate now passes `--exclude occt-sys`. A crate that is *both* a member and somebody's build-dependency gets two build-script runs whenever both are selected, and `cargo tree -e features` will not show the difference because there is none. ### A missing `capabilities/` rebuilt the app on every single build `cargo build` with nothing changed took 16 s, every time. Not compilation: `tauri_build::build()` emits a `rerun-if-changed` for `app/src-tauri/capabilities`, and cargo treats a `rerun-if-changed` on a *missing* path as permanently stale. That re-ran the `bun build` of the DSL bundle, which invalidated `parcad-app` and everything downstream. The directory now exists and holds exactly one capability, `session.json`, which lets the main window listen for the live-session broadcast; anything further would grant permissions the app does not need. A no-op build is 0.2 s. Cargo will not tell you this; ask it: ```bash CARGO_LOG=cargo::core::compiler::fingerprint=info cargo build --release 2>&1 | grep dirty ``` ### Run the app from `app/`, not the repo root `bunx tauri dev` at the repo root fetches `tauri` from npm and dies building `sharp`/`vips`. And **don't** use `bunx --bun tauri dev` — that fails with "could not determine executable to run for package tauri". ```bash cd app && bun install --frozen-lockfile && bun run tauri dev ``` ### `cargo build --release` builds a *dev* app: the window is blank, the browser is fine A binary from a plain `cargo build --locked --release -p parcad-app`, launched directly, brings up the HTTP host and a desktop window whose webview never runs the frontend — no evaluation, no `preview.png`, no session pushed. A browser tab against the *same process* works perfectly. That asymmetry reads as a broken webview and is nothing of the kind. **Tauri's dev/production switch is a Cargo feature, not the cargo profile.** From `tauri-macros`: ```rust dev: cfg!(not(feature = "custom-protocol")), ``` The tauri CLI adds `custom-protocol` to the `cargo build` it runs; nothing else does. Without it the binary is a *dev* binary regardless of `--release`, and two things follow, both from `tauri-codegen`'s `context.rs`: - `dev && dev_url.is_some()` embeds `EmbeddedAssets::default()` — i.e. **nothing**. The window loads `devUrl` (`http://localhost:1420`), where nothing is listening. - `with_config_parent(...)` bakes in the source dir, so the asset resolver falls back to reading `app/dist` **off disk**. That is the only reason the HTTP host serves a UI at all — and it means such a binary is not relocatable. Measured on `747ed26b`, scratch `PARCAD_PROJECTS_DIR`, bare launch, 15 s: | binary | window runs frontend | `/api/session` | `preview.png` | size | |---|---|---|---|---| | `cargo build --release` | no | `revision: 0` | 0 | 13.84 MB | | …same, with vite up on 1420 | **yes** | `bracket` | 1 | — | | `cargo build --release --features tauri/custom-protocol` | **yes** | `bracket` | 1 | 15.07 MB | | `tauri build` bundle (`ParCAD.app`) | **yes** | `bracket` | 1 | 14.86 MB | The +1.2 MB is the frontend the dev binary does not carry. Hiding `app/dist` makes the dev binary's own HTTP host answer `404 … no frontend bundle is embedded in this binary` — the same fact from the other side. Two dead ends, eliminated by measurement: it is not the `.app` bundle or window activation (the tauri-built binary works inside a hand-made minimal `.app`, the cargo-built one fails inside the real `ParCAD.app`, and `NSRunningApplication.activate()` returns `true` and changes nothing), and not release-vs-debug (`tauri build --debug --no-bundle` works because the *CLI* builds it; a plain debug `cargo build` fails the same way). `warn_if_the_window_awaits_a_dev_server` in `lib.rs` now says so at startup. The WebKit log misleads: the page load *completes*, then `view visibility state changed 1 -> 0` throttles the web process to background — the consequence of loading a dead URL, not the cause. ### A second app instance silently has no browser UI The UI port is held by whichever instance bound it first. The second one prints why and keeps its desktop window working — but a browser tab is then talking to the *first* instance's kernel. Read the app's stderr; it names the fix: ```bash PARCAD_HTTP_PORT=4243 cargo run -p parcad-app ``` A browser that loses the host mid-session shows "the parcad desktop process is not answering" and keeps the last good geometry on screen. That is the app having exited, not a failed evaluation — `tauri dev` restarting on a rebuild is the usual cause. ### The MCP server is one host, however the client reaches it There is one host per machine on port 4242. The desktop app, `parcad serve` or `parcad mcp` provides it. `parcad mcp` is the stdio server a client launches, and it is a relay, not a second host: it forwards to the host that is already running, and only when none is running does it host one itself, for as long as the client stays connected. That is the default route (the plugin, the MCP bundle, `claude mcp add parcad -- parcad mcp`) and needs nothing running beforehand. A client that connects by URL cannot start anything, so it only connects while some host is up — `brew services start parcad` keeps one up from login: ```bash claude mcp add --transport http parcad http://127.0.0.1:4242/mcp ``` Whichever process got 4242 first owns the session. Open the desktop app while a relay or the service holds the port, and the window works but its HTTP host fails to bind — the agent is then editing the other process, not the window. Quit one of them. A `.mcp.json` in a workspace needs that workspace **explicitly** trusted, and a workspace under an already-trusted parent inherits trust without the dialog ever firing — so it can never *become* explicitly trusted, and the server stays `⏸ Pending approval` forever. Not parcad's bug, and it reads exactly like one; the fix is `hasTrustDialogAccepted: true` for that path in `~/.claude.json`. ### An imported part is the saved file `importPart(name)` reads the part's `part.js` from the parts folder each time the importer builds, so an unsaved edit to the template in another tab is not what imports see; save it. Four things follow from the imported nodes being ordinary nodes of the importer's graph: - **Thumbnails go stale.** A thumbnail is keyed to the importer's own source, so editing a template redraws its importers only when they are next saved or the look changes. The sandbox's script cache does check every imported source, so a build is never stale; only the picture is. - **Copies share tags.** Two imports of one template carry its tags twice, and `on: "standoff"` in the importer reaches both. Inside each copy a selector only sees that copy. - **`.tag()` on an import replaces the template's own outer tag**, as it does on any shape. Tag a copy to tell it from another; its inner tags stay. - **The window builds twice when a script imports a part it has not read.** A script runs synchronously and the host answers asynchronously, so the first run stops at the new name, the window fetches it and runs again. The next build reads every known part before it starts. ### `cargo build` needs bun, because the sandbox embeds the DSL `build.rs` shells out to `bun build` to compile `app/src/dsl.ts` into the script sandbox. Not a new requirement — the frontend already needed bun — but the failure now happens during `cargo build` rather than at `bun run tauri dev`. The panic names the fix. ### A script's budget is counted, not timed The sandbox used to stop a script after 5 s of wall clock. That is a determinism bug: a script that took 4.0 s idle passed 9 of 10 runs with the machine at load 7 and 0 of 10 under twenty `yes` processes, and the lamp that motivated it (2.5 s idle) failed 2 of 3 under the same load. A debug build tripped it on scripts that finish in microseconds. `script.rs` now counts QuickJS's own interrupt polls. The interpreter decrements a per-context counter on every call and every loop back-jump (and every 10 000 regex backtracking steps) and polls the handler when it reaches zero, so one poll is 10 000 steps and the same bytecode on the same input polls the same number of times. The same near-limit script then passed 10 of 10 idle and 10 of 10 under the hog; one just over the budget failed 10 of 10 both ways. What the count does *not* see is arithmetic between calls, so steps per second varies by workload: about 110 M/s for a bare `Math.sqrt` loop, 26 M/s for the lamp's reaction-diffusion body, on an M-series machine. The default 600 M steps is therefore 5 s of the one and 23 s of the other. The clock backstop (120 s per budget multiple, at most 600 s) exists for a script heavier per step than both; it is the one limit left that load can move. A native call that never polls cannot be stopped by either — the 64 MB cap is what bounds those. The budget belongs to the source, `scriptBudget(n)`, rather than to a call's `timeout_s`: a saved part is also built by the CLI, the picker's thumbnail and `check_fit`, none of which a caller passes a number to. **Native helpers must match their JavaScript to the bit.** The reaction-diffusion lamp spent 75% of its 2.5 s in its field loop and 18% in segment-crossing tests, behind a spatial grid it had to write itself. `simulateReactionDiffusion`, `outlineCrossings` and `outlineGaps` now run in Rust in the sandbox (`generative.rs`) and as JavaScript everywhere else (`dsl.ts`), and the editor and MCP must build the same part from one script. So both sides do the same float operations in the same order, use `sqrt` where a script would reach for `hypot`, and `native_helpers_give_the_same_bits_as_their_javascript` compares them. The lamp rewritten on them builds the same graph under `bun tools/run.ts` as in the sandbox. Native work is charged to the budget before it runs, one step per cell update or pair test, because a native call cannot be interrupted, and a charge past the budget is refused even if the script catches the throw. **A script's result is kept by its source, unless it read the clock.** Every MCP tool that takes a script runs it first, so an export after an evaluation, or a second evaluation asking for views, paid the lamp's 2.5 s again (a repeated `evaluate_part` took 2.95 s with the kernel build already reused; now 0.34 s, and the export after it 4.6 s to 1.5 s). `script.rs` now keeps each result by its exact source. That is sound only because nothing but the source decides the answer: the realm is empty, the budget is counted, and the three things in it that differ between runs — `Math.random`, which QuickJS seeds from the clock, `Date` and `performance` — are wrapped to mark the run, and a marked run is never kept. A budget refusal is kept (the count is a fact of the source); a clock-backstop refusal is not. A part that wants randomness should seed its own generator, which is what every generative part here already does. **A graph's numbers can land one ulp off on the way into Rust.** `serde_json` without its `float_roundtrip` feature parses some shortest-form doubles to a neighbour. Both routes (webview IPC and sandbox) parse the same strings, so they agree with each other, but not always with the script: comparing a sandbox graph with the JSON `bun tools/run.ts` printed shows last-digit differences until both are parsed by serde. QuickJS and V8 agree on `Math.sin`, `cos`, `atan2`, `hypot`, `sqrt`, `pow`, `exp` and `log` to the bit on macOS. ### `cargo build -p parcad-occt` does not build the worker Without `--features kernel` you get only the host half and the binary is skipped entirely. Use `tools/build-worker.sh`. ### A Tauri sidecar is declared, staged and installed under three names `externalBin` in `tauri.conf.json` names `binaries/parcad-occt-worker`. The file on disk has to be `binaries/parcad-occt-worker-aarch64-apple-darwin` or a production build fails outright — that part is loud. What is quiet is the third name: the macOS bundler strips the triple again and writes `parcad.app/Contents/MacOS/parcad-occt-worker`. Nothing documents that as a promise, and it is not the same on every target. So `host::worker_path()` accepts both names, and `tools/build-worker.sh` writes the staging copy — 28 MB, gitignored — every time it builds. Skip the script and `tauri build` either fails or, worse on a stale tree, bundles last week's kernel. Test a bundle from outside the build tree and with the dev environment removed, or the thing being measured is your shell: ```bash cp -R target/release/bundle/macos/parcad.app /tmp/ && cd /tmp env -u PARCAD_OCCT_WORKER PARCAD_HTTP_PORT=4299 /tmp/parcad.app/Contents/MacOS/parcad-app ``` ### tauri-build copies the sidecar beside every app it builds Its build step (`copy_binaries`, tauri-build 2.6.3) deletes `target//parcad-occt-worker` and copies the staged worker there each time the app's build script runs, and refuses to build when the staged file is missing. Both halves bit, measured on 2026-09-17: - **A fresh clone could not build the app at all.** In a new worktree, `tools/check.sh` and a plain `cargo build` stopped at parcad-app's build script: "resource path `binaries/parcad-occt-worker-aarch64-apple-darwin` doesn't exist". Only `tools/build-worker.sh --release` writes that file, which means compiling OpenCASCADE before a build documented not to need it. CI runs `--fast`, which never builds the app, and a working checkout keeps a staged copy from some earlier build, so nothing noticed. - **Where it did exist, a debug app got the release worker.** After `tools/build-worker.sh`, then `--release`, then `cargo build -p parcad-app`, `target/debug/parcad-occt-worker` was the release one, and the debug host beside it refused it: "this worker was built with the `release` cargo profile and the host with `debug`". Every restage also reran the build script and recompiled the app. `app/src-tauri/build.rs` now removes `externalBin` from the configuration tauri-build reads — a `TAURI_CONFIG` merge patch, kept on top of any the caller set — when `DEP_TAURI_DEV` is `true`: tauri's `cargo:dev`, a build without `custom-protocol`, which is every `cargo build`, `cargo test` and `tauri dev`. Those builds need no staged worker and leave the one beside them alone, and a restage rebuilds nothing (0.34 s). A production build keeps it: `tauri build`, and `tauri build --debug` in `tools/test-desktop.sh`, still refuse a missing sidecar by name and still copy the release worker into their target directory, which is why test-desktop.sh runs `tools/build-worker.sh` after its build. A fresh worktree then passed the whole gate: `tools/check.sh` green in 486 s, with `PARCAD_OCCT_PREBUILT` set and the two `bun install`s it names, which it now asks for before it builds anything rather than after the release build. `bun run tauri build` still bundles a working kernel — the bundled `parcad` measured a 10 mm cube at 1000 mm³ through the sidecar beside it, 18 KB larger than `target/release`'s because the bundler signs it. ### The AppImage's launcher could be run only by root Tauri's bundler (2.11.4) downloads `AppRun-x86_64` into `~/.cache/tauri` with mode 0770 (`write_and_make_executable`), copies it into the AppDir keeping that mode, and the GTK plugin renames it `AppRun.wrapped`. Inside the squashfs every file is root's, so the file is executable by root alone. A double-click never noticed: the AppImage's own FUSE mount does not enforce modes. Anything that mounts the image itself does — `firejail --appimage`, a loop mount, an install that root extracts — and there an ordinary user gets `AppRun.wrapped: Permission denied`. AppImageHub's test runs under firejail, which is how it surfaced; 0.0.11 shipped that way. The bundler only downloads when the file is missing, so release.yml puts a hash-checked 0755 copy there first, and "Collect the bundles" reads every mode in the built image with `unsquashfs -ll` and fails the build on one that only its owner can read or run. The guard is the part that matters: if a new bundler renames or moves the file, the seed silently stops applying and the guard says so. Reproduced in Docker (amd64 under emulation, the image's magic bytes 8–10 zeroed or qemu refuses it): on a root loop mount, 0.0.12 as built gave exit 126, and the same image repacked with 0755 reached `parcad-app`. ### zsh aborts a command on an unmatched glob `rm -rf target/release/build/occt-sys-* target/debug/build/occt-sys-*` dies on the second (already-deleted) glob *before running anything*. Use: ```bash find target -maxdepth 3 -name "occt-sys-*" -type d -exec rm -rf {} + ``` ### The WebAssembly kernel: four ways a build links something else All met building `web/build-kernel.sh`; each produced a binary, not an error. - **A rebuilt OpenCASCADE is not relinked.** `opencascade-sys` asks for `static=TK…`, and rustc copies every one of those `.a` files into the crate's own rlib when it compiles it. Replace the libraries and cargo sees nothing changed: an instrumented `libTKMesh.a` that `strings` showed printing never printed, because the worker still linked the copy inside the rlib. The script `cargo clean -p opencascade-sys` after every OCCT build. The same holds for a native `PARCAD_OCCT_PREBUILT` whose contents change under an unchanged path. - **ninja keeps objects when a source goes back in time.** Staging with `rsync -a` restores upstream mtimes, so a file rewritten to *older* contents looks older than its object and is not recompiled. Staging is by checksum with fresh mtimes for that reason. - **setjmp inside a Wasm-EH `try` is invalid wasm.** OCCT defines `OCC_CONVERT_SIGNALS` on every non-MSVC build, which puts a `setjmp` in each `OCC_CATCH_SIGNALS` block; clang then emitted a function V8 rejects at instantiation ("br_table: label arity inconsistent", `ShapeUpgrade_ShapeDivide::Perform`) and binaryen could not parse. The define only matters once `OSD::SetSignal` installs a handler, which nothing in parcad calls, and wasm has no signals; `web/occt-emscripten.cmake` undefines it. - **`--bin` filters every `-p`.** `cargo build -p a --bin x -p b` builds no binary of `b`; name each. ## Geometry ### A planar wall meshed two ways `re-entrant-loft`'s five side walls are planar B-spline patches. BRepMesh's deflection control measures the midpoint of each wall's diagonal against the segment (0.8–2.6 mm in-plane, over the 0.01 mm deflection) and asks to insert it — a point exactly on the link it would split. Native arm64 clang's Delaunay insertion takes it and produces no new triangle, so the next pass inserts nothing and every wall stays two triangles (634 in all). The WebAssembly build splits the link and refines each wall to 266–450 (2240). Measured with the same prints in both builds: pass 1 leaves 2 elements natively and 4 in wasm, the first diverging number. Volume (5250.000), area, size, faces and edges agree exactly, which is why the case holds the triangle count to 260% rather than either build's number. Across the whole corpus the WebAssembly build moves only tessellation-derived numbers — at most 1.7e-4 relative in volume or area, 0.002 mm in size, 0.54% in bed contact and 9.8% in triangles elsewhere — and no face or edge count; web/README.md has the table. The request itself was the defect: a planar wall's parametric diagonal midpoint is off the diagonal in its own plane, which the deflection check read as deflection. Vendor patch 0003 (see "A curve that starts slowly meshed in ten times the triangles") samples the foot of the diagonal's own midpoint, which on a plane is the midpoint, so nothing is asked for: natively the walls are two triangles each and the part 20. The WebAssembly build should now agree, since there is no insertion left to round, but it has not been rebuilt to check: if it still refines, `re-entrant-loft` goes red there, which is the measurement still owed. ### The `left` and `right` views were mirrored A boss standing off a part's +Y face drew at column 94 of 128 in the `left` view, where it belongs at 34. `View::rotation` hands back the three screen axes as model directions, and for both side views that triple had determinant −1: a reflection, not a camera. Every other view was right, which is why nothing noticed — a mirrored picture of a symmetric part is the same picture, and every part in `examples/` is symmetric about at least one of these planes. One sign on each of the two, and `every_view_says_which_way_it_looks` now asserts the determinant. Nothing else about handedness was wrong — the mesh, the measurements and the exports were never involved, and `Op::Mirror` is a different thing — but an agent reading a side view of a handed part got it backwards, which is a defect no amount of looking harder at the render would have caught. ### A section cap with a line through it An M10 bolt and nut cut on Y and drawn iso showed a one-pixel dark line down the bolt's axis while the bolt measured one intact piece. Not the tessellation and not the second body: 138 uncapped samples, every one on column 768 of the 1536-sample buffer, which is the model's x = −y line — and in iso that line is where the nut's vertical corner edge at (8.5, −8.5) projects *exactly*, as does any rod whose tessellation puts a vertex at 315°. A sample on an edge two triangles share passed neither triangle's f32 barycentric test (−5e-8 on both sides), so its ray lost one crossing, and the parity that decides cut face flipped all the way down. Sixteen more sat on thread flanks within half a voxel of the plane, where the clip compared the *rounded* depth and counted a crossing on the wrong side. The rasteriser now snaps corners to 1/256 px and evaluates edge functions in integers with the top-left rule, the rule Direct3D and OpenGL rasterisers use so that a sample on a shared edge belongs to exactly one triangle; the clip reads the unrounded depth. Parity is only as good as the crossing count, and a symmetric view puts real edges on the sample grid far more often than "an exact float coincidence" suggested. `eval/cases/section-m10-bolt-and-nut.json` holds the rod's core and the nut's section fully capped, and `a_section_cap_has_no_line_where_an_edge_lies_on_the_sample_grid` holds the rod alone at four sizes that drew the line before. ### A part can be five bodies and pass every check `examples/extrusion-2020.js` was in the corpus for months as one watertight part with the right volume and 37 edges, and it was five bars: its four T-slot chambers overlap at the corners, which severs the core from each corner block. Nothing measured caught it because nothing measured *counted* — watertightness is per edge, volume adds, and the face count of five closed pieces is a plausible number. `bodies` in `MeshStats` (connected components of the mesh, joined by vertex position) is the number that does, and `stands_on` found it first by way of the end face being five patches. Both are recorded for every case now. A number in the reply was still not a verdict: on 2026-10-13 a model exported a plate stand whose through-slots had cut the base into nine strips, with `bodies: 9` and `print_check: passed` in the same reply, and called it a watertight solid. `print_check` now fails one returned shape in several pieces, listing each piece's size and place, and fails any piece that starts above its body's bed; a named body in several pieces is flagged, since naming is how a script says it means several. ### A solid can cross itself, or face inward, and pass every check `BRepCheck_Analyzer` judges each face against its own boundary; it does not ask whether two faces of a solid run through each other, or which way the solid faces. The mesh backstops do not either: a self-crossing surface still closes, and an inside-out mesh encloses the same negative volume as its inside-out solid, so the two volumes agree. Measured on shapes that built and passed all of it: a sweep whose last leg crossed its first (the overlap counted twice in its volume); a star lofted to itself three corners on (volume −3382 mm³); a tray's bottom edges chamfered deeper than its walls, the chamfer faces running through the pocket; rounds on either side of a 1 mm gap crossing each other; a 0.4 mm blend whose corner patch folds over; and, at integration, a walled smooth loft that probed a point 300 mm outside it as material. `BOPAlgo_CheckerSI` finds the crossings and a point classified against each shell finds the orientation; docs/VALIDITY_CHECKS.md has where each runs, what each costs, and the fuzz sets. The first thing to suspect about a new construction that turns surfaces into a solid is both of these, not closure. ### A fillet that fails can still change the shape it was given `BRepFilletAPI_MakeFillet` widens tolerances on the vertices and edges it is handed, in place, and a `TopoDS_Shape` is a handle: the input, every clone of it, every earlier probe and any cached subtree sharing those vertices see the change. Measured on two crossing cylinders: after a 1.5 mm blend that built and was refused, a vertex of the untouched union sat at 42 mm tolerance, and the self-intersection check then read 130 vertex contacts on a sound 1.13 mm blend. Treatments now build on a topology copy (vendor/opencascade PARCAD-CHANGES.md). Booleans alter their arguments too, but by at most 4.7e-7 mm over the corpus. ### A shell of anything treated or combined was refused, blaming the author `BRepOffsetAPI_MakeThickSolid` hands back the inward offset of a filleted, chamfered or combined solid as a bare closed shell, not a solid; subtracting a shell removes nothing, and the part was refused as "the operations cancelled all the material away". 37 of 80 fuzzed shells hit it — every one a plain, buildable part. The cavity is now closed into a solid and turned outward, and the result is held to part volume minus cavity volume; `shell-filleted-box` and `shell-of-a-union` hold it to closed forms. ### A correct solid can mesh as a closed fragment of itself A model building a unicorn over MCP (2026-09-14) reported that "unions lost the body" while the report said built and watertight. Reproduced, the union is not the defect: ```js union(sphere(10), sphere(10).rotate("x", 90)) ``` The fuse returns **4188.79 mm³ exactly** (`BRepGProp`), already one face, and `BRepCheck` calls it valid. `BRepMesh` then triangulated that face as 198 triangles enclosing **727.70 mm³**, a 10 × 20 × 20 closed fragment: watertight, one body. Every number the report shows is read off the mesh, as are the preview and the STL, so all of them described the sliver; only a STEP export was right. The common factor is an operand unioned with a rotated or mirrored copy of itself, so both share one curved surface with different parameterisations — which a figurine does every time a copy lands on the original. It is three defects, each in a representation rather than the geometry: | union with its copy | before | cause | after | |---|---|---|---| | `sphere(10)` turned 90° about X, or also moved | closed fragment, 727.70 mm³ | copy's seam left on the face as an INTERNAL wire; BRepMesh meshes only the region it cuts off | 4188.79 mm³ B-rep, mesh 4182.71 mm³ — plain `sphere(10)`'s | | `sphere(10)` turned 37° or 89° about X | open mesh (4 / 2 edges) | same | same | | `cylinder(5,20)` turned 45° about Z | open mesh, B-rep 523.60 mm³ | `Shape_drop_unused_seam_pcurves` dropped a seam pcurve the other half of the split side still used — pcurves are per surface, not per face | 1570.80 mm³, 3 faces, mesh = plain cylinder's | | `torus(10,2)` turned 30° about Z | "a mesh with no vertices" | `UnifySameDomain` welds the halves into one face with no wires; BRepMesh skips it | 789.57 mm³ B-rep, mesh 786.57 | The fixes are in the vendored crates (see their PARCAD-CHANGES.md): after every unify, `parcad_tidy_faces` drops INTERNAL edges and rebuilds a wireless face with its natural bounds, and the seam pass keeps a seam another face on the same surface borders. `AllowInternalEdges(false)` does not strip existing internal edges; it only stops the unifier making new ones. Measured over sphere, cylinder, torus and cone, each unioned with itself turned 30/45/89/90/120/180° about X, Y and Z and mirrored in each plane: all 84 build and pass both backstops. In the corpus only `tangent-blend` moved — two internal imprint lines on its side walls gone, 50 edges to 48, volume unchanged. `ShapeFix_Shape` also rebounds the torus but leaves the sphere's internal wire; `ShapeUpgrade_ShapeDivideClosed` makes the sphere BRepCheck-invalid; a finer angular deflection meshes the same fragment. The worker keeps three backstops behind this: a mesh that does not close; a face the mesher left untriangulated; and a closed mesh whose enclosed volume differs from the solid's by more than `2 · area · deflection`, the most a tessellation within that deflection can account for. Each names the fix for a cause not yet repaired: overlap the operands by 0.01 mm instead of letting a copy coincide. `eval/cases/coincident-*-union*.json` hold the four rows down. Two guards were tried first and taken out because the defect never reached them: a union volume floor (result ≥ larger operand) and a volume check across `UnifySameDomain`. Both read the exact B-rep, which was never wrong. ### A closed chain of 2D curves concatenates starting at its last piece A part of 2026-09-18 put sixteen `sphere(1)` beads on a `sphere(8)` at a bolt circle of 7.4 mm and would not build: 116 of its mesh edges bordered one face. It reads like the section above, and it is not — those are an operand fused with a *copy of itself*. Here the boolean was exact every time: | stage | faces | volume | BRepCheck | |---|---|---|---| | `sphere(8)` | 1 | 2144.6606 | valid | | `sphere(1)` | 1 | 4.1888 | valid | | after `BRepAlgoAPI_Fuse` | 3 | **2145.1397** | valid | | after `clean()` | 2 | **2140.7807** | `BRepCheck_NotConnected`; the bead's face unorientable | When a bead's kept cap straddles the bead's own seam (u = 0), the fuse returns the cap as two faces sharing that seam, and the intersection circle as two arcs meeting where the seam crosses it. The unify's face pass welds the cap into one face and drops the seam, correctly. That leaves the arcs meeting at vertices of degree two, so the edge pass merges them into one closed circle — and the new edge's pcurves sit **1.233 mm off its 3D curve at every parameter**, on both faces: the chord of 1.673 rad, the first arc's span. `ShapeFix_Face` then rebuilds the bead's face around a degenerate edge at its pole, and the bead is gone. The shift is `Geom2dConvert::ConcatC1`. It joins each piece to the group so far with `Geom2dConvert_CompCurveToBSplineCurve::Add(curve, tolerance)`, whose `After` argument defaults to `false`. `Add` reads it only when the new piece meets both ends of the group — a chain that closes — and there the default prepends the last piece, so the concatenation starts one arc late. `UnionPCurves` reparametrises that curve against a 3D circle that starts at the first arc. The 3D twin, `GeomConvert::ConcatC1`, already passes `true` at the same step; `vendor/occt-sys/patches/0004-concat-closed-chain-appends.patch` makes the 2D copy do the same. Called directly, `ConcatC1` returns identical poles before and after for every open chain tried, so nothing else moves. What each suspect was cleared by, since each looked right at some point: - the mesher and the backstop — the B-rep was invalid and short of volume before anything meshed it; - ParCAD's seam-pcurve pre-pass and `parcad_tidy_faces` — skipping either changes nothing; - the loosened merge tolerances — 1e-4 through 1e-9 break identically; - the order of the unify's two passes. Edges before faces builds this part, because the edge pass then runs while the seam still splits the circle — but it leaves unmerged every edge a face merge would have exposed, and five corpus cases gained edges. A third edge pass restores them and breaks the bead again. Ordering only moves the defect; - `UnionPCurves`' projection fallback and `ShapeFix_Face` — each is correct when handed a correctly parametrised edge. The window is narrow and closed form, which is why this arrived as "unions are broken" rather than as a seam question. The bead's kept cap has half-angle arccos(0.557) = 56.2°, so its seam cuts the circle only below that; below 5.96° the big sphere's seam cuts it too, the arcs meet a third edge, and they never merge. Swept a degree at a time, one bead failed from 6° to 52°. `polar(4, …)` puts every bead on ±X or ±Y and builds; `polar(16, …)` does not. `eval/cases/beads-on-a-sphere.json` holds it. **The failures were the loud end of it.** From 53° to 56° the first arc is short enough that `ShapeFix_Face` lets the shift through: the part builds, BRepCheck passes and the volume is right, while the merged edge's pcurves sit 0.759, 0.641, 0.481 and 0.186 mm off its 3D circle. No backstop reads that and no corpus number moves with it; it is measured with the edge's own curves, the surface at its pcurve against its 3D curve. ### A seam cut the rim of a bead in two OpenCASCADE gives every closed surface a seam — a sphere's runs pole to pole along +X — and a seam has to end on whatever boundary it crosses. Fuse a ball onto a sphere on +X and the sphere's seam ends on the ball's rim, putting two vertices on it: the rim is two kernel edges. Nothing is wrong with the solid. But ParCAD already hid the seam itself, so what a person saw was one bead in a ring of fifteen with its rim halved, and a selector over the rims matched 16, so `.expect({ count: 15 })` failed on a part with fifteen beads. Moving the seam only moves the split: the big sphere turned 12° about Z put it between the beads and onto the fillets' circles instead, 21 faces and 27 edges against 19 and 20. The representation needs its seam somewhere, so the fix is in what ParCAD counts, not in the solid. `Shape::logical_edges` groups the kernel's edges into the part's: seams, splits between faces of one surface and degenerate poles are no edge, and at a vertex that only such an edge also reaches, two pieces of one curve are one edge. The viewer and every selector read the same grouping, so they cannot disagree; a treatment still receives every kernel piece. "One curve" is exact — the same `Geom_Curve`, or equal lines or circles — and two different curves meeting there stay apart. What moved: the drawn-curve count of 16 corpus cases, each join checked to be two pieces of one circle at a seam or split (pipe and tube rims, a cylinder section drawn as two arcs, arcs across +X); one recorded `expect({ count: 2 })` that had been counting a tube rim's two halves, now 1; and no volume, face, kernel edge or validity anywhere, and every seed part still builds. One count was missed until 2026-10-13: `edges` beside a treatment in a reply counted the kernel pieces the builder receives, so the beads' rims read 17 while `.expect` counts 16, and pasting the reply's number failed the build. It is now the selector's own logical count (`eval/cases/fillet-counts-a-split-rim-once.json`). ### A closed fillet ring was reported as crossing itself at its seam The v0.0.14 release corpus failed on Windows alone: `fillet-counts-a-split-rim-once`, the sixteen beads with every rim rounded by 0.2 mm, refused as "its surface runs into itself near (7.449, 2.859, 0.808) (a face crosses itself)", with 0.1 mm measured to fail as well and 0.09 to build. macOS built it — and the same macOS build refuses the same part at 0.1 mm (near (6.022, 5.279, 0.613)) and at 0.15 mm (near (7.432, 2.840, 0.854)), while building it at 0.05–0.09, 0.11–0.14 and 0.16–0.5. A round that cannot fit at 0.15 and fits at 0.14 and 0.16 is not a round that does not fit. Each round is one rational B-spline face all the way round a bead (degree 2 across the section, 10 along it, 3 × 47–62 poles), closed by a seam edge where its spine starts. The surface closes to 1e-9 mm there, and a 0.002 mm mesh of a flagged ring has no two triangles crossing. The point named is on the 22.5° or 45° bead's ring, 9–19 µm from its seam, where that bead's own seam meets its rim. `IntTools_FaceFace` of a face with itself runs `IntPatch_PrmPrmIntersection`'s one-surface walk, seeded by the self-interference of a polyhedron over the surface, which excludes triangle pairs sharing a node and nothing else. The triangles on the two sides of the seam share no node — the closure is topology the polyhedron never sees — so at some radii they seed a walk, and every point walked from them has either both preimages on the seam (v₁ = v_max, v₂ = v_min, u₁ = u₂: one point of the face) or one preimage outside the face, 2–17 µm past v_max or u_max, where the B-spline extended beyond its end lands on its own start. Stock `BOPAlgo_CheckerSI` reports the same face, so this is OpenCASCADE's check and not the fresh-face subclass; the closed analytic surfaces it skips by type — cylinder, cone, sphere, the ring torus — would meet themselves at their seams the same way. Which radius trips it is the last bits of the seeds and steps, which differ between arm64 clang and x86_64 MSVC: the 0.15 of one is the 0.2 of the other. `ParcadFaceSelfIntersection` (vendor/opencascade-sys `wrapper.hxx`) now reads the verdict off the walked lines: a face crosses itself where some walked point has both preimages inside the face (`IntTools_Context::IsPointInOnFace`) and not both within tolerance of one of its seam edges — the checker's own rule, an interference other than through a shared sub-shape, applied to the one sub-shape the polyhedron cannot see. A face the stock check passes still passes; the folded corner of `refuse-folded-blend-corner` is still refused, its crossing being interior. `eval/cases/fillet-ring-closes-on-itself.json` holds the 0.15 mm part, and `a_fillet_ring_closed_by_a_seam_does_not_cross_itself` all three radii. ### Delabella, OCCT's other triangulator, meshes an extruded spline open `IMeshTools_Parameters::MeshAlgo`, or the `CSF_MeshAlgo=delabella` environment variable the default factory reads, swaps OpenCASCADE's Watson triangulator for Delabella. It is faster — on ParCAD web's planter, 199k triangles over 37 revolved bumps, the mesher went 1.27 s to 0.94 s and the whole part 3.29 to 2.86 (M4 Max, release); in WebAssembly it saved 6% of the mesh time — and it returns meshes that do not close. Two lines are enough: ```js return extrude([[-10, 0], [10, 0], { bezier: [[0, 20]] }], 3); ``` 82 of that shape's 240 mesh edges border one face instead of two, which the worker's first backstop refuses; a `{ spline: … }` section reads 98 of 288. The same shapes mesh watertight under the default. The failure follows the *surface*, not curves in general: a box, a cylinder, a sphere, an `extrude` whose curve is a circular arc, a `revolve` with a spline section, and a `loft` between those very Bézier outlines all pass. What fails is a straight extrusion of a B-spline, and the planar caps that curve bounds. Two corpus cases hold it down — `parabola-bezier` and `curve-edges-by-kind` — and two more drift past tolerance under it: `probe-port-meets-gallery` reads a 5 mm wall as 5.003, and `thread-m8-3-turns` gains 6.2% triangles. Delabella is not OCCT's default and its factory chooses a different algorithm per surface type, so this is a less-travelled path; measured on OCCT 8.0, 2026-09-16, and not reported upstream (their tracker's only Delabella item is an unrelated pointer-arithmetic fix from February 2026). The symptom is all that has been observed — free edges counted by our own check — not a dump of which face loses its triangles. `MinSize` was measured at the same time and is not worth having either: at 0.05 mm it removes 1% of the triangles and no time, because the count is set by real curvature, not by slivers. ### A helix cut through its own cylinder opened past two turns *(fixed before it was diagnosed)* A groove swept along a helix and cut from a cylinder on the same axis, at the helix's radius, was recorded in the helix commit (`32feba99`) as building and closing at one and two turns and not at three: | `cylinder(3, 10).cut(pipe({ helix: { radius: 3, pitch: 2, turns } }, 1))` | at `32feba99` | on main since `a5f1ccda` | |---|---|---| | 1 or 2 turns | watertight | watertight, 275.82 / 268.90 mm³ | | 3, 4 or 12 turns | 8 mesh edges border one face; refused | watertight; 3 turns 261.98 mm³ = closed form | | a V groove, 8 turns | 261 open edges (117 in the original script); refused | watertight | | a V ridge unioned onto a core, 8 turns | 1361 open edges (1043 in the original); refused | watertight | **The boolean was never wrong, and neither was the mesher.** The defect was parcad's own `Shape_drop_unused_seam_pcurves`, the pre-pass `clean()` runs before `UnifySameDomain`. A helical groove splits the cylinder's side into strips — one more per turn — that all lie on one `Geom_CylindricalSurface`, and each strip's wire uses its piece of the seam line once. The pass read "used once" as a stale seam and dropped one of the edge's two pcurves; but pcurves are stored per surface, not per face, and the neighbouring strip was using the one it dropped. Measured stage by stage in a C++ probe mirroring the pipeline at 3 turns: | stage | B-rep volume | BRepCheck | open mesh edges | |---|---|---|---| | `BRepAlgoAPI_Cut` | 261.9828 mm³ (closed form 261.982900) | valid | 0 | | seam pass, unguarded | 205.7516 | `UnorientableShape` ×2 | 8 | | seam pass, guarded | 261.9828, 0 pcurves dropped | valid | 0 | Serial or parallel, one `Build()` or two, the same. The fix is the guard `a5f1ccda` added for a different part — a cylinder unioned with its own rotated copy, GOTCHAS "A correct solid can mesh as a closed fragment of itself" — which keeps a seam another face on the same surface borders. The helix branch was cut from main before that commit and measured there; nobody re-measured after the merge. Confirmed in the real pipeline: the helix commit's own worker opens by exactly the recorded counts, and the same tree with only that 15-line guard applied closes every row with main's numbers. `eval/cases/helical-groove.json` holds the 3-turn cut to its closed form. At one and two turns the unguarded pass dropped pcurves too; in the pipeline the result still meshed and exported right (STEP 268.903 mm³ at two turns), which is why the table's first row passed. ### Threads: which construction measures right `threadedRod` and `threadedHole` build an ISO 68-1 basic-profile thread as `Op::Thread`. What was measured before choosing, on an M8 × 1.25 and then on every coarse size M2 to M20 at 1 to 20 turns, both hands, against the slab closed form `V = π r1² L + 2π L / P · ∫ r w(r) dr`: | construction | from | result | |---|---|---| | tooth in the axial plane swept (MakePipeShell, Frenet) along a **one-edge** helix; core ∪ tooth; ends cut by two boxes | parcad's `sweep` | valid and closed; 206 of 220 within 1e-5, the rest 1–3e-5 — the fitted spine | | the same along a helix of **one edge per turn** | FreeCAD `makeLongHelix` + PartDesign Hole | 220 of 220 within 1e-5 (the rods about 1e-6, the tooth alone 8e-7 of Pappus); bolt and nut pairs 36 of 36 | | the same, end faces sewn on (FreeCAD's exact route) rather than `MakeSolid` | FreeCAD | the rods the same; the tooth alone 4e-9, below anything the boolean after it keeps, so parcad keeps `MakeSolid` | | four ruled faces between helices, two planar caps, sewn | cq_warehouse `Thread` | rods right, but the tooth alone read +25% / −12% of Pappus while `BRepCheck` passed it: rejected | | `ThruSections` (ruled) between two closed wires on coaxial cylinders | OCCT MakeBottle tutorial | invalid solids of wrong, some negative, volume at 3, 8 and 20 turns; the tutorial's two-turn ellipse is not a 60° thread, and it never fuses its thread either | | one 11-section loft per turn, fused | bd_warehouse `Thread` | valid, closed, and short everywhere — the tooth 1.1e-4, the rod 8e-5: a loft approximates the helicoid | And two ways to finish the chosen construction that return **valid, closed, wrong** solids with no warning beyond `BOPAlgo_AlertFaceBuilderUnusedEdges`: | finish | result | |---|---| | core **shorter** than the swept tooth, so the tooth overhangs the core's ends | 94 of 132 rods 16–100% short, some empty | | ends squared by a **common with a cylinder** rather than by cutting boxes | fails the same way on the overhanging form; on the chosen form with a one-edge spine, 5 of 132 fail once the cylinders' seams are turned 37°. A box has no seam | So `build_thread` in `backend.rs` sweeps along one edge per turn, gives the core exactly the swept height, and cuts two boxes; and because the wrong answers above were all valid and closed, it measures every thread it builds against the closed form and refuses one more than 2e-5 off. Two things follow for authors: the tooth crosses +X at z = 0 of the thread's own frame, so a rod and a hole mate only a whole number of pitches apart (or turned by 360° × offset / pitch) — `between_bodies` reads a pair out of phase as interfering — and a clearance `c` on each part is `c` across the flanks, shortened by the lead angle to `c (2/√3) / √(4/3 + (P / 2πr)²)`. ### The volume integral misreads a wavy B-spline wall `Shape::signed_volume` — `BRepGProp::VolumeProperties` with the adaptive `Eps` — is the reference the mesh backstop compares every mesh with, and on a wall extruded from a wiggly B-spline it is the number that is wrong. Measured on a 120-point circle of radius 20 with ±0.15 mm of noise on every point, extruded 10 (the true disc is 12566 mm³), against the area the curve's own dense samples enclose by Green's theorem: | curve through the points | samples × 10 | mesh at 0.01 mm | B-rep, eps 1e-7 | B-rep, eps 1e-10 | |---|---|---|---|---| | `{ fit }` at 0.2 mm, 35 poles | 12568.9 | 12564.1 | 12666.6 | within the allowance | | `{ spline }` (interpolating), 123 poles | — | 12571.3 | 12828.3 | 11947.7 | The mesh sits where a chord tessellation should, 0.04 % under; the integral is 0.8 % high on the fit and 2 % high on the interpolant, and *5 % low* on the same interpolant when asked for more precision — not converging, moving. Nor is waviness needed: a 36-point cam fitted to 19 poles at 0.05 mm and extruded 8 read 13045 mm³ against a mesh of 12662 and samples of 12666, and the field suite's first model spent eleven refused calls on it. The surface is the culprit, not the curve — `MakePrism` sweeps a B-spline edge into a `Geom_SurfaceOfLinearExtrusion`, and the same cam as a ruled loft between the section at its two heights (a `Geom_BSplineSurface` of the same shape) reads 12663.5. So `Op::Extrude` now sweeps every curved outline as that ruled loft and keeps `MakePrism` for polygons; the three curved extrusions in the corpus kept their volumes to the recorded digits and mesh in a fifth of the triangles. `BRepGProp::SurfaceProperties` has the same trouble one dimension down: on the planar face a lamp section's fitted curve bounds (131 poles) it read 8488 mm² where the curve's samples enclose 8835, and the inset guard built on it refused a good inset for "growing". Faces from arcs, planes and smooth fits of few poles integrate to the last digit, which is why the corpus never saw it; the backstop's refusal on such a part is a false alarm that reads as a missing surface, and its message now says so. That was the fixed-order rule: the adaptive one a reply's area has been read from since 2026-10-13 reads a 331-pole fitted cap to a millionth of Green's theorem over its samples. Extrusions no longer make the surface it misreads, and the inset guard measures both its areas by Green's theorem over dense samples of the wires instead. A sweep along a straight path still can. Fit the curve at a looser tolerance, or smooth the points, and the integral settles. `eps` stays 1e-7. ### A curve that starts slowly meshed in ten times the triangles *(fixed)* The mesher judged a face in its parameters, so how fast a curve ran through its own parameter showed up in the triangle count even when the shape was the same. One arc of radius 20 over 2.5 rad, extruded 10: drawn with `{ curve }` in its angle, 240 triangles; with angle `t²/2.5`, so it starts at rest, 1030. The involute is the case that matters: its speed in the roll angle is `rb·t`, zero on the base circle — intrinsic, because its curvature is infinite there and a polynomial can only follow that by stopping — so a certified 20-tooth gear meshed in 20300 triangles against 1916 for a `fit` through the same flanks, and its mesh took 182 ms against 117. Two assumptions in BRepMesh, both about B-spline faces, and `vendor/occt-sys/patches/0003-mesh-deflection-at-the-foot.patch` changes both: - **The deflection check sampled a link at its parametric middle.** On the ruled flank wall a link running up the wall has its parametric middle `rb·dt²/8` along the wall from its own middle — 0.014 mm on one knot span of a module 2 gear, over the 0.01 mm deflection — so every diagonal "failed", was split, and the new diagonals failed the same way: eight passes and 450 triangles a flank. The sample is now the foot of the perpendicular from the link's (or triangle's) own middle. Measured over the whole corpus that is also the more *correct* check: the parametric middle had let parts mesh outside 0.01 mm (the helical groove to 0.011, a helical spring to 0.017), and the parts that gained triangles are those, now all nearer their surfaces and seven of them inside the deflection. - **A knot where the normal cannot be computed added a row of nodes.** The flank's zero-speed edge is such a place, and it sits on the face's boundary, where no node is ever inserted; the row it added at v = 0.5 doubled every flank. Only knots strictly inside a face count now. The node inserted for a failing element is the sample itself — the surface point that failed. Inserting it at the parametric middle with the sample at the foot was tried, and left every twisted or helical part measured outside its deflection (`untriangle-v3` 0.0096 → 0.0166 mm worst; the split no longer removed the deviation it was made for, and the passes ran out). ### A twisted wall's straight edge was one link *(fixed)* `untriangle-v3` built on macOS and Linux arm64 and was refused on Linux x86_64 with two of 38616 mesh edges bordering one face. Not the compiler. BRepMesh discretises an edge by its own curve, so a straight ruling of a twisted wall stays one boundary link however far the face's normal turns along it, while every interior link is held to `AngleInterior` (1 rad). The triangle standing on the ruling then always has a side to the far corner spanning the whole turn; the interior splits that side at its middle each pass, the new node is the next apex, half as near the ruling, and only the 11-pass cap ends it. On x86_64 the two walls sharing a ruling ended their chains 0.0008 mm from it, and the 0.001 mm weld fused the pair into one vertex with four triangles on each of its edges; arm64's rounding stopped a pass short and they stayed apart. A MinSize floor against frontier links was tried and measured inert: the chain runs through the angular check, which never asks it. `vendor/occt-sys/patches/0005-split-edges-where- the-normal-turns.patch` splits a boundary segment where the normal turns more than `AngleInterior`, a node both faces share by construction, and a Delaunay triangle cannot then reach from an apex near the ruling past its neighbours on it. `crates/parcad-occt/examples/mesh_deviation.rs` is how "the mesh got coarser" and "the mesh got worse" were told apart. After: that arc 252 triangles, the certified gear 1996 in 120 ms (the fit, 1916 in 116), the seeded gear pair 36428 → 5452 and 415 → 302 ms. The whole corpus, read back from STEP and meshed both ways, lost 12.6 % of its triangles and 11 % of its mesh time, and no part's worst sampled distance from its surface grew. `involute-gear` holds the count. Neither the knot scale, double knots, nor the extrusion was the cause, as was once checked. ### A sideways inset of a leaning wall is thinner than the inset Stepping a section inward by `t` in its own plane makes a wall `t · cos φ` thick, square to a surface leaning `φ` from vertical. The fitted lamp shade built as outer sections minus sections stepped in by 1.6 mm measured 1.211 mm at its thinnest in `measure_wall_thickness` (kind `wall`, not a rim artifact), and a horizontal inset of one sloped section measured 1.387 mm. `loft(..., { wall })` steps by `t / cos φ` from the built outside, so its `loft_wall_mm` is the wall square to the surface. It now steps along the outside's surface normal rather than sideways, with the height solved so floors and rims stay level: the sideways step needed `t / cos φ`, which refused anything within 14° of flat, and a bowl or a dome was impossible to wall. ### `BRepLib::OrientClosedSolid` can reverse a solid that was right It classifies the point at infinity by one line along a face's normal and trusts the transition at its farthest crossing. A walled pleated shade (`walled-twisted-pleats`: 288 points, 21 sections, a 35° twist) came out of the sewing facing the right way; the classifier answered IN, the solid was reversed, and the part was all of space except the shade — while `mass_properties` reported `volume.abs()` and every check passed. The same script with 25 sections and a 70° twist got OUT. Nothing about the geometry is wrong; the line through dozens of walls 1.2 mm apart on large B-spline bands misses a crossing. The skinner states which way is out and checks the face, so a skinned loft is never classified. Everything else that turns surfaces into a solid is classified per shell (`facing_outward`, a point outside the box against each shell on its own — the same kind of ray, so it can misfire the same way), and behind all of it the mesh backstop in `serve.rs` checks every closed mesh shell's winding against its nesting, which a turned solid cannot pass. Built by the fitter that came after, the same shade faces the right way even under the old classifier, and no lighter variant of it (48 tried) flips; the case is kept for the backstop, which turns any recurrence red. ### Uniform knots over uneven points leave spans empty A least-squares fit on uniform knots needs every span to hold points (Schoenberg–Whitney). A pleated section sampled at equal steps along its base star, then pushed out and in, had chord steps from 0.76 to 3.31 mm: on 256 uniform spans its fit plateaued at 0.15 mm, and on 511 — nearly one span per point — it was still 0.163 mm off, because the long chords had spans with no point in them and the matrix was only nominally invertible. Knots placed by the parameters (same points per span) fit the same section to 0.0007 mm on 508 spans. `PeriodicFit` refuses a factorisation whose pivots span more than twelve orders of magnitude. That section's 0.15 mm plateau is its own: at `z = 0` near (67, 27) its points zigzag, turning ±1.4 mm every 0.77 mm, a staircase the pleat growth left behind. Held to 0.05 mm the outside must follow it, and a 0.8 mm wall's inside loops there at every refinement — refused, naming the place. At 0.25 mm the fit smooths it and the wall holds. ### `Edge::fit`'s closed seam is only G1, and can loop `AppParCurves_TangencyPoint` fixes the *direction* of the tangent at each end of the fit, not its size: `AppParCurves_LeastSquare` solves the two end magnitudes (`lambda1`, `lambda2`) freely along with the poles. So the closed `{ fit }` a section resolves to meets itself with one tangent direction and two speeds, and where the least squares wants the curve slow — the inside of a lamp at a star tip, where the offset turns with a radius of 0.4 mm — one magnitude goes to nearly zero and the curve cusps and loops by 0.01 mm at its start, at every span count up to the interpolation limit. The walled loft found it and does not use `Edge::fit`: its sections are fitted periodic (`parcad_core::skin::PeriodicFit`), with no seam at all. A closed `{ fit }` section in an extrude, a revolve or anywhere else no longer does either: `section_wire` fits it as a one-section skinned loft (`skinned::fit_closed`) — parameters corrected to follow the curve, knots following the parameters, the fewest spans that hold, C2 through where the points start — and measures the deviation again on the edge OCCT holds. The six-lobed outline of `fit-six-lobes` (180 points) measured: | tolerance | `Edge::fit` | periodic fit | |---|---|---| | 0.6 | 35 poles, 0.594 mm | 22 poles, 0.436 mm | | 0.1 | 131 poles, 0.042 mm | 27 poles, 0.067 mm | | 0.05 | 131 poles, 0.042 mm | 49 poles, 0.044 mm | | 0.02 | refused | 57 poles, 0.017 mm | `Edge::fit` still makes the open fits between two corners, whose ends are the corners and have no seam. Both keep a fit to four fewer free poles than points: at the limit the loft's fitter used to allow, one fewer, a circle with 0.3 mm of noise "held" 0.01 mm on 122 poles for 120 points, which is interpolation. ### One large B-spline face meshes far slower than the same surface in bands The fitted lamp as one smooth outer face and one inner (4 faces) spent 102 s in the loft's bounding-box tessellation at 0.01 mm; the same two surfaces cut into a face per stretch between its 15 sections (30 faces) took 18.7 s, and the mesh after it 1 s. `ThruSections`' single smooth face in the older lamp took 166 s. Splitting further, in `u` as well, did not help measurably (16 to 19 s under load) on that surface's few spans; on 512 it does (below). So a skinned loft is always banded at its sections, and the edges between bands are `dihedral: "smooth"`. Bands cut from one `Geom_BSplineSurface` do not stay bands, though: `ShapeUpgrade_UnifySameDomain` counts two faces on the same surface *handle* as one domain, so the cleanup after any boolean welded them back into one face per skin. A lamp of two ruled 41-section lofts and a cut came out with 4 faces and took 78 s (35 s of it tessellating those faces) where `ThruSections` had taken 17.6 s with 82. Each band is now its own segment of the surface (`Geom_BSplineSurface::CheckAndSegment`, exact knot insertion), which unify leaves apart; a whole-surface copy per band would too, but segments are smaller and the same lamp built in 6.3 s against 10.6 s with copies. The segments change only where the mesher puts triangles — it splits a face at its own knots — so the fitted-frustum case meshes in 20422 triangles instead of 14842, within the same 0.01 mm. The mesher also ran one face at a time: the binding built `BRepMesh_IncrementalMesh` with `isInParallel` off, and a sample of the worker showed OCCT's thread pool idle while it meshed eighty independent bands. It is on now (`Mesher::new`, `Shape::write_stl`); every corpus case other than the two skinned ones meshes to the identical triangle count, volume and area, and the corpus's summed mesh-and-build wall time fell from 47 s to 29 s. ### A fitted curve is wider than its points A `{ fit }` section's box was taken as its points' box plus its tolerance, and a loft was refused when the solid reached past it. But a smooth curve peaks between its samples: the star r = 30 + 12 cos 5a sampled at 60 points reaches y = ±39.944 at the points and ±40.201 on the curve, and every loft of it was refused as "bulging 0.20–0.27 mm outside its sections' own extent" — shifted pairings and the unshifted one alike — while the fit was right. The check now compares the solid with the curves as fitted (`BSpline::extent`, closed form on each span of a cubic; a knot is a station, since a turn exactly on one solves to the span's end). The graph's `framing_bounds` still use points plus tolerance, since no curve exists before the kernel runs; the reported bounds are measured. The same lofts logged a ruled facet sag of 2.3–2.7 mm where two sections make the ruled loft the smooth one. `Surface::distance_near` descended only from the nearest point of a coarse grid, and on a wall twisted seven points round another sheet passes nearer a grid point than the sheet the point is on; it now also descends from the point it is given (`fitted-loft-shifted-star`). ### The volume integral misreads a thickened pleat A pleated shade thickened to 1.4 mm read 160 731 mm³ through `Shape::signed_volume` (adaptive, `eps` 1e-7), 153 800 through the fixed-order default, and its mesh 165 584; Gauss–Kronrod over every knot span (`BRepGProp::VolumePropertiesGK`, spans on) converged on 165 729.6 — the mesh's number less its chord — but took 25 s at `eps` 1e-3, still 1.8 % off, and 538 s at 1e-7. A deeper pleat read 15 % low. A closed form settles which number is wrong. A surface lofted through one pleated curve at three heights is a cylinder over the curve, with no Gaussian curvature, so a wall centred on it and closed along its normals holds exactly t × L × h, L the curve's length (half the surface's free edge length). For 24 pleats 4 mm deep round 60 mm, 200 mm tall, 1.4 mm thick (`a_thickened_pleat_reports_the_volume_its_closed_form_gives` in `surfaces.rs`, the `thickened-pleat-cylinder` case): | reading | mm³ | off | |---|---|---| | t × L × h | 155 888.9 | — | | Gauss–Kronrod span by span, `eps` 1e-4 | 155 896.8 | +0.005 % | | the mesh at 0.01 mm (what the part reports) | 155 981.4 | +0.06 %, inside its chord bound | | `signed_volume`, adaptive whole-face | 556 182.5 | +257 % | | `BRepGProp::VolumeProperties`, fixed order | −170 703 | wrong sign | So the whole-face integral cannot arbitrate a mesh on these walls, and the mesh backstop, which compares the two, refused good shades. It now asks, before refusing, the question the volume stands in for: does every face's triangulation cover the face? In each face's parameter plane the triangles' area must equal the area the face's own mesh boundary — the polygon every edge was discretised into — encloses, to rounding (`Shape::uncovered_faces`); a mesh of part of a face falls short. The nodes of a triangulation lie on their surface, so a mesh that covers every face bounds the solid to within its chord, and on these walls the part's reported volume is the mesh's, never the integral's (see "A part's volume is the B-rep's, except on derived surfaces" below). Only when some face fails does the old verdict stand. Anything else that needs a B-spline solid's volume integrates span by span (`Shape::volume_by_spans`); `stitchSurfaces` does. ### A part's volume is the B-rep's, except on derived surfaces A reply reported the mesh's volume, area and centroid, which sit a chord's sag inside every curved face: sixteen beads read 2148.23 mm³ against an exact 2152.326, 0.19 % short, and a 1 mm tube 0.53 %. They are now `BRepGProp`'s, integrated adaptively over the exact surfaces (`Shape::volume_properties`, to 1e-7; `Shape::surface_properties`, to 1e-5), on every body whose faces all lie on a plane, cylinder, cone, sphere, torus or B-spline surface, and whose integral agrees with its mesh to the backstop's allowance. The line was drawn by measuring, not assumed (`examples/volume_survey.rs`, 169 corpus and seed parts that build). On the 162 parts with no other kind of face the integral and the mesh differ by a median 0.009 % and at most 0.54 %, always in the direction a chord errs, and every case with a closed form now records that closed form to its printed digits — the torus, the cone, the parabola's 400.000, the 848.230016 of a spline pipe. The seven parts with a face on an offset, an extrusion or revolution of a curve, or a Bezier patch (`Shape::has_derived_surfaces`) are where the integral fails: the thickened pleat 72 % low, the pleated shade 18.7 % high, a pleated cut 4.6 % low, while four others agree. Those keep the mesh's figures, and the reply says so in `volume_note`. Area follows the same line: the face listing's areas use the fixed-order rule and read a spline hook's wall 5.8 % off, so the part's area is the adaptive integral's, which read 1194.3795 mm² for a helix whose tube formula gives 1194.380. What it costs: the volume integral was already paid by the mesh backstop. The area integral adds 4–100 ms to the mesh phase of an ordinary part, and 0.76 s to `fitted-ring`, whose 187-pole inset wall the volume integral already takes 0.78 s over; below 1e-3 the bound changes nothing in that time, and at 1e-3 OCCT appears to fall back to the fixed-order rule. ### A skinned surface in whole bands meshes in minutes and unifies in seconds per face A shade skinned on 512 knot spans, cut only at its sections, took 470 s to mesh at 0.01 mm; in pieces of 32 spans along u, 91 s under load, and 23 s with the mesher's faces on every core (`BRepMesh_IncrementalMesh`'s parallel flag, which meshes the same triangles). The pieces must be *segments* of the surface, not faces trimmed from one: `ShapeUpgrade_UnifySameDomain` asks `GeomLib_IsPlanarSurface` of every neighbouring pair, which samples the face's whole underlying surface — `8 + 3 × intervals` points each way — and an offset of a 512-span surface is that for every one of 672 faces: 43 s of a boolean that changed nothing, 0.5 s once each face had its own segment. The same unify welds faces that *share* one surface back into one face. ### A thickened fold meshed open A deeply pleated shade skinned on 1024 spans, its fold tips rounded to 1.5 mm and thickened 1.4 mm, built, measured 1.4 mm everywhere and passed the kernel's checker, and its mesh had 2094 open edges among 18.6 million. The tips were rounded on the script's *points*; the curve fitted through them turns tighter between points than the points do. On a closed form — 24 pleats round 60 mm, the fitted curve's tightest turn found by dense sampling — 6 mm pleats turn at 0.740 mm (0.857 mm on the function sampled), so half of a 1.4 mm wall leaves the inside skin turning at 0.04 mm. Its surface moves at 5 % of the speed of the surface it was offset from, and `BRepMesh` left holes inside five of its faces, 0.4 to 5 % of each face's parameter area (19 % with a finer angular deflection). Measured by pleat depth: | pleats | tightest turn | offset × curvature | mesh | |---|---|---|---| | 5 mm | 0.912 mm | 0.77 | closed | | 5.5 mm | 0.821 mm | 0.85 | closed | | 5.8 mm | 0.770 mm | 0.91 | open in two faces | | 6 mm | 0.740 mm | 0.95 | open in five faces | Two faults let it through. `thicken`'s fold check sampled a 6 × 6 grid over each face, and a face of 32 knot spans hides every pleat tip between those points; and it refused only past 0.98. It now searches each face from four points in every continuous stretch of its surface each way, climbs the extremes to 1e-9 of the face's parameters (`Shape::bend_extremes`), and refuses past 0.88, naming the radius, where it is, and the rounding that would pass. ### `thicken` measures its wall on the part's own mesh Reading the wall at 9360 points of a pleated shade by `Extrema_ExtPS` — a sample grid rebuilt per point on each nearby offset B-spline face — took 12.5 s native and 18 s under WebAssembly, three quarters of the build. The thickened solid is now meshed first, with the same `Mesher` the part's report uses, so `NearestBoundary` seeds its Newton steps from triangles; and every face the later cut leaves alone keeps that triangulation, which the final `BRepMesh_IncrementalMesh` reuses rather than repeats. The readings are the same exact distances (1.40000 to 1.40000 mm). What is left is the mesher itself: 546 offset faces at 0.01 mm are about 28 s single-threaded in a tab (3 s on every native core), and no parameter that keeps the deflection bound makes that smaller. Bounding them was the other half: `AddOptimal` on an offset face runs a particle-swarm search per coordinate, 4.3 s for a shade's tag extent. Only the faces whose enclosing box reaches past what the tag's mesh nodes already reach are optimised now, which gives the identical box (checked bit for bit on the corpus's tagged parts) from a few dozen faces. ### A thickened surface's rim leans `thicken` closes the wall at a free edge with a face along the surface's normals there, so the rim of a shade whose wall leans is not flat: a flared shade stood 0.155 mm below its lowest section on a line, and `stands_on` read 0 mm². A print bed needs the flat ring, which is a cut: a slab off each end, `shade.thicken(t).cut(slab.at(0, 0, rim - 10), ...)`. ### `clearance` is a word a lamp script reaches for It is the fastener table's function, so `const clearance = wall + 0.8` does not parse. Every export is a reserved word; name a local for what it is, `closest`. Since 2026-09-18 the refusal names the word — the engine's own said `Cannot declare a const variable twice`, or under QuickJS `invalid redefinition of parameter name`, and a coin-holder session read that as a mystery — proved by compiling the script without each name (`__parcadShadowedBuiltins` in `dsl.ts`, docs/DSL_GAPS.md §7). ### `offset_surface` lies It returns valid-looking wrong answers rather than failing: - On a union it silently **drops bodies** — a 44×24×34 part came back 20×20×34. `clean()` does not help. - `offset_surface(+3)` can return an **inside-out** solid; the next offset then runs backwards (74×49×32 instead of 66×41×24). - On **any body with a fillet on it** it returns that inside-out solid every time: right size, right shape, every face pointing in, and a later cut or union reads it as all of space minus the part — `box(50,30,20).edges("|Z") .fillet(5).offset(1)` cut *nothing* out of a block until this was caught. The bounding box cannot see it. `Shape::signed_volume` (`BRepGProp`) can: it comes back negative, and `facing_outward` in `backend.rs` finds and turns it (docs/VALIDITY_CHECKS.md); `ShapeFix` does not. Feed the builder the solid, not the compound a treatment wraps it in — `single_solid()` first — or the offset of a compound is a compound and the reorientation passes it through untouched. This is why every offset carries a bounding-box post-condition *and* a sign check. Don't remove either. ### A filleted box is a `Compound`, not a `Solid` `BRepFilletAPI_MakeFillet` returns a compound holding one solid. A subsequent boolean against it **succeeds and produces nothing** — the error surfaces much later as "the result has no faces". Unwrap with `Shape::single_solid()`. ### `Rotate` and `Scale` don't commute with translation accumulation `backend.rs` peels translations off children to accumulate offsets. Rotation and scale must therefore build their child **at the origin** and translate afterwards. ### The reported tolerance is not the one you asked for `Request::deflection` is **advisory**. The vendored bindings hard-code 0.01 mm in `Mesher::new`. `Success::deflection_mm` reports what the mesher *actually* used — report that, never the request. (We once claimed 0.050 mm quality while meshing at 0.010.) ### A blended union of face-touching solids is refused — it used to kill the kernel Two solids that meet *exactly* on a plane — a hub standing on a flange face, a gusset landing on a plate — cannot be blended at any radius: the seam has no corner for a fillet to roll along, and OCCT raises instead of building. That raise used to escape the bridge and take the worker with it (`SIGABRT`, blamed on the radius); the fillet boundary now catches it, and the refusal names the tangency, the overlap workaround, and — probed, not guessed — that no smaller radius built either. Overlap the solids: bury the hub a few millimetres into the plate, or blend two solids and union the third on unblended. `examples/flange.js` and `examples/motor-mount.js` carry the workaround with a comment; docs/DSL_GAPS.md §4 has the full table of shapes that trigger it. `eval/cases/refuse-tangent-blend-union.json` pins the refusal, keyed on the sentence our tangency detection writes, so it also goes red if an OCCT upgrade rewords the raise that detection reads. ### A boss that pokes out of the far face gives the blend a second seam A boss placed on the plate's *underside* plane rather than its top passes through and stands out of the far face by exactly the depth it was meant to be buried: the union then has two seams, the intended one on top and one round a stub underneath, and a fillet cannot reach further along the stub than the stub goes. That is the real ceiling behind a table this entry once carried — 1.88 mm on a 2 mm stub, 3.88 mm on 4 mm — and it reads convincingly as "the blend must be smaller than the overlap". It is not: with the boss ending *inside* the plate there is one seam and the radius does not depend on the depth at all, a Ø12 cone buried 0.5, 1 or 2 mm into a 6 mm plate taking a 4 mm blend every time. The tell was the bounding box: the part measured 45.08 mm tall and its lowest z was −5.5, and nobody read the low end because the height matched a plausible sum — read both ends. And a refusal that names a radius on a seam you did not intend is telling you about the seam, not the radius. `examples/plate-stand.js` carried the stubs for one commit; its pegs are now on the top face and buried 3 mm. ### A blend that ends on a face it is tangent to — fixed, and worth knowing anyway The sibling of the case above, and the origin of a vendored kernel patch. A boss standing on a plate exactly as wide as itself puts the blend's end against a face it touches without crossing; stock OpenCASCADE returns `IsDone() == true` and a solid that will not close. Found rebuilding a Fusion 360 part (`reference/retainer.js`, gitignored with the export it was measured against): **22 open edges**, and it looked right in the viewport, exported and measured. **The trigger is tangency, not the curved seam and not the run-off.** Sweeping the clearance between a boss and the plate's side walls, on a 20 mm version of the same shape — every row measured, `blend: 2` throughout, current kernel: | clearance | result | |---|---| | 0 (tangent) | valid, watertight — **fixed** by `vendor/occt-sys/patches/0001-tangent-pinch-corner.patch` | | 1e-9, 1e-7 | valid, watertight — same fix (the union still builds the tangency vertex here) | | 3.8e-5 .. 1e-4 | valid, watertight — always was | | 3e-4 .. 2e-3 | refused, naming a measured fallback (0.25 mm builds at 3e-4, 1.82 mm at 2e-3); a caught SIGABRT before the boundary learned to catch | | 3e-3 .. 1.999 | valid, watertight — the fillet runs 1.5 mm off the plate and is trimmed | | 2.0 (= the radius) | invalid, refused by the validity gate (unchanged) | | 2.001 and up | valid, watertight | The fillet's width at angle θ is bounded by the plate's side plane, at radial distance `R/|cos θ|` from the boss axis; at the tangency that equals `R` exactly, so the strip pinches to zero width. It still has a valid topology — the toroidal face trimmed by the wall plane, ending at an ordinary vertex where the trim curve meets the inner contact circle at a finite angle. OCCT's own walking already computed those points, then its generic corner code threw them away and filled the corner with a GeomPlate patch; the patch replaces that corner treatment for this configuration and nothing else. On the 20 mm shape the blend now adds 47.50 mm³ of fillet against the 5.17 the broken cap added, inside an exact 20 × 40 × 30 box, and the full retainer measures 143829.66 mm³ against the reference B-rep's 143825.6. **Open edges alone are not a success criterion.** At radius >= 3 this operation once destroyed 23% of the retainer's volume *while reducing* the open-edge count. Check volume too — that is why `check_blend` pairs containment with `BRepCheck_Analyzer`, and why the worker refuses any welded mesh with an open edge as a backstop that does not depend on knowing the cause. The face-touching family above is a different defect, still refused rather than fixed (docs/DSL_GAPS.md §4). `eval/cases/tangent-blend.json` and `eval/cases/tangent-blend-retainer.json` hold the fix down; `eval/cases/blend-runs-off-the-edge.json` remains the control proving overrun was never the problem. ### A fillet that ends exactly on a hole's rim — fixed in the kernel A 40 × 30 × 5 base, a 3 mm fin across it, a Ø5 through hole at x = 6 and a 2 mm fillet on the fin's root: the fillet's trim line on the floor (x = 3.5) touches the hole's rim at one point. Found by @FuruRok comparing OCCT with NVIDIA's usd-brep (2026-10-13). The part is well defined — a point contact removes nothing, so its volume is the hole-clear part's, 7303.330 mm³ — and stock OCCT 8.0.1 builds it `IsDone()`. What it hands back is a floor face whose outer wire touches its inner wire with no vertex there, which `BRepCheck_Analyzer` rejects (`IntersectingWires`) and ParCAD's self-intersection check reported as an edge meeting an edge. Every curve in it is right; only the topology does not say the two boundaries meet. **The fix is a vertex, and it belongs to the fillet builder.** ChFi3d is its own filler for the TopOpeBRep data structure: it writes the contact curves, their ends, and the points where they land on the face's edges, but never asked where a curve touches an edge along its length. `vendor/occt-sys/patches/0007-fillet-touch-on-a-face-edge.patch` adds that question to `ChFi3d_FilDS`, with the test `BRepCheck_Face` itself runs (pcurves intersected in the face's parameter space), and writes a touch as one point with a leaving and an entering interference on the curve and on the edge; the builder then cuts both there and the face's boundary passes through the vertex. Two builder rules had to follow, each measured on the way: a pave pair of one vertex at one parameter is a pass-through, not a closed-edge cut (it rotated the rim to start at the touch and dropped the half past the seam vertex: 7297.122 mm³, a shell that would not close), and a face in the data structure is rebuilt when an edge of it carries geometry (the hole's cylinder kept the whole rim while the floor had its pieces: three free edges). A crossing is left alone: the 0.5 mm overlap trims itself as it always did. What the kernel does once the foot runs *into* the hole is the next entry. The session that landed it first wrote the same vertex in after the build, in the treatment history. That mended ParCAD's result and nothing else, and recomputed an intersection the filler had in hand; it was taken out once the kernel did the work. **The STEP export carries the touch as two vertices on purpose.** OCCT's writer runs `SplitCommonVertex` before `DirectFaces` (`ToSTEP.exec.op`), because the STEP schema does not let a face's bounds share a vertex; its reader's `FixShape` heals it on the way back. Held by `eval/cases/fillet-ends-on-a-hole-rim.json`, with `fillet-overlaps-a-hole` (the hole 0.5 mm into the fillet's foot) and `fillet-clear-of-a-hole` (the volume the touching part must match) as its controls, by `chamfer-ends-on-a-hole-rim` for the chamfer builder, which writes the same data structure, and by `a_fillet_ending_on_a_hole_rim_builds_valid_with_the_touch_as_a_vertex` in `backend.rs`. The standalone harness that found the two builder rules — the three patched sources compiled against the prebuilt static libraries, shadowing the archive members, a two-second cycle instead of an OCCT rebuild — is the way to iterate on any kernel patch. ### A fillet that runs over a hole is built before the cut, and the cut made again Sweeping the radius on the hole-rim part above, once the touch built: from 2 to 4.5 mm OpenCASCADE builds something, from 4.6 to 7 mm it reports not done (`ChFiDS_TwistedSurface`, then a faulty vertex at the far rim), and from 7.1 mm it returns `IsDone()` with the hole's rim left dangling in the cylinder's face — one free edge, `BRepCheck_NotClosed`. Stock 8.0.1 and the patched kernel behave identically. The cause is a definition, not a bug: ChFi3d treats the rim as an *obstacle edge* and, between the two points where the foot crosses it, rolls the ball along the rim (its surface/rest-line blend, "bypass of obstacle" in `ChFi3d_Builder_6.cxx`). That bridges the hole. It works only while the contact stays on the near half of the rim, folds once the foot passes the hole's centre, has no corner case for the tangency at the far rim, and is never triggered when the foot clears the hole, so the cylinder is then never cut up through the new material. And where it works it is a different solid from what anyone means by a hole: 7366.770 mm³ at 3 mm against 7367.599 for the hole through the fillet. **The kernel cannot know the edge is a hole; the graph can.** A fillet or chamfer whose stripe runs over a cut made before it means "the treatment as if the cut were not there, then the cut through it" — the only reading with one meaning at every radius, and what every mainstream CAD builds. So `treat_before_cuts` in `backend.rs` builds the treatment on the solid before the chain of plain cuts under it and makes those cuts again through the result, exactly, and the reply's treatment lists them as `before_cuts`. Measured: every radius from 1 to 8 mm builds valid with the same 13 faces (7570.740 at 5, 7870.907 at 7, 8057.336 at 8, the harness's fillet-then-cut numbers to the micron), the chamfer the same, and the tray whose bottom chamfer ran through its walls into the pocket — refused since the self-intersection gate landed — now builds as a tray with its corners cut open, 9423.01 mm³. It is taken only on evidence: the selector must name the same edges on the solid before the cuts as after them, piece by piece (`EDGE_MATCH_MM`), so edges a cut created are never treated before it, and a tool of those cuts must come within the treatment's reach of a selected edge (`treatment_reach`, the rolling ball's centre distance plus its radius, a bound and not an estimate); otherwise the treatment runs where it was written. A blended cut ends the chain: its blend runs on the seam the cut makes. Held by `fillet-over-a-hole-centre`, `fillet-past-a-hole`, `chamfer-over-a-hole` and `chamfer-through-a-tray-wall`, with `fillet-overlaps-a-hole` re-recorded for the hole through the fillet, and by `a_chamfer_through_the_wall_behind_it_is_built_before_the_pocket`; the kernel's own self-crossing chamfer stays refused in `a_chamfer_through_the_wall_behind_it_is_refused_with_a_size_that_builds`, which puts the cut tray to the kernel directly. ### `ThruSections` quietly untwisted a loft — `CheckCompatibility` re-origins wires A ruled loft between a square and the same square listed a quarter turn on should twist 90° over its length — that pairing is the wall definition. With `CheckCompatibility(true)` (which the vendored wrapper used to set, following upstream), the builder re-origins the section wires to *minimise* twist first, and the same two sections came back as a straight prism: right height, right sections, 3000 mm³ instead of 2000, no error anywhere. Found recreating UnTriangle v3, whose whole geometry is that twist. `Solid::loft_sections` now passes `CheckCompatibility(false)` — the authored vertex order *is* the pairing — and `validate_loft` requires every section to carry the same point count, because with the compatibility pass off OCCT no longer invents a correspondence for mismatched wires. `eval/cases/twisted-loft.json` holds the volume against the closed form (⅔·a²·L: the twisted bar is two thirds of its prism), the tripwire for this pass ever being turned back on. ### `adjacentTo: { faceNormal }` also matches a hole's own wall A rim edge borders two faces: the flat face it sits in, and the cylindrical wall of the hole. The wall answers to axis-aligned normals, so both end rims of a bore drilled along X match `adjacentTo: { faceNormal: "+z" }`. On a part with cross-drillings — `examples/manifold-block.js` — the "opens onto the top face" query silently picked up two extra rims. Use `at: { z: "max" }` there; the face-normal form is fine when every hole is drilled along one axis. ### A name on a face ends at `clean()`, and a moved copy is not the face it copies Two ways face provenance died before it worked, both measured on the V holder: - **The boolean's history stops at the boolean.** `unified()` runs `ShapeUpgrade_UnifySameDomain` afterwards, and the coplanar faces it merges — a cup's side face with the fan's — are new faces the boolean never saw. The cup's outer corner had no tagged face and `{ on: "cup" }` missed it. The fix is `Shape::into_unified`, which keeps the unify pass's own `BRepTools_History`, and `unified_tracked` in `backend.rs`, which follows every name through it. - **`Modified(S)` answers only for the sub-shape it was given.** Carrying a tracked face through a rotation by transforming it separately produces an exactly placed *copy*; the next boolean reports it neither modified nor deleted, the copy survives with its old boundary, and after the union it matches nothing. `through_transform` therefore trades each moved copy for the transformed shape's own face or edge with the same geometry before any later operation asks. The mirrored cup is what found this: the unmirrored one kept its name and the mirrored one lost it at the same union. ### A tag's face list doubled at every boolean layer *(fixed 2026-11-05)* The planter saucer — a dish fused with 37 revolved bumps — built in 1.3 s untagged and never finished with `.tag("saucer")` on the union. Nothing in the boolean: a `sample` of the worker put all of it in `EdgeLineage`'s `modified_face` loops, allocating and freeing face handles. The lineage kept each name's faces as a `Vec`, and two entries can be asked about one face: when a fuse splits a face on the same domain (the dish's bottom plane and a bump's coplanar bottom disk), OCCT's `Modified` returns *both* pieces, and the unify pass after it merges the pieces back into one face. One face, two entries; the tag then appends every result face on top. At the next boolean each entry is asked about again, so the list doubled per step — 2^37 for the saucer, bumps fused one at a time as they were then. A plate with n posts stacked at one spot, every post its own boolean layer, measured the law at HEAD: 17 / 97 / 1479 / 5028 / 20652 ms tagged for n = 8 / 16 / 20 / 22 / 24 against ~40 ms untagged. `boolean_in_layers` had hidden the saucer by fusing non-overlapping tools in one layer; it never hid overlapping ones. A tag names a *set*. `EdgeLineage::unique` now keeps each name's faces and edges once each, by sub-shape identity (`Identity`, `IsSame` — the same `TShape` at the same location — in the vendored crate), after every history it is carried through and after every tag written; `boolean_in_layers`' seam edges the same. Nothing a reader asked changed: `face_tags`, the extents, and `equivalent_sources` already wrote each tag once per face, so a duplicated entry meant one face everywhere. The corpus's tag extents and face counts are unchanged; `eval/cases/tagged-stack.json` holds the 26-post stack under the 20 s budget it used to miss by a factor of four. Not `tag_extents`, the first suspect: `AddOptimal` over all 44 saucer faces is 16 ms. ### `generatedBy` names the cut, not the tool Lineage records the boolean node's tag, so `blank.cut(grooves, shaftBore, grub)` gives all three tools one name and `generatedBy: "bore"` — the tool's own tag — matches nothing at all. `timing-pulley.js` asked for `{ generatedBy: "machined", curve: "circle", role: "hole", at: { z: "min" } }` and passed `expect({ count: 1 })` only because the twenty groove rims beside the bore rim were split into open arcs by coplanar face splits, and `role: "hole"` wants a closed circle. The backend now merges those faces (`unified`, in `backend.rs`), the arcs close, and the same query matches 21 — the assertion had been resting on how fragmented the topology happened to be. Cut in one tagged step per feature, as `knurled-knob.js` does, and the name means something. That merge moves counts elsewhere too, always downward: the D-bore lead-in in `knurled-knob.js` used to select five arc fragments and now selects the two curves they always described. A count over fragments is a count over how the kernel happened to split a face — assert over features. ### A cutter coplanar with the face it cuts loses the rim `countersink()` first built its cone with the wide end exactly on the top face — where a countersink geometrically ends. OCCT merged the cone's flat top into that face, and the rim stopped being an edge the cut had generated: the selector matched one edge instead of five, and no dimension looked wrong. The helper now builds the cone 0.5 mm taller and wider along its own taper, so the section at the face is still the called-out head diameter. Same rule as every cutter in `examples/`: run past the material. ### A cut needs overlength at both ends, and the entry end is the silent one The rule is usually stated for where a cutter *exits*: run past the material, because a tool ending exactly on the face it leaves through makes a zero-thickness sliver. Nothing stated it for where a cutter *enters*, because until `display-bezel.js` every seeded part cut through something. An external session recessing glass panels into a model car found the other half: its cutters' outer faces were meant to lie on the body surface, and it shipped a **0.004 mm** feather edge that `measure_wall_thickness` found afterwards and nothing caught when it was made. **Exact coincidence is not the trap — it is the safe case.** A 60 × 40 × 20 plate, a 30 × 20 pocket 4 mm deep, and the cutter's outer face put at four distances from the front face it enters: | the cutter's outer face | volume | faces | mesh | thinnest wall | |---|---|---|---|---| | exactly on the face | 45600.00 mm³ | 11 | 28 tri, watertight | 10.000 mm | | 3 mm proud | 45600.00 mm³ | 11 | 28 tri, watertight | 10.000 mm | | 0.001 mm short | 45600.60 mm³ | 12 | 24 tri, watertight | 0.001 mm | | 0.004 mm short | 45602.40 mm³ | 12 | 24 tri, watertight | 0.004 mm | | 0.1 mm short | 45660.00 mm³ | 12 | 24 tri, watertight | 0.100 mm | The bottom three rows are not a pocket with a thin lid over it; **they are not a pocket at all.** The part is a solid block with a sealed void inside it, the front face is unbroken, and the extra volume is the lid. Nothing that reads like a failure moves: same bounding box, same watertight mesh, fewer triangles than the correct part. What moves is the volume, up by the lid, and the face count — also up, because a void adds surfaces rather than removing them. **The microns come from arithmetic, not from typing them**, so the trap needs a face that is not axis-aligned. Take a block whose front face rises 20 mm over 85 mm and a 4 mm panel recessed into it. Write the gradient the way a sketch reads it — `0.235`, where the exact value is `20 / 85 = 0.23529…` — and the recess's outer edge runs *inside* the face by `(65 − x) · 0.000294 · cos 13.24°`: 0.0029 mm at one end of the cut and 0.019 mm at the other. OCCT builds it, reports watertight and 11 faces, and removes 4519.86 mm³ against the parallelogram's exact 4519.87 — the cutter's own volume and not a micron more, so the membrane is intact and nothing in the reply mentions it. **The measurement that finds it used to miss it.** `measure_wall_thickness` fired from sampled surface points, so it reported a membrane only when a sample landed on one: on the sloped case above, when it sampled seven rendered views at 96 px it reported a minimum of 10.82 mm and *nothing* under a 1 mm threshold, while 256 px reported 0.0104 mm — the formula's value there to six places. A membrane is a wall between two faces that share no edge, and those are now searched for on the exact surfaces whatever the sample count, and read where they are thinnest; a sliver whose faces meet is a feather, found from the edge itself and reported at 0. `eval/cases/pierced-membrane.json` holds a 0.004 mm lid at 200 samples. docs/PERCEPTION.md §5, "What is certain", has what still rests on the samples. So the rule has two ends: **a cutter crosses every face it meets** — past the material where it exits, proud of the material where it enters. `holeFor` and `countersink` already do it at 0.5 mm; `examples/display-bezel.js` does it on a recess, at the entry of its seat and at both ends of its aperture. ### The cut that seals a void is refused; coincidence itself is not, and the table above is why The kernel refuses one shape of this defect, and it is worth being exact about which. It does **not** refuse on coincidence, or on any measured clearance. It counts closed shells across a subtract: a cut that *adds* an internal void has broken through no face and removed nothing reachable, so the result is a solid with a cavity sealed inside it — watertight, plausible in every render, unmanufacturable. There is no threshold in that rule, which is what makes it safe; row 1 of the table stays at zero voids and goes on building. `crates/parcad-occt/src/backend.rs` carries it; `eval/cases/refuse-sealed-void.json` and `coincident-cutter-entry.json` pin the accident and the safe row against each other. One limit, real. It catches a cut that closes behind itself, not a cut that lands thin: a blind hole one micron shy of breaking through the *far* face is an ordinary blind hole by every topological measure, and stays silent. Nothing broader fires, and both reasons still hold. Row 1 of the table is correct geometry — what a boss trimmed back to a face or a slot cut flush with an underside produces, and `examples/v-block.js` ships one — so a refusal on coincidence would refuse a legitimate part; and what is actually wrong is *near*-coincidence, a continuum where 0.004 mm is an accident, 0.4 mm is a design, and any threshold between them is a number some part reaches legitimately. Measuring the outcome instead of the cause fails on trust, not cost: the wall sweep runs in 10–40 ms at 96 px, but swept over `examples/` it returns **0.0055 mm** for `hydraulic-line.js` and **0.0069 mm** for `timing-pulley.js`, both correct parts. Two false alarms, two causes, one fixable. The hydraulic-line sample sits at z = 20.000, where the bend's arc is trimmed by its own end plane: both fields are zero, `max` ties, and the gradient comes back as the *plane's* normal while the tube surface there is vertical, so the ray runs along the face and the crossing is f32 noise — 0.0055 mm at 96 px, 0.0096 mm at 256 px. (A real feature reports the same number twice; `thickness.rs` drops *collapsed* gradients via `GRADIENT_TOLERANCE`, but the wrong branch of a `max` has a perfectly good unit one.) The other cause cannot be fixed: a tangential feature genuinely has no minimum wall. The ring between that boss and its O-ring groove is `0.5 − √(1 − (x − 4)²)` mm thick — x along the boss axis, the torus centred at x = 4, 1 mm minor radius — so it tapers to zero at the rim, and sampling nearer returns a smaller number without limit. Every groove, fillet and blend that runs off an edge does it. Two false alarms in twenty-one shipped parts teaches an agent to ignore the line, which is why there is no thin-wall number in every reply: "the thinnest material anywhere" is not the question "is there a wall here too thin to make". The shell count escapes that by having a discrete answer where a measurement would need a threshold nobody can defend. Where a defect can be restated that way it can be refused; where it cannot, the entry-side rule stays in the examples, in this file, and in `display-bezel.js`'s comments. ### A multi-tool cut is one cut `outside.cut(cavity, bore)` — a cavity sealed 3 mm inside every face, and a bore through the top that opens it — was **refused** as a sealed void, while `outside.cut(bore, cavity)` built the identical part. The cut ran one boolean per tool and judged each intermediate, so the cavity was condemned before the bore reached it. The same fold refused `cut(box(4, 4, 4), box(10, 10, 20))` as a sealed void and the reverse order as a tool that "meets nowhere", and a blended `union(left, right, plate)` failed because the two bosses fused first share no seam to blend. Now a union or a cut applies all its tools before anything is judged (`boolean_in_layers` in `backend.rs`): the void count is taken on the result, a tool is a miss only when it left no face *and* is clear of the original material (a tool inside another tool's volume is not a miss), and a blend rounds the whole seam once. `eval/cases/cut-opens-its-own-cavity.json`, `cut-with-a-redundant-tool.json`, `blended-union-any-order.json` and the two `refuse-multi-tool-*` cases pin it. Tools whose boxes are clear of each other go into **one** OCCT boolean: 144 holes in a plate built in 70–100 ms against 1.3–4 s one at a time, 64 bosses in 25–45 ms against 0.3–0.9 s. Tools that overlap go into successive booleans, because OCCT also intersects the tools of one boolean with each other: forty slots crossing at a centre took **70–140 s** as one boolean and 1 s in turn. Intersection stays a fold: `BRepAlgoAPI_Common` with several tools intersects with their *union*, and an empty common at any step is empty at the end whatever the order, so its refusal was never order-dependent. Two things are still order-dependent, deliberately. A chain is several nodes: `outside.cut(cavity).cut(bore)` still refuses, because `outside.cut(cavity)` is a shape the script made and could return — list both tools in one `cut`. And the fold's old hazard, "A union of pieces that do not touch each other kills the fuse" below, did not reproduce on OCCT 8 in either form (three quarter-torus arcs listed before four runs build in 27–29 ms either way), but a layer can still hold disjoint pieces. ### A union that drops solids Two domes 9 mm across (a revolved smootherstep, flat where it meets the floor) unioned onto a plate 8.64 mm apart, so their feet overlap by 0.36 mm: the fuse returned a watertight, valid plate with **one** dome, 1643.64 mm³ and 7 faces, and raised no alert. 8.4 mm apart it builds both (1687.19 mm³, 8 faces), and so does 8.0, 7 and 6; spheres at the same places always build. What matters is two surfaces meeting at a grazing angle over a sliver of overlap, not the count: a planter saucer with 48 such bumps, in rings whose neighbours sat 8.64 apart, lost 19 of them, and a hex grid 8 apart lost the dish and came back as 15 separate bumps. Nothing in the kernel's own reporting is enough to catch it. On the saucer the boolean's history said no bump was deleted and `DumpWarnings` was empty; on the hex grid one layer reported `BOPAlgo_AlertFaceBuilderUnusedEdges` and the rest of the loss was silent. Unioning the bumps together first, one at a time, or with a 0.3 mm vertical step at the foot lost them too. So a union is checked on its result (`require_inputs_kept` in `backend.rs`): every point of an input's faces is inside or on the union of it with anything, and a 3 × 3 grid per input face is classified against the result at 1e-3 mm. A point outside, and more than that from the result (see the next entry), refuses the union, naming the inputs lost and any alert the kernel raised. With a blend the check reads the boolean before the blend, which may take material off a convex seam. It costs 150 ms of 2.1 s on the twisted planter's 38-input saucer and 20 ms on 48 spheres, paid once per build. `eval/cases/refuse-union-grazing-domes.json` holds it. Which OCCT step loses the input is not found yet. ### A point on a B-spline wall read as outside the union that kept it *(fixed 2026-11-09)* A shelf base with a lip ring on top, the ring's inner wall `inset()` of the shelf's spline outline, was refused as a union that had lost the lip: 1 of 137 points outside, in the middle of one of the inner wall's faces. The union was right. It measured 4759342.29 mm³, the sum of the two pieces, which touch only at z = 18, and `lip.union(base)` built the same solid. The guard's own classifier was what failed. `BRepClass3d_SolidClassifier` casts one ray from the point toward a point inside the first face it finds. With the base first, that face is the base's underside, so the ray points straight down, and a ray straight down from a point on a vertical wall lies inside the wall. On a plane, the face intersector reports the line as parallel and the classifier measures the distance instead. On a B-spline surface it reports a scatter of tangent points (18 here, none at the ray's start), the classifier skips tangent points, and the first real crossing, into the base at z = 18, made the point OUT. The same contour built with a hand-written offset passed by luck: the input order, the sample and the ray all decide it. Any union with a sample on a vertical B-spline wall above the first input's underside can trip it. A point the classifier puts outside now has its exact distance to the result measured, and counts as on the result within the guard's 0.001 mm. Only points already read as outside are measured, so a union that passes pays nothing. The grazing domes still refuse, because their lost dome's points lie millimetres from anything. `eval/cases/union-inset-lip-onto-a-shelf.json` holds the shelf. The classifier itself is unchanged. Its other callers probe points placed off the surface on purpose, so their rays never start on a face. ### `role: "hole"` does not match a conical opening A countersink rim is an inner boundary of the top face by any reading, and `role: "hole"` drops it — `cover-plate.js` selects on `curve` and position instead. Related to the D-bore case in docs/DSL_GAPS.md §6: the term matches a narrower thing than its name suggests. ### A clamped primitive has no gradient inside itself `cuboid` is the standard exact form: `length3` of the three clamped-positive terms, plus the negative interior term. Inside the solid all three clamp to zero, so the exterior half is `sqrt(0)` — and the derivative of `sqrt` at zero is a division by zero. The gradient is **NaN throughout the interior**, not merely undefined on the surface. `cylinder` is built the same way and has it too. Pinned rather than fixed because it is measured not to reach output. Both consumers of the gradient normalise behind `len > 1e-9`, which NaN fails, so a NaN becomes the `[0, 0, 1]` fallback instead of a NaN normal; and `faceted` samples off the crease toward each triangle's own centroid, which lands outside the solid. `a_box_is_shaded_by_its_own_faces` measures the cost on a real mesh — 0 of 1152 shading normals wrong, checking the 576+ that sit unambiguously mid-wall. The weak version of that test passes while proving nothing: `[0, 0, 1]` *is* a legitimate top-face normal, so counting axis-aligned normals finds no fault however broken the shading is; it has to be checked against the wall each vertex is actually on. Fixing the NaN means an epsilon under the `sqrt`, which moves the zero level set everywhere — a deliberate change to make against `eval/cases/`, not a drive-by. ### `.at(x, y, h)` vs `.at(x, y, h - wall)` A latent bug in an example: placing a lid at `h` instead of `h - wall` sealed the box in *both* backends, so nothing looked wrong. Geometry that is subtly wrong in the same way everywhere is the hardest kind to see. ### Every outline with an inward corner was refused as an inset *(fixed 2026-11-09)* An L of 100 × 100 less a 40 × 40 corner, inset 10, was refused: "strays 4.142 mm from lying 10 mm inside the outline; the most this outline takes is about 0.12 mm". Both numbers were the measurement's. The kernel's inset was right — intersection joins put the corner opposite (60,60) at (50,50), on the bisector 10·√2 from it, where two offset lines meet — but the guard held every sample to one distance, and 10·√2 − 10 is 4.142; bisecting then found the distance at which that same 0.414·d slipped under the tolerance, a number true of nothing. The measurement now knows a valley (`ParcadInsetMeasure`, `vendor/opencascade/include/curve.hxx`): a point `d` from the tangent line of one of an inward corner's edges, inside, and no further than `d / cos(turn / 2)` from the corner is that corner's; every other point is held to `d`, a fold (`short`) and a dropped stretch (`excess`) refused by name and place, and a smaller distance named only where a smaller distance is the answer. `eval/cases/inset-l-shape`, `inset-notch` and `inset-step` hold the closed forms; a unit test in `backend.rs` holds the negatives. The second refusal was OCCT's: a tray round a spline meeting a straight edge at a 7° inward turn "could not step inward" at any distance. `BRepOffsetAPI_MakeOffset` with intersection joins fails on any curve that meets its neighbour at a re-entrant corner — the medial axis's fallback bisector points out of the domain, and a curve's parallel stops where a line's runs to infinity, so the result never closes. Patch 0008 in `vendor/occt-sys/patches/` fixes both; its header and `vendor/occt-sys/PARCAD-CHANGES.md` carry the measurements. Polylines were never affected, which is why the corpus's insets never saw it. ### `shell` refused every revolved B-spline wall at the volume identity *(fixed 2026-11-09)* `shell` subtracts the part's inward offset from the part, and then held `hollow = part - cavity` to 1e-6 of the part's volume as proof the cavity lay inside. On a turned cup with a spline wall the three integrals disagree by 1.9e-6 (1645.289 against 1645.300 mm³, the same with round and sharp joins), so every such shell was refused as "the cavity is not inside the part": the volume integral's own noise on B-spline faces, not the geometry. What a shell is, is measured instead now: samples over the cavity's faces, each inside the part and no nearer its boundary than the wall, and for a round join no further either (`Op::Shell` in `backend.rs`); the identity stays at 1e-4 as the backstop for a cut that removed nothing. `eval/cases/shell-spline-wall.json` holds the cup. On the bell that motivated the branch, the same check read the round join's real failure on a 0.65 mm spigot step, narrower than the 1 mm round it wants to sit on, and named `corners: "sharp"`, which built it (2026-09-24; the bell is not in the repo). ### A sharp shell builds three corpus parts a round one cannot, and loses nine `shell(t, { corners: "sharp" })` shrinks the part with `GeomAbs_Intersection` joins instead of `GeomAbs_Arc`, so an inside corner of the cavity stays square, as Fusion's Shell leaves it. Where it builds it is exact: the L block of `eval/cases/shell-sharp-corners.json` holds 958.000 mm³ to the digit, where the round default leaves 956.283, 0.18 % apart, which is more than the corpus tolerates, so a Fusion shell recreated here needs the sharp join to hit its volume. Measured over every corpus and seed part shelled by 1 mm on 2026-11-09 (`crates/parcad-occt/examples/shell_corners.rs`, one process per part and join, after the wall check above replaced the volume identity): of 246 scripts, 31 are several bodies and 45 do not build unshelled; of the 170 left, 51 shell both ways, 9 only round (`fillet-spans-seam-and-rim`, `rounded-plate`, `smooth-uprights`, `stadium-extrude`, `tangent-blend`, `tangent-blend-retainer`, `thickness-under-a-fillet`, `pipe-tee`, `shaft-coupler`), 3 only sharp (`fillet-past-a-hole`, `smooth-bar-root`, `plate-stand`), 95 neither, and 12 abort the kernel with either join, the five shells of a shell among them (on those the offset builder throws `StdFail_NotDone` through the binding, which the worker's crash handling reports as a kernel crash; the same with either join). Where both build, sharp leaves up to 0.71 % more material, the square inside corners, and takes the same time (medians 135 against 159 ms). Where only one builds, the other's offset mostly fails to close: a round of the wall's radius has no room on a fillet or a step narrower than the wall, and an intersection join runs two faces past each other where a round would have turned. So round stays the default; a step or fillet narrower than the wall takes `corners: "sharp"`, a turned wall is `revolve`'s `wall`, and a bell blended to tubes is its outside minus a cavity built the same way (docs/DSL_GAPS.md, "hollowing a part with faces left open"). In September, over 144 parts, sharp built none that round could not and lost 5; two of today's three are parts the corpus gained in October, and `plate-stand`'s round offset aborts the kernel today. ## Rust/wrapper - `offset_surface` **consumes `self`**. The fork adds `impl Clone for Shape` (cheap — `TopoDS_Shape` is a handle onto a refcounted `TShape`). - `Shape.inner` is `pub(crate)` upstream, which is the *only* reason we forked. `opencascade-sys` already binds every OCCT call we needed. - TypeScript: `isLineSegments` doesn't exist on the intersected three.js type. Use `isLine` — `LineSegments extends Line`. - `Shape::write_stl` **re-meshed at 0.001 mm** — ten times finer than the 0.01 mm the worker had just meshed at, so every face was triangulated twice and the second pass cost 1.1 s per filleted boss. Eighteen bosses spent the kernel's whole 20 s budget writing a file for a slicer, and the timeout blamed "an unknown operation". The writer now takes the tolerance; the worker passes the mesher's (`vendor/opencascade/PARCAD-CHANGES.md` has it). The CLI and the app no longer ask for that file at all: both write the welded viewport mesh themselves, binary, a sixth of the bytes and the same triangles on screen and in the slicer. ## One malformed tool schema hides the entire MCP surface `probe_step_export` returned `Json`. A `Value` has no schema, so schemars emitted an output schema with no `"type"`, and a client that validates `tools/list` rejects **the whole array** over one bad entry. Every model saw no parcad tools at all, for a day, while the server went on answering `tools/list` correctly to anything that asked it directly. Fixed in `c426c465`; `every_advertised_schema_is_an_object` in `mcp.rs` is the regression, and it covers input schemas too because a client validates the whole descriptor. **`--mcp-config` cannot report it** — it says `connected` and swallows the parse error. `claude mcp list` prints it, and `CLAUDE_CONFIG_DIR` keeps the probe out of the real config: ```bash CLAUDE_CONFIG_DIR=/tmp/probe-cfg claude mcp add --transport http parcad http://127.0.0.1:4344/mcp CLAUDE_CONFIG_DIR=/tmp/probe-cfg claude mcp list ``` **From the field suite it reads as "no model can use these tools."** The tell is uniformity: every trial failing the same way in both models and both arms, which a real distribution does not do. ## A tools/list reply without `ttlMs` and `cacheScope` hides the surface too Same symptom as the malformed schema above, different cause, found the same way. Claude Code 2.1.268 negotiates MCP protocol 2026-07-28 and validates every `tools/list` reply against a schema in which `ttlMs` (a number) and `cacheScope` (`public` or `private`) are required; rmcp 3.1 leaves both off the wire when unset. `--mcp-config` said `connected`, the model saw no parcad tools, every field trial failed the same way, and curl saw fifteen tools. `claude mcp list` did not show it either this time — only `claude -p --debug`, in `~/.claude/debug/`, with `tools/list failed (Invalid result …)`. `mcp.rs` now writes its own `list_tools` with both fields; `the_tool_list_carries_a_ttl_on_the_wire` pins them. Found beside it: the client truncates server `instructions` at 2048 characters and says so only in that debug log. Ours were 3511, and the paragraph that fell off was the one about selectors. `the_instructions_fit_the_client_window` holds the length. ## Protocol 2026-07-28 has no session, and refuses a request that acts as if it did The other side of the same version, met writing `parcad call`. rmcp 3.1 serves 2026-07-28 statelessly (SEP-2567): there is no `Mcp-Session-Id`, and every request after `initialize` must carry, in `params._meta`, `io.modelcontextprotocol/protocolVersion` and `io.modelcontextprotocol/clientCapabilities`, or it is refused with "request _meta is missing". Every POST must also repeat its method in an `Mcp-Method` header and, for `tools/call`, the tool in `Mcp-Name` (SEP-2243) — the body alone gets "missing required Mcp-Method header". Both rules are gated on the `MCP-Protocol-Version` header the client sends; the older versions keep their session and want neither. `call.rs` in the CLI speaks the new one only, and says so. ## A tool reply over 50,000 characters reaches the model as a 2 KB preview Claude Code 2.1.273 counts a tool result's text in **characters, not bytes**, and at **50,000** it stops handing the text to the model: longer than that, the result is written to a file under `~/.claude/projects/…/tool-results/` and the model gets a `` note saying `Output too large (NN.NKB)` with the first 2 KB. The KB there is characters ÷ 1024. A model with no Read tool — every field trial, and any client that denies it — cannot open that file, so the rest of the reply does not exist for it. Nothing fails: the call succeeds, and the model works from whatever else it has. Measured on 2026-09-16 with a one-tool stdio server returning `n` copies of one character, asked once per `n` by haiku: | text returned | reached the model | |---|---| | 49,000 × `x` | whole | | 50,000 × `x` | whole | | 50,001 × `x` | persisted, 2,368-character preview | | 51,000 × `x` | persisted | | 50,000 × `—` (150,000 bytes) | whole | | 30,000 × `é` (60,000 bytes) | whole | A second form hides a reply completely: `Error: result (61,261 characters) exceeds maximum allowed tokens. Output has been saved to …`, with no preview. Sonnet got it for the gears branch's 61,261-character `dsl` topic, where haiku got the preview form, and sonnet got the preview form for a 53,601-character reply. The client's token cap, `MAX_MCP_OUTPUT_TOKENS` (25,000 by default), is the likely trigger; that is not measured. Handed the error, sonnet went looking for PowerShell, Grep or Read in 10 of 12 trials. For a tool that returns a struct through rmcp's `Json`, the counted text is the compact JSON (`Value::to_string()`, non-ASCII unescaped), so a newline in a document costs two characters and a quote costs two. `read_docs` was the tool it hid: the `dsl` topic ran 58–62 KB on the gears branch, and in `eval/field/how-close-to-the-involute.md` every trial got the preview, read a seeded part's source instead, and graded LUCKY. `gotchas` was 53,601 on main. `docs.rs` now serves any topic longer than `SECTION_BUDGET_CHARS` (12,000 escaped — the client limit is a ceiling, not a target) as its contents and then one `section` per call; `every_reply_fits_in_what_a_client_shows` holds every reply under `CLIENT_RESULT_LIMIT_CHARS`. Any other tool that can grow — a long `list_entities`, a large probe — has the same ceiling. The field grader no longer counts such a call as reaching its tool, and shows how many replies were hidden: field/README.md, "A call is not a read". evaluate_part and edit_part met it next, on parts in many bodies: the plant-shelves session (2026-11-08..11) lost 9 of 32 of those replies to the error form, the largest 87,631 characters, 64,610 of them `between_bodies` listing 465 pairs of 31 bodies. `reply_budget.rs` now holds every such reply under `REPLY_BUDGET_CHARS` (12,000, WRITING_FOR_MODELS rule 1): each list that grows with the part is ranked problems first and cut at a cap — 6 bodies, 16 pairs, 4 printed bodies, 16 tag extents — and `_unlisted`, right after the list, counts what was cut and names the `bodies` or `tags` argument that lists it. Past the caps the lists halve in tiers, detail first and a failed check's evidence last, so any part fits. probe_part, list_entities and measure_wall_thickness trim to the same budget and say what they left out. The window still gets every pair; only the MCP reply is held. ## rmcp's `Json` widens an f32 to seventeen digits A tool returning `Json` is serialised through a `serde_json::Value`, text block and `structuredContent` alike, and a `Value` holds every number as an f64. An `f32` field that serde would print as `6.3` reaches the client as `6.300000190734863`: list_entities' edges carried `-51.60611343383789` for a centre, and its 60 edges were 11,765 characters. Round such a field to f64 where the reply is built (`round_point`, as `service::entity` does now); a comment there used to say f32 needed none. ## opencode 2.0.29 stalls before it starts an MCP server Measured 2026-11-12 with `opencode run --standalone`: with any server under `mcp` in `opencode.json` — parcad's `/mcp`, `parcad mcp` over stdio, or a 30-line stdio echo server — the run's `serve --stdio` child starts and then logs nothing and never spawns the server, and no run of nine had answered after 60–120 s; without `mcp` the same command answers in 3 s. The v2 shape is `"mcp": { "servers": { … } }` (the top-level form is deprecated), and both shapes stall. Two more traps on the way: `--format json` with a space prints the help, and `--auto` before `--format=json` in one quoted argument does too. Its free models (`opencode/…-free`) refuse any other client — "FreeUsageLimitError: Free models work in OpenCode" — so they cannot be reached around it. No open model has been put to the field suite for that reason. ## An MCP argument the host did not read was dropped, not refused *(fixed 2026-09-18)* No request struct in `mcp.rs` carried `deny_unknown_fields`, so serde dropped a field it had no slot for and the call went ahead without it. The coin-holder session (docs/COIN_HOLDER_REVIEW.md, L4) asked `evaluate_part` for `section: { axis: "x", offset: -38 }`: `offset` is not a field — `at_mm` is — so the cut went through the middle of the part, and the model reasoned about coin fit from that picture. The reply's resolved `section` said where the cut really was, and nothing read it. A wrong measurement, not a retry, which is the class of failure this file exists for. Every request struct now refuses an unknown field, and `arguments::Args` names the field that was meant (`offset` → `at_mm`) and the shape of the whole argument, from the schema `tools/list` publishes, so the two cannot drift. The same rule one layer down is `envelope::check_node`, which refuses a graph field the host would silently drop. `eval/field/where-was-it-cut.md` measures whether a model reads the refusal. ## A union of pieces that do not touch each other kills the fuse that joins them `pipe()` builds a tube as runs and bend arcs and unions them. Assembled with every arc first and the straight runs afterwards, OCCT **hung** on a two-bend route and **segfaulted** on a three-bend one; assembled in path order — run, arc, run, arc — the same route builds in milliseconds. The fold is pairwise. Arcs at different corners are disjoint solids, so fusing them first produces a compound of separate lumps, and the boolean that finally bridges them has to resolve every contact at once; fusing along the chain means each step touches what is already there. **The rule:** when unioning a chain of shapes, union them in the order they touch. Cheap to get right and expensive to debug, because the failure is a SIGSEGV three operations later. ## The MSI names a bundled file after its source, not its target *(fixed 2026-09-27)* Tauri 2.11's WiX template writes each resource as `` with no `Name`, so Windows installs it under the source's file name and ignores the target's. `vendor/opencascade/LICENSE` mapped to `licenses/LICENSE_opencascade_LGPL_21.txt` was installed as `licenses/LICENSE` on Windows only; once ParCAD's own `LICENSE` joined it in `licenses/`, `light.exe` failed on two files of one name and the 0.0.11 tag built no release. The CLI archives had the same collision in a `cp`, which kept whichever came last. **The rule:** a resource keeps its own file name and gets a folder of its own when that name is taken; `bundled_resources_install_under_their_own_names` in `crates/parcad-cli/src/stdio.rs` holds it. ## A point classification next to a B-spline edge costs milliseconds *(fixed 2026-09-27)* `BRepClass3d_SolidClassifier::Perform` asks whether its test ray passes through an edge, and for every edge whose box the ray crosses it runs `Extrema_ExtCC`, which on a B-spline edge is a global optimisation (`math_GlobOptMin`). Loading the solid is not the cost, so `classify_points` does not help. Bridge detection classified one probe per boundary *segment of the mesh*: on `blade-shade` the ring's ceiling meets four fitted blades, the 0.01 mm mesh cut their outlines into hundreds of segments, and the overhang pass took 11.7 s of a 20 s budget on an M4 Max and all of it on CI, where it failed the 0.0.10 release. **The rule:** probe per B-rep edge, never per mesh element; the count of exact classifications must follow the geometry, not the tessellation. `overhang::held_edges` does, and the case runs in 1.3 s. ## A coaxial torus groove in a cylinder segfaulted `UnifySameDomain` *(fixed)* ```js cylinder(12, 14).cut(torus(11.5, 1)) // SIGSEGV, before OCCT 8.0.1 ``` That is an O-ring groove, which is the shape a torus exists for. The **boolean was fine** — the crash was in `unified()`, the `clean()` / `UnifySameDomain` pass every result goes through to weld away imprint edges, and it died on the two coaxial circular seams the cut leaves in the cylinder wall. What did *not* crash, kept because it is how the next crash of this family gets narrowed down: | shape | result | |---|---| | `box(30, 30, 14).cut(torus(11.5, 1))` | fine | | `cylinder(12, 14).cut(torus(20, 4))` — enters from the side | fine | | `cylinder(12, 14).cut(cylinder(11, 20))` — coaxial, no torus | fine | | `cylinder(12, 14).cut(torus(11.5, 1))` | **SIGSEGV** | **Vendoring OCCT 8.0.1 fixed it**, and the `known_defect` marker came off `eval/cases/torus-gland.json` — that marker working as designed rather than a case being quietly relaxed. The case still runs, so the fix cannot silently regress, and `examples/hydraulic-line.js` now carries the groove in a real part. What that part cannot do is a *catalogue* gland: a torus cut is a circle in section, and a standard gland is rectangular and wider than the cord. The blocker moved from the kernel to the section — docs/DSL_GAPS.md, "arcs in a section". ## A release build of the app compiled the host three times Measured on 2026-09-14, from an empty `target/`, running release.yml's cargo steps in order on a 14-core M4 Pro (two runs each): | | `tauri build` step | whole sequence | |---|---|---| | as it was | 213 s, 193 s | 326 s, 294 s | | `crate-type = ["rlib"]` only | 143 s, 145 s | 225 s, 229 s | | and the CLI in tauri's own cargo invocation | 81 s, 80 s | 191 s, 185 s | Two independent causes. `parcad-app` declared `["staticlib", "cdylib", "rlib"]`, Tauri's template default: the first two exist for iOS and Android, and on a desktop build each is a full thin-LTO link of the whole app. And `beforeBuildCommand` built `parcad-cli` in its own `cargo build` before tauri ran another for `parcad-app`. Features unify per invocation, so the second saw different features on `serde`, `libc`, `syn`, `tokio` and 94 more crates and rebuilt all of them — and a later `cargo build -p parcad-cli` step rebuilt 93 again for the same reason. CI now overrides `beforeBuildCommand` to the frontend alone and hands tauri `-- -p parcad-app -p parcad-cli`, so one invocation builds both. The override is CI's, not tauri.conf.json's: a plain `bun run tauri build` still has to produce the `parcad` the macOS bundle carries, and `tauri dev` reads the same runner configuration. Do not add a `cargo build -p parcad-cli` after it; assert the binary exists instead. ## A fit's reference was built from the part's cache Found on 2026-09-17, and there since the warm worker (`fdd010e7`, 2026-09-14). `check_fit` builds the part and then the reference inside one build-cache scope, and the memo of subtree keys that scope keeps is indexed by node id and was filled by the part: the reference's node `i` was looked up under the part's node `i`. | `box(40,40,10).cut(box(20,20,20))` against | answered | right | |---|---|---| | `box(19.5,19.5,30)` | interfering by 12000 mm³: the plate, the part's node 0 | clear by 0.25 mm | | the same `.at(0,0,0.001)` | touching: the 20 mm cutter, the part's node 1 | clear by 0.25 mm | A cold worker answered the same: the part fills the cache in the same request. It is a hit only where the part built that node at the same offset, so a reference placed where the part built nothing came back right — the laptop in `does-the-laptop-fit` read 0.5 mm on the old worker — and was then kept under the part's keys. After fitting `box(40,40,10).cut(box(20,20,20).at(0,0,3))` against `sphere(4).at(0,0,3)`, which answered correctly, the next `evaluate_part` of `box(40,40,10).cut(box(20,20,20))` on that worker cut the ball: 15774.534 mm³ and 7 faces for 12000 and 10. A node id means something only in its own document. The scope now records the document its memo belongs to, a node of any other document is built without the cache, and `check_fit` builds its reference under a memo of its own (`in_document` in `backend.rs`), so both are reused. Nothing had caught it because nothing asked: no corpus case called `check_fit`, `between_bodies` measures the bodies of one document, and the unit test called `backend::check_fit` with no cache installed. `eval/cases/fit-in-the-holes.json` now asks three references twice each on the worker that has just evaluated the part. ## Parts are not in Documents Found 2026-09-18: the pencil handed `~/Documents/parcad/bracket.parcad/part.js` to Cursor, and Cursor answered `NoPermissions (FileSystemError)`. macOS guards Documents, Desktop, Downloads and iCloud Drive per app (TCC). ParCAD having the grant says nothing about the editor it hands a file to. That editor was already running, launched from the Dock, so the read was its own. The same wall stands in front of every agent, shell and launchd service that reads the folder. So the folder is `dirs::data_dir()/parcad` — `~/Library/Application Support/parcad` on macOS — which no app needs a grant for. The files are the same plain files. Nothing moved an older folder over; one user, done by hand. ## ParCAD web: the host in a tab Found building `crates/parcad-wasm-host` on 2026-09-17; docs/ARCHITECTURE.md, "A third host: ParCAD web", is the design these constrain. ### QuickJS's stack check misfires under Emscripten, and then trips too late With Emscripten's default 64 KB stack, every script failed with "Maximum call stack size exceeded": QuickJS measures its depth against the stack it starts on, and a stack that small puts the limit below address zero. The host links a 16 MB stack. Then the opposite failure: QuickJS's recursion nests several wasm frames per JavaScript call on the *engine's* native stack, which runs out long before the 16 MB shadow stack does, as a `RangeError` trap that leaves the module's state unusable. Measured under Node 22, plain self-recursion: | `set_max_stack_size` | what a runaway recursion gets | |---|---| | 64 KB | a clean exception at depth 162 | | 256 KB | a clean exception at depth 654 | | 512 KB | the engine's trap | `script.rs` sets 256 KB on this target. In Chrome's worker the same runaway recursion came back as QuickJS's clean refusal, not a trap; Firefox and Safari are not measured. A script that recurses 650 deep builds on the desktop and not in a tab, and says so in words. ### A tab's call runs twice when it needs the kernel, so it must ask before it writes `page.rs` pauses a call at its kernel request by unwinding, and runs it again once the page has the answer. Anything the call wrote before asking is written again on the second pass. `save_project` used to snapshot and write `part.js` and then build the thumbnail; its second pass then kept the *new* script as a snapshot and reported that. It builds first now. A tool that writes and then builds has the same bug in a tab only. The second pass also names new scratch files, so an answer is matched to its request with `step_path` and `stl_path` blanked, and a carried file is written to the new request's path by what it is (the STEP, the STL), not where it was. ### A call that waits must hand the page its events first `set_script` with `wait_s` waits for the window to report the new revision — and the host worker delivered session events only when a call *finished*. So the page heard of the edit only when some other call ended, which was the agent chip's status poll four seconds later, and every `set_script` took 3.9 s. The worker now delivers events before any call waits: 0.14 s, measured through the relay with the window drawing the part. ### rmcp's HTTP service answers only a loopback `Host` `StreamableHttpService` checks the `Host` header against an allow-list that defaults to loopback, and answers anything else with 403 "Host header is not allowed". The tab has no host name of its own, so `page.rs` builds every request as `http://localhost/mcp` with `Host: localhost`, whatever the relay received. Behind a tunnel or a reverse proxy the same check bites the other way: the `Host` the host sees is the public name, so every request through the proxy is that 403 while `curl` on the box works. `parcad serve --hosted` therefore requires `--public-host NAME` and adds it to the list — loopback stays on it, so the container's health check and a shell inside it still get through — and the same name is the `url` the MCP status reports. The end-to-end test in `http.rs` sends one request with a `Host` the list was not told and expects the 403, so a change to the list's defaults fails there rather than on a server. ### rmcp opens every SSE reply with an empty event rmcp 3.1's `StreamableHttpServerConfig::default()` keeps sessions and sets `sse_retry`, so every SSE reply on a session begins with a priming event, `data: ` with nothing after it, for a client to resume from. The MCP Python SDK before 1.25 parses each event as JSON-RPC and fails the handshake on it ("Error parsing SSE message"), while 1.25 and later skip it. Measured 2026-11-06 against the hosted server: 1.22 failed, 1.25 and 2.3 connected. Nothing here resumes a stream, so `mcp::transport` sets both settings that send it to `None`: the service config's `sse_retry`, which primes the handshake, and the session manager's `session_config.sse_retry`, which primes every reply after it. The session stays, because the window's count of connected agents is kept per session. `a_session_streams_each_reply_with_no_empty_event_before_it` in `http.rs` holds it. ### claude.ai hides a JSON-RPC error from the model A tool that refused by returning `Err(ErrorData)` reached the wire as a JSON-RPC error. Claude Code puts its message in front of the model, so every field round read the refusal and fixed the script. claude.ai and the Claude phone app do not: the model sees "Error: the tool failed (unavailable)" and nothing else. On 2026-11-06 a cut that missed its material came back that way, and Opus told the user "ParCAD не отвечает" while the server was up and had named the fix. `Parcad::call_tool` now turns every tool's refusal into a result with `isError: true` and the message as its text, the form the spec gives tool errors, and keeps the `kind`/`line`/`node`/`stage` under `_meta`, not in `structuredContent`, which a client checks against the output schema. Only an unknown tool stays a protocol error. `a_refusal_reaches_the_model_as_a_tool_result_naming_the_fix` in `http.rs` holds it. ### A pasted image is a placeholder in the Claude apps claude.ai and the Claude phone app do not load an image a reply links to from another host: each `![view](https://…)` a model pastes is a black "Load image" box the user has to tap, and on 2026-11-06 the tap opened a download sheet, because renders were served as `attachment` (they are `inline` now). Those clients draw open_project's part in 3D themselves, so a view carries `markdown` only for a client that does not declare the viewer's media type; `DRAWS_THE_PART` in `mcp.rs` carries that per call. Codex and Claude Code, which declare nothing, still get the line, and `show-me-the-part` still measures that they paste it. ### New page script against an old host module reads nonsense Under `vite --mode web`, rebuilding the host module while a page is open and letting the page reload can pair the new worker script with the module the old one fetched. An argument added to an export then lands in the wrong slot: `host_answer` read a pointer as a length, the reply packet "had 211936156 bytes past its end", and the call asked the kernel again, forever. A production build cannot mix them — both files sit under one content hash — and an unreadable reply now ends the call with "reload the tab", but restart the dev server after rebuilding either module. ### Two checkouts sharing one `CARGO_TARGET_DIR` overwrite each other Cargo hashes a path dependency's artifacts by its path *relative to the workspace root*, so a git worktree building into the main checkout's `target/` writes the same files. Building the committed code for a benchmark that way left the main checkout's `parcad-host` compiling against the worktree's `parcad-occt` ("no `packet` in the root"). `cargo clean -p` the workspace crates afterwards, or give the worktree its own target directory and reuse only OpenCASCADE through `PARCAD_OCCT_PREBUILT`. ## `checks: [...]` on its own line is a label, and built a part with no checks A Haiku 5.5 field run, the night "requirements first" shipped, wrote the request's checks as a statement before `return part;`. JavaScript reads `checks: [...]` as a labelled expression statement: legal, evaluated, and discarded. The host built the part, reported no checks, and said nothing. Every runner — the sandbox, the editor, `tools/run.ts`, `importPart` — now refuses it through `__parcadLabelledPartKeys` in `dsl.ts`, which asks the engine rather than reading the text: a label may not repeat inside the statement it labels, so the script wrapped in `checks: { … }` compiles unless it has a `checks:` label of its own, and renaming every other `checks` in turn says which occurrence it is. Strings, comments, templates, regex literals and object keys never trip it, because the parser that will run the script is the one asked; a label inside a nested function is not found, by the same rule. It costs two compiles of the source on a script without one — 15 µs on JavaScriptCore and 75 µs in the QuickJS sandbox for the 55-line bracket, paid once per source since the sandbox keeps results by it — and the editor underlines the word through the same call. The list of keys is `PART_KEYS`; `brief` is a call, not a key, so `brief: { … }` as a statement is the same trap and not yet caught. `eval/cases/refuse-checks-as-a-label.json` holds the refusal. ## A hosted host: many accounts in one process ### A task-local does not cross `spawn_blocking`, and rmcp spawns every handler The request's tenant is a tokio task-local that the auth middleware in `http.rs` sets around `next.run(request)`. Two things lose it. `spawn_blocking` runs on a thread with no task context, so a `blocking()` helper must capture `tenant::current()` first and re-enter it inside with `tenant::scope`, which both helpers do. rmcp serves every `/mcp` request on a task it spawns, so no task-local set by any middleware reaches a tool body: the tenant also rides on the request's extensions, and `Parcad::call_tool` reads it off the HTTP parts rmcp hands each handler and enters it itself. A new `spawn_blocking`, or a new `ServerHandler` method that reaches `service` without going through `call_tool`, is where this comes back. The symptom differs by mode, which is the trap. In a local process a request outside every scope falls through to `local` and nothing is wrong. In a hosted one (`tenant::require()`) `tenant::current()` panics: "a hosted request reached the service outside tenant::scoped/scope: wrap the spawn_blocking". That panic is deliberate — the alternative is one account's files in the operator's own folder — and because `require()` is process-wide, the test for it runs its panicking half in a child process. ### A hosted call holds its slots from its script to its reply The build gate used to sit in `service::kernel()`, so a script spinning in the sandbox, a render or a probe's perception held nothing, and one account could keep every core busy. Admission now happens once per call, in `Parcad::call_tool` and in `http::admitted` for `/api` (`limits.rs`), and the call's slots ride to the blocking thread on `tenant::handoff()`, beside the tenant, so they stay held until the work there ends even if the client hangs up. Two things follow. A new `spawn_blocking` must carry the handoff, not the tenant alone, or its kernel calls lose their slot. And a hosted kernel call outside an admitted build is refused as a bug ("reached the kernel without a build slot"): a new tool that builds belongs in `limits::BUILD_TOOLS`, which `every_tool_is_classified_for_the_limits` holds against the router. ### An error with `structuredContent` fails an SDK 1.x client's schema check The MCP TypeScript SDK 1.x (1.32.2, `Client.callTool`) validates a result's `structuredContent` against the tool's output schema even when `isError` is true, and throws "Structured content does not match the tool's output schema" on a mismatch; 2.0 skips the check on an error. A limit's `{ type, retry_after_seconds, limit }` block in `structuredContent` would turn every limit reply on a schema'd tool into that protocol error, which claude.ai shows a model as "the tool failed (unavailable)". So the block rides in `_meta["parcad/limit"]`, as a refusal's details ride in `_meta["parcad/refusal"]`, and the sentence in the text is what a model reads. ## The editor does not check the length of an array `app/src/intellisense/analyzer.ts` drops any diagnostic whose cause bottoms out in TypeScript's 2620 or 2621 — *"Target requires N element(s) but source may have fewer"* and its opposite. Both mean the same thing: the compiler agrees the element types are right and cannot prove how many there are. A part is JavaScript, so it never can. `hull(points).map(([x, y]) => [x, y * k])` is a `number[][]`, the DSL wants `[number, number][]`, and there is no annotation a `.js` file can carry to say otherwise. Before the filter the seed corpus produced ten of these and no other complaint at all — a checker that is wrong on every shipped example is worse than no checker, because the first squiggle a reader dismisses is the last one they read. What survives is everything that is not about length: a string among the numbers, a misspelt method, a missing argument, an option key that does not exist, and a written-out `[0, 0, 0]` where two are wanted, which is code 2618 and still reported. `analyzer.test.ts` runs every part in `examples/` and requires zero, and runs seven mistakes and requires one each. ## A part in somebody else's editor `crates/parcad-host/src/editor_types.rs` writes `jsconfig.json` and a `.types/` folder at the root of the parts directory, so the pencil in the titlebar opens a file VS Code can hover. Three things about that file were each measured wrong first: - **`"include": [".types"]` does not work.** TypeScript will not walk into a directory whose name begins with a dot, and says nothing about it — the declarations simply resolve to nothing and every DSL name reads as undefined. It has to be spelt `".types/**/*"`. The folder stays dotted because `projects::walk` skips dotted entries, and a visible one would appear in the parts picker as a collection the user did not make. - **`"moduleDetection": "force"` is load-bearing.** Left as scripts, every `part.js` shares one global scope, and the second part to write `const plate` redeclares the first. Twenty parts in a folder is twenty files of errors about each other. - **`"checkJs"` is off, and stays off.** A part ends in `return`, which is an error outside a function body. The window's own editor compiles the document wrapped in one (`intellisense/part-file.ts`); a file on disk cannot be. Completion, hover and signature help need no wrapper, and they are the whole point of the file. The declarations are generated from `script::surface()` — the DSL evaluated in the sandbox — so they are the names `new Function` binds, not the names a parser found. A `jsconfig.json` without parcad's marker comment belongs to the user and is never replaced. ## The completion details panel is outside its parent CodeMirror hangs `.cm-completionInfo` off the completion list as a *child*, and then positions it entirely beside the list — parent right edge 361, panel left edge 365, in the measurement that found this. Any `overflow: hidden` on `.cm-tooltip` therefore does not trim the panel, it deletes it: the layout still reports a sensible box and sensible text, and nothing is painted. That is what makes it worth writing down. The panel was invisible for three commits while a check that read `innerText` off the element reported it working, because `innerText` cannot see a clip. `document.elementFromPoint` inside the panel's own box is the test that finds it — it answered `CANVAS`, the viewport behind. So the radius is clipped on `.cm-tooltip-hover` and `.cm-completionInfo`, which need it because a section rule is drawn past their padding, and never on the list. ## The webview is not the browser you tested in `requestIdleCallback` does not exist in WKWebView. It is in Safari, it is in every browser this app is developed against, and it is not in the webview the desktop app actually ships. Calling it unguarded in `ui/editor.tsx` threw inside the editor's mount effect, which took the whole Preact tree with it: no viewport, no parts picker, nothing but a titlebar and a report — because the report's text comes from a path that had already rendered. Three things let it through, and all three are worth keeping in mind: - **Every check was a browser check.** The dev server, the production bundle served over HTTP, a second app instance read through Chromium — all of them render correctly, because all of them are not WKWebView. The failure needs the app's own window. - **`tools/test-desktop.sh` was not in `tools/check.sh`.** Its own header says browser-only tests are "intentionally insufficient for this suite: its purpose is to catch failures in Tauri's WebKit host and IPC bridge" — and a nine-stage green gate said nothing at all about this. - **The viewport spec asked what the viewport *knew*, not what it drew.** `bracket.e2e.mjs` projects an edge and hovers it, and passes perfectly while every mesh is invisible. `draws-the-part.e2e.mjs` renders the scene twice, once with the part hidden, and compares the frames; it needs to know nothing about the background or the theme, and a scene that draws everything except the part cannot satisfy it. Before reaching for a browser API in the frontend, check it against WKWebView rather than against caniuse's "Safari" column — the two are not the same thing. Where one is worth using anyway, guard it and provide the fallback, as `idle()` in `ui/editor.tsx` does. ## Bambu Studio 02.08.02 calls every plain 3MF "invalid config" Bambu Studio 02.08.02.60 and .61 open any 3MF that carries no Bambu project config behind a modal, "The 3mf file has invalid config, load geometry data only": ours, Fusion 360's, the 3MF Consortium's own samples ([bambulab/BambuStudio#11927](https://github.com/bambulab/BambuStudio/issues/11927)). `check_project_config` in their `Plater.cpp` failed any config without `nozzle_diameter`, and a core-spec 3MF has no config at all. The geometry loads intact; the dialog is the only effect. Upstream fixed the check in `926a61e` and turned the dialog into a toast in `2ba84fc`, both in 02.08.03.66 and later. Don't silence it by writing `Metadata/project_settings.config`. That is Bambu's undocumented project format, which `threemf.rs` stays out of on purpose, and a non-empty config is one Bambu goes on to load as settings. ## A smoothed three-edge corner is not even tangent `smooth()` is a fillet with a different cross-section, built inside OpenCASCADE's own fillet machinery (`vendor/occt-sys/patches/0006`), so every part of a fillet it does not change is inherited, the corner construction included. Where two smoothed edges meet at a vertex the two blends are intersected, which is right. Where three meet, `ChFi3d` fills the corner with its generic plate patch, and against curvature-continuous blends that patch measures 2.8° off tangent with a curvature jump of 12.9/mm, on a solid `BRepCheck` calls valid: the circular case's spherical corner, by contrast, meets its cylinders at 8.5e-7°. The harness that measured it is described in the patch's header. So the kernel is not asked: `backend.rs` counts the selected edges at each vertex before building and refuses a `smooth()` whose edges meet three or more at a corner, naming the corner and the count, and says to `fillet()` those. The same reasoning removed `.vertices().smooth()` from the language, since every corner vertex of a solid is such a corner. After every `smooth()` that does build, the angle and the step in normal curvature between each blend and the face it was rolled on are measured along every rail, and the build is refused above 0.05° or 1 % of 1/R; the number is the reply's `curvature_jump_per_mm`. A G2 corner patch, an n-sided patch with curvature constraints, is the open item in docs/NEXT.md. ## `Shape_faces_json` described the first solid only *(fixed 2026-11-09)* The vendored `Shape_faces_json` walked the faces of the *first solid* in a shape, while the mesher and `shape.faces()` number every face of it, so for a body that is a compound of several solids (a union whose operands never met, a boss floating over an open cavity) or a surface, `perceive::describe_faces` returned fewer summaries than there are face numbers, and indexing the one by the other read the wrong surface. The motion sweep learned this first: a base whose boss did not touch it came back with no `plane` kinds past face 6, every face was treated as curved, and the sweep spent its whole budget in the kernel; it reads planarity off each face's own triangulation since. In a part of several bodies the short list shifted every later body. `Assembled::append` numbers faces by the shape's own count, and appends the descriptions it was given, so after a body of four screws in one union each face read the description 9 places on. In `plant-shelves-v4` three such bodies put 27 between them: the chat viewer drew the eight cones last in the list with no faces at all, since their numbers ran past the end of `faces`, and hiding `pot4` hid the top of `soil`. The writer now walks the whole shape in the mesher's traversal, with neighbours numbered the same way; `every_meshed_face_is_described_in_its_own_body` in `serve.rs` holds it. One measurement moved with it: overhang counts an undescribed face as not planar and then builds no support, so `chamfer-through-a-tray-wall`, a floor and a wall ring, reports its 1999.176 mm³ of support only now. ## A body after a reference body drew as the reference *(fixed 2026-11-06)* `serve::Assembled::append` offset each body's face numbers by the part's own face count, which leaves references out, so the body appended after a `.reference()` body reused its numbers: in `hinged-box`, `object2`'s six faces came back as 72–77, `object`'s, and `faces[78..83]` were never referenced. Everything that reads a face by its number — window picking, regions and materials, `face@N`, the chat viewer's per-body grouping — credited `object2`'s triangles to `object`. Face numbers now run through every body, references included (`BodySpan::faces` says so); the part's own counts still leave references out. Two numbers, two jobs: don't fold them back into one. ## A drag on the chat viewer scrolled the chat on an iPhone `touch-action: none`, which three.js's OrbitControls already sets on the canvas, is advice to WebKit's own scrolling only. A host that holds the MCP App's web view in a native scroll view — what the Claude iOS app behaved like — lets that scroll view take every vertical drag that starts on the part: the model turned *and* the chat moved. A touch the page cancels (`touchstart`/`touchmove`, non-passive, `preventDefault`) is the only signal that reaches it: `app/src/hold-touches.ts`, on the canvas and on the motion sliders. That is also why `Slider` reads pointer events itself rather than wrapping a native range input, whose own touch handling cancelling would switch off. Measured in a native harness on the simulator: the same drag moved the chat 155 pt before, 0 after; a drag that starts on the body chips still scrolls it, as it should. A touch that lands while the chat is still coasting after a fling belongs to the scroll view whatever the page does, so test drags from rest. ## The chat viewer's frame stopped growing The ext-apps `App` reports the page's height with a `ResizeObserver` on `` and ``; `style.css` gives both `h-full`, so their boxes are the frame's and never change when content grows inside them. The viewer measured 428 px before its panel rendered and stayed there. `viewer.html` sets `h-auto` on both, so the client is told the height the page needs. ## The viewport showed linear colour *(fixed 2026-11-06)* The outline pass made the viewport an `EffectComposer`: the scene rendered into an 8-bit target and the outline `ShaderPass` drew it to the canvas. three tone-maps and sRGB-encodes only what is drawn straight to the canvas, and the outline shader did neither, so the canvas showed linear values: every colour darker and greyer than asked for. The sky asked for `#e8ecf1` and showed `#cbd1dd` (linear-shown-raw predicts `#ced6e0`); the parts' `#aeb8c6` showed near `#6c7a90`. That was most of the "overcast" a phone saw in the chat viewer. The beauty target is now half-float and an `OutputPass` comes last. The workbench's colours had been tuned against the bug, so they moved with it: its sky is now the colours it used to show, and Neutral tone mapping replaced ACES, which desaturates. The backdrop goes through tone mapping with the rest of the scene, and Neutral's toe sinks a dark grey to black, so `backdrop()` in `viewport.ts` fills it with the inverse of the curve: it shows the colour the look names. The silhouette pass is not antialiased; widening it stair-steps every outline, so a look widens the kernel's edges (`LineMaterial`) instead. ## A swinging body's shadow fell on an invisible floor *(fixed 2026-11-06)* The ground — grid and shadow catcher — lay at the part's lowest point as drawn. A mechanism can carry a body below that: a key holder's lever swung its hook 9.3 mm under the frame's bed, so the transparent shadow plane cut across the lever and the hook's shadow fell on nothing. The chat viewer now hands `Viewport.setTravel` every pose each body passes through (`travelPoses` in `motion.ts`: every knot, turns stepped at most 2°), and the ground goes under the lowest vertex any pose reaches. It stays put while the mechanism plays, so at rest a part may stand above its shadow by that drop; a floor that followed the pose would read as the part moving. The sun's shadow covers the same swept box, or the swung body's shadow is clipped. The same box left references out *(fixed 2026-11-08)*: shelves drawn among `.reference()` walls got their ground 537 mm up, with the walls through it. `layGround` now counts every drawn body; the camera still frames the part alone. ## A translucent wide edge beaded along every curve *(fixed 2026-11-09)* A look that gives edges a width (the chat viewer's) draws them as `LineSegments2`: one screen-space quad per sampled segment, with round ends. Neighbouring quads overlap at every joint and three edges overlap at every corner, so at the old opacity of 0.85 those pixels were blended two or three times, and a curve came out as a row of dark beads on a grey line. Blending is in linear light, which is why the beads stood out: one coat of 0.85 near-black over a cream face lets about 40% of the face through once encoded to sRGB, two coats about 2%. On a ring 2.5 device px wide the darkest pixel per column read 6–68 along the curve. Both looks now draw edges opaque, where overlap cannot show. If a look ever needs translucent wide edges again, give each one a stencil bit where it draws and skip pixels already marked (bit 7 sits above the section caps' crossing counts), so every pixel is blended once; that was measured to remove the beads. --- # Op coverage, measured against Fusion 360 What a mainstream parametric CAD tool offers, what parcad has, and what the missing ones would cost *here*. Fusion 360 is the yardstick because it is the tool most people who would use this have used; the comparison is about the **operations**, not the interaction model. The deciding rule is the project's, not Fusion's: > **An op ships when the kernel can be honest about it, and a closed form can > check it.** OCCT builds far more than it can be trusted with. Anything whose > result cannot be measured against something independent — a closed-form > volume, a containment box, a fitted curve's deviation — gets refused, > restricted to the case that can, or left out, never approximated. That rule is why this list is short rather than a wish list, and why some entries are marked *hold* with a reason instead of a plan. --- ## Where we stand | Fusion 360 | parcad | note | |---|---|---| | Box / Sphere / Cylinder primitives | ✅ `box` `sphere` `cylinder` | centred on the origin, placed with `.at()` | | Extrude a sketch profile | ✅ `extrude`, `ngon` | lines, arcs, rounded corners and splines, re-entrant allowed; §2 | | Revolve a sketch profile | ✅ `revolve`, `cone`, `countersink` | the same section type; §2 | | Combine (join / cut / intersect) | ✅ `union` `cut` `intersect` | plus `blend` for a rounded seam | | Move / Copy, Rotate, Scale | ✅ `.at()` `.rotate()` `.scale()` | non-uniform scale builds exact B-splines (`BRepBuilderAPI_GTransform`), held to the determinant | | Mirror | ✅ `.mirror()` | | | Rectangular / circular pattern | ✅ `grid` `polar` `around` `repeat` | DSL helpers, not graph ops — the graph shares one node | | Fillet / Chamfer | ✅ `.fillet()` `.chamfer()` with selectors | constant radius / equal distance | | Torus primitive | ✅ `torus(major, minor, { sweep })` | full ring or arc; the arc is what a bend is | | Shell | ✅ `.shell()` | | | Offset face / Thicken | ✅ `.offset()` | whole-body offset, not per face | | Hole | ✅ `holeFor` `tapDrill` `clearance` `counterbore` | ISO metric coarse, M2–M20 | | Draft | ✅ `extrude(..., { draft })` | §1 | | Sweep | ✅ `sweep(profile, path, { bend, taper })`, `pipe(path, dia, { bend, taper })` | runs and bend arcs, a `{ helix }` or a `{ spline }` | | Loft | ✅ `loft(sections, { smooth })` | §4 | | Section view | ✅ viewport plane, `section` on `evaluate_part` | §8 | | Coil | ✅ `pipe({ helix }, dia)`, `sweep(profile, { helix })` | §5 | | Thread | ✅ `threadedRod` `threadedHole` | ISO 68-1 basic 60° profile, named sizes or `{ diameter, pitch }`, a clearance for printing; measured against its closed form — §5 | | **Rib / Web** | ❌ | sugar over what exists — §6 | | Split face | ✅ `.split(tool)` on a surface | pieces stay joined along the cut; §9 | | **Split body** | ❌ | two intersections returned as two bodies; §8 | | Surface: extrude, revolve, loft, sweep | ✅ `surfaceExtrude` `surfaceRevolve` `surfaceLoft` `surfaceSweep` | open or closed curves; fitted curves skinned on one knot vector; §9 | | Surface: patch, stitch, trim, split, offset | ✅ `.edges({ role: "boundary" }).patch()` `stitchSurfaces` `.trim()` `.split()` `.offsetSurface()` | measured: patch gap, closed-ness, offset distance; §9 | | Thicken | ✅ `.thicken(t, { side })` | wall measured at a grid on every face; §9 | | **Surface: extend, ruled, boundary fill of arbitrary curves** | ❌ | §9 | | Sheet metal, T-spline, Mesh, Simulation, CAM | ❌ | out of scope by design | | Sketch constraints, timeline, parameters | n/a | the DSL is the parametric model; JS is a better parameter table | | Several bodies in one part | ✅ `return { base, lid }` | never fused; each measured alone and every pair measured on the exact solids; STEP a solid per body; B-rep only | | Assemblies / joints | ❌ | a body is placed by its own coordinates, never by a mate — a solver, and the wide reading of NEXT.md §2 | --- ## 1. Draft — **DONE** `extrude(profile, height, { draft })` and `ngon(..., { draft })`; `revolve` does not take it. An angle the outline cannot carry is refused, naming the maximum. `drafted-boss` in `eval/cases/` holds the kernel to the closed form for a square frustum (29282.008 mm³); `refuse-impossible-draft` holds the refusal. ## 2. Arcs and splines in a section — **DONE** An arc in the section is what makes an O-ring groove, a bearing seat, a radiused shoulder, and a radius authored in section is exact where a rolling-ball fillet on the solid is a surface fit. It shipped with splines, as one section type (`crates/parcad-core/src/section.rs`, `SectionEntry` in the DSL): - a section is a list of corners `[x, y]`, closed by itself; `{ at, round }` is a corner rounded between its two straight edges; - between two corners, `{ through: [x, y] }` or `{ radius: r }` is a circular arc, `{ spline: points, start, end }` a chord-length cubic through points, `{ bezier: controls }` and `{ bspline: poles, degree }` curves by control points; `[{ spline: points }]` alone is a closed C2 curve; - the same type is the profile of `extrude`, `revolve`, `loft` (whose first or last section may be `{ z, point }`) and `sweep`, and `pipe`/`sweep` take `{ spline: [[x, y, z], ...] }` as a path. **Prior art, and why this spelling.** CadQuery (`lineTo`, `threePointArc`, `radiusArc`, `spline` over `GeomAPI_Interpolate`, `close`) and build123d (`Polyline`, `ThreePointArc`, `RadiusArc`, `Spline`, `Bezier`, `FilletPolyline`, `make_face`) were read for the vocabulary, and SVG's path commands for what a model already knows (`C` is the cubic Bézier; the `A` command's large-arc and sweep flags were rejected as the classic way to get an arc on the wrong side). A section is data rather than a builder chain because the graph carries it verbatim and every export name is a reserved word: not one new export was added. Three-point arcs (`GC_MakeArcOfCircle`) are the unambiguous form; `radius` exists because drawings dimension arcs that way, with the sign saying which side. Everything resolves **in the core**, not the kernel: arcs become three points and a centre, every curve a clamped B-spline with explicit poles, and the kernel builds exactly those poles (`Edge::bspline`). That is why `spline` is its own documented interpolation rather than `GeomAPI_Interpolate`, whose end-tangent estimate lives in C++ — bounds, area, the axis check and a sweep's reach need the curve before the kernel runs. A polygon takes the old construction bit for bit: all 94 surviving corpus cases re-recorded unchanged. **Convexity lifted.** Extrude, revolve, loft and sweep sections may be re-entrant; the reason recorded for refusing them was the implicit kernel's missing distance field, and a prism or a revolution pairs nothing. A loft pairs edges by index with `CheckCompatibility` off, so a re-entrant section pairs as literally as a convex one. A polygon that crosses itself is refused by name in the graph; a curve that crosses its outline by `BRepCheck` (`SelfIntersectingWire`) on the section face. Draft still takes a convex polygon, because its inset is a half-plane intersection. **Measured**, exact B-rep read off each part's STEP: `stadium-extrude` 4000 + 500π = 5570.796327; `rounded-plate` 3961.371669; `domed-revolve` 8000π/3 = 8377.580410; `revolved-fillet-shoulder` Pappus 6169.962838; `circle-section-ring` 2π²Rr² = 4934.802201; `parabola-bezier` Archimedes' 400; `loft-to-a-point` 1000π; `swept-stadium-bend` Pappus 2611.421237; `stepped-shaft` 1720π; `l-plate` 2700; `re-entrant-loft` 5250 (a frustum of an L); `spline-pipe-straight` 848.229790 against 848.230016 (the pipe shell's fit). Edges from arcs select as `curve: "circle"`, from curves as `curve: "spline"` (`curve-edges-by-kind`). `hydraulic-line`'s gland is a catalogue section now, its cut 283.712922 mm³ against Pappus to the last printed digit. **What it moved.** One Fusion target, `untitled2-v1`, both bodies from the export's degree-5 pole rows (examples/fusion360/README.md). The other three it was supposed to unblock were not waiting on curves once probed. **Since added:** `{ fit: points, tolerance }`, a curve the kernel fits through sampled points and *measures* — the worst distance from any point to the built curve is `deviation_mm` in every report, and a fit that cannot hold its tolerance or crosses itself is refused — and `inset(outline, d)`, the outline stepped inward by the kernel's offset and measured before it is used. What they close is geometry that arrives as points: a simulation, a scan, an involute sampled from its equation. docs/DSL_GAPS.md has the measurements. **And since:** `{ curve: (t) => [x, y], from, to, tolerance }`, a curve given by a formula, drawn by the script — where the function lives — as C1 cubic Hermite pieces with a bound it states and, given the function's derivative and a fourth-derivative bound, certifies; the kernel measures the built curve against the function between the pieces. `spurGearOutline` draws involute teeth on it, profile-shifted when asked, and `spurGearPair` meshes two at the centre distance their shifts need. docs/DSL_GAPS.md has the measurements. **Still out:** a section with holes (cut a second solid), a periodic or rational B-spline entry, tangent continuity asked for across a corner, and draft on a curved outline. ## 3. Sweep — **DONE**, in two honesty classes A path is made of straight runs and circular bends — what a tube bender does and what CAM posts — and that is what both forms take. `pipe(points, diameter, { bend })` is a union of cylinders and partial tori, every one of them exact. Without a bend radius the corners are square and filled with a ball — inside the swept envelope, fine for clearance, not a shape anybody can make; with one, a bend that will not fit refuses, naming the largest radius the corner takes (`refuse-tight-sweep-bend`). `bent-tube` checks a 90° bend against the closed form (π·25·70 + π·15·25·π/2 = 7348.34 mm³), and `pipe-run` checks the square-cornered form against a closed form that is *not* the obvious one — two perpendicular runs overlap in a quarter of a Steinmetz solid, and the ball that fills the corner is three quarters redundant. `sweep(profile, path, { bend })` takes a convex outline along the same path model. `swept-channel` holds it against Pappus's closed form (9141.59 mm³, read 0.005% under by tessellation, the same effect `bent-tube` records). A *round* profile should stay a `pipe()`. **Since added:** a helical path and a linear taper (§5). **Since added:** a spline path, `{ spline: [[x, y, z], ...] }`, the chord-length cubic of §2, refused where it bends tighter than the section reaches (the radius and where are in the message). **Still out:** a profile that twists along the path. ## 4. Loft — **DONE**, and the hold lifted deliberately The hold this entry once recorded was lifted as a product decision — loft and sweep each appear in more of the owner's real documents than revolve, which we did build (DSL_GAPS, "What twenty-one real Fusion 360 designs actually needed") — and the way it was lifted matters more than the op: **Refuse rather than approximate, even here.** While the implicit backend existed it refused a loft by name rather than fit a distance field to one — an approximate field would have meant probes and wall thickness confidently measuring a part that does not exist — and the cost was that a lofted part lost every field-backed capability at once. Both went with the field: probes, wall thickness, renders and sections run on the exact solid, so a loft is inspected like any other op. `loft-frustum` holds the closed form (a 40→20 mm square prismatoid, 28000 mm³ exactly). `loft(sections, { smooth })` takes two or more outlines stacked along +Z, with arcs and curves since §2, and a point as the first or last section. Sections must resolve to the same edge count, because pairing is by index and taken literally: a rotated outline authors a *twisted* wall on purpose, which is what recreated UnTriangle v3, held to a closed form by `eval/cases/twisted-loft.json`. `smooth: true` is Fusion's look, one surface fitted through all sections, and a fit that bulges past the sections' own bounding box by more than the slip tolerance is refused (`eval/cases/refuse-bulging-loft.json`), so the graph's cheap bounds stay honest. Sections may be re-entrant since §2: with the compatibility pass off the pairing is the author's, re-entrant or not. ## 5. Coils — **DONE**; threads — **DONE**, after the measurement that held them was re-run The hold was lifted by a model building a unicorn, which needed a spiral horn, a tapering mane and a tail and faked them from stacked primitives. What shipped is the honest version: `{ helix: { radius, pitch, turns, endRadius, hand } }` as a path for `pipe` and `sweep`, and `taper` on both. The helix is a straight line in a cylinder's or cone's parameter space; its fitted 3D curve is *measured* against the exact helix (1e-8 mm on the corpus) and refused past 1e-4 mm. Five eval cases hold volume to closed forms at about 1e-6 of BRepGProp, and the graph refuses a pitch that runs a turn into the next and a section that crosses the axis at either end. A **thread** was held back for a measurement: a helical groove cut through its coaxial cylinder opened past two turns. Diagnosed, it was never the boolean — parcad's seam-pcurve pass dropped a pcurve the groove's other strips of the same cylinder still used, and a guard for a different part had fixed it on main before the helix merged (docs/GOTCHAS.md). What shipped is `Op::Thread`, as `threadedRod` and `threadedHole`: the ISO 68-1 basic profile, swept in the axial plane along a helix of one edge per turn (FreeCAD's construction, chosen over cq_warehouse's ruled faces, bd_warehouse's lofted loops and the MakeBottle tutorial's `ThruSections` by measurement — GOTCHAS "Threads"), measured against its closed form on every build. The **thread annotation on the node** — a `PartReport` that says "M6 × 1, 12 deep" for a hole whose geometry is honestly a 5 mm drill — is still worth having for machined parts, where the tap drill remains the right drawing. ## 6. Rib / Web — sugar, not an op Fusion's Rib grows a thin wall from a sketch line down to the body. Here that is `union(body, box(...).at(...))`, and the gussets in `motor-mount.js` and `pillow-block.js` already do it. A `rib(from, to, thickness)` helper in `dsl.ts` would remove the arithmetic, the way `polar()` did. No graph change. ## 7. Hole feature — **DONE** `METRIC_FASTENERS` (M2–M20), with `tapDrill`, `clearance(thread, fit)`, `counterbore` and `countersink("M5")` reading from it, plus `holeFor(thread, depth, { fit, tapped, through })` for the cutter: DSL_GAPS §1's "standards knowledge hides in arithmetic" applied to the numbers a machinist knows by heart. Never write one as a literal in a part. The cutter is called `holeFor` because an export called `hole` broke four saved scripts that had `const hole = cylinder(...)` — every export becomes a parameter name in the script sandbox. That hazard is DSL_GAPS §7 and binds every future export. ## 8. Split and section — a view concern — done Splitting a body for inspection is a clipping plane, not an op: the section control on the viewport, and `section` on `evaluate_part` for a model that has no window. The part is untouched — every measurement in the reply is still of the whole solid. docs/PERCEPTION.md §7 records how it works and the two ways it can quietly cut nothing. **Splitting a body into two *parts*** is two intersections returned as two bodies — `return { left: part.intersect(box(...)), right: ... }` — measured per half and against each other; `eval/cases/split-halves.json` is that shape. There is no one-call split op, and a mirrored half is the usual second body. ## 9. Surfaces — **DONE**, the core of Fusion's surface workspace Held out of scope until the owner reversed it: "Fusion has a full mode of surface work; we should not be this strict." The case that decided it is a shade of separate blades, whose free edges no closed skin can have. docs/ARCHITECTURE.md, "Surfaces", has the design. What shipped, each with its closed form in `eval/cases/`: a tube's area and free-edge length (`surface-cylinder`), a tube patched and stitched into a solid (`surface-capped-cylinder`), a cube stitched from walls and patches (`surface-stitched-box`), a disc trimmed by a cylinder (`surface-trimmed-disc`), a plane and a surface trim, a thickened sheet, tube (three sides) and dome, an offset tube, a swept arc by Pappus, open and closed fitted lofts, a partial revolve, a mixed part, a four-blade shade, and six refusals. **Still out:** *extend* (lengthening a surface past a free edge — `BRepLib::ExtendFace` extends one face by a chord length, and a skinned surface's faces would each extend on their own and no longer meet, so there is nothing honest to build yet); a patch whose boundary is authored curves rather than a surface's free edges; ruled surfaces between two arbitrary curves; and trimming a *solid* by a surface, which is `thicken` then `cut`. --- ## What is deliberately not on this list Sheet metal, T-spline modelling, mesh repair, simulation and CAM are whole product areas, not missing ops. Sketch constraints are not a gap either: a constraint solver exists to make a drawing consistent, and a script that says `gap / 2 + armT / 2` is consistent by construction — the trade this DSL makes on purpose, and `v-block.js`'s exact 90° vee is what it buys. ## Suggested order Done, in this order: the hole standards table (which found the export-collision hazard on the way through), draft, torus, `pipe()`, the `UnifySameDomain` segfault fix by vendoring OCCT 8.0.1, then loft and sweep of an authored profile — the hold lifted as the product decision recorded in §4. Then arcs and splines in a section (§2), which drew `hydraulic-line.js`'s gland as the catalogue section it had to approximate with a torus. Everything else: hold, with the reason recorded above rather than the intention. --- # Style: field instrument A way for a part to look and be made: a small precise instrument that is also a little bit of a toy. Use it when the user asks for this style by name, or for something "like a synth / a lab instrument / a toy that is a tool". It changes proportions, edges and layout — never the fit, the fasteners or the checks. The numbers below were measured off shipped hand-held instruments in this manner, from scale drawings and renders. They are a starting point to keep, not a range to wander in. ## The idea in five rules 1. **One simple solid.** A thin slab, a box or a cylinder. Not a sculpted shell, not a stack of shapes. 2. **One module, and everything on it.** Pick a pitch and put every hole, key, knob, pocket and edge margin on it or on half of it. 3. **Flat faces, detail only where something touches.** Large uninterrupted planes; features only where a finger, a screw, a cable or a foot goes. 4. **One gesture.** Exactly one control or feature is larger, rounder or odder than the rest: a big dial, a dish, a crank, a handle that is also the stand. 5. **The process shows.** A machined part looks machined, a moulded part looks moulded. Do not mix the two looks in one body. ## Proportions | kind of part | plan ratio | thickness | |---|---|---| | hand-held, desk object | about √2 : 1 (1.36–1.45) | 6–10 % of the short side; more only for a stated reason | | long control strip | about 2.75 : 1 | 5–10 mm | | character object, speaker | 1 : 1 | as the contents need | ## Edges | body | plan corners (vertical edges) | top perimeter | bottom perimeter | |---|---|---|---| | machined metal look | radius ≈ 5 % of the short side (3 mm at 62, 5 mm at 102) | crisp, or a 0.3–0.5 mm break | a larger chamfer, 1–1.5 mm, so the part reads as floating on its feet | | moulded plastic look | 0–0.5 mm: near-sharp | crisp | crisp | Keep every radius constant along its edge. No blends between features, no domes. ## Layout - **Module**: often a multiple of 2.5 mm (7.5, 12.5), or 8 mm when the part should take LEGO-compatible pins. Keys sit on 12.5–24 mm pitches; secondary controls sit on twice the key pitch. - **Margins**: one small margin, repeated — about 4.5 mm on a hand-held, or one module. Every row of features shares an edge line with another row. - **Gaps between keys**: 0.3–1 mm. - **Pockets and caps**: a cap stands 0.5 mm inside its pocket on every side (15 mm cap in a 16 mm pocket), its corners rounded 1.5–1.75 mm, pocket corners near-sharp. - **Knobs**: a small cap in a larger recessed ring — Ø8 in Ø16, Ø6.4 caps on narrow strips. One knob may be the gesture. ## Holes and grilles - **Grille**: holes on a square grid, hole diameter 0.55–0.75 of the pitch (Ø1.15 on 2.0, Ø1.6 on 2.2, Ø3.0 on 4.0), in one rectangular or round field on a flat face. One field per face. - **For a print**, keep the web between grille holes at 1.2 mm or more (three lines of a 0.4 mm nozzle): Ø2.8 on a 4 mm pitch, not Ø3.0. - **Fasteners** hidden on the top face; countersunk or recessed underneath. Standard metric only: `holeFor`, `clearance`, `counterbore`, `countersink`. - **Feet**: four round recesses for bumpers, one module in from the corners and never over a screw. ## Colour and marks - A neutral body and at most **one accent** on the single most important feature. Colour means something: red or orange is record, stop or danger. - A part is one colour when printed, so express the accent as geometry: the caps as their own body (`return { body, caps }`), a ring, a deeper pocket. - Labels, when there are any, are lowercase, small and on the module. There is no text operation yet; do not fake letters from boxes. ## Do not - Round the top perimeter of a moulded body, or leave a metal body's plan corners sharp. - Scatter features off the module "to balance" a layout. - Add a second gesture, a second accent, or a decorative feature with no job. - Copy a real product's face, name or model code. Use the style's rules, not anyone's product. ## Before you call it done The style makes small features, and small features make thin walls and collisions that every number in `evaluate_part` passes. Two checks, every time: 1. `measure_wall_thickness` with `threshold_mm` set to the process minimum (1.2 for a 0.4 mm nozzle). Read every `thin_spots` entry; a spot between two features you did not mean to touch is a defect, not a style choice. 2. For a part in several bodies, `between_bodies`: caps `touching` their pocket floors, nothing `interfering`. Then tell the user the thinnest wall and where it is, in the same reply as the part. A pocket that leaves 1 mm under it, a grille hole that nicks a screw boss and a cable channel that leaves a 0.01 mm sliver under a slot have all shipped from this style once, and all three were visible to check 1. `examples/field-instrument/` has two parts built by these rules: `control-box.js` (moulded) and `desk-stand.js` (machined). --- # Example parts Each is a whole part, as it ships in examples/. ## ASME B16.5 slip-on flange `examples/flange.js` ```js // A slip-on pipe flange, dimensioned after ASME B16.5 class 150, NPS 2. // // Nominal dimensions, all millimetres: OD 152.4, flange thickness 19.1, // hub OD 92.1, length through hub 25.4 measured from the back face, bore 60.3, // four bolt holes of 19.1 on a 120.7 bolt circle. The holes straddle the // centrelines, which is the part of the standard people get wrong: they sit at // 45°, not at 0°. // // `polar()` is the rotational counterpart to `grid()`, and `straddle` is the // convention itself: the holes sit half a step off the centrelines. Written as // arithmetic it was a `+ 0.5` nobody could check against a drawing. const od = 152.4; const thickness = 19.1; const bore = 60.3; const hubOd = 92.1; const throughHub = 25.4; // back face to top of hub const boltCircle = 120.7; const boltDia = 19.1; const bolts = 4; // The flange plate straddles z = 0; the hub grows off its top face, so the // back face (the one that gets faced flat) stays at z = -thickness / 2. const plate = cylinder(od / 2, thickness).tag("plate"); // The hub is modelled buried to the plate's mid-plane rather than standing on // its top face. Two coaxial cylinders that meet exactly on a face cannot be // blended — the kernel refuses at any radius, including 1 mm — and an // overlap is both the fix and what a casting actually is. See docs/DSL_GAPS.md. const hubTop = throughHub - thickness / 2; const hub = cylinder(hubOd / 2, hubTop) .at(0, 0, hubTop / 2) .tag("hub"); // A small blend at the hub root: a sharp internal corner there is a stress // riser, and every real casting has a radius at it. const body = union(plate, hub, { blend: 3 }).tag("body"); // Overlength on purpose. A cutting tool that ends exactly on a face leaves a // zero-thickness sliver that booleans handle badly — extend it past both ends. const throughBore = cylinder(bore / 2, throughHub * 4).tag("bore"); const boltHoles = polar(bolts, boltCircle / 2, { straddle: true }); // One tagged cut, not two. `generatedBy` names a single operation, so bore and // bolt holes have to be drilled by the same node for one selector to reach all // five rims — splitting the cut in two silently halves what the query matches. const drilled = body .cut(throughBore, repeat(cylinder(boltDia / 2, thickness * 4), boltHoles)) .tag("drilled"); // Break the bore rim where a gasket seats. `role: "hole"` is what keeps the // hub's outside rim — same curve, same face normal — out of the selection. return drilled .edges({ generatedBy: "drilled", curve: "circle", role: "hole", adjacentTo: { faceNormal: "-z" }, }) .expect({ count: 5 }) .chamfer(1.5) .tag("back_face_breaks"); ``` ## Spur gear pair, module 2 `examples/spur-gears.js` ```js // A spur gear pair, module 2, 20 and 30 teeth, meshed at their centre // distance: a 20 mm pitch-radius pinion on a 5 mm motor shaft driving a 30 mm // pitch-radius wheel on an 8 mm shaft, 1.5 : 1. // // Every flank is the involute of its base circle, drawn by spurGearOutline as // a curve the script certifies against the formula; the report's // curve_bound_mm is how far that proof lets a flank stray. Tip and root are // exact arcs; below the base circle the flank runs straight in to the // root, where a hobbed gear would have a trochoid that nothing touches. // // Each tooth is thinned by 0.1 mm at the pitch circle. Meshed centred, that // play is shared between the two flanks of every tooth space, and involute // flanks in mesh stay the same distance apart along the line of action, so // the two bodies come no closer than 0.1 · cos 20° = 0.094 mm anywhere — // which `between_bodies` measures on the built solids. const m = 2; const pinionTeeth = 20; const wheelTeeth = 30; const width = 8; const backlash = 0.1; const centres = (m * (pinionTeeth + wheelTeeth)) / 2; const pinion = extrude(spurGearOutline({ module: m, teeth: pinionTeeth, backlash }), width) .cut(cylinder(5 / 2, width * 2)) .tag("pinion"); // A tooth of the pinion points along +X, at the wheel. The wheel's teeth also // start on +X, so turning it half a tooth puts a space on -X to take it. const wheel = extrude(spurGearOutline({ module: m, teeth: wheelTeeth, backlash }), width) .cut(cylinder(8 / 2, width * 2)) .rotate("z", 180 / wheelTeeth) .at(centres, 0, 0) .tag("wheel"); return { pinion, wheel }; ``` ## Twisted star planter `examples/twisted-planter.js` ```js // Twisted planter and its drip saucer, printed as two parts. // // The planter is a six-point star lofted through nine sections, each wider // and turned further than the last, so its walls come out as twisted facets. // The saucer is a dish of domed bumps in rings of 1, 6, 12 and 18, each eased // into the floor with a curvature-continuous blend, which the planter stands on. const points = 6; const height = 90; const twist = 60; // degrees, base to rim const layers = 8; const wall = 3; // inset across the star; the facets' own wall measures 2.0 mm const floor = 3; // At height z: the star's tip radius, flaring fastest near the base, and its turn. const radiusAt = (z) => 34 + 18 * Math.sin((z / height) * (Math.PI / 2)); const turnAt = (z) => (z / height) * twist; function star(z, inset) { const outline = Array.from({ length: 2 * points }, (_, i) => { const r = (i % 2 === 0 ? 1 : 0.78) * radiusAt(z) - inset; const angle = ((i * 180) / points + turnAt(z)) * (Math.PI / 180); return [r * Math.cos(angle), r * Math.sin(angle)]; }); return { z, outline }; } // Section heights shared by the outside and the cavity, so their facets stay parallel. const levels = Array.from({ length: layers + 1 }, (_, i) => (i * height) / layers); const outside = loft(levels.map((z) => star(z, 0))); const cavity = loft([floor, ...levels.filter((z) => z > floor), height + 1].map((z) => star(z, wall))); const drain = cylinder(2.5, 3 * floor); const planter = outside .cut( cavity, // 1 mm past the rim, so the top opens cleanly ...polar(6, 14, { straddle: true }).map(([x, y]) => drain.at(x, y)), // between the saucer's first ring of bumps ) .tag("planter"); // Wide enough that the 34 mm star base sits inside with room for runoff. const saucerRadius = 42; const saucerFloor = 2; const bumpHeight = 2.4; const ringSpacing = 11; const ringCount = 3; // Each bump is a dome: the cap of a sphere whose centre is at the saucer's // underside, so it stays inside the floor. Its crease with the floor is eased // afterwards with smooth(), a blend with no jump in curvature at either end. const domeRadius = saucerFloor + bumpHeight; const easeRadius = 2; // the blend's foot lands 5.0 mm from the bump's axis function bump() { const foot = Math.sqrt(domeRadius ** 2 - saucerFloor ** 2); // where the sphere crosses the floor const shoulder = domeRadius / Math.SQRT2; return revolve([ [0, 0], [foot, 0], [foot, saucerFloor], { through: [shoulder, shoulder] }, [0, saucerFloor + bumpHeight], ]); } const dish = revolve([ [0, 0], { at: [saucerRadius + 2, 0], round: 3 }, [saucerRadius + 2, 11], { through: [saucerRadius + 1, 12] }, [saucerRadius, 11], { at: [saucerRadius, saucerFloor], round: 2 }, [0, saucerFloor], ]); // One bump in the middle, then rings of 6, 12, 18: each ring's circumference grows // by 2π·spacing, so six more keeps every bump the same distance from its neighbours. // Even rings sit half a step round from the odd ones. const one = bump(); const bumps = [one]; for (let ring = 1; ring <= ringCount; ring++) { const count = 6 * ring; const start = ring % 2 === 0 ? 180 / count : 0; for (const [x, y] of polar(count, ring * ringSpacing, { start })) bumps.push(one.at(x, y)); } // Every bump's root is the saucer's only sharp concave circle; the dish's own // corners are rounds, which read as smooth. const saucer = dish .union(...bumps) .edges({ dihedral: "concave", curve: "circle" }) .expect({ count: 1 + 6 * ((ringCount * (ringCount + 1)) / 2) }) .smooth(easeRadius); return { planter: planter.at(0, 0, saucerFloor + bumpHeight), saucer, }; ``` ## Hydraulic manifold `examples/manifold-block.js` ```js // A hydraulic manifold block: a solid with cross-drilled galleries that meet // inside it, plus the plugged drilling access every real manifold has. // // The interesting property of a manifold is that its function lives in the // negative space. Nothing here is a feature on the surface — the part is a // rectangle of metal and a set of intersecting bores, and whether it works // depends on whether those bores actually meet. const w = 80, d = 50, h = 40; const gallery = 8; // main flow bore const port = 10; // threaded port drill const mountHole = 6.6; // clearance for M6 const body = box(w, d, h).tag("body"); // The long gallery runs the length of the block on the centre plane, drilled // in from one end. In a real block the far end is plugged; here it is simply // drilled through, which is the honest representation of the cut. const mainBore = cylinder(gallery / 2, w * 1.2).rotate("y", 90).tag("main_bore"); // Two ports drop from the top face and intersect the gallery. Their depth is // what makes them meet it, and it has to overshoot: a port that bottoms on the // gallery's centreline is tangent to the bore's lower half, which leaves a // flat floor with its own rim instead of an opening. One millimetre past the // far wall of the gallery is a real, unambiguous intersection. const portDepth = h / 2 + gallery / 2 + 1; const dropPort = cylinder(port / 2, portDepth * 2); const ports = union( dropPort.at(-22, 0, h / 2), dropPort.at(22, 0, h / 2), ).tag("ports"); // A cross gallery on the other axis, meeting the main bore at the centre. const crossBore = cylinder(gallery / 2, d * 1.2).rotate("x", 90).tag("cross_bore"); const mounting = repeat( cylinder(mountHole / 2, h * 2), grid(2, 2, w - 20, d - 20), ); const drilled = body .cut(mainBore, ports, crossBore, mounting) .tag("drilled"); // Every rim that opens onto the top face gets a chamfer: two ports and four // mounting holes. Port chamfers are functional here — a sealing fitting needs // the lead-in — so this is not just deburring. // // `at: { z: "max" }` and not `adjacentTo: { faceNormal: "+z" }`, which is what // the other examples use. A rim is "adjacent to" its own cylindrical wall as // well as the flat face it sits in, and a bore drilled along X reports that // wall as +Z-facing, so the face-normal form also picks up both end rims of // the main gallery. See docs/DSL_GAPS.md; on a part made mostly of cross // drillings, position is the selector that says what was meant. return drilled .edges({ generatedBy: "drilled", curve: "circle", role: "hole", at: { z: "max" }, }) .expect({ count: 6 }) .chamfer(1) .tag("top_face_lead_ins"); ``` ## Screw-top jar, M40 × 3 `examples/screw-top-jar.js` ```js // Screw-top jar: a jar with a threaded neck and its cap, printed as two parts. // // The thread is modelled, not drawn as a tap drill: M40 × 3 (ISO 261 fine) // with the ISO 68-1 basic profile, `threadedRod` on the neck and // `threadedHole` in the cap, each given the same radial clearance so the pair // turns freely off the printer. // // A thread and its mate only fit in phase. Both functions put the tooth on +X // at z = 0 of their own frame; the neck's frame is at z = 48 and the cap's // cutter's at z = 46, two millimetres, or 2/3 of a pitch, lower. Turning the // cutter 360° × 2/3 = 240° backwards — the same as 120° forwards — puts it // back in phase, and `between_bodies` reads the flank gap: // clearance · (2/√3) / √(4/3 + (P / 2πr)²), 0.2499 mm at the cap's crest. // // Jar: a Ø50 × 45 body with a 2 mm wall and floor, the neck's 7.5 mm of thread // above it, and a Ø34 bore through the neck. Cap: Ø48 × 12, the thread cut // 10 mm up into it from its open face, 2 mm of roof above. // // A horizontal slice of a thread has the same area at every height, so any // length L of one is π r1² L + 2π L / P · ∫ r w(r) dr, w the tooth's width: // 8369.020 mm³ for the neck's 7.5 mm above the body and 11758.317 mm³ for the // cap's 10 mm of cutter. Jar: 88357.293 + 8369.020 − 68138.003 (cavity) − // 8625.243 (bore, 43 to 52.5) = 19963.068 mm³. Cap: 21714.688 − 11758.317 = // 9956.371 mm³. The exact solids read 19963.054 and 9956.381. const neck = { diameter: 40, pitch: 3 }; const play = 0.25; // clearance on each part, radially; tune it on the printer const bodyR = 25, bodyH = 45, wall = 2, floor = 2; const neckLength = 9, neckZ = 48; // thread from 43.5 to 52.5, 1.5 of it inside the body const boreR = 17; const capR = 24, capBottom = 46, capHeight = 12, capDepth = 10; const jar = cylinder(bodyR, bodyH) .at(0, 0, bodyH / 2) .union(threadedRod(neck, neckLength, { clearance: play }).at(0, 0, neckZ)) // The bore first, from past the neck's top down into where the cavity will // be, so the cavity's cut opens into it rather than sealing a void. .cut(cylinder(boreR, 13).at(0, 0, 48.5)) .cut(cylinder(bodyR - wall, bodyH - floor - wall).at(0, 0, floor + (bodyH - floor - wall) / 2)) .tag("jar"); const cap = cylinder(capR, capHeight) .at(0, 0, capBottom + capHeight / 2) .cut( threadedHole(neck, capDepth, { clearance: play }) .rotate("x", 180) // enter from below, going up .rotate("z", 120) // back in phase with the neck; see above .at(0, 0, capBottom), ) .tag("cap"); return { jar, cap }; ``` ## Socket-weld pipe tee `examples/pipe-tee.js` ```js // A socket-weld pipe tee for 1" schedule 40 pipe. // // The pipe it joins is 33.4 OD, 3.38 wall, so the bore is 26.64. The fitting // body is deliberately fatter than the pipe: its sockets have to swallow the // pipe OD and still leave a wall, which is why the body diameters are not // `pipeOd`. The run and branch bodies differ because a blended union of two // equal-radius cylinders crossing at 90° is refused at this size — // see docs/DSL_GAPS.md; unequal radii is also what a real fitting looks like. A // socket cut wider than the body would not be a counterbore at all — it would // saw the end off, and the reported bounding box is where that shows up. // // Two bodies of revolution crossing at 90° is the shape booleans are best at, // and the reason this is a good part to test a kernel with: the branch // intersection curve is a genuine 3D curve, not a circle, outside and in. const pipeOd = 33.4; const runOd = 48; // fitting body around the run const branchOd = 42; // fitting body around the branch const bore = 26.64; const run = 100; // end to end along X const branch = 55; // centre to branch face along Z const socketDia = 33.9; // slip fit over the pipe OD const socketDepth = 12; const runBody = cylinder(runOd / 2, run).rotate("y", 90).tag("run"); const branchBody = cylinder(branchOd / 2, branch) .at(0, 0, branch / 2) .tag("branch"); // The blend at the crotch is what a cast or forged fitting actually has, and // it is structurally the point: the bare intersection of two cylinders is a // stress concentration exactly where the pressure load is highest. const body = union(runBody, branchBody, { blend: 2 }).tag("body"); // One bore through the run, one down the branch. They meet inside, so the // hollow is a single connected volume — as it must be for a fitting. const runBore = cylinder(bore / 2, run * 1.2).rotate("y", 90); const branchBore = cylinder(bore / 2, branch * 1.2).at(0, 0, branch / 2); // Sockets: a counterbore at each of the three ends that the pipe slips into, // stopping on a shoulder. Modelled overlength outward for the same reason // every other cutter here is. const socket = cylinder(socketDia / 2, socketDepth * 2); const sockets = union( socket.rotate("y", 90).at(run / 2, 0, 0), socket.rotate("y", 90).at(-run / 2, 0, 0), socket.at(0, 0, branch), ); const bored = body.cut(runBore, branchBore, sockets).tag("bored"); // Break the branch's socket mouth, the rim a pipe is pushed into. Selecting on // position rather than face normal is deliberate: a rim also borders its own // cylindrical wall, and on this part those walls point in every direction. return bored .edges({ generatedBy: "bored", curve: "circle", role: "hole", at: { z: "max" } }) .expect({ count: 1 }) .chamfer(1.5) .tag("branch_socket_lead_in"); ``` ## Pleated lampshade `examples/pleated-shade.js` ```js // A pleated pendant shade: two six-pointed stars, each divided evenly and // every point pushed along its own normal — out on even points, in on odd — // in equal increments, frozen where the outline would touch itself; each // corner of the result rounded; a tween between the two with a sine swell; // and a loft *surface* through the tweens, thickened into the printable wall. scriptBudget(4); const points = 48; // divisions of each star: one pleat per two points const increments = 20; // steps the pleating is pushed out in const height = 200; const levels = 21; // sections the surface is lofted through const wall = 1.4; // printed wall, centred on the surface // The narrowest a pleat may close: two walls and the gap a nozzle needs // between them, or the thickened walls would run into each other. const closest = wall + 0.8; const smoothing = 0.25; // share of the vertex rule blended in each step const flare = 0.22; // the sine swell of the silhouette, as a share of the radius // Corners are rounded to `tipRadius`; one whose folds are too short for that // is rounded tighter, but never below `tightest`. A fitted curve turns // tighter between its points than the points do, and `thicken` refuses a // wall that would leave its inside skin all but folded, so both stay well // clear of half the wall. const tipRadius = 2.2; const tightest = 1.2; // A star as a smooth closed curve, divided into equal lengths. function star(tips, outer, inner, twist) { const dense = 4000; const raw = Array.from({ length: dense }, (_, i) => { const a = (2 * Math.PI * i) / dense; const r = (outer + inner) / 2 + ((outer - inner) / 2) * Math.cos(tips * a); return [r * Math.cos(a + twist), r * Math.sin(a + twist)]; }); const run = [0]; for (let i = 1; i <= dense; i++) { const p = raw[i - 1]; const q = raw[i % dense]; run.push(run[i - 1] + Math.hypot(q[0] - p[0], q[1] - p[1])); } const total = run[dense]; const out = []; let k = 0; for (let i = 0; i < points; i++) { const target = (i * total) / points; while (run[k + 1] < target) k++; const f = (target - run[k]) / (run[k + 1] - run[k]); const p = raw[k]; const q = raw[(k + 1) % dense]; out.push([p[0] + f * (q[0] - p[0]), p[1] + f * (q[1] - p[1])]); } return out; } // Unit normal from the chord between a point's neighbours: outward for an // anticlockwise ring. function normals(ring) { const n = ring.length; return ring.map((_, i) => { const a = ring[(i + n - 1) % n]; const b = ring[(i + 1) % n]; const tx = b[0] - a[0]; const ty = b[1] - a[1]; const len = Math.sqrt(tx * tx + ty * ty); return [ty / len, -tx / len]; }); } // Radius of the circle through a point and its neighbours, and which side // the ring turns toward there (+1 outward, -1 inward). function bend(ring, i) { const n = ring.length; const p = ring[(i + n - 1) % n]; const q = ring[i]; const r = ring[(i + 1) % n]; const cross = (q[0] - p[0]) * (r[1] - q[1]) - (q[1] - p[1]) * (r[0] - q[0]); const a = Math.hypot(q[0] - p[0], q[1] - p[1]); const b = Math.hypot(r[0] - q[0], r[1] - q[1]); const c = Math.hypot(r[0] - p[0], r[1] - p[1]); const radius = Math.abs(cross) < 1e-12 ? Infinity : (a * b * c) / (2 * Math.abs(cross)); // An anticlockwise ring turning left bends toward its inside. return { radius, concave: cross > 0 ? -1 : 1 }; } // The cubic B-spline vertex rule — Chaikin's corner cutting read back onto // the points — blended in by `smoothing`. function rebuild(ring) { const n = ring.length; return ring.map((q, i) => { const p = ring[(i + n - 1) % n]; const r = ring[(i + 1) % n]; const sx = p[0] / 8 + (3 * q[0]) / 4 + r[0] / 8; const sy = p[1] / 8 + (3 * q[1]) / 4 + r[1] / 8; return [q[0] + smoothing * (sx - q[0]), q[1] + smoothing * (sy - q[1])]; }); } // Push every point out or in by `amplitude` over `increments` steps. A point // moving into a bend moves no further than most of that bend's radius, and a // point whose ring would cross itself or close past `closest` is frozen where // it was. function pleat(base, amplitude) { const baseNormals = normals(base); const offset = new Array(points).fill(0); const frozen = new Array(points).fill(false); const place = () => rebuild(base.map((p, i) => [p[0] + baseNormals[i][0] * offset[i], p[1] + baseNormals[i][1] * offset[i]])); let ring = place(); for (let step = 0; step < increments; step++) { const previous = offset.slice(); for (let i = 0; i < points; i++) { if (frozen[i]) continue; const out = i % 2 === 0 ? 1 : -1; let move = (out * amplitude) / increments; const { radius, concave } = bend(ring, i); if (Math.sign(move) === concave) move = Math.sign(move) * Math.min(Math.abs(move), 0.8 * radius); offset[i] += move; } ring = place(); const touching = new Set(); for (const [i, j] of outlineCrossings(ring)) { for (const k of [i, i + 1, j, j + 1]) touching.add(k % points); } const gaps = outlineGaps(ring, { ignoreWithin: 2 * closest, upTo: closest }); gaps.forEach((g, i) => { if (g < closest) touching.add(i); }); if (touching.size) { for (const i of touching) { offset[i] = previous[i]; frozen[i] = true; } ring = place(); } } return ring; } // The pleated points as a curve with every corner rounded: an arc tangent to // the two straight folds either side, then the fold. Every corner is sampled // the same number of times on every star, so the points pair across the // sections the surface is lofted through. function filletRing(ring, radius, roundSamples, foldSamples) { const n = ring.length; const out = []; const corners = ring.map((q, i) => { const p = ring[(i + n - 1) % n]; const r = ring[(i + 1) % n]; const u = [q[0] - p[0], q[1] - p[1]]; const v = [r[0] - q[0], r[1] - q[1]]; const lu = Math.hypot(u[0], u[1]); const lv = Math.hypot(v[0], v[1]); const du = [u[0] / lu, u[1] / lu]; const dv = [v[0] / lv, v[1] / lv]; const turn = Math.acos(Math.max(-1, Math.min(1, du[0] * dv[0] + du[1] * dv[1]))); // The round may take up to 45 % of the shorter fold either side. const round = Math.min(radius, (0.45 * Math.min(lu, lv)) / Math.tan(turn / 2)); if (round < tightest) { throw new Error(`a fold of ${Math.min(lu, lv).toFixed(2)} mm turning ${(turn * 180 / Math.PI).toFixed(0)}° can only be rounded to ${round.toFixed(2)} mm, under ${tightest}; make the pleats shallower`); } const cut = round * Math.tan(turn / 2); const start = [q[0] - du[0] * cut, q[1] - du[1] * cut]; const end = [q[0] + dv[0] * cut, q[1] + dv[1] * cut]; const side = du[0] * dv[1] - du[1] * dv[0] > 0 ? 1 : -1; const centre = [start[0] - side * du[1] * round, start[1] + side * du[0] * round]; return { start, end, centre, turn, side, round }; }); for (let i = 0; i < n; i++) { const c = corners[i]; const a0 = Math.atan2(c.start[1] - c.centre[1], c.start[0] - c.centre[0]); for (let k = 0; k < roundSamples; k++) { const a = a0 + c.side * c.turn * (k / roundSamples); out.push([c.centre[0] + c.round * Math.cos(a), c.centre[1] + c.round * Math.sin(a)]); } const next = corners[(i + 1) % n].start; for (let k = 0; k < foldSamples; k++) { const t = k / foldSamples; out.push([c.end[0] + t * (next[0] - c.end[0]), c.end[1] + t * (next[1] - c.end[1])]); } } return out; } // The top star is turned π/4 against the bottom one. Points pair by index // across the sections, so each pleat climbs to the matching pleat of the top // star and the pleats twist as they rise. const bottom = filletRing(pleat(star(6, 90, 72, 0), 12), tipRadius, 4, 10); const top = filletRing(pleat(star(6, 64, 52, Math.PI / 4), 10), tipRadius, 4, 10); const centroid = (ring) => ring.reduce((c, p) => [c[0] + p[0] / ring.length, c[1] + p[1] / ring.length], [0, 0]); const sections = Array.from({ length: levels }, (_, k) => { const t = k / (levels - 1); const z = t * height; const tween = bottom.map((p, i) => [p[0] + t * (top[i][0] - p[0]), p[1] + t * (top[i][1] - p[1])]); const [cx, cy] = centroid(tween); const s = 1 + flare * Math.sin(Math.PI * t); return { z, curve: [{ fit: tween.map(([x, y]) => [cx + s * (x - cx), cy + s * (y - cy)]), tolerance: 0.05 }] }; }); const shade = surfaceLoft(sections, { closed: true, smooth: true }).tag("shade"); // A thickened wall's rims lean with the surface; a slab off each end leaves // the flat ring a printer starts on and a clean top, and the part is lowered // by what the bottom slab took, so that ring is the bed at z = 0. const rim = 0.4; const slab = box(400, 400, 20); return shade.thicken(wall).cut(slab.at(0, 0, rim - 10), slab.at(0, 0, height - rim + 10)).at(0, 0, -rim); ``` ## Wash bottle `examples/wash-bottle.js` ```js // A wash bottle: a turned body whose outline is one section — straight // walls, a spline shoulder, a neck with a rolled bead and a radiused base — // hollowed by a second section, with a smooth spout tube curving out of the // shoulder. // // Everything curved is drawn in section rather than filleted afterwards: // `{ at, round }` for the base radius, `{ spline }` for the shoulder, // `{ through }` for the bead on the neck. The spout is `pipe({ spline })`, // one exact curve rather than runs and bends. const bodyR = 32; const shoulderZ = 70; const neckR = 11; const neckZ = 114; const topZ = 132; const wall = 2; const outer = revolve([ [0, 0], { at: [bodyR, 0], round: 6 }, [bodyR, shoulderZ], { spline: [[28, 95], [16, 108]], start: [0, 1], end: [-0.4, 1] }, [neckR, neckZ], [neckR, topZ - 6], { through: [neckR + 2, topZ - 4] }, [neckR, topZ - 2], [neckR, topZ], [0, topZ], ]).tag("body"); // The cavity: the same outline brought in by the wall, open through the neck. const cavity = revolve([ [0, wall], { at: [bodyR - wall, wall], round: 4 }, [bodyR - wall, shoulderZ], { spline: [[26, 94], [14, 106]], start: [0, 1], end: [-0.4, 1] }, [neckR - wall, neckZ], [neckR - wall, topZ + 1], [0, topZ + 1], ]).tag("cavity"); const spout = pipe({ spline: [[20, 0, 96], [40, 0, 112], [62, 0, 116], [80, 0, 108]] }, 5).tag("spout"); const bore = pipe({ spline: [[20, 0, 96], [40, 0, 112], [62, 0, 116], [80, 0, 108]] }, 2.6); return union(outer, spout).cut(cavity, bore); ``` ## Knurled knob, D-bore `examples/knurled-knob.js` ```js // A knurled control knob for a 6 mm shaft with a flat — the D-bore that stops // the knob from spinning on the shaft. // // The knurl is 24 axial flutes cut with a small cylinder each. That is what a // moulded knob really has; a machined diamond knurl is a different process and // would need a helical cut, which this DSL cannot express (docs/DSL_GAPS.md). const knobDia = 30; const knobH = 16; const shaft = 6; const flatDepth = 0.5; // how much of the shaft is flatted (a "D" shaft) const flutes = 24; const fluteDia = 3; const body = cylinder(knobDia / 2, knobH).tag("body"); // One flute, placed on the rim once and spun around the axis. Centred on the // rim, so half of it cuts in and half cuts air — which is what makes the flute // a scallop rather than a slot. const flute = cylinder(fluteDia / 2, knobH * 1.2).at(knobDia / 2, 0); const knurl = around(flute, flutes).tag("knurl"); // The D-bore: a round hole with one side flatted off. The flat is what // transmits torque, so its depth is a fit dimension, not decoration. // // Intersection, not union. Adding a box to the cutter would push the flat out // past the bore and cut a keyway slot into the knob instead — the shape is // "the cylinder, trimmed" and has to be written that way. const bore = intersect( cylinder(shaft / 2, knobH * 2), box(shaft, shaft - flatDepth, knobH * 2).at(0, -flatDepth / 2), ).tag("d_bore"); // A dished top, so a thumb sits in the knob rather than on it. A sphere large // enough that only its bottom cap enters the material makes a shallow dish; // its radius sets how shallow. const dish = sphere(26).at(0, 0, knobH / 2 + 26 - 2).tag("dish"); const turned = body.cut(knurl, dish).tag("turned"); // The bore is cut in its own tagged step so the lead-in below can name it. // Rolled into the same cut as the knurl, `generatedBy` would also cover the // forty-odd flute edges around the bottom rim, and the count would say nothing. const bored = turned.cut(bore).tag("bored"); // Break the bottom of the bore — the edge that is pushed onto the shaft. // // Two edges: a D-bore's outline is the straight flat plus one arc, and that is // what the count asserts. It was five until the backend started merging // same-domain faces, which welded the arc's four pieces — 4.71 + 2.95 + 4.71 + // 2.95 mm — back into the single 15.33 mm curve they always described. // `role: "hole"` would match nothing here either way, because it wants a closed // circle and this outline is not one. return bored .edges({ generatedBy: "bored", at: { z: "min" } }) .expect({ count: 2 }) .chamfer(0.5) .tag("bore_lead_in"); ``` ## Rod-end clevis `examples/clevis.js` ```js // A threaded rod-end clevis: a shank with spanner flats, and a two-armed fork // for a 10 mm pin. // // This is the part that shows what `mirror` is for. The fork is symmetric, so // one arm is authored and the other is its reflection — before mirror existed // the second arm was a second copy of the same arithmetic with the signs // changed by hand, which is how a part ends up with one arm 13 mm thick and the // other 13.5. `ngon` is the other new one: spanner flats are quoted across the // flats, and that is what the call says. const shankDia = 20; const shankLen = 26; const drill = tapDrill("M12"); // 10.2, for M12 x 1.75 const threadDepth = 20; const flats = 17; // across the flats, a 17 mm spanner const flatsLen = 12; const crownW = 40; const crownD = 24; const crownT = 8; const armT = 13; // each arm const gap = 14; // the tongue that fits between them const armH = 26; const pinDia = 10; const pinZ = crownT + armH - 9; // The shank hangs below the crown. It runs up past z = 0 so the crown has // something to sit *in* rather than *on*: a blended union of two solids that // only touch on a face cannot be blended (docs/GOTCHAS.md). const shank = cylinder(shankDia / 2, shankLen + 3).at(0, 0, (-shankLen + 3) / 2); // Spanner flats: a band of everything outside the hexagon, taken off the round // shank. The hexagon is the thing being kept, so the cutter is the band with // the hexagon removed from it — which reads as what a machinist would say. const band = box(shankDia * 2, shankDia * 2, flatsLen) .at(0, 0, -shankLen + flatsLen / 2) .cut(ngon(6, flats, flatsLen, { across: "flats" }).at(0, 0, -shankLen + flatsLen / 2)); const crown = box(crownW, crownD, crownT).at(0, 0, crownT / 2); // One arm, authored on +X, and its reflection. `union` is separate from // `mirror` on purpose: the reflection alone is the left-hand part. const arm = box(armT, crownD, armH).at(gap / 2 + armT / 2, 0, crownT + armH / 2); const fork = union(arm, arm.mirror("x")); const body = union(shank, crown, fork).tag("body"); // The tapped hole is drawn as its tap drill — there is no thread op, and a // stack of tori pretending to be one is the approximation this project refuses. // It starts below the end face so the cutter crosses it rather than ending on // it, and reaches the called-out depth. const thread = cylinder(drill / 2, threadDepth + 4) .at(0, 0, -shankLen - 4 + (threadDepth + 4) / 2) .tag("tap_drill"); const turned = body.cut(band, thread).tag("turned"); // Across both arms, and out the far side of each. const pin = cylinder(pinDia / 2, crownW * 2).rotate("y", 90).at(0, 0, pinZ); const machined = turned.cut(pin).tag("machined"); // Four pin-hole rims: two arms, two faces each, and no others because the bore // is its own cut. The count is the assertion that the pin hole still goes all // the way through — an arm moved outboard of it would leave two. return machined .edges({ generatedBy: "machined", curve: "circle", role: "hole" }) .expect({ count: 4 }) .chamfer(0.5) .tag("pin_lead_in"); ``` ## Bent hydraulic line `examples/hydraulic-line.js` ```js // A bent hydraulic line with a flare fitting boss at each end, and the O-ring // groove that seals one of them. // // `pipe` routes 12 mm tube through 20 mm bends — straight runs and partial // tori, both exact, which is what a tube bender makes. A hose that curves // continuously would be `pipe({ spline: [...] }, d)` instead. // // The inlet boss carries its O-ring gland, drawn the way a catalogue draws // one: a rectangular section wider than the cord, with its bottom corners // radiused. It was a round-bottomed torus cut until sections could hold a // rounded corner, because a torus is a circle in section and nothing else // was — see docs/DSL_GAPS.md, "arcs in a section". The coaxial seams a cut // like this leaves in a cylinder wall once segfaulted `UnifySameDomain`; // `eval/cases/torus-gland.json` holds that fix. const tube = 12; const wall = 1.5; const bend = 20; // centreline bend radius, 1.67 x diameter const bossDia = 24; const bossLen = 14; const cord = 2; // O-ring cord diameter, 2 mm metric const glandDepth = 1.5; // 25% squeeze: 0.5 mm of the cord stands proud const glandWidth = 2.7; // wider than the cord, so it has room to deform const glandRadius = 0.3; // bottom corner radius, the catalogue's 0.2–0.4 const glandFromEnd = 4; // back from the free end, clear of the fitting's lead-in // The route: out of the pump, along, up, and across to the manifold. Each // corner gets the same bend, which is what one tool setting gives you. const route = [ [0, 0, 0], [70, 0, 0], [70, 55, 0], [70, 55, 40], [130, 55, 40], ]; const line = pipe(route, tube, { bend }).tag("line"); // The cutter is the gland's section in (radius, z), revolved: its floor // `glandDepth` under the boss surface with both floor corners rounded, and // its outer side 1 mm proud of the boss so the cut breaks the surface // cleanly. Turned onto the boss's X axis afterwards. const floor = bossDia / 2 - glandDepth; const proud = bossDia / 2 + 1; const gland = revolve([ { at: [floor, -glandWidth / 2], round: glandRadius }, [proud, -glandWidth / 2], [proud, glandWidth / 2], { at: [floor, glandWidth / 2], round: glandRadius }, ]) .rotate("y", 90) .at(glandFromEnd, 0, 0) .tag("gland"); // A boss at each end, over the tube, for the fitting to thread into. Each sits // on the run it belongs to, so it is placed by the route's own numbers. const inlet = cylinder(bossDia / 2, bossLen) .rotate("y", 90) .at(bossLen / 2, 0, 0) .cut(gland); const outlet = cylinder(bossDia / 2, bossLen) .rotate("y", 90) .at(130 - bossLen / 2, 55, 40); const body = union(line, inlet, outlet).tag("body"); // The bore is the same route at the wall diameter — one `pipe` call cannot be // reused at two sizes, but the route can, and that is the part that must not // drift. Both ends are pushed 2 mm past the tube ends: a cutter that stops // exactly on the face it exits leaves a zero-thickness sliver, and here it // killed the kernel outright rather than producing a bad solid. const past = 2; const boreRoute = [ [-past, 0, 0], ...route.slice(1, -1), [130 + past, 55, 40], ]; const bore = pipe(boreRoute, tube - 2 * wall, { bend }).tag("bore"); const machined = body.cut(bore).tag("machined"); // Both tube ends, where the fitting seats. Two rims, and the count is the // assertion that the bore still runs the whole route: a bend radius that stops // fitting would break the chain and leave a different number. return machined .edges({ generatedBy: "machined", curve: "circle", role: "hole" }) .expect({ count: 2 }) .chamfer(0.5) .tag("seat_chamfer"); ``` ## Extruded heat sink `examples/heat-sink.js` ```js // An extruded-profile heat sink, 60 x 60, with a plain fin array. // // Fin pitch and thickness are the whole design: closer fins add surface area // but choke natural convection, and 2 mm walls at a 6 mm pitch is the usual // compromise for an extrusion. The two mounting holes are on the diagonal of // a 30 mm square, which is the common pattern for clamping to a TO-247. const plan = 60; const baseT = 5; const finT = 2; const finH = 25; const pitch = 6; const fins = 9; const base = box(plan, plan, baseT).at(0, 0, baseT / 2).tag("base"); // One fin, reused at every station. Because shapes are values, this is a // single node in the graph with nine placements — not nine cylinders' worth // of duplicated intent. const fin = box(finT, plan, finH + baseT).at(0, 0, (finH + baseT) / 2); const stations = Array.from({ length: fins }, (_, i) => [ (i - (fins - 1) / 2) * pitch, 0, ]); const body = union(base, repeat(fin, stations)).tag("body"); const mount = cylinder(1.7, baseT * 4); const drilled = body .cut(repeat(mount, [[-15, -15], [15, 15]])) .tag("drilled"); // Only the underside rims: that face beds against the device and must not // carry a burr. The fin roots are untouched — a fillet there would be nice // for casting but this profile is extruded, and the die makes that corner. return drilled .edges({ generatedBy: "drilled", curve: "circle", role: "hole", adjacentTo: { faceNormal: "-z" }, }) .expect({ count: 2 }) .chamfer(0.4) .tag("seat_face_deburr"); ```