partforge 0.146.0 → 0.146.2
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
|
@@ -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) {
|
|
@@ -1,87 +1,175 @@
|
|
|
1
1
|
// Minimal 3MF reader, the read twin of threemf.js's writer. 3MF is an OPC
|
|
2
|
-
// package (a zip) holding
|
|
3
|
-
// extract vertices/triangles
|
|
4
|
-
// merge every build item into one soup-free indexed mesh in
|
|
2
|
+
// package (a zip) holding one or more XML model parts; unzip (fflate), find the
|
|
3
|
+
// ROOT model part, extract vertices/triangles, resolve <components> and per-item
|
|
4
|
+
// transforms, and merge every build item into one soup-free indexed mesh in
|
|
5
|
+
// millimetres.
|
|
6
|
+
//
|
|
7
|
+
// Slicer project files (Bambu Studio, Orca, PrusaSlicer) keep no mesh in the
|
|
8
|
+
// root part at all: its objects are <components> pointing, through the
|
|
9
|
+
// Production extension's `p:path`, at meshes in other parts
|
|
10
|
+
// (3D/Objects/object_1.model). Reading only one part — or only top-level
|
|
11
|
+
// <object><mesh> — finds nothing in exactly the files people export most.
|
|
5
12
|
//
|
|
6
13
|
// Regex-based extraction, NOT a DOM parse — workers have no DOMParser and the
|
|
7
14
|
// worker graph must stay DOM-free (test/worker-layering.test.js enforces
|
|
8
15
|
// this transitively). Scope: geometry only — materials, colors and beam
|
|
9
|
-
// lattices are ignored
|
|
10
|
-
// (no <components> nesting).
|
|
16
|
+
// lattices are ignored.
|
|
11
17
|
import { unzipSync } from "fflate";
|
|
12
18
|
|
|
13
19
|
const UNIT_MM = { micron: 0.001, millimeter: 1, centimeter: 10, inch: 25.4, foot: 304.8, meter: 1000 };
|
|
20
|
+
const MAX_COMPONENT_DEPTH = 32;
|
|
21
|
+
const IDENTITY = [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0];
|
|
14
22
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
23
|
+
// Part names are compared without a leading slash and case-insensitively: OPC
|
|
24
|
+
// names are case-insensitive, and references carry a leading "/" that zip
|
|
25
|
+
// entry names do not.
|
|
26
|
+
const partKey = (name) => name.replace(/^\/+/, "").toLowerCase();
|
|
27
|
+
|
|
28
|
+
// Transforms are 12 numbers, row-major 4x3 with the translation in the last
|
|
29
|
+
// row, per the 3MF core spec (applied to a row vector: v' = v*M, i.e.
|
|
30
|
+
// x' = x*m00 + y*m10 + z*m20 + m30). The translation is in the units of the
|
|
31
|
+
// part that holds the transform, so it is scaled to mm on read; the linear
|
|
32
|
+
// part is unitless.
|
|
33
|
+
function readTransform(tag, scale) {
|
|
34
|
+
const raw = tag.match(/\btransform="([^"]+)"/)?.[1];
|
|
35
|
+
if (!raw) return IDENTITY;
|
|
36
|
+
const t = raw.trim().split(/\s+/).map(Number);
|
|
37
|
+
if (t.length !== 12 || t.some((n) => !Number.isFinite(n))) return IDENTITY;
|
|
38
|
+
return [...t.slice(0, 9), t[9] * scale, t[10] * scale, t[11] * scale];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Child first, then parent: v * C * P.
|
|
42
|
+
function compose(c, p) {
|
|
43
|
+
const r = new Array(12);
|
|
44
|
+
for (let row = 0; row < 4; row++) {
|
|
45
|
+
for (let col = 0; col < 3; col++) {
|
|
46
|
+
r[row * 3 + col] =
|
|
47
|
+
c[row * 3] * p[col] + c[row * 3 + 1] * p[3 + col] + c[row * 3 + 2] * p[6 + col] + (row === 3 ? p[9 + col] : 0);
|
|
48
|
+
}
|
|
22
49
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
50
|
+
return r;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The OPC/Production-extension `path` attribute, whatever its namespace prefix.
|
|
54
|
+
const readPath = (tag) => tag.match(/\s(?:[\w.-]+:)?path="([^"]+)"/)?.[1] ?? null;
|
|
55
|
+
|
|
56
|
+
function rootModelPath(files, keys) {
|
|
57
|
+
const rels = files.get("_rels/.rels");
|
|
58
|
+
if (rels) {
|
|
59
|
+
const xml = new TextDecoder().decode(rels);
|
|
60
|
+
for (const m of xml.matchAll(/<Relationship\b[^>]*>/g)) {
|
|
61
|
+
const type = m[0].match(/\bType="([^"]+)"/)?.[1] ?? "";
|
|
62
|
+
const target = m[0].match(/\bTarget="([^"]+)"/)?.[1];
|
|
63
|
+
if (target && /\/3dmodel$/i.test(type) && files.has(partKey(target))) return partKey(target);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (files.has("3d/3dmodel.model")) return "3d/3dmodel.model";
|
|
67
|
+
return keys.find((k) => k.endsWith(".model")) ?? null;
|
|
68
|
+
}
|
|
26
69
|
|
|
70
|
+
function parseModelPart(xml, key) {
|
|
27
71
|
const unit = xml.match(/<model\b[^>]*\bunit="([^"]+)"/)?.[1] ?? "millimeter";
|
|
28
72
|
const scale = UNIT_MM[unit];
|
|
29
73
|
if (!scale) throw new Error(`3mf import: unknown unit "${unit}"`);
|
|
30
74
|
|
|
31
|
-
// objects: id -> {
|
|
75
|
+
// objects: id -> { type, mesh: {P (mm), I} | null, components: [{key, id, t}] }
|
|
32
76
|
const objects = new Map();
|
|
33
|
-
const objRe = /<object\b[^>]
|
|
77
|
+
const objRe = /<object\b([^>]*)>([\s\S]*?)<\/object>/g;
|
|
34
78
|
for (let m; (m = objRe.exec(xml)); ) {
|
|
35
|
-
const [,
|
|
79
|
+
const [, attrs, body] = m;
|
|
80
|
+
const id = attrs.match(/\bid="(\d+)"/)?.[1];
|
|
81
|
+
if (!id) continue;
|
|
82
|
+
const type = attrs.match(/\btype="([^"]+)"/)?.[1] ?? "model";
|
|
36
83
|
const P = [], I = [];
|
|
37
84
|
const vRe = /<vertex\b[^>]*\bx="([^"]+)"[^>]*\by="([^"]+)"[^>]*\bz="([^"]+)"/g;
|
|
38
85
|
for (let v; (v = vRe.exec(body)); ) P.push(+v[1] * scale, +v[2] * scale, +v[3] * scale);
|
|
39
86
|
const tRe = /<triangle\b[^>]*\bv1="(\d+)"[^>]*\bv2="(\d+)"[^>]*\bv3="(\d+)"/g;
|
|
40
87
|
for (let t; (t = tRe.exec(body)); ) I.push(+t[1], +t[2], +t[3]);
|
|
41
|
-
|
|
88
|
+
const components = [];
|
|
89
|
+
for (const c of body.matchAll(/<component\b[^>]*>/g)) {
|
|
90
|
+
const cid = c[0].match(/\bobjectid="(\d+)"/)?.[1];
|
|
91
|
+
if (!cid) continue;
|
|
92
|
+
const path = readPath(c[0]);
|
|
93
|
+
components.push({ key: path ? partKey(path) : key, id: cid, t: readTransform(c[0], scale) });
|
|
94
|
+
}
|
|
95
|
+
objects.set(id, { type, mesh: I.length ? { P, I } : null, components });
|
|
42
96
|
}
|
|
43
|
-
if (objects.size === 0) throw new Error("3mf import: model contains no mesh geometry");
|
|
44
97
|
|
|
45
|
-
// Build items
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
// Attributes are pulled independently from each <item> tag (rather than in
|
|
52
|
-
// one fixed-order regex) because `objectid` and `transform` can appear in
|
|
53
|
-
// either order and a single ordered pattern with an optional middle group
|
|
54
|
-
// can match the tag while silently leaving `transform` uncaptured.
|
|
98
|
+
// Build items. Attributes are pulled independently from each <item> tag
|
|
99
|
+
// (rather than in one fixed-order regex) because `objectid` and `transform`
|
|
100
|
+
// can appear in either order and a single ordered pattern with an optional
|
|
101
|
+
// middle group can match the tag while silently leaving `transform`
|
|
102
|
+
// uncaptured. Items may be self-closing or carry children (metadata).
|
|
55
103
|
const items = [];
|
|
56
|
-
const
|
|
57
|
-
for (
|
|
58
|
-
const
|
|
59
|
-
const id = tag.match(/\bobjectid="(\d+)"/)?.[1];
|
|
104
|
+
const build = xml.match(/<build\b[^>]*>([\s\S]*?)<\/build>/)?.[1] ?? "";
|
|
105
|
+
for (const m of build.matchAll(/<item\b[^>]*>/g)) {
|
|
106
|
+
const id = m[0].match(/\bobjectid="(\d+)"/)?.[1];
|
|
60
107
|
if (!id) continue;
|
|
61
|
-
const
|
|
62
|
-
items.push({
|
|
108
|
+
const path = readPath(m[0]);
|
|
109
|
+
items.push({ key: path ? partKey(path) : key, id, t: readTransform(m[0], scale) });
|
|
110
|
+
}
|
|
111
|
+
return { objects, items };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function parse3MF(bytes) {
|
|
115
|
+
const u8 = bytes instanceof ArrayBuffer ? new Uint8Array(bytes) : bytes;
|
|
116
|
+
let raw;
|
|
117
|
+
try {
|
|
118
|
+
raw = unzipSync(u8);
|
|
119
|
+
} catch (e) {
|
|
120
|
+
throw new Error(`3mf import: not a readable zip archive (${e?.message || e})`, { cause: e });
|
|
63
121
|
}
|
|
64
|
-
const
|
|
122
|
+
const files = new Map(Object.entries(raw).map(([name, data]) => [partKey(name), data]));
|
|
123
|
+
const root = rootModelPath(files, [...files.keys()]);
|
|
124
|
+
if (!root) throw new Error("3mf import: archive has no 3D model part (*.model)");
|
|
125
|
+
|
|
126
|
+
const parts = new Map();
|
|
127
|
+
const partFor = (key) => {
|
|
128
|
+
if (!parts.has(key)) {
|
|
129
|
+
const data = files.get(key);
|
|
130
|
+
if (!data) throw new Error(`3mf import: a component references a missing model part (/${key})`);
|
|
131
|
+
parts.set(key, parseModelPart(new TextDecoder().decode(data), key));
|
|
132
|
+
}
|
|
133
|
+
return parts.get(key);
|
|
134
|
+
};
|
|
65
135
|
|
|
66
136
|
const V = [], Tr = [];
|
|
67
|
-
|
|
68
|
-
const o = objects.get(id);
|
|
69
|
-
if (!o) continue;
|
|
137
|
+
const emit = ({ P, I }, t) => {
|
|
70
138
|
const base = V.length / 3;
|
|
71
|
-
for (let i = 0; i <
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const y2 = t[1] * x + t[4] * y + t[7] * z + t[10] * scale;
|
|
79
|
-
const z2 = t[2] * x + t[5] * y + t[8] * z + t[11] * scale;
|
|
80
|
-
x = x2; y = y2; z = z2;
|
|
81
|
-
}
|
|
82
|
-
V.push(x, y, z);
|
|
139
|
+
for (let i = 0; i < P.length; i += 3) {
|
|
140
|
+
const x = P[i], y = P[i + 1], z = P[i + 2];
|
|
141
|
+
V.push(
|
|
142
|
+
t[0] * x + t[3] * y + t[6] * z + t[9],
|
|
143
|
+
t[1] * x + t[4] * y + t[7] * z + t[10],
|
|
144
|
+
t[2] * x + t[5] * y + t[8] * z + t[11],
|
|
145
|
+
);
|
|
83
146
|
}
|
|
84
|
-
for (const idx of
|
|
147
|
+
for (const idx of I) Tr.push(base + idx);
|
|
148
|
+
};
|
|
149
|
+
const place = (key, id, t, stack) => {
|
|
150
|
+
const ref = `${key}#${id}`;
|
|
151
|
+
if (stack.includes(ref) || stack.length > MAX_COMPONENT_DEPTH) {
|
|
152
|
+
throw new Error("3mf import: components reference each other in a loop");
|
|
153
|
+
}
|
|
154
|
+
const o = partFor(key).objects.get(id);
|
|
155
|
+
if (!o) return;
|
|
156
|
+
// Support and "other" objects are not part of the printed shape.
|
|
157
|
+
if (o.type === "support" || o.type === "other") return;
|
|
158
|
+
if (o.mesh) emit(o.mesh, t);
|
|
159
|
+
for (const c of o.components) place(c.key, c.id, compose(c.t, t), [...stack, ref]);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// No <build> items: every object in the root part that no component uses,
|
|
163
|
+
// at identity.
|
|
164
|
+
const rootPart = partFor(root);
|
|
165
|
+
let items = rootPart.items;
|
|
166
|
+
if (!items.length) {
|
|
167
|
+
const used = new Set();
|
|
168
|
+
for (const o of rootPart.objects.values()) for (const c of o.components) if (c.key === root) used.add(c.id);
|
|
169
|
+
items = [...rootPart.objects.keys()].filter((id) => !used.has(id)).map((id) => ({ key: root, id, t: IDENTITY }));
|
|
85
170
|
}
|
|
171
|
+
for (const { key, id, t } of items) place(key, id, t, []);
|
|
172
|
+
|
|
173
|
+
if (Tr.length === 0) throw new Error("3mf import: model contains no mesh geometry");
|
|
86
174
|
return { positions: Float32Array.from(V), indices: Uint32Array.from(Tr) };
|
|
87
175
|
}
|
|
@@ -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
|
+
}
|