partforge 0.19.0 → 0.20.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 (37) hide show
  1. package/docs/AUTHORING-PARTS.md +97 -1
  2. package/docs/ERROR-PATTERNS.md +6 -0
  3. package/package.json +3 -1
  4. package/src/app-bracket.js +9 -0
  5. package/src/app-nameplate.js +9 -0
  6. package/src/app-text-smoke.js +10 -0
  7. package/src/bracket-worker.js +3 -0
  8. package/src/framework/app.css +23 -6
  9. package/src/framework/cutaway-controls.js +155 -0
  10. package/src/framework/cutaway-gizmo.js +686 -0
  11. package/src/framework/cutaway-math.js +53 -0
  12. package/src/framework/cutaway-render.js +338 -0
  13. package/src/framework/cutaway.js +469 -0
  14. package/src/framework/fonts.js +36 -0
  15. package/src/framework/geometry/curve-fill.js +86 -0
  16. package/src/framework/geometry/fonts/Roboto-LICENSE.txt +93 -0
  17. package/src/framework/geometry/fonts/Roboto-Regular.ttf +0 -0
  18. package/src/framework/geometry/fonts/default-font.js +3 -0
  19. package/src/framework/geometry/kernel-front.js +53 -0
  20. package/src/framework/geometry/kernel.js +1 -1
  21. package/src/framework/geometry/text2d.js +98 -0
  22. package/src/framework/geometry-service.js +21 -2
  23. package/src/framework/jobs.js +9 -0
  24. package/src/framework/mount.js +278 -223
  25. package/src/framework/selection/hover.js +102 -36
  26. package/src/framework/selection/raycast.js +4 -1
  27. package/src/framework/tooltip.js +282 -0
  28. package/src/framework/viewer-controls.js +25 -2
  29. package/src/framework/viewer-lighting.js +13 -0
  30. package/src/framework/viewer.js +83 -10
  31. package/src/nameplate-worker.js +3 -0
  32. package/src/parts/bracket.js +76 -0
  33. package/src/parts/nameplate.js +67 -0
  34. package/src/parts/text-smoke.js +21 -0
  35. package/src/testing/manifold.js +6 -2
  36. package/src/testing/occt.js +6 -2
  37. package/src/text-smoke-worker.js +3 -0
@@ -4,12 +4,14 @@ import { toCreasedNormals } from "three/addons/utils/BufferGeometryUtils.js";
4
4
  import { LineSegments2 } from "three/addons/lines/LineSegments2.js";
5
5
  import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js";
6
6
  import { LineMaterial } from "three/addons/lines/LineMaterial.js";
7
+ import { createCutaway } from "./cutaway.js";
8
+ import { addViewerLights } from "./viewer-lighting.js";
7
9
 
8
10
  export function createViewer(container, part) {
9
11
  const names = Object.keys(part.parts);
10
12
 
11
13
  // --- renderer / scene / camera --------------------------------------------
12
- const renderer = new THREE.WebGLRenderer({ antialias: true });
14
+ const renderer = new THREE.WebGLRenderer({ antialias: true, stencil: true });
13
15
  renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
14
16
  container.appendChild(renderer.domElement);
15
17
 
@@ -32,10 +34,7 @@ export function createViewer(container, part) {
32
34
  controls.autoRotateSpeed = 1.6;
33
35
 
34
36
  // --- lights + grid --------------------------------------------------------
35
- scene.add(new THREE.HemisphereLight(0xbfd4ff, 0x202024, 1.1));
36
- const key = new THREE.DirectionalLight(0xffffff, 1.4);
37
- key.position.set(8, 14, 10);
38
- scene.add(key);
37
+ addViewerLights(scene);
39
38
  // 1 cm grid (mm units): 300 mm wide, 30 divisions -> 10 mm (1 cm) squares.
40
39
  const GRID_SIZE = 300, GRID_DIVS = 30;
41
40
  let floorY = 0; // world Y of the grid plane; set to the part's bbox bottom in frameTo
@@ -86,7 +85,7 @@ export function createViewer(container, part) {
86
85
 
87
86
  // CAD-style feature edge lines (anti-aliased "fat" lines), one per sub-part.
88
87
  const EDGE_ANGLE = 35; // deg — OCCT fallback threshold (Manifold supplies seam-aware edges)
89
- const lineMaterial = new LineMaterial({ color: 0x1c232d, linewidth: 1.0 }); // ~10% lighter, 1 px
88
+ const lineMaterial = new LineMaterial({ color: THEME.dark.line, linewidth: 1.0 }); // ~10% lighter, 1 px
90
89
  lineMaterial.resolution.set(1, 1); // real size set by resize() below
91
90
  const subLines = Object.fromEntries(
92
91
  names.map((n) => [n, new LineSegments2(new LineSegmentsGeometry(), lineMaterial)])
@@ -96,6 +95,32 @@ export function createViewer(container, part) {
96
95
  partsGroup.add(l);
97
96
  }
98
97
 
98
+ // The cutaway plane lives in world space, so its initial/reset bounds must
99
+ // include the pivot rotation and the per-view recentering transform.
100
+ const _worldBounds = new THREE.Box3();
101
+ function getVisibleWorldBounds() {
102
+ _worldBounds.makeEmpty();
103
+ for (const mesh of Object.values(subMesh)) {
104
+ if (!mesh.visible || !mesh.geometry) continue;
105
+ mesh.updateWorldMatrix(true, false);
106
+ _worldBounds.expandByObject(mesh);
107
+ }
108
+ return _worldBounds;
109
+ }
110
+
111
+ const cutaway = createCutaway({
112
+ renderer,
113
+ scene,
114
+ camera,
115
+ orbitControls: controls,
116
+ domElement: renderer.domElement,
117
+ getBounds: getVisibleWorldBounds,
118
+ edgeColor: THEME.dark.line,
119
+ });
120
+ for (const name of names) {
121
+ cutaway.setSubpart(name, subMesh[name], subLines[name]);
122
+ }
123
+
99
124
  // Smooth shading within CREASE_ANGLE of a shared edge, hard edge past it — so the
100
125
  // round body and helical groove read smooth while bore rims, drum faces, and
101
126
  // groove walls stay crisp. Lower = more hard edges; raise toward Math.PI/3 for
@@ -135,9 +160,13 @@ export function createViewer(container, part) {
135
160
  const subCache = Object.fromEntries(names.map((n) => [n, null]));
136
161
 
137
162
  function setSubGeometry(name, payload) {
138
- const prev = subCache[name]; // free the geometry this replaces (its own buffers + edge lines)
163
+ const prev = subCache[name];
164
+ const next = buildGeometry(payload);
165
+ subCache[name] = next;
166
+ // Section helpers must stop referring to the old buffers before those
167
+ // buffers are released.
168
+ cutaway.updateGeometry(name, next);
139
169
  if (prev) { prev.userData.edges?.dispose(); prev.dispose(); }
140
- subCache[name] = buildGeometry(payload);
141
170
  }
142
171
 
143
172
  // Cache queries for the app's regenerate loop (so it never reaches into subCache).
@@ -178,6 +207,7 @@ export function createViewer(container, part) {
178
207
  subLines[name].visible = on;
179
208
  }
180
209
  if (frame) frameTo(visibleNames);
210
+ cutaway.setVisible(visibleNames);
181
211
  }
182
212
 
183
213
  // Re-frame whatever is currently visible (the reframe button).
@@ -185,7 +215,19 @@ export function createViewer(container, part) {
185
215
  frameTo(names.filter((n) => subMesh[n].visible && subCache[n]));
186
216
  }
187
217
 
188
- function setAutoRotate(on) { controls.autoRotate = on; }
218
+ let autoRotateRequested = true;
219
+ function syncAutoRotate() {
220
+ controls.autoRotate = autoRotateRequested && !cutaway.isEnabled;
221
+ }
222
+ function setAutoRotate(on) {
223
+ autoRotateRequested = !!on;
224
+ syncAutoRotate();
225
+ }
226
+ function setCutawayEnabled(on) {
227
+ const changed = cutaway.setEnabled(on);
228
+ syncAutoRotate();
229
+ return changed;
230
+ }
189
231
 
190
232
  // Swap the scene background, grid, and edge-line colors for the given theme.
191
233
  function setTheme(mode) {
@@ -196,11 +238,13 @@ export function createViewer(container, part) {
196
238
  grid.position.y = floorY; // keep the floor at the bbox bottom across theme swaps
197
239
  scene.add(grid);
198
240
  lineMaterial.color.set(t.line);
241
+ cutaway.setTheme(mode, t.line);
199
242
  }
200
243
 
201
244
  function hideAssembly() {
202
245
  for (const m of Object.values(subMesh)) m.visible = false;
203
246
  for (const l of Object.values(subLines)) l.visible = false;
247
+ cutaway.setVisible([]);
204
248
  }
205
249
 
206
250
  // --- resize ---------------------------------------------------------------
@@ -211,6 +255,7 @@ export function createViewer(container, part) {
211
255
  camera.aspect = w / h;
212
256
  camera.updateProjectionMatrix();
213
257
  lineMaterial.resolution.set(w, h); // fat lines need the viewport size for px width
258
+ cutaway.setViewportSize(w, h, renderer.getPixelRatio());
214
259
  }
215
260
  const ro = new ResizeObserver(resize);
216
261
  ro.observe(container);
@@ -219,7 +264,9 @@ export function createViewer(container, part) {
219
264
  // --- render loop ----------------------------------------------------------
220
265
  renderer.setAnimationLoop(() => {
221
266
  controls.update();
267
+ if (cutaway.isEnabled) cutaway.updateForCamera();
222
268
  renderer.render(scene, camera);
269
+ cutaway.renderOverlay(renderer, camera);
223
270
  });
224
271
 
225
272
  // --- camera state (read/write for persistence; mount.js owns storage) -------
@@ -265,6 +312,7 @@ export function createViewer(container, part) {
265
312
  controls.dispose();
266
313
  for (const t of flashTimers) clearTimeout(t);
267
314
  flashTimers.clear();
315
+ cutaway.dispose();
268
316
  for (const n of names) {
269
317
  const g = subCache[n];
270
318
  if (g) { g.userData.edges?.dispose(); g.dispose(); subCache[n] = null; }
@@ -279,5 +327,30 @@ export function createViewer(container, part) {
279
327
  renderer.domElement.remove();
280
328
  }
281
329
 
282
- return { showAssembly, hideAssembly, setSubGeometry, hasSubMesh, subTriangles, frame, setAutoRotate, setTheme, getCameraState, setCameraState, onCameraEnd, camera, domElement: renderer.domElement, _subMeshes: subMesh, flashPoint, dispose };
330
+ return {
331
+ showAssembly,
332
+ hideAssembly,
333
+ setSubGeometry,
334
+ hasSubMesh,
335
+ subTriangles,
336
+ frame,
337
+ setAutoRotate,
338
+ setTheme,
339
+ getCameraState,
340
+ setCameraState,
341
+ onCameraEnd,
342
+ camera,
343
+ domElement: renderer.domElement,
344
+ _subMeshes: subMesh,
345
+ flashPoint,
346
+ cutawaySupported: () => cutaway.isSupported,
347
+ cutawayEnabled: () => cutaway.isEnabled,
348
+ setCutawayEnabled,
349
+ flipCutaway: cutaway.flip,
350
+ resetCutaway: cutaway.reset,
351
+ isWorldPointVisible: cutaway.isPointVisible,
352
+ registerCutawayMaterial: cutaway.registerClippableMaterial,
353
+ onCutawayHandleHover: cutaway.onHandleHoverChange,
354
+ dispose,
355
+ };
283
356
  }
@@ -0,0 +1,3 @@
1
+ import part from "./parts/nameplate.js";
2
+ import { runWorker } from "./framework/worker.js";
3
+ runWorker(part);
@@ -0,0 +1,76 @@
1
+ // Demo part — a cross bracket. Showcases the 2-D Shape2D toolkit end to end:
2
+ // • union — two rounded-rectangle bars fused into a plus
3
+ // • intersect — optionally clipped to a disc so the arm tips round off
4
+ // • cutAll — four corner bolt holes drilled in one batch
5
+ // • cut — a central bore
6
+ // • offset — an optional print-clearance grow with rounded corners
7
+ // The rounded corners and circular holes are true arcs (curve-native profiles), so
8
+ // the whole outline stays curve-exact. Open /bracket.html after `npm run dev`.
9
+ import { roundedRectPolygon, circleProfile } from "partforge/geometry";
10
+
11
+ export default {
12
+ meta: { title: "Cross bracket", units: "mm", background: 0x15181d },
13
+ parameters: [
14
+ {
15
+ id: "size",
16
+ title: "Bracket",
17
+ description: "A plus-shaped plate made by **union**-ing a horizontal and a vertical rounded bar.",
18
+ advanced: [
19
+ { key: "span", label: "Span", unit: "mm", min: 30, max: 100, step: 1,
20
+ description: "Overall arm length, tip to tip, in both directions." },
21
+ { key: "bar", label: "Arm width", unit: "mm", min: 10, max: 40, step: 1,
22
+ description: "Width of each arm." },
23
+ { key: "corner", label: "Corner radius", unit: "mm", min: 0, max: 12, step: 0.5,
24
+ description: "Rounding on the bar corners (arcs, kept exact to STEP)." },
25
+ { key: "thickness", label: "Thickness", unit: "mm", min: 2, max: 10, step: 0.5,
26
+ description: "Plate thickness." },
27
+ ],
28
+ },
29
+ {
30
+ id: "holes",
31
+ title: "Holes",
32
+ description: "Four corner bolt holes (**cutAll** in one batch) and an optional central bore (**cut**).",
33
+ advanced: [
34
+ { key: "hole_d", label: "Bolt hole ø", unit: "mm", min: 2, max: 10, step: 0.5,
35
+ description: "Diameter of the four corner holes." },
36
+ { key: "inset", label: "Hole inset", unit: "mm", min: 5, max: 24, step: 1,
37
+ description: "How far the corner holes sit in from the arm tips." },
38
+ { key: "center_d", label: "Center bore ø", unit: "mm", min: 0, max: 30, step: 1,
39
+ description: "Central through-bore. Set to 0 for none." },
40
+ ],
41
+ },
42
+ {
43
+ id: "shape",
44
+ title: "Shape ops",
45
+ toggles: [
46
+ { key: "clip", label: "Clip arms to a disc (intersect)", on: 1,
47
+ description: "**Intersect** the cross with a circle so the four arm tips are rounded off to a common radius." },
48
+ ],
49
+ advanced: [
50
+ { key: "clearance", label: "Print-clearance offset", unit: "mm", min: 0, max: 1, step: 0.1,
51
+ description: "**Offset** the whole outline outward (round corners) for a looser slip fit. 0 = none." },
52
+ ],
53
+ },
54
+ ],
55
+ defaults: { span: 60, bar: 22, corner: 4, thickness: 4, hole_d: 5, inset: 8, center_d: 16, clip: 0, clearance: 0 },
56
+ parts: {
57
+ bracket: {
58
+ label: "Cross bracket",
59
+ views: ["bracket"],
60
+ export: { name: "cross-bracket" },
61
+ build: (k, p) => {
62
+ const barH = k.shape2d(roundedRectPolygon(p.span, p.bar, p.corner));
63
+ const barV = k.shape2d(roundedRectPolygon(p.bar, p.span, p.corner));
64
+ let plate = barH.union(barV); // union
65
+ if (p.clip) plate = plate.intersect(k.shape2d(circleProfile(p.span / 2))); // intersect
66
+ const d = p.span / 2 - p.inset;
67
+ const holes = [[d, d], [-d, d], [d, -d], [-d, -d]].map((c) => circleProfile(p.hole_d / 2, c));
68
+ plate = plate.cutAll(holes); // batch cut
69
+ if (p.center_d > 0) plate = plate.cut(k.shape2d(circleProfile(p.center_d / 2))); // cut
70
+ if (p.clearance) plate = plate.offset(p.clearance, { corners: "round" }); // offset
71
+ return k.extrude({ profile: plate, h: p.thickness });
72
+ },
73
+ },
74
+ },
75
+ views: { bracket: { label: "Cross bracket" } },
76
+ };
@@ -0,0 +1,67 @@
1
+ // Demo part — a lettered nameplate. Showcases k.text2d (the vector-text feature):
2
+ // two lines of text with counters and curves (P A R T F O R G E, digits) resolved to
3
+ // exact curve regions, then either raised above (emboss) or cut into (deboss) a
4
+ // rounded plate. The plate itself is a Shape2D rounded-rectangle, so the part also
5
+ // exercises shape2d + the extrude/boolean path. Open /nameplate.html after `npm run dev`.
6
+ import { roundedRectPolygon } from "partforge/geometry";
7
+
8
+ const LABEL = "PARTFORGE\nv0.20";
9
+
10
+ export default {
11
+ meta: { title: "Nameplate", units: "mm", background: 0x15181d },
12
+ parameters: [
13
+ {
14
+ id: "text",
15
+ title: "Lettering",
16
+ description: "The text is a two-line label (`PARTFORGE` over `v0.20`). It is resolved to exact glyph curves — counters in **A R O G** and the **0** stay open — and sized by cap height.",
17
+ advanced: [
18
+ { key: "size", label: "Cap height", unit: "mm", min: 4, max: 16, step: 0.5,
19
+ description: "Height of the uppercase letters. The second line scales with it." },
20
+ { key: "depth", label: "Relief depth", unit: "mm", min: 0.4, max: 3, step: 0.1,
21
+ description: "How far the lettering is raised above (emboss) or recessed into (engrave) the plate face." },
22
+ ],
23
+ },
24
+ {
25
+ id: "plate",
26
+ title: "Plate",
27
+ description: "A rounded-rectangle backing plate, sized automatically from the text bounding box plus the border.",
28
+ advanced: [
29
+ { key: "margin", label: "Border", unit: "mm", min: 2, max: 12, step: 0.5,
30
+ description: "Clear space between the lettering and the plate edge." },
31
+ { key: "corner", label: "Corner radius", unit: "mm", min: 0, max: 10, step: 0.5,
32
+ description: "Rounding on the plate corners (clamped so it never exceeds half the shorter side)." },
33
+ { key: "thickness", label: "Thickness", unit: "mm", min: 1.5, max: 8, step: 0.5,
34
+ description: "Plate thickness." },
35
+ ],
36
+ },
37
+ {
38
+ id: "style",
39
+ title: "Style",
40
+ toggles: [
41
+ { key: "engrave", label: "Engrave (recessed)", on: 1,
42
+ description: "Cut the lettering into the top face instead of raising it above the plate." },
43
+ ],
44
+ },
45
+ ],
46
+ defaults: { size: 8, depth: 1.2, margin: 4, corner: 3, thickness: 3, engrave: 0 },
47
+ parts: {
48
+ plate: {
49
+ label: "Nameplate",
50
+ views: ["plate"],
51
+ export: { name: "nameplate" },
52
+ build: (k, p) => {
53
+ const text = k.text2d(LABEL, { size: p.size, align: "center", valign: "middle", lineHeight: p.size * 1.7 });
54
+ const bb = text.boundingBox();
55
+ const w = (bb.max[0] - bb.min[0]) + 2 * p.margin;
56
+ const h = (bb.max[1] - bb.min[1]) + 2 * p.margin;
57
+ const corner = Math.max(0, Math.min(p.corner, Math.min(w, h) / 2 - 0.5));
58
+ const plate = k.extrude({ profile: k.shape2d(roundedRectPolygon(w, h, corner)), h: p.thickness }).label("Plate");
59
+ const relief = k.extrude({ profile: text, h: p.depth });
60
+ return p.engrave
61
+ ? plate.cut(relief.translate([0, 0, p.thickness - p.depth]))
62
+ : plate.union(relief.translate([0, 0, p.thickness]).label("Lettering"));
63
+ },
64
+ },
65
+ },
66
+ views: { plate: { label: "Nameplate" } },
67
+ };
@@ -0,0 +1,21 @@
1
+ // Dev/CI worker-safety fixture (Task 3): a real text-bearing part, wired through
2
+ // the actual app/worker path so scripts/check-app.mjs can boot it in real Chromium.
3
+ // This is the definitive proof that paper.js (via resolveCurveFill) actually
4
+ // executes inside the Vite geometry Web Worker, not just under Node/vitest.
5
+ export default {
6
+ meta: { title: "Text worker smoke", units: "mm", background: 0x15181d },
7
+ parameters: [],
8
+ defaults: {},
9
+ parts: {
10
+ text: {
11
+ label: "Text",
12
+ views: ["text"],
13
+ export: { name: "text-smoke" },
14
+ build: (k) => k.extrude({
15
+ profile: k.text2d("B8&", { size: 10, align: "center", valign: "middle" }),
16
+ h: 2,
17
+ }).label("Resolved text"),
18
+ },
19
+ },
20
+ views: { text: { label: "Text" } },
21
+ };
@@ -3,9 +3,13 @@
3
3
  // in the same process — they crash together.)
4
4
  import Module from "manifold-3d";
5
5
  import { createManifoldKernel } from "../framework/geometry/manifold-backend.js";
6
+ import { resolveFonts } from "../framework/fonts.js";
6
7
 
7
- export async function bootManifoldKernel({ quality = "preview" } = {}) {
8
+ export async function bootManifoldKernel({ quality = "preview", fonts } = {}) {
8
9
  const wasm = await Module();
9
10
  wasm.setup();
10
- return createManifoldKernel(wasm, { quality });
11
+ const kernel = createManifoldKernel(wasm, { quality });
12
+ if (fonts) { const opentype = (await import("opentype.js")).default;
13
+ for (const [name, buf] of await resolveFonts(fonts)) kernel._fonts.set(name, opentype.parse(buf)); }
14
+ return kernel;
11
15
  }
@@ -5,8 +5,9 @@ import { fileURLToPath } from "url";
5
5
  import path from "path";
6
6
  import fs from "fs";
7
7
  import { createOcctKernel } from "../framework/geometry/occt-backend.js";
8
+ import { resolveFonts } from "../framework/fonts.js";
8
9
 
9
- export async function bootOcctKernel() {
10
+ export async function bootOcctKernel({ fonts } = {}) {
10
11
  const require = createRequire(import.meta.url);
11
12
  globalThis.require = globalThis.require ?? require;
12
13
  globalThis.__dirname = globalThis.__dirname ?? path.dirname(fileURLToPath(import.meta.url));
@@ -14,5 +15,8 @@ export async function bootOcctKernel() {
14
15
  const OC = await init({ wasmBinary: fs.readFileSync(require.resolve("replicad-opencascadejs/src/replicad_single.wasm")) });
15
16
  const replicad = await import("replicad");
16
17
  replicad.setOC(OC);
17
- return createOcctKernel(replicad);
18
+ const kernel = createOcctKernel(replicad);
19
+ if (fonts) { const opentype = (await import("opentype.js")).default;
20
+ for (const [name, buf] of await resolveFonts(fonts)) kernel._fonts.set(name, opentype.parse(buf)); }
21
+ return kernel;
18
22
  }
@@ -0,0 +1,3 @@
1
+ import part from "./parts/text-smoke.js";
2
+ import { runWorker } from "./framework/worker.js";
3
+ runWorker(part);