partforge 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -88,7 +88,7 @@ handles. The same code runs on **Manifold** (fast meshes — preview + STL + 3MF
88
88
  |---|---|
89
89
  | `k.cylinder(rBottom, rTop, h, { center? })` | cylinder/cone along +Z (frustum if radii differ) |
90
90
  | `k.box(min, max)` | axis-aligned box from `[x,y,z]` min/max |
91
- | `k.prism(points2D, h)` | extrude a 2-D polygon (CCW `[[x,y],…]`) from z=0 |
91
+ | `k.prism(points2D, h, { twist?, scaleTop? })` | extrude a 2-D polygon from z=0; optional `twist` (degrees over the height) and `scaleTop` (uniform top taper: 1 straight, <1 taper in, 0 → point/cone) |
92
92
  | `k.sphere(r)` | sphere centred at the origin |
93
93
  | `k.revolve(points2D, { degrees })` | revolve a lathe profile `[[r,z],…]` (r ≥ 0) around the Z axis (full or partial) |
94
94
  | `k.helixSweptTube({ pathR, profileR, pitch, turns, z0, lefthand })` | circle swept along a helix (e.g. a rope groove) |
@@ -108,6 +108,7 @@ entry pulls in the DOM viewer/controls, and your build functions run in a Web Wo
108
108
  | `s.translate([x,y,z])` | move |
109
109
  | `s.rotate(deg, center, axis)` | rotate `deg` about `axis` through `center` |
110
110
  | `s.mirror("XY"\|"XZ"\|"YZ")` | mirror across a plane |
111
+ | `s.scale(factor, center?)` | uniform scale (single factor) about `center` (default origin) — scaling an off-origin part about the origin also moves it; pass a center (e.g. `s.boundingBox().center`) to resize in place |
111
112
  | `s.clone()` | independent copy (replicad consumes solids on transform) |
112
113
  | `s.boundingBox()` | `{ min, max, center, size }` axis-aligned bounds (query) |
113
114
  | `s.volume()` | volume in mm³ (Manifold) |
@@ -117,6 +118,18 @@ entry pulls in the DOM viewer/controls, and your build functions run in a Web Wo
117
118
  You normally only call the *make/combine/transform* ops; the framework handles
118
119
  `toMesh`/`toSTL`/`toIndexedMesh`/`toSTEP`. Units are millimetres.
119
120
 
121
+ ### Caching & determinism
122
+
123
+ The preview kernel memoizes geometry by content hash, so editing a parameter only
124
+ re-runs the operations that parameter actually affects. For this to be sound, a
125
+ `build` must be a **pure function of `(k, p, d)`** — no `Math.random`, no clock, no
126
+ module-level mutable state. An impure build will silently return stale geometry.
127
+
128
+ Cache granularity follows the operations you call. Booleans and heavy primitives are
129
+ cached; cheap transforms are recomputed. To make a multi-step shape into a single
130
+ cache node, use (or add) a **compound op** like `k.boredCylinder({ od, h, bore })` —
131
+ it hashes from its own arguments and never exposes its internals to the cache.
132
+
120
133
  ---
121
134
 
122
135
  ## Parameters: the control-panel schema
@@ -250,6 +263,10 @@ Pure helpers from `partforge/geometry` (no backend dependency):
250
263
  `slotPolygon(length,r)` (overall length = `length + 2r`), `starPolygon(points,outerR,innerR)`,
251
264
  `ringSectorPolygon(innerR,outerR,arcDeg)` (**arcDeg < 360** — a full ring is a contour-with-hole;
252
265
  cut an inner cylinder from an outer one instead).
266
+ `circleProfile(r, center?)` — a circle of radius `r` centered at `[cx,cy]` (default origin).
267
+ Compose it for round solids: `k.prism(circleProfile(r), h)` is a cylinder, and
268
+ **a torus is `k.revolve(circleProfile(minorR, [majorR, 0]))`** (with `majorR > minorR`) —
269
+ partforge has no `torus` primitive because it's just a revolved circle.
253
270
 
254
271
  **Patterns** (return `Solid[]` — feed to `k.union(...)` for features or `s.cutAll(...)` for holes):
255
272
  `linearPattern(solid, count, [dx,dy,dz])`, `circularPattern(solid, count, { center, axis, angle, rotateCopies })`.
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
- "repository": { "type": "git", "url": "git+https://github.com/scottsykora/partforge.git" },
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/scottsykora/partforge.git"
10
+ },
8
11
  "homepage": "https://github.com/scottsykora/partforge#readme",
9
12
  "bugs": "https://github.com/scottsykora/partforge/issues",
10
13
  "engines": {
@@ -0,0 +1,40 @@
1
+ // src/framework/debug-overlay.js
2
+ // A dev-only overlay (shown under ?debug) with a caching on/off toggle and a
3
+ // readout of the last build's time + what the cache did. Self-contained: it
4
+ // creates its own DOM and knows nothing about geometry — mount.js wires it in.
5
+ export function createDebugOverlay({ initialCachingOn = true, onToggle } = {}) {
6
+ const box = document.createElement("div");
7
+ box.id = "pf-debug";
8
+ Object.assign(box.style, {
9
+ position: "fixed", bottom: "12px", right: "12px", zIndex: "9999",
10
+ font: "12px ui-monospace, monospace", background: "rgba(0,0,0,0.7)",
11
+ color: "#e6e6e6", padding: "8px 10px", borderRadius: "6px",
12
+ lineHeight: "1.5", whiteSpace: "pre",
13
+ });
14
+
15
+ const label = document.createElement("label");
16
+ label.style.cssText = "display:block;cursor:pointer;margin-bottom:4px";
17
+ const cb = document.createElement("input");
18
+ cb.type = "checkbox";
19
+ cb.checked = initialCachingOn;
20
+ cb.style.marginRight = "6px";
21
+ label.append(cb, document.createTextNode("Caching"));
22
+
23
+ const readout = document.createElement("div");
24
+ readout.textContent = "build: —";
25
+
26
+ box.append(label, readout);
27
+ document.body.appendChild(box);
28
+
29
+ cb.addEventListener("change", () => onToggle?.(cb.checked));
30
+
31
+ return {
32
+ update({ ms, hits = 0, misses = 0, skipped = 0, rebuilt = 0 } = {}) {
33
+ const l2 = cb.checked ? `${hits} hit / ${misses} miss` : "off";
34
+ readout.textContent =
35
+ `build: ${ms != null ? Math.round(ms) + " ms" : "—"}\n` +
36
+ `L2 ops: ${l2}\n` +
37
+ `L1 parts: ${skipped} skipped / ${rebuilt} rebuilt`;
38
+ },
39
+ };
40
+ }
@@ -3,6 +3,7 @@
3
3
 
4
4
  /**
5
5
  * @typedef {Object} Solid An opaque handle to a backend solid.
6
+ * @property {string} _hash content hash (Manifold backend only; drives the worker solid cache)
6
7
  * @property {(tool: Solid) => Solid} cut
7
8
  * @property {(tools: Solid[]) => Solid} cutAll batch subtract (backend-optimized)
8
9
  * @property {(other: Solid) => Solid} intersect boolean intersection (Manifold)
@@ -12,6 +13,7 @@
12
13
  * @property {(v: number[]) => Solid} translate
13
14
  * @property {(deg: number, center: number[], axis: number[]) => Solid} rotate
14
15
  * @property {(plane: "XY"|"XZ"|"YZ") => Solid} mirror
16
+ * @property {(factor:number, center?:number[]) => Solid} scale uniform scale about center (default origin)
15
17
  * @property {() => number} volume solid volume in mm³ (Manifold; used by collision tests)
16
18
  * @property {(opts?: {quality?: "preview"|"print"}) => {positions:Float32Array, normals:Float32Array, indices:Uint32Array, triangles:number}} toMesh
17
19
  * @property {(opts?: {quality?: "preview"|"print"}) => Promise<ArrayBuffer>} toSTL
@@ -19,9 +21,10 @@
19
21
  *
20
22
  * @typedef {Object} GeometryKernel
21
23
  * @property {(rBottom:number, rTop:number, h:number, opts?:{center?:boolean}) => Solid} cylinder
24
+ * @property {(o:{od:number,h:number,bore:number}) => Solid} boredCylinder compound: bored-through cylinder (one cache node)
22
25
  * @property {(r:number) => Solid} sphere sphere centred at the origin
23
26
  * @property {(min:number[], max:number[]) => Solid} box
24
- * @property {(points2D:number[][], h:number) => Solid} prism extrude polygon from z=0
27
+ * @property {(points2D:number[][], h:number, opts?:{twist?:number,scaleTop?:number}) => Solid} prism extrude polygon from z=0 (optional twist° + uniform top taper)
25
28
  * @property {(points2D:number[][], opts?:{degrees?:number}) => Solid} revolve revolve a lathe profile [[r,z],…] around Z
26
29
  * @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
27
30
  * @property {(solids:Solid[]) => Solid} union
@@ -1,5 +1,7 @@
1
1
  import { helixTube } from "./helix-tube.js";
2
2
  import { KernelCapabilityError } from "./errors.js";
3
+ import { h } from "./solid-hash.js";
4
+ import { createSolidCache } from "./solid-cache.js";
3
5
 
4
6
  const PLANE_NORMAL = { XY: [0, 0, 1], XZ: [0, 1, 0], YZ: [1, 0, 0] };
5
7
  // 'preview' = interactive view (fast); 'print' = STL export (high-res, used only
@@ -22,6 +24,14 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
22
24
  const T = (obj) => { tracked.push(obj); return obj; };
23
25
  const unionRaw = (ms) => ms.reduce((a, b) => T(a.add(b))); // track each reduce step
24
26
 
27
+ const cache = createSolidCache();
28
+ // Boundary ops route through cache.lookup; on a miss `make` runs the WASM op,
29
+ // tracks the result, and returns the triple the cache needs to pin/dispose it.
30
+ const cached = (hash, computeM) => cache.lookup(hash, () => {
31
+ const m = computeM(); // already T()-tracked by the op
32
+ return { value: wrap(m, hash), pin: m, dispose: () => m.delete?.() };
33
+ });
34
+
25
35
  // Copy the mesh out into JS-owned arrays (so it survives cleanup) and free the
26
36
  // transient mesh handle.
27
37
  function meshOut(m, asStl) {
@@ -48,12 +58,14 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
48
58
  return { positions, indices };
49
59
  }
50
60
 
51
- const wrap = (m) => ({
61
+ const wrap = (m, hash) => ({
52
62
  _m: m,
53
- cut: (t) => wrap(T(m.subtract(t._m))),
54
- cutAll: (tools) => wrap(T(m.subtract(unionRaw(tools.map((t) => t._m))))),
55
- intersect: (t) => wrap(T(m.intersect(t._m))),
56
- clone: () => wrap(m),
63
+ _hash: hash,
64
+ cut: (t) => cached(h("cut", hash, t._hash), () => T(m.subtract(t._m))),
65
+ cutAll: (tools) => cached(h("cutAll", hash, tools.map((t) => t._hash)),
66
+ () => T(m.subtract(unionRaw(tools.map((t) => t._m))))),
67
+ intersect: (t) => cached(h("intersect", hash, t._hash), () => T(m.intersect(t._m))),
68
+ clone: () => wrap(m, hash),
57
69
  boundingBox: () => {
58
70
  const b = m.boundingBox(); // { min: Vec3, max: Vec3 }
59
71
  const min = [...b.min], max = [...b.max];
@@ -66,14 +78,20 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
66
78
  volume: () => m.volume(),
67
79
  genus: () => m.genus(),
68
80
  isEmpty: () => m.isEmpty(),
69
- translate: (v) => wrap(T(m.translate(v))),
81
+ translate: (v) => wrap(T(m.translate(v)), h("translate", hash, v)),
70
82
  rotate: (deg, center, axis) => {
71
83
  const euler = [axis[0] * deg, axis[1] * deg, axis[2] * deg];
72
84
  const a = T(m.translate([-center[0], -center[1], -center[2]]));
73
85
  const b = T(a.rotate(euler));
74
- return wrap(T(b.translate(center)));
86
+ return wrap(T(b.translate(center)), h("rotate", hash, deg, center, axis));
87
+ },
88
+ mirror: (plane) => wrap(T(m.mirror(PLANE_NORMAL[plane])), h("mirror", hash, plane)),
89
+ scale: (factor, center = [0, 0, 0]) => {
90
+ if (!(factor > 0)) throw new Error("scale: factor must be > 0");
91
+ const a = T(m.translate([-center[0], -center[1], -center[2]]));
92
+ const b = T(a.scale([factor, factor, factor]));
93
+ return wrap(T(b.translate(center)), h("scale", hash, factor, center));
75
94
  },
76
- mirror: (plane) => wrap(T(m.mirror(PLANE_NORMAL[plane]))),
77
95
  toMesh: () => meshOut(m, false),
78
96
  toSTL: () => Promise.resolve(meshOut(m, true)),
79
97
  toIndexedMesh: () => indexedMeshOut(m),
@@ -83,26 +101,45 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
83
101
  });
84
102
 
85
103
  return {
86
- cylinder: (rb, rt, h, { center = false } = {}) => wrap(T(Manifold.cylinder(h, rb, rt, segs, center))),
87
- sphere: (r) => wrap(T(Manifold.sphere(r, segs))),
104
+ cylinder: (rb, rt, h2, { center = false } = {}) =>
105
+ wrap(T(Manifold.cylinder(h2, rb, rt, segs, center)), h("cylinder", rb, rt, h2, center, segs)),
106
+ // Compound op: hashed ATOMICALLY from its own args, so it is a single cache
107
+ // node — its internal cylinders/cut are never retained. The template for
108
+ // future compounds: build internals with T(), return the final tracked solid.
109
+ boredCylinder: ({ od, h: height, bore }) => cached(h("boredCylinder", od, height, bore, segs), () => {
110
+ const body = T(Manifold.cylinder(height, od / 2, od / 2, segs, false));
111
+ const tool0 = T(Manifold.cylinder(height + 4, bore / 2, bore / 2, segs, false));
112
+ const tool = T(tool0.translate([0, 0, -2])); // raw ops: track each result
113
+ return T(body.subtract(tool));
114
+ }),
115
+ sphere: (r) => wrap(T(Manifold.sphere(r, segs)), h("sphere", r, segs)),
88
116
  box: (min, max) => {
89
117
  const cube = T(Manifold.cube([max[0] - min[0], max[1] - min[1], max[2] - min[2]]));
90
- return wrap(T(cube.translate(min)));
91
- },
92
- prism: (pts, h) => {
93
- const cs = T(CrossSection.ofPolygons([pts]));
94
- return wrap(T(cs.extrude(h)));
95
- },
96
- helixSweptTube: (o) => wrap(T(helixTube(wasm, { ...o, ...tube }))),
97
- revolve: (pts, { degrees = 360 } = {}) => {
98
- for (const [r] of pts) if (r < 0) throw new Error("revolve: profile radius must be ≥ 0");
99
- return wrap(T(Manifold.revolve([pts], segs, degrees)));
118
+ return wrap(T(cube.translate(min)), h("box", min, max));
100
119
  },
101
- union: (solids) => wrap(unionRaw(solids.map((s) => s._m))), // unionRaw already tracks its result
120
+ prism: (pts, height, { twist = 0, scaleTop = 1 } = {}) =>
121
+ cached(h("prism", pts, height, twist, scaleTop, segs), () => {
122
+ if (scaleTop < 0) throw new Error("prism: scaleTop must be ≥ 0");
123
+ const cs = T(CrossSection.ofPolygons([pts]));
124
+ if (twist === 0 && scaleTop === 1) return T(cs.extrude(height));
125
+ const nDiv = Math.max(1, Math.ceil(Math.abs(twist) / 5));
126
+ return T(cs.extrude(height, nDiv, twist, scaleTop));
127
+ }),
128
+ helixSweptTube: (o) => cached(h("helixSweptTube", o, tube), () => T(helixTube(wasm, { ...o, ...tube }))),
129
+ revolve: (pts, { degrees = 360 } = {}) =>
130
+ cached(h("revolve", pts, degrees, segs), () => {
131
+ for (const [r] of pts) if (r < 0) throw new Error("revolve: profile radius must be ≥ 0");
132
+ return T(Manifold.revolve([pts], segs, degrees));
133
+ }),
134
+ union: (solids) => cached(h("union", solids.map((s) => s._hash)), () => unionRaw(solids.map((s) => s._m))),
102
135
  toSTEP: () => { throw new Error("STEP export not supported by the Manifold backend"); },
103
- // Free every WASM object created since the last cleanup. Call after each job
104
- // once its meshes/buffers have been copied out (meshOut already did).
105
- cleanup: () => { for (const o of tracked) o.delete?.(); tracked.length = 0; },
136
+ beginSubPart: (name) => cache.begin(name),
137
+ endSubPart: () => cache.end(),
138
+ cacheStats: () => cache.stats(),
139
+ resetCacheStats: () => cache.resetStats(),
140
+ // Free every WASM object created since the last cleanup EXCEPT solids the cache
141
+ // still pins (they must survive for the next build to resume from them).
142
+ cleanup: () => { for (const o of tracked) if (!cache.isPinned(o)) o.delete?.(); tracked.length = 0; },
106
143
  };
107
144
  }
108
145
 
@@ -93,6 +93,10 @@ export function createOcctKernel(replicad) {
93
93
  translate: (v) => wrap(shape.translate(v)),
94
94
  rotate: (deg, center, axis) => wrap(shape.rotate(deg, center, axis)),
95
95
  mirror: (plane) => wrap(shape.mirror(plane)),
96
+ scale: (factor, center = [0, 0, 0]) => {
97
+ if (!(factor > 0)) throw new Error("scale: factor must be > 0");
98
+ return wrap(shape.scale(factor, center));
99
+ },
96
100
  toMesh: ({ quality = "preview" } = {}) => {
97
101
  const m = shape.mesh(MESH[quality]);
98
102
  return {
@@ -127,10 +131,16 @@ export function createOcctKernel(replicad) {
127
131
  };
128
132
 
129
133
  // extrude a 2-D polygon from z=0
130
- const prism = (pts, h) => {
134
+ const prism = (pts, h, { twist = 0, scaleTop = 1 } = {}) => {
135
+ if (scaleTop < 0) throw new Error("prism: scaleTop must be ≥ 0");
131
136
  let pen = draw(pts[0]);
132
137
  for (let i = 1; i < pts.length; i++) pen = pen.lineTo(pts[i]);
133
- return wrap(pen.close().sketchOnPlane("XY").extrude(h));
138
+ const sketch = pen.close().sketchOnPlane("XY");
139
+ if (twist === 0 && scaleTop === 1) return wrap(sketch.extrude(h));
140
+ const cfg = {};
141
+ if (twist !== 0) cfg.twistAngle = twist;
142
+ if (scaleTop !== 1) cfg.extrusionProfile = { profile: "linear", endFactor: scaleTop };
143
+ return wrap(sketch.extrude(h, cfg));
134
144
  };
135
145
 
136
146
  // revolve a lathe profile [[r,z],…] around the Z axis (degrees defaults to 360)
@@ -152,7 +162,10 @@ export function createOcctKernel(replicad) {
152
162
  };
153
163
 
154
164
  return {
155
- cylinder, box: (min, max) => wrap(makeBox(min, max)), prism, revolve, helixSweptTube,
165
+ cylinder,
166
+ boredCylinder: ({ od, h, bore }) =>
167
+ cylinder(od / 2, od / 2, h).cut(cylinder(bore / 2, bore / 2, h + 4).translate([0, 0, -2])),
168
+ box: (min, max) => wrap(makeBox(min, max)), prism, revolve, helixSweptTube,
156
169
  sphere: (r) => wrap(makeSphere(r)),
157
170
  union: (solids) => wrap(solids.map((s) => s._s).reduce((a, b) => a.fuse(b))),
158
171
  toSTEP: (named) => exportSTEP(named.map(({ name, solid }) => ({ name, shape: solid._s }))).arrayBuffer(),
@@ -124,3 +124,17 @@ export function circularPattern(solid, count, { center = [0, 0, 0], axis = "Z",
124
124
  }
125
125
  return out;
126
126
  }
127
+
128
+ // CCW circle of radius r centered at [cx, cy]. A shared 2-D profile primitive:
129
+ // compose with the kernel's profile ops — e.g. revolve(circleProfile(minorR,
130
+ // [majorR, 0])) is a torus, prism(circleProfile(r), h) a cylinder.
131
+ export function circleProfile(r, center = [0, 0], segs = 48) {
132
+ if (!(r > 0)) throw new Error("circleProfile: r must be > 0");
133
+ const [cx, cy] = center;
134
+ const pts = [];
135
+ for (let i = 0; i < segs; i++) {
136
+ const a = (2 * Math.PI * i) / segs;
137
+ pts.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]);
138
+ }
139
+ return pts;
140
+ }
@@ -15,6 +15,7 @@ export function createProbeKernel() {
15
15
  translate() { note("translate"); return proxy; },
16
16
  rotate() { note("rotate"); return proxy; },
17
17
  mirror() { note("mirror"); return proxy; },
18
+ scale() { note("scale"); return proxy; },
18
19
  fillet() { note("fillet"); return proxy; },
19
20
  chamfer() { note("chamfer"); return proxy; },
20
21
  shell() { note("shell"); return proxy; },
@@ -25,6 +26,7 @@ export function createProbeKernel() {
25
26
  };
26
27
  const kernel = {
27
28
  cylinder() { note("cylinder"); return proxy; },
29
+ boredCylinder() { note("boredCylinder"); return proxy; },
28
30
  sphere() { note("sphere"); return proxy; },
29
31
  box() { note("box"); return proxy; },
30
32
  prism() { note("prism"); return proxy; },
@@ -0,0 +1,39 @@
1
+ // Worker-side cache of boundary-op solids, partitioned per sub-part. Retention is
2
+ // bounded to the CURRENT build's graph: each begin()/end() bracket rebuilds a
3
+ // sub-part's retained set from scratch, disposing any entry not re-used this round.
4
+ // WASM-agnostic — it stores opaque {value, pin, dispose} triples supplied by the
5
+ // caller (the Manifold backend), so it is unit-testable with plain objects.
6
+ export function createSolidCache() {
7
+ const caches = new Map(); // name -> Map(hash -> { value, pin, dispose })
8
+ const pinned = new Set(); // every live `pin` across all sub-parts
9
+ let name = null, active = null, prev = null;
10
+ let hits = 0, misses = 0;
11
+
12
+ return {
13
+ begin(n) { name = n; prev = caches.get(n) ?? new Map(); active = new Map(); },
14
+
15
+ end() {
16
+ if (name == null) return;
17
+ for (const [hash, entry] of prev) {
18
+ if (!active.has(hash)) { pinned.delete(entry.pin); entry.dispose(); } // evict
19
+ }
20
+ caches.set(name, active);
21
+ name = null; active = prev = null;
22
+ },
23
+
24
+ lookup(hash, make) {
25
+ if (name == null) return make().value; // not bracketed → no caching
26
+ if (active.has(hash)) { hits++; return active.get(hash).value; }
27
+ if (prev.has(hash)) { hits++; const e = prev.get(hash); active.set(hash, e); return e.value; }
28
+ misses++;
29
+ const entry = make();
30
+ active.set(hash, entry);
31
+ pinned.add(entry.pin);
32
+ return entry.value;
33
+ },
34
+
35
+ isPinned: (pin) => pinned.has(pin),
36
+ stats: () => ({ hits, misses }),
37
+ resetStats: () => { hits = 0; misses = 0; },
38
+ };
39
+ }
@@ -0,0 +1,21 @@
1
+ // Stable content hash for cached solids. Serializes scalar args canonically and
2
+ // folds via FNV-1a → base36. Solid operands are passed as their own (already
3
+ // computed) short `_hash` string, so composing two solids stays O(1) and the
4
+ // resulting key length stays bounded no matter how deep the build graph is.
5
+ export function h(...parts) {
6
+ return fnv(parts.map(canon).join("|"));
7
+ }
8
+
9
+ function canon(x) {
10
+ if (Array.isArray(x)) return "[" + x.map(canon).join(",") + "]";
11
+ if (x && typeof x === "object") return "{" + Object.keys(x).sort().map((k) => k + ":" + canon(x[k])).join(",") + "}";
12
+ return String(x);
13
+ }
14
+
15
+ // FNV-1a folded to 32-bit space (collision risk acceptable: retained only for one
16
+ // sub-part's build graph ~3–15 nodes, rebuilt each round, no accumulation).
17
+ function fnv(s) {
18
+ let hsh = 0x811c9dc5;
19
+ for (let i = 0; i < s.length; i++) { hsh ^= s.charCodeAt(i); hsh = Math.imul(hsh, 0x01000193); }
20
+ return (hsh >>> 0).toString(36);
21
+ }
@@ -42,13 +42,20 @@ export async function handle(kernel, part, msg, post) {
42
42
  try {
43
43
  if (msg.type === "generate") {
44
44
  const t0 = Date.now();
45
+ const useCache = msg.cache !== false; // ?debug toggle can disable caching (cache:false)
45
46
  const meshes = [];
47
+ kernel.resetCacheStats?.(); // count hits/misses for just this job
46
48
  for (const name of msg.subparts) {
47
- const m = buildPosed(name, "display", msg.view).toMesh({ quality: "preview" });
48
- meshes.push({ name, positions: m.positions, normals: m.normals, indices: m.indices, triangles: m.triangles, edges: m.edges });
49
- kernel.cleanup?.();
49
+ if (useCache) kernel.beginSubPart?.(name); // open the per-sub-part cache round
50
+ try {
51
+ const m = buildPosed(name, "display", msg.view).toMesh({ quality: "preview" });
52
+ meshes.push({ name, positions: m.positions, normals: m.normals, indices: m.indices, triangles: m.triangles, edges: m.edges });
53
+ } finally {
54
+ if (useCache) kernel.endSubPart?.(); // always close the bracket — a throw mid-build must not strand pinned solids
55
+ kernel.cleanup?.(); // free this round's transients (cached/pinned solids survive)
56
+ }
50
57
  }
51
- post({ type: "meshes", meshes, ms: Date.now() - t0 });
58
+ post({ type: "meshes", meshes, ms: Date.now() - t0, cache: kernel.cacheStats?.() });
52
59
  } else if (msg.type === "export-stl") {
53
60
  const out = [];
54
61
  for (const name of exportSubParts(part, msg.view, p)) {
@@ -3,10 +3,12 @@ import { zipSync } from "fflate";
3
3
  import { createViewer } from "./viewer.js";
4
4
  import { loadRotating, saveRotating, loadCamera, saveCamera, loadView, saveView } from "./view-state.js";
5
5
  import { buildControls } from "./controls.js";
6
- import { relevantParamKeys } from "./param-deps.js";
6
+ import { relevantParamKeys, subPartReadKeys, relevanceHash, RELEVANT_ALL } from "./param-deps.js";
7
7
  import { createGeometryService } from "./geometry-service.js";
8
8
  import { viewSubParts } from "./jobs.js";
9
9
  import { detectBackend } from "./geometry/probe.js";
10
+ import { createDebugOverlay } from "./debug-overlay.js";
11
+ import { attachPicker, formatSelection } from "./selection/index.js";
10
12
 
11
13
  // Mount a full parametric-part app from a PartDefinition: 3-D viewer + control
12
14
  // panel + the two geometry workers + the auto-regenerating view/cache loop +
@@ -24,6 +26,15 @@ export function mount(part, { createWorker, container = document.getElementById(
24
26
  if (forcedBackend !== "occt" && forcedBackend !== "manifold") forcedBackend = null;
25
27
  const backendFor = () => forcedBackend ?? detectBackend(part, params);
26
28
 
29
+ // ?debug shows the cache debug overlay; ?debug&nocache starts with caching off.
30
+ const qs = new URLSearchParams(location.search);
31
+ const debug = qs.has("debug");
32
+ let cachingOn = !(debug && qs.has("nocache"));
33
+ let lastGen = { skipped: 0, rebuilt: 0 }; // Layer-1 counts for the most recent generate
34
+ const dbg = debug
35
+ ? createDebugOverlay({ initialCachingOn: cachingOn, onToggle: (on) => { cachingOn = on; forceRegen(); } })
36
+ : null;
37
+
27
38
  const statusEl = document.getElementById("status");
28
39
  const dlBtn = document.getElementById("download");
29
40
  const dlStepBtn = document.getElementById("download-step");
@@ -56,16 +67,76 @@ export function mount(part, { createWorker, container = document.getElementById(
56
67
  const savedBtn = savedView ? [...partSeg.querySelectorAll("button[data-part]")].find((b) => b.dataset.part === savedView) : null;
57
68
  let view = savedBtn ? savedView : defaultView;
58
69
  if (savedBtn) for (const b of partSeg.children) b.classList.toggle("on", b === savedBtn);
70
+
71
+ // ?pick enables click-to-select: a toggle button + a transient toast. Off by
72
+ // default — no button, no listener, no behavior change. Deleting this block and
73
+ // the selection/ dir reverts the app exactly.
74
+ if (qs.has("pick")) {
75
+ const btn = document.createElement("button");
76
+ btn.id = "pf-pick";
77
+ btn.textContent = "Pick";
78
+ btn.title = "Click a surface to copy a selection token";
79
+ Object.assign(btn.style, {
80
+ position: "fixed", left: "12px", bottom: "12px", zIndex: 9999,
81
+ font: "12px system-ui, sans-serif", padding: "6px 10px", cursor: "pointer",
82
+ });
83
+ document.body.appendChild(btn);
84
+
85
+ const toast = document.createElement("div");
86
+ Object.assign(toast.style, {
87
+ position: "fixed", left: "12px", bottom: "48px", zIndex: 9999, maxWidth: "60ch",
88
+ font: "12px ui-monospace, monospace", padding: "6px 10px", borderRadius: "4px",
89
+ background: "rgba(20,24,29,0.92)", color: "#d8e0ea", display: "none",
90
+ whiteSpace: "pre-wrap", wordBreak: "break-word",
91
+ });
92
+ document.body.appendChild(toast);
93
+
94
+ const picker = attachPicker(viewer, {
95
+ part,
96
+ getContext: () => ({ view, params, derived: part.derive ? part.derive({ ...part.defaults, ...params }) : {} }),
97
+ onPick: (selection) => {
98
+ const token = formatSelection(selection, { style: "token" });
99
+ navigator.clipboard?.writeText(token);
100
+ toast.textContent = `copied: ${token}`;
101
+ toast.style.display = "block";
102
+ setTimeout(() => { toast.style.display = "none"; }, 4000);
103
+ },
104
+ });
105
+
106
+ btn.addEventListener("click", () => {
107
+ const isActive = btn.classList.toggle("on");
108
+ picker.setActive(isActive);
109
+ btn.style.outline = isActive ? "2px solid #ffcc33" : "";
110
+ });
111
+ }
112
+
59
113
  let framedView = null; // the view the camera was last framed to (null until first show)
60
114
  let cameraRestored = false; // saved camera applied once, on the first frame after load
61
115
  let generating = false;
62
116
  let paramsVersion = 0; // bumped on every settings edit
63
117
  let genVersion = -1; // the params version the in-flight generate is building
64
118
  let genTimer = null; // debounce timer for auto-regenerate
65
- const cacheVersion = Object.fromEntries(names.map((n) => [n, -1])); // params version each was built at
119
+ const cacheHash = {}; // n -> relevance hash each sub-part's cached mesh was built at
120
+
121
+ // Memoize the per-sub-part read-key map per (paramsVersion, view): subPartReadKeys
122
+ // runs probe builds, so we compute it once per change, not per sub-part.
123
+ let _readsKey = null, _readsMap = null;
124
+ const readsFor = () => {
125
+ const key = `${paramsVersion}|${view}`;
126
+ if (_readsKey !== key) { _readsKey = key; _readsMap = subPartReadKeys(part, view, params); }
127
+ return _readsMap;
128
+ };
129
+ // The relevance hash for one sub-part at the current params (RELEVANT_ALL → hash
130
+ // over ALL params, so any edit invalidates it — the safe fallback).
131
+ const hashFor = (n) => {
132
+ if (!cachingOn) return `v${paramsVersion}`; // caching off: any edit invalidates every sub-part (Layer 1 off)
133
+ const reads = readsFor();
134
+ const keys = reads === RELEVANT_ALL ? Object.keys(params) : [...(reads.get(n) ?? Object.keys(params))];
135
+ return relevanceHash(keys, params);
136
+ };
66
137
 
67
- // A cached sub-part is current only if it was built at the latest params version.
68
- const isCurrent = (n) => viewer._subCache[n] && cacheVersion[n] === paramsVersion;
138
+ // A cached sub-part is current only if its relevance hash is unchanged.
139
+ const isCurrent = (n) => !!viewer._subCache[n] && cacheHash[n] === hashFor(n);
69
140
  const missingParts = () => viewSubParts(part, view, params).filter((n) => !isCurrent(n));
70
141
 
71
142
  // Reflect the active view. If every needed part is current, show it and enable
@@ -138,13 +209,14 @@ export function mount(part, { createWorker, container = document.getElementById(
138
209
  for (const m of data.meshes) {
139
210
  if (viewer._subCache[m.name]) { viewer._subCache[m.name].userData.edges?.dispose(); viewer._subCache[m.name].dispose(); }
140
211
  viewer.setSubGeometry(m.name, m);
141
- cacheVersion[m.name] = genVersion;
212
+ cacheHash[m.name] = hashFor(m.name);
142
213
  }
143
214
  hideBusy();
144
215
  refreshView();
145
216
  if (data.ms && missingParts().length === 0) {
146
217
  setStatus(`${statusEl.textContent} · ${(data.ms / 1000).toFixed(1)} s`);
147
218
  }
219
+ dbg?.update({ ms: data.ms, hits: data.cache?.hits ?? 0, misses: data.cache?.misses ?? 0, skipped: lastGen.skipped, rebuilt: lastGen.rebuilt });
148
220
  maybeGenerate(); // active view may still need parts (tab switched during build)
149
221
  break;
150
222
  }
@@ -194,12 +266,22 @@ export function mount(part, { createWorker, container = document.getElementById(
194
266
  // Build whatever the active view is missing — automatic, no Generate button.
195
267
  function maybeGenerate() {
196
268
  if (!kernelReady || generating) return; // retried when the current build finishes
197
- const missing = missingParts();
269
+ const needed = viewSubParts(part, view, params);
270
+ const missing = needed.filter((n) => !isCurrent(n));
198
271
  if (missing.length === 0) return;
199
272
  generating = true;
200
273
  genVersion = paramsVersion;
274
+ lastGen = { skipped: needed.length - missing.length, rebuilt: missing.length }; // for the overlay
201
275
  showBusy("generating");
202
- service.generate({ type: "generate", subparts: missing, view, params }, backendFor());
276
+ service.generate({ type: "generate", subparts: missing, view, params, cache: cachingOn }, backendFor());
277
+ }
278
+
279
+ // Re-run the active view under the current caching setting, so toggling the
280
+ // ?debug switch updates the readout for the same design without a param change.
281
+ function forceRegen() {
282
+ for (const n of viewSubParts(part, view, params)) delete cacheHash[n];
283
+ refreshView();
284
+ maybeGenerate();
203
285
  }
204
286
 
205
287
  partSeg.addEventListener("click", (e) => {
@@ -48,3 +48,34 @@ export function relevantParamKeys(part, view, params) {
48
48
  return RELEVANT_ALL; // couldn't analyze — treat everything as relevant
49
49
  }
50
50
  }
51
+
52
+ // Per-sub-part version of relevantParamKeys: which raw params each ON-SCREEN
53
+ // sub-part of the active view reads. Used by Layer 1 (mount.js) to skip
54
+ // regenerating sub-parts whose inputs are unchanged. Errs to RELEVANT_ALL on any
55
+ // analysis failure (caller then treats every param as relevant — safe, just slower).
56
+ export function subPartReadKeys(part, view, params) {
57
+ try {
58
+ const deriveInputs = new Set();
59
+ const derived = part.derive ? (part.derive(recorder(params, deriveInputs)) ?? {}) : {};
60
+ const { kernel } = createProbeKernel();
61
+ const map = new Map();
62
+ for (const name of viewSubParts(part, view, params)) {
63
+ const sp = part.parts[name];
64
+ const reads = new Set();
65
+ const dSeen = new Set();
66
+ if (sp.enabled) sp.enabled(recorder(params, reads)); // gate params change presence too
67
+ sp.build(kernel, recorder(params, reads), recorder(derived, dSeen));
68
+ if (dSeen.size > 0) for (const k of deriveInputs) reads.add(k);
69
+ map.set(name, reads);
70
+ }
71
+ return map;
72
+ } catch {
73
+ return RELEVANT_ALL;
74
+ }
75
+ }
76
+
77
+ // Stable string of the given param keys' current values — the cache-validity key
78
+ // for one sub-part. Sorted so key order never affects the result.
79
+ export function relevanceHash(keys, params) {
80
+ return JSON.stringify(keys.slice().sort().map((k) => [k, params[k]]));
81
+ }
@@ -0,0 +1,31 @@
1
+ // Pure serializer. No three.js, no DOM. Three styles for the same Selection:
2
+ // token — compact clipboard/CLI line
3
+ // json — the structured object (embedded tool-call transport)
4
+ // prompt — one natural-language sentence an LLM ingests well
5
+ const AXIS_LABEL = { "1,0,0": "+X", "-1,0,0": "-X", "0,1,0": "+Y", "0,-1,0": "-Y", "0,0,1": "+Z", "0,0,-1": "-Z" };
6
+
7
+ const fmtNormal = (n) => AXIS_LABEL[n.join(",")] ?? n.join(",");
8
+ const fmtParams = (p) => Object.entries(p).map(([k, v]) => `${k}:${v}`).join(",");
9
+
10
+ function tokenStyle(s) {
11
+ const head = `@${s.subPart}`;
12
+ const feat = s.feature
13
+ ? ` · ${s.feature.kind === "cylinder"
14
+ ? `cyl-face r=${s.feature.radius} axis=${s.feature.axis}`
15
+ : `${s.feature.kind}-face`}`
16
+ : "";
17
+ return `${head}${feat} · pt(${s.point.join(",")}) n(${fmtNormal(s.normal)}) · {${fmtParams(s.params)}}`;
18
+ }
19
+
20
+ function promptStyle(s) {
21
+ const params = Object.entries(s.params).map(([k, v]) => `${k}: ${v}`).join(", ");
22
+ const feat = s.feature ? ` a ${s.feature.kind} face,` : "";
23
+ return `On sub-part **${s.subPart}**, the user pointed at${feat} local point (${s.point.join(", ")}), `
24
+ + `normal ${fmtNormal(s.normal)}, with params {${params}}.`;
25
+ }
26
+
27
+ export function formatSelection(selection, { style = "token" } = {}) {
28
+ if (style === "json") return selection;
29
+ if (style === "prompt") return promptStyle(selection);
30
+ return tokenStyle(selection);
31
+ }
@@ -0,0 +1,5 @@
1
+ // Public surface for the click-to-select module. The future agent harness depends
2
+ // only on attachPicker's onPick callback + the Selection contract — nothing else.
3
+ export { resolveSelection, quantizePoint, snapNormal } from "./resolve.js";
4
+ export { formatSelection } from "./format.js";
5
+ export { attachPicker, worldToSubPartLocal } from "./pick.js";
@@ -0,0 +1,48 @@
1
+ // Viewer adapter — the ONLY three.js/DOM-aware file in the selection module.
2
+ // Raycasts a click against the visible sub-meshes, converts the hit to the
3
+ // sub-part's local CAD frame, and hands a resolved Selection to onPick.
4
+ import * as THREE from "three";
5
+ import { resolveSelection } from "./resolve.js";
6
+
7
+ // Invert the mesh's world transform (pivot rotation + per-view recentring) to recover
8
+ // shared-frame CAD coords — the same frame build() models in.
9
+ export function worldToSubPartLocal(mesh, world) {
10
+ const v = Array.isArray(world) ? new THREE.Vector3(world[0], world[1], world[2]) : world.clone();
11
+ mesh.worldToLocal(v);
12
+ return [v.x, v.y, v.z];
13
+ }
14
+
15
+ export function attachPicker(viewer, { part, getContext, onPick }) {
16
+ const raycaster = new THREE.Raycaster();
17
+ const ndc = new THREE.Vector2();
18
+ let active = false;
19
+
20
+ function onClick(ev) {
21
+ if (!active) return;
22
+ const rect = viewer.domElement.getBoundingClientRect();
23
+ ndc.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
24
+ ndc.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
25
+ raycaster.setFromCamera(ndc, viewer.camera);
26
+
27
+ const meshes = Object.values(viewer._subMeshes).filter((m) => m.visible);
28
+ const hit = raycaster.intersectObjects(meshes, false)[0];
29
+ if (!hit) return;
30
+
31
+ const selection = resolveSelection(part, getContext(), {
32
+ subPart: hit.object.name,
33
+ pointLocal: worldToSubPartLocal(hit.object, hit.point),
34
+ // face.normal is in the geometry's local frame, which equals the CAD frame here
35
+ // (the mesh carries no local transform; only its parents rotate/recentre).
36
+ normalLocal: hit.face ? [hit.face.normal.x, hit.face.normal.y, hit.face.normal.z] : [0, 0, 0],
37
+ // hit.face metadata (kind/axis/radius) is the L1 increment — not populated yet.
38
+ });
39
+ viewer.flashPoint([hit.point.x, hit.point.y, hit.point.z]);
40
+ onPick(selection);
41
+ }
42
+
43
+ viewer.domElement.addEventListener("click", onClick);
44
+ return {
45
+ setActive: (on) => { active = !!on; },
46
+ detach: () => viewer.domElement.removeEventListener("click", onClick),
47
+ };
48
+ }
@@ -0,0 +1,54 @@
1
+ // Pure core: turn a backend-agnostic raycast hit into a semantic Selection.
2
+ // No three.js, no DOM, no kernel — only the param-deps read-key analysis.
3
+ import { subPartReadKeys, RELEVANT_ALL } from "../param-deps.js";
4
+
5
+ const COS_3DEG = 0.99863; // a normal within 3° of an axis snaps to that axis
6
+ const q2 = (x) => { const r = Math.round(x * 100) / 100; return r === 0 ? 0 : r; }; // 0.01mm, kill -0
7
+
8
+ export function quantizePoint(p) {
9
+ return [q2(p[0]), q2(p[1]), q2(p[2])];
10
+ }
11
+
12
+ export function snapNormal(n) {
13
+ const len = Math.hypot(n[0], n[1], n[2]) || 1;
14
+ const u = [n[0] / len, n[1] / len, n[2] / len];
15
+ let ai = 0; // index of the dominant axis
16
+ if (Math.abs(u[1]) > Math.abs(u[ai])) ai = 1;
17
+ if (Math.abs(u[2]) > Math.abs(u[ai])) ai = 2;
18
+ if (Math.abs(u[ai]) >= COS_3DEG) {
19
+ const axis = [0, 0, 0];
20
+ axis[ai] = u[ai] > 0 ? 1 : -1;
21
+ return axis;
22
+ }
23
+ return [q2(u[0]), q2(u[1]), q2(u[2])];
24
+ }
25
+
26
+ // Only the params the clicked sub-part actually reads — "this geometry, at these inputs".
27
+ function scopeParams(part, view, params, subPart) {
28
+ const reads = subPartReadKeys(part, view, params);
29
+ const keys = reads === RELEVANT_ALL
30
+ ? Object.keys(params)
31
+ : [...(reads.get(subPart) ?? Object.keys(params))];
32
+ const out = {};
33
+ for (const k of keys) out[k] = params[k];
34
+ return out;
35
+ }
36
+
37
+ export function resolveSelection(part, ctx, hit) {
38
+ const point = quantizePoint(hit.pointLocal);
39
+ const selection = {
40
+ subPart: hit.subPart,
41
+ point,
42
+ normal: snapNormal(hit.normalLocal),
43
+ params: scopeParams(part, ctx.view, ctx.params, hit.subPart),
44
+ };
45
+ if (hit.face) {
46
+ // L1 — feature.selector is the author's own { dir, inPlane, at, near } vocabulary,
47
+ // so the LLM can drop it straight into a faces(...)/edges(...) call.
48
+ const feature = { kind: hit.face.kind, selector: { near: point } };
49
+ if (hit.face.axis != null) { feature.axis = hit.face.axis; feature.selector.dir = hit.face.axis; }
50
+ if (hit.face.radius != null) feature.radius = hit.face.radius;
51
+ selection.feature = feature;
52
+ }
53
+ return selection;
54
+ }
@@ -76,7 +76,8 @@ export function createViewer(container, part) {
76
76
  const subMesh = Object.fromEntries(
77
77
  names.map((n) => [n, new THREE.Mesh(new THREE.BufferGeometry(), materialFor(n))])
78
78
  );
79
- for (const m of Object.values(subMesh)) {
79
+ for (const [n, m] of Object.entries(subMesh)) {
80
+ m.name = n;
80
81
  m.visible = false;
81
82
  partsGroup.add(m);
82
83
  }
@@ -226,5 +227,17 @@ export function createViewer(container, part) {
226
227
  container.removeChild(renderer.domElement);
227
228
  }
228
229
 
229
- return { showAssembly, hideAssembly, setSubGeometry, resize, dispose, frame, setAutoRotate, setTheme, getCameraState, setCameraState, onCameraEnd, _subCache: subCache };
230
+ // Transient marker at a world-space point visual confirmation of a pick.
231
+ function flashPoint(world) {
232
+ const dot = new THREE.Mesh(
233
+ new THREE.SphereGeometry(1.2, 16, 12),
234
+ new THREE.MeshBasicMaterial({ color: 0xffcc33, depthTest: false })
235
+ );
236
+ dot.renderOrder = 999;
237
+ dot.position.set(world[0], world[1], world[2]);
238
+ scene.add(dot);
239
+ setTimeout(() => { scene.remove(dot); dot.geometry.dispose(); dot.material.dispose(); }, 1200);
240
+ }
241
+
242
+ return { showAssembly, hideAssembly, setSubGeometry, resize, dispose, frame, setAutoRotate, setTheme, getCameraState, setCameraState, onCameraEnd, _subCache: subCache, camera, domElement: renderer.domElement, _subMeshes: subMesh, flashPoint };
230
243
  }