partforge 0.146.1 → 0.146.3
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/ERROR-PATTERNS.md
CHANGED
|
@@ -78,7 +78,13 @@ The framework itself rebuilds each sub-part fresh per job and applies `place` on
|
|
|
78
78
|
|
|
79
79
|
- **Symptom:** `produced an impossible result` — the full message reads `boolean result invalid: <op> produced an impossible result — …`, thrown from `union`, `cut`, `cutAll` or `intersect`, naming one of: `a negative volume`, `a union smaller than its largest operand`, `a cut larger than its body`, `a cut that emptied its body although the tools cover at most <x> mm³ of its <y> mm³`, `an intersection larger than its smallest operand`.
|
|
80
80
|
- **Cause:** The kernel's boolean returned geometry that violates the one property no boolean may (a union contains its inputs, a cut only removes, an intersection lies inside each input, a solid has non-negative volume) without reporting an error. On the exact kernel this follows a tangent or self-touching contact the coincidence guard could not recognise up front ([boolean-coincident-faces-hang](#boolean-coincident-faces-hang) covers the form it does refuse); on the mesh kernel it should never happen and would be a kernel bug worth reporting. The gate carries 1% slack on the largest operand, so volume-integration noise cannot trip it.
|
|
81
|
-
- **Fix:** The same menu as the coincidence guard's — genuine overlap or genuine clearance (0.05 mm or more) instead of exact contact, cut tools overcut past the faces they pierce, threads in the periodic `screwSweep` form or via `k.tappedBore`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Gotchas". A refusal is a throw, not a warning, on purpose: the alternative is the silently wrong part these rules exist to stop, and a live preview keeps its last good mesh on screen across a failed rebuild.
|
|
81
|
+
- **Fix:** The same menu as the coincidence guard's — genuine overlap or genuine clearance (0.05 mm or more) instead of exact contact, cut tools overcut past the faces they pierce, threads in the periodic `screwSweep` form or via `k.tappedBore`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Gotchas". A refusal is a throw, not a warning, on purpose: the alternative is the silently wrong part these rules exist to stop, and a live preview keeps its last good mesh on screen across a failed rebuild. A result of exactly `0 mm³` on the mesh kernel almost always means an operand was already broken, not the boolean. Since 0.146.3 that case is reported as [boolean-invalid-operand](#boolean-invalid-operand) instead.
|
|
82
|
+
|
|
83
|
+
## boolean-invalid-operand
|
|
84
|
+
|
|
85
|
+
- **Symptom:** `<op>: operand <n> is not a valid solid (Manifold status <status>)`, thrown from `union`, `cut`, `cutAll` or `intersect` on the mesh kernel; or `prism: the profile encloses no area` / `extrude: the profile encloses no area`, thrown from the op that built the broken operand.
|
|
86
|
+
- **Cause:** An earlier op built nothing: a primitive with a zero dimension (`cylinder({r: 0})`), or a cross-section with no area (points that are collinear, repeated, or cancel each other out). Manifold marks such a solid as an error rather than throwing, and any boolean it reaches then comes back empty. Before 0.146.3 the boolean result gate reported that as [boolean-impossible-result](#boolean-impossible-result), and a `prism` whose single outline was wound clockwise produced it too. A prism now accepts either winding, like `extrude` and the OCCT backend.
|
|
87
|
+
- **Fix:** Give the operand real size. Check the op that built it and the parameter values that size it. A dimension driven to zero, or two profile points that coincide, is the usual culprit. If a parameter can legitimately reach zero, skip the feature in `build` when it does. Do not add overlap or clearance: the boolean was never the problem.
|
|
82
88
|
|
|
83
89
|
## chamfer-rescue-bisection
|
|
84
90
|
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -288,7 +288,7 @@ above. All ops return a `Solid`.
|
|
|
288
288
|
| `torus({ rMajor, rMinor })` | Torus centered at the origin, tube centerline in the z = 0 plane; requires 0 < rMinor < rMajor. Curve-exact on B-rep backends. Options-only. |
|
|
289
289
|
| `roundedBox({ size, center?, round })` | Box with selectively rounded edges; `round`: number = every edge, or `{ side?, top?, bottom? }` (vertical edges / top rim / bottom rim). Corner semantics and the `0 < side < rim` clamp-with-warning rule are normative in the design spec and summarized under [Rounded primitives](#rounded-primitives). Options-only. |
|
|
290
290
|
| `box({size, center?})` · `box({min, max})` | Axis-aligned box: `{size:[x,y,z]}` centered in X/Y with base at z = 0 (`center: true` also centers Z), or explicit `[x,y,z]` `{min, max}` corners. |
|
|
291
|
-
| `prism({points, h, twist?, scaleTop?})` | Extrude one
|
|
291
|
+
| `prism({points, h, twist?, scaleTop?})` | Extrude one contour (point list or arc profile; either winding, CCW by convention) from z = 0. `twist` = total degrees over the height; `scaleTop` = uniform top scale (1 straight, 0 → apex). |
|
|
292
292
|
| `extrude({profile, h, twist?, scaleTop?, bevel?})` | Same, for a polygon-with-holes region — `profile` is `{outer, holes?}` (bare contour = outer only) — in one op, no per-hole boolean. `profile` may also be a `Shape2D` (see below). `bevel` (number = both rims, `{bottom?, top?}` = per rim) cuts a 45° rim bevel; it desugars at the shared front into extrude + loft + intersect/cut, so it is backend-identical by construction and is **not** a CAD-only op (no OCCT routing). Every profile form works — point array, arc profile, `{outer, holes}` (hole rims flare outward), or `Shape2D` (multi-region bevels each and unions) — but curved profiles are **materialized to point rings** first, so a beveled extrusion is faceted at the sampling LOD even in STEP (arc contours at a fixed pure-JS LOD, backend-identical; a `Shape2D` at its backend's own LOD — `hull`'s parity class). No `twist`/`scaleTop`, and `bottom + top < h` or it throws; a bevel a rim's narrow features cannot take is deterministically reduced with a console warning (`ERROR-PATTERNS.md#extrude-bevel-reduced`). |
|
|
293
293
|
| `revolve({profile, degrees?})` | Revolve a lathe profile in `[r, z]` (r ≥ 0) about Z; `degrees` < 360 gives a capped partial revolve. Default 360. `profile` may be a point list, a `Shape2D`, or — lifted to a `Shape2D` by the kernel front before the op runs — a `{start, segments}` path contour or an `{outer, holes}` region. A mesh backend spends its circle count in proportion to the sweep. |
|
|
294
294
|
| `loft({rings, ruled?, closed?})` | Stack cross-sections along Z with ruled walls and capped ends (per-ring `z`/`rotate`/`scale`). A ring's `polygon` may be a point list, `sides`+`radius`, a curve contour, or a single-region hole-free `Shape2D` (multi-region / holed shapes throw). Rings with **identical all-line segment structure** (one straight-sided shape reused at different z/scale/rotate) loft **bit-identically** on both backends — parity by construction, unchanged legacy behavior. Rings with **identical curve structure** (containing arcs or Béziers, the same shape at different z/scale/rotate) loft curve-natively on a B-rep kernel — STEP keeps exact arc edges — while a mesh kernel facets the same sections at a fixed LOD (`hull`'s parity class). **Structurally different rings** (a rounded square morphing to a circle, unequal-N point lists) are arc-length-resampled once, in shared pure-JS code, to a common vertex count with a deterministic seam (the outermost +X-ray crossing from each ring's centroid; per-ring `rotate` tunes the phase) and snapped corners — every backend then lofts the **identical** resampled point rings, so the result is parity **by construction** and STEP is faceted at the sampling LOD. Must self-correct a fully inverted result (CW rings / descending z) to an outward solid. |
|
package/package.json
CHANGED
|
@@ -21,6 +21,7 @@ import { KernelCapabilityError } from "./errors.js";
|
|
|
21
21
|
import { isPlainOptions, KERNEL_OP_SPECS } from "./op-options.js";
|
|
22
22
|
import { isPathContour, contourToPoints } from "./profile.js";
|
|
23
23
|
import { textGlyphs } from "./text2d.js";
|
|
24
|
+
import { unionMany } from "./union-many.js";
|
|
24
25
|
import { placeRegions } from "./vector2d.js";
|
|
25
26
|
import { beveledExtrude } from "./rim-bevel.js";
|
|
26
27
|
import { DEFAULT_FONT_BYTES } from "./fonts/default-font.js";
|
|
@@ -279,7 +280,7 @@ export function finishKernel(k) {
|
|
|
279
280
|
if (regions.length === 0) throw new Error("text2d: string produced no glyph geometry (empty or all-whitespace?)");
|
|
280
281
|
// Glyph regions are machine-resolved by curve-fill — trusted, so a text
|
|
281
282
|
// part does not pay profile validation per glyph per rebuild.
|
|
282
|
-
return regions.map((r) => k.shape2d.trusted(r))
|
|
283
|
+
return unionMany(regions.map((r) => k.shape2d.trusted(r)), k.shape2d.trusted);
|
|
283
284
|
};
|
|
284
285
|
|
|
285
286
|
// 2-D vector art as a Shape2D. Backend-agnostic for the same reason text2d is:
|
|
@@ -300,7 +301,7 @@ export function finishKernel(k) {
|
|
|
300
301
|
if (!doc) throw new Error(`vector2d: unknown vector "${name}" — declare it in the part's \`vectors\` field`);
|
|
301
302
|
// vector documents carry their own validation (VECTOR-FORMAT.md); trusted lift
|
|
302
303
|
const lift = (regions, measureAgainst = regions) =>
|
|
303
|
-
placeRegions(regions, doc.units, opts, { measureAgainst, name }).map((r) => k.shape2d.trusted(r))
|
|
304
|
+
unionMany(placeRegions(regions, doc.units, opts, { measureAgainst, name }).map((r) => k.shape2d.trusted(r)), k.shape2d.trusted);
|
|
304
305
|
if (opts.shape != null) {
|
|
305
306
|
const entry = doc.shapes.get(opts.shape);
|
|
306
307
|
if (!entry) {
|
|
@@ -25,6 +25,21 @@ import { checkBooleanResult } from "./boolean-gate.js";
|
|
|
25
25
|
import { KernelCapabilityError } from "./errors.js";
|
|
26
26
|
import { heightfieldMesh, hashGridData } from "./heightfield.js";
|
|
27
27
|
|
|
28
|
+
// A boolean whose operand was already broken. See `judged` below.
|
|
29
|
+
const invalidOperand = (op, index, status) => new Error(
|
|
30
|
+
`${op}: operand ${index} is not a valid solid (Manifold status ${status}) — an earlier op ` +
|
|
31
|
+
"built nothing, so this boolean has nothing to work with. The usual causes are a primitive " +
|
|
32
|
+
"with a zero dimension (a radius, height or size driven to 0) and a profile that encloses no " +
|
|
33
|
+
"area (collinear, repeated, or self-cancelling points). Check the op that built this operand " +
|
|
34
|
+
"and the parameter values that size it. " +
|
|
35
|
+
"See ERROR-PATTERNS.md#boolean-invalid-operand.");
|
|
36
|
+
|
|
37
|
+
// A point-list profile whose cross-section came out empty. Thrown at the op,
|
|
38
|
+
// before the empty solid can reach a boolean and be misreported there.
|
|
39
|
+
const emptyProfile = (op) => new Error(
|
|
40
|
+
`${op}: the profile encloses no area — nothing to build (its points may be collinear, ` +
|
|
41
|
+
"repeated, or cancel each other out). See ERROR-PATTERNS.md#boolean-invalid-operand.");
|
|
42
|
+
|
|
28
43
|
const PLANE_NORMAL = { XY: [0, 0, 1], XZ: [0, 1, 0], YZ: [1, 0, 0] };
|
|
29
44
|
// 'preview' = interactive view (fast); 'print' = STL export (high-res, used only
|
|
30
45
|
// by the export path — Manifold meshing is cheap, so we tessellate generously).
|
|
@@ -89,6 +104,14 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
89
104
|
};
|
|
90
105
|
const BOX_SLACK = 1e-3; // mm — an enclosure test, not a measurement
|
|
91
106
|
const judged = (op, operandMs, resultM) => {
|
|
107
|
+
// An operand that is already in an error state (Manifold's status, e.g.
|
|
108
|
+
// InvalidConstruction from extruding an empty cross-section) makes the
|
|
109
|
+
// boolean return an empty error-state result. Judged by volume, that reads
|
|
110
|
+
// as "a union smaller than its largest operand" or "a cut that emptied
|
|
111
|
+
// its body", and the coaching sends the author hunting for tangent contacts
|
|
112
|
+
// that are not there. It is the operand's failure, so say that instead.
|
|
113
|
+
const bad = operandMs.findIndex((mm) => mm.status() !== "NoError");
|
|
114
|
+
if (bad >= 0) throw invalidOperand(op, bad, operandMs[bad].status());
|
|
92
115
|
const box = (mm) => mm.boundingBox();
|
|
93
116
|
const err = checkBooleanResult(op, operandMs.map(volumeOf), volumeOf(resultM), {
|
|
94
117
|
overlap: (i, j) => {
|
|
@@ -760,7 +783,13 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
760
783
|
},
|
|
761
784
|
prism: (pts, height, { twist = 0, scaleTop = 1 } = {}) =>
|
|
762
785
|
cached(h("prism", pts, height, twist, scaleTop, segs), () => {
|
|
763
|
-
|
|
786
|
+
// Even/odd like `extrude` below and like the OCCT prism, so the outline's
|
|
787
|
+
// winding does not matter. Under Manifold's default Positive rule a
|
|
788
|
+
// CLOCKWISE outline filled nothing: the extrude came back
|
|
789
|
+
// InvalidConstruction and the next boolean reported an "impossible
|
|
790
|
+
// result" (118 production builds in two weeks, every one a 0 mm³ result).
|
|
791
|
+
const cs = T(CrossSection.ofPolygons([tessellateContour(pts, segsAt)], "EvenOdd"));
|
|
792
|
+
if (cs.isEmpty()) throw emptyProfile("prism");
|
|
764
793
|
if (twist === 0 && scaleTop === 1) return T(cs.extrude(height));
|
|
765
794
|
const nDiv = Math.max(1, Math.ceil(Math.abs(twist) / 5));
|
|
766
795
|
// Manifold's extrude scaleTop is a Vec2 — a scalar is NOT broadcast (it scales
|
|
@@ -856,6 +885,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
856
885
|
const { outer, holes } = tessellateProfile(profile, segsAt);
|
|
857
886
|
return T(CrossSection.ofPolygons([outer, ...holes], "EvenOdd"));
|
|
858
887
|
})();
|
|
888
|
+
if (cs.isEmpty()) throw emptyProfile("extrude");
|
|
859
889
|
if (twist === 0 && scaleTop === 1) return T(cs.extrude(height));
|
|
860
890
|
const nDiv = Math.max(1, Math.ceil(Math.abs(twist) / 5));
|
|
861
891
|
return T(cs.extrude(height, nDiv, twist, [scaleTop, scaleTop]));
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Union of many Shape2Ds — the text2d / vector2d reduction — without the
|
|
2
|
+
// quadratic left fold those ops used to run.
|
|
3
|
+
//
|
|
4
|
+
// `shapes.reduce((a, b) => a.union(b))` hands paper.js the WHOLE accumulated
|
|
5
|
+
// result on every step, so n glyphs cost about n²/2 glyph conversions plus n
|
|
6
|
+
// planar booleans over an ever-growing compound path. Measured on a 4-line,
|
|
7
|
+
// 290-glyph paragraph (feedback #169, a bathroom sign): 14 s for text2d alone,
|
|
8
|
+
// over a minute for the forge. Two independent savings, applied in order:
|
|
9
|
+
//
|
|
10
|
+
// 1. Shapes whose bounding boxes do not touch cannot share a point, so their
|
|
11
|
+
// union is just their regions side by side — no boolean at all. Text is
|
|
12
|
+
// almost entirely this case: letters sit apart, and only a kerned pair, a
|
|
13
|
+
// script face or negative tracking makes neighbours overlap. Shapes are
|
|
14
|
+
// grouped into clusters whose boxes overlap (transitively); the clusters
|
|
15
|
+
// are mutually disjoint, so concatenating them IS the union.
|
|
16
|
+
// 2. Inside a cluster that does need booleans, the union is balanced
|
|
17
|
+
// (pairs, then pairs of pairs), so each shape is converted O(log n)
|
|
18
|
+
// times instead of O(n).
|
|
19
|
+
//
|
|
20
|
+
// Bounds are paper's exact curve bounds (Shape2D.boundingBox), padded by EPS so
|
|
21
|
+
// shapes that merely touch along an edge still meet a boolean — touching
|
|
22
|
+
// glyphs must fuse, not sit as two regions sharing an edge.
|
|
23
|
+
//
|
|
24
|
+
// Deterministic: clusters are emitted in the order of their first member, and
|
|
25
|
+
// members keep input order, so the same inputs give the same regions (and so
|
|
26
|
+
// the same Shape2D hash — build purity rests on it).
|
|
27
|
+
const EPS = 1e-6;
|
|
28
|
+
|
|
29
|
+
// `fromContours` builds a Shape2D from a list of already-resolved regions (the
|
|
30
|
+
// kernel's k.shape2d.trusted) — passed in so this module needs no kernel.
|
|
31
|
+
export function unionMany(shapes, fromContours) {
|
|
32
|
+
if (shapes.length === 0) throw new Error("unionMany: no shapes");
|
|
33
|
+
if (shapes.length === 1) return shapes[0];
|
|
34
|
+
|
|
35
|
+
const boxes = shapes.map((s) => s.boundingBox());
|
|
36
|
+
const n = shapes.length;
|
|
37
|
+
const parent = Array.from({ length: n }, (_, i) => i);
|
|
38
|
+
const find = (i) => { while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; } return i; };
|
|
39
|
+
const join = (a, b) => { const ra = find(a), rb = find(b); if (ra !== rb) parent[Math.max(ra, rb)] = Math.min(ra, rb); };
|
|
40
|
+
|
|
41
|
+
// Sweep in x: `active` holds every box whose x-range can still reach the
|
|
42
|
+
// current one. Worst case (a vertical column of shapes) is O(n²) box checks —
|
|
43
|
+
// cheap arithmetic, nothing like the booleans it saves.
|
|
44
|
+
const order = [...boxes.keys()].sort((a, b) => boxes[a].min[0] - boxes[b].min[0] || a - b);
|
|
45
|
+
let active = [];
|
|
46
|
+
for (const i of order) {
|
|
47
|
+
const bi = boxes[i];
|
|
48
|
+
active = active.filter((j) => boxes[j].max[0] + EPS >= bi.min[0]);
|
|
49
|
+
for (const j of active) {
|
|
50
|
+
const bj = boxes[j];
|
|
51
|
+
if (bj.min[1] - EPS <= bi.max[1] && bi.min[1] - EPS <= bj.max[1]) join(i, j);
|
|
52
|
+
}
|
|
53
|
+
active.push(i);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const clusters = new Map(); // root → member indices, input order
|
|
57
|
+
for (let i = 0; i < n; i++) {
|
|
58
|
+
const r = find(i);
|
|
59
|
+
if (!clusters.has(r)) clusters.set(r, []);
|
|
60
|
+
clusters.get(r).push(i);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const merged = [...clusters.values()].map((members) => balancedUnion(members.map((i) => shapes[i])));
|
|
64
|
+
if (merged.length === 1) return merged[0];
|
|
65
|
+
return fromContours(merged.flatMap((s) => s.toContours()));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function balancedUnion(xs) {
|
|
69
|
+
while (xs.length > 1) {
|
|
70
|
+
const next = [];
|
|
71
|
+
for (let i = 0; i < xs.length; i += 2) next.push(i + 1 < xs.length ? xs[i].union(xs[i + 1]) : xs[i]);
|
|
72
|
+
xs = next;
|
|
73
|
+
}
|
|
74
|
+
return xs[0];
|
|
75
|
+
}
|