partforge 0.65.0 → 0.66.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/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -2241,6 +2241,16 @@ melt away, holes narrower than `2r` seal shut. That makes it ideal for
|
|
|
2241
2241
|
"soften this whole organic part" and wrong for parts where a specific edge
|
|
2242
2242
|
must stay sharp (use `fillet` with a selector for that).
|
|
2243
2243
|
|
|
2244
|
+
**Cost warning: roundAll is morphological (three Minkowski passes) and its
|
|
2245
|
+
runtime grows steeply with the solid's triangle count.** On a simple bracket it
|
|
2246
|
+
is interactive; on a complex outline it is not — measured 30+ seconds on a
|
|
2247
|
+
text-outline extrusion whose `fillet({ r, edges: { inPlane: "XY", at: h } })`
|
|
2248
|
+
takes well under a second. **Never use roundAll just to round the top or bottom
|
|
2249
|
+
rim of an extrusion** — that is exactly what the `inPlane` fillet selector is
|
|
2250
|
+
for, at a tiny fraction of the cost. Reach for roundAll only when the design
|
|
2251
|
+
genuinely needs every edge softened at once and the solid is geometrically
|
|
2252
|
+
simple.
|
|
2253
|
+
|
|
2244
2254
|
Rules of thumb:
|
|
2245
2255
|
|
|
2246
2256
|
- Keep `r` under half your thinnest wall unless you *want* melting.
|
package/package.json
CHANGED
|
@@ -72,7 +72,12 @@ export function creasedNormals(g, { policies = null, featureLabels = null } = {}
|
|
|
72
72
|
const v = tris[t * 3 + k];
|
|
73
73
|
let nx = 0, ny = 0, nz = 0;
|
|
74
74
|
for (const t2 of incident.get(remap[v])) {
|
|
75
|
-
|
|
75
|
+
// different cut surface → hard, EXCEPT two blend surfaces (boundaryLines on
|
|
76
|
+
// both): one band is many tool surfaces continuing each other tangentially,
|
|
77
|
+
// and hard normals at their handovers would put lighting seams along a band
|
|
78
|
+
// that used to shade as one re-originaled surface
|
|
79
|
+
if (triOID[t2] !== oid &&
|
|
80
|
+
!(polFor(triOID[t2]).boundaryLines && polFor(oid).boundaryLines)) continue;
|
|
76
81
|
if (fn[t2 * 3] * fx + fn[t2 * 3 + 1] * fy + fn[t2 * 3 + 2] * fz < sharpCos) continue; // sharp same-surface edge → hard
|
|
77
82
|
nx += fn[t2 * 3]; ny += fn[t2 * 3 + 1]; nz += fn[t2 * 3 + 2];
|
|
78
83
|
}
|
|
@@ -105,16 +110,32 @@ export function creasedNormals(g, { policies = null, featureLabels = null } = {}
|
|
|
105
110
|
// are invisible but whose long boundary edges would otherwise draw at full
|
|
106
111
|
// line weight. A triangle thinner than MIN_EDGE cannot be seen, so its
|
|
107
112
|
// edges are noise by definition — same threshold the segment filter uses.
|
|
108
|
-
|
|
113
|
+
const sameOID = triOID[prev] === triOID[t];
|
|
114
|
+
// Blend boundary: a cross-surface seam with a BLEND policy on EXACTLY one side
|
|
115
|
+
// is the start/end of a fillet band — draw it even when tangent (the band's
|
|
116
|
+
// extent must be readable). It also bypasses the thin-triangle gate below:
|
|
117
|
+
// simplify() cannot collapse the boolean's slivers ACROSS the blend/base run
|
|
118
|
+
// boundary, so half the seam's edges border a sliver, and gating them dashed
|
|
119
|
+
// the ring — those slivers ride within microns OF the seam curve, so their
|
|
120
|
+
// long edges redraw it rather than add noise (the MIN_EDGE segment-length
|
|
121
|
+
// filter still drops the short ones).
|
|
122
|
+
const boundary = !sameOID &&
|
|
123
|
+
!!polFor(triOID[prev]).boundaryLines !== !!polFor(triOID[t]).boundaryLines;
|
|
124
|
+
if (!boundary && (thin[prev] < MIN_EDGE || thin[t] < MIN_EDGE)) continue;
|
|
109
125
|
const dot = fn[prev * 3] * fn[t * 3] + fn[prev * 3 + 1] * fn[t * 3 + 1] + fn[prev * 3 + 2] * fn[t * 3 + 2];
|
|
110
126
|
// A multi-hole cap triangulation can contain an opposite-wound bridge:
|
|
111
127
|
// its two normals disagree by 180 degrees even though both triangles lie
|
|
112
128
|
// in the same plane. Gate on the unoriented supporting-plane angle first
|
|
113
129
|
// so that triangulation seam never becomes a feature line.
|
|
114
130
|
const bends = Math.abs(dot) < COPLANAR_COS;
|
|
115
|
-
|
|
131
|
+
// Two blend surfaces (a handover along one band) line-draw like ONE surface:
|
|
132
|
+
// the 35° same-surface bar, not the 5° cut-seam bar — a band is many tool
|
|
133
|
+
// surfaces whose overshoot crossings bend a few degrees by construction.
|
|
134
|
+
const bothBlend = !sameOID &&
|
|
135
|
+
!!polFor(triOID[prev]).boundaryLines && !!polFor(triOID[t]).boundaryLines;
|
|
136
|
+
const hard = boundary || (bends && (sameOID || bothBlend
|
|
116
137
|
? polFor(triOID[t]).sameSurfaceLines && dot < cosFor(triOID[t])
|
|
117
|
-
: true);
|
|
138
|
+
: true));
|
|
118
139
|
if (hard) {
|
|
119
140
|
const ai = i * np, bj = j * np;
|
|
120
141
|
const dx = vp[ai] - vp[bj], dy = vp[ai + 1] - vp[bj + 1], dz = vp[ai + 2] - vp[bj + 2];
|
|
@@ -13,7 +13,7 @@ import { offsetRegions } from "./contour-offset.js";
|
|
|
13
13
|
import { finishKernel } from "./kernel-front.js";
|
|
14
14
|
import { meshToStl } from "./mesh-stl.js";
|
|
15
15
|
import { creasedNormals } from "./creased-normals.js";
|
|
16
|
-
import { loftShadingPolicy, SMOOTH } from "./shading-policy.js";
|
|
16
|
+
import { loftShadingPolicy, SMOOTH, BLEND } from "./shading-policy.js";
|
|
17
17
|
import { meshFillet, meshChamfer, UnsupportedEdgeError } from "./mesh-fillet.js";
|
|
18
18
|
import { meshRoundAll } from "./mesh-roundall.js";
|
|
19
19
|
import { KernelCapabilityError } from "./errors.js";
|
|
@@ -46,7 +46,12 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
46
46
|
// (manifests as "Out of bounds memory access").
|
|
47
47
|
const tracked = [];
|
|
48
48
|
const T = (obj) => { tracked.push(obj); return obj; };
|
|
49
|
-
|
|
49
|
+
// n-ary union in ONE batch op. This must never be a pairwise reduce: a hundred-tool
|
|
50
|
+
// cutAll (a text rim's fillet) reduced sequentially runs a hundred booleans on an
|
|
51
|
+
// ever-growing intermediate — measured 12 s and a 4 GB peak on a lettering part —
|
|
52
|
+
// while Manifold's own batch operator evaluates the same union as a balanced tree.
|
|
53
|
+
// A one-solid "union" returns the operand's own Manifold untouched (see union below).
|
|
54
|
+
const unionRaw = (ms) => (ms.length === 1 ? ms[0] : T(Manifold.union(ms)));
|
|
50
55
|
|
|
51
56
|
const cache = createSolidCache();
|
|
52
57
|
const featureLabels = new Map(); // originalID -> label string (grows per label(); tiny)
|
|
@@ -118,16 +123,45 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
118
123
|
// degenerate normals poison the crease pass into drawing phantom edge lines).
|
|
119
124
|
// Unsupported edge classes (helical edges, varying dihedral, …) surface as
|
|
120
125
|
// KernelCapabilityError so the framework reroutes that sub-part to OCCT.
|
|
121
|
-
// simplify() will not collapse triangles across run (originalID) boundaries,
|
|
122
|
-
// and the boolean's sliver triangles sit exactly on them — so the result is
|
|
123
|
-
// re-originaled first. That folds every surface into one fresh original,
|
|
124
|
-
// which is also the documented B-rep semantic: fillet/chamfer produce new
|
|
125
|
-
// surfaces, so feature-label attribution downstream of the op uses the
|
|
126
|
-
// fallback path (AUTHORING-PARTS.md), and the blend shades SMOOTH.
|
|
127
126
|
const SIMPLIFY_EPS = 1e-4; // 0.1 µm — must exceed the boolean's sliver widths (~2e-5)
|
|
128
|
-
|
|
127
|
+
// Debris sweep: where blend tools graze each other or a flank near-tangentially, the
|
|
128
|
+
// boolean can strand a CLOSED femto-component (measured ~1e-8 mm³, 4 triangles) that
|
|
129
|
+
// simplify() cannot remove — it collapses edges, never whole components — and that
|
|
130
|
+
// flips the result's genus/decompose count. Anything below DEBRIS_VOL is two orders
|
|
131
|
+
// under the smallest feature the tessellation itself can express, and three under
|
|
132
|
+
// anything printable, so dropping it can never erase real geometry.
|
|
133
|
+
const DEBRIS_VOL = 1e-6; // mm³
|
|
134
|
+
const dropDebris = (m) => {
|
|
135
|
+
const parts = m.decompose();
|
|
136
|
+
if (parts.length <= 1) { for (const p of parts) T(p); return m; }
|
|
137
|
+
const kept = [];
|
|
138
|
+
for (const p of parts) { T(p); if (p.volume() >= DEBRIS_VOL) kept.push(p); }
|
|
139
|
+
return kept.length === parts.length ? m : T(Manifold.compose(kept));
|
|
140
|
+
};
|
|
141
|
+
// Blend surfaces KEEP their originalIDs, and every id the op introduced is
|
|
142
|
+
// registered with the BLEND policy — that is what lets creased-normals draw the
|
|
143
|
+
// band's start/end (a tangent blend↔base seam draws regardless of bend) while
|
|
144
|
+
// blend↔blend handovers along one band stay invisible. This replaced a blanket
|
|
145
|
+
// asOriginal(): folding the result into one fresh original made simplify able to
|
|
146
|
+
// collapse boundary slivers, but it also erased the one distinction the boundary
|
|
147
|
+
// lines need. The trade is stated honestly: simplify() cannot collapse across the
|
|
148
|
+
// surviving run boundaries, so seam-adjacent slivers persist as triangles — they
|
|
149
|
+
// are sub-MIN_EDGE thin, so the line pass gates them, and closed sliver DEBRIS is
|
|
150
|
+
// swept above; the visible cost is a modestly larger mesh, bounded by
|
|
151
|
+
// mesh-fillet-perf.test.js. Shading is unchanged (BLEND shades like SMOOTH), and
|
|
152
|
+
// label() on a blend result already handles a mixed-original mesh (the majority
|
|
153
|
+
// vote below).
|
|
154
|
+
const runOids = (mm) => { const g = mm.getMesh(); const s = new Set(g.runOriginalID); g.delete?.(); return s; };
|
|
155
|
+
const meshCadOp = (op, baseM, run) => {
|
|
129
156
|
try {
|
|
130
|
-
|
|
157
|
+
const raw = run()._m;
|
|
158
|
+
const baseOids = runOids(baseM);
|
|
159
|
+
for (const oid of runOids(raw)) if (!baseOids.has(oid)) oidPolicies.set(oid, BLEND);
|
|
160
|
+
// debris sweep AFTER simplify: the boolean's own femto-components are joined by
|
|
161
|
+
// ones simplify() itself pinches off while collapsing seam slivers (measured:
|
|
162
|
+
// 4-triangle atto-scale bubbles, one with negative volume, after a rim fillet
|
|
163
|
+
// over vertical-fillet bands) — sweeping first missed those
|
|
164
|
+
return dropDebris(T(raw.simplify(SIMPLIFY_EPS)));
|
|
131
165
|
} catch (e) {
|
|
132
166
|
if (e instanceof UnsupportedEdgeError) throw new KernelCapabilityError(`${op}: ${e.message}`);
|
|
133
167
|
throw e;
|
|
@@ -142,13 +176,13 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
142
176
|
if (typeof selector === "function") throw new KernelCapabilityError("fillet: function selectors need the OCCT backend");
|
|
143
177
|
if (r === 0) return wrap(m, hash); // contract: zero magnitude is the identity
|
|
144
178
|
return cached(h("fillet", hash, r, selector ?? null, segs), () =>
|
|
145
|
-
meshCadOp("fillet", () => meshFillet(kernel, wrap(m, hash), { r, edges: selector, segs })));
|
|
179
|
+
meshCadOp("fillet", m, () => meshFillet(kernel, wrap(m, hash), { r, edges: selector, segs })));
|
|
146
180
|
},
|
|
147
181
|
chamfer: (d, selector) => {
|
|
148
182
|
if (typeof selector === "function") throw new KernelCapabilityError("chamfer: function selectors need the OCCT backend");
|
|
149
183
|
if (d === 0) return wrap(m, hash);
|
|
150
184
|
return cached(h("chamfer", hash, d, selector ?? null, segs), () =>
|
|
151
|
-
meshCadOp("chamfer", () => meshChamfer(kernel, wrap(m, hash), { d, edges: selector, segs })));
|
|
185
|
+
meshCadOp("chamfer", m, () => meshChamfer(kernel, wrap(m, hash), { d, edges: selector, segs })));
|
|
152
186
|
},
|
|
153
187
|
roundAll: (r) => {
|
|
154
188
|
if (r === 0) return wrap(m, hash); // contract: zero magnitude is the identity
|
|
@@ -158,8 +192,10 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
158
192
|
// tessellation, and so the result, is tier-dependent.
|
|
159
193
|
return cached(h("roundAll", hash, r, quality), () => T(meshRoundAll(wasm, m, r, quality)));
|
|
160
194
|
},
|
|
195
|
+
// batch difference: first minus the union of the rest, evaluated as one boolean
|
|
196
|
+
// tree — no materialized intermediate union (the unionRaw memory note applies)
|
|
161
197
|
cutAll: (tools) => cached(h("cutAll", hash, tools.map((t) => t._hash)),
|
|
162
|
-
() => T(
|
|
198
|
+
() => T(Manifold.difference([m, ...tools.map((t) => t._m)]))),
|
|
163
199
|
intersect: (t) => cached(h("intersect", hash, t._hash), () => T(m.intersect(t._m))),
|
|
164
200
|
union: (t) => cached(h("union", [hash, t._hash]), () => unionRaw([m, t._m])),
|
|
165
201
|
clone: () => wrap(m, hash),
|
|
@@ -171,6 +207,49 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
171
207
|
label: (name) => {
|
|
172
208
|
const lh = h("label", hash, name);
|
|
173
209
|
return cache.lookup(lh, () => {
|
|
210
|
+
// Blend-aware re-stamp. If this mesh carries blend surfaces (the boundaryLines
|
|
211
|
+
// policy), one asOriginal() would fold band and base into a single surface and
|
|
212
|
+
// erase the band-boundary overlay on exactly the solids parts label — every
|
|
213
|
+
// real part labels its top-level solids. Re-stamp as TWO reserved ids instead,
|
|
214
|
+
// base and blend: the label covers both (same string → one feature entry), the
|
|
215
|
+
// distinction survives labeling and every later boolean, and reserved ids are
|
|
216
|
+
// fresh so cached-solid reuse under another label cannot collide — the same
|
|
217
|
+
// guarantee asOriginal() gives the plain path below.
|
|
218
|
+
const g0 = m.getMesh();
|
|
219
|
+
const isBlend = (oid) => !!oidPolicies.get(oid)?.boundaryLines;
|
|
220
|
+
const oids0 = new Set(g0.runOriginalID);
|
|
221
|
+
if ([...oids0].some(isBlend)) {
|
|
222
|
+
const baseId = Manifold.reserveIDs(2), blendId = baseId + 1;
|
|
223
|
+
// base-group policy: the same triangle-weighted majority vote as the plain
|
|
224
|
+
// path, but over the NON-blend runs only — blend runs would elect BLEND for
|
|
225
|
+
// the base group and flag BOTH sides of every boundary seam, which is
|
|
226
|
+
// exactly the no-line state this path exists to avoid.
|
|
227
|
+
const ri = g0.runIndex, roid = g0.runOriginalID;
|
|
228
|
+
const weightByKey = new Map();
|
|
229
|
+
let bestWeight = -1, basePol;
|
|
230
|
+
for (let r = 0; r < roid.length; r++) {
|
|
231
|
+
if (isBlend(roid[r])) continue;
|
|
232
|
+
const pol = oidPolicies.get(roid[r]) ?? SMOOTH;
|
|
233
|
+
const key = `${pol.creaseAngle}/${pol.sameSurfaceLines}/${!!pol.boundaryLines}`;
|
|
234
|
+
const weight = (weightByKey.get(key) || 0) + (ri[r + 1] / 3 - ri[r] / 3);
|
|
235
|
+
weightByKey.set(key, weight);
|
|
236
|
+
const better = weight > bestWeight || (weight === bestWeight && !pol.sameSurfaceLines && basePol?.sameSurfaceLines);
|
|
237
|
+
if (better) { bestWeight = weight; basePol = pol; }
|
|
238
|
+
}
|
|
239
|
+
g0.runOriginalID = Uint32Array.from(roid, (o2) => (isBlend(o2) ? blendId : baseId));
|
|
240
|
+
const o = T(new Manifold(g0));
|
|
241
|
+
g0.delete?.();
|
|
242
|
+
featureLabels.set(baseId, name);
|
|
243
|
+
featureLabels.set(blendId, name);
|
|
244
|
+
oidPolicies.set(blendId, BLEND);
|
|
245
|
+
if (basePol !== undefined) oidPolicies.set(baseId, basePol);
|
|
246
|
+
return { value: wrap(o, lh), pin: o, dispose: () => {
|
|
247
|
+
featureLabels.delete(baseId); featureLabels.delete(blendId);
|
|
248
|
+
oidPolicies.delete(baseId); oidPolicies.delete(blendId);
|
|
249
|
+
o.delete?.();
|
|
250
|
+
} };
|
|
251
|
+
}
|
|
252
|
+
g0.delete?.();
|
|
174
253
|
const prevId = typeof m.originalID === "function" ? m.originalID() : -1;
|
|
175
254
|
const o = T(m.asOriginal());
|
|
176
255
|
const id = o.originalID();
|
|
@@ -210,7 +289,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
210
289
|
let bestWeight = -1, bestPol;
|
|
211
290
|
for (let r = 0; r < roid.length; r++) {
|
|
212
291
|
const pol = oidPolicies.get(roid[r]) ?? SMOOTH;
|
|
213
|
-
const key = `${pol.creaseAngle}/${pol.sameSurfaceLines}`;
|
|
292
|
+
const key = `${pol.creaseAngle}/${pol.sameSurfaceLines}/${!!pol.boundaryLines}`;
|
|
214
293
|
const weight = (weightByKey.get(key) || 0) + (ri[r + 1] / 3 - ri[r] / 3);
|
|
215
294
|
weightByKey.set(key, weight);
|
|
216
295
|
const better = weight > bestWeight || (weight === bestWeight && !pol.sameSurfaceLines && bestPol?.sameSurfaceLines);
|
|
@@ -325,10 +404,14 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
325
404
|
// (closed/cornerRadius) so a shape change is a fresh node and an identical rebuild hits.
|
|
326
405
|
sweep: (profile, path, opts = {}) => cached(h("sweep", profile, path, opts), () => T(sweepMesh(wasm, profile, path, opts))),
|
|
327
406
|
helixSweptTube: (o) => cached(h("helixSweptTube", o, tube), () => T(helixTube(wasm, { ...o, ...tube }))),
|
|
328
|
-
|
|
407
|
+
// opts.segs may only COARSEN below the kernel's quality (min), never exceed it:
|
|
408
|
+
// callers use it where a small feature's sagitta bound needs fewer facets than
|
|
409
|
+
// the per-circle quality would spend (mesh-fillet's free-standing corner arcs).
|
|
410
|
+
revolve: (pts, { degrees = 360, segs: segsOverride } = {}) => {
|
|
411
|
+
const density = Math.min(segs, segsOverride ?? segs);
|
|
329
412
|
if (pts && pts._shape2d)
|
|
330
|
-
return cached(h("revolve", pts._hash, degrees,
|
|
331
|
-
return cached(h("revolve", pts, degrees,
|
|
413
|
+
return cached(h("revolve", pts._hash, degrees, density), () => T(csFor(pts).revolve(density, degrees)));
|
|
414
|
+
return cached(h("revolve", pts, degrees, density), () => T(Manifold.revolve([pts], density, degrees)));
|
|
332
415
|
},
|
|
333
416
|
// A one-solid union is an identity — no new WASM / cache entry (avoids double-free):
|
|
334
417
|
// unionRaw's reduce returns the operand's own Manifold untouched, so caching it
|
|
@@ -16,11 +16,19 @@
|
|
|
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
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
19
|
+
// Corner treatment: a salient two-chain corner in a common face plane is ROUNDED —
|
|
20
|
+
// a small arc chain (radius ~1.05-1.25× the magnitude) replaces the mitre, its horn
|
|
21
|
+
// block shaves the sharp corner down to band depth, and the band sweeps around with
|
|
22
|
+
// no crease (see roundSalientCorners). The silhouette inside the band rounds by about
|
|
23
|
+
// the blend radius at such corners; the flat shelf the horn leaves at the band's base
|
|
24
|
+
// is the deliberate residue. Corners keep their mitre — today's and OCCT's behavior,
|
|
25
|
+
// a real crease the feature-line overlay honestly draws — when they are REFLEX (the
|
|
26
|
+
// ball cannot reach in), or too tight to host the setback (glyph-scale features
|
|
27
|
+
// smaller than ~3× the magnitude), or when three or more chains meet (the spherical
|
|
28
|
+
// cornerPatches below own the orthogonal three-chain case).
|
|
29
|
+
//
|
|
30
|
+
// Known limits (documented, not bugs): radius feasibility is the caller's job (clamp
|
|
31
|
+
// like filleted-box.js does — an oversized radius self-intersects the cutters).
|
|
24
32
|
//
|
|
25
33
|
// Selector object mirrors edge-selector.js semantics ({dir, inPlane, at, near});
|
|
26
34
|
// `dir` only ever matches straight chains, like replicad's inDirection.
|
|
@@ -37,6 +45,24 @@ export class UnsupportedEdgeError extends Error {
|
|
|
37
45
|
constructor(message) { super(message); this.name = "UnsupportedEdgeError"; }
|
|
38
46
|
}
|
|
39
47
|
|
|
48
|
+
// Blend-band tessellation density: enough facets to keep the chord sagitta invisible,
|
|
49
|
+
// never more. The kernel's `segs` is a per-circle quality knob sized for part-scale
|
|
50
|
+
// circles; spending it on a blend of radius r tessellates a 0.5 mm fillet to 0.2 µm
|
|
51
|
+
// sagitta at preview quality — and a text rim's hundred-tool boolean then carries ~4×
|
|
52
|
+
// the triangles it needs (measured 12 s / 4 GB on a lettering part before this cap).
|
|
53
|
+
// BLEND_SAG (2 µm) is finer than preview quality's own ~4 µm sagitta at part scale;
|
|
54
|
+
// the 0.02·r term keeps micro-blends sane, and the floor of 12 keeps every facet
|
|
55
|
+
// angle (≤30°) under the viewer's 35° same-surface crease threshold.
|
|
56
|
+
const BLEND_SAG = 2e-3; // mm — max chord sagitta of a blend cross-section
|
|
57
|
+
function blendSegs(segs, r) {
|
|
58
|
+
const s = Math.min(BLEND_SAG, 0.02 * r);
|
|
59
|
+
return Math.min(segs, Math.max(12, Math.ceil(Math.PI / Math.acos(1 - s / r))));
|
|
60
|
+
}
|
|
61
|
+
// One derivation for a synthetic corner arc's angular density, shared by revolveTool
|
|
62
|
+
// (which sweeps at it) and cornerHornTool (whose apothem bound below depends on it) —
|
|
63
|
+
// the horn's containment proof only holds if both compute the same number.
|
|
64
|
+
const cornerArcSegs = (segs, R, magnitude) => blendSegs(segs, R + magnitude);
|
|
65
|
+
|
|
40
66
|
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
41
67
|
const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
|
42
68
|
const scl = (a, s) => [a[0] * s, a[1] * s, a[2] * s];
|
|
@@ -242,7 +268,19 @@ function stitchPlanarChains(chains) {
|
|
|
242
268
|
while (progress) {
|
|
243
269
|
progress = false;
|
|
244
270
|
outer: for (let i = 0; i < open.length; i++) {
|
|
245
|
-
|
|
271
|
+
let a = open[i];
|
|
272
|
+
// Orientations: b forward/reversed against a's END covers end-start and end-end;
|
|
273
|
+
// the ordered (j,i) pass covers start-end. START-START needs a itself reversed —
|
|
274
|
+
// without this clause two chains seeded outward from the same junction vertex
|
|
275
|
+
// (the edge walk picks its seeds by graph order, not geometry) never stitch, and
|
|
276
|
+
// their tools overshoot tangentially into each other at that junction.
|
|
277
|
+
if (open.some((b, j) => j !== i && compatible(a, b) &&
|
|
278
|
+
(key(b.points[0]) === key(a.points[0]) || key(b.points[b.points.length - 1]) === key(a.points[0]))) &&
|
|
279
|
+
!open.some((b, j) => j !== i && compatible(a, b) &&
|
|
280
|
+
(key(b.points[0]) === key(a.points[a.points.length - 1]) || key(b.points[b.points.length - 1]) === key(a.points[a.points.length - 1])))) {
|
|
281
|
+
a = rev(a);
|
|
282
|
+
}
|
|
283
|
+
const aEnd = key(a.points[a.points.length - 1]);
|
|
246
284
|
for (let j = 0; j < open.length; j++) {
|
|
247
285
|
if (i === j || !compatible(a, open[j])) continue;
|
|
248
286
|
let b = open[j];
|
|
@@ -359,11 +397,39 @@ function fitArcChain(members, points, convex, closed) {
|
|
|
359
397
|
const alpha = (l2 * (l1 - c12)) / det, beta = (l1 * (l2 - c12)) / det;
|
|
360
398
|
const O = add(p0, add(scl(e1, alpha), scl(e2, beta)));
|
|
361
399
|
const R = len(sub(p0, O));
|
|
362
|
-
|
|
400
|
+
// The fit tolerance is ABSOLUTE and tight (2 µm) on purpose, sandwiched from both
|
|
401
|
+
// sides. Below: it must ACCEPT this module's own blend rims — profile2D's area-exact
|
|
402
|
+
// bump parks interior arc vertices up to r·θ²/12 ≈ 1.3 µm off the true circle (the
|
|
403
|
+
// sagitta bound caps θ so that ceiling is density-independent), and a true revolved
|
|
404
|
+
// rim's float32 quantization is far under that. Above: it must REJECT an offset
|
|
405
|
+
// outline that merely APPROXIMATES a circle after simplify() — those deviate by
|
|
406
|
+
// several microns, and the revolve tool follows the FITTED circle, so accepting one
|
|
407
|
+
// turns every real deviation into tangent-seam jitter along the whole run (measured:
|
|
408
|
+
// a label backing drew ~450 band-edge lines from two accepted pseudo-arcs). Rejected
|
|
409
|
+
// rims fall through to the planar-path rescue, whose sweep follows the true polyline
|
|
410
|
+
// exactly. The old max(1e-3, 1e-3·R) relative term is what let the pseudo-arcs in.
|
|
411
|
+
const rtol = 2e-3;
|
|
363
412
|
for (const p of points) {
|
|
364
413
|
if (Math.abs(len(sub(p, O)) - R) > rtol) return bad("edge curve is not circular");
|
|
365
414
|
if (Math.abs(dot(sub(p, O), w)) > rtol) return bad("edge curve is not planar");
|
|
366
415
|
}
|
|
416
|
+
// Chord-dip gate: the wall facets hang on these same points, so the deepest chord
|
|
417
|
+
// midpoint below the fitted circle measures how coarse the flank tessellation
|
|
418
|
+
// really is. The revolve tool is the right instrument only for kernel-quality
|
|
419
|
+
// surfaces of revolution — its tangent extension chases facets a few microns deep.
|
|
420
|
+
// A rim whose facets dip an order deeper (a polygonal prism, a coarse offset
|
|
421
|
+
// outline) must blend along its own polyline instead: the planar rescue's sweep
|
|
422
|
+
// makes station-exact contact per facet, where a revolve's round tail can only
|
|
423
|
+
// graze a deep flat facet (measured: 24-gon rim, 188 band lines as an arc, zero as
|
|
424
|
+
// a planar chain). Bound: 3× the dip a DEFAULT_SEGS-quality wall would have, plus
|
|
425
|
+
// the fit tolerance both sides of the chord ride on.
|
|
426
|
+
let dip = 0;
|
|
427
|
+
for (let i = 0; i + 1 < points.length; i++) {
|
|
428
|
+
const q = sub(scl(add(points[i], points[i + 1]), 0.5), O);
|
|
429
|
+
dip = Math.max(dip, R - len(sub(q, scl(w, dot(q, w)))));
|
|
430
|
+
}
|
|
431
|
+
if (dip > 3 * R * (1 - Math.cos(Math.PI / DEFAULT_SEGS)) + 2 * rtol)
|
|
432
|
+
return bad("edge polyline is coarser than a kernel-quality surface of revolution");
|
|
367
433
|
// frame: azimuth 0 at the first point; flip w so azimuths increase along the run
|
|
368
434
|
const u0 = norm(sub(points[0], O));
|
|
369
435
|
let v0 = cross(w, u0);
|
|
@@ -515,17 +581,28 @@ function profile2D({ P, n1, n2, magnitude, mode, convex, segs, ext = 0 }) {
|
|
|
515
581
|
// plane-on-plane, which the kernel resolves exactly.
|
|
516
582
|
const s2 = Math.sign(phi) || 1, span = Math.abs(phi);
|
|
517
583
|
const nArc = Math.max(2, Math.ceil((span / (2 * Math.PI)) * segs));
|
|
584
|
+
// Area-exact tessellation: an inscribed chord polygon under-sweeps the ball arc by a
|
|
585
|
+
// first-order-in-facet-angle area deficit whose RELATIVE size is radius-independent
|
|
586
|
+
// (~0.23/n² of the blend cross-section) — at the sagitta-bounded density above it
|
|
587
|
+
// would bias every blend's volume by ~0.2-0.3%. Interior vertices sit at
|
|
588
|
+
// r·√(θ/sinθ), the radius at which the chord polygon sweeps exactly the arc's area
|
|
589
|
+
// (a micron-scale outward nudge that is material-safe in both boolean directions:
|
|
590
|
+
// a cutter bites a hair deeper mid-chord, a filler overlaps a hair more). The two
|
|
591
|
+
// END vertices stay exactly on the ball — they are the seam with the flanks.
|
|
592
|
+
const th = (span + 2 * ext) / nArc;
|
|
593
|
+
const rEq = r * Math.sqrt(th / Math.sin(th));
|
|
518
594
|
const pts = [corner];
|
|
519
595
|
for (let i = 0; i <= nArc; i++) {
|
|
520
596
|
const nv = rot2(n1, s2 * (-ext + ((span + 2 * ext) * i) / nArc));
|
|
521
|
-
|
|
597
|
+
const ri = i === 0 || i === nArc ? r : rEq;
|
|
598
|
+
pts.push([C[0] + sgn * ri * nv[0], C[1] + sgn * ri * nv[1]]);
|
|
522
599
|
}
|
|
523
600
|
return pts;
|
|
524
601
|
}
|
|
525
602
|
|
|
526
603
|
// ---------------------------------------------------------------------------
|
|
527
604
|
// Cutter/filler solids.
|
|
528
|
-
function prismTool(k, chain, magnitude, mode, segs) {
|
|
605
|
+
function prismTool(k, chain, magnitude, mode, segs, pSegs = segs) {
|
|
529
606
|
const { a, dir: e, length, n1, n2, convex } = chain;
|
|
530
607
|
// pose rotation Z → e; the 2D basis is the image of X,Y under the SAME rotation
|
|
531
608
|
const axisRaw = cross([0, 0, 1], e);
|
|
@@ -536,10 +613,12 @@ function prismTool(k, chain, magnitude, mode, segs) {
|
|
|
536
613
|
const u = axis ? rotVec([1, 0, 0], axis, theta) : [1, 0, 0];
|
|
537
614
|
const v = axis ? rotVec([0, 1, 0], axis, theta) : [0, 1, 0];
|
|
538
615
|
const p2 = (w) => [dot(w, u), dot(w, v)];
|
|
539
|
-
const poly = profile2D({ P: [0, 0], n1: p2(n1), n2: p2(n2), magnitude, mode, convex, segs });
|
|
616
|
+
const poly = profile2D({ P: [0, 0], n1: p2(n1), n2: p2(n2), magnitude, mode, convex, segs: pSegs });
|
|
540
617
|
// convex cutters overshoot the edge ends (sticking outside the solid is
|
|
541
|
-
// harmless when subtracting
|
|
542
|
-
//
|
|
618
|
+
// harmless when subtracting, and at a rounded corner the overshoot continues
|
|
619
|
+
// tangentially into the arc tool, like a stadium rim's prisms always have);
|
|
620
|
+
// concave fillers must end flush — any overshoot would bulge outside the part
|
|
621
|
+
// when unioned
|
|
543
622
|
const over = convex ? Math.max(1e-3, 0.05 * magnitude) : 0;
|
|
544
623
|
let tool = k.loft(
|
|
545
624
|
[{ polygon: poly, z: -over }, { polygon: poly, z: length + over }],
|
|
@@ -549,7 +628,11 @@ function prismTool(k, chain, magnitude, mode, segs) {
|
|
|
549
628
|
return tool.translate(a);
|
|
550
629
|
}
|
|
551
630
|
|
|
552
|
-
|
|
631
|
+
// `segs` is the KERNEL quality — it sizes the flank-facet guards (sag/ext) and the
|
|
632
|
+
// closed-revolve dephase, which are about matching the neighboring tessellation and
|
|
633
|
+
// must not follow the blend cap. `pSegs` is the sagitta-bounded density for the blend
|
|
634
|
+
// cross-section itself (blendSegs above).
|
|
635
|
+
function revolveTool(k, chain, magnitude, mode, segs, pSegs = segs) {
|
|
553
636
|
const { O, w, u0, v0, R, span, closed, n1, n2, convex } = chain;
|
|
554
637
|
// Seam-grazing guard. The edge circle passes through the flank tessellation's
|
|
555
638
|
// VERTICES (circumradius) while its facets sit at the apothem, so a revolved
|
|
@@ -558,14 +641,33 @@ function revolveTool(k, chain, magnitude, mode, segs) {
|
|
|
558
641
|
// cannot always collapse them. `sag` is that facet sagitta plus a roundoff pad
|
|
559
642
|
// bounded relative to the requested feature, so tiny blends never inherit a
|
|
560
643
|
// fixed allowance larger than their own cross-section.
|
|
561
|
-
|
|
644
|
+
//
|
|
645
|
+
// The kernel-density term is an ASSUMPTION about the flank, and it is wrong
|
|
646
|
+
// whenever the wall's facets hang on a polyline coarser than kernel quality —
|
|
647
|
+
// an offset outline, a polygonal prism — or when the rim rides the fit tolerance
|
|
648
|
+
// off the fitted circle. The wall facets hang on the chain's own points, so the
|
|
649
|
+
// real depth is measurable: the deepest chord midpoint below the fitted circle.
|
|
650
|
+
// Where the assumed extension fell short of that, the crossing failed mid-facet
|
|
651
|
+
// and a radial knife-fin of wall survived both cutters, drawing a line along the
|
|
652
|
+
// band (the label-backing bug). A synthetic corner arc measures nothing — its two
|
|
653
|
+
// points span the whole corner, and its flanks are planes, not a tessellation.
|
|
654
|
+
const kernelSag = (R + magnitude) * (1 - Math.cos(Math.PI / segs));
|
|
655
|
+
let dip = 0;
|
|
656
|
+
if (!chain.synthetic) {
|
|
657
|
+
const pts = chain.points;
|
|
658
|
+
for (let i = 0; i + 1 < pts.length; i++) {
|
|
659
|
+
const q = sub(scl(add(pts[i], pts[i + 1]), 0.5), O);
|
|
660
|
+
dip = Math.max(dip, R - len(sub(q, scl(w, dot(q, w)))));
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
const sag = Math.max(kernelSag, dip) + Math.min(2e-4, 0.02 * magnitude);
|
|
562
664
|
// Fillet: size the arc-tail extension to cross the facet planes, but cap it at
|
|
563
665
|
// 0.4 rad. Below the mesh's own facet scale a larger tail wraps around the tiny
|
|
564
666
|
// profile and creates one tunnel per facet; the cap bounds penetration to 8%
|
|
565
667
|
// of the requested radius while the cutter's outside corner still opens into
|
|
566
668
|
// free space.
|
|
567
669
|
const ext = Math.min(0.4, Math.max(0.01, Math.acos(Math.max(-1, 1 - sag / magnitude))));
|
|
568
|
-
let poly = profile2D({ P: [R, 0], n1, n2, magnitude, mode, convex, segs, ext });
|
|
670
|
+
let poly = profile2D({ P: [R, 0], n1, n2, magnitude, mode, convex, segs: pSegs, ext });
|
|
569
671
|
if (mode === "chamfer") {
|
|
570
672
|
// Chamfer: the cone itself is the cutting surface — no tail to extend, so
|
|
571
673
|
// bury the whole profile by `sag` along the material-side bisector instead.
|
|
@@ -588,7 +690,12 @@ function revolveTool(k, chain, magnitude, mode, segs) {
|
|
|
588
690
|
if (area < 0) poly = poly.slice().reverse();
|
|
589
691
|
const ovAng = closed || !convex ? 0 : Math.min(0.15, Math.max(1e-3, (0.05 * magnitude) / R));
|
|
590
692
|
const degrees = closed ? 360 : ((span + 2 * ovAng) * 180) / Math.PI;
|
|
591
|
-
|
|
693
|
+
// A real edge-circle arc keeps the kernel's angular density — its facets interact
|
|
694
|
+
// with the flank's own tessellation of the same circle (the dephase note below).
|
|
695
|
+
// A SYNTHETIC corner arc (cornerArcAt) is free-standing between planes, so its
|
|
696
|
+
// angular density follows the same sagitta bound as the cross-section.
|
|
697
|
+
const aSegs = chain.synthetic ? cornerArcSegs(segs, R, magnitude) : segs;
|
|
698
|
+
let tool = k.revolve(poly, { degrees, segs: aSegs });
|
|
592
699
|
// pose: Z → w, then twist so the revolve's start azimuth (+X) lands on the
|
|
593
700
|
// chain's start direction (backed off by the angular overshoot)
|
|
594
701
|
const startDir = closed ? u0 : add(scl(u0, Math.cos(-ovAng)), scl(v0, Math.sin(-ovAng)));
|
|
@@ -628,7 +735,7 @@ function revolveTool(k, chain, magnitude, mode, segs) {
|
|
|
628
735
|
// watertight). Any residual sweep refusal (float-edge fold the pre-split missed) is
|
|
629
736
|
// converted to UnsupportedEdgeError so the caller reroutes to OCCT instead of failing
|
|
630
737
|
// the build. Returns an ARRAY of tools — one per stretch.
|
|
631
|
-
function planarTool(k, chain, magnitude, mode, segs) {
|
|
738
|
+
function planarTool(k, chain, magnitude, mode, segs, pSegs = segs) {
|
|
632
739
|
const { points, closed, convex, faceN, wallNs } = chain;
|
|
633
740
|
const pts = closed ? points.slice(0, -1) : points; // drop the duplicated closure point
|
|
634
741
|
const m = pts.length;
|
|
@@ -645,15 +752,46 @@ function planarTool(k, chain, magnitude, mode, segs) {
|
|
|
645
752
|
// (0.45 vs 0.5) so the split fires before the sweep would throw. `reach` is a cheap
|
|
646
753
|
// rigid upper bound on the profile's half-width — exact reach needs the profile, the
|
|
647
754
|
// profile needs the stretch, and conservatism here only costs an extra mitred split.
|
|
755
|
+
// Split at a vertex whose miter would fold (fold guard, stricter 0.45 factor so the
|
|
756
|
+
// split fires before the sweep would throw) — and also at any stitched-junction
|
|
757
|
+
// corner sharper than SMOOTH_MAX_DEG, whose miter crease would otherwise exceed the
|
|
758
|
+
// viewer's line threshold and draw across the band. A salient split corner with room
|
|
759
|
+
// for the setback gets a corner ARC (cornerArcAt — the same rounded-corner treatment
|
|
760
|
+
// apply() gives two-chain junctions), the adjoining stretches trimmed to its tangent
|
|
761
|
+
// points; reflex or too-tight splits keep the overshoot mitre.
|
|
648
762
|
const reach = magnitude * 1.5;
|
|
649
|
-
const isBreak = (i) => { // vertex i, with a segment on both sides
|
|
650
|
-
const dIn = segDir[(i - 1 + nSeg) % nSeg], dOut = segDir[i];
|
|
651
|
-
const c = clamp1(dot(dIn, dOut));
|
|
652
|
-
if (c < -1 + 1e-6) return true; // reversal cusp
|
|
653
|
-
return reach * Math.tan(Math.acos(c) / 2) > 0.45 * Math.min(segLen[(i - 1 + nSeg) % nSeg], segLen[i]);
|
|
654
|
-
};
|
|
655
763
|
const breaks = [];
|
|
656
|
-
for (let i = closed ? 0 : 1; i < (closed ? m : m - 1); i++)
|
|
764
|
+
for (let i = closed ? 0 : 1; i < (closed ? m : m - 1); i++) {
|
|
765
|
+
const iIn = (i - 1 + nSeg) % nSeg;
|
|
766
|
+
const c = clamp1(dot(segDir[iIn], segDir[i]));
|
|
767
|
+
const turn = Math.acos(c);
|
|
768
|
+
const fold = c < -1 + 1e-6 || reach * Math.tan(turn / 2) > 0.45 * Math.min(segLen[iIn], segLen[i]);
|
|
769
|
+
const sharp = turn > (SMOOTH_MAX_DEG * Math.PI) / 180;
|
|
770
|
+
if (fold || sharp) breaks.push(i);
|
|
771
|
+
}
|
|
772
|
+
// Corner arcs per break vertex, with each side's setback budget measured along the
|
|
773
|
+
// polyline to the ADJACENT break (or chain end) — a single tessellation segment says
|
|
774
|
+
// nothing about the room a whole smooth stretch offers.
|
|
775
|
+
const cornerArcs = new Map(); // break vertex index → { arc, t }
|
|
776
|
+
if (convex && breaks.length) {
|
|
777
|
+
const segSum = (from, to) => {
|
|
778
|
+
let sum = 0;
|
|
779
|
+
for (let i = from; i < to; i++) sum += segLen[((i % nSeg) + nSeg) % nSeg];
|
|
780
|
+
return sum;
|
|
781
|
+
};
|
|
782
|
+
for (let j = 0; j < breaks.length; j++) {
|
|
783
|
+
const i = breaks[j], iIn = (i - 1 + nSeg) % nSeg;
|
|
784
|
+
const prevB = closed
|
|
785
|
+
? breaks[(j - 1 + breaks.length) % breaks.length] - (j === 0 ? m : 0)
|
|
786
|
+
: (j > 0 ? breaks[j - 1] : 0);
|
|
787
|
+
const nextB = closed
|
|
788
|
+
? breaks[(j + 1) % breaks.length] + (j + 1 === breaks.length ? m : 0)
|
|
789
|
+
: (j + 1 < breaks.length ? breaks[j + 1] : m - 1);
|
|
790
|
+
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);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
657
795
|
|
|
658
796
|
const over = convex ? Math.max(1e-3, 0.05 * magnitude) : 0;
|
|
659
797
|
const overshoot = (path) => {
|
|
@@ -661,6 +799,20 @@ function planarTool(k, chain, magnitude, mode, segs) {
|
|
|
661
799
|
const a = path[0], b = path[1], y = path[path.length - 1], x = path[path.length - 2];
|
|
662
800
|
return [add(a, scl(norm(sub(a, b)), over)), ...path, add(y, scl(norm(sub(y, x)), over))];
|
|
663
801
|
};
|
|
802
|
+
// pull a stretch endpoint back along the polyline by t, toward a corner arc's
|
|
803
|
+
// tangent point — consuming whole segments where the setback spans several
|
|
804
|
+
const pullBack = (path, t, fromEnd) => {
|
|
805
|
+
if (!(t > 0) || path.length < 2) return path;
|
|
806
|
+
let p = fromEnd ? path.slice().reverse() : path.slice();
|
|
807
|
+
let rem = t;
|
|
808
|
+
while (rem > 1e-12 && p.length >= 2) {
|
|
809
|
+
const seg = sub(p[1], p[0]), l = len(seg);
|
|
810
|
+
if (l > rem + 1e-9) { p[0] = add(p[0], scl(seg, rem / l)); break; }
|
|
811
|
+
rem -= l;
|
|
812
|
+
p.shift();
|
|
813
|
+
}
|
|
814
|
+
return fromEnd ? p.reverse() : p;
|
|
815
|
+
};
|
|
664
816
|
|
|
665
817
|
// One tool per stretch. The profile's wall normal is the SEED member's — the segment
|
|
666
818
|
// whose tangent the sweep frame is seeded ⟂ to: the closing segment for a closed loop,
|
|
@@ -679,7 +831,7 @@ function planarTool(k, chain, magnitude, mode, segs) {
|
|
|
679
831
|
const q = [dot(v, N), dot(v, B)], l = Math.hypot(q[0], q[1]) || 1;
|
|
680
832
|
return [q[0] / l, q[1] / l];
|
|
681
833
|
};
|
|
682
|
-
const poly = profile2D({ P: [0, 0], n1: p2(faceN), n2: p2(wallN), magnitude, mode, convex, segs });
|
|
834
|
+
const poly = profile2D({ P: [0, 0], n1: p2(faceN), n2: p2(wallN), magnitude, mode, convex, segs: pSegs });
|
|
683
835
|
return k.sweep(poly, path3D, { closed: isClosed });
|
|
684
836
|
};
|
|
685
837
|
|
|
@@ -693,18 +845,285 @@ function planarTool(k, chain, magnitude, mode, segs) {
|
|
|
693
845
|
? breaks.map((b, j) => [b, breaks[(j + 1) % breaks.length] + (j + 1 === breaks.length ? m : 0)])
|
|
694
846
|
: (breaks.length ? [[0, breaks[0]], ...breaks.map((b, j) => [b, j + 1 < breaks.length ? breaks[j + 1] : m - 1])] : [[0, m - 1]]);
|
|
695
847
|
const tools = [];
|
|
848
|
+
const arcAt = (i) => cornerArcs.get(((i % m) + m) % m);
|
|
696
849
|
for (const [s, e] of bounds) {
|
|
697
850
|
if (e <= s) continue;
|
|
698
|
-
|
|
851
|
+
let path = [];
|
|
699
852
|
for (let i = s; i <= e; i++) path.push(at(i));
|
|
853
|
+
const aS = arcAt(s), aE = arcAt(e);
|
|
854
|
+
if (aS) path = pullBack(path, aS.t, false);
|
|
855
|
+
if (aE) path = pullBack(path, aE.t, true);
|
|
700
856
|
tools.push(toolFor(overshoot(path), false, wallNs[s % nSeg]));
|
|
701
857
|
}
|
|
858
|
+
for (const got of cornerArcs.values()) {
|
|
859
|
+
tools.push(revolveTool(k, got.arc, magnitude, mode, segs, pSegs));
|
|
860
|
+
if (len(sub(got.vertex, got.arc.O)) - got.arc.R > 0.02 * magnitude)
|
|
861
|
+
tools.push(cornerHornTool(k, got, magnitude, segs));
|
|
862
|
+
}
|
|
702
863
|
return tools;
|
|
703
864
|
} catch (e) {
|
|
704
865
|
throw new UnsupportedEdgeError(`planar sweep: ${e.message}`);
|
|
705
866
|
}
|
|
706
867
|
}
|
|
707
868
|
|
|
869
|
+
// ---------------------------------------------------------------------------
|
|
870
|
+
// Rounded corners. Where exactly TWO selected convex chains meet at a salient corner
|
|
871
|
+
// in a common face plane (a letter corner, a polygon corner on a rim), the blend used
|
|
872
|
+
// to continue straight through from both sides and the two tools crossed in a mitre.
|
|
873
|
+
// That groove is a REAL crease — 76-90° dihedral, measured — so the feature-line
|
|
874
|
+
// overlay faithfully drew a polyline ACROSS the blend band at every such corner, and
|
|
875
|
+
// OCCT's native fillet produces the same intersection-and-trim crease. There is no
|
|
876
|
+
// groove-free construction that keeps the silhouette sharp: the two straight blends
|
|
877
|
+
// must either intersect or the blend must steer around the corner. This steers: the
|
|
878
|
+
// corner is replaced by a small circular ARC chain (radius ~1.05-1.25× the blend
|
|
879
|
+
// magnitude, tangent to both neighbors at a setback), the neighbors are trimmed to the
|
|
880
|
+
// tangent points, and the existing revolveTool sweeps the arc — exactly the shape a
|
|
881
|
+
// rounded-rectangle rim already has, which renders line-free today. The cost, stated
|
|
882
|
+
// plainly: within the band the silhouette rounds by about the blend radius at corners
|
|
883
|
+
// sharper than CORNER_ROUND_MIN_TURN; gentler corners keep their exact silhouette (a
|
|
884
|
+
// mitre under the viewer\'s 35° line threshold draws nothing).
|
|
885
|
+
//
|
|
886
|
+
// REFLEX corners keep the mitre: the ball genuinely cannot reach into the corner, the
|
|
887
|
+
// crease there is real geometry, and rounding the path would ADD material. A corner
|
|
888
|
+
// whose neighbors are too short to host the setback (tight glyph features) falls back
|
|
889
|
+
// to the mitre too — the fallback is today\'s behavior, never a failure.
|
|
890
|
+
// Corners gentler than this keep their mitre: the two blends there differ by less than
|
|
891
|
+
// the mitre turn everywhere, far under the viewer's 35° line threshold, and the shallow
|
|
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.
|
|
896
|
+
const CORNER_ROUND_MIN_TURN = (8 * Math.PI) / 180;
|
|
897
|
+
const RHO_MIN = 1.05; // × magnitude — revolve floor: the profile reaches magnitude inward of the arc
|
|
898
|
+
const RHO_PREF = 1.25; // × magnitude — preferred corner radius, a hair over the floor for margin
|
|
899
|
+
|
|
900
|
+
// Corner-arc descriptor at one vertex. tin1/tin2 point from the vertex INTO each side;
|
|
901
|
+
// wall1/wall2 are the sides\' outward wall normals at the vertex; len1/len2 bound the
|
|
902
|
+
// setback. Returns { arc, t } (a synthetic kind:"arc" chain for revolveTool, plus the
|
|
903
|
+
// 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) {
|
|
905
|
+
const tIn = scl(tin1, -1), tOut = tin2; // travel: arrive along side 1, depart into 2
|
|
906
|
+
const turn = Math.acos(clamp1(dot(tIn, tOut)));
|
|
907
|
+
if (turn < CORNER_ROUND_MIN_TURN) return null;
|
|
908
|
+
const turnS = dot(cross(tIn, tOut), f);
|
|
909
|
+
const matLeft = dot(wall1, cross(tIn, f)) > 0;
|
|
910
|
+
if ((turnS > 0) !== matLeft) return null; // reflex: the crease is real — keep the mitre
|
|
911
|
+
const tanH = Math.tan(turn / 2);
|
|
912
|
+
if (!(tanH > 1e-6) || !Number.isFinite(tanH)) return null;
|
|
913
|
+
const t = Math.min(RHO_PREF * magnitude * tanH, 0.45 * len1, 0.45 * len2);
|
|
914
|
+
const rho = t / tanH;
|
|
915
|
+
if (rho < RHO_MIN * magnitude) return null; // no room: mitre fallback
|
|
916
|
+
// inward bisector from the walls; O sits at distance rho from both edge lines
|
|
917
|
+
const proj = (wl) => { const p = sub(scl(wl, -1), scl(f, -dot(wl, f))); const l = len(p) || 1; return scl(p, 1 / l); };
|
|
918
|
+
const uA = proj(wall1), uB = proj(wall2);
|
|
919
|
+
const bisRaw = add(uA, uB);
|
|
920
|
+
if (len(bisRaw) < 1e-9) return null;
|
|
921
|
+
const O = add(vertex, scl(norm(bisRaw), rho / Math.cos(turn / 2)));
|
|
922
|
+
const pA = add(vertex, scl(tin1, t)), pB = add(vertex, scl(tin2, t));
|
|
923
|
+
const u0raw = sub(pA, O), uEraw = sub(pB, O);
|
|
924
|
+
const u0 = norm(u0raw), uE = norm(uEraw);
|
|
925
|
+
const span = Math.acos(clamp1(dot(u0, uE)));
|
|
926
|
+
if (!(span > 1e-4)) return null;
|
|
927
|
+
const s = dot(cross(u0, uE), f) >= 0 ? 1 : -1; // orient w so azimuth increases pA → pB
|
|
928
|
+
const w = scl(f, s);
|
|
929
|
+
// rotating-frame flanks, fitArcChain\'s convention ([ρ-component, w-component]):
|
|
930
|
+
// the face flank is pure ±w, the wall is pure outward radial
|
|
931
|
+
return {
|
|
932
|
+
t,
|
|
933
|
+
vertex,
|
|
934
|
+
f,
|
|
935
|
+
arc: { kind: "arc", points: [pA, pB], O, w, u0, v0: cross(w, u0), R: rho, span,
|
|
936
|
+
closed: false, n1: [0, s], n2: [1, 0], convex: true, synthetic: true },
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// The horn cutter that completes a rounded corner. The arc tool blends the band around
|
|
941
|
+
// the corner's arc cylinder, but the SOLID still has its sharp corner: the column of
|
|
942
|
+
// material between that cylinder and the original vertex would poke up through the band
|
|
943
|
+
// untouched. This block removes it — footprint bounded by the two walls and an arc-side
|
|
944
|
+
// polyline held strictly INSIDE the arc tool's own cut region — from just above the
|
|
945
|
+
// face down to exactly band depth. What remains below is a small flat shelf at the
|
|
946
|
+
// corner base; its rim is a boundary line BELOW the band, the deliberate trade for a
|
|
947
|
+
// band with no lines across it.
|
|
948
|
+
//
|
|
949
|
+
// The arc-side vertices sit at the arc TOOL's guaranteed apothem, R·cos(π/aSegs), less
|
|
950
|
+
// a micron margin — not on the circle itself. Vertices on the circle only bow inside
|
|
951
|
+
// the SMOOTH cylinder; the tool is a polygonal revolve whose facets sit at ITS apothem,
|
|
952
|
+
// and whenever the horn's chords landed shallower than a tool facet (a short-span arc
|
|
953
|
+
// at reduced angular density), the wall between them survived both cutters as a lens
|
|
954
|
+
// filament — an island or a handle, decided by facet phase (measured: the arrow's
|
|
955
|
+
// 20.7° corner flipped genus at some densities and not others). The apothem bound makes
|
|
956
|
+
// containment a proof instead of a phase lottery: tool pitch ≤ 2π/aSegs by definition
|
|
957
|
+
// of its step count, so its apothem ≥ R·cos(π/aSegs) > every horn vertex radius. The
|
|
958
|
+
// cost is a micron-deep extra bite at the corner base, covered near the tangent lines
|
|
959
|
+
// by the neighbors' own overshoot.
|
|
960
|
+
function cornerHornTool(k, { vertex, f, arc }, magnitude, segs) {
|
|
961
|
+
const { O, w, u0, R, span } = arc;
|
|
962
|
+
const delta = 0.02 * magnitude;
|
|
963
|
+
const rH = R * Math.cos(Math.PI / cornerArcSegs(segs, R, magnitude)) - Math.min(1e-3, 0.02 * magnitude);
|
|
964
|
+
// Pose and depth run along the FACE normal f (material below the face), never the
|
|
965
|
+
// arc's w — w flips sign with the arc's travel direction, and a block lofted along a
|
|
966
|
+
// downward w would stand above the face and cut the top instead of the horn.
|
|
967
|
+
const aRaw = cross([0, 0, 1], f);
|
|
968
|
+
const s = len(aRaw);
|
|
969
|
+
let axis = null, theta = 0;
|
|
970
|
+
if (s > 1e-9) { axis = scl(aRaw, 1 / s); theta = Math.atan2(s, f[2]); }
|
|
971
|
+
else if (f[2] < 0) { axis = [1, 0, 0]; theta = Math.PI; }
|
|
972
|
+
const u = axis ? rotVec([1, 0, 0], axis, theta) : [1, 0, 0];
|
|
973
|
+
const v = axis ? rotVec([0, 1, 0], axis, theta) : [0, 1, 0];
|
|
974
|
+
const p2 = (p) => { const q = sub(p, O); return [dot(q, u), dot(q, v)]; };
|
|
975
|
+
const poly = [];
|
|
976
|
+
poly.push(p2(add(vertex, scl(norm(sub(vertex, O)), delta)))); // vertex, nudged outward
|
|
977
|
+
poly.push(p2(add(O, scl(u0, R + delta)))); // tangent A, nudged past its wall
|
|
978
|
+
const steps = 8;
|
|
979
|
+
for (let i = 0; i <= steps; i++) poly.push(p2(add(O, scl(rotVec(u0, w, (span * i) / steps), rH))));
|
|
980
|
+
poly.push(p2(add(O, scl(rotVec(u0, w, span), R + delta)))); // tangent B, nudged past its wall
|
|
981
|
+
let area = 0;
|
|
982
|
+
for (let i = 0; i < poly.length; i++) {
|
|
983
|
+
const [x1, y1] = poly[i], [x2, y2] = poly[(i + 1) % poly.length];
|
|
984
|
+
area += x1 * y2 - x2 * y1;
|
|
985
|
+
}
|
|
986
|
+
const ring = area < 0 ? poly.slice().reverse() : poly;
|
|
987
|
+
let tool = k.loft([{ polygon: ring, z: -magnitude }, { polygon: ring, z: delta }], { shading: "smooth" });
|
|
988
|
+
if (axis) tool = tool.rotateAbout({ axis, deg: (theta * 180) / Math.PI });
|
|
989
|
+
return tool.translate(O);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function chainEndInfo(ch, end) {
|
|
993
|
+
if (ch.kind === "line") {
|
|
994
|
+
return end === "start"
|
|
995
|
+
? { v: ch.a, tin: ch.dir, flanks: [ch.n1, ch.n2], len: ch.length }
|
|
996
|
+
: { v: ch.b, tin: scl(ch.dir, -1), flanks: [ch.n1, ch.n2], len: ch.length };
|
|
997
|
+
}
|
|
998
|
+
const pts = ch.points, m = pts.length;
|
|
999
|
+
let plen = 0;
|
|
1000
|
+
for (let i = 0; i + 1 < m; i++) plen += len(sub(pts[i + 1], pts[i]));
|
|
1001
|
+
return end === "start"
|
|
1002
|
+
? { v: pts[0], tin: norm(sub(pts[1], pts[0])), flanks: [ch.faceN, ch.wallNs[0]], len: plen }
|
|
1003
|
+
: { 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
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Corner arc for two chain ends meeting at one vertex, or null (no common face plane,
|
|
1007
|
+
// gentle turn, reflex corner, or no room for the setback).
|
|
1008
|
+
function cornerArcBetween(E1, E2, magnitude) {
|
|
1009
|
+
let f = null, wall1 = null, wall2 = null;
|
|
1010
|
+
for (const c1 of E1.flanks) {
|
|
1011
|
+
for (const c2 of E2.flanks) {
|
|
1012
|
+
if (dot(c1, c2) <= FLANK_COS) continue;
|
|
1013
|
+
// the shared face is ⟂ BOTH tangents; each wall is ⟂ only its own chain\'s
|
|
1014
|
+
if (Math.abs(dot(c1, E1.tin)) > 0.05 || Math.abs(dot(c1, E2.tin)) > 0.05) continue;
|
|
1015
|
+
f = norm(add(c1, c2));
|
|
1016
|
+
wall1 = E1.flanks[0] === c1 ? E1.flanks[1] : E1.flanks[0];
|
|
1017
|
+
wall2 = E2.flanks[0] === c2 ? E2.flanks[1] : E2.flanks[0];
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
if (!f) return null;
|
|
1021
|
+
return cornerArcAt(E1.v, f, E1.tin, E2.tin, wall1, wall2, E1.len, E2.len, magnitude);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Convert a FACE-PLANE arc chain to the equivalent planar chain, or return null when
|
|
1025
|
+
// the arc has no world-constant flank (a rim on a curved face — revolveTool's
|
|
1026
|
+
// irreplaceable case). In-plane rims blend by sweeping their own polyline instead of
|
|
1027
|
+
// revolving a fitted circle, for two reasons measured on a label backing. The sweep's
|
|
1028
|
+
// stations sit ON the rim vertices, so its flank contact is plane-exact per wall facet,
|
|
1029
|
+
// where the revolve's contact is a three-way micron contest (its own angular chords,
|
|
1030
|
+
// the wall's facets, and the circle fit's offset) that strands radial knife-fins along
|
|
1031
|
+
// the band whenever the margins interfere. And a planar chain STITCHES to its planar
|
|
1032
|
+
// neighbors, so the arc↔planar junction — two tools overshooting tangentially into
|
|
1033
|
+
// each other, which roundSalientCorners never handled because it skips arc chains —
|
|
1034
|
+
// stops existing as a category. Selection still runs on the ARC form (near-selectors
|
|
1035
|
+
// match the fitted circle, not its chords); conversion happens after, in apply().
|
|
1036
|
+
function planarizeArc(ch) {
|
|
1037
|
+
if (ch.kind !== "arc") return null;
|
|
1038
|
+
// face flank = the rotating-frame flank that is axial (±w, world-constant); ~3° bar
|
|
1039
|
+
const pick = Math.abs(ch.n1[0]) <= 0.05 ? 0 : Math.abs(ch.n2[0]) <= 0.05 ? 1 : -1;
|
|
1040
|
+
if (pick === -1) return null;
|
|
1041
|
+
const [face, wall] = pick === 0 ? [ch.n1, ch.n2] : [ch.n2, ch.n1];
|
|
1042
|
+
const faceN = scl(ch.w, Math.sign(face[1]));
|
|
1043
|
+
const pts = ch.points;
|
|
1044
|
+
const wallNs = [];
|
|
1045
|
+
for (let i = 0; i + 1 < pts.length; i++) {
|
|
1046
|
+
const q = sub(scl(add(pts[i], pts[i + 1]), 0.5), ch.O);
|
|
1047
|
+
const rho = norm(sub(q, scl(ch.w, dot(q, ch.w))));
|
|
1048
|
+
wallNs.push(norm(add(scl(rho, wall[0]), scl(ch.w, wall[1]))));
|
|
1049
|
+
}
|
|
1050
|
+
return { kind: "planar", points: pts.map((p) => [p[0], p[1], p[2]]), closed: ch.closed,
|
|
1051
|
+
convex: ch.convex, w: faceN, faceN, wallNs };
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Trim a chain back by tStart/tEnd (0 = untouched) toward the corner arcs that replace
|
|
1055
|
+
// its mitred ends. Line chains shift their endpoints; planar chains walk the polyline in
|
|
1056
|
+
// from each end, dropping consumed vertices (and their members\' wall normals) and
|
|
1057
|
+
// planting the new endpoint mid-segment. Returns the trimmed copy, or null when nothing
|
|
1058
|
+
// usable remains (guarded against by cornerArcAt\'s 0.45·length setback cap).
|
|
1059
|
+
function trimChain(ch, tStart, tEnd) {
|
|
1060
|
+
if (!(tStart > 0) && !(tEnd > 0)) return ch;
|
|
1061
|
+
if (ch.kind === "line") {
|
|
1062
|
+
const length = ch.length - tStart - tEnd;
|
|
1063
|
+
if (!(length > 1e-9)) return null;
|
|
1064
|
+
return { ...ch, a: add(ch.a, scl(ch.dir, tStart)), b: sub(ch.b, scl(ch.dir, tEnd)), length };
|
|
1065
|
+
}
|
|
1066
|
+
let pts = ch.points.map((p) => [p[0], p[1], p[2]]);
|
|
1067
|
+
let walls = ch.wallNs.slice();
|
|
1068
|
+
const eat = (t) => { // consume t from the FRONT of pts/walls
|
|
1069
|
+
while (t > 1e-12 && pts.length >= 2) {
|
|
1070
|
+
const seg = sub(pts[1], pts[0]), l = len(seg);
|
|
1071
|
+
if (l > t + 1e-12) { pts[0] = add(pts[0], scl(seg, t / l)); return true; }
|
|
1072
|
+
t -= l;
|
|
1073
|
+
pts.shift();
|
|
1074
|
+
walls.shift();
|
|
1075
|
+
}
|
|
1076
|
+
return pts.length >= 2;
|
|
1077
|
+
};
|
|
1078
|
+
const flip = () => { pts.reverse(); walls.reverse(); };
|
|
1079
|
+
if (tStart > 0 && !eat(tStart)) return null;
|
|
1080
|
+
if (tEnd > 0) { flip(); if (!eat(tEnd)) return null; flip(); }
|
|
1081
|
+
if (pts.length < 2) return null;
|
|
1082
|
+
return { ...ch, points: pts, wallNs: walls, closed: false };
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// Round the salient two-chain corners of a selection: returns the effective chain list
|
|
1086
|
+
// (trimmed neighbors substituted in place) plus the synthetic corner-arc chains.
|
|
1087
|
+
function roundSalientCorners(selected, magnitude) {
|
|
1088
|
+
const keyOf = (p) => `${Math.round(p[0] * WELD)},${Math.round(p[1] * WELD)},${Math.round(p[2] * WELD)}`;
|
|
1089
|
+
const ends = new Map();
|
|
1090
|
+
for (const ch of selected) {
|
|
1091
|
+
if (ch.closed || ch.convex !== true) continue;
|
|
1092
|
+
if (ch.kind !== "line" && ch.kind !== "planar") continue;
|
|
1093
|
+
for (const end of ["start", "end"]) {
|
|
1094
|
+
const info = chainEndInfo(ch, end);
|
|
1095
|
+
const kk = keyOf(info.v);
|
|
1096
|
+
(ends.get(kk) ?? ends.set(kk, []).get(kk)).push({ ch, end, info });
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
const arcs = [], horns = [], trims = new Map();
|
|
1100
|
+
const addTrim = (ch, end, t) => {
|
|
1101
|
+
const cur = trims.get(ch) ?? { start: 0, end: 0 };
|
|
1102
|
+
cur[end] = t;
|
|
1103
|
+
trims.set(ch, cur);
|
|
1104
|
+
};
|
|
1105
|
+
for (const list of ends.values()) {
|
|
1106
|
+
if (list.length !== 2 || (list[0].ch === list[1].ch && list[0].end === list[1].end)) continue;
|
|
1107
|
+
const got = cornerArcBetween(list[0].info, list[1].info, magnitude);
|
|
1108
|
+
if (!got) continue;
|
|
1109
|
+
arcs.push(got.arc);
|
|
1110
|
+
// a gentle corner's horn is a sliver — depth ρ·(1/cos(turn/2) − 1), microns at
|
|
1111
|
+
// small turns — not worth a cutter (and thin cutters are their own noise source)
|
|
1112
|
+
if (len(sub(got.vertex, got.arc.O)) - got.arc.R > 0.02 * magnitude)
|
|
1113
|
+
horns.push({ vertex: got.vertex, f: got.f, arc: got.arc });
|
|
1114
|
+
addTrim(list[0].ch, list[0].end, got.t);
|
|
1115
|
+
addTrim(list[1].ch, list[1].end, got.t);
|
|
1116
|
+
}
|
|
1117
|
+
if (!arcs.length) return { chains: selected, arcs, horns };
|
|
1118
|
+
const chains = [];
|
|
1119
|
+
for (const ch of selected) {
|
|
1120
|
+
const tr = trims.get(ch);
|
|
1121
|
+
const eff = tr ? trimChain(ch, tr.start, tr.end) : ch;
|
|
1122
|
+
if (eff) chains.push(eff);
|
|
1123
|
+
}
|
|
1124
|
+
return { chains, arcs, horns };
|
|
1125
|
+
}
|
|
1126
|
+
|
|
708
1127
|
// ---------------------------------------------------------------------------
|
|
709
1128
|
// Spherical corner patches. Where exactly three selected straight convex chains
|
|
710
1129
|
// meet at a vertex with mutually orthogonal directions (a box-like corner), the
|
|
@@ -776,13 +1195,20 @@ function apply(k, solid, mode, magnitude, { edges, segs = DEFAULT_SEGS, sharpDeg
|
|
|
776
1195
|
if (!selected.length) throw new UnsupportedEdgeError(`${mode} selector matched no sharp edges`);
|
|
777
1196
|
const unsupported = selected.find((ch) => ch.kind === "unsupported");
|
|
778
1197
|
if (unsupported) throw new UnsupportedEdgeError(`${mode}: ${unsupported.reason}`);
|
|
1198
|
+
// Face-plane arc rims sweep their own polyline (see planarizeArc); re-stitch so a
|
|
1199
|
+
// converted arc joins its planar neighbors — chainEdges' own stitch pass ran before
|
|
1200
|
+
// these chains were planar, so their junctions are still open here.
|
|
1201
|
+
const planarized = stitchPlanarChains(selected.map((ch) => planarizeArc(ch) ?? ch));
|
|
1202
|
+
const { chains: effective, arcs, horns } = roundSalientCorners(planarized, magnitude);
|
|
1203
|
+
const pSegs = blendSegs(segs, magnitude);
|
|
779
1204
|
const toolsFor = (ch) =>
|
|
780
1205
|
ch.kind === "planar"
|
|
781
|
-
? planarTool(k, ch, magnitude, mode, segs)
|
|
782
|
-
: [(ch.kind === "arc" ? revolveTool : prismTool)(k, ch, magnitude, mode, segs)];
|
|
783
|
-
const cutters =
|
|
784
|
-
|
|
785
|
-
|
|
1206
|
+
? planarTool(k, ch, magnitude, mode, segs, pSegs)
|
|
1207
|
+
: [(ch.kind === "arc" ? revolveTool : prismTool)(k, ch, magnitude, mode, segs, pSegs)];
|
|
1208
|
+
const cutters = [...effective, ...arcs].filter((ch) => ch.convex).flatMap(toolsFor);
|
|
1209
|
+
cutters.push(...horns.map((h) => cornerHornTool(k, h, magnitude, segs)));
|
|
1210
|
+
const fillers = effective.filter((ch) => !ch.convex).flatMap(toolsFor);
|
|
1211
|
+
if (mode === "fillet") cutters.push(...cornerPatches(k, effective, magnitude, segs));
|
|
786
1212
|
let out = solid;
|
|
787
1213
|
if (cutters.length) out = out.cutAll(cutters);
|
|
788
1214
|
if (fillers.length) out = k.union([out, ...fillers]);
|
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
|
|
9
9
|
export const SMOOTH = Object.freeze({ creaseAngle: 35, sameSurfaceLines: true });
|
|
10
10
|
export const FACETED = Object.freeze({ creaseAngle: 10, sameSurfaceLines: false });
|
|
11
|
+
// A blend surface (fillet/chamfer band). `boundaryLines` widens the CROSS-surface
|
|
12
|
+
// rule for it: a seam between a blend and a NON-blend surface draws regardless of
|
|
13
|
+
// bend — the band's start/end are tangent (~0°) and would otherwise be invisible,
|
|
14
|
+
// leaving the fillet's extent unreadable in the overlay. A seam between TWO blend
|
|
15
|
+
// surfaces keeps the ordinary bend rule (exactly-one-side semantics), so the
|
|
16
|
+
// handover seams along one band — tool splits, corner arcs continuing a sweep —
|
|
17
|
+
// stay invisible while real mitre crossings still draw.
|
|
18
|
+
export const BLEND = Object.freeze({ creaseAngle: 35, sameSurfaceLines: true, boundaryLines: true });
|
|
11
19
|
|
|
12
20
|
export const COPLANAR_ANGLE = 5; // deg — cut seams bending less than this are coplanar: no line
|
|
13
21
|
export const TANGENT_ANGLE = 5; // deg — B-rep edges whose faces agree within this are tangent: no line
|