partforge 0.67.2 → 0.67.4
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 +1 -1
- package/src/framework/geometry/contour-offset.js +65 -3
- package/src/framework/geometry/kernel-front.js +3 -3
- package/src/framework/geometry/mesh-fillet.js +92 -4
- package/src/framework/geometry/opentype-interop.js +22 -0
- package/src/framework/geometry/sweep.js +9 -2
- package/src/framework/jobs.js +2 -2
- package/src/testing/manifold.js +2 -2
- package/src/testing/occt.js +2 -2
package/package.json
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// The cubic subdivision approach is ported from glenzli/paperjs-offset
|
|
9
9
|
// (https://github.com/glenzli/paperjs-offset, MIT License, Copyright (c) glenzli),
|
|
10
10
|
// adapted from paper.js Segments to the partforge contour IR.
|
|
11
|
-
import { arcCenterAndSweep } from "./paper-bridge.js";
|
|
11
|
+
import { arcCenterAndSweep, booleanRegions } from "./paper-bridge.js";
|
|
12
12
|
import { cubicAt, splitCubic, jointTangents, SMOOTH_JOINT_DEG } from "./contour-ops.js";
|
|
13
13
|
import { tessellateContour, closeContourGap } from "./profile.js";
|
|
14
14
|
import { ringArea, pointInRing } from "./shape2d-regions.js";
|
|
@@ -718,7 +718,11 @@ const flattenRing = (contour, segs) => {
|
|
|
718
718
|
// All seven rescues are oracle-checked: median area error 0.0972 %, worst 1.663 %, with zero
|
|
719
719
|
// region-count losses and zero complete arc losses. The ladder stays because those seven raw
|
|
720
720
|
// arrangements remain numerically unclosable, not because the formerly parked comb/text
|
|
721
|
-
// failures still exist.
|
|
721
|
+
// failures still exist. Those seven are all erosion (negative delta) or single-region cases;
|
|
722
|
+
// the per-region rung below is positive-delta-and-multi-region only, so it wins none of them
|
|
723
|
+
// and the rates above are unchanged by its addition — its own coverage class (whole-word text
|
|
724
|
+
// dilation, feedback 86970b00) sits outside this corpus, whose glyphs are single characters
|
|
725
|
+
// and whose "Scott" case never reaches the delta band where the merged word fails to close.
|
|
722
726
|
//
|
|
723
727
|
// Rung ORDER is by fidelity of what survives, not by hit rate:
|
|
724
728
|
// 1. delta perturbed by ±1e-9 relative. Escapes an exactly-degenerate arrangement (two
|
|
@@ -729,7 +733,13 @@ const flattenRing = (contour, segs) => {
|
|
|
729
733
|
// that radius apart onto one vertex. This can merge a genuine severing pinch, so the
|
|
730
734
|
// 20x rung is not widened further even though the current seven rescues preserve the
|
|
731
735
|
// oracle's region count.
|
|
732
|
-
// 3.
|
|
736
|
+
// 3. per-region-union (delta > 0, more than one region only). Offsets each region alone and
|
|
737
|
+
// unites the results through paper's curve-native boolean. EXACT, not approximate: a
|
|
738
|
+
// positive dilation distributes over union, so this equals the whole-region offset the
|
|
739
|
+
// resolver could not close — with arcs intact, above the polyline rungs. It is here rather
|
|
740
|
+
// than at #1 only because it costs a boolean per region and re-runs the earlier rungs on
|
|
741
|
+
// each; the two cheaper exact rungs get first refusal.
|
|
742
|
+
// 4. the raw outline re-run as polylines (64/256/1024 facets per turn). Geometrically
|
|
733
743
|
// faithful to the chord error of that tessellation, but it DEGRADES THE IR: round joins
|
|
734
744
|
// come back as chords, so A STEP EXPORT OF A POLYLINE-RUNG RESULT LOSES ITS TRUE CIRCLES.
|
|
735
745
|
// No current corpus rescue loses every arc, but these rungs remain last because that
|
|
@@ -753,6 +763,25 @@ const flattenRing = (contour, segs) => {
|
|
|
753
763
|
// (±1e-4 and ±1e-3 mm absolute rungs) was measured too: it bought ONE extra case out of 62 and
|
|
754
764
|
// more than doubled the worst absolute error, 0.048 → 0.112 mm², so it is not here either.
|
|
755
765
|
//
|
|
766
|
+
// Offset each region on its own and unite the results through paper's planar boolean engine.
|
|
767
|
+
// This is EXACT for a positive delta, not an approximation: Minkowski dilation distributes
|
|
768
|
+
// over union, (⋃ Rᵢ) ⊕ B = ⋃ (Rᵢ ⊕ B), so the whole-region offset the winding resolver cannot
|
|
769
|
+
// close as one merged arrangement equals the union of the single-region offsets. Each single
|
|
770
|
+
// region is a far simpler arrangement — a lone glyph rather than a whole word's worth of offset
|
|
771
|
+
// walls meeting near-tangentially — and its own base offset still gets the earlier rungs'
|
|
772
|
+
// rescues, because the per-region call re-enters the public offsetRegions (which runs its own
|
|
773
|
+
// ladder for that one region). booleanRegions unites through paper's CURVE-native engine rather
|
|
774
|
+
// than a tessellation, so arcs stay arcs and a STEP export keeps its true circles — which is why
|
|
775
|
+
// this rung sits ABOVE the polyline rungs, whose chord approximation is the fidelity floor.
|
|
776
|
+
function perRegionUnion(regions, delta, corners) {
|
|
777
|
+
let out = [];
|
|
778
|
+
for (const rg of regions) {
|
|
779
|
+
const one = offsetRegions([rg], delta, { corners }); // single region: never re-enters this rung
|
|
780
|
+
out = out.length ? booleanRegions(out, one, "unite") : one;
|
|
781
|
+
}
|
|
782
|
+
return out;
|
|
783
|
+
}
|
|
784
|
+
|
|
756
785
|
// The ladder as named, LAZY rungs — one list, walked by chainFallback below and by
|
|
757
786
|
// scripts/offset-rates.mjs, so a measurement of "what each rung costs" can never drift from
|
|
758
787
|
// the ladder that actually ships. Every rung's whole body (including tessellating the outline
|
|
@@ -765,6 +794,13 @@ export function _ladderRungs(regions, raw, delta, corners) {
|
|
|
765
794
|
run: () => resolveOrRaw(rawOffset(regions, delta * (1 + sign * 1e-9), corners)) })),
|
|
766
795
|
...[4, 20].map((mult) => ({ name: `clusterTol*${mult}`,
|
|
767
796
|
run: () => resolveOffsetWinding(raw, { clusterTol: CLUSTER_TOL * mult }) })),
|
|
797
|
+
// Exact for positive dilation and arc-preserving, so it ranks above the polyline rungs but
|
|
798
|
+
// below the two cheaper exact rungs that need no boolean. Only meaningful when there is more
|
|
799
|
+
// than one region to decompose, and only distributive for a positive delta; the guard is
|
|
800
|
+
// also what bounds the recursion — a single-region offsetRegions call never reaches here.
|
|
801
|
+
...(delta > 0 && regions.length > 1
|
|
802
|
+
? [{ name: "per-region-union", run: () => perRegionUnion(regions, delta, corners) }]
|
|
803
|
+
: []),
|
|
768
804
|
...[64, 256, 1024].map((segs) => ({ name: `polyline@${segs}`,
|
|
769
805
|
run: () => resolveOffsetWinding(raw.map((rg) => ({ outer: flattenRing(rg.outer, segs),
|
|
770
806
|
holes: rg.holes.map((h) => flattenRing(h, segs)) }))) })),
|
|
@@ -865,6 +901,31 @@ function dropSubresolutionPositiveLoops(out, delta) {
|
|
|
865
901
|
});
|
|
866
902
|
}
|
|
867
903
|
|
|
904
|
+
// Positive dilation may also discard whole SUB-SLIVER rings — the multi-segment
|
|
905
|
+
// sibling of the zero-chord splice loops above. The winding resolver can emit
|
|
906
|
+
// entire junk rings a few segments long (measured on "Scott" size 28, delta 5:
|
|
907
|
+
// 14 of 16 holes were resolver debris of 1e-8..1e-5 mm² beside two real ~6-8 mm²
|
|
908
|
+
// counters), and every one of them extrudes into a degenerate fin or sliver face
|
|
909
|
+
// that downstream mesh consumers trip over (the planar rim fillet's knife-edge
|
|
910
|
+
// refusals). The bar is the corpus oracle's own SLIVER convention (1e-3 mm² —
|
|
911
|
+
// test/offset-text.test.js, the fuzz suite): rings under it are "resolver
|
|
912
|
+
// artifacts, not features". The proof this cannot eat real geometry is
|
|
913
|
+
// dilation-only, in two halves: a genuine separate REGION of a dilation is at
|
|
914
|
+
// least the dilation disc (area ≥ π·δ²), and a genuine HOLE under that bar is a
|
|
915
|
+
// counter within a hair of closing — which the oracle already counts as closed.
|
|
916
|
+
// Erosion keeps everything, same as dropSubresolutionPositiveLoops: a tiny
|
|
917
|
+
// surviving island there is real geometry with no source-domain proof otherwise.
|
|
918
|
+
const RING_SLIVER = 1e-3; // mm² — the corpus oracle's sub-sliver bar
|
|
919
|
+
function dropSubSliverRings(out, delta) {
|
|
920
|
+
if (delta <= 0) return out;
|
|
921
|
+
const tiny = (ring) => Math.abs(ringArea(tessellateContour(ring, VALIDATE_SEGS))) < RING_SLIVER;
|
|
922
|
+
return out.flatMap((rg) => {
|
|
923
|
+
if (tiny(rg.outer)) return [];
|
|
924
|
+
const holes = rg.holes.filter((h) => !tiny(h));
|
|
925
|
+
return [holes.length === rg.holes.length ? rg : { outer: rg.outer, holes }];
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
|
|
868
929
|
// Region-in / region-out offset: the engine behind Shape2D.offset on BOTH backends.
|
|
869
930
|
// Fast path: raw per-ring offsets that validate cleanly are returned as-is (lines/arcs
|
|
870
931
|
// exact). Cleanup path: anything dirty or invalid goes through resolveOffsetWinding
|
|
@@ -892,6 +953,7 @@ export function offsetRegions(regions, delta, { corners = "round" } = {}) {
|
|
|
892
953
|
}
|
|
893
954
|
out = sourceBackedPositiveRegions(regions, out, delta);
|
|
894
955
|
out = dropSubresolutionPositiveLoops(out, delta);
|
|
956
|
+
out = dropSubSliverRings(out, delta);
|
|
895
957
|
if (out.length === 0) throw new Error("Shape2D.offset: offset collapses the shape (reduce |delta|)");
|
|
896
958
|
return out;
|
|
897
959
|
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// opentype.js's namespace shape differs between bundler and Node resolution —
|
|
16
16
|
// see opentype-interop.js for the trap (it has bitten once in each direction).
|
|
17
17
|
import * as opentypeNamespace from "opentype.js";
|
|
18
|
-
import { normalizeOpentype } from "./opentype-interop.js";
|
|
18
|
+
import { normalizeOpentype, parseFont } from "./opentype-interop.js";
|
|
19
19
|
const opentype = normalizeOpentype(opentypeNamespace);
|
|
20
20
|
import { KernelCapabilityError } from "./errors.js";
|
|
21
21
|
import { isPlainOptions, KERNEL_OP_SPECS } from "./op-options.js";
|
|
@@ -95,7 +95,7 @@ export function finishKernel(k) {
|
|
|
95
95
|
// EXACT byte range — arg.buffer alone spans the whole (possibly pooled) backing
|
|
96
96
|
// buffer, which would feed opentype garbage for a byteOffset>0 view.
|
|
97
97
|
const buf = ArrayBuffer.isView(arg) ? arg.buffer.slice(arg.byteOffset, arg.byteOffset + arg.byteLength) : arg;
|
|
98
|
-
f = opentype
|
|
98
|
+
f = parseFont(opentype, buf); byteCache.set(arg, f);
|
|
99
99
|
}
|
|
100
100
|
return f;
|
|
101
101
|
};
|
|
@@ -109,7 +109,7 @@ export function finishKernel(k) {
|
|
|
109
109
|
// guards against that ever changing.
|
|
110
110
|
if (!k._defaultFont) {
|
|
111
111
|
const { buffer, byteOffset, byteLength } = DEFAULT_FONT_BYTES;
|
|
112
|
-
k._defaultFont = opentype
|
|
112
|
+
k._defaultFont = parseFont(opentype, buffer.slice(byteOffset, byteOffset + byteLength), "the bundled default");
|
|
113
113
|
}
|
|
114
114
|
return k._defaultFont;
|
|
115
115
|
}
|
|
@@ -572,7 +572,10 @@ export function matchesSelector(chain, sel) {
|
|
|
572
572
|
const rot2 = ([x, y], th) => [x * Math.cos(th) - y * Math.sin(th), x * Math.sin(th) + y * Math.cos(th)];
|
|
573
573
|
function profile2D({ P, n1, n2, magnitude, mode, convex, segs, ext = 0 }) {
|
|
574
574
|
const c = clamp1(n1[0] * n2[0] + n1[1] * n2[1]);
|
|
575
|
-
|
|
575
|
+
// `knifeEdge` marks the refusal as the anti-parallel-flank degeneracy, so the
|
|
576
|
+
// planar rim machinery can SKIP a noise stretch (a sliver facet's flipped
|
|
577
|
+
// normal) instead of failing the whole selection on it.
|
|
578
|
+
if (1 + c < 1e-6) throw Object.assign(new UnsupportedEdgeError("~180° knife edge"), { knifeEdge: true });
|
|
576
579
|
const bl = Math.hypot(n1[0] + n2[0], n1[1] + n2[1]);
|
|
577
580
|
const bis = [(n1[0] + n2[0]) / bl, (n1[1] + n2[1]) / bl];
|
|
578
581
|
const delta = 0.02 * magnitude;
|
|
@@ -859,6 +862,35 @@ function collapseTightCorners(pts0, wallNs0, closed, magnitude) {
|
|
|
859
862
|
return { pts: out, wallNs: outWalls };
|
|
860
863
|
}
|
|
861
864
|
|
|
865
|
+
// Weld consecutive coincident chain points (the module's own 1/WELD vertex-identity
|
|
866
|
+
// grid, pivotKey's). collapseTightCorners can land a virtual corner V exactly ON a
|
|
867
|
+
// flanking chain point — an offset outline's micro-spike doubles back through the
|
|
868
|
+
// same vertex, so the flanking edge lines intersect AT it — and a coincident pair
|
|
869
|
+
// becomes a zero-length sweep path segment that k.sweep rejects, failing the whole
|
|
870
|
+
// fillet (the "Scott" offset-backing regression). Dropping the point drops the
|
|
871
|
+
// degenerate segment's WALL, keeping walls one-per-surviving-segment.
|
|
872
|
+
function weldChainPoints(pts, wallNs, closed) {
|
|
873
|
+
const eps = 1 / WELD;
|
|
874
|
+
const outP = [pts[0]], outW = [];
|
|
875
|
+
for (let i = 1; i < pts.length; i++) {
|
|
876
|
+
if (len(sub(pts[i], outP[outP.length - 1])) < eps) continue;
|
|
877
|
+
outP.push(pts[i]);
|
|
878
|
+
outW.push(wallNs[i - 1]); // wall of the span arriving at pts[i]
|
|
879
|
+
}
|
|
880
|
+
if (closed) {
|
|
881
|
+
// The closing segment's wall: the original closing span's — unless the wrap
|
|
882
|
+
// itself welds (last ≈ first), where the popped point's arriving wall is the
|
|
883
|
+
// span that now closes the loop.
|
|
884
|
+
let closingW = wallNs[pts.length - 1];
|
|
885
|
+
while (outP.length > 1 && len(sub(outP[outP.length - 1], outP[0])) < eps) {
|
|
886
|
+
outP.pop();
|
|
887
|
+
closingW = outW.pop();
|
|
888
|
+
}
|
|
889
|
+
outW.push(closingW);
|
|
890
|
+
}
|
|
891
|
+
return { pts: outP, wallNs: outW };
|
|
892
|
+
}
|
|
893
|
+
|
|
862
894
|
function planarTool(k, chain, magnitude, mode, segs, pSegs = segs, endTins = null) {
|
|
863
895
|
const { points, closed, convex, faceN } = chain;
|
|
864
896
|
let { wallNs } = chain;
|
|
@@ -871,6 +903,10 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs, endTins = nul
|
|
|
871
903
|
// bold outlines never hit this because the 0.4 mm round offset pads every
|
|
872
904
|
// convex radius past the fold threshold).
|
|
873
905
|
if (convex) ({ pts, wallNs } = collapseTightCorners(pts, wallNs, closed, magnitude));
|
|
906
|
+
({ pts, wallNs } = weldChainPoints(pts, wallNs, closed));
|
|
907
|
+
// A chain welded below the grid (a sub-micron rim loop — offset-noise islands)
|
|
908
|
+
// has nothing a blend of this magnitude can attach to; skip it rather than fail.
|
|
909
|
+
if (pts.length < (closed ? 3 : 2)) return [];
|
|
874
910
|
const m = pts.length;
|
|
875
911
|
const at = (i) => pts[((i % m) + m) % m];
|
|
876
912
|
const nSeg = closed ? m : m - 1;
|
|
@@ -999,9 +1035,48 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs, endTins = nul
|
|
|
999
1035
|
return k.sweep(poly, path3D, { closed: isClosed });
|
|
1000
1036
|
};
|
|
1001
1037
|
|
|
1038
|
+
// Sweep one open stretch; when the sweep refuses a VERTEX fold the pre-split
|
|
1039
|
+
// guard let through — the guard classifies bends by the LOCAL wall normals,
|
|
1040
|
+
// and an offset outline's micro-spike facets carry noise normals that can
|
|
1041
|
+
// read reflex (lenient reach) where the sweep's frame-transported measure is
|
|
1042
|
+
// salient (full magnitude) — split at that exact vertex and sweep the pieces.
|
|
1043
|
+
// That is the same treatment the guard itself would have applied with the
|
|
1044
|
+
// right classification: adjacent stretches mitre into each other across the
|
|
1045
|
+
// split via their overshoots. The sweep is the oracle, so the two can never
|
|
1046
|
+
// disagree into a failure.
|
|
1047
|
+
const buildStretch = (path, wallN, depth = 0) => {
|
|
1048
|
+
try {
|
|
1049
|
+
return [toolFor(overshoot(path), false, wallN)];
|
|
1050
|
+
} catch (e) {
|
|
1051
|
+
// A knife PROFILE here means this stretch's wall is a degenerate sliver's
|
|
1052
|
+
// flipped normal (anti-parallel to the face) — a real rim wall is ~90° to
|
|
1053
|
+
// its face and cannot produce it. The rim piece is sub-resolution noise;
|
|
1054
|
+
// skip it rather than fail every other stretch of the selection.
|
|
1055
|
+
if (e?.knifeEdge) return [];
|
|
1056
|
+
const v = e?.foldVertex;
|
|
1057
|
+
// overshoot() prepended one point, so sweep index v is path index v-1
|
|
1058
|
+
const i = v != null ? v - (over > 0 && path.length >= 2 ? 1 : 0) : null;
|
|
1059
|
+
if (i == null || depth > 16 || !(i > 0 && i < path.length - 1)) throw e;
|
|
1060
|
+
return [
|
|
1061
|
+
...buildStretch(path.slice(0, i + 1), wallN, depth + 1),
|
|
1062
|
+
...buildStretch(path.slice(i), wallN, depth + 1),
|
|
1063
|
+
];
|
|
1064
|
+
}
|
|
1065
|
+
};
|
|
1066
|
+
|
|
1002
1067
|
try {
|
|
1003
1068
|
if (closed && breaks.length === 0) {
|
|
1004
|
-
|
|
1069
|
+
const loop = pts.map((p) => [p[0], p[1], p[2]]);
|
|
1070
|
+
try {
|
|
1071
|
+
return [toolFor(loop, true, wallNs[nSeg - 1])];
|
|
1072
|
+
} catch (e) {
|
|
1073
|
+
if (e?.knifeEdge) return []; // degenerate sliver loop — nothing to blend
|
|
1074
|
+
const v = e?.foldVertex;
|
|
1075
|
+
if (v == null) throw e;
|
|
1076
|
+
// the loop folds at v with no break to split on: open it there and let
|
|
1077
|
+
// buildStretch's splitting take over (the seam gets the overshoot mitre)
|
|
1078
|
+
return buildStretch([...loop.slice(v), ...loop.slice(0, v + 1)], wallNs[v % nSeg]);
|
|
1079
|
+
}
|
|
1005
1080
|
}
|
|
1006
1081
|
// Open stretches between breaks. An open chain's endpoints are implicit breaks; a
|
|
1007
1082
|
// closed chain's stretches wrap from each break to the next.
|
|
@@ -1017,7 +1092,7 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs, endTins = nul
|
|
|
1017
1092
|
const aS = arcAt(s), aE = arcAt(e);
|
|
1018
1093
|
if (aS) path = pullBack(path, aS.t, false);
|
|
1019
1094
|
if (aE) path = pullBack(path, aE.t, true);
|
|
1020
|
-
tools.push(
|
|
1095
|
+
tools.push(...buildStretch(path, wallNs[s % nSeg]));
|
|
1021
1096
|
}
|
|
1022
1097
|
for (const got of cornerArcs.values()) {
|
|
1023
1098
|
tools.push(revolveTool(k, got.arc, magnitude, mode, segs, pSegs));
|
|
@@ -1505,7 +1580,20 @@ function apply(k, solid, mode, magnitude, { edges, segs = DEFAULT_SEGS, sharpDeg
|
|
|
1505
1580
|
(endTins.get(kk) ?? endTins.set(kk, []).get(kk)).push(info.tin);
|
|
1506
1581
|
}
|
|
1507
1582
|
}
|
|
1508
|
-
|
|
1583
|
+
let { chains: effective, arcs, horns, pivots } = roundSalientCorners(planarized, magnitude);
|
|
1584
|
+
// An edge whose two flanks fold back on themselves (anti-parallel normals) is a
|
|
1585
|
+
// zero-thickness fin or slit rim — a self-touching offset outline extrudes these.
|
|
1586
|
+
// There is no wedge between the flanks for a blend to live in (profile2D's own
|
|
1587
|
+
// ~180° knife-edge refusal), so skip the chain rather than fail every OTHER edge
|
|
1588
|
+
// of the selection with it.
|
|
1589
|
+
// Planar variant of the same degeneracy: a zero-area sliver in the face
|
|
1590
|
+
// triangulation flips its facet normal, classifying as a "wall" anti-parallel
|
|
1591
|
+
// to the face — profile2D's projected normals then hit the same refusal.
|
|
1592
|
+
const knife = (ch) => ch.kind === "planar"
|
|
1593
|
+
? ch.wallNs.every((wn) => dot(ch.faceN, wn) < -1 + 1e-6)
|
|
1594
|
+
: ch.n1 && ch.n2 && dot(ch.n1, ch.n2) < -1 + 1e-6;
|
|
1595
|
+
effective = effective.filter((ch) => !knife(ch));
|
|
1596
|
+
arcs = arcs.filter((ch) => !knife(ch));
|
|
1509
1597
|
const pSegs = blendSegs(segs, magnitude);
|
|
1510
1598
|
const toolsFor = (ch) =>
|
|
1511
1599
|
ch.kind === "planar"
|
|
@@ -11,3 +11,25 @@
|
|
|
11
11
|
// one function so the two interop shapes stay handled in one place.
|
|
12
12
|
export const normalizeOpentype = (ns) =>
|
|
13
13
|
typeof ns?.parse === "function" ? ns : (ns?.default ?? ns);
|
|
14
|
+
|
|
15
|
+
// Parse font bytes into an opentype.Font, turning opentype.js's own low-level parse
|
|
16
|
+
// failures into a NAMED, actionable error. A single unreadable font in a part's `fonts`
|
|
17
|
+
// map otherwise kills the whole build with a message that names neither the font nor the
|
|
18
|
+
// fix — a RangeError deep in the TrueType reader, or opentype.js's raw "WOFF2 require an
|
|
19
|
+
// external decompressor library" URL — and the part just "won't build" with no clue which
|
|
20
|
+
// font or why. This is the exact dead end a variable font or a WOFF/WOFF2 upload lands in:
|
|
21
|
+
// opentype.js 2.x reads neither, so the guidance is always the same (supply a static TTF or
|
|
22
|
+
// OTF). `label` is the declared font name where one is known (the `fonts` map key), and is
|
|
23
|
+
// omitted for an inline-bytes font, which has no name to give. All parse sites route through
|
|
24
|
+
// here so the message stays in one place.
|
|
25
|
+
export function parseFont(opentype, buf, label) {
|
|
26
|
+
try {
|
|
27
|
+
return opentype.parse(buf);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
const who = label ? `font "${label}"` : "an inline font";
|
|
30
|
+
throw new Error(
|
|
31
|
+
`text2d: ${who} could not be read as a TTF or OTF — a variable font or a WOFF/WOFF2 ` +
|
|
32
|
+
`file will fail here; supply a static TTF or OTF instead. (${err?.message ?? err})`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -43,6 +43,13 @@ const rodrigues = (v, k, ang) => {
|
|
|
43
43
|
const placeRing = (profile2D, center, N, B) =>
|
|
44
44
|
profile2D.map(([x, y]) => add(center, add(scl(N, x), scl(B, y))));
|
|
45
45
|
|
|
46
|
+
// A per-VERTEX fold refusal (miter would fold / reversal is ambiguous) carries the
|
|
47
|
+
// offending path index as `foldVertex`, so a caller that owns the path can split it
|
|
48
|
+
// there and sweep the pieces instead of failing — mesh-fillet's planar chains do
|
|
49
|
+
// exactly that when their pre-split guard's wall-normal heuristic disagrees with
|
|
50
|
+
// this module's direction-aware measure (offset-outline micro-noise walls).
|
|
51
|
+
const foldError = (message, vtx) => Object.assign(new Error(message), { foldVertex: vtx });
|
|
52
|
+
|
|
46
53
|
// The seed frame a sweep of `path3D` will start from — exported so a caller authoring a
|
|
47
54
|
// profile FOR a sweep (mesh-fillet's planar-chain tool) can express it in exactly the
|
|
48
55
|
// frame the sweep will use, instead of replicating the reference-vector pick and drifting
|
|
@@ -91,7 +98,7 @@ export function resolveSweepStations(profile2D, path3D, { closed = false, corner
|
|
|
91
98
|
const axisRaw = cross(tIn, tOut), s = vlen(axisRaw);
|
|
92
99
|
const cdot = Math.max(-1, Math.min(1, dot(tIn, tOut)));
|
|
93
100
|
if (cdot < -1 + 1e-6)
|
|
94
|
-
throw
|
|
101
|
+
throw foldError(`sweep: 180° reversal at vertex ${vtx} is ambiguous — insert an intermediate point or use cornerRadius`, vtx);
|
|
95
102
|
if (s < EPS) { stations.push(placeRing(profile2D, center, N, B)); return; } // collinear: no turn, frame unchanged
|
|
96
103
|
const axis = scl(axisRaw, 1 / s);
|
|
97
104
|
const theta = Math.atan2(s, cdot); // exterior turn angle
|
|
@@ -127,7 +134,7 @@ export function resolveSweepStations(profile2D, path3D, { closed = false, corner
|
|
|
127
134
|
let reachIn = 0;
|
|
128
135
|
for (const [x, y] of profile2D) reachIn = Math.max(reachIn, x * uN + y * uB);
|
|
129
136
|
if ((reachIn / cosh) * Math.tan(theta / 2) > 0.5 * Math.min(lenIn, lenOut))
|
|
130
|
-
throw
|
|
137
|
+
throw foldError(`sweep: profile too wide for the bend at vertex ${vtx} (turn too sharp / segment too short) — increase cornerRadius or lengthen the segment`, vtx);
|
|
131
138
|
stations.push(profile2D.map(([x, y]) => {
|
|
132
139
|
const p = add(scl(Nh, x), scl(Bh, y)); // profile point in the miter plane (spanned by u, axis)
|
|
133
140
|
return add(center, add(scl(axis, dot(p, axis)), scl(u, dot(p, u) / cosh))); // stretch the u component
|
package/src/framework/jobs.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { meshTo3MF } from "./geometry/threemf.js";
|
|
6
6
|
import { exportablePartNames } from "./export-select.js";
|
|
7
7
|
import { resolveFonts } from "./fonts.js";
|
|
8
|
-
import { normalizeOpentype } from "./geometry/opentype-interop.js";
|
|
8
|
+
import { normalizeOpentype, parseFont } from "./geometry/opentype-interop.js";
|
|
9
9
|
import { ensureImports, resolveImports } from "./imports.js";
|
|
10
10
|
import { safeName } from "./safe-name.js";
|
|
11
11
|
import { exportSubParts, resolveParams, buildPosed } from "./part-model.js";
|
|
@@ -107,7 +107,7 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
107
107
|
if (part.fonts && kernel._fonts) {
|
|
108
108
|
const opentype = normalizeOpentype(await import("opentype.js"));
|
|
109
109
|
const bufs = await resolveFonts(part.fonts);
|
|
110
|
-
for (const [name, buf] of bufs) if (!kernel._fonts.has(name)) kernel._fonts.set(name, opentype
|
|
110
|
+
for (const [name, buf] of bufs) if (!kernel._fonts.has(name)) kernel._fonts.set(name, parseFont(opentype, buf, name));
|
|
111
111
|
}
|
|
112
112
|
// Register this part's declared imports on the kernel running this job — the
|
|
113
113
|
// import-asset sibling of the fonts preload above. See ensureImports for the
|
package/src/testing/manifold.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import Module from "manifold-3d";
|
|
5
5
|
import { createManifoldKernel } from "../framework/geometry/manifold-backend.js";
|
|
6
6
|
import { resolveFonts } from "../framework/fonts.js";
|
|
7
|
-
import { normalizeOpentype } from "../framework/geometry/opentype-interop.js";
|
|
7
|
+
import { normalizeOpentype, parseFont } from "../framework/geometry/opentype-interop.js";
|
|
8
8
|
import { ensureImports } from "../framework/imports.js";
|
|
9
9
|
import { nodeAssetSources } from "./assets.js";
|
|
10
10
|
import { tessellateStepAssets } from "./step-mesh.js";
|
|
@@ -14,7 +14,7 @@ export async function bootManifoldKernel({ quality = "preview", fonts, imports,
|
|
|
14
14
|
wasm.setup();
|
|
15
15
|
const kernel = createManifoldKernel(wasm, { quality });
|
|
16
16
|
if (fonts) { const opentype = normalizeOpentype(await import("opentype.js"));
|
|
17
|
-
for (const [name, buf] of await resolveFonts(nodeAssetSources(fonts))) kernel._fonts.set(name, opentype
|
|
17
|
+
for (const [name, buf] of await resolveFonts(nodeAssetSources(fonts))) kernel._fonts.set(name, parseFont(opentype, buf, name)); }
|
|
18
18
|
if (imports) {
|
|
19
19
|
const decl = nodeAssetSources(imports);
|
|
20
20
|
const { resolveImports } = await import("../framework/imports.js");
|
package/src/testing/occt.js
CHANGED
|
@@ -6,7 +6,7 @@ import path from "path";
|
|
|
6
6
|
import fs from "fs";
|
|
7
7
|
import { createOcctKernel } from "../framework/geometry/occt-backend.js";
|
|
8
8
|
import { resolveFonts } from "../framework/fonts.js";
|
|
9
|
-
import { normalizeOpentype } from "../framework/geometry/opentype-interop.js";
|
|
9
|
+
import { normalizeOpentype, parseFont } from "../framework/geometry/opentype-interop.js";
|
|
10
10
|
import { ensureImports } from "../framework/imports.js";
|
|
11
11
|
import { nodeAssetSources } from "./assets.js";
|
|
12
12
|
|
|
@@ -20,7 +20,7 @@ export async function bootOcctKernel({ fonts, imports, importMeshes } = {}) {
|
|
|
20
20
|
replicad.setOC(OC);
|
|
21
21
|
const kernel = createOcctKernel(replicad);
|
|
22
22
|
if (fonts) { const opentype = normalizeOpentype(await import("opentype.js"));
|
|
23
|
-
for (const [name, buf] of await resolveFonts(nodeAssetSources(fonts))) kernel._fonts.set(name, opentype
|
|
23
|
+
for (const [name, buf] of await resolveFonts(nodeAssetSources(fonts))) kernel._fonts.set(name, parseFont(opentype, buf, name)); }
|
|
24
24
|
if (imports) await ensureImports(kernel, nodeAssetSources(imports), importMeshes ?? null);
|
|
25
25
|
return kernel;
|
|
26
26
|
}
|