partforge 0.19.0 → 0.20.1

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/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-embed-test.js +68 -0
  6. package/src/app-nameplate.js +9 -0
  7. package/src/app-text-smoke.js +10 -0
  8. package/src/bracket-worker.js +3 -0
  9. package/src/framework/app.css +23 -6
  10. package/src/framework/cutaway-controls.js +155 -0
  11. package/src/framework/cutaway-gizmo.js +686 -0
  12. package/src/framework/cutaway-math.js +53 -0
  13. package/src/framework/cutaway-render.js +338 -0
  14. package/src/framework/cutaway.js +469 -0
  15. package/src/framework/fonts.js +36 -0
  16. package/src/framework/geometry/curve-fill.js +86 -0
  17. package/src/framework/geometry/fonts/Roboto-LICENSE.txt +93 -0
  18. package/src/framework/geometry/fonts/Roboto-Regular.ttf +0 -0
  19. package/src/framework/geometry/fonts/default-font.js +3 -0
  20. package/src/framework/geometry/kernel-front.js +53 -0
  21. package/src/framework/geometry/kernel.js +1 -1
  22. package/src/framework/geometry/text2d.js +98 -0
  23. package/src/framework/geometry-service.js +21 -2
  24. package/src/framework/jobs.js +9 -0
  25. package/src/framework/mount.js +278 -223
  26. package/src/framework/selection/hover.js +102 -36
  27. package/src/framework/selection/raycast.js +4 -1
  28. package/src/framework/tooltip.js +282 -0
  29. package/src/framework/viewer-controls.js +25 -2
  30. package/src/framework/viewer-lighting.js +13 -0
  31. package/src/framework/viewer.js +83 -10
  32. package/src/nameplate-worker.js +3 -0
  33. package/src/parts/bracket.js +76 -0
  34. package/src/parts/nameplate.js +74 -0
  35. package/src/parts/text-smoke.js +21 -0
  36. package/src/testing/manifold.js +6 -2
  37. package/src/testing/occt.js +6 -2
  38. package/src/text-smoke-worker.js +3 -0
@@ -12,8 +12,11 @@
12
12
  // capability (Manifold can't do toSTEP; both backends now define shape2d, so
13
13
  // that stub is dead in practice — kept as a safety net for a future backend).
14
14
  // The per-Solid twin of this layer is addSugar() in solid-sugar.js.
15
+ import * as opentype from "opentype.js";
15
16
  import { KernelCapabilityError } from "./errors.js";
16
17
  import { isPlainOptions, KERNEL_OP_SPECS } from "./op-options.js";
18
+ import { textGlyphs } from "./text2d.js";
19
+ import { DEFAULT_FONT_BYTES } from "./fonts/default-font.js";
17
20
 
18
21
  export function finishKernel(k) {
19
22
  // Compound default: bored-through cylinder (tool overshoots 2 mm each end for
@@ -35,5 +38,55 @@ export function finishKernel(k) {
35
38
  k.toSTEP ??= () => { throw new KernelCapabilityError("toSTEP requires the OCCT backend"); };
36
39
  k.shape2d ??= () => { throw new KernelCapabilityError("shape2d requires the Manifold backend"); };
37
40
 
41
+ // 2-D text as a Shape2D. Backend-agnostic: builds per-glyph Shape2Ds and unions
42
+ // them. Fonts come from k._fonts (framework-preloaded by name) or inline bytes.
43
+ k._fonts ??= new Map();
44
+ // Parse inline bytes once per buffer, keyed by the buffer's own IDENTITY (a stable
45
+ // import yields the same ArrayBuffer each build). WeakMap-by-buffer avoids the
46
+ // wrong-font bug a byteLength key would cause. No separate text2d cache/fontId is
47
+ // needed: text2d builds k.shape2d(glyphContours)+union, and the Shape2D hash keys
48
+ // on the actual glyph coordinates — a different font → different geometry →
49
+ // different cache entry, automatically.
50
+ const byteCache = new WeakMap(); // original view/buffer → parsed font
51
+ const parseBytes = (arg) => {
52
+ let f = byteCache.get(arg);
53
+ if (!f) {
54
+ // Key on the ORIGINAL arg (stable identity for the cache), but parse the view's
55
+ // EXACT byte range — arg.buffer alone spans the whole (possibly pooled) backing
56
+ // buffer, which would feed opentype garbage for a byteOffset>0 view.
57
+ const buf = ArrayBuffer.isView(arg) ? arg.buffer.slice(arg.byteOffset, arg.byteOffset + arg.byteLength) : arg;
58
+ f = opentype.parse(buf); byteCache.set(arg, f);
59
+ }
60
+ return f;
61
+ };
62
+ const resolveFont = (font) => {
63
+ if (font == null) {
64
+ // Lazily parse + memoize the framework's bundled default (vendored Roboto,
65
+ // SIL OFL 1.1 — see fonts/Roboto-LICENSE.txt) so k.text2d works with zero
66
+ // setup when { font } is omitted. Slice to the exact byte range rather than
67
+ // handing opentype the raw .buffer — DEFAULT_FONT_BYTES is a Uint8Array and,
68
+ // while it spans its own freshly-allocated buffer today (byteOffset 0), slicing
69
+ // guards against that ever changing.
70
+ if (!k._defaultFont) {
71
+ const { buffer, byteOffset, byteLength } = DEFAULT_FONT_BYTES;
72
+ k._defaultFont = opentype.parse(buffer.slice(byteOffset, byteOffset + byteLength));
73
+ }
74
+ return k._defaultFont;
75
+ }
76
+ if (typeof font === "string") {
77
+ const f = k._fonts.get(font);
78
+ if (!f) throw new Error(`text2d: unknown font "${font}" — declare it in the part's \`fonts\` field`);
79
+ return f;
80
+ }
81
+ return parseBytes(font);
82
+ };
83
+ k.text2d = (string, opts = {}) => {
84
+ const { font, size = 10, align = "center", valign = "middle", lineHeight, tracking = 0, kerning = true } = opts;
85
+ const parsed = resolveFont(font);
86
+ const regions = textGlyphs(parsed, string, { size, align, valign, lineHeight, tracking, kerning });
87
+ if (regions.length === 0) throw new Error("text2d: string produced no glyph geometry (empty or all-whitespace?)");
88
+ return regions.map((r) => k.shape2d(r)).reduce((a, b) => a.union(b));
89
+ };
90
+
38
91
  return k;
39
92
  }
@@ -19,7 +19,7 @@ export const CONTRACT_VERSION = 1;
19
19
  // Ops every backend kernel must implement.
20
20
  export const KERNEL_OPS = [
21
21
  "cylinder", "boredCylinder", "sphere", "box", "prism", "extrude", "revolve",
22
- "loft", "sweep", "helixSweptTube", "union", "shape2d", "toSTEP",
22
+ "loft", "sweep", "helixSweptTube", "union", "shape2d", "text2d", "toSTEP",
23
23
  ];
24
24
 
25
25
  // Backend-optional kernel ops: the Manifold cache brackets + WASM lifetime hooks.
@@ -0,0 +1,98 @@
1
+ // Pure (kernel-free) text layout: an opentype.js font + string → per-glyph curve-
2
+ // contour region specs ({outer, holes}, pathProfile contours), positioned + scaled
3
+ // so uppercase letters are `size` mm tall, in math (y-up, CCW) coordinates. The
4
+ // kernel's text2d maps these to k.shape2d and unions them. No WASM, no DOM.
5
+ import { pathProfile } from "./polygon.js";
6
+ import { resolveCurveFill } from "./curve-fill.js";
7
+
8
+ const capHeightUnits = (font) =>
9
+ font.tables?.os2?.sCapHeight || font.charToGlyph("H").getBoundingBox().y2 || font.unitsPerEm * 0.7;
10
+
11
+ // opentype path commands (font units, y-DOWN) → array of pathProfile contours
12
+ // (y flipped to math up). Each M starts a new contour; Q elevates to cubic.
13
+ function glyphContours(glyph, font) {
14
+ const cmds = glyph.getPath(0, 0, font.unitsPerEm).commands;
15
+ const contours = [];
16
+ let pen = null, cur = null, start = null;
17
+ const P = ([x, y]) => [x, -y]; // y-down → y-up
18
+ for (const c of cmds) {
19
+ if (c.type === "M") { if (pen) contours.push(pen.close()); start = cur = P([c.x, c.y]); pen = pathProfile(start); }
20
+ else if (c.type === "L") { cur = P([c.x, c.y]); pen.lineTo(cur); }
21
+ else if (c.type === "C") { pen.cubicTo(P([c.x, c.y]), P([c.x1, c.y1]), P([c.x2, c.y2])); cur = P([c.x, c.y]); }
22
+ else if (c.type === "Q") { // quadratic → cubic elevation
23
+ const p0 = cur, q = P([c.x1, c.y1]), end = P([c.x, c.y]);
24
+ const c1 = [p0[0] + (2/3)*(q[0]-p0[0]), p0[1] + (2/3)*(q[1]-p0[1])];
25
+ const c2 = [end[0] + (2/3)*(q[0]-end[0]), end[1] + (2/3)*(q[1]-end[1])];
26
+ pen.cubicTo(end, c1, c2); cur = end;
27
+ }
28
+ else if (c.type === "Z") { if (pen) { contours.push(pen.close()); pen = null; } }
29
+ }
30
+ if (pen) contours.push(pen.close());
31
+ return contours;
32
+ }
33
+
34
+ // Translate a pathProfile contour by (dx,dy) and scale by s (about origin, post-translate order: scale then translate).
35
+ const xform = (contour, s, dx, dy) => {
36
+ const T = ([x, y]) => [x * s + dx, y * s + dy];
37
+ const out = { start: T(contour.start), segments: contour.segments.map((seg) => {
38
+ const m = { to: T(seg.to) };
39
+ if (seg.via) m.via = T(seg.via);
40
+ if (seg.c1) { m.c1 = T(seg.c1); m.c2 = T(seg.c2); }
41
+ return m;
42
+ }) };
43
+ return out;
44
+ };
45
+
46
+ export function textGlyphs(font, string, { size = 10, align = "center", valign = "middle",
47
+ lineHeight, tracking = 0, kerning = true } = {}) {
48
+ const upm = font.unitsPerEm;
49
+ const s = size / capHeightUnits(font); // font units → mm (cap height)
50
+ const lineAdv = (lineHeight ?? (font.ascender - font.descender) / upm * size * 1.0);
51
+ const kern = (a, b) => { if (!kerning || !a || !b) return 0; try { return font.getKerningValue(a, b); } catch { return 0; } };
52
+ // Every OpenType outline format — TrueType (glyf) and PostScript (CFF/CFF2) — is filled
53
+ // with the NONZERO winding rule (the glyf / Type 2 charstring imaging model). even-odd is
54
+ // not an OpenType fill rule; the resolver keeps it only as a general capability.
55
+ const fillRule = "nonzero";
56
+
57
+ const lines = string.split("\n");
58
+ // 1) lay out each line in font-unit x, collect glyph region specs + line width (mm)
59
+ const laid = lines.map((line) => {
60
+ // One glyph per input character via font.charToGlyph, NOT font.stringToGlyphs.
61
+ // stringToGlyphs runs opentype.js's bidi/GSUB text-shaping engine (ligatures,
62
+ // and — unconditionally, regardless of the `features` option — ccmp glyph
63
+ // composition). That engine eagerly instantiates a lookup method for every
64
+ // subtable in play and throws for lookup types it hasn't implemented (e.g.
65
+ // "lookupType 6 - substFormat: 2", class-based chaining contextual
66
+ // substitution) even when the actual input never matches that subtable's
67
+ // coverage. Real-world fonts commonly carry such lookups in their ccmp
68
+ // feature (e.g. the bundled Roboto, for accent composition), so
69
+ // stringToGlyphs throws on almost any 2+ character string against them —
70
+ // this is a per-character CAD label generator, not a typesetting engine, so
71
+ // literal glyph-per-character mapping (no ligatures/substitution) is exactly
72
+ // the semantics wanted here anyway.
73
+ const glyphs = Array.from(line).map((ch) => font.charToGlyph(ch));
74
+ let penX = 0; const specs = [];
75
+ glyphs.forEach((g, i) => {
76
+ if (i > 0) penX += kern(glyphs[i - 1], g);
77
+ for (const region of resolveCurveFill(glyphContours(g, font), { fillRule }))
78
+ specs.push({ region, penX }); // remember this glyph's pen origin (font units)
79
+ penX += g.advanceWidth + (tracking / s); // tracking is mm → font units
80
+ });
81
+ return { specs, widthMm: penX * s };
82
+ });
83
+
84
+ const totalH = (lines.length - 1) * lineAdv + size; // block height (mm), caps as the line box
85
+ const blockDy = valign === "top" ? -size : valign === "bottom" ? totalH - size
86
+ : valign === "middle" ? (totalH / 2 - size) : 0; // "baseline" → 0
87
+
88
+ const out = [];
89
+ laid.forEach(({ specs, widthMm }, li) => {
90
+ const alignDx = align === "center" ? -widthMm / 2 : align === "right" ? -widthMm : 0;
91
+ const dy = -li * lineAdv + blockDy; // lines stack downward
92
+ for (const { region, penX } of specs) {
93
+ const dx = penX * s + alignDx;
94
+ out.push({ outer: xform(region.outer, s, dx, dy), holes: region.holes.map((h) => xform(h, s, dx, dy)) });
95
+ }
96
+ });
97
+ return out;
98
+ }
@@ -5,8 +5,27 @@
5
5
  // `new Worker(new URL("./part-worker.js", import.meta.url), { type:"module", name })`
6
6
  // pattern INLINE — Vite only bundles a worker (and its backend chunks) when it sees
7
7
  // that literal call, so the framework can't construct it from a passed-in URL.
8
+ function terminateWorkers(workers) {
9
+ const errors = [];
10
+ for (const worker of workers) {
11
+ try { worker.terminate(); } catch (error) { errors.push(error); }
12
+ }
13
+ if (errors.length === 1) throw errors[0];
14
+ if (errors.length > 1) {
15
+ throw new AggregateError(errors, "geometry worker termination failed");
16
+ }
17
+ }
18
+
8
19
  export function createGeometryService({ createWorker, onMessage }) {
9
- const workers = { manifold: createWorker("manifold"), occt: createWorker("occt") };
20
+ const manifold = createWorker("manifold");
21
+ let occt;
22
+ try {
23
+ occt = createWorker("occt");
24
+ } catch (error) {
25
+ try { terminateWorkers([manifold]); } catch { /* preserve the worker creation error */ }
26
+ throw error;
27
+ }
28
+ const workers = { manifold, occt };
10
29
  workers.manifold.onmessage = onMessage;
11
30
  workers.occt.onmessage = onMessage;
12
31
  // Post a job to the chosen backend's worker. The message's own `type` says what to
@@ -14,6 +33,6 @@ export function createGeometryService({ createWorker, onMessage }) {
14
33
  // — manifold for preview/STL/3MF, occt for STEP (the caller passes "occt" for that).
15
34
  return {
16
35
  send: (msg, backend = "manifold") => workers[backend].postMessage(msg),
17
- terminate: () => { workers.manifold.terminate(); workers.occt.terminate(); },
36
+ terminate: () => terminateWorkers([workers.manifold, workers.occt]),
18
37
  };
19
38
  }
@@ -1,5 +1,6 @@
1
1
  import { meshTo3MF } from "./geometry/threemf.js";
2
2
  import { resolveDerived } from "./derive.js";
3
+ import { resolveFonts } from "./fonts.js";
3
4
 
4
5
  // Names of the sub-parts a view shows: declared in the view and enabled for these
5
6
  // params. Order follows Object.keys(part.parts) (definition order).
@@ -56,6 +57,14 @@ export async function handle(kernel, part, msg, post) {
56
57
  const exportName = (name) => part.parts[name].export?.name ?? name;
57
58
 
58
59
  try {
60
+ // Preload any part-declared fonts into the kernel before building — once per
61
+ // font name; a lazy dynamic import because this is async context (unlike the
62
+ // synchronous kernel-front), so it doesn't cost sync callers anything.
63
+ if (part.fonts && kernel._fonts) {
64
+ const opentype = (await import("opentype.js")).default;
65
+ const bufs = await resolveFonts(part.fonts);
66
+ for (const [name, buf] of bufs) if (!kernel._fonts.has(name)) kernel._fonts.set(name, opentype.parse(buf));
67
+ }
59
68
  // Inside the try so a throwing derive posts an error the UI can show,
60
69
  // instead of killing the worker turn silently (an endless spinner).
61
70
  const { p, d } = resolveParams(part, msg.params);