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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.91.0",
3
+ "version": "0.93.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",
@@ -21,6 +21,7 @@
21
21
  "docs/AUTHORING-PARTS.md",
22
22
  "docs/ERROR-PATTERNS.md",
23
23
  "docs/KERNEL-CONTRACT.md",
24
+ "docs/VECTOR-FORMAT.md",
24
25
  "README.md"
25
26
  ],
26
27
  "types": "./types/index.d.ts",
@@ -53,6 +54,10 @@
53
54
  "types": "./types/testing.d.ts",
54
55
  "default": "./src/testing.js"
55
56
  },
57
+ "./ingest": {
58
+ "types": "./types/ingest.d.ts",
59
+ "default": "./src/ingest.js"
60
+ },
56
61
  "./tokens.css": "./src/framework/tokens.css",
57
62
  "./chrome.css": "./src/framework/chrome.css"
58
63
  },
@@ -75,6 +80,9 @@
75
80
  ],
76
81
  "testing": [
77
82
  "./types/testing.d.ts"
83
+ ],
84
+ "ingest": [
85
+ "./types/ingest.d.ts"
78
86
  ]
79
87
  }
80
88
  },
@@ -0,0 +1,15 @@
1
+ import "@fontsource-variable/geist";
2
+ import "@fontsource-variable/geist-mono";
3
+ import emblemPart from "./parts/emblem.js";
4
+ import { mount } from "./framework/index.js";
5
+
6
+ // Dev-only example app for the emblem part (the k.vector2d reference).
7
+ // `npm run dev`, then open /emblem.html.
8
+ window.__pfRuntime = mount(emblemPart, {
9
+ createWorker: (name) =>
10
+ new Worker(new URL("./emblem-worker.js", import.meta.url), { type: "module", name }),
11
+ onAnnotationSend: (payload) => {
12
+ window.__pfLastAnnotation = payload;
13
+ console.log("annotation payload", payload);
14
+ },
15
+ });
@@ -0,0 +1,3 @@
1
+ import part from "./parts/emblem.js";
2
+ import { runWorker } from "./framework/worker.js";
3
+ runWorker(part);
@@ -1,11 +1,12 @@
1
- // Shared source-resolution core for fonts.js and imports.js. Both resolve a
1
+ // Shared source-resolution core for fonts.js, imports.js, and vectors.js. All three resolve a
2
2
  // part's declared `{ name: source }` map before the synchronous build, where a
3
3
  // source is: an ArrayBuffer/typed-array view (bytes), a URL string, a `URL`
4
4
  // instance (fetched), or a thunk (possibly async) returning any of those —
5
5
  // including the `{ default: … }` shape a Vite dynamic `import('./x.ttf')`
6
- // yields. The two callers differ only in what they do with the resolved bytes
7
- // (fonts keep the raw ArrayBuffer; imports also stamp a digest + format), so
8
- // each owns its own cache Map and result shape; this module owns just the
6
+ // yields. The three callers differ only in what they do with the resolved bytes
7
+ // (fonts keep the raw ArrayBuffer; imports also stamp a digest + format; vectors
8
+ // parses the bytes as a partforge-vector document), so each owns its own cache
9
+ // Map and result shape; this module owns just the
9
10
  // source→bytes grammar and the identity-memoization rule (a source, e.g. a
10
11
  // thunk, is content-stable for a session — resolve it once). DOM-free and
11
12
  // node:-free so it stays safe in the geometry worker's import closure.
@@ -0,0 +1,146 @@
1
+ // Circular-arc recovery: runs of cubic segments that lie on a common circle
2
+ // become symbolic {to, via} arcs.
3
+ //
4
+ // paper.js has no arc primitive, so importSVG returns every curve as a cubic —
5
+ // a <circle> arrives as four of them. Without this pass the OCCT backend would
6
+ // build a spline where the artwork had a circle. Recovering at the CONTOUR level
7
+ // rather than special-casing <circle> means arcs from `A` commands, rounded-rect
8
+ // corners, and transformed circles all come back through one mechanism.
9
+ //
10
+ // The fit is exact, not approximate. Paper's kappa construction pins each
11
+ // cubic's ENDPOINTS to the true circle and only the interior deviates, so a
12
+ // three-point fit through endpoints recovers the original centre and radius to
13
+ // float precision. The tolerances are therefore an acceptance test on the
14
+ // interiors, not the accuracy of the result — see runFits for why there are two
15
+ // of them and why a radius-relative one alone is not enough.
16
+ //
17
+ // Pure leaf: DOM-free, node-free.
18
+ import { arcCenterAndSweep } from "./paper-bridge.js";
19
+ import { cubicAt } from "./contour-ops.js";
20
+
21
+ const ARC_TOL = 1e-3; // relative to the FITTED radius
22
+ const CHORD_TOL = 2e-3; // relative to each cubic's OWN chord — see runFits
23
+ const PROBE_TS = [0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875];
24
+ const MAX_SWEEP = Math.PI; // split arcs at 180° so the 3-point form stays unambiguous
25
+
26
+ const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
27
+
28
+ // Does every probed interior point of every cubic in `run` lie on the circle?
29
+ //
30
+ // TWO bounds, and the tighter one wins. `ARC_TOL * r` alone is not a usable
31
+ // acceptance test, because r is the FITTED radius: the flatter a curve is, the
32
+ // bigger the circle it fits, so the band grows without limit exactly where the
33
+ // author's own feature is smallest. A gentle asymmetric cubic — the single most
34
+ // common thing in real logo artwork — fitted a circle of r = 913 and was accepted
35
+ // with a maximum deviation of 0.70 against its own sagitta of 1.43. Half the
36
+ // curve's depth, silently, and the stored file then claimed `"kind": "arc"`, so
37
+ // nothing downstream could recover the intent.
38
+ //
39
+ // The second bound is relative to the cubic's own CHORD — the straight distance
40
+ // between its endpoints. Chord is the right base rather than sagitta because it
41
+ // stays finite and meaningful as the curve flattens (sagitta goes to zero, which
42
+ // would reject genuine shallow arcs) and never degenerates on a closed run (the
43
+ // whole run's chord does: a full circle's endpoints coincide). It is a scale the
44
+ // artwork actually has, and it does not move when the fit does.
45
+ //
46
+ // CHORD_TOL is set with real headroom over what genuine circles need. Paper's
47
+ // kappa construction pins each cubic's endpoints to the true circle and errs only
48
+ // in the interior, by about 2.7e-4 * r for a 90° cubic and far less for shallower
49
+ // ones. Measured against this module's own fit, over sweeps of 1°-90° and radii
50
+ // from 0.5 to 1e5, the worst genuine deviation is 1.83e-4 of chord — so 2e-3
51
+ // leaves an 11x margin. The two shallow non-circular cubics that used to be
52
+ // accepted deviate by 3.1e-3 and 6.8e-3 of chord, comfortably outside it.
53
+ function runFits(run, from, center, r) {
54
+ let p = from;
55
+ for (const s of run) {
56
+ const tol = Math.min(ARC_TOL * r, CHORD_TOL * dist(p, s.to));
57
+ for (const t of PROBE_TS) {
58
+ const q = cubicAt(p, s.c1, s.c2, s.to, t);
59
+ if (Math.abs(dist(q, center) - r) > tol) return false;
60
+ }
61
+ p = s.to;
62
+ }
63
+ return true;
64
+ }
65
+
66
+ // Fit through the run's first, middle and last ENDPOINT — all exact on the
67
+ // source circle. A two-cubic run has three endpoints and uses them directly.
68
+ // A one-cubic run has only two endpoints, so the three-point fit would
69
+ // degenerate (the "middle" point would just be the last point again); use the
70
+ // cubic's own midpoint instead. That point is NOT exact on the circle (it's
71
+ // off by paper's kappa error), so the resulting centre/radius are only
72
+ // approximate for a still-unextended single-cubic run — but they're accurate
73
+ // enough to pass runFits's tolerance, and any later join with a second
74
+ // segment re-fits through three real endpoints and recovers the exact circle.
75
+ function fitCircle(run, from) {
76
+ const pts = [from, ...run.map((s) => s.to)];
77
+ const mid = pts.length >= 3
78
+ ? pts[Math.floor(pts.length / 2)]
79
+ : cubicAt(from, run[0].c1, run[0].c2, run[0].to, 0.5);
80
+ const c = arcCenterAndSweep(pts[0], mid, pts.at(-1));
81
+ if (!c || !Number.isFinite(c.r) || c.r <= 0) return null;
82
+ return c;
83
+ }
84
+
85
+ // One arc from `from` to `to` about `center`, split so no piece exceeds 180°.
86
+ // `via` is placed at each piece's angular midpoint, which is what makes the
87
+ // three-point form recoverable.
88
+ function arcsBetween(from, to, center, r, sweepSign) {
89
+ const ang = (p) => Math.atan2(p[1] - center[1], p[0] - center[0]);
90
+ const a0 = ang(from);
91
+ let dA = ang(to) - a0;
92
+ const twoPi = 2 * Math.PI;
93
+ while (dA <= 0) dA += twoPi;
94
+ while (dA > twoPi) dA -= twoPi;
95
+ if (sweepSign < 0) dA -= twoPi;
96
+ const pieces = Math.max(1, Math.ceil(Math.abs(dA) / MAX_SWEEP - 1e-9));
97
+ const out = [];
98
+ for (let i = 0; i < pieces; i++) {
99
+ const s0 = a0 + dA * (i / pieces), s1 = a0 + dA * ((i + 1) / pieces);
100
+ const m = (s0 + s1) / 2;
101
+ const P = (t) => [center[0] + r * Math.cos(t), center[1] + r * Math.sin(t)];
102
+ out.push({ to: P(s1), via: P(m) });
103
+ }
104
+ out.at(-1).to = [to[0], to[1]]; // pin the exact endpoint
105
+ return out;
106
+ }
107
+
108
+ // The direction the run actually travels, from the first cubic's own geometry.
109
+ function sweepSignOf(run, from, center) {
110
+ const a = [from[0] - center[0], from[1] - center[1]];
111
+ const q = cubicAt(from, run[0].c1, run[0].c2, run[0].to, 0.5);
112
+ const b = [q[0] - center[0], q[1] - center[1]];
113
+ return a[0] * b[1] - a[1] * b[0] >= 0 ? 1 : -1;
114
+ }
115
+
116
+ export function recoverArcs(contour) {
117
+ const segs = contour.segments;
118
+ const out = [];
119
+ let from = contour.start;
120
+ let i = 0;
121
+
122
+ while (i < segs.length) {
123
+ if (!segs[i].c1) { out.push(segs[i]); from = segs[i].to; i++; continue; }
124
+
125
+ // Greedy: extend the cubic run while it still fits one circle.
126
+ const runFrom = from;
127
+ let best = null, bestEnd = i;
128
+ let j = i;
129
+ while (j < segs.length && segs[j].c1) {
130
+ const run = segs.slice(i, j + 1);
131
+ const c = fitCircle(run, runFrom);
132
+ if (c && runFits(run, runFrom, c.center, c.r)) { best = c; bestEnd = j; }
133
+ j++;
134
+ }
135
+
136
+ if (!best) { out.push(segs[i]); from = segs[i].to; i++; continue; }
137
+
138
+ const run = segs.slice(i, bestEnd + 1);
139
+ const end = run.at(-1).to;
140
+ out.push(...arcsBetween(runFrom, end, best.center, best.r, sweepSignOf(run, runFrom, best.center)));
141
+ from = end;
142
+ i = bestEnd + 1;
143
+ }
144
+
145
+ return { start: [...contour.start], segments: out };
146
+ }
@@ -126,6 +126,11 @@ function joinSegs(corner, aEnd, bStart, inTan, outTan, delta, corners) {
126
126
  return [{ via: add(corner, scl(norm(m), Math.abs(delta))), to: bStart }];
127
127
  }
128
128
 
129
+ // Exposed for stroke-outline.js, which walks OPEN chains and so cannot use
130
+ // _offsetContour's ring loop, but needs exactly this join vocabulary
131
+ // (round/chamfer/sharp + miter limit) at its interior vertices.
132
+ export const _joinSegs = joinSegs;
133
+
129
134
  // Offset one explicitly-closed ring. Returns { contour, dirty }.
130
135
  export function _offsetContour(contour, delta, corners) {
131
136
  const pts = [contour.start, ...contour.segments.map((s) => s.to)];
@@ -2,11 +2,49 @@
2
2
  // simple, correctly-nested {outer,holes} curve regions under the requested font fill
3
3
  // rule. Beziers are split where needed but never flattened.
4
4
  //
5
- // The required recipe is:
6
- // 1. resolveCrossings() each contour individually;
7
- // 2. CompoundPath of all the simple sub-paths;
8
- // 3. set the font's nonzero/evenodd rule;
9
- // 4. unite(self) to normalize overlaps and crossings into simple paths.
5
+ // TWO ROUTES, because paper.js cannot evaluate either fill rule directly on a
6
+ // self-overlapping CompoundPath.
7
+ //
8
+ // The original recipe was: build a CompoundPath of every subpath, set its
9
+ // fillRule, and unite it with itself. paper documents that trick and it resolves
10
+ // NESTING correctly — a counter drawn against its outline becomes a hole, which
11
+ // is what every glyph relies on. But it does not merge two subpaths that wind
12
+ // the SAME way and overlap: it returns the even-odd answer regardless of the
13
+ // fillRule set. Measured on two 10x10 squares overlapping in a 5x10 band, as two
14
+ // subpaths of one <path>: area 100 (the band cancelled) where nonzero is 150.
15
+ // Measured identical for both orientations, and for every compound-level variant
16
+ // tried — unite against a clone, unite against an empty path, intersect with a
17
+ // covering rectangle, resolveCrossings on the compound, reorient after it.
18
+ //
19
+ // So:
20
+ //
21
+ // evenodd — XOR every subpath together. That IS the even-odd rule: a point is
22
+ // inside when an odd number of subpaths contain it, whatever their
23
+ // direction. Exact, and it replaces a path that used to THROW (see below).
24
+ //
25
+ // nonzero, all subpaths wound alike — their union, folded pairwise. With no
26
+ // subpath wound against the others, no winding can cancel, so every point
27
+ // any of them covers has |winding| >= 1. Union is therefore exactly nonzero,
28
+ // and this is the case the compound recipe got wrong.
29
+ //
30
+ // nonzero, mixed winding — the original compound recipe. Counters exist, so
31
+ // nesting has to be resolved rather than unioned away, and this is exact for
32
+ // the whole bundled charset (verified against Manifold's NonZero fill,
33
+ // glyph by glyph, in test/curve-fill.test.js).
34
+ //
35
+ // The mixed-winding route keeps one known divergence from true winding-number
36
+ // nonzero, unchanged from before this split and documented under
37
+ // docs/ERROR-PATTERNS.md#svg-overlapping-subpaths: where a subpath wound against
38
+ // the others covers area that two or more same-wound subpaths already cover,
39
+ // true nonzero keeps it (2 - 1 = 1) and this drops it. Fixing that needs a real
40
+ // planar arrangement, not a fold of pairwise booleans.
41
+ //
42
+ // Both routes reorient before grouping: paper's booleans can hand back disjoint
43
+ // pieces with OPPOSITE orientations (measured: an XOR returning +50 and -50 for
44
+ // two disjoint rectangles), and groupPaperPaths reads orientation to tell an
45
+ // outer from a hole — so ungrouped it called the second piece a hole with
46
+ // nothing to contain it and threw "resolved hole has no containing outer". That
47
+ // was a crash on ordinary even-odd artwork, not merely a wrong area.
10
48
  import { paperScope, toPaperPath, groupPaperPaths } from "./paper-bridge.js";
11
49
 
12
50
  export function resolveCurveFill(contours, { fillRule = "nonzero" } = {}) {
@@ -22,8 +60,20 @@ export function resolveCurveFill(contours, { fillRule = "nonzero" } = {}) {
22
60
  for (const k of kids) if (k.segments && k.segments.length >= 2) simple.push(k.clone({ insert: false }));
23
61
  }
24
62
  if (simple.length === 0) return [];
25
- const compound = new scope.CompoundPath({ children: simple, fillRule });
26
- const united = compound.unite(compound, { insert: false });
63
+
64
+ const fold = (xs, op) => (xs.length ? xs.reduce((a, b) => a[op](b, { insert: false })) : null);
65
+ let united;
66
+ if (fillRule === "evenodd") {
67
+ united = fold(simple, "exclude");
68
+ } else if (simple.every((p) => p.clockwise === simple[0].clockwise)) {
69
+ united = fold(simple, "unite");
70
+ } else {
71
+ const compound = new scope.CompoundPath({ children: simple, fillRule });
72
+ united = compound.unite(compound, { insert: false });
73
+ }
74
+ if (!united) return [];
75
+ united = united.reorient(fillRule === "nonzero", true);
76
+
27
77
  const paths = (united.className === "CompoundPath" ? united.children : [united])
28
78
  .filter((p) => p.segments && p.segments.length >= 2 && Math.abs(p.area) > 1e-9);
29
79
  return paths.length ? groupPaperPaths(paths) : [];
@@ -20,6 +20,7 @@ const opentype = normalizeOpentype(opentypeNamespace);
20
20
  import { KernelCapabilityError } from "./errors.js";
21
21
  import { isPlainOptions, KERNEL_OP_SPECS } from "./op-options.js";
22
22
  import { textGlyphs } from "./text2d.js";
23
+ import { placeRegions } from "./vector2d.js";
23
24
  import { beveledExtrude } from "./rim-bevel.js";
24
25
  import { DEFAULT_FONT_BYTES } from "./fonts/default-font.js";
25
26
  import { convexHull, hullPoints } from "./hull.js";
@@ -161,6 +162,51 @@ export function finishKernel(k) {
161
162
  return regions.map((r) => k.shape2d(r)).reduce((a, b) => a.union(b));
162
163
  };
163
164
 
165
+ // 2-D vector art as a Shape2D. Backend-agnostic for the same reason text2d is:
166
+ // it lowers to k.shape2d + union, so both backends get identical curve regions.
167
+ // Regions come from k._vectors, preloaded by name from the part's declared
168
+ // vector documents — this op does no SVG parsing at all, by design.
169
+ // k._vectors holds documents ({ units, shapes: Map<name, { role, regions }> }).
170
+ // With no `shape` option, every "add" shape is unioned and every "subtract"
171
+ // shape is cut from that union (role states the file's own composition; union
172
+ // is commutative so key order is moot within each group). `shape` selects one
173
+ // shape's own geometry by name, whatever its role, so the caller can compose
174
+ // shapes with ordinary booleans in the drawing's own frame.
175
+ k._vectors ??= new Map();
176
+ k.vector2d = (name, opts = {}) => {
177
+ if (typeof name !== "string" || !name)
178
+ throw new Error("vector2d: first argument must be the name of an entry in the part's `vectors` field");
179
+ const doc = k._vectors.get(name);
180
+ if (!doc) throw new Error(`vector2d: unknown vector "${name}" — declare it in the part's \`vectors\` field`);
181
+ const lift = (regions, measureAgainst = regions) =>
182
+ placeRegions(regions, doc.units, opts, { measureAgainst, name }).map((r) => k.shape2d(r)).reduce((a, b) => a.union(b));
183
+ if (opts.shape != null) {
184
+ const entry = doc.shapes.get(opts.shape);
185
+ if (!entry) {
186
+ throw new Error(`vector2d: "${name}" has no shape ${JSON.stringify(opts.shape)} — it declares: ${[...doc.shapes.keys()].join(", ")}`);
187
+ }
188
+ // Naming a shape is a request for THAT geometry; role governs only the
189
+ // default composition below.
190
+ return lift(entry.regions);
191
+ }
192
+ const adds = [...doc.shapes.values()].filter((e) => e.role === "add").flatMap((e) => e.regions);
193
+ const subs = [...doc.shapes.values()].filter((e) => e.role === "subtract").flatMap((e) => e.regions);
194
+ if (subs.length === 0) return lift(adds);
195
+ // ONE transform for the whole document — both groups are measured against
196
+ // the SAME regions, so a size or align option cannot scale the subtracts
197
+ // relative to the adds. (With no size and no align — the common millimetre
198
+ // case — the transform is the identity and this changes nothing.)
199
+ //
200
+ // Measured against the ADDS, not against every region. A subtract may
201
+ // legitimately overhang the adds — a rect that lops off a corner, an
202
+ // overhanging keyway — and that overhang is deleted before the caller sees
203
+ // anything, so sizing or aligning against it puts the visible edge where
204
+ // nobody asked for it. With a subtract reaching 5 mm past the adds' left,
205
+ // measuring against all of them put `{ align: "left" }` — no size option at
206
+ // all — 5 mm right of the origin.
207
+ return lift(adds).cut(lift(subs, adds));
208
+ };
209
+
164
210
  // Convex hull → Shape2D. Backend-agnostic: pure-JS monotone-chain hull of the inputs'
165
211
  // sampled points, lifted via k.shape2d. Faceted (curved inputs at a fixed LOD).
166
212
  k.hull = (inputs) => {
@@ -21,7 +21,7 @@ export const CONTRACT_VERSION = 4;
21
21
  // Ops every backend kernel must implement.
22
22
  export const KERNEL_OPS = [
23
23
  "cylinder", "boredCylinder", "sphere", "box", "prism", "extrude", "revolve",
24
- "loft", "sweep", "helixSweptTube", "screwSweep", "union", "shape2d", "text2d", "hull", "hullChain", "toSTEP",
24
+ "loft", "sweep", "helixSweptTube", "screwSweep", "union", "shape2d", "text2d", "vector2d", "hull", "hullChain", "toSTEP",
25
25
  "roundedCylinder", "torus", "roundedBox", "import",
26
26
  // Additive in 0.84 (no CONTRACT_VERSION bump — the import-op precedent).
27
27
  "loftSmooth",
@@ -31,7 +31,7 @@ const SOLID_ALLOWED = new Set([...SOLID_OPS, ...SOLID_OPTIONAL_OPS, ...SHAPE2D_O
31
31
 
32
32
  // Kernel ops whose result is a Shape2D, and Shape2D ops whose result is a Solid —
33
33
  // the only two places the probe's handle kind changes.
34
- const SHAPE2D_YIELDING_KERNEL_OPS = new Set(["shape2d", "text2d"]);
34
+ const SHAPE2D_YIELDING_KERNEL_OPS = new Set(["shape2d", "text2d", "vector2d"]);
35
35
  const SOLID_YIELDING_SHAPE2D_OPS = new Set(["extrude", "revolve"]);
36
36
 
37
37
  export const MAX_PROBE_OPS = 100000;
@@ -0,0 +1,119 @@
1
+ // Stroke → filled geometry. The half of paperjs-offset that contour-offset.js
2
+ // did not port: `offsetStroke`.
3
+ //
4
+ // Both cases reduce to "offset the path, offset its reverse, let nonzero winding
5
+ // assemble the result":
6
+ //
7
+ // CLOSED outer = offset(contour, +w/2), inner = offset(reverse(contour), +w/2).
8
+ // Two rings of opposite handedness -> an annulus. _offsetContour
9
+ // already does closed rings correctly, so this adds no geometry code.
10
+ // OPEN the same two offsets as open CHAINS, joined end to end by caps into
11
+ // one closed ring.
12
+ //
13
+ // Pure leaf: DOM-free, node:-free.
14
+ import { _joinSegs, _offsetContour, _offsetSegment } from "./contour-offset.js";
15
+ import { SMOOTH_JOINT_DEG, segTangent } from "./contour-ops.js";
16
+ import { closeContourGap, reverseContour } from "./profile.js";
17
+ import { resolveCurveFill } from "./curve-fill.js";
18
+
19
+ const JOIN_EPS = 1e-6;
20
+ const sub = (a, b) => [a[0] - b[0], a[1] - b[1]];
21
+ const add = (a, b) => [a[0] + b[0], a[1] + b[1]];
22
+ const scl = (v, s) => [v[0] * s, v[1] * s];
23
+ const cross = (a, b) => a[0] * b[1] - a[1] * b[0];
24
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1];
25
+ const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
26
+ const rightOf = ([x, y]) => [y, -x]; // matches contour-offset.js:31 exactly
27
+
28
+ // SVG's linejoin vocabulary is contour-offset.js's `corners` vocabulary under
29
+ // different names.
30
+ const CORNERS = { miter: "sharp", round: "round", bevel: "chamfer" };
31
+
32
+ // Each segment's start point, with zero-length lines dropped (no direction, so
33
+ // no offset).
34
+ function chainParts(contour) {
35
+ const segs = [], froms = [];
36
+ let p = contour.start;
37
+ for (const s of contour.segments) {
38
+ if (s.c1 || s.via || dist(p, s.to) > 1e-9) { segs.push(s); froms.push(p); }
39
+ p = s.to;
40
+ }
41
+ return { segs, froms, end: p };
42
+ }
43
+
44
+ // Offset an OPEN chain, joining at interior vertices only. Mirrors
45
+ // _offsetContour's join decision (gap side gets a join, overlap side gets a
46
+ // bevel the winding rule then cancels) minus the wrap-around and the whole-ring
47
+ // collapse predicate, neither of which means anything for a chain.
48
+ function offsetOpenChain(contour, delta, corners) {
49
+ const { segs, froms } = chainParts(contour);
50
+ if (segs.length === 0) return null;
51
+ const pieces = segs.map((s, i) => _offsetSegment(froms[i], s, delta));
52
+ const out = [];
53
+ for (let i = 0; i < pieces.length; i++) {
54
+ if (i > 0) {
55
+ const aEnd = pieces[i - 1].segments.at(-1).to, bStart = pieces[i].start;
56
+ const inTan = segTangent(froms[i - 1], segs[i - 1], false);
57
+ const outTan = segTangent(froms[i], segs[i], true);
58
+ const turn = cross(inTan, outTan);
59
+ const turnDeg = (Math.atan2(Math.abs(turn), Math.max(-1, Math.min(1, dot(inTan, outTan)))) * 180) / Math.PI;
60
+ if (dist(aEnd, bStart) > JOIN_EPS && turnDeg >= SMOOTH_JOINT_DEG) {
61
+ // turn === 0 with a large turnDeg is an exact 180 degree reversal — the
62
+ // same ambiguity _offsetContour calls out; treat it as gap side so a
63
+ // round join is honored rather than flat-capped.
64
+ if (turn * delta > 0 || turn === 0) out.push(..._joinSegs(froms[i], aEnd, bStart, inTan, outTan, delta, corners));
65
+ else out.push({ to: [bStart[0], bStart[1]] });
66
+ }
67
+ }
68
+ out.push(...pieces[i].segments);
69
+ }
70
+ return { start: pieces[0].start, segments: out };
71
+ }
72
+
73
+ // Bridge to `to` around the path endpoint `tip`, where `tangent` points OUT of
74
+ // the path at that end and `hw` is the half stroke width. The current position
75
+ // on entry is tip + hw*rightOf(tangent).
76
+ function capSegments(tip, tangent, hw, linecap, to) {
77
+ if (linecap === "round") return [{ via: add(tip, scl(tangent, hw)), to }];
78
+ if (linecap === "square") {
79
+ const ext = scl(tangent, hw), n = scl(rightOf(tangent), hw);
80
+ return [{ to: add(add(tip, n), ext) }, { to: add(sub(tip, n), ext) }, { to }];
81
+ }
82
+ return [{ to }]; // butt
83
+ }
84
+
85
+ export function outlineStroke(contour, closed, style) {
86
+ const hw = style.strokeWidth / 2;
87
+ if (!(hw > 0)) throw new Error("svg: cannot outline a stroke of zero width");
88
+ const corners = CORNERS[style.linejoin] ?? "sharp";
89
+
90
+ if (closed) {
91
+ const ring = closeContourGap(contour);
92
+ const a = _offsetContour(ring, hw, corners).contour;
93
+ const b = _offsetContour(closeContourGap(reverseContour(ring)), hw, corners).contour;
94
+ const rings = [a, b].filter(Boolean);
95
+ if (rings.length < 2) throw new Error("svg: stroke outline collapsed — stroke-width is too large for this shape");
96
+ return resolveCurveFill(rings, { fillRule: "nonzero" });
97
+ }
98
+
99
+ const fwd = offsetOpenChain(contour, hw, corners);
100
+ const rev = offsetOpenChain(reverseContour(contour), hw, corners);
101
+ if (!fwd || !rev) throw new Error("svg: stroke path has no length to outline");
102
+
103
+ const { segs, froms, end } = chainParts(contour);
104
+ const endTan = segTangent(froms.at(-1), segs.at(-1), false);
105
+ const startTanIn = segTangent(contour.start, segs[0], true);
106
+ const startTanOut = [-startTanIn[0], -startTanIn[1]];
107
+
108
+ const segments = [
109
+ ...fwd.segments,
110
+ ...capSegments(end, endTan, hw, style.linecap, rev.start),
111
+ ...rev.segments,
112
+ ...capSegments(contour.start, startTanOut, hw, style.linecap, fwd.start),
113
+ ];
114
+
115
+ // A stroke path that crosses itself makes this ring self-intersecting.
116
+ // resolveCurveFill under nonzero is exactly the normalizer for that — the same
117
+ // one the fill path uses, not a second mechanism.
118
+ return resolveCurveFill([{ start: fwd.start, segments }], { fillRule: "nonzero" });
119
+ }