partforge 0.91.0 → 0.93.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.
Files changed (38) hide show
  1. package/bin/cli.js +6 -3
  2. package/docs/AUTHORING-PARTS.md +184 -1
  3. package/docs/ERROR-PATTERNS.md +97 -0
  4. package/docs/KERNEL-CONTRACT.md +1 -0
  5. package/docs/VECTOR-FORMAT.md +737 -0
  6. package/package.json +9 -1
  7. package/src/app-emblem.js +15 -0
  8. package/src/emblem-worker.js +3 -0
  9. package/src/framework/asset-resolve.js +5 -4
  10. package/src/framework/geometry/arc-fit.js +146 -0
  11. package/src/framework/geometry/contour-offset.js +5 -0
  12. package/src/framework/geometry/curve-fill.js +57 -7
  13. package/src/framework/geometry/kernel-front.js +46 -0
  14. package/src/framework/geometry/kernel.js +1 -1
  15. package/src/framework/geometry/probe.js +1 -1
  16. package/src/framework/geometry/stroke-outline.js +119 -0
  17. package/src/framework/geometry/vector-format.js +334 -0
  18. package/src/framework/geometry/vector2d.js +96 -0
  19. package/src/framework/ingest/svg-ingest.js +212 -0
  20. package/src/framework/jobs.js +11 -0
  21. package/src/framework/lint/index.js +28 -3
  22. package/src/framework/lint/rules-vector.js +112 -0
  23. package/src/framework/mount.js +30 -2
  24. package/src/framework/pick-flash.js +31 -0
  25. package/src/framework/selection/pick.js +16 -1
  26. package/src/framework/vectors.js +170 -0
  27. package/src/framework/viewer.js +106 -7
  28. package/src/framework/worker.js +46 -1
  29. package/src/ingest.js +8 -0
  30. package/src/parts/assets/emblem.svg +10 -0
  31. package/src/parts/assets/emblem.vector.json +110 -0
  32. package/src/parts/assets/plate.vector.json +27 -0
  33. package/src/parts/emblem.js +102 -0
  34. package/src/testing/manifold.js +3 -1
  35. package/src/testing/occt.js +3 -1
  36. package/types/index.d.ts +22 -0
  37. package/types/ingest.d.ts +118 -0
  38. package/types/kernel.d.ts +28 -0
@@ -6,7 +6,7 @@ import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js
6
6
  import { LineMaterial } from "three/addons/lines/LineMaterial.js";
7
7
  import { createCutaway } from "./cutaway.js";
8
8
  import { CUTAWAY_OVERLAY_RENDER_ORDER } from "./cutaway-render.js";
9
- import { flashWorldRadius } from "./pick-flash.js";
9
+ import { flashWorldRadius, projectToScreen, anchorMoved } from "./pick-flash.js";
10
10
  import { createCameraTween } from "./camera-tween.js";
11
11
  import { orbitPose } from "./camera-orbit.js";
12
12
  import { orthoFrustum, perspectiveDistance } from "./projection.js";
@@ -871,6 +871,17 @@ export function createViewer(container, part) {
871
871
  liveLights.key.visible = false;
872
872
  liveLights.fill.visible = false;
873
873
  scene.add(capKey, capKey.target, capFill, capFill.target);
874
+ // A pick marker is transient UI feedback about a click, never part of the
875
+ // part, so it belongs in no capture. It used to be near enough true that a
876
+ // capture would miss one — a dot faded after 1200ms — but a HELD dot lives
877
+ // as long as the host's UI hangs off it, so any capture taken during a pick
878
+ // would now bake the yellow sphere into an agent-facing render or a
879
+ // published thumbnail. Hidden HERE, at the one offscreen chokepoint, rather
880
+ // than via canonicalCaptureHidden: captureCurrent deliberately ignores that
881
+ // set. Only dots that were actually shown are restored, so this cannot
882
+ // resurrect one the live canvas had hidden for its own reasons.
883
+ const reshowFlashDots = [];
884
+ for (const dot of flashDots) if (dot.visible) { dot.visible = false; reshowFlashDots.push(dot); }
874
885
  try {
875
886
  renderer.setRenderTarget(rt);
876
887
  renderer.render(renderScene, cam);
@@ -878,11 +889,13 @@ export function createViewer(container, part) {
878
889
  // reads antialiased pixels.
879
890
  renderer.readRenderTargetPixels(rt, 0, 0, width, height, buf);
880
891
  } finally {
881
- // Never leave the user's own view unlit or pointed at the offscreen target.
892
+ // Never leave the user's own view unlit, pointed at the offscreen target,
893
+ // or missing a marker the user is still looking at.
882
894
  renderer.setRenderTarget(null);
883
895
  scene.remove(capKey, capKey.target, capFill, capFill.target);
884
896
  liveLights.key.visible = true;
885
897
  liveLights.fill.visible = true;
898
+ for (const dot of reshowFlashDots) dot.visible = true;
886
899
  if (!cachedSize) rt.dispose();
887
900
  }
888
901
  const canvas = document.createElement("canvas");
@@ -1055,6 +1068,12 @@ export function createViewer(container, part) {
1055
1068
  // a dot is only alive for about a second, but orbiting or zooming inside
1056
1069
  // that second must not resize it.
1057
1070
  for (const dot of flashDots) scaleFlashDot(dot);
1071
+ // …and re-project the held one, so a host anchored to it follows the
1072
+ // camera. Change-gated: a still camera publishes nothing.
1073
+ if (anchorDot) {
1074
+ const next = projectPoint([anchorDot.position.x, anchorDot.position.y, anchorDot.position.z]);
1075
+ if (anchorMoved(lastAnchor, next)) publishAnchor(next);
1076
+ }
1058
1077
  renderer.render(scene, activeCamera);
1059
1078
  cutaway.renderOverlay(renderer, activeCamera);
1060
1079
  }
@@ -1157,15 +1176,48 @@ export function createViewer(container, part) {
1157
1176
  //
1158
1177
  // The sphere is a UNIT sphere scaled per frame (see below) so it reads as a
1159
1178
  // constant handful of CSS pixels instead of a fixed millimetre size.
1179
+ const _flashWorld = new THREE.Vector3();
1160
1180
  const FLASH_RENDER_ORDER = CUTAWAY_OVERLAY_RENDER_ORDER + 1;
1161
1181
  const flashTimers = new Set();
1162
1182
  const flashDots = new Set();
1183
+ // The subset that will not fade. A HELD marker outlives the pick that made
1184
+ // it, because the host has hung something off it — see holdFlashPoint.
1185
+ const heldDots = new Set();
1186
+ let lastFlashed = null; // the newest marker, which is what hold() holds
1187
+ let anchorDot = null; // the held marker the anchor stream follows
1188
+ let lastAnchor = null;
1189
+ const anchorListeners = new Set();
1163
1190
  const flashGeometry = new THREE.SphereGeometry(1, 16, 12);
1164
1191
  const flashViewport = new THREE.Vector2();
1192
+
1165
1193
  function scaleFlashDot(dot) {
1166
1194
  renderer.getSize(flashViewport); // CSS px, which is what a pixel radius means
1167
1195
  dot.scale.setScalar(flashWorldRadius(activeCamera, dot.position, flashViewport.y));
1168
1196
  }
1197
+
1198
+ function projectPoint(world) {
1199
+ renderer.getSize(flashViewport);
1200
+ _flashWorld.set(world[0], world[1], world[2]);
1201
+ return projectToScreen(activeCamera, _flashWorld, flashViewport.x, flashViewport.y);
1202
+ }
1203
+
1204
+ function publishAnchor(anchor) {
1205
+ lastAnchor = anchor;
1206
+ // A throwing subscriber must not stop the render loop or the other
1207
+ // subscribers — same containment the frame listeners get.
1208
+ for (const cb of [...anchorListeners]) {
1209
+ try { cb(anchor); } catch (e) { console.warn("partforge: anchor listener failed", e); }
1210
+ }
1211
+ }
1212
+
1213
+ function dropFlashDot(dot) {
1214
+ scene.remove(dot);
1215
+ dot.material.dispose(); // the geometry is shared and freed in dispose()
1216
+ flashDots.delete(dot);
1217
+ heldDots.delete(dot);
1218
+ if (lastFlashed === dot) lastFlashed = null;
1219
+ }
1220
+
1169
1221
  function flashPoint(world) {
1170
1222
  const dot = new THREE.Mesh(
1171
1223
  flashGeometry,
@@ -1178,15 +1230,53 @@ export function createViewer(container, part) {
1178
1230
  scaleFlashDot(dot); // sized before its first frame, not one frame late
1179
1231
  scene.add(dot);
1180
1232
  flashDots.add(dot);
1233
+ lastFlashed = dot;
1181
1234
  const t = setTimeout(() => {
1182
1235
  flashTimers.delete(t);
1183
- flashDots.delete(dot);
1184
- // The geometry is shared by every dot and freed in dispose(), not here.
1185
- scene.remove(dot); dot.material.dispose();
1236
+ dot.userData.fadeTimer = null;
1237
+ dropFlashDot(dot);
1186
1238
  }, 1200);
1239
+ dot.userData.fadeTimer = t;
1187
1240
  flashTimers.add(t);
1188
1241
  }
1189
1242
 
1243
+ // Keep the newest marker on screen until released. Earlier held markers stay
1244
+ // held: picking a second spot should light both, and one release() clears
1245
+ // them together. The anchor stream follows the newest, which is the one a
1246
+ // host's own UI is anchored to.
1247
+ function holdFlashPoint() {
1248
+ if (!lastFlashed) return false;
1249
+ const dot = lastFlashed;
1250
+ if (dot.userData.fadeTimer) {
1251
+ clearTimeout(dot.userData.fadeTimer);
1252
+ flashTimers.delete(dot.userData.fadeTimer);
1253
+ dot.userData.fadeTimer = null;
1254
+ }
1255
+ heldDots.add(dot);
1256
+ anchorDot = dot;
1257
+ publishAnchor(projectPoint([dot.position.x, dot.position.y, dot.position.z]));
1258
+ return true;
1259
+ }
1260
+
1261
+ function releaseFlashPoints() {
1262
+ if (heldDots.size === 0 && !anchorDot) return;
1263
+ for (const dot of [...heldDots]) dropFlashDot(dot);
1264
+ anchorDot = null;
1265
+ publishAnchor(null);
1266
+ }
1267
+
1268
+ function onFlashAnchorChange(cb) {
1269
+ // The `disposed` half is the same guard cutaway's onHandleHoverChange takes,
1270
+ // and for the same two reasons: a subscribe racing teardown (effect-cleanup
1271
+ // ordering, a StrictMode remount) would otherwise be handed an anchor for a
1272
+ // dot no longer in the scene, and would re-populate a listener set that
1273
+ // dispose() will never clear again — retaining the embedder's closure.
1274
+ if (disposed || typeof cb !== "function") return () => {};
1275
+ anchorListeners.add(cb);
1276
+ cb(lastAnchor); // current state on subscribe, like onCutawayHandleHover
1277
+ return () => anchorListeners.delete(cb);
1278
+ }
1279
+
1190
1280
  // Full teardown: render loop, observers, controls, timers, GPU resources, DOM.
1191
1281
  // Idempotent. Cached sub-part geometries and their edge lines are freed; the
1192
1282
  // shared and per-part cloned materials tolerate double-dispose.
@@ -1211,10 +1301,15 @@ export function createViewer(container, part) {
1211
1301
  controls.dispose();
1212
1302
  for (const t of flashTimers) clearTimeout(t);
1213
1303
  flashTimers.clear();
1214
- // A dot whose timer was just cancelled still holds its own material; the
1215
- // sphere geometry is shared across all of them and freed once.
1304
+ // A dot whose timer was just cancelled or one held indefinitely — still
1305
+ // holds its own material; the sphere geometry is shared and freed once.
1216
1306
  for (const dot of flashDots) { scene.remove(dot); dot.material.dispose(); }
1217
1307
  flashDots.clear();
1308
+ heldDots.clear();
1309
+ lastFlashed = null;
1310
+ anchorDot = null;
1311
+ lastAnchor = null;
1312
+ anchorListeners.clear();
1218
1313
  flashGeometry.dispose();
1219
1314
  cutaway.dispose();
1220
1315
  for (const n of names) {
@@ -1284,6 +1379,10 @@ export function createViewer(container, part) {
1284
1379
  __subMesh: (n) => subMesh[n], // test hooks (cf. attachAnimationControls' __viewer)
1285
1380
  __subLines: (n) => subLines[n],
1286
1381
  flashPoint,
1382
+ projectPoint,
1383
+ holdFlashPoint,
1384
+ releaseFlashPoints,
1385
+ onFlashAnchorChange,
1287
1386
  cutawaySupported: () => cutaway.isSupported,
1288
1387
  cutawayEnabled: () => cutaway.isEnabled,
1289
1388
  setCutawayEnabled,
@@ -10,6 +10,7 @@
10
10
  // in docs/KERNEL-CONTRACT.md.
11
11
  import { handle } from "./jobs.js";
12
12
  import { lintPart } from "../lint.js";
13
+ import { cachedVectorDocs } from "./vectors.js";
13
14
 
14
15
  async function manifoldKernels() {
15
16
  const [{ default: Module }, { createManifoldKernel }] = await Promise.all([
@@ -140,7 +141,51 @@ export function runWorker(part, opts = {}) {
140
141
  // the pump awaits that boot, so routing lint through the queue would drag in
141
142
  // OCCT's ~11 MB WASM to run a check that never calls the kernel at all.
142
143
  if (e.data?.type === "lint") {
143
- postMessage({ type: "lint-report", report: lintPart(current, { params: e.data.params }) });
144
+ // `vectorDocs` is what the two document-dependent vector rules need —
145
+ // vector-size-missing (does this file's `units` require a size?) and
146
+ // vector-unknown-shape (does it declare that shape name?). Without it both
147
+ // stay silent, which is how they are designed to degrade.
148
+ //
149
+ // CACHED ONLY, and deliberately so. This handler must stay synchronous:
150
+ // lint is instant and offline by construction, which is the property that
151
+ // lets a sandbox run it on every keystroke. Awaiting the async resolver
152
+ // here made lint hangable (asset-resolve.js's fetch has no timeout) and
153
+ // made the reply order depend on how fast each part's vectors fetched.
154
+ // cachedVectorDocs reads only bytes that are already resolved and never
155
+ // initiates a fetch, so a hosted sandbox gets both rules as soon as one
156
+ // build has run — the common case — and never waits for them.
157
+ //
158
+ // Guarded even so: lintPart's never-throws contract is worth nothing to a
159
+ // host if the handler around it can throw first and post NOTHING at all —
160
+ // in a real worker that surfaces as `unhandledrejection`, not an `error`
161
+ // event, so a host waiting on a lint-report just waits.
162
+ //
163
+ // Two levels, because they mean different things. The inner one covers a
164
+ // hostile `vectors` (a throwing getter, a Proxy whose ownKeys trap throws):
165
+ // that means "no documents", exactly as if the caller passed none, so the
166
+ // report is the same one lintPart alone would produce. The outer one is the
167
+ // last resort for anything neither of us has thought of.
168
+ let report;
169
+ try {
170
+ let vectorDocs;
171
+ try { vectorDocs = Object.fromEntries(cachedVectorDocs(current?.vectors)); }
172
+ catch { vectorDocs = undefined; }
173
+ report = lintPart(current, { params: e.data.params, vectorDocs });
174
+ } catch (cause) {
175
+ report = {
176
+ ok: false,
177
+ errors: [{
178
+ rule: "lint-context-error",
179
+ severity: "error",
180
+ message: `partforge/lint could not run: ${cause?.message || String(cause)}`,
181
+ hint: "The part or the lint request is too malformed to analyze — make sure `vectors`, `defaults` and `params` are plain, side-effect-free data rather than throwing getters or hostile Proxies.",
182
+ path: "",
183
+ }],
184
+ warnings: [],
185
+ notes: [],
186
+ };
187
+ }
188
+ postMessage({ type: "lint-report", report });
144
189
  return;
145
190
  }
146
191
  // Only generates supersede each other; exports/inspect always run (cancelling
package/src/ingest.js ADDED
@@ -0,0 +1,8 @@
1
+ // The published `partforge/ingest` entry: SVG -> the partforge-vector JSON
2
+ // format. DOM-required and main-thread only — a host runs it once per artwork
3
+ // and stores the result, the same division of labour as `fontCatalog`.
4
+ // partforge does not write files.
5
+ //
6
+ // Deliberately NOT re-exported from `partforge` (the main entry) or from
7
+ // `partforge/geometry`: this must stay unreachable from the geometry worker.
8
+ export { ingestSvg } from "./framework/ingest/svg-ingest.js";
@@ -0,0 +1,10 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
2
+ <!-- A filled shape and a stroked OPEN shape, so this one file exercises both
3
+ of ingest's geometry paths. The union's tight bbox is 40 x 30 artwork
4
+ units: the circle spans 14..34 in both axes, and the stroked bar spans
5
+ 4..44 in x (round caps add half the 4-unit width at each end) and 40..44
6
+ in y. Deliberately NOT centred in the viewBox, so anything that sizes from
7
+ the viewBox instead of the geometry gets a visibly wrong answer. -->
8
+ <circle cx="24" cy="24" r="10" fill="#111"/>
9
+ <polyline points="6 42 42 42" fill="none" stroke="#111" stroke-width="4" stroke-linecap="round"/>
10
+ </svg>
@@ -0,0 +1,110 @@
1
+ {
2
+ "format": "partforge-vector",
3
+ "version": 1,
4
+ "units": "artwork",
5
+ "note": "Filled 2-D outlines for k.vector2d. `units` is \"mm\" (coordinates are millimetres, placed as authored) or \"artwork\" (no physical meaning; a size is required at every call site). `shapes` maps a name to a list of filled regions; each region's `outer` is its boundary and `holes` are subtracted from it. A contour is a `kind`: \"path\", \"circle\", \"rect\", or \"polygon\". Path segments run head-to-tail from `start`, and the contour closes implicitly from the last `to` back to `start`. y points UP. See docs/VECTOR-FORMAT.md.",
6
+ "source": "emblem.svg",
7
+ "bbox": {
8
+ "minX": 4,
9
+ "minY": -44,
10
+ "maxX": 44,
11
+ "maxY": -14
12
+ },
13
+ "shapes": {
14
+ "artwork": [
15
+ {
16
+ "outer": {
17
+ "kind": "path",
18
+ "start": [
19
+ 14,
20
+ -24
21
+ ],
22
+ "segments": [
23
+ {
24
+ "kind": "arc",
25
+ "to": [
26
+ 31.071068,
27
+ -31.071068
28
+ ],
29
+ "through": [
30
+ 20.173166,
31
+ -33.238795
32
+ ]
33
+ },
34
+ {
35
+ "kind": "arc",
36
+ "to": [
37
+ 24,
38
+ -14
39
+ ],
40
+ "through": [
41
+ 33.238795,
42
+ -20.173166
43
+ ]
44
+ },
45
+ {
46
+ "kind": "arc",
47
+ "to": [
48
+ 14,
49
+ -24
50
+ ],
51
+ "through": [
52
+ 16.928932,
53
+ -16.928932
54
+ ]
55
+ }
56
+ ]
57
+ },
58
+ "holes": []
59
+ },
60
+ {
61
+ "outer": {
62
+ "kind": "path",
63
+ "start": [
64
+ 6,
65
+ -40
66
+ ],
67
+ "segments": [
68
+ {
69
+ "kind": "arc",
70
+ "to": [
71
+ 6,
72
+ -44
73
+ ],
74
+ "through": [
75
+ 4,
76
+ -42
77
+ ]
78
+ },
79
+ {
80
+ "kind": "line",
81
+ "to": [
82
+ 42,
83
+ -44
84
+ ]
85
+ },
86
+ {
87
+ "kind": "arc",
88
+ "to": [
89
+ 42,
90
+ -40
91
+ ],
92
+ "through": [
93
+ 44,
94
+ -42
95
+ ]
96
+ },
97
+ {
98
+ "kind": "line",
99
+ "to": [
100
+ 6,
101
+ -40
102
+ ]
103
+ }
104
+ ]
105
+ },
106
+ "holes": []
107
+ }
108
+ ]
109
+ }
110
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "format": "partforge-vector",
3
+ "version": 1,
4
+ "units": "mm",
5
+ "note": "Emblem backing plate. Drawn at 40 x 24 mm with M3 clearance holes on 28 mm centres and a keyway placed low, in the gap between the emblem artwork's disc and its bar, so it stays a real through-slot rather than getting capped by the emboss at the default emblem_w. Coordinates are millimetres and place as authored, so `body`, `holes`, and `keyway` share one frame — the cut in build lands where it is drawn.",
6
+ "shapes": {
7
+ "body": {
8
+ "role": "add",
9
+ "regions": [
10
+ { "outer": { "kind": "rect", "center": [0, 0], "width": 40, "height": 24, "radius": 4 } }
11
+ ]
12
+ },
13
+ "holes": {
14
+ "role": "subtract",
15
+ "regions": [
16
+ { "outer": { "kind": "circle", "center": [-14, 0], "r": 1.7 } },
17
+ { "outer": { "kind": "circle", "center": [14, 0], "r": 1.7 } }
18
+ ]
19
+ },
20
+ "keyway": {
21
+ "role": "subtract",
22
+ "regions": [
23
+ { "outer": { "kind": "polygon", "points": [[-3, -8], [3, -8], [0, -4]] } }
24
+ ]
25
+ }
26
+ }
27
+ }
@@ -0,0 +1,102 @@
1
+ // The k.vector2d reference part — for BOTH paths the format supports: ingested
2
+ // artwork (`emblem`, `units: "artwork"`, sized per call site) and an authored
3
+ // millimetre drawing (`plate`, `units: "mm"`, placed exactly as drawn). The two
4
+ // vectors share one build, composed together with an ordinary boolean.
5
+ //
6
+ // `vectors` is declared with `new URL(..., import.meta.url)`, the same form
7
+ // import-demo.js uses for its STL: Vite turns it into a bundled asset URL, and
8
+ // in Node it is a file: URL that src/testing/assets.js reads off disk. A bare
9
+ // `() => import("./assets/emblem.vector.json")` would work in Vite and fail in the CLI.
10
+ //
11
+ // The source artwork lives beside it as emblem.svg, and the .json is regenerated
12
+ // with `node scripts/ingest-svg.mjs src/parts/assets/emblem.svg`. plate.vector.json
13
+ // is hand-authored — no ingest step, no source SVG — and is kept legible enough
14
+ // to serve as documentation's worked example of a multi-shape, role-composed file.
15
+ export default {
16
+ meta: { title: "Emblem", units: "mm", background: 0x15181d },
17
+ vectors: {
18
+ emblem: new URL("./assets/emblem.vector.json", import.meta.url),
19
+ plate: new URL("./assets/plate.vector.json", import.meta.url),
20
+ },
21
+ parameters: [
22
+ {
23
+ id: "plate",
24
+ title: "Plate",
25
+ description: "The backing plate the artwork is embossed on. Its outline — including the bolt "
26
+ + "holes and keyway — is drawn in plate.vector.json, not parameterized.",
27
+ advanced: [
28
+ { key: "plate_t", label: "Thickness", unit: "mm", min: 1, max: 10, step: 0.5, description: "Plate thickness." },
29
+ ],
30
+ },
31
+ {
32
+ id: "art",
33
+ title: "Artwork",
34
+ description: "The embossed vector art. `emblem.svg` carries a filled circle and a stroked bar, so both of ingest's geometry paths are exercised.",
35
+ advanced: [
36
+ { key: "emblem_w", label: "Emblem width", unit: "mm", min: 8, max: 70, step: 1,
37
+ description: "Width of the artwork's **tight bounding box** in mm — not its `viewBox`. Stroke thickness scales with it." },
38
+ { key: "emboss", label: "Emboss height", unit: "mm", min: 0.4, max: 4, step: 0.2,
39
+ description: "How far the artwork stands proud of the plate." },
40
+ ],
41
+ },
42
+ ],
43
+ defaults: { plate_t: 3, emblem_w: 30, emboss: 1 },
44
+ parts: {
45
+ plate: {
46
+ label: "Plate",
47
+ views: ["plate"],
48
+ export: { name: "emblem-plate" },
49
+ // No shape named and no size: the file's own roles compose it (body minus
50
+ // holes minus keyway), and units "mm" places it exactly as drawn. A size
51
+ // HERE would be safe — the composed call measures the whole document at
52
+ // once — but it would also be pointless. What is not safe is fetching
53
+ // body/holes/keyway separately and sizing each call: each would scale
54
+ // against ITS OWN bounds, and the drawing's shared frame would be gone
55
+ // (ERROR-PATTERNS.md#vector-mm-shapes-misscaled). Millimetres place as
56
+ // authored; that is the whole point of the units mode.
57
+ // The keyway sits clear of the artwork at the default `emblem_w` (30) —
58
+ // confirmed by measurement, not eyeballed: their 2-D footprints have zero
59
+ // intersection. That clearance is deliberate, not incidental: a much
60
+ // larger `emblem_w` would grow the emboss until it overlaps the keyway's
61
+ // footprint again, and the union below would then cap it from above —
62
+ // a through-slot the drawing marks `role: "subtract"` quietly becoming a
63
+ // blind pocket. This is exactly the failure mode the `holes` gate below
64
+ // exists to catch, which is why that gate is only asserted at defaults.
65
+ build: (k, p) => k
66
+ .vector2d("plate")
67
+ .extrude({ h: p.plate_t })
68
+ .union(k.vector2d("emblem", { width: p.emblem_w }).extrude({ h: p.emboss }).translate([0, 0, p.plate_t])),
69
+ },
70
+ },
71
+ views: { plate: { label: "Plate" } },
72
+ verify: {
73
+ expect: {
74
+ plate: {
75
+ // Tight in all three axes, by the same +1 mm convention as
76
+ // import-demo.js's gates: verify evaluates the `defaults` case only
77
+ // (this part declares no presets), so bbox is checked against one
78
+ // deterministic value (40 x 24 x 4) forever, not a swept range —
79
+ // z = plate_t + emboss = 3 + 1 = 4 at these defaults specifically,
80
+ // not the schema's wider plate_t/emboss envelope. Revisit this bound
81
+ // if a preset is ever added that sweeps plate_t or emboss.
82
+ bbox: "<=[41,25,5]",
83
+ // Measured at defaults: 3013 mm^3 (`npx partforge measure`). The bare
84
+ // plate (no emboss union) is 2748 mm^3 — comfortably under this bound —
85
+ // so a silently-vanished emboss union fails here. Complemented by the
86
+ // `holes` gate below for the opposite failure (a cut that stops working
87
+ // raises volume, not lowers it, so this bound alone can't catch that).
88
+ volume: ">=2900",
89
+ watertight: true,
90
+ // Three through-holes: the two bolt circles, plus the keyway triangle —
91
+ // all cut clean through the extruded plate and, at this part's default
92
+ // `emblem_w`, none of them sit under the artwork's emboss (see the
93
+ // build comment above for why that placement matters). Confirmed with
94
+ // `npx partforge measure`, and falsified by temporarily flipping
95
+ // "holes"/"keyway" to role "add" in plate.vector.json (which drops
96
+ // this to 0, proving the gate can fail).
97
+ holes: 3,
98
+ },
99
+ _view: { overlaps: 0 },
100
+ },
101
+ },
102
+ };
@@ -6,10 +6,11 @@ import { createManifoldKernel } from "../framework/geometry/manifold-backend.js"
6
6
  import { resolveFonts } from "../framework/fonts.js";
7
7
  import { normalizeOpentype, parseFont } from "../framework/geometry/opentype-interop.js";
8
8
  import { ensureImports } from "../framework/imports.js";
9
+ import { ensureVectors } from "../framework/vectors.js";
9
10
  import { nodeAssetSources } from "./assets.js";
10
11
  import { tessellateStepAssets } from "./step-mesh.js";
11
12
 
12
- export async function bootManifoldKernel({ quality = "preview", fonts, imports, importMeshes } = {}) {
13
+ export async function bootManifoldKernel({ quality = "preview", fonts, imports, importMeshes, vectors } = {}) {
13
14
  const wasm = await Module();
14
15
  wasm.setup();
15
16
  const kernel = createManifoldKernel(wasm, { quality });
@@ -24,5 +25,6 @@ export async function bootManifoldKernel({ quality = "preview", fonts, imports,
24
25
  const meshes = importMeshes ?? (stepEntries.length ? await tessellateStepAssets(stepEntries) : null);
25
26
  await ensureImports(kernel, decl, meshes);
26
27
  }
28
+ if (vectors) await ensureVectors(kernel, nodeAssetSources(vectors));
27
29
  return kernel;
28
30
  }
@@ -8,9 +8,10 @@ import { createOcctKernel } from "../framework/geometry/occt-backend.js";
8
8
  import { resolveFonts } from "../framework/fonts.js";
9
9
  import { normalizeOpentype, parseFont } from "../framework/geometry/opentype-interop.js";
10
10
  import { ensureImports } from "../framework/imports.js";
11
+ import { ensureVectors } from "../framework/vectors.js";
11
12
  import { nodeAssetSources } from "./assets.js";
12
13
 
13
- export async function bootOcctKernel({ fonts, imports, importMeshes } = {}) {
14
+ export async function bootOcctKernel({ fonts, imports, importMeshes, vectors } = {}) {
14
15
  const require = createRequire(import.meta.url);
15
16
  globalThis.require = globalThis.require ?? require;
16
17
  globalThis.__dirname = globalThis.__dirname ?? path.dirname(fileURLToPath(import.meta.url));
@@ -22,5 +23,6 @@ export async function bootOcctKernel({ fonts, imports, importMeshes } = {}) {
22
23
  if (fonts) { const opentype = normalizeOpentype(await import("opentype.js"));
23
24
  for (const [name, buf] of await resolveFonts(nodeAssetSources(fonts))) kernel._fonts.set(name, parseFont(opentype, buf, name)); }
24
25
  if (imports) await ensureImports(kernel, nodeAssetSources(imports), importMeshes ?? null);
26
+ if (vectors) await ensureVectors(kernel, nodeAssetSources(vectors));
25
27
  return kernel;
26
28
  }
package/types/index.d.ts CHANGED
@@ -44,6 +44,8 @@ export interface PickEvent {
44
44
  prompt: string;
45
45
  /** The selection formatted as a compact token. */
46
46
  token: string;
47
+ /** Where the pick's marker sits on the canvas, in CSS px from its top-left. */
48
+ anchor: { x: number; y: number };
47
49
  }
48
50
 
49
51
  /** Fired once per completed build. NOT fired for a pose-only edit. */
@@ -244,6 +246,24 @@ export interface AnnotateRuntime {
244
246
  onModeChange(cb: () => void): () => void;
245
247
  }
246
248
 
249
+ /**
250
+ * The yellow marker a pick flashes, as a thing with a lifetime — for a host
251
+ * that hangs its own UI (a chat bubble, a callout) off the dot. A marker fades
252
+ * on its own about a second after the pick, so `hold()` belongs in the `onPick`
253
+ * handler, not behind a later user action.
254
+ */
255
+ export interface PickMarkerRuntime {
256
+ /** Keep the newest marker on screen; false when there is none. Earlier held markers stay held. */
257
+ hold(): boolean;
258
+ /** Clear every held marker. */
259
+ release(): void;
260
+ /**
261
+ * Where the newest held marker is, as the camera moves; null when nothing is
262
+ * held. Fires immediately with the current state. Returns an unsubscribe.
263
+ */
264
+ onAnchorChange(cb: (anchor: { x: number; y: number; visible: boolean } | null) => void): () => void;
265
+ }
266
+
247
267
  /** Where playback is: idle, swinging the camera to an intro cue, playing, or paused. */
248
268
  export type AnimationStatus = "idle" | "intro" | "playing" | "paused";
249
269
 
@@ -359,6 +379,8 @@ export interface PartRuntime {
359
379
  measure: MeasureRuntime;
360
380
  /** Annotation mode's runtime-controls API — mode on/off, ink state, and send. Always present; a no-op stand-in when `onAnnotationSend` was not supplied. */
361
381
  annotate: AnnotateRuntime;
382
+ /** The pick marker's lifetime — hold it on screen and follow it across the canvas. Always present (a no-op stand-in outside `makeHandle` tests). */
383
+ pickMarker: PickMarkerRuntime;
362
384
  }
363
385
 
364
386
  /** Mount a full parametric-part app from a `PartDefinition`. */