Public: anyone can read this; the crew can change it.
A .3mf print project opens in the 3D view, like an STL #10899
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/10892-a-3mf-print-project-opens-in-the-3d-view → main 3 commits fork 3 threads · 3 unresolved
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()`. | ||
| 9 | 9 | |
| 10 | 10 | - **Types** come only from the allowlist in `src/web/render/kinds.ts` |
| 11 | 11 | (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 | |
| 13 | 13 | the file's first bytes confirm it; a mismatch, or any other file, is |
| 14 | 14 | `application/octet-stream`. Never HTML, never script. |
| 15 | 15 | - **Headers:** `X-Content-Type-Options: nosniff` and |
| @@ -133,14 +133,21 @@ repository content only ever runs under the media route's sandbox. `render/markd | ||
| 133 | 133 | |
| 134 | 134 | ## 3D models |
| 135 | 135 | |
| 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 | |
| 137 | 137 | part, ready to turn (`src/web/render/model3d.ts`, #7335). |
| 138 | 138 | |
| 139 | 139 | - **Turn it:** drag to orbit, scroll or pinch to zoom, right-drag or two |
| 140 | 140 | fingers to pan; with the view focused, the arrow keys pan. |
| 141 | 141 | - **The head** says the triangle count and the bounding box in millimetres, |
| 142 | 142 | 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). | |
| 144 | 151 | - **Wireframe** and **Axes** toggle in the head. The camera fits the part, |
| 145 | 152 | a grid sits under it, and every part wears one neutral material. An STL |
| 146 | 153 | 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>> = { | ||
| 144 | 144 | otf: { kind: "font", name: "OpenType", type: "font/otf", magic: ascii("OTTO") }, |
| 145 | 145 | woff: { kind: "font", name: "WOFF", type: "font/woff", magic: ascii("wOFF") }, |
| 146 | 146 | 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) }, | |
| 147 | 148 | glb: { kind: "model3d", name: "glTF binary", type: "model/gltf-binary", magic: ascii("glTF") }, |
| 148 | 149 | }; |
| 149 | 150 |
src/web/render/model-facts.ts +14 −4
| @@ -3,14 +3,17 @@ | ||
| 3 | 3 | * says `3D model · STL` over `12 triangles · 20 × 20 × 20 mm` where the page draws |
| 4 | 4 | * the part. Plain data in, plain data out. |
| 5 | 5 | * |
| 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 | |
| 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. | |
| 10 | 11 | */ |
| 11 | 12 | |
| 13 | import { inflateRawSync } from "node:zlib"; | |
| 12 | 14 | import { kindByName } from "./kinds.ts"; |
| 13 | 15 | import { bounds, parseObj, parseStl, sizeText } from "./mesh.ts"; |
| 16 | import { INFLATE_LIMIT, locateModel, parseModelXml } from "./threemf.ts"; | |
| 14 | 17 | |
| 15 | 18 | /** Over this a model is named and sized, not read. */ |
| 16 | 19 | export const FACTS_CAP = 2_000_000; |
| @@ -51,6 +54,13 @@ export function modelFacts(path: string, bytes: Uint8Array | null, size: number) | ||
| 51 | 54 | } |
| 52 | 55 | |
| 53 | 56 | 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 | } | |
| 54 | 64 | if (ext === "stl" || ext === "obj") { |
| 55 | 65 | const mesh = ext === "stl" ? parseStl(bytes) : parseObj(new TextDecoder().decode(bytes)); |
| 56 | 66 | return { triangles: mesh.triangles, size: bounds(mesh.positions).size }; |
src/web/render/model3d-client.ts +8 −5
| @@ -35,6 +35,7 @@ import { | ||
| 35 | 35 | import { OrbitControls } from "three/addons/controls/OrbitControls.js"; |
| 36 | 36 | import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js"; |
| 37 | 37 | import { REFUSED_URI, withinModelDir } from "./gltf-uri.ts"; |
| 38 | import { parse3mf } from "./threemf.ts"; | |
| 38 | 39 | import { bounds, parseObj, parseStl, sizeText, type Bounds } from "./mesh.ts"; |
| 39 | 40 | |
| 40 | 41 | /** |
| @@ -93,14 +94,16 @@ function soup(positions: Float32Array): Mesh { | ||
| 93 | 94 | async function load(src: string, format: string): Promise<Loaded> { |
| 94 | 95 | const response = await fetch(src, { credentials: "same-origin" }); |
| 95 | 96 | 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") { | |
| 97 | 98 | 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())); | |
| 101 | 104 | const mesh = soup(parsed.positions); |
| 102 | 105 | // 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; | |
| 104 | 107 | return { object: mesh, triangles: parsed.triangles, box: bounds(parsed.positions) }; |
| 105 | 108 | } |
| 106 | 109 | // 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)) | ||
| 30 | 30 | const VENDOR = join(import.meta.dir, "vendor", "three"); |
| 31 | 31 | |
| 32 | 32 | /** 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" }; | |
| 34 | 34 | |
| 35 | 35 | const formatOf = (path: string): string => |
| 36 | 36 | 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
|
||
| 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]> = { | ||
| 37 | 37 | mov: [box("qt "), "video", "video/quicktime"], |
| 38 | 38 | stl: [enc("solid cube\n"), "model3d", "model/stl"], |
| 39 | 39 | glb: [enc("glTF\x02\0\0\0"), "model3d", "model/gltf-binary"], |
| 40 | "3mf": [enc("PK\x03\x04"), "model3d", "model/3mf"], | |
| 40 | 41 | db: [enc("SQLite format 3\0"), "sqlite", "application/vnd.sqlite3"], |
| 41 | 42 | sqlite: [enc("SQLite format 3\0"), "sqlite", "application/vnd.sqlite3"], |
| 42 | 43 | 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 | }); |
Re-wrap this comment line; it runs far past the surrounding lines.