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.
- package/bin/cli.js +6 -3
- package/docs/AUTHORING-PARTS.md +184 -1
- package/docs/ERROR-PATTERNS.md +97 -0
- package/docs/KERNEL-CONTRACT.md +1 -0
- package/docs/VECTOR-FORMAT.md +737 -0
- package/package.json +9 -1
- package/src/app-emblem.js +15 -0
- package/src/emblem-worker.js +3 -0
- package/src/framework/asset-resolve.js +5 -4
- package/src/framework/geometry/arc-fit.js +146 -0
- package/src/framework/geometry/contour-offset.js +5 -0
- package/src/framework/geometry/curve-fill.js +57 -7
- package/src/framework/geometry/kernel-front.js +46 -0
- package/src/framework/geometry/kernel.js +1 -1
- package/src/framework/geometry/probe.js +1 -1
- package/src/framework/geometry/stroke-outline.js +119 -0
- package/src/framework/geometry/vector-format.js +334 -0
- package/src/framework/geometry/vector2d.js +96 -0
- package/src/framework/ingest/svg-ingest.js +212 -0
- package/src/framework/jobs.js +11 -0
- package/src/framework/lint/index.js +28 -3
- package/src/framework/lint/rules-vector.js +112 -0
- package/src/framework/mount.js +30 -2
- package/src/framework/pick-flash.js +31 -0
- package/src/framework/selection/pick.js +16 -1
- package/src/framework/vectors.js +170 -0
- package/src/framework/viewer.js +106 -7
- package/src/framework/worker.js +46 -1
- package/src/ingest.js +8 -0
- package/src/parts/assets/emblem.svg +10 -0
- package/src/parts/assets/emblem.vector.json +110 -0
- package/src/parts/assets/plate.vector.json +27 -0
- package/src/parts/emblem.js +102 -0
- package/src/testing/manifold.js +3 -1
- package/src/testing/occt.js +3 -1
- package/types/index.d.ts +22 -0
- package/types/ingest.d.ts +118 -0
- package/types/kernel.d.ts +28 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "partforge",
|
|
3
|
-
"version": "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
|
+
});
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
// Shared source-resolution core for fonts.js and
|
|
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
|
|
7
|
-
// (fonts keep the raw ArrayBuffer; imports also stamp a digest + format
|
|
8
|
-
//
|
|
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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
|
|
26
|
-
const
|
|
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
|
+
}
|