Public: anyone can read this; the crew can change it.

A .3mf print project opens in the 3D view, like an STL #10899

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/10892-a-3mf-print-project-opens-in-the-3d-view → main 3 commits fork 3 threads · 3 unresolved

  • Merged. torque/10892-a-3mf-print-project-opens-in-the-3d-view was merged into main as 2129c51.

Review

Sign in to review this change.
8 files changed +425 −13
docs/features/file-media.md +10 −3
@@ -9,7 +9,7 @@ media type, for `<img>`, `<video>`, `<audio>`, `<object>` and `fetch()`.
99
1010 - **Types** come only from the allowlist in `src/web/render/kinds.ts`
1111 (png, jpeg, gif, webp, avif, bmp, ico, svg, pdf, mp3, ogg/oga, wav, flac,
12 m4a, aac, mp4, m4v, webm, mov, stl, glb). The extension proposes a type and
12 m4a, aac, mp4, m4v, webm, mov, stl, 3mf, glb). The extension proposes a type and
1313 the file's first bytes confirm it; a mismatch, or any other file, is
1414 `application/octet-stream`. Never HTML, never script.
1515 - **Headers:** `X-Content-Type-Options: nosniff` and
@@ -133,14 +133,21 @@ repository content only ever runs under the media route's sandbox. `render/markd
133133
134134 ## 3D models
135135
136 An `.stl` (ASCII or binary), `.obj`, `.glb` or `.gltf` file's page shows the
136 An `.stl` (ASCII or binary), `.obj`, `.3mf`, `.glb` or `.gltf` file's page shows the
137137 part, ready to turn (`src/web/render/model3d.ts`, #7335).
138138
139139 - **Turn it:** drag to orbit, scroll or pinch to zoom, right-drag or two
140140 fingers to pan; with the view focused, the arrow keys pan.
141141 - **The head** says the triangle count and the bounding box in millimetres,
142142 in the file's own axes. STL and OBJ units are taken as millimetres; glTF
143 is metres by its spec, so its box is multiplied by 1000.
143 is metres by its spec, so its box is multiplied by 1000. A `.3mf` print
144 project takes its units from the model's `unit` attribute (millimetre by
145 default; micron, centimetre, inch, foot and metre are scaled to mm), and
146 is stood Z-up like an STL. Its build items and transforms are applied.
147 A 3MF is refused, with a sentence, Source and Download kept, when it is
148 zip64, has an encrypted entry, holds more than 5 million triangles, inflates
149 to more than 64 MB of XML (`INFLATE_LIMIT`), or is a production-extension
150 model whose objects live in other files (it names an object that is not there).
144151 - **Wireframe** and **Axes** toggle in the head. The camera fits the part,
145152 a grid sits under it, and every part wears one neutral material. An STL
146153 is stood Z-up, the way a printer reads it.
src/web/render/kinds.ts +1
@@ -144,6 +144,7 @@ const MEDIA: Readonly<Record<string, Media>> = {
144144 otf: { kind: "font", name: "OpenType", type: "font/otf", magic: ascii("OTTO") },
145145 woff: { kind: "font", name: "WOFF", type: "font/woff", magic: ascii("wOFF") },
146146 woff2: { kind: "font", name: "WOFF2", type: "font/woff2", magic: ascii("wOF2") },
147 "3mf": { kind: "model3d", name: "3MF", type: "model/3mf", magic: bytes(0x50, 0x4b, 0x03, 0x04) },
147148 glb: { kind: "model3d", name: "glTF binary", type: "model/gltf-binary", magic: ascii("glTF") },
148149 };
149150
src/web/render/model-facts.ts +14 −4
@@ -3,14 +3,17 @@
33 * says `3D model · STL` over `12 triangles · 20 × 20 × 20 mm` where the page draws
44 * the part. Plain data in, plain data out.
55 *
6 * STL and OBJ go through the same parsers the browser runs (`mesh.ts`). A
7 * glTF is not parsed into geometry: its JSON already says each primitive's
8 * vertex count and each position accessor's bounds, so those are read, in
9 * metres by the spec, before any node transform.
6 * A 3MF is read by `threemf.ts` (its XML, in millimetres). STL and OBJ go
outdated

Re-wrap this comment line; it runs far past the surrounding lines.

7 * through the same parsers the browser runs (`mesh.ts`). A glTF is not parsed
8 * into geometry: its JSON already says each primitive's vertex count and each
9 * position accessor's bounds, so those are read, in metres by the spec, before
10 * any node transform.
1011 */
1112
13 import { inflateRawSync } from "node:zlib";
1214 import { kindByName } from "./kinds.ts";
1315 import { bounds, parseObj, parseStl, sizeText } from "./mesh.ts";
16 import { INFLATE_LIMIT, locateModel, parseModelXml } from "./threemf.ts";
1417
1518 /** Over this a model is named and sized, not read. */
1619 export const FACTS_CAP = 2_000_000;
@@ -51,6 +54,13 @@ export function modelFacts(path: string, bytes: Uint8Array | null, size: number)
5154 }
5255
5356 function read(ext: string, bytes: Uint8Array): Facts {
57 if (ext === "3mf") {
58 // The zip's model XML, inflated under the same cap the viewer holds.
59 const entry = locateModel(bytes);
60 const xml = entry.method === 0 ? entry.data : inflateRawSync(entry.data, { maxOutputLength: INFLATE_LIMIT });
61 const mesh = parseModelXml(new TextDecoder().decode(xml));
62 return { triangles: mesh.triangles, size: bounds(mesh.positions).size };
63 }
5464 if (ext === "stl" || ext === "obj") {
5565 const mesh = ext === "stl" ? parseStl(bytes) : parseObj(new TextDecoder().decode(bytes));
5666 return { triangles: mesh.triangles, size: bounds(mesh.positions).size };
src/web/render/model3d-client.ts +8 −5
@@ -35,6 +35,7 @@ import {
3535 import { OrbitControls } from "three/addons/controls/OrbitControls.js";
3636 import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
3737 import { REFUSED_URI, withinModelDir } from "./gltf-uri.ts";
38 import { parse3mf } from "./threemf.ts";
3839 import { bounds, parseObj, parseStl, sizeText, type Bounds } from "./mesh.ts";
3940
4041 /**
@@ -93,14 +94,16 @@ function soup(positions: Float32Array): Mesh {
9394 async function load(src: string, format: string): Promise<Loaded> {
9495 const response = await fetch(src, { credentials: "same-origin" });
9596 if (!response.ok) throw new Error(`the file answered ${response.status}`);
96 if (format === "stl" || format === "obj") {
97 if (format === "stl" || format === "obj" || format === "3mf") {
9798 const parsed =
98 format === "stl"
99 ? parseStl(new Uint8Array(await response.arrayBuffer()))
100 : parseObj(await response.text());
99 format === "obj"
100 ? parseObj(await response.text())
101 : format === "3mf"
102 ? await parse3mf(new Uint8Array(await response.arrayBuffer()))
103 : parseStl(new Uint8Array(await response.arrayBuffer()));
101104 const mesh = soup(parsed.positions);
102105 // A printed part is modelled Z-up; three.js stands Y-up.
103 if (format === "stl") mesh.rotation.x = -Math.PI / 2;
106 if (format === "stl" || format === "3mf") mesh.rotation.x = -Math.PI / 2;
104107 return { object: mesh, triangles: parsed.triangles, box: bounds(parsed.positions) };
105108 }
106109 // glTF: binary, or JSON whose buffers sit beside it on the media route.
src/web/render/model3d.ts +1 −1
@@ -30,7 +30,7 @@ const ENTRY = Bun.fileURLToPath(new URL("./model3d-client.ts", import.meta.url))
3030 const VENDOR = join(import.meta.dir, "vendor", "three");
3131
3232 /** What the viewer reads each extension as. */
33 const FORMATS: Readonly<Record<string, string>> = { stl: "stl", obj: "obj", glb: "glb", gltf: "gltf" };
33 const FORMATS: Readonly<Record<string, string>> = { stl: "stl", obj: "obj", glb: "glb", gltf: "gltf", "3mf": "3mf" };
3434
3535 const formatOf = (path: string): string =>
3636 FORMATS[path.slice(path.lastIndexOf(".") + 1).toLowerCase()] ?? "stl";
src/web/render/threemf.ts added +242
@@ -0,0 +1,242 @@
1 /**
2 * A 3MF print project as one triangle soup, in millimetres, with no library.
3 * 3MF (3D Manufacturing Format Core Specification 1.3) is a zip, Open
4 * Packaging Conventions style, whose `3D/3dmodel.model` is XML: `<object>`s
5 * holding a `<mesh>` of `<vertex>` and `<triangle>`, or `<components>` of
6 * other objects; a `<build>` of `<item>`s says which objects are printed and
7 * where. The `unit` attribute of `<model>` (§3.4: micron, millimeter,
8 * centimeter, inch, foot, meter; millimeter when absent) is applied here, so
9 * the numbers that leave are millimetres.
10 *
11 * The viewer (`model3d-client.ts`) and the terminal's facts (`model-facts.ts`)
12 * both read through this. It has no DOM and no node: `locateModel` finds the
13 * model's bytes in the zip, the caller inflates them (a stream in the browser,
14 * zlib on the server) and `parseModelXml` reads the XML. Inflating is bounded
15 * by entry count and inflated size, so a zip bomb is refused.
16 */
17
18 import { MeshError, type Mesh } from "./mesh.ts";

256 MB of inflated XML held as a string and regexed on the main thread will stall a phone. Lower this to around 64 MB so the refusal arrives as a sentence, with Source and Download kept.

19
20 /** More entries than any slicer writes; a zip with more is refused. */
21 export const ENTRY_LIMIT = 10_000;
22 /** The most the model XML may inflate to. */
23 export const INFLATE_LIMIT = 64 * 1024 * 1024;
24 /** Triangles beyond which a part is refused rather than drawn. */
25 export const TRIANGLE_LIMIT = 5_000_000;
26 const DEPTH_LIMIT = 16;
27
28 /** Millimetres in one unit of each `unit` value (3MF Core §3.4). */
29 const UNIT_MM: Readonly<Record<string, number>> = {
30 micron: 0.001,
31 millimeter: 1,
32 centimeter: 10,
33 inch: 25.4,
34 foot: 304.8,
35 meter: 1000,
36 };
37
38 /** The model's entry in the zip, still packed. */
39 export interface ModelEntry {
40 readonly data: Uint8Array;
41 /** 0 stored, 8 deflate. */
42 readonly method: 0 | 8;
43 /** Inflated size the zip declares. */
44 readonly size: number;
45 }
46
47 const CENTRAL = 0x02014b50;
48 const LOCAL = 0x04034b50;
49 const EOCD = 0x06054b50;
50
51 /** Finds `3D/3dmodel.model` (or the first `3D/*.model`) in a zip's bytes. */
52 export function locateModel(bytes: Uint8Array): ModelEntry {
53 const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
54 let eocd = -1;
55 for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 22 - 0xffff); i--)
56 if (v.getUint32(i, true) === EOCD) {
57 eocd = i;
58 break;
59 }
60 if (eocd < 0) throw new MeshError("not a readable zip");
61 const total = v.getUint16(eocd + 10, true);
62 let p = v.getUint32(eocd + 16, true);
63 if (total === 0xffff || p === 0xffffffff) throw new MeshError("a zip64 project is too large to read");
64 if (total > ENTRY_LIMIT) throw new MeshError(`the zip holds more than ${ENTRY_LIMIT} entries`);
65 let found: { at: number; method: number; size: number; packed: number; flags: number } | undefined;
66 for (let n = 0; n < total; n++) {
67 if (p + 46 > bytes.length || v.getUint32(p, true) !== CENTRAL) throw new MeshError("a broken zip directory");
68 const nameLen = v.getUint16(p + 28, true);
69 const next = p + 46 + nameLen + v.getUint16(p + 30, true) + v.getUint16(p + 32, true);
70 if (next > bytes.length) throw new MeshError("a broken zip directory");
71 const name = new TextDecoder().decode(bytes.subarray(p + 46, p + 46 + nameLen));
72 const lower = name.toLowerCase();
73 const main = lower === "3d/3dmodel.model";
74 if (main || (found === undefined && lower.startsWith("3d/") && lower.endsWith(".model"))) {
75 found = {
76 at: v.getUint32(p + 42, true),
77 method: v.getUint16(p + 10, true),
78 size: v.getUint32(p + 24, true),
79 packed: v.getUint32(p + 20, true),
80 flags: v.getUint16(p + 8, true),
81 };
82 if (main) break;
83 }
84 p = next;
85 }
86 if (found === undefined) throw new MeshError("the zip has no 3D model (3D/3dmodel.model)");
87 if (found.flags & 1) throw new MeshError("the model is encrypted");
88 if (found.method !== 0 && found.method !== 8) throw new MeshError("the model uses a compression this does not read");
89 if (found.size > INFLATE_LIMIT) throw new MeshError("the model inflates to more than the limit");
90 if (found.at + 30 > bytes.length || v.getUint32(found.at, true) !== LOCAL) throw new MeshError("a broken zip entry");
91 const start = found.at + 30 + v.getUint16(found.at + 26, true) + v.getUint16(found.at + 28, true);
92 if (start + found.packed > bytes.length) throw new MeshError("a zip entry past the end of the file");
93 return { data: bytes.subarray(start, start + found.packed), method: found.method, size: found.size };
94 }
95
96 /** Inflates the entry in a stream, giving up the moment it passes the limit. */
97 export async function inflateModel(entry: ModelEntry): Promise<Uint8Array> {
98 if (entry.method === 0) return entry.data;
99 const reader = new Blob([entry.data as unknown as ArrayBuffer])
100 .stream()
101 .pipeThrough(new DecompressionStream("deflate-raw"))
102 .getReader();
103 const chunks: Uint8Array[] = [];
104 let length = 0;
105 for (;;) {
106 const { done, value } = await reader.read();
107 if (done) break;
108 length += value.length;
109 if (length > INFLATE_LIMIT) {
110 await reader.cancel();
111 throw new MeshError("the model inflates to more than the limit");
112 }
113 chunks.push(value);
114 }
115 const out = new Uint8Array(length);
116 let at = 0;
117 for (const c of chunks) {
118 out.set(c, at);
119 at += c.length;
120 }
121 return out;
122 }
123
124 /** A 3MF file's mesh in millimetres. */
125 export async function parse3mf(bytes: Uint8Array): Promise<Mesh> {
126 const xml = new TextDecoder().decode(await inflateModel(locateModel(bytes)));
127 return parseModelXml(xml);
128 }
129
130 type Matrix = readonly number[];
131 const IDENTITY: Matrix = [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0];
132
133 /** `a` then `b`, for the row-vector form 3MF uses (§3.3). */
134 function then(a: Matrix, b: Matrix): Matrix {
135 const out: number[] = [];
136 for (let r = 0; r < 4; r++)
137 for (let c = 0; c < 3; c++) {
138 const row = r < 3 ? a.slice(r * 3, r * 3 + 3) : a.slice(9, 12);
139 out.push(row[0]! * b[c]! + row[1]! * b[3 + c]! + row[2]! * b[6 + c]! + (r === 3 ? b[9 + c]! : 0));
140 }
141 return out;
142 }
143
144 function matrix(text: string | undefined): Matrix {
145 if (text === undefined) return IDENTITY;
146 const m = text.trim().split(/\s+/).map(Number);
147 if (m.length !== 12 || !m.every(Number.isFinite)) throw new MeshError("a transform that is not twelve numbers");
148 return m;
149 }
150
151 interface Obj {
152 vertices: number[];
153 triangles: number[];
154 components: { id: string; transform: Matrix }[];
155 }
156
157 const TAG = /<(\/?)([A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*)((?:\s+[^\s=>/]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>/g;
158 const ATTR = /([^\s=]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
159
160 /**
161 * The XML of a model part, read into millimetres: every `<item>` of the
162 * `<build>` placed by its transform, or, with no build, every object.
163 */
164 export function parseModelXml(xml: string): Mesh {
165 const objects = new Map<string, Obj>();
166 const items: { id: string; transform: Matrix }[] = [];
167 let unit = 1;
168 let current: Obj | undefined;
169 let sawModel = false;
170 const text = xml.replace(/<!--[\s\S]*?-->/g, "").replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, "");
171 for (const m of text.matchAll(TAG)) {
172 const [, closing, , name, attrText] = m;
173 const attrs: Record<string, string> = {};
174 for (const a of (attrText ?? "").matchAll(ATTR)) attrs[a[1]!] = a[2] ?? a[3] ?? "";
175 if (closing === "/") {
176 if (name === "object") current = undefined;
177 continue;
178 }
179 switch (name) {
180 case "model": {
181 sawModel = true;
182 const u = attrs.unit ?? "millimeter";
183 if (!(u in UNIT_MM)) throw new MeshError(`the model's unit "${u}" is not one 3MF names`);
184 unit = UNIT_MM[u]!;
185 break;
186 }
187 case "object":
188 if (attrs.id === undefined) throw new MeshError("an object with no id");
189 current = { vertices: [], triangles: [], components: [] };
190 objects.set(attrs.id, current);
191 break;
192 case "vertex": {
193 const xyz = [Number(attrs.x), Number(attrs.y), Number(attrs.z)];
194 if (current === undefined || !xyz.every(Number.isFinite)) throw new MeshError("a vertex that is not three numbers");
195 current.vertices.push(...xyz);
196 break;
197 }
198 case "triangle": {
199 const ijk = [attrs.v1, attrs.v2, attrs.v3].map((s) => Number(s));
200 if (current === undefined || !ijk.every((n) => Number.isInteger(n) && n >= 0 && n < current!.vertices.length / 3))
201 throw new MeshError("a triangle names a vertex that is not there");
202 current.triangles.push(...ijk);
203 break;
204 }
205 case "component":
206 if (current === undefined || attrs.objectid === undefined) throw new MeshError("a component that names no object");
207 current.components.push({ id: attrs.objectid, transform: matrix(attrs.transform) });
208 break;
209 case "item":
210 if (attrs.objectid === undefined) throw new MeshError("a build item that names no object");
211 items.push({ id: attrs.objectid, transform: matrix(attrs.transform) });
212 break;
213 }
214 }
215 if (!sawModel) throw new MeshError("not a 3MF model");
216 const placed = items.length > 0 ? items : [...objects.keys()].map((id) => ({ id, transform: IDENTITY }));
217
218 const count = (id: string, depth: number): number => {
219 const o = objects.get(id);
220 if (o === undefined) throw new MeshError(`an item names object ${id}, which is not there`);
221 if (depth > DEPTH_LIMIT) throw new MeshError("components nested too deep");
222 return o.triangles.length / 3 + o.components.reduce((n, c) => n + count(c.id, depth + 1), 0);
223 };
224 const triangles = placed.reduce((n, i) => n + count(i.id, 0), 0);
225 if (triangles > TRIANGLE_LIMIT) throw new MeshError(`more than ${TRIANGLE_LIMIT.toLocaleString("en")} triangles`);
226
227 const positions = new Float32Array(triangles * 9);
228 let at = 0;
229 const emit = (id: string, to: Matrix): void => {
230 const o = objects.get(id)!;
231 for (let i = 0; i < o.triangles.length; i++) {
232 const v = o.triangles[i]! * 3;
233 const [x, y, z] = [o.vertices[v]!, o.vertices[v + 1]!, o.vertices[v + 2]!];
234 positions[at++] = (to[0]! * x + to[3]! * y + to[6]! * z + to[9]!) * unit;
235 positions[at++] = (to[1]! * x + to[4]! * y + to[7]! * z + to[10]!) * unit;
236 positions[at++] = (to[2]! * x + to[5]! * y + to[8]! * z + to[11]!) * unit;
237 }
238 for (const c of o.components) emit(c.id, then(c.transform, to));
outdated

const t = to; is a pointless alias; use to.

239 };
240 for (const i of placed) emit(i.id, i.transform);
241 return { positions, triangles };
242 }
test/render-kinds.test.ts +1
@@ -37,6 +37,7 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = {
3737 mov: [box("qt "), "video", "video/quicktime"],
3838 stl: [enc("solid cube\n"), "model3d", "model/stl"],
3939 glb: [enc("glTF\x02\0\0\0"), "model3d", "model/gltf-binary"],
40 "3mf": [enc("PK\x03\x04"), "model3d", "model/3mf"],
4041 db: [enc("SQLite format 3\0"), "sqlite", "application/vnd.sqlite3"],
4142 sqlite: [enc("SQLite format 3\0"), "sqlite", "application/vnd.sqlite3"],
4243 sqlite3: [enc("SQLite format 3\0"), "sqlite", "application/vnd.sqlite3"],
test/threemf.test.ts added +148
@@ -0,0 +1,148 @@
1 /** 3MF print projects (#10892): the cube is built here, by hand, inside a zip. */
2
3 import { expect, test } from "bun:test";
4 import { deflateRawSync } from "node:zlib";
5 import { fileKind } from "../src/web/render/kinds.ts";
6 import { modelFacts } from "../src/web/render/model-facts.ts";
7 import { bounds } from "../src/web/render/mesh.ts";
8 import { ENTRY_LIMIT, INFLATE_LIMIT, locateModel, parse3mf } from "../src/web/render/threemf.ts";
9
10 const enc = new TextEncoder();
11
12 /** A zip of name → content, each deflated (or stored), by the appnote's layout. */
13 function zip(files: Record<string, string>, method: 0 | 8 = 8): Uint8Array {
14 const parts: Uint8Array[] = [];
15 const central: Uint8Array[] = [];
16 let offset = 0;
17 for (const [name, content] of Object.entries(files)) {
18 const raw = enc.encode(content);
19 const packed = method === 8 ? new Uint8Array(deflateRawSync(raw)) : raw;
20 const n = enc.encode(name);
21 const local = new Uint8Array(30 + n.length);
22 const lv = new DataView(local.buffer);
23 lv.setUint32(0, 0x04034b50, true);
24 lv.setUint16(4, 20, true);
25 lv.setUint16(8, method, true);
26 lv.setUint32(14, Bun.hash.crc32(raw), true);
27 lv.setUint32(18, packed.length, true);
28 lv.setUint32(22, raw.length, true);
29 lv.setUint16(26, n.length, true);
30 local.set(n, 30);
31 const c = new Uint8Array(46 + n.length);
32 const cv = new DataView(c.buffer);
33 cv.setUint32(0, 0x02014b50, true);
34 cv.setUint16(4, 20, true);
35 cv.setUint16(6, 20, true);
36 cv.setUint16(10, method, true);
37 cv.setUint32(16, Bun.hash.crc32(raw), true);
38 cv.setUint32(20, packed.length, true);
39 cv.setUint32(24, raw.length, true);
40 cv.setUint16(28, n.length, true);
41 cv.setUint32(42, offset, true);
42 c.set(n, 46);
43 central.push(c);
44 parts.push(local, packed);
45 offset += local.length + packed.length;
46 }
47 const size = central.reduce((s, c) => s + c.length, 0);
48 const end = new Uint8Array(22);
49 const ev = new DataView(end.buffer);
50 ev.setUint32(0, 0x06054b50, true);
51 ev.setUint16(8, central.length, true);
52 ev.setUint16(10, central.length, true);
53 ev.setUint32(12, size, true);
54 ev.setUint32(16, offset, true);
55 const all = [...parts, ...central, end];
56 const out = new Uint8Array(all.reduce((s, p) => s + p.length, 0));
57 let at = 0;
58 for (const p of all) {
59 out.set(p, at);
60 at += p.length;
61 }
62 return out;
63 }
64
65 const CUBE_V = [[0, 0, 0], [20, 0, 0], [20, 20, 0], [0, 20, 0], [0, 0, 20], [20, 0, 20], [20, 20, 20], [0, 20, 20]];
66 const CUBE_T = [[0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5], [2, 3, 7], [2, 7, 6], [3, 0, 4], [3, 4, 7]];
67
68 function model(unit?: string, build = `<item objectid="1"/>`): string {
69 const u = unit === undefined ? "" : ` unit="${unit}"`;
70 return `<?xml version="1.0" encoding="UTF-8"?>
71 <model${u} xml:lang="en-US" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02">
72 <resources><object id="1" type="model"><mesh><vertices>
73 ${CUBE_V.map(([x, y, z]) => `<vertex x="${x}" y="${y}" z="${z}"/>`).join("\n")}
74 </vertices><triangles>
75 ${CUBE_T.map(([a, b, c]) => `<triangle v1="${a}" v2="${b}" v3="${c}"/>`).join("\n")}
76 </triangles></mesh></object></resources><build>${build}</build></model>`;
77 }
78
79 const project = (xml: string, method: 0 | 8 = 8): Uint8Array => zip({ "[Content_Types].xml": "<Types/>", "3D/3dmodel.model": xml }, method);
80
81 test("a 20 mm cube is 12 triangles in a 20 × 20 × 20 box", async () => {
82 const mesh = await parse3mf(project(model()));
83 expect(mesh.triangles).toBe(12);
84 expect(bounds(mesh.positions).size).toEqual([20, 20, 20]);
85 expect(bounds((await parse3mf(project(model(), 0))).positions).size).toEqual([20, 20, 20]);
86 });
87
88 test("the unit is converted to millimetres: 20 inches is 508 mm", async () => {
89 expect(bounds((await parse3mf(project(model("inch")))).positions).size).toEqual([508, 508, 508]);
90 expect(bounds((await parse3mf(project(model("centimeter")))).positions).size).toEqual([200, 200, 200]);
91 expect(bounds((await parse3mf(project(model("micron")))).positions).size[0]).toBeCloseTo(0.02, 6);
92 });
93
94 test("every build item is placed by its transform", async () => {
95 const items = `<item objectid="1"/><item objectid="1" transform="1 0 0 0 1 0 0 0 1 50 0 0"/>`;
96 const mesh = await parse3mf(project(model(undefined, items)));
97 expect(mesh.triangles).toBe(24);
98 expect(bounds(mesh.positions).size).toEqual([70, 20, 20]);
99 });
100
101 test("components of another object carry their own transform", async () => {
102 const xml = model(undefined, `<item objectid="2"/>`).replace(
103 "</resources>",
104 `<object id="2"><components><component objectid="1" transform="1 0 0 0 1 0 0 0 1 0 0 5"/></components></object></resources>`,
105 );
106 const b = bounds((await parse3mf(project(xml))).positions);
107 expect(b.min[2]).toBe(5);
108 expect(b.max[2]).toBe(25);
109 });
110
111 test("a zip with no model, a broken zip and a broken model are refused", async () => {
112 await expect(parse3mf(zip({ "readme.txt": "hi" }))).rejects.toThrow(/no 3D model/);
113 await expect(parse3mf(enc.encode("PK\x03\x04 not really a zip"))).rejects.toThrow(/zip/);
114 await expect(parse3mf(project("<html/>"))).rejects.toThrow(/not a 3MF/);
115 await expect(parse3mf(project(model().replace('v1="0"', 'v1="99"')))).rejects.toThrow(/vertex that is not there/);
116 await expect(parse3mf(project(model("furlong")))).rejects.toThrow(/unit/);
117 });
118
119 test("a zip bomb is refused by entry count and by inflated size", async () => {
120 const many: Record<string, string> = {};
121 for (let i = 0; i <= ENTRY_LIMIT; i++) many[`f${i}`] = "";
122 expect(() => locateModel(zip(many, 0))).toThrow(/more than/);
123 const bytes = project(model());
124 // The directory claims a size over the limit.
125 const lie = bytes.slice();
126 const v = new DataView(lie.buffer);
127 for (let i = 0; i + 46 < lie.length; i++)
128 if (v.getUint32(i, true) === 0x02014b50) v.setUint32(i + 24, 0x7fffffff, true);
129 expect(() => locateModel(lie)).toThrow(/limit/);
130 // Just over the cap is refused too.
131 const edge = bytes.slice();
132 const w = new DataView(edge.buffer);
133 for (let i = 0; i + 46 < edge.length; i++)
134 if (w.getUint32(i, true) === 0x02014b50) w.setUint32(i + 24, INFLATE_LIMIT + 1, true);
135 expect(() => locateModel(edge)).toThrow(/limit/);
136 expect(INFLATE_LIMIT).toBe(64 * 1024 * 1024);
137 });
138
139 test("the registry knows a .3mf by its zip signature, and the terminal measures it", () => {
140 const bytes = project(model());
141 expect(fileKind("part.3mf", bytes)).toBe("model3d");
142 expect(fileKind("part.3mf", enc.encode("plain text"))).not.toBe("model3d");
143 expect(modelFacts("part.3mf", bytes, bytes.length)).toEqual({
144 title: "3D model · 3MF",
145 facts: "12 triangles · 20 × 20 × 20 mm",
146 });
147 expect(modelFacts("part.3mf", zip({ "a.txt": "x" }), 100).facts).toBe("not a readable 3MF file");
148 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs