partforge 0.65.0 → 0.65.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/package.json
CHANGED
|
@@ -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)
|
|
@@ -125,9 +130,23 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
125
130
|
// surfaces, so feature-label attribution downstream of the op uses the
|
|
126
131
|
// fallback path (AUTHORING-PARTS.md), and the blend shades SMOOTH.
|
|
127
132
|
const SIMPLIFY_EPS = 1e-4; // 0.1 µm — must exceed the boolean's sliver widths (~2e-5)
|
|
133
|
+
// Debris sweep: where blend tools graze each other or a flank near-tangentially, the
|
|
134
|
+
// boolean can strand a CLOSED femto-component (measured ~1e-8 mm³, 4 triangles) that
|
|
135
|
+
// simplify() cannot remove — it collapses edges, never whole components — and that
|
|
136
|
+
// flips the result's genus/decompose count. Anything below DEBRIS_VOL is two orders
|
|
137
|
+
// under the smallest feature the tessellation itself can express, and three under
|
|
138
|
+
// anything printable, so dropping it can never erase real geometry.
|
|
139
|
+
const DEBRIS_VOL = 1e-6; // mm³
|
|
140
|
+
const dropDebris = (m) => {
|
|
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
|
+
return kept.length === parts.length ? m : T(Manifold.compose(kept));
|
|
146
|
+
};
|
|
128
147
|
const meshCadOp = (op, run) => {
|
|
129
148
|
try {
|
|
130
|
-
return T(T(run()._m.asOriginal()).simplify(SIMPLIFY_EPS));
|
|
149
|
+
return T(dropDebris(T(run()._m.asOriginal())).simplify(SIMPLIFY_EPS));
|
|
131
150
|
} catch (e) {
|
|
132
151
|
if (e instanceof UnsupportedEdgeError) throw new KernelCapabilityError(`${op}: ${e.message}`);
|
|
133
152
|
throw e;
|
|
@@ -158,8 +177,10 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
158
177
|
// tessellation, and so the result, is tier-dependent.
|
|
159
178
|
return cached(h("roundAll", hash, r, quality), () => T(meshRoundAll(wasm, m, r, quality)));
|
|
160
179
|
},
|
|
180
|
+
// batch difference: first minus the union of the rest, evaluated as one boolean
|
|
181
|
+
// tree — no materialized intermediate union (the unionRaw memory note applies)
|
|
161
182
|
cutAll: (tools) => cached(h("cutAll", hash, tools.map((t) => t._hash)),
|
|
162
|
-
() => T(
|
|
183
|
+
() => T(Manifold.difference([m, ...tools.map((t) => t._m)]))),
|
|
163
184
|
intersect: (t) => cached(h("intersect", hash, t._hash), () => T(m.intersect(t._m))),
|
|
164
185
|
union: (t) => cached(h("union", [hash, t._hash]), () => unionRaw([m, t._m])),
|
|
165
186
|
clone: () => wrap(m, hash),
|
|
@@ -325,10 +346,14 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
325
346
|
// (closed/cornerRadius) so a shape change is a fresh node and an identical rebuild hits.
|
|
326
347
|
sweep: (profile, path, opts = {}) => cached(h("sweep", profile, path, opts), () => T(sweepMesh(wasm, profile, path, opts))),
|
|
327
348
|
helixSweptTube: (o) => cached(h("helixSweptTube", o, tube), () => T(helixTube(wasm, { ...o, ...tube }))),
|
|
328
|
-
|
|
349
|
+
// opts.segs may only COARSEN below the kernel's quality (min), never exceed it:
|
|
350
|
+
// callers use it where a small feature's sagitta bound needs fewer facets than
|
|
351
|
+
// the per-circle quality would spend (mesh-fillet's free-standing corner arcs).
|
|
352
|
+
revolve: (pts, { degrees = 360, segs: segsOverride } = {}) => {
|
|
353
|
+
const density = Math.min(segs, segsOverride ?? segs);
|
|
329
354
|
if (pts && pts._shape2d)
|
|
330
|
-
return cached(h("revolve", pts._hash, degrees,
|
|
331
|
-
return cached(h("revolve", pts, degrees,
|
|
355
|
+
return cached(h("revolve", pts._hash, degrees, density), () => T(csFor(pts).revolve(density, degrees)));
|
|
356
|
+
return cached(h("revolve", pts, degrees, density), () => T(Manifold.revolve([pts], density, degrees)));
|
|
332
357
|
},
|
|
333
358
|
// A one-solid union is an identity — no new WASM / cache entry (avoids double-free):
|
|
334
359
|
// 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]);
|