partforge 0.66.1 → 0.67.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.
- package/docs/AUTHORING-PARTS.md +4 -2
- package/docs/KERNEL-CONTRACT.md +9 -4
- package/package.json +1 -1
- package/src/app-scott-label.js +13 -0
- package/src/framework/geometry/creased-normals.js +49 -13
- package/src/framework/geometry/manifold-backend.js +64 -19
- package/src/framework/geometry/mesh-fillet.js +374 -67
- package/src/framework/geometry/shading-policy.js +9 -0
- package/src/framework/geometry/sweep.js +12 -2
- package/src/parts/scott-label.js +252 -0
- package/src/scott-label-worker.js +3 -0
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -2195,8 +2195,10 @@ STEP export always builds on OCCT, so a filleted part gets exact B-rep blends in
|
|
|
2195
2195
|
STEP even though it previews (and STL/3MF-exports) from the mesh blend — the two agree
|
|
2196
2196
|
to within tessellation on the supported edge classes, with one visible exception:
|
|
2197
2197
|
orthogonal box-style corners where three filleted edges meet get a true sphere-octant
|
|
2198
|
-
cap,
|
|
2199
|
-
blend
|
|
2198
|
+
cap, and a planar rim's own corners blend by turn direction (salient corners steer
|
|
2199
|
+
the band around a small arc; reflex/inside corners round into an arc of the blend
|
|
2200
|
+
radius about the vertex — the rolling-ball pivot), but other blend junctions are
|
|
2201
|
+
mitred on the mesh where OCCT builds a vertex blend.
|
|
2200
2202
|
|
|
2201
2203
|
If the mesh backend hits an edge class it can't blend, it signals `NEEDS_OCCT` and the
|
|
2202
2204
|
framework reroutes **just that sub-part** to OCCT for those exact parameters —
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -60,10 +60,15 @@ Its coverage and tolerance band are part of the contract:
|
|
|
60
60
|
guards, all ≤ ~1e-3 mm). Volumes agree with the B-rep result to ~0.1% on covered
|
|
61
61
|
edge classes. **Corners:** where exactly three selected straight convex chains meet
|
|
62
62
|
at a mutually orthogonal vertex (a box corner), the fillet caps it with the
|
|
63
|
-
rolling-ball sphere octant
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
rolling-ball sphere octant. A two-chain corner in a common face plane (a rim
|
|
64
|
+
corner) blends by turn direction: SALIENT corners steer the band around a small
|
|
65
|
+
arc (the silhouette rounds by about the blend radius inside the band), and REFLEX
|
|
66
|
+
corners get the rolling-ball pivot — the face's blend boundary rounds into an arc
|
|
67
|
+
of the blend radius about the vertex, exactly what a ball rolling into an inside
|
|
68
|
+
corner leaves. Every other junction of blended chains is a **mitre** (the blend
|
|
69
|
+
surfaces intersect), where the B-rep class builds a kernel-specific vertex blend
|
|
70
|
+
instead — parity at such corners is approximate, like `roundedBox`'s documented
|
|
71
|
+
corner carve-out.
|
|
67
72
|
- **New surfaces:** like the B-rep op, a mesh blend produces new surfaces — feature
|
|
68
73
|
labels upstream of the call do not survive through it (attribution uses the
|
|
69
74
|
fallback path), and the result shades with the default SMOOTH policy.
|
package/package.json
CHANGED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Local test harness for the "Scott Layered Label" cloud part (feedback report
|
|
2
|
+
// 41850ea2, part 0d47960f) — the reflex-rim-pivot fix's real-world fixture. The
|
|
3
|
+
// part source is a verbatim copy from the cloud part's part.js into
|
|
4
|
+
// src/parts/scott-label.js. `npm run dev`, then open /scott-label.html.
|
|
5
|
+
import "@fontsource-variable/geist";
|
|
6
|
+
import "@fontsource-variable/geist-mono";
|
|
7
|
+
import part from "./parts/scott-label.js";
|
|
8
|
+
import { mount } from "./framework/index.js";
|
|
9
|
+
|
|
10
|
+
mount(part, {
|
|
11
|
+
createWorker: (name) =>
|
|
12
|
+
new Worker(new URL("./scott-label-worker.js", import.meta.url), { type: "module", name }),
|
|
13
|
+
});
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// near-tangent), and a surface's own sharp edges stay crisp too. Each original
|
|
9
9
|
// surface may carry a shading policy (shading-policy.js); surfaces without one
|
|
10
10
|
// use SMOOTH, which reproduces the pre-policy behavior exactly.
|
|
11
|
-
import { SMOOTH, COPLANAR_ANGLE, MIN_EDGE, cosDeg } from "./shading-policy.js";
|
|
11
|
+
import { SMOOTH, COPLANAR_ANGLE, MIN_EDGE, MIN_FACE, cosDeg } from "./shading-policy.js";
|
|
12
12
|
|
|
13
13
|
const COPLANAR_COS = cosDeg(COPLANAR_ANGLE);
|
|
14
14
|
const MIN_EDGE2 = MIN_EDGE * MIN_EDGE;
|
|
@@ -48,17 +48,43 @@ export function creasedNormals(g, { policies = null, featureLabels = null } = {}
|
|
|
48
48
|
const vx = vp[c] - vp[a], vy = vp[c + 1] - vp[a + 1], vz = vp[c + 2] - vp[a + 2];
|
|
49
49
|
const wx = vp[c] - vp[b], wy = vp[c + 1] - vp[b + 1], wz = vp[c + 2] - vp[b + 2];
|
|
50
50
|
const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
|
|
51
|
-
const
|
|
51
|
+
const L0 = Math.hypot(nx, ny, nz), L = L0 || 1; // the || 1 is for the normal divide ONLY
|
|
52
52
|
fn[t * 3] = nx / L; fn[t * 3 + 1] = ny / L; fn[t * 3 + 2] = nz / L;
|
|
53
53
|
const longest = Math.max(ux * ux + uy * uy + uz * uz, vx * vx + vy * vy + vz * vz, wx * wx + wy * wy + wz * wz);
|
|
54
|
-
|
|
54
|
+
// |cross| / maxEdge = min height — from the RAW cross magnitude, never the
|
|
55
|
+
// guarded L: a zero-area triangle (two float32-coincident vertices — the
|
|
56
|
+
// render-precision collapse of a sub-micron boolean seam sliver) must report
|
|
57
|
+
// thin 0 so the feature-edge gate drops it. With L it reported 1/maxEdge and
|
|
58
|
+
// sailed past the gate, and its garbage (0,0,0) normal reads as a 90° crease
|
|
59
|
+
// against every neighbor — a full-weight line down an otherwise smooth wall
|
|
60
|
+
// at whatever seam produced it.
|
|
61
|
+
thin[t] = longest > 0 ? L0 / Math.sqrt(longest) : 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Second-stage weld for SHADING adjacency only: a boolean seam whose two
|
|
65
|
+
// sides land sub-micron apart keeps two distinct vertex columns that the
|
|
66
|
+
// merge map does not join, yet at render (float32) precision they are the
|
|
67
|
+
// same point — without this weld the facets on either side average their
|
|
68
|
+
// normals separately and the seam shades as a lighting crease. The line
|
|
69
|
+
// pass below deliberately keeps `remap` (Manifold's own topology): welding
|
|
70
|
+
// its edge keys would make pairing at collapsed seams order-dependent and
|
|
71
|
+
// could pair a boundary ring's edges away.
|
|
72
|
+
const weld = Uint32Array.from(remap);
|
|
73
|
+
{
|
|
74
|
+
const byPos = new Map();
|
|
75
|
+
for (let i = 0; i < nVert; i++) {
|
|
76
|
+
const o = i * np;
|
|
77
|
+
const key = `${vp[o]}|${vp[o + 1]}|${vp[o + 2]}`;
|
|
78
|
+
const first = byPos.get(key);
|
|
79
|
+
if (first === undefined) byPos.set(key, weld[i]); else weld[i] = first;
|
|
80
|
+
}
|
|
55
81
|
}
|
|
56
82
|
|
|
57
83
|
// canonical vertex → incident triangles
|
|
58
84
|
const incident = new Map();
|
|
59
85
|
for (let t = 0; t < nTri; t++)
|
|
60
86
|
for (let k = 0; k < 3; k++) {
|
|
61
|
-
const cv =
|
|
87
|
+
const cv = weld[tris[t * 3 + k]];
|
|
62
88
|
const arr = incident.get(cv);
|
|
63
89
|
if (arr) arr.push(t); else incident.set(cv, [t]);
|
|
64
90
|
}
|
|
@@ -71,13 +97,19 @@ export function creasedNormals(g, { policies = null, featureLabels = null } = {}
|
|
|
71
97
|
for (let k = 0; k < 3; k++) {
|
|
72
98
|
const v = tris[t * 3 + k];
|
|
73
99
|
let nx = 0, ny = 0, nz = 0;
|
|
74
|
-
for (const t2 of incident.get(
|
|
75
|
-
// different cut surface → hard, EXCEPT
|
|
76
|
-
//
|
|
77
|
-
// and hard normals at their handovers
|
|
78
|
-
// that used to shade as one
|
|
100
|
+
for (const t2 of incident.get(weld[v])) {
|
|
101
|
+
// different cut surface → hard, EXCEPT when a blend surface (boundaryLines)
|
|
102
|
+
// is involved on either side. Blend↔blend: one band is many tool surfaces
|
|
103
|
+
// continuing each other tangentially, and hard normals at their handovers
|
|
104
|
+
// would put lighting seams along a band that used to shade as one
|
|
105
|
+
// re-originaled surface. Blend↔base: the band's start/end seams are TANGENT
|
|
106
|
+
// by construction (that is why the line pass needs boundaryLines to draw
|
|
107
|
+
// them at all), so shading them hard painted a permanent lighting ridge
|
|
108
|
+
// along every fillet boundary ring. Both cases still fall to the crease
|
|
109
|
+
// check below, so a genuinely sharp crossing (a chamfer's 45° shoulder, a
|
|
110
|
+
// band end-cap against a wall) stays hard.
|
|
79
111
|
if (triOID[t2] !== oid &&
|
|
80
|
-
!(polFor(triOID[t2]).boundaryLines
|
|
112
|
+
!(polFor(triOID[t2]).boundaryLines || polFor(oid).boundaryLines)) continue;
|
|
81
113
|
if (fn[t2 * 3] * fx + fn[t2 * 3 + 1] * fy + fn[t2 * 3 + 2] * fz < sharpCos) continue; // sharp same-surface edge → hard
|
|
82
114
|
nx += fn[t2 * 3]; ny += fn[t2 * 3 + 1]; nz += fn[t2 * 3 + 2];
|
|
83
115
|
}
|
|
@@ -108,8 +140,12 @@ export function creasedNormals(g, { policies = null, featureLabels = null } = {}
|
|
|
108
140
|
// independently tessellated tangent surfaces (e.g. a corner sphere meeting
|
|
109
141
|
// its edge-fillet cylinders) can leave micron-wide wall strips whose FACES
|
|
110
142
|
// are invisible but whose long boundary edges would otherwise draw at full
|
|
111
|
-
// line weight. A triangle thinner than
|
|
112
|
-
//
|
|
143
|
+
// line weight. A triangle thinner than MIN_FACE cannot carry a visible
|
|
144
|
+
// crease — the fan slivers a boolean face-split leaves near a tool
|
|
145
|
+
// crossing are 14-34µm wide with wildly tilted normals over sub-15µm of
|
|
146
|
+
// actual relief (see shading-policy.js) — so its edges are noise by
|
|
147
|
+
// definition. The gate is deliberately wider than the segment filter's
|
|
148
|
+
// MIN_EDGE below, which stays tight so short REAL segments survive.
|
|
113
149
|
const sameOID = triOID[prev] === triOID[t];
|
|
114
150
|
// Blend boundary: a cross-surface seam with a BLEND policy on EXACTLY one side
|
|
115
151
|
// is the start/end of a fillet band — draw it even when tangent (the band's
|
|
@@ -121,7 +157,7 @@ export function creasedNormals(g, { policies = null, featureLabels = null } = {}
|
|
|
121
157
|
// filter still drops the short ones).
|
|
122
158
|
const boundary = !sameOID &&
|
|
123
159
|
!!polFor(triOID[prev]).boundaryLines !== !!polFor(triOID[t]).boundaryLines;
|
|
124
|
-
if (!boundary && (thin[prev] <
|
|
160
|
+
if (!boundary && (thin[prev] < MIN_FACE || thin[t] < MIN_FACE)) continue;
|
|
125
161
|
const dot = fn[prev * 3] * fn[t * 3] + fn[prev * 3 + 1] * fn[t * 3 + 1] + fn[prev * 3 + 2] * fn[t * 3 + 2];
|
|
126
162
|
// A multi-hole cap triangulation can contain an opposite-wound bridge:
|
|
127
163
|
// its two normals disagree by 180 degrees even though both triangles lie
|
|
@@ -132,11 +132,20 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
132
132
|
// anything printable, so dropping it can never erase real geometry.
|
|
133
133
|
const DEBRIS_VOL = 1e-6; // mm³
|
|
134
134
|
const dropDebris = (m) => {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
135
|
+
// Iterated, not single-pass: compose() re-welds, and a sliver riding a kept
|
|
136
|
+
// component only as a vertex-weld can come apart as a FRESH femto-component
|
|
137
|
+
// in the composed result (measured on the melt fixture's grazing neck arcs:
|
|
138
|
+
// four needles dropped, two re-minted by the compose). Loop until a pass
|
|
139
|
+
// drops nothing; volumes shrink every round, so three passes is plenty.
|
|
140
|
+
for (let pass = 0; pass < 3; pass++) {
|
|
141
|
+
const parts = m.decompose();
|
|
142
|
+
if (parts.length <= 1) { for (const p of parts) T(p); return m; }
|
|
143
|
+
const kept = [];
|
|
144
|
+
for (const p of parts) { T(p); if (p.volume() >= DEBRIS_VOL) kept.push(p); }
|
|
145
|
+
if (kept.length === parts.length) return m;
|
|
146
|
+
m = T(Manifold.compose(kept));
|
|
147
|
+
}
|
|
148
|
+
return m;
|
|
140
149
|
};
|
|
141
150
|
// Blend surfaces KEEP their originalIDs, and every id the op introduced is
|
|
142
151
|
// registered with the BLEND policy — that is what lets creased-normals draw the
|
|
@@ -189,20 +198,36 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
189
198
|
// the erosion consumes the whole plate below 2r, and just above it the two
|
|
190
199
|
// rim bands graze each other — both belong to the reference morphology
|
|
191
200
|
if (!(height > 2 * r * 1.05)) return null;
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
201
|
+
// Join types are per-STEP, because a join only acts on the side an offset
|
|
202
|
+
// DIVERGES on. The two dilates diverge at SALIENT corners and use MITER,
|
|
203
|
+
// deliberately: the true morphology mints salient corners of radius
|
|
204
|
+
// exactly r, and a rim fillet of radius r over an r-radius salient corner
|
|
205
|
+
// pinches its top tangent contour to a point — the planar sweep refuses,
|
|
206
|
+
// structurally. Miter keeps them SHARP instead and the selector-free
|
|
207
|
+
// fillet below performs their rounding (cutters at r — the same solid the
|
|
208
|
+
// round join would have produced — plus the corner treatments). The erode
|
|
209
|
+
// diverges at REFLEX corners and uses ROUND: the ball morphology rounds a
|
|
210
|
+
// reflex corner into a radius-r concave arc (2r at the erode, halved by
|
|
211
|
+
// the re-dilate), and a concave arc offsets INWARD to 2r, so it cannot
|
|
212
|
+
// pinch the rim sweep — the rim rides it as an ordinary arc-in-planar
|
|
213
|
+
// chain. Keeping reflex corners sharp instead (all-Miter, the original
|
|
214
|
+
// design) handed the fillet a sharp vertical filler PLUS a sharp rim
|
|
215
|
+
// corner at once, and the filler — unioned after the cutters — re-covered
|
|
216
|
+
// the rim tools' corner cut, reintroducing the uncut-wedge point artifact
|
|
217
|
+
// the reflex pivot exists to remove. Melt and seal thresholds stay exact
|
|
218
|
+
// on straight stretches; salient-corner silhouettes differ from the ball
|
|
219
|
+
// morphology by the rim fillet's own corner tolerance (~0.25·r) — the
|
|
220
|
+
// documented corner trade — while reflex corners now match it exactly.
|
|
221
|
+
//
|
|
222
|
+
// Arc facets must stay well under the fillet's sharp-edge threshold
|
|
223
|
+
// (detectSharpEdges' 20° default) or the rounded wall would re-detect as a
|
|
224
|
+
// run of sharp vertical edges: floor the round join at 36 segments/360°
|
|
225
|
+
// (10° per facet) even when the sagitta rule alone would allow coarser.
|
|
226
|
+
const arcSegs = Math.max(roundAllSegs(2 * r, quality), 36);
|
|
202
227
|
let cur = null;
|
|
203
228
|
try {
|
|
204
229
|
for (const delta of [r, -2 * r, r]) {
|
|
205
|
-
const next = (cur ?? cs).offset(delta, "Miter", 2);
|
|
230
|
+
const next = (cur ?? cs).offset(delta, delta > 0 ? "Miter" : "Round", 2, arcSegs);
|
|
206
231
|
const cleaned = next.simplify(1e-6);
|
|
207
232
|
next.delete?.();
|
|
208
233
|
cur?.delete?.();
|
|
@@ -214,10 +239,30 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
214
239
|
const wrapped = wrap(base, h("roundAllPrismBase", mHash, r, quality));
|
|
215
240
|
// selector-free: every sharp edge of the mitered prism gets its radius here
|
|
216
241
|
const filleted = wrapped.fillet(r);
|
|
217
|
-
//
|
|
242
|
+
// Decouple from the fillet cache's pin: cached() will pin the object this
|
|
218
243
|
// returns under the roundAll hash, and one WASM object must never sit
|
|
219
|
-
// under two cache entries (double-dispose on eviction)
|
|
220
|
-
|
|
244
|
+
// under two cache entries (double-dispose on eviction). The decouple is
|
|
245
|
+
// blend-AWARE, the same re-stamp label() performs: a blanket asOriginal()
|
|
246
|
+
// folded blend and base into one fresh surface, erasing the distinction
|
|
247
|
+
// the band-boundary lines need — a roundAll'd prism rendered with no
|
|
248
|
+
// feature lines at all, unlike the identical geometry from fillet().
|
|
249
|
+
// Re-stamp as two reserved ids instead (base stays unregistered → SMOOTH,
|
|
250
|
+
// the extruded section has no policy of its own) so the result draws
|
|
251
|
+
// exactly the lines the fillet drew.
|
|
252
|
+
const g0 = filleted._m.getMesh();
|
|
253
|
+
try {
|
|
254
|
+
const isBlend = (oid) => !!oidPolicies.get(oid)?.boundaryLines;
|
|
255
|
+
if (![...new Set(g0.runOriginalID)].some(isBlend)) return T(filleted._m.asOriginal());
|
|
256
|
+
const baseId = Manifold.reserveIDs(2), blendId = baseId + 1;
|
|
257
|
+
g0.runOriginalID = Uint32Array.from(g0.runOriginalID, (o) => (isBlend(o) ? blendId : baseId));
|
|
258
|
+
oidPolicies.set(blendId, BLEND);
|
|
259
|
+
// The constructor re-welds, and that can pinch a latent sliver off a
|
|
260
|
+
// component as fresh femto-debris (the same rebirth dropDebris's own
|
|
261
|
+
// compose() loop guards against) — sweep the reconstruction too.
|
|
262
|
+
return dropDebris(T(new Manifold(g0)));
|
|
263
|
+
} finally {
|
|
264
|
+
g0.delete?.();
|
|
265
|
+
}
|
|
221
266
|
} finally {
|
|
222
267
|
cur?.delete?.();
|
|
223
268
|
}
|
|
@@ -16,16 +16,25 @@
|
|
|
16
16
|
// Anything else (helical edges, varying dihedral, branching curves) raises
|
|
17
17
|
// UnsupportedEdgeError so a caller can reroute the build to the B-rep backend.
|
|
18
18
|
//
|
|
19
|
-
// Corner treatment
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// is
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
19
|
+
// Corner treatment, by how sharp the corner is. A SHARP salient corner (turn past
|
|
20
|
+
// SMOOTH_MAX_DEG — the same bar that makes chainEdges call it a corner at all) keeps
|
|
21
|
+
// the honest MITRE: the two blends run to the vertex and cross in the classic
|
|
22
|
+
// intersection seam every B-rep fillet shows; the seam is a real crease, its feature
|
|
23
|
+
// line is correct, and the top face keeps its sharp corner (decided 2026-08-17 —
|
|
24
|
+
// there is provably no band that hugs both walls around a salient corner without
|
|
25
|
+
// creasing, and every lift-off construction strands a corner column that reads as an
|
|
26
|
+
// artifact). A GENTLE salient corner (CORNER_ROUND_MIN_TURN..SMOOTH_MAX_DEG) is
|
|
27
|
+
// steered instead — a small arc chain (radius ~1.05-1.25× the magnitude) replaces
|
|
28
|
+
// the mitre and a horn block shaves the corner column to band depth — because a
|
|
29
|
+
// shallow mitre's overlap wedge triangulates into junk lines while the steer's
|
|
30
|
+
// silhouette cost is sub-visible at these angles. A REFLEX corner in a common face
|
|
31
|
+
// plane gets the rolling-ball PIVOT (reflexPivotAt/reflexPivotTool): the ball swings
|
|
32
|
+
// about the corner's face-normal axis, touching the face and the vertical corner
|
|
33
|
+
// edge, and the face's blend boundary rounds into an arc of radius r about the
|
|
34
|
+
// vertex — without it the flush-ended neighbor tools leave a wedge of the original
|
|
35
|
+
// rim uncut and the face keeps a point AT the corner (the label-part "artifacts"
|
|
36
|
+
// bug). Three-or-more-chain vertices go to the spherical cornerPatches below (the
|
|
37
|
+
// orthogonal three-chain case).
|
|
29
38
|
//
|
|
30
39
|
// Known limits (documented, not bugs): radius feasibility is the caller's job (clamp
|
|
31
40
|
// like filleted-box.js does — an oversized radius self-intersects the cutters).
|
|
@@ -50,10 +59,10 @@ export class UnsupportedEdgeError extends Error {
|
|
|
50
59
|
// circles; spending it on a blend of radius r tessellates a 0.5 mm fillet to 0.2 µm
|
|
51
60
|
// sagitta at preview quality — and a text rim's hundred-tool boolean then carries ~4×
|
|
52
61
|
// the triangles it needs (measured 12 s / 4 GB on a lettering part before this cap).
|
|
53
|
-
// BLEND_SAG (
|
|
62
|
+
// BLEND_SAG (1 µm) is finer than preview quality's own ~4 µm sagitta at part scale;
|
|
54
63
|
// the 0.02·r term keeps micro-blends sane, and the floor of 12 keeps every facet
|
|
55
64
|
// angle (≤30°) under the viewer's 35° same-surface crease threshold.
|
|
56
|
-
const BLEND_SAG =
|
|
65
|
+
const BLEND_SAG = 1e-3; // mm — max chord sagitta of a blend cross-section
|
|
57
66
|
function blendSegs(segs, r) {
|
|
58
67
|
const s = Math.min(BLEND_SAG, 0.02 * r);
|
|
59
68
|
return Math.min(segs, Math.max(12, Math.ceil(Math.PI / Math.acos(1 - s / r))));
|
|
@@ -64,6 +73,7 @@ function blendSegs(segs, r) {
|
|
|
64
73
|
const cornerArcSegs = (segs, R, magnitude) => blendSegs(segs, R + magnitude);
|
|
65
74
|
|
|
66
75
|
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
76
|
+
const pivotKey = (p) => `${Math.round(p[0] * WELD)},${Math.round(p[1] * WELD)},${Math.round(p[2] * WELD)}`;
|
|
67
77
|
const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
|
68
78
|
const scl = (a, s) => [a[0] * s, a[1] * s, a[2] * s];
|
|
69
79
|
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
@@ -256,11 +266,36 @@ export function chainEdges(edges) {
|
|
|
256
266
|
// crossing) when sharp. Chains stitch only when they share an endpoint, the same face
|
|
257
267
|
// plane, and the same convexity — the same-plane test is what keeps a top rim from ever
|
|
258
268
|
// stitching to a bottom rim.
|
|
259
|
-
function stitchPlanarChains(chains) {
|
|
269
|
+
function stitchPlanarChains(chains, { absorbLines = false } = {}) {
|
|
260
270
|
const open = [], out = [];
|
|
261
271
|
for (const c of chains) (c.kind === "planar" && !c.closed ? open : out).push(c);
|
|
262
|
-
if (open.length < 2) return chains;
|
|
263
272
|
const key = (p) => `${Math.round(p[0] * WELD)},${Math.round(p[1] * WELD)},${Math.round(p[2] * WELD)}`;
|
|
273
|
+
// Absorb LINE chains that continue an open planar chain — apply()'s re-stitch
|
|
274
|
+
// only, post-selection, so a dir/line selector still sees the line form. A
|
|
275
|
+
// straight run flanking a planarized arc otherwise keeps its prism tool, and
|
|
276
|
+
// the tangent junction between the two tools is exactly the overlap-seam
|
|
277
|
+
// category stitching exists to remove (measured: an exact rounded-rect rim
|
|
278
|
+
// with 0.5 mm corner arcs under a 0.3 mm fillet drew ~6 lines per junction).
|
|
279
|
+
// Absorbed into the sweep, the straight is geometrically identical — a prism
|
|
280
|
+
// IS the one-segment case of the sweep — and the junction becomes an interior
|
|
281
|
+
// tangent vertex the sweep miters.
|
|
282
|
+
if (absorbLines) {
|
|
283
|
+
for (let i = out.length - 1; i >= 0; i--) {
|
|
284
|
+
const c = out[i];
|
|
285
|
+
if (c.kind !== "line") continue;
|
|
286
|
+
const ends = [key(c.a), key(c.b)];
|
|
287
|
+
const partner = open.find((p) => p.convex === c.convex &&
|
|
288
|
+
[p.points[0], p.points[p.points.length - 1]].some((q) => ends.includes(key(q))));
|
|
289
|
+
if (!partner) continue;
|
|
290
|
+
const face = [c.n1, c.n2].find((n) => dot(n, partner.faceN) > FLANK_COS);
|
|
291
|
+
if (!face) continue;
|
|
292
|
+
const wall = face === c.n1 ? c.n2 : c.n1;
|
|
293
|
+
out.splice(i, 1);
|
|
294
|
+
open.push({ kind: "planar", points: [[...c.a], [...c.b]], closed: false,
|
|
295
|
+
convex: c.convex, w: face, faceN: face, wallNs: [wall] });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (open.length < 2) return [...out, ...open];
|
|
264
299
|
const compatible = (a, b) => a.convex === b.convex && dot(a.faceN, b.faceN) > FLANK_COS &&
|
|
265
300
|
Math.abs(dot(a.points[0], a.faceN) - dot(b.points[0], a.faceN)) <= TOL;
|
|
266
301
|
const rev = (c) => ({ ...c, points: [...c.points].reverse(), wallNs: [...c.wallNs].reverse() });
|
|
@@ -735,9 +770,107 @@ function revolveTool(k, chain, magnitude, mode, segs, pSegs = segs) {
|
|
|
735
770
|
// watertight). Any residual sweep refusal (float-edge fold the pre-split missed) is
|
|
736
771
|
// converted to UnsupportedEdgeError so the caller reroutes to OCCT instead of failing
|
|
737
772
|
// the build. Returns an ARRAY of tools — one per stretch.
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
773
|
+
// Collapse corner features smaller than the blend into virtual sharp corners.
|
|
774
|
+
// A convex corner round with radius under the fold threshold (~0.37·magnitude at
|
|
775
|
+
// the 1.1× reach) cannot be swept — the band's top tangent contour pinches — and
|
|
776
|
+
// cannot be steered either (no setback room), so the fold guard breaks at EVERY
|
|
777
|
+
// facet joint and the band shatters into overshot micro-tools. But such a feature
|
|
778
|
+
// is geometrically a sharp corner blurred by less than the blend radius: replace
|
|
779
|
+
// each maximal run of two or more consecutive breaking joints whose connecting
|
|
780
|
+
// segments are shorter than the magnitude with the intersection of the flanking
|
|
781
|
+
// edge lines, and the ordinary corner machinery (mitre / gentle steer / reflex
|
|
782
|
+
// pivot) handles it downstream. The silhouette cost is bounded by the feature's
|
|
783
|
+
// own radius — sub-blend by construction. Runs that have no usable intersection
|
|
784
|
+
// (a ~180° cap, whose radius is stroke-scale and sweeps fine anyway) or whose
|
|
785
|
+
// intersection lands implausibly far are left untouched.
|
|
786
|
+
function collapseTightCorners(pts0, wallNs0, closed, magnitude) {
|
|
787
|
+
let pts = pts0, wallNs = wallNs0;
|
|
788
|
+
const m0 = pts.length, nSeg0 = closed ? m0 : m0 - 1;
|
|
789
|
+
if (nSeg0 < 3) return { pts, wallNs };
|
|
790
|
+
const reach = magnitude * 1.1, reachWall = 0.1 * magnitude;
|
|
791
|
+
const breaksAt = (pp, ww) => {
|
|
792
|
+
const m = pp.length, nSeg = closed ? m : m - 1;
|
|
793
|
+
const dir = [], sl = [];
|
|
794
|
+
for (let i = 0; i < nSeg; i++) {
|
|
795
|
+
const d = sub(pp[(i + 1) % m], pp[i]), l = len(d);
|
|
796
|
+
dir.push(scl(d, 1 / (l || 1))); sl.push(l);
|
|
797
|
+
}
|
|
798
|
+
const flags = new Array(m).fill(false);
|
|
799
|
+
for (let i = closed ? 0 : 1; i < (closed ? m : m - 1); i++) {
|
|
800
|
+
const iIn = (i - 1 + nSeg) % nSeg;
|
|
801
|
+
const c = clamp1(dot(dir[iIn], dir[i]));
|
|
802
|
+
const turn = Math.acos(c);
|
|
803
|
+
const bendIn = norm(sub(dir[i], dir[iIn]));
|
|
804
|
+
const reflexBend = dot(bendIn, ww[iIn]) + dot(bendIn, ww[i % nSeg]) > 0;
|
|
805
|
+
const r = reflexBend ? reachWall : reach;
|
|
806
|
+
flags[i] = c < -1 + 1e-6 || r * Math.tan(turn / 2) > 0.45 * Math.min(sl[iIn], sl[i]) ||
|
|
807
|
+
turn > (SMOOTH_MAX_DEG * Math.PI) / 180;
|
|
808
|
+
}
|
|
809
|
+
return { flags, dir, sl };
|
|
810
|
+
};
|
|
811
|
+
let { flags, dir, sl } = breaksAt(pts, wallNs);
|
|
812
|
+
// rotate a closed chain so runs never wrap; a chain breaking everywhere is left alone
|
|
813
|
+
if (closed) {
|
|
814
|
+
const pivot = flags.findIndex((b) => !b);
|
|
815
|
+
if (pivot === -1) return { pts, wallNs };
|
|
816
|
+
if (pivot > 0) {
|
|
817
|
+
pts = [...pts.slice(pivot), ...pts.slice(0, pivot)];
|
|
818
|
+
wallNs = [...wallNs.slice(pivot), ...wallNs.slice(0, pivot)];
|
|
819
|
+
({ flags, dir, sl } = breaksAt(pts, wallNs));
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
const m = pts.length, nSeg = closed ? m : m - 1;
|
|
823
|
+
const out = [], outWalls = [];
|
|
824
|
+
let i = 0;
|
|
825
|
+
const pushPoint = (p, wallIdx) => {
|
|
826
|
+
out.push(p);
|
|
827
|
+
if (wallIdx != null) outWalls.push(wallNs[wallIdx]);
|
|
828
|
+
};
|
|
829
|
+
while (i < m) {
|
|
830
|
+
// maximal run of breaking joints chained by sub-magnitude segments
|
|
831
|
+
let j = i;
|
|
832
|
+
while (j + 1 < m && flags[j] && flags[j + 1] && sl[j] < magnitude) j++;
|
|
833
|
+
if (flags[i] && j > i) {
|
|
834
|
+
const iIn = (i - 1 + nSeg) % nSeg;
|
|
835
|
+
const d1 = dir[iIn], d2 = dir[j % nSeg];
|
|
836
|
+
const p1 = pts[i], p2 = pts[j];
|
|
837
|
+
// intersect the flanking edge lines: p1 + a·d1 = p2 − b·d2 (in-plane)
|
|
838
|
+
const c12 = dot(d1, d2);
|
|
839
|
+
const denom = 1 - c12 * c12;
|
|
840
|
+
let V = null;
|
|
841
|
+
if (denom > 1e-6) {
|
|
842
|
+
const w0 = sub(p2, p1);
|
|
843
|
+
const a = (dot(w0, d1) - c12 * dot(w0, d2)) / denom;
|
|
844
|
+
const cand = add(p1, scl(d1, a));
|
|
845
|
+
let extent = 0;
|
|
846
|
+
for (let t = i; t < j; t++) extent += sl[t];
|
|
847
|
+
if (len(sub(cand, p1)) < 2 * magnitude + extent) V = cand;
|
|
848
|
+
}
|
|
849
|
+
if (V) {
|
|
850
|
+
pushPoint(V, j % nSeg); // V starts the outgoing segment: wall of seg j
|
|
851
|
+
i = j + 1;
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
pushPoint(pts[i], i < nSeg ? i : null);
|
|
856
|
+
i++;
|
|
857
|
+
}
|
|
858
|
+
if (out.length < (closed ? 3 : 2)) return { pts: pts0, wallNs: wallNs0 };
|
|
859
|
+
return { pts: out, wallNs: outWalls };
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function planarTool(k, chain, magnitude, mode, segs, pSegs = segs, endTins = null) {
|
|
863
|
+
const { points, closed, convex, faceN } = chain;
|
|
864
|
+
let { wallNs } = chain;
|
|
865
|
+
let pts = closed ? points.slice(0, -1) : points; // drop the duplicated closure point
|
|
866
|
+
// Corner features SMALLER than the blend collapse to a virtual sharp corner
|
|
867
|
+
// BEFORE any tool is built (see collapseTightCorners) — a run of fold-breaking
|
|
868
|
+
// joints on a sub-blend-radius corner round otherwise shatters into per-facet
|
|
869
|
+
// micro-tools whose disagreements notch the band (the non-bold glyph "divot":
|
|
870
|
+
// a raw letter terminal's ~0.1-0.25 mm corner rounds under a 0.3 mm fillet;
|
|
871
|
+
// bold outlines never hit this because the 0.4 mm round offset pads every
|
|
872
|
+
// convex radius past the fold threshold).
|
|
873
|
+
if (convex) ({ pts, wallNs } = collapseTightCorners(pts, wallNs, closed, magnitude));
|
|
741
874
|
const m = pts.length;
|
|
742
875
|
const at = (i) => pts[((i % m) + m) % m];
|
|
743
876
|
const nSeg = closed ? m : m - 1;
|
|
@@ -758,14 +891,38 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs) {
|
|
|
758
891
|
// viewer's line threshold and draw across the band. A salient split corner with room
|
|
759
892
|
// for the setback gets a corner ARC (cornerArcAt — the same rounded-corner treatment
|
|
760
893
|
// apply() gives two-chain junctions), the adjoining stretches trimmed to its tangent
|
|
761
|
-
// points; reflex
|
|
762
|
-
|
|
894
|
+
// points; a reflex split gets the rolling-ball PIVOT (reflexPivotTool — without it
|
|
895
|
+
// the flush stretch ends leave the corner wedge uncut and the face keeps its point);
|
|
896
|
+
// too-tight salient splits keep the overshoot mitre.
|
|
897
|
+
// The reach bound is SIDE-aware, mirroring the sweep's own direction-aware
|
|
898
|
+
// check: rings converge only on the inside of a bend, and only the profile's
|
|
899
|
+
// reach TOWARD the bend center matters. The bend axis of a planar chain is
|
|
900
|
+
// the face normal, so that reach is the profile's IN-PLANE extent — the
|
|
901
|
+
// face-tangency inset, magnitude (+2% corner delta; the arc between the
|
|
902
|
+
// tangencies never reaches past them on the corner side) — NOT the rigid
|
|
903
|
+
// 1.5× diagonal bound, whose extra 50% is the AXIAL extent that a bend about
|
|
904
|
+
// the face normal cannot consume. The old bound split any salient outline
|
|
905
|
+
// arc under ~1.7·magnitude into per-facet micro-tools (a bold glyph's 0.4 mm
|
|
906
|
+
// offset-round corners under a 0.3 mm rim fillet became a patchwork of
|
|
907
|
+
// ~20 µm tools whose disagreements notched the band — the "divot" artifact);
|
|
908
|
+
// with the in-plane bound those arcs ride the one continuous sweep, and
|
|
909
|
+
// splitting starts only near the genuine pinch (R ≈ 1.2·magnitude, where
|
|
910
|
+
// the top tangent contour is closing toward a point). A reflex bend curves
|
|
911
|
+
// past the wall, where the profile reaches only the corner delta — the
|
|
912
|
+
// symmetric bound there shattered concave arcs of the same radii (the
|
|
913
|
+
// roundAll fast path's reflex arcs exactly).
|
|
914
|
+
const reach = magnitude * 1.1;
|
|
915
|
+
const reachWall = 0.1 * magnitude;
|
|
763
916
|
const breaks = [];
|
|
764
917
|
for (let i = closed ? 0 : 1; i < (closed ? m : m - 1); i++) {
|
|
765
918
|
const iIn = (i - 1 + nSeg) % nSeg;
|
|
766
919
|
const c = clamp1(dot(segDir[iIn], segDir[i]));
|
|
767
920
|
const turn = Math.acos(c);
|
|
768
|
-
|
|
921
|
+
// inside-of-bend direction ≈ change of travel; past the wall ⇒ reflex bend
|
|
922
|
+
const bendIn = norm(sub(segDir[i], segDir[iIn]));
|
|
923
|
+
const reflexBend = dot(bendIn, wallNs[iIn]) + dot(bendIn, wallNs[i % nSeg]) > 0;
|
|
924
|
+
const r = reflexBend ? reachWall : reach;
|
|
925
|
+
const fold = c < -1 + 1e-6 || r * Math.tan(turn / 2) > 0.45 * Math.min(segLen[iIn], segLen[i]);
|
|
769
926
|
const sharp = turn > (SMOOTH_MAX_DEG * Math.PI) / 180;
|
|
770
927
|
if (fold || sharp) breaks.push(i);
|
|
771
928
|
}
|
|
@@ -773,6 +930,7 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs) {
|
|
|
773
930
|
// polyline to the ADJACENT break (or chain end) — a single tessellation segment says
|
|
774
931
|
// nothing about the room a whole smooth stretch offers.
|
|
775
932
|
const cornerArcs = new Map(); // break vertex index → { arc, t }
|
|
933
|
+
const pivots = []; // reflex break vertices → rolling-ball pivots
|
|
776
934
|
if (convex && breaks.length) {
|
|
777
935
|
const segSum = (from, to) => {
|
|
778
936
|
let sum = 0;
|
|
@@ -787,9 +945,15 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs) {
|
|
|
787
945
|
const nextB = closed
|
|
788
946
|
? breaks[(j + 1) % breaks.length] + (j + 1 === breaks.length ? m : 0)
|
|
789
947
|
: (j + 1 < breaks.length ? breaks[j + 1] : m - 1);
|
|
948
|
+
// a SHARP corner may steer only when another selected chain leaves this
|
|
949
|
+
// vertex out of the face plane (see apply()'s endTins note) — otherwise
|
|
950
|
+
// it keeps the honest mitre and cornerArcAt's upper gate refuses it
|
|
951
|
+
const allowSharp = !!(endTins?.get(pivotKey(at(i)))?.some((t) => Math.abs(dot(t, faceN)) > 0.7));
|
|
790
952
|
const got = cornerArcAt(at(i), faceN, scl(segDir[iIn], -1), segDir[i],
|
|
791
|
-
wallNs[iIn], wallNs[i], segSum(prevB, i), segSum(i, nextB), magnitude);
|
|
792
|
-
if (got) cornerArcs.set(i, got);
|
|
953
|
+
wallNs[iIn], wallNs[i], segSum(prevB, i), segSum(i, nextB), magnitude, allowSharp);
|
|
954
|
+
if (got) { cornerArcs.set(i, got); continue; }
|
|
955
|
+
const piv = reflexPivotAt(at(i), faceN, scl(segDir[iIn], -1), segDir[i], wallNs[iIn], wallNs[i]);
|
|
956
|
+
if (piv) pivots.push(piv);
|
|
793
957
|
}
|
|
794
958
|
}
|
|
795
959
|
|
|
@@ -860,6 +1024,7 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs) {
|
|
|
860
1024
|
if (len(sub(got.vertex, got.arc.O)) - got.arc.R > 0.02 * magnitude)
|
|
861
1025
|
tools.push(cornerHornTool(k, got, magnitude, segs));
|
|
862
1026
|
}
|
|
1027
|
+
for (const piv of pivots) tools.push(reflexPivotTool(k, piv, magnitude, mode, segs, pSegs));
|
|
863
1028
|
return tools;
|
|
864
1029
|
} catch (e) {
|
|
865
1030
|
throw new UnsupportedEdgeError(`planar sweep: ${e.message}`);
|
|
@@ -867,32 +1032,32 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs) {
|
|
|
867
1032
|
}
|
|
868
1033
|
|
|
869
1034
|
// ---------------------------------------------------------------------------
|
|
870
|
-
//
|
|
871
|
-
// in a common face plane (a letter corner, a polygon corner on a rim), the
|
|
872
|
-
//
|
|
873
|
-
//
|
|
874
|
-
//
|
|
875
|
-
//
|
|
876
|
-
//
|
|
877
|
-
//
|
|
878
|
-
//
|
|
879
|
-
//
|
|
880
|
-
//
|
|
881
|
-
//
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
//
|
|
1035
|
+
// Steered corners. Where exactly TWO selected convex chains meet at a salient corner
|
|
1036
|
+
// in a common face plane (a letter corner, a polygon corner on a rim), the two blends
|
|
1037
|
+
// cross in a mitre — a REAL crease, 76-90° dihedral measured, the same
|
|
1038
|
+
// intersection-and-trim seam OCCT's native fillet produces. There is no groove-free
|
|
1039
|
+
// construction that keeps the silhouette sharp: a band tangent to both walls around a
|
|
1040
|
+
// salient corner must crease, and a band that lifts off the walls strands the corner
|
|
1041
|
+
// column (verified again 2026-08-17 — the reflex pivot's torus does NOT mirror to
|
|
1042
|
+
// salient corners; the ball never touches a convex corner edge, and the mitre groove
|
|
1043
|
+
// of the neighbor cylinders survives beyond any such patch). So the mitre IS the
|
|
1044
|
+
// treatment for corners sharp enough to read as corners — see cornerArcAt's upper
|
|
1045
|
+
// gate — and the steer below exists only for the GENTLE band
|
|
1046
|
+
// (CORNER_ROUND_MIN_TURN..SMOOTH_MAX_DEG): the corner is replaced by a small
|
|
1047
|
+
// circular ARC chain (radius ~1.05-1.25× the blend magnitude, tangent to both
|
|
1048
|
+
// neighbors at a setback), the neighbors are trimmed to the tangent points, and the
|
|
1049
|
+
// existing revolveTool sweeps the arc. At these angles a mitre's long shallow
|
|
1050
|
+
// overlap wedge triangulates into >35° junk lines (measured: a 20.7° mitre still
|
|
1051
|
+
// drew, an ~8° one does not) while the steer's silhouette cost — a sagitta of
|
|
1052
|
+
// ρ·(1−cos(turn/2)), plus the horn's sub-visible shelf — stays microns deep, so the
|
|
1053
|
+
// trade runs the opposite way to a sharp corner's.
|
|
885
1054
|
//
|
|
886
|
-
// REFLEX corners
|
|
887
|
-
//
|
|
888
|
-
//
|
|
889
|
-
//
|
|
890
|
-
//
|
|
891
|
-
//
|
|
892
|
-
// overlap sliver stays too flat for simplify() to fold into visible creases. Measured:
|
|
893
|
-
// a 20.7° mitre still drew (its long shallow overlap wedge triangulates into >35°
|
|
894
|
-
// junk), an ~8° one does not. The silhouette cost of rounding a gentle corner is a
|
|
895
|
-
// sagitta of ρ·(1−cos(turn/2)) — sub-micron at these angles — so the gate is safe low.
|
|
1055
|
+
// REFLEX corners take the rolling-ball PIVOT instead (reflexPivotAt below): steering
|
|
1056
|
+
// the band path around a reflex corner would ADD material, but the ball itself swings
|
|
1057
|
+
// about the corner touching the face and the vertical corner edge — see the reflex
|
|
1058
|
+
// pivot section. A gentle salient corner whose neighbors are too short to host the
|
|
1059
|
+
// setback (tight glyph features) falls back to the mitre — that fallback is never a
|
|
1060
|
+
// failure.
|
|
896
1061
|
const CORNER_ROUND_MIN_TURN = (8 * Math.PI) / 180;
|
|
897
1062
|
const RHO_MIN = 1.05; // × magnitude — revolve floor: the profile reaches magnitude inward of the arc
|
|
898
1063
|
const RHO_PREF = 1.25; // × magnitude — preferred corner radius, a hair over the floor for margin
|
|
@@ -901,10 +1066,24 @@ const RHO_PREF = 1.25; // × magnitude — preferred corner radius, a hair over
|
|
|
901
1066
|
// wall1/wall2 are the sides\' outward wall normals at the vertex; len1/len2 bound the
|
|
902
1067
|
// setback. Returns { arc, t } (a synthetic kind:"arc" chain for revolveTool, plus the
|
|
903
1068
|
// setback to trim each side by) or null when the corner keeps its mitre.
|
|
904
|
-
function cornerArcAt(vertex, f, tin1, tin2, wall1, wall2, len1, len2, magnitude) {
|
|
1069
|
+
function cornerArcAt(vertex, f, tin1, tin2, wall1, wall2, len1, len2, magnitude, allowSharp = false) {
|
|
905
1070
|
const tIn = scl(tin1, -1), tOut = tin2; // travel: arrive along side 1, depart into 2
|
|
906
1071
|
const turn = Math.acos(clamp1(dot(tIn, tOut)));
|
|
907
1072
|
if (turn < CORNER_ROUND_MIN_TURN) return null;
|
|
1073
|
+
// A corner past the chain-smoothness bar READS as a corner and keeps the honest
|
|
1074
|
+
// mitre (decided 2026-08-17): the two blends run to the vertex and cross in the
|
|
1075
|
+
// classic intersection seam every B-rep fillet shows — the seam is a real crease,
|
|
1076
|
+
// its feature line is correct, and the top face keeps its sharp corner. The
|
|
1077
|
+
// arc-steer below is only for gentle corners, where the sub-visible horn shelf is
|
|
1078
|
+
// a fair price for killing the shallow-overlap junk lines a gentle mitre draws.
|
|
1079
|
+
// Steering SHARP corners bought a clean band at the cost of a rounded in-band
|
|
1080
|
+
// silhouette hovering over the sharp extrude corner with a flat shelf between —
|
|
1081
|
+
// a mismatch no CAD user expects (the label part's non-bold letter terminals).
|
|
1082
|
+
// `allowSharp` is the one exception: when the corner's vertical edge is being
|
|
1083
|
+
// blended too (roundAll, a selector-free fillet), the column below the band is
|
|
1084
|
+
// itself rounded at r — the steer approximates the ball's sphere corner there
|
|
1085
|
+
// and nothing is left to mismatch (planarTool derives it from apply()'s endTins).
|
|
1086
|
+
if (!allowSharp && turn > (SMOOTH_MAX_DEG * Math.PI) / 180) return null;
|
|
908
1087
|
const turnS = dot(cross(tIn, tOut), f);
|
|
909
1088
|
const matLeft = dot(wall1, cross(tIn, f)) > 0;
|
|
910
1089
|
if ((turnS > 0) !== matLeft) return null; // reflex: the crease is real — keep the mitre
|
|
@@ -989,6 +1168,95 @@ function cornerHornTool(k, { vertex, f, arc }, magnitude, segs) {
|
|
|
989
1168
|
return tool.translate(O);
|
|
990
1169
|
}
|
|
991
1170
|
|
|
1171
|
+
// ---------------------------------------------------------------------------
|
|
1172
|
+
// Reflex pivots. At a REFLEX corner the neighboring blend tools end flush against the
|
|
1173
|
+
// planes through the vertex (their only overshoot is the 0.05·r anti-graze allowance),
|
|
1174
|
+
// so the wedge of rim material between those end planes — azimuthal extent = the turn
|
|
1175
|
+
// angle — survives both cutters and the face keeps a point essentially AT the vertex.
|
|
1176
|
+
// The rolling ball does not stop there: it pivots about the corner, touching the face
|
|
1177
|
+
// and the vertical corner edge, its center swinging on an arc of radius r about the
|
|
1178
|
+
// face-normal axis through the vertex. The envelope is a horn-torus patch — the
|
|
1179
|
+
// revolve, about that axis, of the blend cross-section running from the axis point
|
|
1180
|
+
// r below the face to the face tangency at radius r — and the face's blend boundary
|
|
1181
|
+
// becomes an arc of radius r about the vertex (the "rounded inside curve"). The pivot
|
|
1182
|
+
// cross-section at each span end coincides exactly with the neighbor tool's own
|
|
1183
|
+
// cross-section at the vertex (same circle, same plane), so the handover is G1; the
|
|
1184
|
+
// small angular overshoot below only re-cuts band the neighbors already cut.
|
|
1185
|
+
|
|
1186
|
+
// Pivot descriptor at one vertex, or null (salient — cornerArcAt's case — or a turn
|
|
1187
|
+
// too gentle to matter). Same argument convention as cornerArcAt: tin1/tin2 point
|
|
1188
|
+
// from the vertex INTO each side, wall1/wall2 are the sides' outward wall normals.
|
|
1189
|
+
function reflexPivotAt(vertex, f, tin1, tin2, wall1, wall2) {
|
|
1190
|
+
const tIn = scl(tin1, -1), tOut = tin2;
|
|
1191
|
+
const turn = Math.acos(clamp1(dot(tIn, tOut)));
|
|
1192
|
+
if (turn < CORNER_ROUND_MIN_TURN) return null; // wedge sagitta sub-micron: keep today's behavior
|
|
1193
|
+
const turnS = dot(cross(tIn, tOut), f);
|
|
1194
|
+
const matLeft = dot(wall1, cross(tIn, f)) > 0;
|
|
1195
|
+
if ((turnS > 0) === matLeft) return null; // salient: roundSalientCorners' territory
|
|
1196
|
+
// inward in-plane wall normals bound the uncut wedge; the pivot sweeps between them
|
|
1197
|
+
const proj = (wl) => { const p = sub(scl(wl, -1), scl(f, -dot(wl, f))); const l = len(p) || 1; return scl(p, 1 / l); };
|
|
1198
|
+
let u0 = proj(wall1), uE = proj(wall2);
|
|
1199
|
+
const span = Math.acos(clamp1(dot(u0, uE)));
|
|
1200
|
+
if (!(span > 1e-4)) return null;
|
|
1201
|
+
if (dot(cross(u0, uE), f) < 0) [u0, uE] = [uE, u0]; // azimuth increases u0 → uE about +f
|
|
1202
|
+
return { vertex, f, u0, span };
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
// The pivot cutter: revolve of the blend cross-section about the face-normal axis
|
|
1206
|
+
// through the vertex. Cross-section in (ρ, z) with z along f (face at z = 0): the
|
|
1207
|
+
// fillet arc runs from the axis point (0, −r) to the face tangency (r, 0) on the
|
|
1208
|
+
// circle centered (r, −r) — extended past the tangency by revolveTool's arc-tail
|
|
1209
|
+
// recipe, because the blend meets the face tangentially there and a tessellated
|
|
1210
|
+
// tangency grazes — then closes above the face so the corner column goes with it.
|
|
1211
|
+
// A chamfer takes the chord instead (a cone), which meets face and edge transversally
|
|
1212
|
+
// and needs no tail. Interior arc vertices use profile2D's area-exact radius.
|
|
1213
|
+
function reflexPivotTool(k, { vertex, f, u0, span }, magnitude, mode, segs, pSegs) {
|
|
1214
|
+
const r = magnitude;
|
|
1215
|
+
const delta = 0.02 * r;
|
|
1216
|
+
let poly;
|
|
1217
|
+
if (mode === "chamfer") {
|
|
1218
|
+
poly = [[0, -r], [r, 0], [r + delta, delta], [0, delta]];
|
|
1219
|
+
} else {
|
|
1220
|
+
const sag = Math.min(2e-4, 0.02 * r);
|
|
1221
|
+
const ext = Math.min(0.4, Math.max(0.01, Math.acos(Math.max(-1, 1 - sag / r))));
|
|
1222
|
+
const arcSpan = Math.PI / 2 + ext;
|
|
1223
|
+
const nArc = Math.max(2, Math.ceil((arcSpan / (2 * Math.PI)) * pSegs));
|
|
1224
|
+
const th = arcSpan / nArc;
|
|
1225
|
+
const rEq = r * Math.sqrt(th / Math.sin(th));
|
|
1226
|
+
poly = [];
|
|
1227
|
+
for (let i = 0; i <= nArc; i++) {
|
|
1228
|
+
const a = Math.PI - arcSpan * (i / nArc); // 180° (axis) → past the face tangency
|
|
1229
|
+
const ri = i === 0 || i === nArc ? r : rEq;
|
|
1230
|
+
poly.push([r + ri * Math.cos(a), -r + ri * Math.sin(a)]);
|
|
1231
|
+
}
|
|
1232
|
+
poly.push([poly[poly.length - 1][0], delta], [0, delta]);
|
|
1233
|
+
}
|
|
1234
|
+
let area = 0;
|
|
1235
|
+
for (let i = 0; i < poly.length; i++) {
|
|
1236
|
+
const [x1, y1] = poly[i], [x2, y2] = poly[(i + 1) % poly.length];
|
|
1237
|
+
area += x1 * y2 - x2 * y1;
|
|
1238
|
+
}
|
|
1239
|
+
if (area < 0) poly = poly.slice().reverse();
|
|
1240
|
+
// angular overshoot past both span ends: crosses the walls (tangent contacts at the
|
|
1241
|
+
// ends) decisively, re-cutting only band the flush-ended neighbors already cut
|
|
1242
|
+
const ovAng = 0.05;
|
|
1243
|
+
const degrees = ((span + 2 * ovAng) * 180) / Math.PI;
|
|
1244
|
+
let tool = k.revolve(poly, { degrees, segs: cornerArcSegs(segs, 0, magnitude) });
|
|
1245
|
+
// pose: Z → f, then twist the revolve's start azimuth (+X) onto u0 backed off by ovAng
|
|
1246
|
+
const v0 = cross(f, u0);
|
|
1247
|
+
const startDir = add(scl(u0, Math.cos(-ovAng)), scl(v0, Math.sin(-ovAng)));
|
|
1248
|
+
const axisRaw = cross([0, 0, 1], f);
|
|
1249
|
+
const s = len(axisRaw);
|
|
1250
|
+
let axis = null, theta = 0;
|
|
1251
|
+
if (s > 1e-9) { axis = scl(axisRaw, 1 / s); theta = Math.atan2(s, f[2]); }
|
|
1252
|
+
else if (f[2] < 0) { axis = [1, 0, 0]; theta = Math.PI; }
|
|
1253
|
+
if (axis) tool = tool.rotateAbout({ axis, deg: (theta * 180) / Math.PI });
|
|
1254
|
+
const xImage = axis ? rotVec([1, 0, 0], axis, theta) : [1, 0, 0];
|
|
1255
|
+
const twist = Math.atan2(dot(f, cross(xImage, startDir)), dot(xImage, startDir));
|
|
1256
|
+
if (Math.abs(twist) > 1e-9) tool = tool.rotateAbout({ axis: f, deg: (twist * 180) / Math.PI });
|
|
1257
|
+
return tool.translate(vertex);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
992
1260
|
function chainEndInfo(ch, end) {
|
|
993
1261
|
if (ch.kind === "line") {
|
|
994
1262
|
return end === "start"
|
|
@@ -1003,9 +1271,10 @@ function chainEndInfo(ch, end) {
|
|
|
1003
1271
|
: { v: pts[m - 1], tin: norm(sub(pts[m - 2], pts[m - 1])), flanks: [ch.faceN, ch.wallNs[ch.wallNs.length - 1]], len: plen };
|
|
1004
1272
|
}
|
|
1005
1273
|
|
|
1006
|
-
// Corner
|
|
1007
|
-
//
|
|
1008
|
-
|
|
1274
|
+
// Corner treatment for two chain ends meeting at one vertex: {arc} for a salient
|
|
1275
|
+
// corner with room for the setback, {pivot} for a reflex corner, or null (no common
|
|
1276
|
+
// face plane, gentle turn, or a too-tight salient corner keeping its mitre).
|
|
1277
|
+
function cornerBlendBetween(E1, E2, magnitude) {
|
|
1009
1278
|
let f = null, wall1 = null, wall2 = null;
|
|
1010
1279
|
for (const c1 of E1.flanks) {
|
|
1011
1280
|
for (const c2 of E2.flanks) {
|
|
@@ -1018,7 +1287,10 @@ function cornerArcBetween(E1, E2, magnitude) {
|
|
|
1018
1287
|
}
|
|
1019
1288
|
}
|
|
1020
1289
|
if (!f) return null;
|
|
1021
|
-
|
|
1290
|
+
const corner = cornerArcAt(E1.v, f, E1.tin, E2.tin, wall1, wall2, E1.len, E2.len, magnitude);
|
|
1291
|
+
if (corner) return { corner };
|
|
1292
|
+
const pivot = reflexPivotAt(E1.v, f, E1.tin, E2.tin, wall1, wall2);
|
|
1293
|
+
return pivot ? { pivot } : null;
|
|
1022
1294
|
}
|
|
1023
1295
|
|
|
1024
1296
|
// Convert a FACE-PLANE arc chain to the equivalent planar chain, or return null when
|
|
@@ -1082,8 +1354,9 @@ function trimChain(ch, tStart, tEnd) {
|
|
|
1082
1354
|
return { ...ch, points: pts, wallNs: walls, closed: false };
|
|
1083
1355
|
}
|
|
1084
1356
|
|
|
1085
|
-
//
|
|
1086
|
-
//
|
|
1357
|
+
// Blend the two-chain corners of a selection: salient corners round (trimmed neighbors
|
|
1358
|
+
// substituted in place, plus the synthetic corner-arc chains and their horns); reflex
|
|
1359
|
+
// corners get rolling-ball pivots (neighbors stay flush — the pivot owns the wedge).
|
|
1087
1360
|
function roundSalientCorners(selected, magnitude) {
|
|
1088
1361
|
const keyOf = (p) => `${Math.round(p[0] * WELD)},${Math.round(p[1] * WELD)},${Math.round(p[2] * WELD)}`;
|
|
1089
1362
|
const ends = new Map();
|
|
@@ -1096,7 +1369,7 @@ function roundSalientCorners(selected, magnitude) {
|
|
|
1096
1369
|
(ends.get(kk) ?? ends.set(kk, []).get(kk)).push({ ch, end, info });
|
|
1097
1370
|
}
|
|
1098
1371
|
}
|
|
1099
|
-
const arcs = [], horns = [], trims = new Map();
|
|
1372
|
+
const arcs = [], horns = [], pivots = [], trims = new Map();
|
|
1100
1373
|
const addTrim = (ch, end, t) => {
|
|
1101
1374
|
const cur = trims.get(ch) ?? { start: 0, end: 0 };
|
|
1102
1375
|
cur[end] = t;
|
|
@@ -1104,8 +1377,10 @@ function roundSalientCorners(selected, magnitude) {
|
|
|
1104
1377
|
};
|
|
1105
1378
|
for (const list of ends.values()) {
|
|
1106
1379
|
if (list.length !== 2 || (list[0].ch === list[1].ch && list[0].end === list[1].end)) continue;
|
|
1107
|
-
const
|
|
1108
|
-
if (!
|
|
1380
|
+
const blend = cornerBlendBetween(list[0].info, list[1].info, magnitude);
|
|
1381
|
+
if (!blend) continue;
|
|
1382
|
+
if (blend.pivot) { pivots.push(blend.pivot); continue; }
|
|
1383
|
+
const got = blend.corner;
|
|
1109
1384
|
arcs.push(got.arc);
|
|
1110
1385
|
// a gentle corner's horn is a sliver — depth ρ·(1/cos(turn/2) − 1), microns at
|
|
1111
1386
|
// small turns — not worth a cutter (and thin cutters are their own noise source)
|
|
@@ -1114,14 +1389,14 @@ function roundSalientCorners(selected, magnitude) {
|
|
|
1114
1389
|
addTrim(list[0].ch, list[0].end, got.t);
|
|
1115
1390
|
addTrim(list[1].ch, list[1].end, got.t);
|
|
1116
1391
|
}
|
|
1117
|
-
if (!arcs.length) return { chains: selected, arcs, horns };
|
|
1392
|
+
if (!arcs.length) return { chains: selected, arcs, horns, pivots };
|
|
1118
1393
|
const chains = [];
|
|
1119
1394
|
for (const ch of selected) {
|
|
1120
1395
|
const tr = trims.get(ch);
|
|
1121
1396
|
const eff = tr ? trimChain(ch, tr.start, tr.end) : ch;
|
|
1122
1397
|
if (eff) chains.push(eff);
|
|
1123
1398
|
}
|
|
1124
|
-
return { chains, arcs, horns };
|
|
1399
|
+
return { chains, arcs, horns, pivots };
|
|
1125
1400
|
}
|
|
1126
1401
|
|
|
1127
1402
|
// ---------------------------------------------------------------------------
|
|
@@ -1139,12 +1414,28 @@ function roundSalientCorners(selected, magnitude) {
|
|
|
1139
1414
|
// so the only new surface is the octant. Non-orthogonal corners keep the mitre
|
|
1140
1415
|
// — the safe, documented default.
|
|
1141
1416
|
function cornerPatches(k, selected, r, segs) {
|
|
1142
|
-
const lines = selected.filter((ch) => ch.kind === "line" && ch.convex);
|
|
1143
1417
|
const byVertex = new Map();
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1418
|
+
const push = (pt, dirOut) => {
|
|
1419
|
+
const key = pt.map((v) => Math.round(v * 1e4)).join(",");
|
|
1420
|
+
(byVertex.get(key) ?? byVertex.set(key, []).get(key)).push({ pt, dirOut });
|
|
1421
|
+
};
|
|
1422
|
+
for (const ch of selected) {
|
|
1423
|
+
if (ch.convex !== true) continue;
|
|
1424
|
+
if (ch.kind === "line") {
|
|
1425
|
+
push(ch.a, ch.dir);
|
|
1426
|
+
push(ch.b, scl(ch.dir, -1));
|
|
1427
|
+
} else if (ch.kind === "planar" && !ch.closed) {
|
|
1428
|
+
// A planar chain END whose end segment runs straight for ≥ r qualifies
|
|
1429
|
+
// too: inside the corner cube the sweep is the same straight cylinder a
|
|
1430
|
+
// line chain's prism tool would cut, so the octant construction holds
|
|
1431
|
+
// unchanged. This is the roundAll fast path's mixed corner — a straight
|
|
1432
|
+
// rim edge and a vertical edge (line chains) meeting a planar rim chain
|
|
1433
|
+
// whose curvature lives far from the corner. Requiring the full r of
|
|
1434
|
+
// straight run keeps the cube inside the cylinder-only zone.
|
|
1435
|
+
const p = ch.points, n = p.length;
|
|
1436
|
+
const d0 = sub(p[1], p[0]), dN = sub(p[n - 2], p[n - 1]);
|
|
1437
|
+
if (len(d0) >= r) push(p[0], scl(d0, 1 / len(d0)));
|
|
1438
|
+
if (len(dN) >= r) push(p[n - 1], scl(dN, 1 / len(dN)));
|
|
1148
1439
|
}
|
|
1149
1440
|
}
|
|
1150
1441
|
const patches = [];
|
|
@@ -1198,15 +1489,31 @@ function apply(k, solid, mode, magnitude, { edges, segs = DEFAULT_SEGS, sharpDeg
|
|
|
1198
1489
|
// Face-plane arc rims sweep their own polyline (see planarizeArc); re-stitch so a
|
|
1199
1490
|
// converted arc joins its planar neighbors — chainEdges' own stitch pass ran before
|
|
1200
1491
|
// these chains were planar, so their junctions are still open here.
|
|
1201
|
-
const planarized = stitchPlanarChains(selected.map((ch) => planarizeArc(ch) ?? ch));
|
|
1202
|
-
|
|
1492
|
+
const planarized = stitchPlanarChains(selected.map((ch) => planarizeArc(ch) ?? ch), { absorbLines: true });
|
|
1493
|
+
// Ends of selected chains, keyed by vertex — planarTool steers a SHARP break
|
|
1494
|
+
// corner only when another selected chain leaves that vertex out of the face
|
|
1495
|
+
// plane (a vertical edge being blended too, the roundAll/trihedral case where
|
|
1496
|
+
// the corner column below the band is itself rounded and the steer's shelf is
|
|
1497
|
+
// consumed by that blend). A sharp corner with nothing else selected there
|
|
1498
|
+
// keeps the honest mitre — see the steered-corners section.
|
|
1499
|
+
const endTins = new Map();
|
|
1500
|
+
for (const ch of planarized) {
|
|
1501
|
+
if (ch.closed || (ch.kind !== "line" && ch.kind !== "planar")) continue;
|
|
1502
|
+
for (const end of ["start", "end"]) {
|
|
1503
|
+
const info = chainEndInfo(ch, end);
|
|
1504
|
+
const kk = pivotKey(info.v);
|
|
1505
|
+
(endTins.get(kk) ?? endTins.set(kk, []).get(kk)).push(info.tin);
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
const { chains: effective, arcs, horns, pivots } = roundSalientCorners(planarized, magnitude);
|
|
1203
1509
|
const pSegs = blendSegs(segs, magnitude);
|
|
1204
1510
|
const toolsFor = (ch) =>
|
|
1205
1511
|
ch.kind === "planar"
|
|
1206
|
-
? planarTool(k, ch, magnitude, mode, segs, pSegs)
|
|
1512
|
+
? planarTool(k, ch, magnitude, mode, segs, pSegs, endTins)
|
|
1207
1513
|
: [(ch.kind === "arc" ? revolveTool : prismTool)(k, ch, magnitude, mode, segs, pSegs)];
|
|
1208
1514
|
const cutters = [...effective, ...arcs].filter((ch) => ch.convex).flatMap(toolsFor);
|
|
1209
1515
|
cutters.push(...horns.map((h) => cornerHornTool(k, h, magnitude, segs)));
|
|
1516
|
+
cutters.push(...pivots.map((p) => reflexPivotTool(k, p, magnitude, mode, segs, pSegs)));
|
|
1210
1517
|
const fillers = effective.filter((ch) => !ch.convex).flatMap(toolsFor);
|
|
1211
1518
|
if (mode === "fillet") cutters.push(...cornerPatches(k, effective, magnitude, segs));
|
|
1212
1519
|
let out = solid;
|
|
@@ -20,6 +20,15 @@ export const BLEND = Object.freeze({ creaseAngle: 35, sameSurfaceLines: true, bo
|
|
|
20
20
|
export const COPLANAR_ANGLE = 5; // deg — cut seams bending less than this are coplanar: no line
|
|
21
21
|
export const TANGENT_ANGLE = 5; // deg — B-rep edges whose faces agree within this are tangent: no line
|
|
22
22
|
export const MIN_EDGE = 0.01; // mm — drop shorter segments (degenerate slivers, pole edges)
|
|
23
|
+
// mm — a feature-line's incident FACES must both be at least this wide (min height).
|
|
24
|
+
// Wider than MIN_EDGE deliberately: a boolean face-split near a tool crossing (a
|
|
25
|
+
// mitre corner, a pivot's angular overshoot) re-triangulates the split band quad
|
|
26
|
+
// against seam vertices that sit microns off its plane — long fan slivers 14-34µm
|
|
27
|
+
// wide whose normals tilt 40-56°, drawing a full-weight line down an otherwise
|
|
28
|
+
// perfect band (total surface relief under 15µm — sub-visible at any scale this
|
|
29
|
+
// kernel prints at). Real band facets at the sagitta-bounded density are ≥ ~70µm
|
|
30
|
+
// wide; boundary rings bypass the gate entirely.
|
|
31
|
+
export const MIN_FACE = 0.04;
|
|
23
32
|
|
|
24
33
|
// Loft rings with at least this many sides read as an approximation of a smooth
|
|
25
34
|
// surface (e.g. a 64-gon "circle"), not as 64 intentional facets.
|
|
@@ -112,12 +112,22 @@ export function resolveSweepStations(profile2D, path3D, { closed = false, corner
|
|
|
112
112
|
rodrigues(N, axis, ang), rodrigues(B, axis, ang)));
|
|
113
113
|
}
|
|
114
114
|
} else { // sharp miter: one station in the bisecting plane
|
|
115
|
-
if (maxReach * Math.tan(theta / 2) > 0.5 * Math.min(lenIn, lenOut))
|
|
116
|
-
throw new Error(`sweep: profile too wide for the bend at vertex ${vtx} (turn too sharp / segment too short) — increase cornerRadius or lengthen the segment`);
|
|
117
115
|
const Nh = rodrigues(N, axis, theta / 2), Bh = rodrigues(B, axis, theta / 2);
|
|
118
116
|
const mDir = rodrigues(tIn, axis, theta / 2); // ring-plane normal (average travel dir)
|
|
119
117
|
const u = norm(cross(axis, mDir)); // in-plane bend direction (stretch axis)
|
|
120
118
|
const cosh = Math.cos(theta / 2);
|
|
119
|
+
// Fold check, DIRECTION-aware: consecutive mitered rings converge only on
|
|
120
|
+
// the inside of the bend (u points toward the bend center; the far side
|
|
121
|
+
// diverges), so what must fit is the profile's stretched reach along +u —
|
|
122
|
+
// not its symmetric max-|vertex| bound, which refused tight concave rim
|
|
123
|
+
// sweeps whose inside-of-bend extent is only the blend profile's ~2%-of-
|
|
124
|
+
// magnitude beyond-wall margin (mesh-fillet's planar chains over a
|
|
125
|
+
// radius≈magnitude concave outline arc are exactly that case).
|
|
126
|
+
const uN = dot(u, Nh), uB = dot(u, Bh);
|
|
127
|
+
let reachIn = 0;
|
|
128
|
+
for (const [x, y] of profile2D) reachIn = Math.max(reachIn, x * uN + y * uB);
|
|
129
|
+
if ((reachIn / cosh) * Math.tan(theta / 2) > 0.5 * Math.min(lenIn, lenOut))
|
|
130
|
+
throw new Error(`sweep: profile too wide for the bend at vertex ${vtx} (turn too sharp / segment too short) — increase cornerRadius or lengthen the segment`);
|
|
121
131
|
stations.push(profile2D.map(([x, y]) => {
|
|
122
132
|
const p = add(scl(Nh, x), scl(Bh, y)); // profile point in the miter plane (spanned by u, axis)
|
|
123
133
|
return add(center, add(scl(axis, dot(p, axis)), scl(u, dot(p, u) / cosh))); // stretch the u component
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
const FONT_URLS = {
|
|
2
|
+
pacifico: "https://raw.githubusercontent.com/google/fonts/main/ofl/pacifico/Pacifico-Regular.ttf",
|
|
3
|
+
dancing: "https://raw.githubusercontent.com/google/fonts/main/ofl/dancingscript/DancingScript%5Bwght%5D.ttf",
|
|
4
|
+
greatVibes: "https://raw.githubusercontent.com/google/fonts/main/ofl/greatvibes/GreatVibes-Regular.ttf",
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
const slantProfile = (k, profile, shear) => {
|
|
8
|
+
if (!shear) return profile;
|
|
9
|
+
const regions = profile.toRegions();
|
|
10
|
+
if (!Array.isArray(regions)) return profile;
|
|
11
|
+
|
|
12
|
+
let combined = null;
|
|
13
|
+
for (const region of regions) {
|
|
14
|
+
const outer = [];
|
|
15
|
+
for (const point of region.outer) {
|
|
16
|
+
outer.push([point[0] + shear * point[1], point[1]]);
|
|
17
|
+
}
|
|
18
|
+
const holes = [];
|
|
19
|
+
for (const hole of region.holes) {
|
|
20
|
+
const slantedHole = [];
|
|
21
|
+
for (const point of hole) {
|
|
22
|
+
slantedHole.push([point[0] + shear * point[1], point[1]]);
|
|
23
|
+
}
|
|
24
|
+
holes.push(slantedHole);
|
|
25
|
+
}
|
|
26
|
+
const slantedRegion = k.shape2d({ outer, holes });
|
|
27
|
+
combined = combined ? combined.union(slantedRegion) : slantedRegion;
|
|
28
|
+
}
|
|
29
|
+
return combined || profile;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const styleProfile = (k, profile, bold, italic) => {
|
|
33
|
+
let styled = slantProfile(k, profile, italic ? 0.2126 : 0);
|
|
34
|
+
if (bold) {
|
|
35
|
+
styled = styled.offset(0.4, { corners: "round", segs: 32 });
|
|
36
|
+
}
|
|
37
|
+
return styled;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const offsetGlyphContours = (k, profile, distance) => {
|
|
41
|
+
const options = { corners: "round", segs: 32 };
|
|
42
|
+
const regions = profile.regions();
|
|
43
|
+
if (!Array.isArray(regions)) return k.hull([profile]).offset(distance, options);
|
|
44
|
+
let combined = null;
|
|
45
|
+
for (const region of regions) {
|
|
46
|
+
const expanded = k.hull([region]).offset(distance, options);
|
|
47
|
+
combined = combined ? combined.union(expanded) : expanded;
|
|
48
|
+
}
|
|
49
|
+
return combined || k.hull([profile]).offset(distance, options);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const planarTopFillet = (solid, height, radius) => solid.fillet({
|
|
53
|
+
r: radius,
|
|
54
|
+
edges: { inPlane: "XY", at: height },
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const filletGlyphRegions = (k, profile, height, radius) => {
|
|
58
|
+
const regions = profile.regions();
|
|
59
|
+
if (!Array.isArray(regions)) return profile.extrude({ h: height });
|
|
60
|
+
const rounded = [];
|
|
61
|
+
for (const region of regions) {
|
|
62
|
+
const glyph = region.extrude({ h: height });
|
|
63
|
+
rounded.push(planarTopFillet(glyph, height, radius));
|
|
64
|
+
}
|
|
65
|
+
if (rounded.length === 1) return rounded[0];
|
|
66
|
+
return k.union(rounded);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export default {
|
|
70
|
+
meta: {
|
|
71
|
+
title: "Editable Layered Label",
|
|
72
|
+
units: "mm",
|
|
73
|
+
background: 0x171a20,
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
parameters: [
|
|
77
|
+
{
|
|
78
|
+
id: "label",
|
|
79
|
+
title: "Label",
|
|
80
|
+
description: "Dimensions for the raised name and its larger lower backing layer.",
|
|
81
|
+
controls: [
|
|
82
|
+
{
|
|
83
|
+
key: "text",
|
|
84
|
+
type: "text",
|
|
85
|
+
label: "Label text",
|
|
86
|
+
description: "Text shown on the raised face; changes preview immediately.",
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
key: "fontStyle",
|
|
90
|
+
type: "select",
|
|
91
|
+
label: "Font",
|
|
92
|
+
options: [
|
|
93
|
+
{ value: "roboto", label: "Roboto" },
|
|
94
|
+
{ value: "pacifico", label: "Pacifico Script" },
|
|
95
|
+
{ value: "dancing", label: "Dancing Script" },
|
|
96
|
+
{ value: "greatVibes", label: "Great Vibes Script" },
|
|
97
|
+
],
|
|
98
|
+
description: "Choose the bundled sans-serif or an elegant script font.",
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
key: "bold",
|
|
102
|
+
type: "checkbox",
|
|
103
|
+
label: "Bold",
|
|
104
|
+
description: "Adds 0.4 mm of weight around every letter stroke.",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
key: "italic",
|
|
108
|
+
type: "checkbox",
|
|
109
|
+
label: "Italic",
|
|
110
|
+
description: "Slants the lettering 12 degrees to the right.",
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
key: "textSize",
|
|
114
|
+
label: "Letter height",
|
|
115
|
+
unit: "mm",
|
|
116
|
+
min: 10,
|
|
117
|
+
max: 60,
|
|
118
|
+
step: 1,
|
|
119
|
+
description: "Nominal height of the raised lettering.",
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
key: "backingStyle",
|
|
123
|
+
type: "select",
|
|
124
|
+
label: "Backing edge style",
|
|
125
|
+
options: [
|
|
126
|
+
{ value: "fillet", label: "Top fillet (planar)" },
|
|
127
|
+
{ value: "roundAll", label: "Round all edges" },
|
|
128
|
+
],
|
|
129
|
+
description: "Top fillet rounds only the top rim; Round all rounds every edge of the backing (roundAll test).",
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
key: "border",
|
|
133
|
+
label: "Backing offset",
|
|
134
|
+
unit: "mm",
|
|
135
|
+
min: 2,
|
|
136
|
+
max: 20,
|
|
137
|
+
step: 0.5,
|
|
138
|
+
recommended: [4, 12],
|
|
139
|
+
description: "Distance the lower layer extends beyond the lettering.",
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
key: "backingThickness",
|
|
143
|
+
label: "Lower layer thickness",
|
|
144
|
+
unit: "mm",
|
|
145
|
+
min: 1.2,
|
|
146
|
+
max: 8,
|
|
147
|
+
step: 0.1,
|
|
148
|
+
recommended: [2, 4],
|
|
149
|
+
description: "Printable thickness of the larger backing layer.",
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
key: "letterThickness",
|
|
153
|
+
label: "Raised letter thickness",
|
|
154
|
+
unit: "mm",
|
|
155
|
+
min: 0.8,
|
|
156
|
+
max: 6,
|
|
157
|
+
step: 0.1,
|
|
158
|
+
recommended: [1.2, 3],
|
|
159
|
+
description: "Height of the raised lettering above the lower layer.",
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
|
|
165
|
+
defaults: {
|
|
166
|
+
text: "Scott",
|
|
167
|
+
fontStyle: "roboto",
|
|
168
|
+
bold: 0,
|
|
169
|
+
italic: 0,
|
|
170
|
+
backingStyle: "roundAll",
|
|
171
|
+
textSize: 31,
|
|
172
|
+
border: 5,
|
|
173
|
+
backingThickness: 2.5,
|
|
174
|
+
letterThickness: 2,
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
derive: (p) => ({
|
|
178
|
+
textValue: String(p.text ?? "").trim() || "Scott",
|
|
179
|
+
fontUrl: FONT_URLS[p.fontStyle],
|
|
180
|
+
topZ: p.backingThickness,
|
|
181
|
+
totalHeight: p.backingThickness + p.letterThickness,
|
|
182
|
+
}),
|
|
183
|
+
|
|
184
|
+
parts: {
|
|
185
|
+
backing: {
|
|
186
|
+
label: "Lower outer layer",
|
|
187
|
+
views: ["backing", "assembly"],
|
|
188
|
+
display: { color: 0x1e88e5 },
|
|
189
|
+
build: (k, p, d) => {
|
|
190
|
+
const rawTextProfile = k.text2d(d.textValue, {
|
|
191
|
+
size: p.textSize,
|
|
192
|
+
...(d.fontUrl ? { font: d.fontUrl } : {}),
|
|
193
|
+
align: "center",
|
|
194
|
+
valign: "middle",
|
|
195
|
+
});
|
|
196
|
+
const textProfile = slantProfile(k, rawTextProfile, p.italic ? 0.2126 : 0);
|
|
197
|
+
const backingProfile = offsetGlyphContours(
|
|
198
|
+
k,
|
|
199
|
+
textProfile,
|
|
200
|
+
p.border + (p.bold ? 0.4 : 0),
|
|
201
|
+
);
|
|
202
|
+
const topRound = Math.min(0.3, p.backingThickness / 2 - 0.1);
|
|
203
|
+
const solid = p.backingStyle === "roundAll"
|
|
204
|
+
? backingProfile.extrude({ h: p.backingThickness }).roundAll(topRound)
|
|
205
|
+
: filletGlyphRegions(k, backingProfile, p.backingThickness, topRound);
|
|
206
|
+
return solid.label("Letter contour backing");
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
lettering: {
|
|
210
|
+
label: "Editable lettering",
|
|
211
|
+
views: ["lettering", "assembly"],
|
|
212
|
+
display: { color: 0xfdd835 },
|
|
213
|
+
build: (k, p, d) => {
|
|
214
|
+
const rawLetteringProfile = k.text2d(d.textValue, {
|
|
215
|
+
size: p.textSize,
|
|
216
|
+
...(d.fontUrl ? { font: d.fontUrl } : {}),
|
|
217
|
+
align: "center",
|
|
218
|
+
valign: "middle",
|
|
219
|
+
});
|
|
220
|
+
const letteringProfile = styleProfile(k, rawLetteringProfile, p.bold, p.italic);
|
|
221
|
+
const topRound = Math.min(0.3, p.letterThickness / 2 - 0.1, p.textSize * 0.015);
|
|
222
|
+
return filletGlyphRegions(k, letteringProfile, p.letterThickness, topRound)
|
|
223
|
+
.at([0, 0, d.topZ])
|
|
224
|
+
.label("Raised text");
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
views: {
|
|
230
|
+
backing: { label: "Lower layer" },
|
|
231
|
+
lettering: { label: "Lettering" },
|
|
232
|
+
assembly: { label: "Layered label" },
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
verify: {
|
|
236
|
+
process: "fdm-pla",
|
|
237
|
+
expect: {
|
|
238
|
+
backing: {
|
|
239
|
+
watertight: true,
|
|
240
|
+
bbox: "<=[*,*,8]",
|
|
241
|
+
},
|
|
242
|
+
lettering: {
|
|
243
|
+
watertight: true,
|
|
244
|
+
bbox: "<=[*,*,14]",
|
|
245
|
+
},
|
|
246
|
+
_view: {
|
|
247
|
+
overlaps: 0,
|
|
248
|
+
contacts: [["backing", "lettering"]],
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
};
|