partforge 0.65.1 → 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";
|
|
@@ -123,12 +123,6 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
123
123
|
// degenerate normals poison the crease pass into drawing phantom edge lines).
|
|
124
124
|
// Unsupported edge classes (helical edges, varying dihedral, …) surface as
|
|
125
125
|
// KernelCapabilityError so the framework reroutes that sub-part to OCCT.
|
|
126
|
-
// simplify() will not collapse triangles across run (originalID) boundaries,
|
|
127
|
-
// and the boolean's sliver triangles sit exactly on them — so the result is
|
|
128
|
-
// re-originaled first. That folds every surface into one fresh original,
|
|
129
|
-
// which is also the documented B-rep semantic: fillet/chamfer produce new
|
|
130
|
-
// surfaces, so feature-label attribution downstream of the op uses the
|
|
131
|
-
// fallback path (AUTHORING-PARTS.md), and the blend shades SMOOTH.
|
|
132
126
|
const SIMPLIFY_EPS = 1e-4; // 0.1 µm — must exceed the boolean's sliver widths (~2e-5)
|
|
133
127
|
// Debris sweep: where blend tools graze each other or a flank near-tangentially, the
|
|
134
128
|
// boolean can strand a CLOSED femto-component (measured ~1e-8 mm³, 4 triangles) that
|
|
@@ -144,9 +138,30 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
144
138
|
for (const p of parts) { T(p); if (p.volume() >= DEBRIS_VOL) kept.push(p); }
|
|
145
139
|
return kept.length === parts.length ? m : T(Manifold.compose(kept));
|
|
146
140
|
};
|
|
147
|
-
|
|
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) => {
|
|
148
156
|
try {
|
|
149
|
-
|
|
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)));
|
|
150
165
|
} catch (e) {
|
|
151
166
|
if (e instanceof UnsupportedEdgeError) throw new KernelCapabilityError(`${op}: ${e.message}`);
|
|
152
167
|
throw e;
|
|
@@ -161,13 +176,13 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
161
176
|
if (typeof selector === "function") throw new KernelCapabilityError("fillet: function selectors need the OCCT backend");
|
|
162
177
|
if (r === 0) return wrap(m, hash); // contract: zero magnitude is the identity
|
|
163
178
|
return cached(h("fillet", hash, r, selector ?? null, segs), () =>
|
|
164
|
-
meshCadOp("fillet", () => meshFillet(kernel, wrap(m, hash), { r, edges: selector, segs })));
|
|
179
|
+
meshCadOp("fillet", m, () => meshFillet(kernel, wrap(m, hash), { r, edges: selector, segs })));
|
|
165
180
|
},
|
|
166
181
|
chamfer: (d, selector) => {
|
|
167
182
|
if (typeof selector === "function") throw new KernelCapabilityError("chamfer: function selectors need the OCCT backend");
|
|
168
183
|
if (d === 0) return wrap(m, hash);
|
|
169
184
|
return cached(h("chamfer", hash, d, selector ?? null, segs), () =>
|
|
170
|
-
meshCadOp("chamfer", () => meshChamfer(kernel, wrap(m, hash), { d, edges: selector, segs })));
|
|
185
|
+
meshCadOp("chamfer", m, () => meshChamfer(kernel, wrap(m, hash), { d, edges: selector, segs })));
|
|
171
186
|
},
|
|
172
187
|
roundAll: (r) => {
|
|
173
188
|
if (r === 0) return wrap(m, hash); // contract: zero magnitude is the identity
|
|
@@ -192,6 +207,49 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
192
207
|
label: (name) => {
|
|
193
208
|
const lh = h("label", hash, name);
|
|
194
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?.();
|
|
195
253
|
const prevId = typeof m.originalID === "function" ? m.originalID() : -1;
|
|
196
254
|
const o = T(m.asOriginal());
|
|
197
255
|
const id = o.originalID();
|
|
@@ -231,7 +289,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
231
289
|
let bestWeight = -1, bestPol;
|
|
232
290
|
for (let r = 0; r < roid.length; r++) {
|
|
233
291
|
const pol = oidPolicies.get(roid[r]) ?? SMOOTH;
|
|
234
|
-
const key = `${pol.creaseAngle}/${pol.sameSurfaceLines}`;
|
|
292
|
+
const key = `${pol.creaseAngle}/${pol.sameSurfaceLines}/${!!pol.boundaryLines}`;
|
|
235
293
|
const weight = (weightByKey.get(key) || 0) + (ri[r + 1] / 3 - ri[r] / 3);
|
|
236
294
|
weightByKey.set(key, weight);
|
|
237
295
|
const better = weight > bestWeight || (weight === bestWeight && !pol.sameSurfaceLines && bestPol?.sameSurfaceLines);
|
|
@@ -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
|