diff --git a/README.md b/README.md index 01eb38d..4d3ce55 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ npm run pack -- my-module # -> my-module.zip, ready to install | [health](modules/health/) | Hit points for objects and players. An object's hp is DERIVED from core Counters fed by `damage`/`heal` pulses (one trigger-log entry per pulse, the count riding the DEVX #18 handshake, so two peers and a late joiner read ONE number with no authority); a player's own hp rides their own `peerVars` row with regen derived from the clock. Sources: a wired event, click, touch, a zone, the knock feed (speed-scaled); death hides or respawns; a manager toolbox; HUD actions. Needs the R3a game seams. | | [waves](modules/waves/) | 2.0 (30b): a VR wave shooter — a gun in hand (Blaster / Scatter / Beam), an ability on the grip (Shield / Slow-mo / Pulse), five levels of grunts, runners and tanks, the crystal breached, a menu reachable in VR. Wave survival composed on `health`: pre-placed enemies with health chains, the wave derived from their hit counters (each wave heals them back with local pulses), enemies walking spawn point → goal on the synced clock, `over` → the game shell, kills on each player's own row, the run log in `gameState.vars`. A late joiner lands on the same wave. Needs `health`. | | [untangle](modules/untangle/) | The 190 test-flight GAME: drag the dots until no edges cross. Procedural guaranteed-solvable puzzles (seed-deterministic — determinism IS the netcode), desktop+VR drag via `api.pointerRay()`, replicated moves, lockstep win/level advance, generative WebAudio pad + SFX. Needs app ≥ the `api.pointerRay` SDK (core PR #37). | +| [smart-unwrap](modules/smart-unwrap/) | **Smart (xatlas)** in the UV editor's Unwrap menu: automatic seams, flattened charts and an overlap-free packed atlas from [xatlas](https://github.com/jpcy/xatlas) compiled to WebAssembly. The 177 kB `.wasm` ships inside the zip and loads through `api.assetUrl` on the first unwrap — no network, no CSP change. Charts come out unmirrored; peers without the module still receive the unwrapped mesh. Needs core ≥ 1.3 (`api.registerUnwrapBackend`). | ## Module format diff --git a/index.json b/index.json index 0897e7a..3715920 100644 --- a/index.json +++ b/index.json @@ -247,6 +247,22 @@ "rhythm" ] }, + { + "id": "smart-unwrap", + "name": "Smart unwrap (xatlas)", + "version": "1.0.0", + "description": "Adds \"Smart (xatlas)\" to the UV editor's Unwrap menu: automatic seams, flattened charts and an overlap-free packed atlas, from xatlas compiled to WebAssembly (the .wasm ships in the zip).", + "author": "theprototype-app", + "source": "modules/smart-unwrap", + "zip": "smart-unwrap.zip", + "category": "tool", + "tags": [ + "uv", + "unwrap", + "texturing", + "wasm" + ] + }, { "id": "tutorial-room", "name": "Tutorial Room", diff --git a/modules/smart-unwrap/README.md b/modules/smart-unwrap/README.md new file mode 100644 index 0000000..628c4b9 --- /dev/null +++ b/modules/smart-unwrap/README.md @@ -0,0 +1,127 @@ +# smart-unwrap + +**Smart unwrap (xatlas)**: an automatic UV unwrapper for the UV editor. Core's +Unwrap menu ships projections (box, planar, cylindrical, spherical). This module +adds **Smart (xatlas)**, which cuts the mesh into charts along creases and seams, +flattens each chart (planar, ortho or LSCM), and packs them into one atlas with +no overlap. Other tools call this "smart UV project" or "lightmap pack". Under +the hood it is [xatlas](https://github.com/jpcy/xatlas) compiled to WebAssembly. +The `.wasm` travels inside the zip, so it needs no network, no CDN and no CSP +change. + +## Use it + +1. Burger menu ▸ **Modules ▸ Install** (from zip: `npm run pack -- smart-unwrap` + writes `smart-unwrap.zip`). +2. Select a mesh and open the **UV editor**. +3. **Unwrap ▾** ▸ **Smart (xatlas)**. With faces picked in Edit Mesh, only those + faces are unwrapped and the rest of the mesh keeps its UVs. That part is core's + scoped-unwrap path. + +The first unwrap instantiates the wasm (about 177 kB) and later ones reuse it. +The result is committed by core (`uvEditor.unwrapObject`) as one undo entry and +replicated as a `meshgeo` snapshot. Peers **without** the module still get the +unwrapped mesh, because only the peer who presses the button runs xatlas. + +If the menu was already open in a UV editor when you installed the module, close +and reopen the editor. The editor reads the backend list when it opens. + +## Options + +The UV editor calls the backend with `{margin: 0.02}`. A script can pass more +through `uvEditor.unwrapObject(uuid, 'mod-smart-unwrap-xatlas', options)`: + +| option | default | meaning | +|---|---|---| +| `margin` | `0.01` | gap between charts as a fraction of the texture, the same meaning as for the built-in packer. It is converted to xatlas padding of `margin × resolution / 2` texels per chart. | +| `resolution` | `512` | the texture size the padding is computed for. Going higher is not reliably tighter: at 1024 the 216-tri sphere covered 46% of the square, against 49% at 512, and took about 10× longer. The 12k-tri sphere went from 50% to 54% coverage. | +| `padding` | (from `margin`) | xatlas padding in texels, which overrides `margin` | +| `chartOptions` | `{fixWinding: true}` | raw xatlas `ChartOptions` (`maxChartArea`, `maxCost`, `normalSeamWeight`, …), merged last | +| `packOptions` | see below | raw xatlas `PackOptions`, merged last | + +The pack defaults are `bilinear: true`, `rotateCharts: false`, and `bruteForce` +for meshes of 4000 triangles or fewer. + +**Why `rotateCharts` is off:** xatlas's packer turns a chart 90° by +*transposing* it (it swaps u and v), and a transpose is a mirror. With it on, a +216-triangle UV sphere came back with 2 of its 6 charts mirrored, so painted text +would read backwards on those faces. With it off and `fixWinding` on, every +triangle in the tests winds counter-clockwise (unmirrored for three's v-up UVs). +The cost is a slightly looser pack. `rotateChartsToAxis` is a true rotation and +stays on. + +## Limits + +- **Main thread.** A few hundred triangles take tens of ms. A 12k-triangle sphere + takes about 1.5 s and a 9.6k-triangle torus knot about 1.5 s, and the UI waits + for it. (These times are from node on a Steam Deck. A worker would keep the UI + responsive and is a possible next step.) +- **One atlas, 0..1.** If xatlas spills into several atlases at the chosen + resolution, the backend re-packs into one so that nothing overlaps once + normalised. +- **Degenerate faces** (zero area or NaN) are ignored by xatlas. They get + `[0,0]` UVs and ride one extra island, so every face is still accounted for. +- **Welding is exact.** Corners are joined only when their positions are + bit-identical, which is true of every mesh the editor builds. A mesh whose + "shared" vertices differ by float noise unwraps with more seams. +- **Failure** (missing asset or a wasm abort) shows a toast and returns + nothing, so core commits nothing. The next unwrap starts a fresh runtime. + +## Lifecycle + +`api.registerUnwrapBackend` is journaled by the SDK (`sdk/backends.js` +`onDispose`). Unloading or live-updating the module removes **Smart (xatlas)** +from the menu with no code here. The module's own `api.onUnload` drops the +cached wasm runtime so its memory can be collected. + +## Which wasm build, and why + +**xatlas-wasm 0.1.3** (mode777, MIT declared in package.json; xatlas itself is +MIT, © Jonathan Young), not **watlas 1.0.1** (toji, MIT). The reasons: + +- **Size.** The xatlas-wasm `.wasm` is 177 kB, a plain C API with 6 imports and + no embind or C++ exceptions. watlas is 225 kB with 56 embind and exception + imports. +- **Glue.** xatlas-wasm has a 39 kB release glue, and its emscripten + `Module.instantiateWasm` hook takes our blob-URL bytes with no patching. + watlas has an 87 kB ASSERTIONS build that *aborts* if `instantiateWasm` or + `wasmBinary` is supplied, and it resolves `watlas.wasm` against + `import.meta.url`, which is meaningless for a blob-URL module. It would need + its minified glue patched. +- **Threads.** Both run single-threaded on the main thread (no pthreads or + SharedArrayBuffer), and both expose the same xatlas API (positions + indices + in, then per-vertex uv / xref / chartIndex and per-chart face lists out). +- **The cost.** xatlas-wasm is young (one maintainer, a few stars), and its repo + has no LICENSE file of its own, only the MIT declaration. The text and + attributions are in [THIRD_PARTY_LICENSES.txt](THIRD_PARTY_LICENSES.txt), + which ships in the zip. + +### Refreshing the vendored build + +`module.js` carries the xatlas-wasm glue between the `VENDORED` markers, and +`assets/xatlas.wasm` is the binary. Both come from the npm tarball through +`vendor.mjs`, which reads the package as **text** and never runs it: + +```bash +mkdir /tmp/xw && cd /tmp/xw && npm pack xatlas-wasm@0.1.3 --ignore-scripts && tar xzf *.tgz +node modules/smart-unwrap/vendor.mjs /tmp/xw/package/dist/index.mjs +npm run test:smart-unwrap +``` + +## Tests + +`npm run test:smart-unwrap` runs in node with no app. It loads the real +`module.js` and the real `assets/xatlas.wasm` through a fake `api` whose +`assetUrl` returns blob URLs, as the app does. It then unwraps a cube (12 tris), +a UV sphere (216), a cylinder (240), a scoped half-sphere and a 12k-tri sphere. +It checks properties rather than exact numbers: + +- 3 corners per face, every uv in [0,1], no zero-area uv triangle and a total + area of at most 1 +- the islands partition the faces, and no two islands overlap on a 512² raster +- every triangle is counter-clockwise, and the cube has 6 or fewer islands + +Counterfactuals show that each check catches a broken result: stacked islands, +a duplicated island, collapsed UVs, shifted UVs, a dropped face, unwelded input +and xatlas's mirroring `rotateCharts`. The suite also covers the lazy and +cached instantiation, the unload hook and the failure path. diff --git a/modules/smart-unwrap/THIRD_PARTY_LICENSES.txt b/modules/smart-unwrap/THIRD_PARTY_LICENSES.txt new file mode 100644 index 0000000..0177293 --- /dev/null +++ b/modules/smart-unwrap/THIRD_PARTY_LICENSES.txt @@ -0,0 +1,82 @@ +Smart unwrap (xatlas) — third-party software +============================================= + +This module ships, unmodified except as noted, a WebAssembly build of xatlas and the +JavaScript glue that drives it: + + assets/xatlas.wasm the xatlas-wasm 0.1.3 binary, decoded byte-for-byte from the + package's inline (SINGLE_FILE) copy + module.js the xatlas-wasm 0.1.3 emscripten glue + wrapper, between the + "VENDORED xatlas-wasm glue" markers; two edits: the inline binary + is dropped (the .wasm above is loaded through + Module.instantiateWasm instead) and the ES `export {...}` block is + removed + +Source package: https://www.npmjs.com/package/xatlas-wasm (version 0.1.3, + sha512-LGWvSyHryDda9+a3+le2bd+QWPTDMFjfu3CPlVauTm8Liqd+S9wIjQScBzlOChFTyL8OpzZ4AI0tE0zoF3kc6g==) +Repository: https://github.com/mode777/xatlas-wasm + + +------------------------------------------------------------------------------- +xatlas-wasm +------------------------------------------------------------------------------- +Copyright (c) Alexander Klingenbeck (alexander.klingenbeck@gmx.de) + +xatlas-wasm declares the MIT license (package.json "license": "MIT"); the package and +repository carry no separate license text, so the standard MIT terms are reproduced +here: + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +------------------------------------------------------------------------------- +xatlas — https://github.com/jpcy/xatlas +------------------------------------------------------------------------------- +MIT License + +Copyright (c) 2018-2020 Jonathan Young + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +------------------------------------------------------------------------------- +Code inside xatlas (notices from source/xatlas/xatlas.cpp, all MIT, terms as above) +------------------------------------------------------------------------------- +thekla_atlas — https://github.com/Thekla/thekla_atlas + Copyright (c) 2013 Thekla, Inc + Copyright NVIDIA Corporation 2006 -- Ignacio Castano + +Fast-BVH — https://github.com/brandonpelfrey/Fast-BVH + Copyright (c) 2012 Brandon Pelfrey diff --git a/modules/smart-unwrap/assets/xatlas.wasm b/modules/smart-unwrap/assets/xatlas.wasm new file mode 100644 index 0000000..358a89b Binary files /dev/null and b/modules/smart-unwrap/assets/xatlas.wasm differ diff --git a/modules/smart-unwrap/manifest.json b/modules/smart-unwrap/manifest.json new file mode 100644 index 0000000..67c056b --- /dev/null +++ b/modules/smart-unwrap/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "smart-unwrap", + "name": "Smart unwrap (xatlas)", + "version": "1.0.0", + "format": 1, + "description": "Adds \"Smart (xatlas)\" to the UV editor's Unwrap menu: automatic seams, flattened charts and an overlap-free packed atlas, from xatlas compiled to WebAssembly.", + "entry": "module.js", + "files": ["module.js", "assets/xatlas.wasm", "THIRD_PARTY_LICENSES.txt"] +} diff --git a/modules/smart-unwrap/module.js b/modules/smart-unwrap/module.js new file mode 100644 index 0000000..fc64c61 --- /dev/null +++ b/modules/smart-unwrap/module.js @@ -0,0 +1,1356 @@ +// smart-unwrap — an automatic UV unwrapper for the UV editor, backed by xatlas +// (https://github.com/jpcy/xatlas) compiled to WebAssembly (the xatlas-wasm build). +// +// Core ships projection unwraps (box / planar / cylindrical / spherical) and a registry; +// this module adds "Smart (xatlas)" to that registry through api.registerUnwrapBackend. +// xatlas segments the mesh into charts along creases and seams, flattens each chart +// (planar / ortho / LSCM), and packs them into one atlas without overlap — the unwrap a +// DCC calls "smart UV project" / "lightmap pack". +// +// HOW THE WASM LOADS: the .wasm travels INSIDE this module's zip (assets/xatlas.wasm) and +// is reached through api.assetUrl — no CDN, no network, no CSP surface. The emscripten glue +// below is vendored verbatim from xatlas-wasm 0.1.3 except that its inline copy of the +// binary is dropped: the binary is handed over through Module.instantiateWasm, the +// standard emscripten hook. It is instantiated on the FIRST unwrap, not at install, and +// cached until the module unloads. +// +// Runs on the main thread: a few hundred triangles take tens of milliseconds, a 12k +// triangle sphere about 1.5 s (the UI waits for it; measured in node on a Steam Deck). +// The UV result is committed by core (uvEditor.unwrapObject: snapshot, undo entry, meshgeo +// broadcast), so peers WITHOUT this module still receive the unwrapped mesh — only the +// peer pressing the button runs xatlas. +// +// License: xatlas (MIT, Jonathan Young) + xatlas-wasm (MIT, Alexander Klingenbeck) — see +// THIRD_PARTY_LICENSES.txt, packed into the zip alongside this file. + +// ---- BEGIN VENDORED xatlas-wasm glue (0.1.3, MIT) — regenerate with vendor.mjs, do not edit ---- +// dist/xatlas.mjs +async function createXAtlasModule(moduleArg = {}) { + var moduleRtn; + var Module = moduleArg; + var ENVIRONMENT_IS_WEB = !!globalThis.window; + var ENVIRONMENT_IS_WORKER = !!globalThis.WorkerGlobalScope; + var ENVIRONMENT_IS_NODE = globalThis.process?.versions?.node && globalThis.process?.type != "renderer"; + if (ENVIRONMENT_IS_NODE) { + const { createRequire } = await import("node:module"); + var require2 = createRequire(import.meta.url); + } + var arguments_ = []; + var thisProgram = "./this.program"; + var quit_ = (status, toThrow) => { + throw toThrow; + }; + var _scriptName = import.meta.url; + var scriptDirectory = ""; + var readAsync, readBinary; + if (ENVIRONMENT_IS_NODE) { + var fs = require2("node:fs"); + if (_scriptName.startsWith("file:")) { + scriptDirectory = require2("node:path").dirname(require2("node:url").fileURLToPath(_scriptName)) + "/"; + } + readBinary = (filename) => { + filename = isFileURI(filename) ? new URL(filename) : filename; + var ret = fs.readFileSync(filename); + return ret; + }; + readAsync = async (filename, binary = true) => { + filename = isFileURI(filename) ? new URL(filename) : filename; + var ret = fs.readFileSync(filename, binary ? void 0 : "utf8"); + return ret; + }; + if (process.argv.length > 1) { + thisProgram = process.argv[1].replace(/\\/g, "/"); + } + arguments_ = process.argv.slice(2); + quit_ = (status, toThrow) => { + process.exitCode = status; + throw toThrow; + }; + } else if (ENVIRONMENT_IS_WEB || ENVIRONMENT_IS_WORKER) { + try { + scriptDirectory = new URL(".", _scriptName).href; + } catch { + } + { + readAsync = async (url) => { + var response = await fetch(url, { credentials: "same-origin" }); + if (response.ok) { + return response.arrayBuffer(); + } + throw new Error(response.status + " : " + response.url); + }; + } + } else { + } + var out = console.log.bind(console); + var err = console.error.bind(console); + var wasmBinary; + var ABORT = false; + var EXITSTATUS; + var isFileURI = (filename) => filename.startsWith("file://"); + class EmscriptenEH { + } + class EmscriptenSjLj extends EmscriptenEH { + } + function binaryDecode(bin) { + for (var i = 0, l = bin.length, o = new Uint8Array(l), c; i < l; ++i) { + c = bin.charCodeAt(i); + o[i] = ~c >> 8 & c; + } + return o; + } + var readyPromiseResolve, readyPromiseReject; + var runtimeInitialized = false; + function updateMemoryViews() { + var b = wasmMemory.buffer; + HEAP8 = new Int8Array(b); + HEAP16 = new Int16Array(b); + Module["HEAPU8"] = HEAPU8 = new Uint8Array(b); + Module["HEAPU16"] = HEAPU16 = new Uint16Array(b); + Module["HEAP32"] = HEAP32 = new Int32Array(b); + Module["HEAPU32"] = HEAPU32 = new Uint32Array(b); + Module["HEAPF32"] = HEAPF32 = new Float32Array(b); + HEAPF64 = new Float64Array(b); + HEAP64 = new BigInt64Array(b); + HEAPU64 = new BigUint64Array(b); + } + function preRun() { + if (Module["preRun"]) { + if (typeof Module["preRun"] == "function") Module["preRun"] = [Module["preRun"]]; + while (Module["preRun"].length) { + addOnPreRun(Module["preRun"].shift()); + } + } + callRuntimeCallbacks(onPreRuns); + } + function initRuntime() { + runtimeInitialized = true; + wasmExports["__wasm_call_ctors"](); + } + function postRun() { + if (Module["postRun"]) { + if (typeof Module["postRun"] == "function") Module["postRun"] = [Module["postRun"]]; + while (Module["postRun"].length) { + addOnPostRun(Module["postRun"].shift()); + } + } + callRuntimeCallbacks(onPostRuns); + } + function abort(what) { + Module["onAbort"]?.(what); + what = `Aborted(${what})`; + err(what); + ABORT = true; + what += ". Build with -sASSERTIONS for more info."; + var e = new WebAssembly.RuntimeError(what); + readyPromiseReject?.(e); + throw e; + } + var wasmBinaryFile; + function findWasmBinary() { + return void 0; // smart-unwrap: wasm supplied via Module.instantiateWasm + } + function getBinarySync(file) { + return file; + } + async function getWasmBinary(binaryFile) { + return getBinarySync(binaryFile); + } + async function instantiateArrayBuffer(binaryFile, imports) { + try { + var binary = await getWasmBinary(binaryFile); + var instance = await WebAssembly.instantiate(binary, imports); + return instance; + } catch (reason) { + err(`failed to asynchronously prepare wasm: ${reason}`); + abort(reason); + } + } + async function instantiateAsync(binary, binaryFile, imports) { + return instantiateArrayBuffer(binaryFile, imports); + } + function getWasmImports() { + var imports = { env: wasmImports, wasi_snapshot_preview1: wasmImports }; + return imports; + } + async function createWasm() { + function receiveInstance(instance, module) { + wasmExports = instance.exports; + assignWasmExports(wasmExports); + updateMemoryViews(); + return wasmExports; + } + function receiveInstantiationResult(result2) { + return receiveInstance(result2["instance"]); + } + var info = getWasmImports(); + if (Module["instantiateWasm"]) { + return new Promise((resolve, reject) => { + Module["instantiateWasm"](info, (inst, mod) => { + resolve(receiveInstance(inst, mod)); + }); + }); + } + wasmBinaryFile ??= findWasmBinary(); + var result = await instantiateAsync(wasmBinary, wasmBinaryFile, info); + var exports = receiveInstantiationResult(result); + return exports; + } + class ExitStatus { + name = "ExitStatus"; + constructor(status) { + this.message = `Program terminated with exit(${status})`; + this.status = status; + } + } + var HEAP16; + var HEAP32; + var HEAP64; + var HEAP8; + var HEAPF32; + var HEAPF64; + var HEAPU16; + var HEAPU32; + var HEAPU64; + var HEAPU8; + var callRuntimeCallbacks = (callbacks) => { + while (callbacks.length > 0) { + callbacks.shift()(Module); + } + }; + var onPostRuns = []; + var addOnPostRun = (cb) => onPostRuns.push(cb); + var onPreRuns = []; + var addOnPreRun = (cb) => onPreRuns.push(cb); + function getValue(ptr, type = "i8") { + if (type.endsWith("*")) type = "*"; + switch (type) { + case "i1": + return HEAP8[ptr]; + case "i8": + return HEAP8[ptr]; + case "i16": + return HEAP16[ptr >> 1]; + case "i32": + return HEAP32[ptr >> 2]; + case "i64": + return HEAP64[ptr >> 3]; + case "float": + return HEAPF32[ptr >> 2]; + case "double": + return HEAPF64[ptr >> 3]; + case "*": + return HEAPU32[ptr >> 2]; + default: + abort(`invalid type for getValue: ${type}`); + } + } + var noExitRuntime = true; + function setValue(ptr, value, type = "i8") { + if (type.endsWith("*")) type = "*"; + switch (type) { + case "i1": + HEAP8[ptr] = value; + break; + case "i8": + HEAP8[ptr] = value; + break; + case "i16": + HEAP16[ptr >> 1] = value; + break; + case "i32": + HEAP32[ptr >> 2] = value; + break; + case "i64": + HEAP64[ptr >> 3] = BigInt(value); + break; + case "float": + HEAPF32[ptr >> 2] = value; + break; + case "double": + HEAPF64[ptr >> 3] = value; + break; + case "*": + HEAPU32[ptr >> 2] = value; + break; + default: + abort(`invalid type for setValue: ${type}`); + } + } + var stackRestore = (val) => __emscripten_stack_restore(val); + var stackSave = () => _emscripten_stack_get_current(); + var __abort_js = () => abort(""); + var runtimeKeepaliveCounter = 0; + var __emscripten_runtime_keepalive_clear = () => { + noExitRuntime = false; + runtimeKeepaliveCounter = 0; + }; + var timers = {}; + var handleException = (e) => { + if (e instanceof ExitStatus || e == "unwind") { + return EXITSTATUS; + } + quit_(1, e); + }; + var keepRuntimeAlive = () => noExitRuntime || runtimeKeepaliveCounter > 0; + var _proc_exit = (code) => { + EXITSTATUS = code; + if (!keepRuntimeAlive()) { + Module["onExit"]?.(code); + ABORT = true; + } + quit_(code, new ExitStatus(code)); + }; + var exitJS = (status, implicit) => { + EXITSTATUS = status; + _proc_exit(status); + }; + var _exit = exitJS; + var maybeExit = () => { + if (!keepRuntimeAlive()) { + try { + _exit(EXITSTATUS); + } catch (e) { + handleException(e); + } + } + }; + var callUserCallback = (func) => { + if (ABORT) { + return; + } + try { + return func(); + } catch (e) { + handleException(e); + } finally { + maybeExit(); + } + }; + var _emscripten_get_now = () => performance.now(); + var __setitimer_js = (which, timeout_ms) => { + if (timers[which]) { + clearTimeout(timers[which].id); + delete timers[which]; + } + if (!timeout_ms) return 0; + var id = setTimeout(() => { + delete timers[which]; + callUserCallback(() => __emscripten_timeout(which, _emscripten_get_now())); + }, timeout_ms); + timers[which] = { id, timeout_ms }; + return 0; + }; + var getHeapMax = () => 2147483648; + var alignMemory = (size, alignment) => Math.ceil(size / alignment) * alignment; + var growMemory = (size) => { + var oldHeapSize = wasmMemory.buffer.byteLength; + var pages = (size - oldHeapSize + 65535) / 65536 | 0; + try { + wasmMemory.grow(pages); + updateMemoryViews(); + return 1; + } catch (e) { + } + }; + var _emscripten_resize_heap = (requestedSize) => { + var oldSize = HEAPU8.length; + requestedSize >>>= 0; + var maxHeapSize = getHeapMax(); + if (requestedSize > maxHeapSize) { + return false; + } + for (var cutDown = 1; cutDown <= 4; cutDown *= 2) { + var overGrownHeapSize = oldSize * (1 + 0.2 / cutDown); + overGrownHeapSize = Math.min(overGrownHeapSize, requestedSize + 100663296); + var newSize = Math.min(maxHeapSize, alignMemory(Math.max(requestedSize, overGrownHeapSize), 65536)); + var replacement = growMemory(newSize); + if (replacement) { + return true; + } + } + return false; + }; + var printCharBuffers = [null, [], []]; + var UTF8Decoder = globalThis.TextDecoder && new TextDecoder(); + var findStringEnd = (heapOrArray, idx, maxBytesToRead, ignoreNul) => { + var maxIdx = idx + maxBytesToRead; + if (ignoreNul) return maxIdx; + while (heapOrArray[idx] && !(idx >= maxIdx)) ++idx; + return idx; + }; + var UTF8ArrayToString = (heapOrArray, idx = 0, maxBytesToRead, ignoreNul) => { + var endPtr = findStringEnd(heapOrArray, idx, maxBytesToRead, ignoreNul); + if (endPtr - idx > 16 && heapOrArray.buffer && UTF8Decoder) { + return UTF8Decoder.decode(heapOrArray.subarray(idx, endPtr)); + } + var str = ""; + while (idx < endPtr) { + var u0 = heapOrArray[idx++]; + if (!(u0 & 128)) { + str += String.fromCharCode(u0); + continue; + } + var u1 = heapOrArray[idx++] & 63; + if ((u0 & 224) == 192) { + str += String.fromCharCode((u0 & 31) << 6 | u1); + continue; + } + var u2 = heapOrArray[idx++] & 63; + if ((u0 & 240) == 224) { + u0 = (u0 & 15) << 12 | u1 << 6 | u2; + } else { + u0 = (u0 & 7) << 18 | u1 << 12 | u2 << 6 | heapOrArray[idx++] & 63; + } + if (u0 < 65536) { + str += String.fromCharCode(u0); + } else { + var ch = u0 - 65536; + str += String.fromCharCode(55296 | ch >> 10, 56320 | ch & 1023); + } + } + return str; + }; + var printChar = (stream, curr) => { + var buffer = printCharBuffers[stream]; + if (curr === 0 || curr === 10) { + (stream === 1 ? out : err)(UTF8ArrayToString(buffer)); + buffer.length = 0; + } else { + buffer.push(curr); + } + }; + var UTF8ToString = (ptr, maxBytesToRead, ignoreNul) => ptr ? UTF8ArrayToString(HEAPU8, ptr, maxBytesToRead, ignoreNul) : ""; + var _fd_write = (fd, iov, iovcnt, pnum) => { + var num = 0; + for (var i = 0; i < iovcnt; i++) { + var ptr = HEAPU32[iov >> 2]; + var len = HEAPU32[iov + 4 >> 2]; + iov += 8; + for (var j = 0; j < len; j++) { + printChar(fd, HEAPU8[ptr + j]); + } + num += len; + } + HEAPU32[pnum >> 2] = num; + return 0; + }; + var getCFunc = (ident) => { + var func = Module["_" + ident]; + return func; + }; + var writeArrayToMemory = (array, buffer) => { + HEAP8.set(array, buffer); + }; + var lengthBytesUTF8 = (str) => { + var len = 0; + for (var i = 0; i < str.length; ++i) { + var c = str.charCodeAt(i); + if (c <= 127) { + len++; + } else if (c <= 2047) { + len += 2; + } else if (c >= 55296 && c <= 57343) { + len += 4; + ++i; + } else { + len += 3; + } + } + return len; + }; + var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { + if (!(maxBytesToWrite > 0)) return 0; + var startIdx = outIdx; + var endIdx = outIdx + maxBytesToWrite - 1; + for (var i = 0; i < str.length; ++i) { + var u = str.codePointAt(i); + if (u <= 127) { + if (outIdx >= endIdx) break; + heap[outIdx++] = u; + } else if (u <= 2047) { + if (outIdx + 1 >= endIdx) break; + heap[outIdx++] = 192 | u >> 6; + heap[outIdx++] = 128 | u & 63; + } else if (u <= 65535) { + if (outIdx + 2 >= endIdx) break; + heap[outIdx++] = 224 | u >> 12; + heap[outIdx++] = 128 | u >> 6 & 63; + heap[outIdx++] = 128 | u & 63; + } else { + if (outIdx + 3 >= endIdx) break; + heap[outIdx++] = 240 | u >> 18; + heap[outIdx++] = 128 | u >> 12 & 63; + heap[outIdx++] = 128 | u >> 6 & 63; + heap[outIdx++] = 128 | u & 63; + i++; + } + } + heap[outIdx] = 0; + return outIdx - startIdx; + }; + var stringToUTF8 = (str, outPtr, maxBytesToWrite) => stringToUTF8Array(str, HEAPU8, outPtr, maxBytesToWrite); + var stackAlloc = (sz) => __emscripten_stack_alloc(sz); + var stringToUTF8OnStack = (str) => { + var size = lengthBytesUTF8(str) + 1; + var ret = stackAlloc(size); + stringToUTF8(str, ret, size); + return ret; + }; + var ccall = (ident, returnType, argTypes, args, opts) => { + var toC = { string: (str) => { + var ret2 = 0; + if (str !== null && str !== void 0 && str !== 0) { + ret2 = stringToUTF8OnStack(str); + } + return ret2; + }, array: (arr) => { + var ret2 = stackAlloc(arr.length); + writeArrayToMemory(arr, ret2); + return ret2; + } }; + function convertReturnValue(ret2) { + if (returnType === "string") { + return UTF8ToString(ret2); + } + if (returnType === "boolean") return Boolean(ret2); + return ret2; + } + var func = getCFunc(ident); + var cArgs = []; + var stack = 0; + if (args) { + for (var i = 0; i < args.length; i++) { + var converter = toC[argTypes[i]]; + if (converter) { + if (stack === 0) stack = stackSave(); + cArgs[i] = converter(args[i]); + } else { + cArgs[i] = args[i]; + } + } + } + var ret = func(...cArgs); + function onDone(ret2) { + if (stack !== 0) stackRestore(stack); + return convertReturnValue(ret2); + } + ret = onDone(ret); + return ret; + }; + var cwrap = (ident, returnType, argTypes, opts) => { + var numericArgs = !argTypes || argTypes.every((type) => type === "number" || type === "boolean"); + var numericRet = returnType !== "string"; + if (numericRet && numericArgs && !opts) { + return getCFunc(ident); + } + return (...args) => ccall(ident, returnType, argTypes, args, opts); + }; + var wasmTableMirror = []; + var getWasmTableEntry = (funcPtr) => { + var func = wasmTableMirror[funcPtr]; + if (!func) { + wasmTableMirror[funcPtr] = func = wasmTable.get(funcPtr); + } + return func; + }; + var updateTableMap = (offset, count) => { + if (functionsInTableMap) { + for (var i = offset; i < offset + count; i++) { + var item = getWasmTableEntry(i); + if (item) { + functionsInTableMap.set(item, i); + } + } + } + }; + var functionsInTableMap; + var getFunctionAddress = (func) => { + if (!functionsInTableMap) { + functionsInTableMap = /* @__PURE__ */ new WeakMap(); + updateTableMap(0, wasmTable.length); + } + return functionsInTableMap.get(func) || 0; + }; + var freeTableIndexes = []; + var getEmptyTableSlot = () => { + if (freeTableIndexes.length) { + return freeTableIndexes.pop(); + } + return wasmTable["grow"](1); + }; + var setWasmTableEntry = (idx, func) => { + wasmTable.set(idx, func); + wasmTableMirror[idx] = wasmTable.get(idx); + }; + var uleb128EncodeWithLen = (arr) => { + const n = arr.length; + return [n % 128 | 128, n >> 7, ...arr]; + }; + var wasmTypeCodes = { i: 127, p: 127, j: 126, f: 125, d: 124, e: 111 }; + var generateTypePack = (types) => uleb128EncodeWithLen(Array.from(types, (type) => { + var code = wasmTypeCodes[type]; + return code; + })); + var convertJsFunctionToWasm = (func, sig) => { + var bytes = Uint8Array.of(0, 97, 115, 109, 1, 0, 0, 0, 1, ...uleb128EncodeWithLen([1, 96, ...generateTypePack(sig.slice(1)), ...generateTypePack(sig[0] === "v" ? "" : sig[0])]), 2, 7, 1, 1, 101, 1, 102, 0, 0, 7, 5, 1, 1, 102, 0, 0); + var module = new WebAssembly.Module(bytes); + var instance = new WebAssembly.Instance(module, { e: { f: func } }); + var wrappedFunc = instance.exports["f"]; + return wrappedFunc; + }; + var addFunction = (func, sig) => { + var rtn = getFunctionAddress(func); + if (rtn) { + return rtn; + } + var ret = getEmptyTableSlot(); + try { + setWasmTableEntry(ret, func); + } catch (err2) { + if (!(err2 instanceof TypeError)) { + throw err2; + } + var wrapped = convertJsFunctionToWasm(func, sig); + setWasmTableEntry(ret, wrapped); + } + functionsInTableMap.set(func, ret); + return ret; + }; + var removeFunction = (index) => { + functionsInTableMap.delete(getWasmTableEntry(index)); + setWasmTableEntry(index, null); + freeTableIndexes.push(index); + }; + { + if (Module["noExitRuntime"]) noExitRuntime = Module["noExitRuntime"]; + if (Module["print"]) out = Module["print"]; + if (Module["printErr"]) err = Module["printErr"]; + if (Module["wasmBinary"]) wasmBinary = Module["wasmBinary"]; + if (Module["arguments"]) arguments_ = Module["arguments"]; + if (Module["thisProgram"]) thisProgram = Module["thisProgram"]; + if (Module["preInit"]) { + if (typeof Module["preInit"] == "function") Module["preInit"] = [Module["preInit"]]; + while (Module["preInit"].length > 0) { + Module["preInit"].shift()(); + } + } + } + Module["ccall"] = ccall; + Module["cwrap"] = cwrap; + Module["addFunction"] = addFunction; + Module["removeFunction"] = removeFunction; + Module["setValue"] = setValue; + Module["getValue"] = getValue; + Module["UTF8ToString"] = UTF8ToString; + var _xatlasCreate, _xatlasDestroy, _xatlasAddMesh, _xatlasAddMeshJoin, _xatlasAddUvMesh, _xatlasComputeCharts, _xatlasPackCharts, _xatlasGenerate, _xatlasSetProgressCallback, _xatlasSetAlloc, _xatlasSetPrint, _xatlasAddMeshErrorString, _xatlasProgressCategoryString, _xatlasMeshDeclInit, _xatlasUvMeshDeclInit, _xatlasChartOptionsInit, _xatlasPackOptionsInit, _free, _xatlasAtlas_getWidth, _xatlasAtlas_getHeight, _xatlasAtlas_getAtlasCount, _xatlasAtlas_getChartCount, _xatlasAtlas_getMeshCount, _xatlasAtlas_getTexelsPerUnit, _xatlasAtlas_getUtilization, _xatlasAtlas_getMesh, _xatlasAtlas_getImage, _xatlasMesh_getVertexCount, _xatlasMesh_getIndexCount, _xatlasMesh_getChartCount, _xatlasMesh_getVertexArray, _xatlasMesh_getIndexArray, _xatlasMesh_getChartArray, _xatlasVertex_getAtlasIndex, _xatlasVertex_getChartIndex, _xatlasVertex_getUV0, _xatlasVertex_getUV1, _xatlasVertex_getXref, _xatlasChart_getAtlasIndex, _xatlasChart_getFaceCount, _xatlasChart_getType, _xatlasChart_getMaterial, _xatlasChart_getFaceArray, _xatlasMeshDecl_size, _xatlasUvMeshDecl_size, _xatlasChartOptions_size, _xatlasPackOptions_size, __emscripten_timeout, _malloc, __emscripten_stack_restore, __emscripten_stack_alloc, _emscripten_stack_get_current, memory, __indirect_function_table, wasmMemory, wasmTable; + function assignWasmExports(wasmExports2) { + _xatlasCreate = Module["_xatlasCreate"] = wasmExports2["xatlasCreate"]; + _xatlasDestroy = Module["_xatlasDestroy"] = wasmExports2["xatlasDestroy"]; + _xatlasAddMesh = Module["_xatlasAddMesh"] = wasmExports2["xatlasAddMesh"]; + _xatlasAddMeshJoin = Module["_xatlasAddMeshJoin"] = wasmExports2["xatlasAddMeshJoin"]; + _xatlasAddUvMesh = Module["_xatlasAddUvMesh"] = wasmExports2["xatlasAddUvMesh"]; + _xatlasComputeCharts = Module["_xatlasComputeCharts"] = wasmExports2["xatlasComputeCharts"]; + _xatlasPackCharts = Module["_xatlasPackCharts"] = wasmExports2["xatlasPackCharts"]; + _xatlasGenerate = Module["_xatlasGenerate"] = wasmExports2["xatlasGenerate"]; + _xatlasSetProgressCallback = Module["_xatlasSetProgressCallback"] = wasmExports2["xatlasSetProgressCallback"]; + _xatlasSetAlloc = Module["_xatlasSetAlloc"] = wasmExports2["xatlasSetAlloc"]; + _xatlasSetPrint = Module["_xatlasSetPrint"] = wasmExports2["xatlasSetPrint"]; + _xatlasAddMeshErrorString = Module["_xatlasAddMeshErrorString"] = wasmExports2["xatlasAddMeshErrorString"]; + _xatlasProgressCategoryString = Module["_xatlasProgressCategoryString"] = wasmExports2["xatlasProgressCategoryString"]; + _xatlasMeshDeclInit = Module["_xatlasMeshDeclInit"] = wasmExports2["xatlasMeshDeclInit"]; + _xatlasUvMeshDeclInit = Module["_xatlasUvMeshDeclInit"] = wasmExports2["xatlasUvMeshDeclInit"]; + _xatlasChartOptionsInit = Module["_xatlasChartOptionsInit"] = wasmExports2["xatlasChartOptionsInit"]; + _xatlasPackOptionsInit = Module["_xatlasPackOptionsInit"] = wasmExports2["xatlasPackOptionsInit"]; + _free = Module["_free"] = wasmExports2["free"]; + _xatlasAtlas_getWidth = Module["_xatlasAtlas_getWidth"] = wasmExports2["xatlasAtlas_getWidth"]; + _xatlasAtlas_getHeight = Module["_xatlasAtlas_getHeight"] = wasmExports2["xatlasAtlas_getHeight"]; + _xatlasAtlas_getAtlasCount = Module["_xatlasAtlas_getAtlasCount"] = wasmExports2["xatlasAtlas_getAtlasCount"]; + _xatlasAtlas_getChartCount = Module["_xatlasAtlas_getChartCount"] = wasmExports2["xatlasAtlas_getChartCount"]; + _xatlasAtlas_getMeshCount = Module["_xatlasAtlas_getMeshCount"] = wasmExports2["xatlasAtlas_getMeshCount"]; + _xatlasAtlas_getTexelsPerUnit = Module["_xatlasAtlas_getTexelsPerUnit"] = wasmExports2["xatlasAtlas_getTexelsPerUnit"]; + _xatlasAtlas_getUtilization = Module["_xatlasAtlas_getUtilization"] = wasmExports2["xatlasAtlas_getUtilization"]; + _xatlasAtlas_getMesh = Module["_xatlasAtlas_getMesh"] = wasmExports2["xatlasAtlas_getMesh"]; + _xatlasAtlas_getImage = Module["_xatlasAtlas_getImage"] = wasmExports2["xatlasAtlas_getImage"]; + _xatlasMesh_getVertexCount = Module["_xatlasMesh_getVertexCount"] = wasmExports2["xatlasMesh_getVertexCount"]; + _xatlasMesh_getIndexCount = Module["_xatlasMesh_getIndexCount"] = wasmExports2["xatlasMesh_getIndexCount"]; + _xatlasMesh_getChartCount = Module["_xatlasMesh_getChartCount"] = wasmExports2["xatlasMesh_getChartCount"]; + _xatlasMesh_getVertexArray = Module["_xatlasMesh_getVertexArray"] = wasmExports2["xatlasMesh_getVertexArray"]; + _xatlasMesh_getIndexArray = Module["_xatlasMesh_getIndexArray"] = wasmExports2["xatlasMesh_getIndexArray"]; + _xatlasMesh_getChartArray = Module["_xatlasMesh_getChartArray"] = wasmExports2["xatlasMesh_getChartArray"]; + _xatlasVertex_getAtlasIndex = Module["_xatlasVertex_getAtlasIndex"] = wasmExports2["xatlasVertex_getAtlasIndex"]; + _xatlasVertex_getChartIndex = Module["_xatlasVertex_getChartIndex"] = wasmExports2["xatlasVertex_getChartIndex"]; + _xatlasVertex_getUV0 = Module["_xatlasVertex_getUV0"] = wasmExports2["xatlasVertex_getUV0"]; + _xatlasVertex_getUV1 = Module["_xatlasVertex_getUV1"] = wasmExports2["xatlasVertex_getUV1"]; + _xatlasVertex_getXref = Module["_xatlasVertex_getXref"] = wasmExports2["xatlasVertex_getXref"]; + _xatlasChart_getAtlasIndex = Module["_xatlasChart_getAtlasIndex"] = wasmExports2["xatlasChart_getAtlasIndex"]; + _xatlasChart_getFaceCount = Module["_xatlasChart_getFaceCount"] = wasmExports2["xatlasChart_getFaceCount"]; + _xatlasChart_getType = Module["_xatlasChart_getType"] = wasmExports2["xatlasChart_getType"]; + _xatlasChart_getMaterial = Module["_xatlasChart_getMaterial"] = wasmExports2["xatlasChart_getMaterial"]; + _xatlasChart_getFaceArray = Module["_xatlasChart_getFaceArray"] = wasmExports2["xatlasChart_getFaceArray"]; + _xatlasMeshDecl_size = Module["_xatlasMeshDecl_size"] = wasmExports2["xatlasMeshDecl_size"]; + _xatlasUvMeshDecl_size = Module["_xatlasUvMeshDecl_size"] = wasmExports2["xatlasUvMeshDecl_size"]; + _xatlasChartOptions_size = Module["_xatlasChartOptions_size"] = wasmExports2["xatlasChartOptions_size"]; + _xatlasPackOptions_size = Module["_xatlasPackOptions_size"] = wasmExports2["xatlasPackOptions_size"]; + __emscripten_timeout = wasmExports2["_emscripten_timeout"]; + _malloc = Module["_malloc"] = wasmExports2["malloc"]; + __emscripten_stack_restore = wasmExports2["_emscripten_stack_restore"]; + __emscripten_stack_alloc = wasmExports2["_emscripten_stack_alloc"]; + _emscripten_stack_get_current = wasmExports2["emscripten_stack_get_current"]; + memory = wasmMemory = wasmExports2["memory"]; + __indirect_function_table = wasmTable = wasmExports2["__indirect_function_table"]; + } + var wasmImports = { _abort_js: __abort_js, _emscripten_runtime_keepalive_clear: __emscripten_runtime_keepalive_clear, _setitimer_js: __setitimer_js, emscripten_resize_heap: _emscripten_resize_heap, fd_write: _fd_write, proc_exit: _proc_exit }; + function run() { + preRun(); + function doRun() { + Module["calledRun"] = true; + if (ABORT) return; + initRuntime(); + readyPromiseResolve?.(Module); + Module["onRuntimeInitialized"]?.(); + postRun(); + } + if (Module["setStatus"]) { + Module["setStatus"]("Running..."); + setTimeout(() => { + setTimeout(() => Module["setStatus"](""), 1); + doRun(); + }, 1); + } else { + doRun(); + } + } + var wasmExports; + wasmExports = await createWasm(); + run(); + if (runtimeInitialized) { + moduleRtn = Module; + } else { + moduleRtn = new Promise((resolve, reject) => { + readyPromiseResolve = resolve; + readyPromiseReject = reject; + }); + } + ; + return moduleRtn; +} +var xatlas_default = createXAtlasModule; + +// lib/xatlas.mjs +var ChartType = Object.freeze({ + Planar: 0, + Ortho: 1, + LSCM: 2, + Piecewise: 3, + Invalid: 4 +}); +var IndexFormat = Object.freeze({ + UInt16: 0, + UInt32: 1 +}); +var AddMeshError = Object.freeze({ + Success: 0, + Error: 1, + IndexOutOfRange: 2, + InvalidFaceVertexCount: 3, + InvalidIndexCount: 4 +}); +var ProgressCategory = Object.freeze({ + AddMesh: 0, + ComputeCharts: 1, + PackCharts: 2, + BuildOutputMeshes: 3 +}); +function copyToHeap(module, typedArray, heapType) { + const numBytes = typedArray.byteLength; + const ptr = module._malloc(numBytes); + if (!ptr) throw new Error("xatlas-wasm: malloc failed"); + const dst = new Uint8Array(module.HEAPU8.buffer, ptr, numBytes); + dst.set(new Uint8Array(typedArray.buffer, typedArray.byteOffset, numBytes)); + return ptr; +} +var XAtlas = class { + /** @internal */ + constructor(module) { + this._m = module; + this._ptr = module._xatlasCreate(); + this._progressFnPtr = 0; + if (!this._ptr) throw new Error("xatlas-wasm: xatlasCreate returned null"); + } + destroy() { + if (this._progressFnPtr) { + this._m.removeFunction(this._progressFnPtr); + this._progressFnPtr = 0; + } + if (this._ptr) { + this._m._xatlasDestroy(this._ptr); + this._ptr = 0; + } + } + /** + * Add a triangle mesh to the atlas. + * @param {object} opts + * @param {Float32Array} opts.positions - Vertex positions (flat xyz, length = vertexCount * 3). + * @param {Float32Array} [opts.normals] - Vertex normals (flat xyz). + * @param {Float32Array} [opts.uvs] - Vertex UVs (flat xy). + * @param {Uint16Array|Uint32Array} [opts.indices] - Triangle indices. + * @param {Uint32Array} [opts.faceMaterialData] - Per-face material IDs. + * @param {number} [opts.meshCountHint=0] + * @returns {number} AddMeshError code. + */ + addMesh(opts) { + const m = this._m; + const { + positions, + normals = null, + uvs = null, + indices = null, + faceMaterialData = null, + meshCountHint = 0 + } = opts; + const vertexCount = positions.length / 3 | 0; + const declSize = m._xatlasMeshDecl_size(); + const declPtr = m._malloc(declSize); + m.HEAPU8.fill(0, declPtr, declPtr + declSize); + m._xatlasMeshDeclInit(declPtr); + const allocs = [declPtr]; + const posPtr = copyToHeap(m, positions); + allocs.push(posPtr); + let normPtr = 0; + if (normals) { + normPtr = copyToHeap(m, normals); + allocs.push(normPtr); + } + let uvPtr = 0; + if (uvs) { + uvPtr = copyToHeap(m, uvs); + allocs.push(uvPtr); + } + let idxPtr = 0; + let indexCount = 0; + let indexFormat = IndexFormat.UInt16; + if (indices) { + idxPtr = copyToHeap(m, indices); + allocs.push(idxPtr); + indexCount = indices.length; + indexFormat = indices instanceof Uint32Array ? IndexFormat.UInt32 : IndexFormat.UInt16; + } + let faceMatPtr = 0; + if (faceMaterialData) { + faceMatPtr = copyToHeap(m, faceMaterialData); + allocs.push(faceMatPtr); + } + m.setValue(declPtr + 0, posPtr, "i32"); + m.setValue(declPtr + 4, normPtr, "i32"); + m.setValue(declPtr + 8, uvPtr, "i32"); + m.setValue(declPtr + 12, idxPtr, "i32"); + m.setValue(declPtr + 16, 0, "i32"); + m.setValue(declPtr + 20, faceMatPtr, "i32"); + m.setValue(declPtr + 24, 0, "i32"); + m.setValue(declPtr + 28, vertexCount, "i32"); + m.setValue(declPtr + 32, 3 * 4, "i32"); + m.setValue(declPtr + 36, normals ? 3 * 4 : 0, "i32"); + m.setValue(declPtr + 40, uvs ? 2 * 4 : 0, "i32"); + m.setValue(declPtr + 44, indexCount, "i32"); + m.setValue(declPtr + 48, 0, "i32"); + m.setValue(declPtr + 52, 0, "i32"); + m.setValue(declPtr + 56, indexFormat, "i32"); + const error = m._xatlasAddMesh(this._ptr, declPtr, meshCountHint); + for (const p of allocs) m._free(p); + return error; + } + /** + * Add a UV mesh for repacking. + * @param {object} opts + * @param {Float32Array} opts.uvs - Vertex UVs (flat xy). + * @param {Uint16Array|Uint32Array} [opts.indices] + * @param {Uint32Array} [opts.faceMaterialData] + * @returns {number} AddMeshError code. + */ + addUvMesh(opts) { + const m = this._m; + const { uvs, indices = null, faceMaterialData = null } = opts; + const vertexCount = uvs.length / 2 | 0; + const declSize = m._xatlasUvMeshDecl_size(); + const declPtr = m._malloc(declSize); + m.HEAPU8.fill(0, declPtr, declPtr + declSize); + m._xatlasUvMeshDeclInit(declPtr); + const allocs = [declPtr]; + const uvPtr = copyToHeap(m, uvs); + allocs.push(uvPtr); + let idxPtr = 0, indexCount = 0, indexFormat = IndexFormat.UInt16; + if (indices) { + idxPtr = copyToHeap(m, indices); + allocs.push(idxPtr); + indexCount = indices.length; + indexFormat = indices instanceof Uint32Array ? IndexFormat.UInt32 : IndexFormat.UInt16; + } + let faceMatPtr = 0; + if (faceMaterialData) { + faceMatPtr = copyToHeap(m, faceMaterialData); + allocs.push(faceMatPtr); + } + m.setValue(declPtr + 0, uvPtr, "i32"); + m.setValue(declPtr + 4, idxPtr, "i32"); + m.setValue(declPtr + 8, faceMatPtr, "i32"); + m.setValue(declPtr + 12, vertexCount, "i32"); + m.setValue(declPtr + 16, 2 * 4, "i32"); + m.setValue(declPtr + 20, indexCount, "i32"); + m.setValue(declPtr + 24, 0, "i32"); + m.setValue(declPtr + 28, indexFormat, "i32"); + const error = m._xatlasAddUvMesh(this._ptr, declPtr); + for (const p of allocs) m._free(p); + return error; + } + addMeshJoin() { + this._m._xatlasAddMeshJoin(this._ptr); + } + /** + * Compute charts for all added meshes. + * @param {object} [opts] + */ + computeCharts(opts) { + const m = this._m; + if (!opts) { + m._xatlasComputeCharts(this._ptr, 0); + return; + } + const size = m._xatlasChartOptions_size(); + const ptr = m._malloc(size); + m._xatlasChartOptionsInit(ptr); + this._writeChartOptions(ptr, opts); + m._xatlasComputeCharts(this._ptr, ptr); + m._free(ptr); + } + /** + * Pack charts into atlas(es). + * @param {object} [opts] + */ + packCharts(opts) { + const m = this._m; + if (!opts) { + m._xatlasPackCharts(this._ptr, 0); + return; + } + const size = m._xatlasPackOptions_size(); + const ptr = m._malloc(size); + m._xatlasPackOptionsInit(ptr); + this._writePackOptions(ptr, opts); + m._xatlasPackCharts(this._ptr, ptr); + m._free(ptr); + } + /** + * Generate atlas (computeCharts + packCharts in one call). + * @param {object} [chartOptions] + * @param {object} [packOptions] + */ + generate(chartOptions, packOptions) { + const m = this._m; + let chartPtr = 0, packPtr = 0; + if (chartOptions) { + const size = m._xatlasChartOptions_size(); + chartPtr = m._malloc(size); + m._xatlasChartOptionsInit(chartPtr); + this._writeChartOptions(chartPtr, chartOptions); + } + if (packOptions) { + const size = m._xatlasPackOptions_size(); + packPtr = m._malloc(size); + m._xatlasPackOptionsInit(packPtr); + this._writePackOptions(packPtr, packOptions); + } + m._xatlasGenerate(this._ptr, chartPtr, packPtr); + if (chartPtr) m._free(chartPtr); + if (packPtr) m._free(packPtr); + } + /** + * Set progress callback. Pass null to clear. + * @param {function|null} fn - (category: number, progress: number) => boolean + */ + setProgressCallback(fn) { + const m = this._m; + if (this._progressFnPtr) { + m.removeFunction(this._progressFnPtr); + this._progressFnPtr = 0; + } + if (fn) { + this._progressFnPtr = m.addFunction((category, progress, _userData) => { + return fn(category, progress) ? 1 : 0; + }, "iiii"); + m._xatlasSetProgressCallback(this._ptr, this._progressFnPtr, 0); + } else { + m._xatlasSetProgressCallback(this._ptr, 0, 0); + } + } + /* ── Result getters ──────────────────────────────────────────── */ + get width() { + return this._m._xatlasAtlas_getWidth(this._ptr); + } + get height() { + return this._m._xatlasAtlas_getHeight(this._ptr); + } + get atlasCount() { + return this._m._xatlasAtlas_getAtlasCount(this._ptr); + } + get chartCount() { + return this._m._xatlasAtlas_getChartCount(this._ptr); + } + get meshCount() { + return this._m._xatlasAtlas_getMeshCount(this._ptr); + } + get texelsPerUnit() { + return this._m._xatlasAtlas_getTexelsPerUnit(this._ptr); + } + getUtilization(atlasIndex) { + return this._m._xatlasAtlas_getUtilization(this._ptr, atlasIndex); + } + /** + * Retrieve output mesh data for a given input mesh index. + * @param {number} meshIndex + * @returns {{ vertexCount: number, indexCount: number, chartCount: number, vertices: Array, indices: Uint32Array, charts: Array }} + */ + getMesh(meshIndex) { + const m = this._m; + const meshPtr = m._xatlasAtlas_getMesh(this._ptr, meshIndex); + const vertexCount = m._xatlasMesh_getVertexCount(meshPtr); + const indexCount = m._xatlasMesh_getIndexCount(meshPtr); + const chartCount = m._xatlasMesh_getChartCount(meshPtr); + const vertexArrayPtr = m._xatlasMesh_getVertexArray(meshPtr); + const vertices = new Array(vertexCount); + for (let i = 0; i < vertexCount; i++) { + vertices[i] = { + atlasIndex: m._xatlasVertex_getAtlasIndex(vertexArrayPtr, i), + chartIndex: m._xatlasVertex_getChartIndex(vertexArrayPtr, i), + uv: [ + m._xatlasVertex_getUV0(vertexArrayPtr, i), + m._xatlasVertex_getUV1(vertexArrayPtr, i) + ], + xref: m._xatlasVertex_getXref(vertexArrayPtr, i) + }; + } + const indexArrayPtr = m._xatlasMesh_getIndexArray(meshPtr); + const indices = new Uint32Array( + m.HEAPU32.buffer.slice(indexArrayPtr, indexArrayPtr + indexCount * 4) + ); + const chartArrayPtr = m._xatlasMesh_getChartArray(meshPtr); + const charts = new Array(chartCount); + for (let i = 0; i < chartCount; i++) { + const faceCount = m._xatlasChart_getFaceCount(chartArrayPtr, i); + const faceArrayPtr = m._xatlasChart_getFaceArray(chartArrayPtr, i); + charts[i] = { + atlasIndex: m._xatlasChart_getAtlasIndex(chartArrayPtr, i), + faceCount, + type: m._xatlasChart_getType(chartArrayPtr, i), + material: m._xatlasChart_getMaterial(chartArrayPtr, i), + faces: new Uint32Array( + m.HEAPU32.buffer.slice(faceArrayPtr, faceArrayPtr + faceCount * 4) + ) + }; + } + return { vertexCount, indexCount, chartCount, vertices, indices, charts }; + } + /* ── Private: write option structs ─────────────────────────── */ + _writeChartOptions(ptr, opts) { + const m = this._m; + const fields = [ + ["maxChartArea", 4, "float"], + ["maxBoundaryLength", 8, "float"], + ["normalDeviationWeight", 12, "float"], + ["roundnessWeight", 16, "float"], + ["straightnessWeight", 20, "float"], + ["normalSeamWeight", 24, "float"], + ["textureSeamWeight", 28, "float"], + ["maxCost", 32, "float"], + ["maxIterations", 36, "i32"] + ]; + for (const [name, offset, type] of fields) { + if (opts[name] !== void 0) m.setValue(ptr + offset, opts[name], type); + } + if (opts.useInputMeshUvs !== void 0) m.setValue(ptr + 40, opts.useInputMeshUvs ? 1 : 0, "i8"); + if (opts.fixWinding !== void 0) m.setValue(ptr + 41, opts.fixWinding ? 1 : 0, "i8"); + } + _writePackOptions(ptr, opts) { + const m = this._m; + const intFields = [ + ["maxChartSize", 0, "i32"], + ["padding", 4, "i32"], + ["texelsPerUnit", 8, "float"], + ["resolution", 12, "i32"] + ]; + for (const [name, offset, type] of intFields) { + if (opts[name] !== void 0) m.setValue(ptr + offset, opts[name], type); + } + const boolFields = [ + ["bilinear", 16], + ["blockAlign", 17], + ["bruteForce", 18], + ["createImage", 19], + ["rotateChartsToAxis", 20], + ["rotateCharts", 21] + ]; + for (const [name, offset] of boolFields) { + if (opts[name] !== void 0) m.setValue(ptr + offset, opts[name] ? 1 : 0, "i8"); + } + } +}; +function addMeshErrorString(module, error) { + const ptr = module._xatlasAddMeshErrorString(error); + return module.UTF8ToString(ptr); +} +function progressCategoryString(module, category) { + const ptr = module._xatlasProgressCategoryString(category); + return module.UTF8ToString(ptr); +} +async function createXAtlas() { + const module = await xatlas_default(); + return { + createAtlas: () => new XAtlas(module), + addMeshErrorString: (error) => addMeshErrorString(module, error), + progressCategoryString: (category) => progressCategoryString(module, category), + ChartType, + IndexFormat, + AddMeshError, + ProgressCategory + }; +} +// ---- END VENDORED xatlas-wasm glue ---- + +const WASM_PATH = 'assets/xatlas.wasm'; +/** xatlas's brute-force packer gives the tightest atlas but its cost grows with chart + * count; above this many triangles the (still non-overlapping) random placement is used */ +const BRUTE_FORCE_MAX = 4000; + +/** + * Instantiate the xatlas runtime from the packaged wasm. + * @param {(path: string) => string|null} assetUrl + * @returns {Promise} the emscripten Module + */ +async function instantiateXatlas(assetUrl) { + const url = assetUrl(WASM_PATH); + if (!url) throw new Error(WASM_PATH + ' is missing from the module package'); + const response = await fetch(url); + if (!response.ok) throw new Error('could not read ' + WASM_PATH + ' (' + response.status + ')'); + // arrayBuffer + instantiate rather than instantiateStreaming: a module asset is a blob + // with no MIME type, which streaming compilation rejects (the uv-unwrap-module lesson) + const bytes = await response.arrayBuffer(); + /** @type {(error: any) => void} */ + let fail = () => {}; + const failed = new Promise((_, reject) => (fail = reject)); + const ready = createXAtlasModule({ + /** @param {any} imports @param {(instance: any, module: any) => void} receive */ + instantiateWasm(imports, receive) { + WebAssembly.instantiate(bytes, imports).then( + (result) => receive(result.instance, result.module), + fail + ); + return {}; // emscripten: an empty object means "instantiating asynchronously" + }, + print: () => {}, + /** @param {string} text */ + printErr: (text) => console.warn('smart-unwrap (xatlas): ' + text) + }); + // the glue's instantiateWasm path has no reject of its own: race our failure against it + return Promise.race([ready, failed]); +} + +/** @param {number} value @param {number} min @param {number} max */ +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + +/** twice the signed area of a uv triangle: > 0 = counter-clockwise @param {number[][]} t */ +function signedArea2(t) { + return (t[1][0] - t[0][0]) * (t[2][1] - t[0][1]) - (t[2][0] - t[0][0]) * (t[1][1] - t[0][1]); +} + +/** + * Weld by EXACT position, so xatlas sees the connectivity. The editor hands over unindexed + * triangles (every corner its own vertex); without a weld every triangle would be its own + * chart. Exact equality is the right test: a mesh's shared corners are bit-identical + * copies of one vertex. + * @param {any[]} faces + */ +function weldFaces(faces) { + /** @type {Map} */ + const seen = new Map(); + /** @type {number[]} */ + const positions = []; + const indices = new Uint32Array(faces.length * 3); + for (let k = 0; k < faces.length; k++) + for (let c = 0; c < 3; c++) { + const p = faces[k].corners[c]; + const x = p.x ?? p[0]; + const y = p.y ?? p[1]; + const z = p.z ?? p[2]; + const key = x + ',' + y + ',' + z; + let index = seen.get(key); + if (index === undefined) { + index = positions.length / 3; + seen.set(key, index); + positions.push(x, y, z); + } + indices[k * 3 + c] = index; + } + return { positions: new Float32Array(positions), indices }; +} + +/** + * Per input face, the three output vertex indices of `mesh`, matched by xref (the input + * vertex each output vertex came from) rather than by slot — robust if xatlas ever rotates + * a face's corners. + * @param {any} mesh @param {Uint32Array} inputIndices @param {number} k + */ +function outputCorners(mesh, inputIndices, k) { + const out = [mesh.indices[k * 3], mesh.indices[k * 3 + 1], mesh.indices[k * 3 + 2]]; + return out.map((vi, c) => { + const want = inputIndices[k * 3 + c]; + if (mesh.vertices[vi].xref === want) return vi; + return out.find((o) => mesh.vertices[o].xref === want) ?? vi; + }); +} + +/** + * The unwrap itself — `faces` is core's UnwrapFace list ({corners: [Vector3 x3], tri}), + * the result is core's UnwrapResult: `uvs[k]` = the three [u, v] corners of faces[k], in + * 0..1, and `islands` = groups of indices into `faces` (one per xatlas chart). + * + * WINDING: every chart must come out unmirrored, or painted text and decals land on the + * model backwards. xatlas's `fixWinding` flattens every chart counter-clockwise, but its + * packer's `rotateCharts` turns a chart by TRANSPOSING it (swapping u and v), which is a + * mirror: a 216-triangle sphere came back with 2 of 6 charts clockwise. So rotateCharts is + * off by default (rotateChartsToAxis, a true rotation, stays on) — a slightly looser + * pack for charts that all read the right way round. The unit test holds every triangle + * to counter-clockwise. + * + * Options (all optional; the UV editor passes `{margin: 0.02}`): + * margin gap between charts as a fraction of the texture (default 0.01, as the + * built-in packer) — converted to xatlas texel padding at `resolution` + * resolution the texture size the padding is computed for (default 512) + * padding xatlas padding in texels, overriding `margin` + * chartOptions / packOptions raw xatlas ChartOptions / PackOptions, merged last + * (e.g. `packOptions: {rotateCharts: true}` for the tighter, mirroring pack) + * + * @param {any} xa the instantiated emscripten Module + * @param {any[]} faces @param {any} [options] + */ +function unwrapFaces(xa, faces, options = {}) { + const faceCount = faces.length; + const welded = weldFaces(faces); + + const resolution = Math.round(clamp(Number(options.resolution) || 512, 64, 8192)); + const margin = clamp(Number(options.margin ?? 0.01) || 0, 0, 0.25); + // xatlas pads EACH chart, so the gap between two neighbours is about twice this + const padding = + options.padding != null + ? Math.round(clamp(Number(options.padding) || 0, 0, 64)) + : Math.max(1, Math.round((margin * resolution) / 2)); + + const atlas = new XAtlas(xa); + try { + const error = atlas.addMesh(welded); + if (error !== AddMeshError.Success) throw new Error(addMeshErrorString(xa, error)); + atlas.computeCharts({ fixWinding: true, ...(options.chartOptions ?? {}) }); + const packOptions = { + resolution, + padding, + bilinear: true, + bruteForce: faceCount <= BRUTE_FORCE_MAX, + rotateCharts: false, + ...(options.packOptions ?? {}) + }; + atlas.packCharts(packOptions); + // a fixed resolution can spill into several atlases, which would OVERLAP once + // normalised: re-pack into one, letting xatlas pick the scale + if (atlas.atlasCount > 1) atlas.packCharts({ ...packOptions, resolution: 0, texelsPerUnit: 0 }); + const mesh = atlas.getMesh(0); + if (mesh.indexCount !== faceCount * 3) + throw new Error('xatlas returned ' + mesh.indexCount / 3 + ' faces for ' + faceCount); + // one scale for both axes: a non-square atlas must not stretch the charts + const scale = Math.max(atlas.width, atlas.height) || 1; + + /** @type {number[][][]} */ + const uvs = new Array(faceCount); + let signed = 0; + for (let k = 0; k < faceCount; k++) { + uvs[k] = outputCorners(mesh, welded.indices, k).map((vi) => { + const vertex = mesh.vertices[vi]; + // a face xatlas ignored (zero area, NaN) is in no chart: park it at the origin + return vertex.chartIndex < 0 ? [0, 0] : [vertex.uv[0] / scale, vertex.uv[1] / scale]; + }); + signed += signedArea2(uvs[k]); + } + // guard: should the charts ever all come out clockwise, ONE flip of v for the whole + // atlas makes them counter-clockwise (a mirror of everything stays overlap-free) + if (signed < 0) for (const corners of uvs) for (const uv of corners) uv[1] = 1 - uv[1]; + for (const corners of uvs) + for (const uv of corners) { + uv[0] = clamp(uv[0], 0, 1); + uv[1] = clamp(uv[1], 0, 1); + } + + /** @type {number[][]} */ + const islands = mesh.charts.map((/** @type {any} */ chart) => Array.from(chart.faces)); + // the ignored faces ride one extra island, so every face is accounted for (as the + // built-ins do) + const charted = new Uint8Array(faceCount); + for (const island of islands) for (const fi of island) charted[fi] = 1; + const loose = []; + for (let k = 0; k < faceCount; k++) if (!charted[k]) loose.push(k); + if (loose.length) islands.push(loose); + return { uvs, islands }; + } finally { + atlas.destroy(); + } +} + +export default { + id: 'smart-unwrap', + name: 'Smart unwrap (xatlas)', + version: '1.0.0', + description: + 'Adds "Smart (xatlas)" to the UV editor\'s Unwrap menu: automatic seams, flattened charts and an overlap-free packed atlas, from xatlas compiled to WebAssembly.', + + /** @param {any} api */ + register(api) { + /** @type {Promise|null} the runtime, instantiated on the first unwrap */ + let runtime = null; + + const ensureRuntime = () => { + if (!runtime) + runtime = instantiateXatlas((path) => api.assetUrl(path)).catch((error) => { + runtime = null; // let the next unwrap try again + throw error; + }); + return runtime; + }; + + // the SDK journals this registration and removes it from the UV editor's menu when + // the module unloads (sdk/backends.js onDispose) — nothing to undo here + const registered = api.registerUnwrapBackend( + 'xatlas', + 'Smart (xatlas)', + /** @param {any[]} faces @param {any} [options] */ + async (faces, options = {}) => { + try { + const xa = await ensureRuntime(); + return unwrapFaces(xa, faces, options); + } catch (error) { + // a wasm abort leaves that runtime unusable: start fresh next time + runtime = null; + const message = error instanceof Error ? error.message : String(error); + console.warn('smart-unwrap failed', error); + api.toast?.('Smart unwrap failed: ' + message); + return null; // core reports "That unwrap produced nothing" and commits nothing + } + } + ); + + // drop the cached runtime (and with it the wasm memory) on unload / live update + api.onUnload?.(() => { + runtime = null; + }); + + return registered; + } +}; diff --git a/modules/smart-unwrap/test/run.mjs b/modules/smart-unwrap/test/run.mjs new file mode 100644 index 0000000..92a81f6 --- /dev/null +++ b/modules/smart-unwrap/test/run.mjs @@ -0,0 +1,284 @@ +// Pure-node tests for smart-unwrap — run WITHOUT the app: +// node modules/smart-unwrap/test/run.mjs (npm run test:smart-unwrap) +// +// Loads the REAL module.js and the REAL packaged assets/xatlas.wasm through a fake api +// whose assetUrl hands out blob URLs — exactly what the app's userModules does with a +// zip's files — then drives the registered backend the way uvEditor.unwrapObject does: +// unindexed triangles in object space, `{corners: [Vector3 x3], tri}` per face. +// +// It asserts PROPERTIES, not floats (xatlas's packer is allowed to change its mind): +// every uv in 0..1, three corners per face, no degenerate uv triangle, islands partition +// the faces, and no two islands overlap in uv space (rasterised on a grid). The overlap, +// area and range checks are each shown to FAIL on a deliberately broken result, so a +// green run means they can see what they claim to. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import * as THREE from 'three'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const MODULE_DIR = path.join(HERE, '..'); + +let failures = 0; +let passes = 0; +/** @param {boolean} ok @param {string} label */ +function check(ok, label) { + console.log((ok ? 'PASS ' : 'FAIL ') + label); + if (ok) passes++; + else failures++; +} + +// ---- the fake api ---------------------------------------------------------- +const manifest = JSON.parse(fs.readFileSync(path.join(MODULE_DIR, 'manifest.json'), 'utf8')); +/** @type {Record} */ +const assets = {}; +for (const file of manifest.files) { + if (file === manifest.entry) continue; + assets[file] = URL.createObjectURL(new Blob([fs.readFileSync(path.join(MODULE_DIR, file))])); +} +let assetReads = 0; +/** @type {{key: string, label: string, run: Function}[]} */ +const backends = []; +/** @type {Function[]} */ +const unloads = []; +/** @type {string[]} */ +const toasts = []; +const api = { + THREE, + /** @param {string} p */ + assetUrl: (p) => { + if (p.endsWith('.wasm')) assetReads++; + return assets[p] ?? null; + }, + /** @param {string} key @param {string} label @param {Function} run */ + registerUnwrapBackend: (key, label, run) => { + backends.push({ key, label, run }); + return Promise.resolve(); + }, + /** @param {Function} fn */ + onUnload: (fn) => unloads.push(fn), + /** @param {string} text */ + toast: (text) => toasts.push(text) +}; + +const mod = (await import(pathToFileURL(path.join(MODULE_DIR, 'module.js')).href)).default; +check(mod.id === manifest.id && mod.version === manifest.version, `module.js id/version match the manifest (${mod.id} ${mod.version})`); +await mod.register(api); +check(backends.length === 1 && backends[0].key === 'xatlas', 'register() adds ONE unwrap backend under key "xatlas"'); +check(backends[0].label === 'Smart (xatlas)', 'labelled "Smart (xatlas)" for the Unwrap menu'); +check(unloads.length === 1, 'it registers an onUnload hook (drops the cached runtime)'); +const run = backends[0].run; + +// ---- meshes -> the faces uvEditor.unwrapObject passes ------------------------ +/** @param {any} geometry */ +function facesOf(geometry) { + const g = geometry.index ? geometry.toNonIndexed() : geometry; + const pos = g.attributes.position; + const faces = []; + for (let i = 0; i < pos.count; i += 3) + faces.push({ + corners: [0, 1, 2].map((c) => new THREE.Vector3().fromBufferAttribute(pos, i + c)), + tri: i / 3 + }); + return faces; +} + +// ---- the property checks (each returns {ok, detail}) ------------------------ +/** @param {number[][]} t */ +const areaOf = (t) => ((t[1][0] - t[0][0]) * (t[2][1] - t[0][1]) - (t[2][0] - t[0][0]) * (t[1][1] - t[0][1])) / 2; + +/** @param {any} result @param {number} n */ +function checkShape(result, n) { + const corners = result?.uvs?.reduce((sum, /** @type {any} */ f) => sum + f.length, 0) ?? 0; + return { ok: result?.uvs?.length === n && corners === n * 3, detail: `${corners} corners for ${n} tris` }; +} +/** @param {any} result */ +function checkRange(result) { + let bad = 0; + for (const face of result.uvs) + for (const [u, v] of face) if (!(u >= 0 && u <= 1 && v >= 0 && v <= 1)) bad++; + return { ok: bad === 0, detail: `${bad} uvs outside 0..1` }; +} +/** @param {any} result */ +function checkArea(result) { + let degenerate = 0; + let total = 0; + for (const face of result.uvs) { + const a = Math.abs(areaOf(face)); + total += a; + if (!(a > 1e-12)) degenerate++; + } + return { ok: degenerate === 0 && total <= 1 + 1e-9, detail: `${degenerate} zero-area uv tris, total uv area ${total.toFixed(3)}` }; +} +/** every face in exactly one island @param {any} result @param {number} n */ +function checkPartition(result, n) { + const seen = new Uint8Array(n); + let dup = 0; + let outOfRange = 0; + for (const island of result.islands) + for (const fi of island) { + if (!(fi >= 0 && fi < n)) outOfRange++; + else if (seen[fi]++) dup++; + } + const missing = seen.reduce((m, s) => m + (s ? 0 : 1), 0); + return { ok: !dup && !outOfRange && !missing, detail: `${result.islands.length} islands, ${missing} faces missing, ${dup} doubled` }; +} +/** + * Rasterise every uv triangle onto a GRID x GRID raster (sample = cell centre, strictly + * inside the triangle) and record which island owns each cell. A cell claimed by two + * different islands is an overlap — texture painted there would land on both. + * @param {any} result @param {number} [grid] + */ +function checkNoOverlap(result, grid = 512) { + const owner = new Int32Array(grid * grid).fill(-1); + let overlaps = 0; + const islandOf = new Int32Array(result.uvs.length).fill(-1); + result.islands.forEach((/** @type {number[]} */ island, /** @type {number} */ i) => { + for (const fi of island) islandOf[fi] = i; + }); + result.uvs.forEach((/** @type {number[][]} */ t, /** @type {number} */ fi) => { + const area2 = areaOf(t) * 2; + if (Math.abs(area2) < 1e-15) return; + const uMin = Math.max(0, Math.floor(Math.min(t[0][0], t[1][0], t[2][0]) * grid)); + const uMax = Math.min(grid - 1, Math.ceil(Math.max(t[0][0], t[1][0], t[2][0]) * grid)); + const vMin = Math.max(0, Math.floor(Math.min(t[0][1], t[1][1], t[2][1]) * grid)); + const vMax = Math.min(grid - 1, Math.ceil(Math.max(t[0][1], t[1][1], t[2][1]) * grid)); + for (let y = vMin; y <= vMax; y++) + for (let x = uMin; x <= uMax; x++) { + const pu = (x + 0.5) / grid; + const pv = (y + 0.5) / grid; + // barycentric, same sign as the triangle's winding = strictly inside + const w0 = ((t[1][0] - pu) * (t[2][1] - pv) - (t[2][0] - pu) * (t[1][1] - pv)) / area2; + const w1 = ((t[2][0] - pu) * (t[0][1] - pv) - (t[0][0] - pu) * (t[2][1] - pv)) / area2; + const w2 = 1 - w0 - w1; + if (w0 <= 1e-9 || w1 <= 1e-9 || w2 <= 1e-9) continue; + const cell = y * grid + x; + const island = islandOf[fi]; + if (owner[cell] === -1) owner[cell] = island; + else if (owner[cell] !== island) overlaps++; + } + }); + return { ok: overlaps === 0, detail: `${overlaps} cells claimed by two islands (${grid}x${grid})` }; +} +/** positive uv winding = charts read unmirrored with three's v-up uvs @param {any} result */ +function windingCounts(result) { + let ccw = 0; + let cw = 0; + for (const face of result.uvs) (areaOf(face) > 0 ? ccw++ : cw++); + return { ccw, cw }; +} + +/** @param {string} name @param {any} geometry @param {any} [options] */ +async function suite(name, geometry, options = { margin: 0.02 }) { + console.log(`\n=== ${name} ===`); + const faces = facesOf(geometry); + const started = performance.now(); + const result = await run(faces, options); + const ms = performance.now() - started; + check(!!result && Array.isArray(result.uvs) && Array.isArray(result.islands), `${name}: returns {uvs, islands} (${faces.length} tris, ${ms.toFixed(0)} ms)`); + if (!result) return null; + for (const [label, outcome] of [ + ['corner count = 3 x tris', checkShape(result, faces.length)], + ['every uv in [0,1]', checkRange(result)], + ['every uv triangle has area > 0, total <= 1', checkArea(result)], + ['islands partition the faces', checkPartition(result, faces.length)], + ['no two islands overlap in uv space', checkNoOverlap(result)] + ]) + check(/** @type {any} */ (outcome).ok, `${name}: ${label} — ${/** @type {any} */ (outcome).detail}`); + const w = windingCounts(result); + check(w.cw === 0, `${name}: every uv triangle winds counter-clockwise (unmirrored) — ${w.ccw} ccw / ${w.cw} cw`); + return { faces, result }; +} + +// ---- (a) unit cube, (b) uv sphere, (c) cylinder ------------------------------ +const cube = await suite('unit cube', new THREE.BoxGeometry(1, 1, 1)); +if (cube) { + check(cube.faces.length === 12, 'the cube is 12 triangles (premise)'); + check(cube.result.islands.length <= 6, `cube: at most 6 islands (${cube.result.islands.length})`); + check(assetReads === 1, `the wasm was read once, on the first unwrap (${assetReads})`); +} +const sphere = await suite('uv sphere', new THREE.SphereGeometry(1, 12, 10)); +if (sphere) check(sphere.faces.length >= 180 && sphere.faces.length <= 240, `the sphere is ~200 triangles (${sphere.faces.length})`); +const cylinder = await suite('cylinder', new THREE.CylinderGeometry(0.5, 0.5, 2, 24, 4)); +check(assetReads === 1, `...and cached: three meshes, still one wasm read (${assetReads})`); + +// a SCOPED unwrap (Edit Mesh face pick) hands over a subset — the top half of the sphere +await suite('sphere, upper half only (scoped unwrap)', (() => { + const g = new THREE.SphereGeometry(1, 12, 10).toNonIndexed(); + const pos = g.attributes.position; + const keep = []; + for (let i = 0; i < pos.count; i += 3) if (pos.getY(i) + pos.getY(i + 1) + pos.getY(i + 2) > 0) for (let c = 0; c < 3; c++) keep.push(pos.getX(i + c), pos.getY(i + c), pos.getZ(i + c)); + const half = new THREE.BufferGeometry(); + half.setAttribute('position', new THREE.Float32BufferAttribute(keep, 3)); + return half; +})()); + +// a heavier mesh: the main-thread cost the README quotes +await suite('dense sphere', new THREE.SphereGeometry(1, 96, 64), {}); + +// ---- the weld matters: without it every triangle is its own chart ------------- +if (cube) { + const loose = cube.faces.map((/** @type {any} */ f) => ({ ...f, corners: f.corners.map((/** @type {any} */ p) => p.clone().addScalar((f.tri + 1) * 1e-3)) })); + const r = await run(loose, { margin: 0.02 }); + check(!!r && r.islands.length > 6, `counterfactual: unwelded triangles fall apart into ${r?.islands.length} islands, so the weld is what gives the cube <= 6`); +} + +// ---- COUNTERFACTUALS: the checks catch broken results -------------------------- +console.log('\n=== counterfactuals (each check must FAIL on a broken result) ==='); +if (sphere) { + const clone = (/** @type {any} */ r) => ({ uvs: r.uvs.map((/** @type {number[][]} */ f) => f.map((c) => [...c])), islands: r.islands.map((/** @type {number[]} */ i) => [...i]) }); + // stacked: every island moved to the same corner — the classic "unwrap" that overlaps + const stacked = clone(sphere.result); + for (const island of stacked.islands) { + let uMin = Infinity; + let vMin = Infinity; + for (const fi of island) for (const [u, v] of stacked.uvs[fi]) { uMin = Math.min(uMin, u); vMin = Math.min(vMin, v); } + for (const fi of island) stacked.uvs[fi] = stacked.uvs[fi].map(([u, v]) => [u - uMin, v - vMin]); + } + const overlapStacked = checkNoOverlap(stacked); + check(!overlapStacked.ok, `overlap check FAILS on islands stacked at the origin — ${overlapStacked.detail}`); + // duplicated island: island 1 is a copy of island 0's placement + const dup = clone(sphere.result); + const [first, second] = [dup.islands[0], dup.islands[1]]; + for (let k = 0; k < second.length; k++) dup.uvs[second[k]] = dup.uvs[first[k % first.length]].map((c) => [...c]); + const overlapDup = checkNoOverlap(dup); + check(!overlapDup.ok, `overlap check FAILS when one island duplicates another's uvs — ${overlapDup.detail}`); + // collapsed: every corner at one point + const collapsed = { uvs: sphere.result.uvs.map(() => [[0.5, 0.5], [0.5, 0.5], [0.5, 0.5]]), islands: sphere.result.islands }; + const areaCollapsed = checkArea(collapsed); + check(!areaCollapsed.ok, `area check FAILS on all uvs collapsed to one point — ${areaCollapsed.detail}`); + // out of range: shifted past the edge + const shifted = clone(sphere.result); + for (const f of shifted.uvs) for (const c of f) c[0] += 0.5; + const rangeShifted = checkRange(shifted); + check(!rangeShifted.ok, `range check FAILS on uvs shifted by +0.5 — ${rangeShifted.detail}`); + // mirrored charts: xatlas's own rotateCharts transposes charts — the reason it is off + const mirrored = await run(sphere.faces, { margin: 0.02, packOptions: { rotateCharts: true } }); + const mw = windingCounts(mirrored); + check(mw.cw > 0 && checkNoOverlap(mirrored).ok, `winding check FAILS with xatlas's rotateCharts on (${mw.cw} clockwise = mirrored triangles, though still overlap-free)`); + // and the island partition notices a lost face + const lost = clone(sphere.result); + lost.islands[0] = lost.islands[0].slice(1); + check(!checkPartition(lost, sphere.faces.length).ok, 'partition check FAILS when an island drops a face'); +} + +// ---- lifecycle: unload drops the runtime, the next run re-instantiates ---------- +unloads.forEach((fn) => fn()); +const again = await run(facesOf(new THREE.BoxGeometry(2, 1, 0.5)), { margin: 0.02 }); +check(!!again && checkNoOverlap(again).ok, 'after onUnload the backend still runs (fresh runtime)'); +check(assetReads === 2, `...and it re-read the wasm instead of keeping the old instance (${assetReads})`); + +// ---- failure path: a missing wasm toasts and returns null (core commits nothing) ----- +const realUrl = assets['assets/xatlas.wasm']; +unloads.forEach((fn) => fn()); +delete assets['assets/xatlas.wasm']; +const failed = await run(facesOf(new THREE.BoxGeometry()), {}); +check(failed === null && toasts.some((t) => t.includes('Smart unwrap failed')), `a missing wasm returns null and toasts (${toasts.at(-1)})`); +assets['assets/xatlas.wasm'] = realUrl; +const recovered = await run(facesOf(new THREE.BoxGeometry()), {}); +check(!!recovered && checkPartition(recovered, 12).ok, '...and the next run recovers once the asset is back'); + +console.log(`\n${passes} passed, ${failures} failed`); +console.log(failures === 0 ? 'ALL PASS' : failures + ' FAILURES'); +process.exit(failures === 0 ? 0 : 1); diff --git a/modules/smart-unwrap/vendor.mjs b/modules/smart-unwrap/vendor.mjs new file mode 100644 index 0000000..98ce33b --- /dev/null +++ b/modules/smart-unwrap/vendor.mjs @@ -0,0 +1,97 @@ +// vendor.mjs — refresh the vendored xatlas-wasm build inside this module. +// +// mkdir /tmp/xw && cd /tmp/xw && npm pack xatlas-wasm@0.1.3 --ignore-scripts && tar xzf *.tgz +// node modules/smart-unwrap/vendor.mjs /tmp/xw/package/dist/index.mjs +// +// xatlas-wasm ships ONE file: an esbuild'd ES module whose emscripten glue carries the +// .wasm inline as a binary string (SINGLE_FILE). A module entry must be self-contained +// and small enough to read, so this script splits that file in two WITHOUT running it: +// +// assets/xatlas.wasm the binary, decoded from the string literal by hand +// (a JS string-literal parser + emscripten's binaryDecode maths) +// module.js the glue spliced between the VENDORED markers, with exactly two +// edits: the inline binary is dropped (findWasmBinary returns +// nothing — the wasm arrives through Module.instantiateWasm from +// api.assetUrl) and the trailing `export {...}` is removed (the +// module's own `export default` is the only export). +// +// Nothing from the package is executed here: it is read as text. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const source = process.argv[2]; +if (!source) { + console.error('usage: node vendor.mjs /dist/index.mjs'); + process.exit(1); +} +const text = fs.readFileSync(source, 'utf8'); +const lines = text.split('\n'); + +// ---- 1. the binary -------------------------------------------------------- +const OPEN = "return binaryDecode('"; +const blobLine = lines.findIndex((line) => line.trimStart().startsWith(OPEN)); +if (blobLine < 0) throw new Error('no inline wasm (binaryDecode literal) found'); +const literal = lines[blobLine]; +let i = literal.indexOf(OPEN) + OPEN.length; +/** @type {number[]} */ +const codes = []; +const SIMPLE = { n: 10, r: 13, t: 9, b: 8, f: 12, v: 11, '\\': 92, "'": 39, '"': 34 }; +for (;;) { + const ch = literal[i]; + if (ch === undefined) throw new Error('unterminated wasm literal'); + if (ch === "'") break; + if (ch !== '\\') { + const cp = /** @type {number} */ (literal.codePointAt(i)); + codes.push(cp); + i += cp > 0xffff ? 2 : 1; + continue; + } + const e = literal[i + 1]; + i += 2; + if (e in SIMPLE) codes.push(SIMPLE[/** @type {keyof typeof SIMPLE} */ (e)]); + else if (e === '0' && !/[0-9]/.test(literal[i])) codes.push(0); + else if (e === 'x') { + codes.push(parseInt(literal.slice(i, i + 2), 16)); + i += 2; + } else if (e === 'u' && literal[i] === '{') { + const end = literal.indexOf('}', i); + codes.push(parseInt(literal.slice(i + 1, end), 16)); + i = end + 1; + } else if (e === 'u') { + codes.push(parseInt(literal.slice(i, i + 4), 16)); + i += 4; + } else throw new Error('unhandled escape \\' + e); +} +if (literal.slice(i) !== "');") throw new Error('unexpected text after the wasm literal'); +const wasm = new Uint8Array(codes.length); +// emscripten's binaryDecode: o[i] = ~c >> 8 & c +for (let k = 0; k < codes.length; k++) wasm[k] = (~codes[k] >> 8) & codes[k]; +if (!WebAssembly.validate(wasm)) throw new Error('decoded bytes are not a valid wasm module'); +fs.writeFileSync(path.join(HERE, 'assets', 'xatlas.wasm'), wasm); + +// ---- 2. the glue ---------------------------------------------------------- +const exportLine = lines.findIndex((line) => line.startsWith('export {')); +if (exportLine < 0) throw new Error('no trailing export block found'); +const glue = lines + .slice(0, exportLine) + .map((line, n) => + n === blobLine ? ' return void 0; // smart-unwrap: wasm supplied via Module.instantiateWasm' : line + ); +if (glue.some((line) => /^\s*(import|export)[\s{*'"]/.test(line))) + throw new Error('the glue has a static import/export the module loader cannot take'); + +const BEGIN = '// ---- BEGIN VENDORED xatlas-wasm glue'; +const END = '// ---- END VENDORED xatlas-wasm glue'; +const modulePath = path.join(HERE, 'module.js'); +const current = fs.readFileSync(modulePath, 'utf8'); +const a = current.indexOf(BEGIN); +const b = current.indexOf(END); +if (a < 0 || b < a) throw new Error('module.js has no VENDORED markers'); +const beginLineEnd = current.indexOf('\n', a) + 1; +fs.writeFileSync(modulePath, current.slice(0, beginLineEnd) + glue.join('\n') + '\n' + current.slice(b)); +console.log( + 'vendored: assets/xatlas.wasm ' + wasm.length + ' bytes, glue ' + glue.length + ' lines into module.js' +); diff --git a/package.json b/package.json index 6392f44..83c372e 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "build:health": "esbuild modules/health/src/index.js --bundle --format=esm --outfile=modules/health/module.js", "test:health": "node modules/health/test/run.mjs", "build:waves": "node modules/waves/build-gltf.mjs && esbuild modules/waves/src/index.js --bundle --format=esm --outfile=modules/waves/module.js && node modules/waves/emit-def.mjs", - "test:waves": "node modules/waves/test/run.mjs" + "test:waves": "node modules/waves/test/run.mjs", + "test:smart-unwrap": "node modules/smart-unwrap/test/run.mjs" }, "devDependencies": { "esbuild": "0.28.2",