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.
- package/docs/AUTHORING-PARTS.md +18 -1
- package/package.json +5 -2
- package/src/framework/debug-overlay.js +40 -0
- package/src/framework/geometry/kernel.js +4 -1
- package/src/framework/geometry/manifold-backend.js +61 -24
- package/src/framework/geometry/occt-backend.js +16 -3
- package/src/framework/geometry/polygon.js +14 -0
- package/src/framework/geometry/probe.js +2 -0
- package/src/framework/geometry/solid-cache.js +39 -0
- package/src/framework/geometry/solid-hash.js +21 -0
- package/src/framework/jobs.js +11 -4
- package/src/framework/mount.js +89 -7
- package/src/framework/param-deps.js +31 -0
- package/src/framework/selection/format.js +31 -0
- package/src/framework/selection/index.js +5 -0
- package/src/framework/selection/pick.js +48 -0
- package/src/framework/selection/resolve.js +54 -0
- package/src/framework/viewer.js +15 -2
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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
|
|
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
|
+
"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": {
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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,
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
+
}
|
package/src/framework/jobs.js
CHANGED
|
@@ -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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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)) {
|
package/src/framework/mount.js
CHANGED
|
@@ -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
|
|
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
|
|
68
|
-
const isCurrent = (n) => viewer._subCache[n] &&
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/src/framework/viewer.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
}
|