diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml new file mode 100644 index 0000000..b84a7cb --- /dev/null +++ b/.gitea/workflows/verify.yml @@ -0,0 +1,39 @@ +name: Verify + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: verify-${{ gitea.repository }}-${{ gitea.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + CI: "true" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + - name: Select declared npm version + run: npm install --global npm@11.17.0 + - name: Install dependencies + run: npm ci + - name: Audit runtime dependencies + run: npm audit --omit=dev --audit-level=moderate + - name: Check, test, and build + run: npm run check + - name: Install browser engines + run: npx playwright install --with-deps chromium firefox webkit + - name: Browser tests + run: npm run test:browser diff --git a/CHANGELOG.md b/CHANGELOG.md index b50fc02..befb2ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.2.0 - 2026-09-02 + +- Moved model parsing into cancellable worker jobs and made glTF scene selection, + hierarchy matrices and mesh instancing drive preview, bounds, statistics and + export. +- Added explicit per-axis transforms, glTF metallic-roughness material + inspection, safe degenerate-triangle repair and bounded ASCII PLY export. +- Declared accurate supported model input/export and worker/WebGL capability + profiles for Toolbox handoff. + ## 0.1.0 - 2026-09-01 - Added bounded OBJ, STL, PLY and glTF/GLB triangle parsers. diff --git a/README.md b/README.md index 9f8c345..ba6a84e 100644 --- a/README.md +++ b/README.md @@ -6,21 +6,30 @@ Inspect, preview, repair and export triangle meshes locally in the browser. File - Real bounded parsers for Wavefront OBJ, binary/ASCII STL, ASCII/binary little-endian PLY and glTF 2/GLB 2. - Inert embedded glTF buffers only; external buffers, textures, scripts and OBJ material libraries are never fetched. +- Correct selected-scene hierarchy, matrix/TRS transform and mesh-instancing + flattening, including normal matrices and mirrored winding. +- Metallic/roughness material inspection covers base colour, emissive factor, + alpha, sidedness and declared texture slots without decoding texture content. +- Worker-based parsing with progress, cancellation and stale-result suppression. - Mesh, material, scene-node, bound, surface-area and degenerate-triangle statistics. - Responsive drag-to-rotate WebGL preview capped independently from full-file analysis. -- Area-weighted normal repair and finite translation, rotation and uniform-scale baking. -- Deterministic normalized OBJ and binary STL exports with explicit format-loss notes. +- Area-weighted normal repair, degenerate-triangle removal and finite translation, + rotation, uniform and per-axis scale baking. +- Deterministic normalized OBJ, binary STL and bounded ASCII PLY exports with + explicit format-loss notes. - Offline PWA and shared Toolbox system/light/dark shell. ## Supported subset and limits -Input is capped at 32 MiB, one million vertices, one million triangles, 10,000 glTF scene nodes, JSON depth 64 and 100,000 JSON nodes. OBJ polygons and PLY faces are fan-triangulated. PLY supports version-1.0 ASCII and binary little-endian scalar/list properties. glTF supports embedded buffers, FLOAT POSITION/NORMAL accessors, unsigned indices and TRIANGLES primitives. Sparse accessors, animations, skins, textures, Draco/Meshopt and external resources are not decoded in v0.1.0. +Input is capped at 32 MiB, one million vertices, one million triangles, 10,000 glTF scene nodes, JSON depth 64 and 100,000 JSON nodes. OBJ polygons and PLY faces are fan-triangulated. PLY supports version-1.0 ASCII and binary little-endian scalar/list properties. glTF supports embedded buffers, FLOAT POSITION/NORMAL accessors, unsigned indices and TRIANGLES primitives. Sparse accessors, animations, skins, textures, Draco/Meshopt and external resources are not decoded in v0.2.0. -The normalized preview/export mesh does not bake glTF node transforms or recreate source material graphs. These limitations are reported rather than silently represented as equivalent output. +The normalized preview/export mesh bakes the selected glTF scene hierarchy and +instances, but does not recreate source material graphs. These limitations are +reported rather than silently represented as equivalent output. ## Development -Run npm ci, npm run check, npm run test:browser and npm run package:release -- --force with Node.js 22+ and npm 11. The deterministic artifact is release/3d-tools-0.1.0.zip with a SHA-256 sidecar. +Run npm ci, npm run check, npm run test:browser and npm run package:release -- --force with Node.js 22+ and npm 11. The deterministic artifact is release/3d-tools-0.2.0.zip with a SHA-256 sidecar. ## Licence diff --git a/SECURITY.md b/SECURITY.md index e5ae552..4d2ff70 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,15 @@ # Security policy -Report vulnerabilities privately to the repository owner. Supported version: 0.1.x. +Report vulnerabilities privately to the repository owner. Supported version: 0.2.x. -3D Tools makes no uploads, telemetry or third-party requests. JSON is parsed as inert data under text, depth, node and token-count bounds. Exporters escape Android XML and quote Swift strings. Generated source remains an input to another toolchain and must be reviewed before production use. +3D Tools makes no uploads, telemetry or third-party requests. OBJ, STL, PLY and +glTF/GLB files are treated as untrusted data under documented byte, JSON, +geometry and scene-graph bounds. Parsing runs in a disposable worker, and +revision-scoped results prevent cancelled or superseded jobs from replacing +newer state. + +External glTF resources are rejected, data URIs are decoded locally, and OBJ +material-library references are never fetched. Imported text cannot become +HTML, JavaScript or shader source. WebGL receives finite numeric arrays and +fixed application-owned shaders; a preview failure does not block inspection, +repair or export. diff --git a/SOURCE.md b/SOURCE.md index 10af5b2..0294067 100644 --- a/SOURCE.md +++ b/SOURCE.md @@ -1,10 +1,10 @@ # Source identity - Project: 3D Tools -- Version: 0.1.0 +- Version: 0.2.0 - Repository: https://git.add-ideas.de/lotobo/3d-tools - Licence: GPL-3.0-or-later - Build: Node.js 22+, npm 11, `npm ci && npm run release:artifact` -- Artifact: `3d-tools-0.1.0.zip` +- Artifact: `3d-tools-0.2.0.zip` The lockfile pins dependencies exactly. Generated release manifests repeat this source identity and releases include detected runtime licence texts under `LICENSES/`. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index a82a7b8..44a0835 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,3 +1,7 @@ # Third-party notices -3D Tools is GPL-3.0-or-later. Runtime dependencies are React, React DOM and the add·ideas Toolbox Contract, Shell and Helpers packages. Exact versions and declared licences are recorded in `package-lock.json`; detected runtime licence texts are generated into release archives at `LICENSES/npm-runtime-licenses.txt`. +3D Tools is GPL-3.0-or-later. Runtime dependencies are React and React DOM +(MIT), add·ideas Toolbox Contract and Shell React 0.3.0 (Apache-2.0), and +Toolbox Helpers 0.2.0 (GPL-3.0-or-later). Exact versions and declared licences +are recorded in `package-lock.json`; detected runtime licence texts are +generated into release archives at `LICENSES/npm-runtime-licenses.txt`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a9db8f1..0a8d638 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,21 @@ # Architecture -The core/model.ts module converts every supported source into one inert model containing finite position/normal arrays, triangle indices and descriptive scene metadata. Parser-specific bounds are enforced before allocation and again on normalized geometry. Normal repair, transforms, statistics and exporters consume only that model. +The core/model.ts module converts every supported source into one inert model +containing finite position/normal arrays, triangle indices and descriptive scene +metadata. Imports run in a disposable module worker with progress, cancellation +and revision-scoped results. Parser-specific bounds are enforced before +allocation and again on normalized geometry. Normal repair, transforms, +statistics and exporters consume only that model. + +For glTF/GLB, the selected/default scene is traversed as a validated acyclic +hierarchy. Column-major matrices and TRS/quaternion transforms are composed, +inverse-transpose normal transforms and mirrored winding are applied, and every +mesh instance is flattened into the bounded model. Consequently preview, +bounds, surface statistics, OBJ, STL and PLY all describe the same selected scene. +Selected metallic/roughness material factors and declared texture slots remain +inert inspection metadata; texture payloads are not decoded. Per-axis transforms +use inverse-transpose normal handling, while bounded repair can discard only +geometrically degenerate faces before recalculating normals. The preview expands at most 200,000 triangles into a dedicated WebGL buffer. It uses fixed application-owned shaders and never compiles source-file content. Full statistics and exports still use the complete bounded model. glTF data URIs are decoded locally; non-data URIs are rejected. diff --git a/package-lock.json b/package-lock.json index 1489692..57a7bc5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,22 +1,22 @@ { "name": "3d-tools", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "3d-tools", - "version": "0.1.0", + "version": "0.2.0", "license": "GPL-3.0-or-later", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3", - "@add-ideas/toolbox-helpers": "0.1.0", - "@add-ideas/toolbox-shell-react": "0.2.3", + "@add-ideas/toolbox-contract": "0.3.0", + "@add-ideas/toolbox-helpers": "0.2.0", + "@add-ideas/toolbox-shell-react": "0.3.0", "react": "19.2.8", "react-dom": "19.2.8" }, "devDependencies": { - "@add-ideas/toolbox-testkit": "0.2.3", + "@add-ideas/toolbox-testkit": "0.3.0", "@eslint/js": "10.0.1", "@playwright/test": "1.62.1", "@testing-library/jest-dom": "6.9.1", @@ -42,24 +42,24 @@ } }, "node_modules/@add-ideas/toolbox-contract": { - "version": "0.2.3", - "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz", - "integrity": "sha512-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==", + "version": "0.3.0", + "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz", + "integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==", "license": "Apache-2.0" }, "node_modules/@add-ideas/toolbox-helpers": { - "version": "0.1.0", - "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.1.0/toolbox-helpers-0.1.0.tgz", - "integrity": "sha512-UKl1Oxekedf8D2df86VrnVA53AcMhrnh6iUPXY+k8frirBXotb0yd8SGT+IF/3hcqYwcYe/v9WVFuSgKtIYVnw==", + "version": "0.2.0", + "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.2.0/toolbox-helpers-0.2.0.tgz", + "integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==", "license": "GPL-3.0-or-later" }, "node_modules/@add-ideas/toolbox-shell-react": { - "version": "0.2.3", - "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.3/toolbox-shell-react-0.2.3.tgz", - "integrity": "sha512-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==", + "version": "0.3.0", + "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz", + "integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==", "license": "Apache-2.0", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3" + "@add-ideas/toolbox-contract": "0.3.0" }, "peerDependencies": { "react": ">=18 <20", @@ -67,13 +67,13 @@ } }, "node_modules/@add-ideas/toolbox-testkit": { - "version": "0.2.3", - "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.3/toolbox-testkit-0.2.3.tgz", - "integrity": "sha512-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==", + "version": "0.3.0", + "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz", + "integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3" + "@add-ideas/toolbox-contract": "0.3.0" }, "bin": { "toolbox-check": "dist/cli.js" diff --git a/package.json b/package.json index f8e0d98..dedd07a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "3d-tools", - "version": "0.1.0", + "version": "0.2.0", "description": "Inspect, preview, repair and export bounded 3D meshes locally in the browser.", "license": "GPL-3.0-or-later", "author": "Albrecht Degering", @@ -39,14 +39,14 @@ "release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force" }, "dependencies": { - "@add-ideas/toolbox-helpers": "0.1.0", - "@add-ideas/toolbox-contract": "0.2.3", - "@add-ideas/toolbox-shell-react": "0.2.3", + "@add-ideas/toolbox-contract": "0.3.0", + "@add-ideas/toolbox-helpers": "0.2.0", + "@add-ideas/toolbox-shell-react": "0.3.0", "react": "19.2.8", "react-dom": "19.2.8" }, "devDependencies": { - "@add-ideas/toolbox-testkit": "0.2.3", + "@add-ideas/toolbox-testkit": "0.3.0", "@eslint/js": "10.0.1", "@playwright/test": "1.62.1", "@testing-library/jest-dom": "6.9.1", diff --git a/playwright.config.ts b/playwright.config.ts index 941ffe4..d59d735 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -15,7 +15,25 @@ export default defineConfig({ timeout: 180_000, }, projects: [ - { name: "chromium", use: { ...devices["Desktop Chrome"] } }, - { name: "firefox", use: { ...devices["Desktop Firefox"] } }, + { + name: "chromium", + testIgnore: /responsive\.spec\.ts/, + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "firefox", + testIgnore: /responsive\.spec\.ts/, + use: { ...devices["Desktop Firefox"] }, + }, + { + name: "webkit", + testIgnore: /responsive\.spec\.ts/, + use: { ...devices["Desktop Safari"] }, + }, + { + name: "mobile-chromium", + testMatch: /responsive\.spec\.ts/, + use: { ...devices["Pixel 5"] }, + }, ], }); diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md index b50fc02..befb2ff 100644 --- a/public/CHANGELOG.md +++ b/public/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.2.0 - 2026-09-02 + +- Moved model parsing into cancellable worker jobs and made glTF scene selection, + hierarchy matrices and mesh instancing drive preview, bounds, statistics and + export. +- Added explicit per-axis transforms, glTF metallic-roughness material + inspection, safe degenerate-triangle repair and bounded ASCII PLY export. +- Declared accurate supported model input/export and worker/WebGL capability + profiles for Toolbox handoff. + ## 0.1.0 - 2026-09-01 - Added bounded OBJ, STL, PLY and glTF/GLB triangle parsers. diff --git a/public/LICENSES/npm-runtime-licenses.txt b/public/LICENSES/npm-runtime-licenses.txt index 45a7c95..4f412f1 100644 --- a/public/LICENSES/npm-runtime-licenses.txt +++ b/public/LICENSES/npm-runtime-licenses.txt @@ -1,5 +1,5 @@ ============================================================================== -@add-ideas/toolbox-contract@0.2.3 +@add-ideas/toolbox-contract@0.3.0 Declared licence: Apache-2.0 ============================================================================== --- LICENSE --- @@ -198,7 +198,7 @@ Declared licence: Apache-2.0 ============================================================================== -@add-ideas/toolbox-helpers@0.1.0 +@add-ideas/toolbox-helpers@0.2.0 Declared licence: GPL-3.0-or-later ============================================================================== --- LICENSE --- @@ -879,7 +879,7 @@ Public License instead of this License. But first, please read ============================================================================== -@add-ideas/toolbox-shell-react@0.2.3 +@add-ideas/toolbox-shell-react@0.3.0 Declared licence: Apache-2.0 ============================================================================== --- LICENSE --- diff --git a/public/README.md b/public/README.md index 9f8c345..ba6a84e 100644 --- a/public/README.md +++ b/public/README.md @@ -6,21 +6,30 @@ Inspect, preview, repair and export triangle meshes locally in the browser. File - Real bounded parsers for Wavefront OBJ, binary/ASCII STL, ASCII/binary little-endian PLY and glTF 2/GLB 2. - Inert embedded glTF buffers only; external buffers, textures, scripts and OBJ material libraries are never fetched. +- Correct selected-scene hierarchy, matrix/TRS transform and mesh-instancing + flattening, including normal matrices and mirrored winding. +- Metallic/roughness material inspection covers base colour, emissive factor, + alpha, sidedness and declared texture slots without decoding texture content. +- Worker-based parsing with progress, cancellation and stale-result suppression. - Mesh, material, scene-node, bound, surface-area and degenerate-triangle statistics. - Responsive drag-to-rotate WebGL preview capped independently from full-file analysis. -- Area-weighted normal repair and finite translation, rotation and uniform-scale baking. -- Deterministic normalized OBJ and binary STL exports with explicit format-loss notes. +- Area-weighted normal repair, degenerate-triangle removal and finite translation, + rotation, uniform and per-axis scale baking. +- Deterministic normalized OBJ, binary STL and bounded ASCII PLY exports with + explicit format-loss notes. - Offline PWA and shared Toolbox system/light/dark shell. ## Supported subset and limits -Input is capped at 32 MiB, one million vertices, one million triangles, 10,000 glTF scene nodes, JSON depth 64 and 100,000 JSON nodes. OBJ polygons and PLY faces are fan-triangulated. PLY supports version-1.0 ASCII and binary little-endian scalar/list properties. glTF supports embedded buffers, FLOAT POSITION/NORMAL accessors, unsigned indices and TRIANGLES primitives. Sparse accessors, animations, skins, textures, Draco/Meshopt and external resources are not decoded in v0.1.0. +Input is capped at 32 MiB, one million vertices, one million triangles, 10,000 glTF scene nodes, JSON depth 64 and 100,000 JSON nodes. OBJ polygons and PLY faces are fan-triangulated. PLY supports version-1.0 ASCII and binary little-endian scalar/list properties. glTF supports embedded buffers, FLOAT POSITION/NORMAL accessors, unsigned indices and TRIANGLES primitives. Sparse accessors, animations, skins, textures, Draco/Meshopt and external resources are not decoded in v0.2.0. -The normalized preview/export mesh does not bake glTF node transforms or recreate source material graphs. These limitations are reported rather than silently represented as equivalent output. +The normalized preview/export mesh bakes the selected glTF scene hierarchy and +instances, but does not recreate source material graphs. These limitations are +reported rather than silently represented as equivalent output. ## Development -Run npm ci, npm run check, npm run test:browser and npm run package:release -- --force with Node.js 22+ and npm 11. The deterministic artifact is release/3d-tools-0.1.0.zip with a SHA-256 sidecar. +Run npm ci, npm run check, npm run test:browser and npm run package:release -- --force with Node.js 22+ and npm 11. The deterministic artifact is release/3d-tools-0.2.0.zip with a SHA-256 sidecar. ## Licence diff --git a/public/SECURITY.md b/public/SECURITY.md index e5ae552..4d2ff70 100644 --- a/public/SECURITY.md +++ b/public/SECURITY.md @@ -1,5 +1,15 @@ # Security policy -Report vulnerabilities privately to the repository owner. Supported version: 0.1.x. +Report vulnerabilities privately to the repository owner. Supported version: 0.2.x. -3D Tools makes no uploads, telemetry or third-party requests. JSON is parsed as inert data under text, depth, node and token-count bounds. Exporters escape Android XML and quote Swift strings. Generated source remains an input to another toolchain and must be reviewed before production use. +3D Tools makes no uploads, telemetry or third-party requests. OBJ, STL, PLY and +glTF/GLB files are treated as untrusted data under documented byte, JSON, +geometry and scene-graph bounds. Parsing runs in a disposable worker, and +revision-scoped results prevent cancelled or superseded jobs from replacing +newer state. + +External glTF resources are rejected, data URIs are decoded locally, and OBJ +material-library references are never fetched. Imported text cannot become +HTML, JavaScript or shader source. WebGL receives finite numeric arrays and +fixed application-owned shaders; a preview failure does not block inspection, +repair or export. diff --git a/public/SOURCE.md b/public/SOURCE.md index 10af5b2..0294067 100644 --- a/public/SOURCE.md +++ b/public/SOURCE.md @@ -1,10 +1,10 @@ # Source identity - Project: 3D Tools -- Version: 0.1.0 +- Version: 0.2.0 - Repository: https://git.add-ideas.de/lotobo/3d-tools - Licence: GPL-3.0-or-later - Build: Node.js 22+, npm 11, `npm ci && npm run release:artifact` -- Artifact: `3d-tools-0.1.0.zip` +- Artifact: `3d-tools-0.2.0.zip` The lockfile pins dependencies exactly. Generated release manifests repeat this source identity and releases include detected runtime licence texts under `LICENSES/`. diff --git a/public/THIRD_PARTY_NOTICES.md b/public/THIRD_PARTY_NOTICES.md index a82a7b8..44a0835 100644 --- a/public/THIRD_PARTY_NOTICES.md +++ b/public/THIRD_PARTY_NOTICES.md @@ -1,3 +1,7 @@ # Third-party notices -3D Tools is GPL-3.0-or-later. Runtime dependencies are React, React DOM and the add·ideas Toolbox Contract, Shell and Helpers packages. Exact versions and declared licences are recorded in `package-lock.json`; detected runtime licence texts are generated into release archives at `LICENSES/npm-runtime-licenses.txt`. +3D Tools is GPL-3.0-or-later. Runtime dependencies are React and React DOM +(MIT), add·ideas Toolbox Contract and Shell React 0.3.0 (Apache-2.0), and +Toolbox Helpers 0.2.0 (GPL-3.0-or-later). Exact versions and declared licences +are recorded in `package-lock.json`; detected runtime licence texts are +generated into release archives at `LICENSES/npm-runtime-licenses.txt`. diff --git a/public/docs/ARCHITECTURE.md b/public/docs/ARCHITECTURE.md index a9db8f1..0a8d638 100644 --- a/public/docs/ARCHITECTURE.md +++ b/public/docs/ARCHITECTURE.md @@ -1,6 +1,21 @@ # Architecture -The core/model.ts module converts every supported source into one inert model containing finite position/normal arrays, triangle indices and descriptive scene metadata. Parser-specific bounds are enforced before allocation and again on normalized geometry. Normal repair, transforms, statistics and exporters consume only that model. +The core/model.ts module converts every supported source into one inert model +containing finite position/normal arrays, triangle indices and descriptive scene +metadata. Imports run in a disposable module worker with progress, cancellation +and revision-scoped results. Parser-specific bounds are enforced before +allocation and again on normalized geometry. Normal repair, transforms, +statistics and exporters consume only that model. + +For glTF/GLB, the selected/default scene is traversed as a validated acyclic +hierarchy. Column-major matrices and TRS/quaternion transforms are composed, +inverse-transpose normal transforms and mirrored winding are applied, and every +mesh instance is flattened into the bounded model. Consequently preview, +bounds, surface statistics, OBJ, STL and PLY all describe the same selected scene. +Selected metallic/roughness material factors and declared texture slots remain +inert inspection metadata; texture payloads are not decoded. Per-axis transforms +use inverse-transpose normal handling, while bounded repair can discard only +geometrically degenerate faces before recalculating normals. The preview expands at most 200,000 triangles into a dedicated WebGL buffer. It uses fixed application-owned shaders and never compiles source-file content. Full statistics and exports still use the complete bounded model. glTF data URIs are decoded locally; non-data URIs are rejected. diff --git a/public/sw.js b/public/sw.js index 4d31fbf..daa3731 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ const CACHE_PREFIX = "3d-tools-shell-"; -const CACHE_NAME = CACHE_PREFIX + "0.1.0"; +const CACHE_NAME = CACHE_PREFIX + "0.2.0"; const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"]; self.addEventListener("install", (event) => { event.waitUntil( diff --git a/public/toolbox-app.json b/public/toolbox-app.json index d369103..2733e33 100644 --- a/public/toolbox-app.json +++ b/public/toolbox-app.json @@ -3,12 +3,12 @@ "schemaVersion": 1, "id": "de.add-ideas.3d-tools", "name": "3D Tools", - "version": "0.1.0", - "description": "Inspect and repair 3D meshes locally.", + "version": "0.2.0", + "description": "Inspect, transform, repair and export 3D meshes locally.", "entry": "./", "icon": "./favicon.svg", "categories": ["media", "developer", "design"], - "tags": ["3d", "obj", "stl", "ply", "gltf", "webgl"], + "tags": ["3d", "obj", "stl", "ply", "gltf", "pbr", "webgl"], "integration": { "contextVersion": 1, "launchModes": ["navigate", "new-tab"], @@ -21,6 +21,21 @@ "crossOriginIsolated": false, "topLevelContext": false }, + "io": { + "accepts": [ + { "mediaType": "model/obj", "extensions": [".obj"] }, + { "mediaType": "model/stl", "extensions": [".stl"] }, + { "mediaType": "model/ply", "extensions": [".ply"] }, + { "mediaType": "model/gltf+json", "extensions": [".gltf"] }, + { "mediaType": "model/gltf-binary", "extensions": [".glb"] } + ], + "produces": [ + { "mediaType": "model/obj", "extensions": [".obj"] }, + { "mediaType": "model/stl", "extensions": [".stl"] }, + { "mediaType": "model/ply", "extensions": [".ply"] } + ] + }, + "capabilities": { "required": [], "optional": ["workers", "webgl"] }, "privacy": { "processing": "local", "fileUploads": true, diff --git a/src/components/ModelPreview.tsx b/src/components/ModelPreview.tsx index b6fec94..69fed3b 100644 --- a/src/components/ModelPreview.tsx +++ b/src/components/ModelPreview.tsx @@ -52,8 +52,9 @@ export function ModelPreview({ model }: { model: Model3D }) { [ "precision mediump float;", "varying float vLight;", + "uniform vec3 uBaseColor;", "void main() {", - " gl_FragColor = vec4(vec3(0.38, 0.31, 0.85) * vLight + vec3(0.15), 1.0);", + " gl_FragColor = vec4(uBaseColor * vLight + vec3(0.12), 1.0);", "}", ].join("\n"), ); @@ -98,6 +99,10 @@ export function ModelPreview({ model }: { model: Model3D }) { const scaleLocation = gl.getUniformLocation(program, "uScale"); const rotationLocation = gl.getUniformLocation(program, "uRotation"); const aspectLocation = gl.getUniformLocation(program, "uAspect"); + const colorLocation = gl.getUniformLocation(program, "uBaseColor"); + const baseColor = model.materialDetails?.[0]?.baseColorFactor ?? [ + 0.38, 0.31, 0.85, 1, + ]; let yaw = 0.55; let pitch = -0.35; let dragging = false; @@ -127,6 +132,12 @@ export function ModelPreview({ model }: { model: Model3D }) { gl.uniform1f(scaleLocation, 1.35 / extent); gl.uniform2f(rotationLocation, yaw, pitch); gl.uniform1f(aspectLocation, width / height); + gl.uniform3f( + colorLocation, + baseColor[0] ?? 0.38, + baseColor[1] ?? 0.31, + baseColor[2] ?? 0.85, + ); gl.drawArrays(gl.TRIANGLES, 0, triangleCount * 3); }; const resize = new ResizeObserver(draw); diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx index edb3a30..4e97078 100644 --- a/src/components/Workbench.tsx +++ b/src/components/Workbench.tsx @@ -1,17 +1,23 @@ -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { triggerBlobDownload } from "@add-ideas/toolbox-helpers"; import { detectFormat, + exportAsciiPly, exportBinaryStl, exportObj, MAX_FILE_BYTES, modelStats, parseModel, + removeDegenerateTriangles, repairNormals, transformModel, type Model3D, type ModelFormat, } from "../core/model"; +import { + parseModelInWorker, + type ModelWorkerJob, +} from "../core/model-worker-client"; import { ModelPreview } from "./ModelPreview"; const EXAMPLE_TEXT = [ @@ -43,6 +49,10 @@ export function Workbench() { "Example loaded. Choose a local OBJ, STL, PLY, glTF or GLB file.", ); const [busy, setBusy] = useState(false); + const [progress, setProgress] = useState<{ + value: number; + stage: string; + }>(); const [transform, setTransform] = useState({ tx: "0", ty: "0", @@ -51,20 +61,59 @@ export function Workbench() { ry: "0", rz: "0", scale: "1", + sx: "1", + sy: "1", + sz: "1", }); const fileInput = useRef(null); + const parseJob = useRef(undefined); + const parseRevision = useRef(0); const stats = useMemo(() => modelStats(model), [model]); + useEffect( + () => () => { + parseRevision.current += 1; + parseJob.current?.cancel(); + }, + [], + ); + + function cancelParse( + message = "3D parsing cancelled; the current model was retained.", + ) { + parseRevision.current += 1; + parseJob.current?.cancel(); + parseJob.current = undefined; + setBusy(false); + setProgress(undefined); + setStatus(message); + } + async function openFile(file: File | undefined): Promise { if (!file) return; + const revision = ++parseRevision.current; + parseJob.current?.cancel(); setBusy(true); + setProgress({ value: 0, stage: "Reading local file" }); try { if (file.size > MAX_FILE_BYTES) throw new RangeError("3D files are limited to 32 MiB."); - const bytes = new Uint8Array(await file.arrayBuffer()); + const buffer = await file.arrayBuffer(); + if (parseRevision.current !== revision) return; + const bytes = new Uint8Array(buffer); const selected = format === "auto" ? detectFormat(file.name, bytes) : format; - const next = parseModel(file.name, bytes, selected); + const job = parseModelInWorker( + file.name, + buffer, + selected, + (value, stage) => { + if (parseRevision.current === revision) setProgress({ value, stage }); + }, + ); + parseJob.current = job; + const next = await job.promise; + if (parseRevision.current !== revision) return; setModel(next); setStatus( selected + @@ -75,9 +124,17 @@ export function Workbench() { " triangles.", ); } catch (caught) { - setStatus(caught instanceof Error ? caught.message : String(caught)); + if ( + parseRevision.current === revision && + !(caught instanceof DOMException && caught.name === "AbortError") + ) + setStatus(caught instanceof Error ? caught.message : String(caught)); } finally { - setBusy(false); + if (parseRevision.current === revision) { + parseJob.current = undefined; + setBusy(false); + setProgress(undefined); + } if (fileInput.current) fileInput.current.value = ""; } } @@ -90,7 +147,11 @@ export function Workbench() { const next = transformModel(model, { translate: [values.tx, values.ty, values.tz], rotate: [values.rx, values.ry, values.rz], - scale: values.scale, + scale: [ + values.scale * values.sx, + values.scale * values.sy, + values.scale * values.sz, + ], }); setModel(next); setTransform({ @@ -101,6 +162,9 @@ export function Workbench() { ry: "0", rz: "0", scale: "1", + sx: "1", + sy: "1", + sz: "1", }); setStatus("Transform baked into geometry and normals."); } catch (caught) { @@ -114,7 +178,7 @@ export function Workbench() { safeBase(model.name) + ".obj", ); setStatus( - "OBJ exported with positions, repaired/current normals and triangle faces. Source materials and scene transforms are not reconstructed.", + "OBJ exported with current positions, normals and triangle faces. Selected glTF scene transforms are already baked into this flattened mesh.", ); } @@ -129,6 +193,20 @@ export function Workbench() { ); } + function savePly(): void { + try { + triggerBlobDownload( + new Blob([exportAsciiPly(model)], { type: "model/ply;charset=utf-8" }), + safeBase(model.name) + ".ply", + ); + setStatus( + "ASCII PLY exported with positions, normals and triangle topology; material metadata is not represented.", + ); + } catch (caught) { + setStatus(caught instanceof Error ? caught.message : String(caught)); + } + } + return (
@@ -176,10 +254,11 @@ export function Workbench() { disabled={busy} onClick={() => fileInput.current?.click()} > - {busy ? "Parsing…" : "Choose local file"} + Choose local file + {busy ? ( + + ) : null} void openFile(event.target.files?.[0])} /> @@ -199,6 +284,11 @@ export function Workbench() {

{status}

+ {progress ? ( + + ) : null}
@@ -277,6 +367,9 @@ export function Workbench() { ["ry", "Rotate Y (°)"], ["rz", "Rotate Z (°)"], ["scale", "Uniform scale"], + ["sx", "Axis scale X"], + ["sy", "Axis scale Y"], + ["sz", "Axis scale Z"], ] as const ).map(([key, label]) => (
@@ -324,15 +438,19 @@ export function Workbench() {

OBJ preserves the current positions, normals and triangle topology. Binary STL stores only triangles and face normals. Neither export - recreates glTF scene transforms, textures or source material files. + recreates textures or source material files; glTF selected-scene + hierarchy, transforms and mesh instances are flattened at import.

- - +
{model.warnings.length > 0 && ( <> @@ -360,6 +478,52 @@ export function Workbench() { )} )} + {(model.materialDetails?.length ?? 0) > 0 && ( +
+ {model.materialDetails!.slice(0, 100).map((material, index) => ( +
+ Math.round(value * 255)) + .join(" ")} / ${material.baseColorFactor[3]})`, + }} + aria-hidden="true" + /> +

{material.name}

+
+
+
Metallic / roughness
+
+ {material.metallicFactor} / {material.roughnessFactor} +
+
+
+
Alpha
+
+ {material.alphaMode} + {material.alphaMode === "MASK" + ? ` @ ${material.alphaCutoff}` + : ""} +
+
+
+
Faces
+
+ {material.doubleSided ? "Double-sided" : "Culled"} +
+
+
+
Texture slots
+
{material.textures.join(", ") || "None"}
+
+
+
+ ))} +
+ )}
diff --git a/src/core/model-worker-client.ts b/src/core/model-worker-client.ts new file mode 100644 index 0000000..a708416 --- /dev/null +++ b/src/core/model-worker-client.ts @@ -0,0 +1,83 @@ +import { parseModel, type Model3D, type ModelFormat } from "./model"; + +export interface ModelWorkerJob { + promise: Promise; + cancel: () => void; +} + +let nextId = 0; + +export function parseModelInWorker( + name: string, + bytes: ArrayBuffer, + format: ModelFormat, + onProgress?: (progress: number, stage: string) => void, +): ModelWorkerJob { + const id = ++nextId; + if (typeof Worker === "undefined") { + let cancelled = false; + return { + cancel: () => { + cancelled = true; + }, + promise: Promise.resolve().then(() => { + if (cancelled) throw abortError(); + onProgress?.(0.1, `Parsing ${format}`); + const model = parseModel(name, new Uint8Array(bytes), format); + if (cancelled) throw abortError(); + onProgress?.(1, "Complete"); + return model; + }), + }; + } + const worker = new Worker(new URL("./model.worker.ts", import.meta.url), { + type: "module", + }); + let settled = false; + let rejectPromise: (reason: unknown) => void = () => undefined; + const promise = new Promise((resolve, reject) => { + rejectPromise = reject; + worker.onmessage = ( + event: MessageEvent<{ + id: number; + kind: "progress" | "result" | "error"; + progress?: number; + stage?: string; + model?: Model3D; + error?: string; + }>, + ) => { + if (event.data.id !== id || settled) return; + if (event.data.kind === "progress") { + onProgress?.(event.data.progress ?? 0, event.data.stage ?? "Parsing"); + return; + } + settled = true; + worker.terminate(); + if (event.data.kind === "error") + reject(new Error(event.data.error ?? "3D parsing failed.")); + else if (event.data.model) resolve(event.data.model); + else reject(new Error("3D worker returned no model.")); + }; + worker.onerror = () => { + if (settled) return; + settled = true; + worker.terminate(); + reject(new Error("3D worker failed to start.")); + }; + worker.postMessage({ id, name, bytes, format }, [bytes]); + }); + return { + promise, + cancel: () => { + if (settled) return; + settled = true; + worker.terminate(); + rejectPromise(abortError()); + }, + }; +} + +function abortError(): DOMException { + return new DOMException("3D parsing cancelled.", "AbortError"); +} diff --git a/src/core/model.ts b/src/core/model.ts index 3bfe7c1..94298e0 100644 --- a/src/core/model.ts +++ b/src/core/model.ts @@ -15,11 +15,24 @@ export interface Model3D { normals: Float32Array; indices: Uint32Array; materials: string[]; + materialDetails?: MaterialInfo[]; nodes: string[]; warnings: string[]; sourceVertices: number; } +export interface MaterialInfo { + name: string; + baseColorFactor: [number, number, number, number]; + metallicFactor: number; + roughnessFactor: number; + emissiveFactor: [number, number, number]; + alphaMode: "OPAQUE" | "MASK" | "BLEND"; + alphaCutoff: number; + doubleSided: boolean; + textures: string[]; +} + export interface ModelStats { vertices: number; triangles: number; @@ -647,6 +660,8 @@ function plyRecordsToModel( interface Gltf { asset?: { version?: unknown }; + scene?: unknown; + scenes?: Array<{ name?: unknown; nodes?: unknown }>; buffers?: Array<{ uri?: unknown; byteLength?: unknown }>; bufferViews?: Array<{ buffer?: unknown; @@ -672,10 +687,27 @@ interface Gltf { mode?: unknown; }>; }>; - materials?: Array<{ name?: unknown }>; + materials?: Array<{ + name?: unknown; + pbrMetallicRoughness?: { + baseColorFactor?: unknown; + metallicFactor?: unknown; + roughnessFactor?: unknown; + baseColorTexture?: unknown; + metallicRoughnessTexture?: unknown; + }; + normalTexture?: unknown; + occlusionTexture?: unknown; + emissiveTexture?: unknown; + emissiveFactor?: unknown; + alphaMode?: unknown; + alphaCutoff?: unknown; + doubleSided?: unknown; + }>; nodes?: Array<{ name?: unknown; mesh?: unknown; + children?: unknown; matrix?: unknown; translation?: unknown; rotation?: unknown; @@ -683,6 +715,8 @@ interface Gltf { }>; } +type GltfNode = NonNullable[number]; + function parseGltf(name: string, bytes: Uint8Array, binary: boolean): Model3D { let jsonText: string; let binaryChunk: Uint8Array | undefined; @@ -738,12 +772,17 @@ function parseGltf(name: string, bytes: Uint8Array, binary: boolean): Model3D { ); return decoded.subarray(0, declaredLength); }); - const positions: number[] = []; - const normals: number[] = []; - const indices: number[] = []; + interface PrimitiveGeometry { + positions: number[]; + normals?: number[]; + indices: number[]; + material?: number; + } + const meshGeometry: PrimitiveGeometry[][] = []; const warnings = new Set(); - let completeNormals = true; + let sourceVertices = 0; for (const mesh of gltf.meshes ?? []) { + const primitives: PrimitiveGeometry[] = []; for (const primitive of mesh.primitives ?? []) { const mode = numberField(primitive.mode, 4, "primitive mode"); if (mode !== 4) { @@ -762,26 +801,19 @@ function parseGltf(name: string, bytes: Uint8Array, binary: boolean): Model3D { "VEC3", ); const vertexCount = positionValues.length / 3; - if (positions.length / 3 + vertexCount > MAX_VERTICES) - throw new RangeError("glTF exceeds 1,000,000 vertices."); - const base = positions.length / 3; - positions.push(...positionValues); - const normalIndex = primitive.attributes?.NORMAL; - if (Number.isSafeInteger(normalIndex)) { - const normalValues = readAccessor( - gltf, - buffers, - Number(normalIndex), - "VEC3", + sourceVertices += vertexCount; + if (sourceVertices > MAX_VERTICES) + throw new RangeError( + "glTF source geometry exceeds 1,000,000 vertices.", ); + const normalIndex = primitive.attributes?.NORMAL; + let normalValues: number[] | undefined; + if (Number.isSafeInteger(normalIndex)) { + normalValues = readAccessor(gltf, buffers, Number(normalIndex), "VEC3"); if (normalValues.length !== positionValues.length) throw new SyntaxError( "glTF normal count differs from POSITION count.", ); - normals.push(...normalValues); - } else { - completeNormals = false; - normals.push(...new Array(positionValues.length).fill(0)); } const localIndices = Number.isSafeInteger(primitive.indices) ? readIndices(gltf, buffers, Number(primitive.indices)) @@ -790,51 +822,435 @@ function parseGltf(name: string, bytes: Uint8Array, binary: boolean): Model3D { throw new SyntaxError( "glTF triangle index count is not divisible by three.", ); - if (indices.length / 3 + localIndices.length / 3 > MAX_TRIANGLES) - throw new RangeError("glTF exceeds 1,000,000 triangles."); for (const index of localIndices) { if (index < 0 || index >= vertexCount) throw new SyntaxError("glTF index is outside its POSITION accessor."); - indices.push(base + index); } + const material = + primitive.material === undefined + ? undefined + : numberField(primitive.material, -1, "primitive material"); + if (material !== undefined && material >= (gltf.materials?.length ?? 0)) + throw new TypeError("glTF primitive material index is out of range."); + primitives.push({ + positions: positionValues, + ...(normalValues ? { normals: normalValues } : {}), + indices: localIndices, + ...(material !== undefined ? { material } : {}), + }); } + meshGeometry.push(primitives); } - if (indices.length === 0) - throw new SyntaxError("glTF contains no supported triangles."); const nodeList = gltf.nodes ?? []; if (nodeList.length > MAX_SCENE_NODES) throw new RangeError("glTF scene exceeds 10,000 nodes."); - if ( - nodeList.some( - (node) => - node.matrix !== undefined || - node.translation !== undefined || - node.rotation !== undefined || - node.scale !== undefined, - ) - ) - warnings.add( - "Scene-node transforms are inspected but not baked into exported mesh geometry.", + + const positions: number[] = []; + const normals: number[] = []; + const indices: number[] = []; + const selectedNodes: string[] = []; + const selectedMaterials = new Set(); + let completeNormals = true; + const appendMesh = (meshIndex: number, matrix: number[]) => { + const primitives = meshGeometry[meshIndex]; + if (!primitives) + throw new SyntaxError("glTF node references a missing mesh."); + const normalMatrix = gltfNormalMatrix(matrix); + const orientation = gltfDeterminant(matrix); + for (const primitive of primitives) { + if (primitive.material !== undefined) + selectedMaterials.add(primitive.material); + if (positions.length / 3 + primitive.positions.length / 3 > MAX_VERTICES) + throw new RangeError( + "Transformed glTF scene exceeds 1,000,000 vertices.", + ); + if (indices.length / 3 + primitive.indices.length / 3 > MAX_TRIANGLES) + throw new RangeError( + "Transformed glTF scene exceeds 1,000,000 triangles.", + ); + const base = positions.length / 3; + for (let index = 0; index < primitive.positions.length; index += 3) { + positions.push( + ...gltfPoint(matrix, [ + primitive.positions[index] ?? 0, + primitive.positions[index + 1] ?? 0, + primitive.positions[index + 2] ?? 0, + ]), + ); + if (primitive.normals) { + normals.push( + ...normalize3( + gltfDirection(normalMatrix, [ + primitive.normals[index] ?? 0, + primitive.normals[index + 1] ?? 0, + primitive.normals[index + 2] ?? 0, + ]), + ), + ); + } else { + completeNormals = false; + normals.push(0, 0, 0); + } + } + for (let index = 0; index < primitive.indices.length; index += 3) { + const first = base + (primitive.indices[index] ?? 0); + const second = base + (primitive.indices[index + 1] ?? 0); + const third = base + (primitive.indices[index + 2] ?? 0); + if (orientation < 0) indices.push(first, third, second); + else indices.push(first, second, third); + } + } + }; + + if (nodeList.length === 0) { + meshGeometry.forEach((_, meshIndex) => appendMesh(meshIndex, identity4())); + } else { + const children = nodeList.map((node, index) => + gltfIndices(node.children, nodeList.length, `node ${index} children`), ); + const roots = gltfSceneRoots(gltf, children, nodeList.length); + const active = new Set(); + const visited = new Set(); + const visit = (nodeIndex: number, parent: number[]) => { + if (active.has(nodeIndex)) + throw new SyntaxError("glTF scene-node hierarchy contains a cycle."); + if (visited.has(nodeIndex)) + throw new SyntaxError( + "glTF scene-node hierarchy references one node more than once.", + ); + const node = nodeList[nodeIndex]!; + active.add(nodeIndex); + visited.add(nodeIndex); + const world = multiply4(parent, gltfNodeMatrix(node, nodeIndex)); + selectedNodes.push( + typeof node.name === "string" ? node.name : "Node " + (nodeIndex + 1), + ); + if (node.mesh !== undefined) { + const meshIndex = numberField(node.mesh, -1, "node mesh"); + appendMesh(meshIndex, world); + } + for (const child of children[nodeIndex] ?? []) visit(child, world); + active.delete(nodeIndex); + }; + for (const root of roots) visit(root, identity4()); + } + if (indices.length === 0) + throw new SyntaxError( + "The selected glTF scene contains no supported triangles.", + ); + const materialDetails = [...selectedMaterials] + .sort((left, right) => left - right) + .map((index) => gltfMaterial(gltf.materials?.[index], index)); return finishModel({ name, format: binary ? "GLB" : "glTF", positions: new Float32Array(positions), ...(completeNormals ? { normals: new Float32Array(normals) } : {}), indices: new Uint32Array(indices), - materials: (gltf.materials ?? []).map((material, index) => - typeof material.name === "string" - ? material.name - : "Material " + (index + 1), - ), - nodes: nodeList.map((node, index) => - typeof node.name === "string" ? node.name : "Node " + (index + 1), - ), + materials: materialDetails.map((material) => material.name), + materialDetails, + nodes: selectedNodes, warnings: [...warnings], - sourceVertices: positions.length / 3, + sourceVertices, }); } +function gltfMaterial( + material: NonNullable[number] | undefined, + index: number, +): MaterialInfo { + const pbr = material?.pbrMetallicRoughness; + const baseColorFactor = factorArray( + pbr?.baseColorFactor, + [1, 1, 1, 1], + "baseColorFactor", + ) as [number, number, number, number]; + const emissiveFactor = factorArray( + material?.emissiveFactor, + [0, 0, 0], + "emissiveFactor", + ) as [number, number, number]; + const metallicFactor = unitFactor(pbr?.metallicFactor, 1, "metallicFactor"); + const roughnessFactor = unitFactor( + pbr?.roughnessFactor, + 1, + "roughnessFactor", + ); + const alphaCutoff = unitFactor(material?.alphaCutoff, 0.5, "alphaCutoff"); + const alphaMode = material?.alphaMode ?? "OPAQUE"; + if (!(["OPAQUE", "MASK", "BLEND"] as unknown[]).includes(alphaMode)) + throw new TypeError("Invalid glTF material alphaMode."); + if ( + material?.doubleSided !== undefined && + typeof material.doubleSided !== "boolean" + ) + throw new TypeError("Invalid glTF material doubleSided value."); + const textures = [ + ...(pbr?.baseColorTexture ? ["base color"] : []), + ...(pbr?.metallicRoughnessTexture ? ["metallic/roughness"] : []), + ...(material?.normalTexture ? ["normal"] : []), + ...(material?.occlusionTexture ? ["occlusion"] : []), + ...(material?.emissiveTexture ? ["emissive"] : []), + ]; + return { + name: + typeof material?.name === "string" + ? material.name.slice(0, 200) + : "Material " + (index + 1), + baseColorFactor, + metallicFactor, + roughnessFactor, + emissiveFactor, + alphaMode: alphaMode as MaterialInfo["alphaMode"], + alphaCutoff, + doubleSided: material?.doubleSided === true, + textures, + }; +} + +function unitFactor(value: unknown, fallback: number, label: string): number { + if (value === undefined) return fallback; + if ( + typeof value !== "number" || + !Number.isFinite(value) || + value < 0 || + value > 1 + ) + throw new TypeError(`glTF ${label} must be a finite value from 0 to 1.`); + return value; +} + +function factorArray( + value: unknown, + fallback: number[], + label: string, +): number[] { + if (value === undefined) return [...fallback]; + const result = finiteArray(value, fallback.length, label); + if (result.some((item) => item < 0 || item > 1)) + throw new TypeError(`glTF ${label} values must be from 0 to 1.`); + return result; +} + +function identity4(): number[] { + return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; +} + +/** Multiply column-major glTF matrices (`left * right`). */ +function multiply4(left: number[], right: number[]): number[] { + const output = new Array(16).fill(0); + for (let column = 0; column < 4; column += 1) + for (let row = 0; row < 4; row += 1) + for (let index = 0; index < 4; index += 1) + output[column * 4 + row] = + (output[column * 4 + row] ?? 0) + + (left[index * 4 + row] ?? 0) * (right[column * 4 + index] ?? 0); + return output; +} + +function finiteArray(value: unknown, length: number, label: string): number[] { + if ( + !Array.isArray(value) || + value.length !== length || + value.some((item) => typeof item !== "number" || !Number.isFinite(item)) + ) + throw new TypeError(`glTF ${label} must contain ${length} finite numbers.`); + return value as number[]; +} + +function gltfNodeMatrix(node: GltfNode, nodeIndex: number): number[] { + if ( + node.matrix !== undefined && + (node.translation !== undefined || + node.rotation !== undefined || + node.scale !== undefined) + ) + throw new TypeError( + `glTF node ${nodeIndex} combines matrix and TRS transforms.`, + ); + if (node.matrix !== undefined) { + const matrix = finiteArray(node.matrix, 16, `node ${nodeIndex} matrix`); + if ( + Math.abs(matrix[3] ?? 0) > 1e-8 || + Math.abs(matrix[7] ?? 0) > 1e-8 || + Math.abs(matrix[11] ?? 0) > 1e-8 || + Math.abs((matrix[15] ?? 0) - 1) > 1e-8 + ) + throw new TypeError(`glTF node ${nodeIndex} matrix is not affine.`); + gltfNormalMatrix(matrix); + return [...matrix]; + } + const translation = + node.translation === undefined + ? [0, 0, 0] + : finiteArray(node.translation, 3, `node ${nodeIndex} translation`); + const scale = + node.scale === undefined + ? [1, 1, 1] + : finiteArray(node.scale, 3, `node ${nodeIndex} scale`); + if (scale.some((value) => Math.abs(value) < 1e-12)) + throw new TypeError(`glTF node ${nodeIndex} has a singular scale.`); + const rotation = + node.rotation === undefined + ? [0, 0, 0, 1] + : finiteArray(node.rotation, 4, `node ${nodeIndex} rotation`); + const magnitude = Math.hypot(...rotation); + if (magnitude < 1e-12) + throw new TypeError(`glTF node ${nodeIndex} has an invalid quaternion.`); + const [x, y, z, w] = rotation.map((value) => value / magnitude) as [ + number, + number, + number, + number, + ]; + const [sx, sy, sz] = scale as [number, number, number]; + return [ + (1 - 2 * (y * y + z * z)) * sx, + 2 * (x * y + z * w) * sx, + 2 * (x * z - y * w) * sx, + 0, + 2 * (x * y - z * w) * sy, + (1 - 2 * (x * x + z * z)) * sy, + 2 * (y * z + x * w) * sy, + 0, + 2 * (x * z + y * w) * sz, + 2 * (y * z - x * w) * sz, + (1 - 2 * (x * x + y * y)) * sz, + 0, + translation[0]!, + translation[1]!, + translation[2]!, + 1, + ]; +} + +function gltfIndices(value: unknown, limit: number, label: string): number[] { + if (value === undefined) return []; + if ( + !Array.isArray(value) || + value.some( + (item) => + !Number.isSafeInteger(item) || + Number(item) < 0 || + Number(item) >= limit, + ) + ) + throw new TypeError(`glTF ${label} contains an invalid node index.`); + return value.map(Number); +} + +function gltfSceneRoots( + gltf: Gltf, + children: number[][], + nodeCount: number, +): number[] { + const scenes = gltf.scenes ?? []; + if (scenes.length) { + const selected = numberField(gltf.scene, 0, "default scene"); + if (selected >= scenes.length) + throw new TypeError("glTF default scene index is out of range."); + return gltfIndices( + scenes[selected]?.nodes, + nodeCount, + `scene ${selected} roots`, + ); + } + const referenced = new Set(children.flat()); + const roots = Array.from({ length: nodeCount }, (_, index) => index).filter( + (index) => !referenced.has(index), + ); + if (!roots.length) throw new SyntaxError("glTF scene has no root nodes."); + return roots; +} + +function gltfPoint( + matrix: number[], + value: [number, number, number], +): [number, number, number] { + return [ + (matrix[0] ?? 0) * value[0] + + (matrix[4] ?? 0) * value[1] + + (matrix[8] ?? 0) * value[2] + + (matrix[12] ?? 0), + (matrix[1] ?? 0) * value[0] + + (matrix[5] ?? 0) * value[1] + + (matrix[9] ?? 0) * value[2] + + (matrix[13] ?? 0), + (matrix[2] ?? 0) * value[0] + + (matrix[6] ?? 0) * value[1] + + (matrix[10] ?? 0) * value[2] + + (matrix[14] ?? 0), + ]; +} + +function gltfDeterminant(matrix: number[]): number { + const a00 = matrix[0] ?? 0; + const a01 = matrix[4] ?? 0; + const a02 = matrix[8] ?? 0; + const a10 = matrix[1] ?? 0; + const a11 = matrix[5] ?? 0; + const a12 = matrix[9] ?? 0; + const a20 = matrix[2] ?? 0; + const a21 = matrix[6] ?? 0; + const a22 = matrix[10] ?? 0; + return ( + a00 * (a11 * a22 - a12 * a21) - + a01 * (a10 * a22 - a12 * a20) + + a02 * (a10 * a21 - a11 * a20) + ); +} + +/** Row-major inverse-transpose 3×3 used only for normal vectors. */ +function gltfNormalMatrix(matrix: number[]): number[] { + const a00 = matrix[0] ?? 0; + const a01 = matrix[4] ?? 0; + const a02 = matrix[8] ?? 0; + const a10 = matrix[1] ?? 0; + const a11 = matrix[5] ?? 0; + const a12 = matrix[9] ?? 0; + const a20 = matrix[2] ?? 0; + const a21 = matrix[6] ?? 0; + const a22 = matrix[10] ?? 0; + const determinant = gltfDeterminant(matrix); + if (Math.abs(determinant) < 1e-12) + throw new TypeError("glTF scene transform is singular."); + return [ + (a11 * a22 - a12 * a21) / determinant, + (a12 * a20 - a10 * a22) / determinant, + (a10 * a21 - a11 * a20) / determinant, + (a02 * a21 - a01 * a22) / determinant, + (a00 * a22 - a02 * a20) / determinant, + (a01 * a20 - a00 * a21) / determinant, + (a01 * a12 - a02 * a11) / determinant, + (a02 * a10 - a00 * a12) / determinant, + (a00 * a11 - a01 * a10) / determinant, + ]; +} + +function gltfDirection( + matrix: number[], + value: [number, number, number], +): [number, number, number] { + return [ + (matrix[0] ?? 0) * value[0] + + (matrix[1] ?? 0) * value[1] + + (matrix[2] ?? 0) * value[2], + (matrix[3] ?? 0) * value[0] + + (matrix[4] ?? 0) * value[1] + + (matrix[5] ?? 0) * value[2], + (matrix[6] ?? 0) * value[0] + + (matrix[7] ?? 0) * value[1] + + (matrix[8] ?? 0) * value[2], + ]; +} + +function normalize3(value: [number, number, number]): [number, number, number] { + const magnitude = Math.hypot(...value); + return magnitude < 1e-12 + ? [0, 0, 0] + : [value[0] / magnitude, value[1] / magnitude, value[2] / magnitude]; +} + function parseGlb(bytes: Uint8Array): { json: Uint8Array; bin?: Uint8Array } { if (bytes.length < 20) throw new SyntaxError("GLB header is truncated."); const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); @@ -1031,13 +1447,16 @@ export function transformModel( transform: { translate: [number, number, number]; rotate: [number, number, number]; - scale: number; + scale: number | [number, number, number]; }, ): Model3D { - const values = [...transform.translate, ...transform.rotate, transform.scale]; + const scale = Array.isArray(transform.scale) + ? transform.scale + : [transform.scale, transform.scale, transform.scale]; + const values = [...transform.translate, ...transform.rotate, ...scale]; if ( values.some((value) => !Number.isFinite(value)) || - Math.abs(transform.scale) < 1e-9 + scale.some((value) => Math.abs(value) < 1e-9) ) throw new TypeError( "Transform values must be finite and scale must be non-zero.", @@ -1051,38 +1470,75 @@ export function transformModel( const normals = new Float32Array(model.normals.length); for (let index = 0; index < model.positions.length; index += 3) { const point = multiply3(rotation, [ - (model.positions[index] ?? 0) * transform.scale, - (model.positions[index + 1] ?? 0) * transform.scale, - (model.positions[index + 2] ?? 0) * transform.scale, + (model.positions[index] ?? 0) * scale[0]!, + (model.positions[index + 1] ?? 0) * scale[1]!, + (model.positions[index + 2] ?? 0) * scale[2]!, ]); positions[index] = point[0] + transform.translate[0]; positions[index + 1] = point[1] + transform.translate[1]; positions[index + 2] = point[2] + transform.translate[2]; const normal = multiply3(rotation, [ - model.normals[index] ?? 0, - model.normals[index + 1] ?? 0, - model.normals[index + 2] ?? 0, + (model.normals[index] ?? 0) / scale[0]!, + (model.normals[index + 1] ?? 0) / scale[1]!, + (model.normals[index + 2] ?? 0) / scale[2]!, ]); const length = Math.hypot(...normal) || 1; - const orientation = transform.scale < 0 ? -1 : 1; - normals[index] = (normal[0] / length) * orientation; - normals[index + 1] = (normal[1] / length) * orientation; - normals[index + 2] = (normal[2] / length) * orientation; + normals[index] = normal[0] / length; + normals[index + 1] = normal[1] / length; + normals[index + 2] = normal[2] / length; } - const indices = - transform.scale < 0 - ? Uint32Array.from(model.indices, (_, index) => { - const within = index % 3; - return ( - model.indices[ - within === 1 ? index + 1 : within === 2 ? index - 1 : index - ] ?? 0 - ); - }) - : new Uint32Array(model.indices); + const mirrored = scale[0]! * scale[1]! * scale[2]! < 0; + const indices = mirrored + ? Uint32Array.from(model.indices, (_, index) => { + const within = index % 3; + return ( + model.indices[ + within === 1 ? index + 1 : within === 2 ? index - 1 : index + ] ?? 0 + ); + }) + : new Uint32Array(model.indices); return { ...model, positions, normals, indices }; } +export function removeDegenerateTriangles(model: Model3D): Model3D { + const kept: number[] = []; + let removed = 0; + for (let index = 0; index < model.indices.length; index += 3) { + const a = model.indices[index] ?? 0; + const b = model.indices[index + 1] ?? 0; + const c = model.indices[index + 2] ?? 0; + const abx = (model.positions[b * 3] ?? 0) - (model.positions[a * 3] ?? 0); + const aby = + (model.positions[b * 3 + 1] ?? 0) - (model.positions[a * 3 + 1] ?? 0); + const abz = + (model.positions[b * 3 + 2] ?? 0) - (model.positions[a * 3 + 2] ?? 0); + const acx = (model.positions[c * 3] ?? 0) - (model.positions[a * 3] ?? 0); + const acy = + (model.positions[c * 3 + 1] ?? 0) - (model.positions[a * 3 + 1] ?? 0); + const acz = + (model.positions[c * 3 + 2] ?? 0) - (model.positions[a * 3 + 2] ?? 0); + const area2 = Math.hypot( + aby * acz - abz * acy, + abz * acx - abx * acz, + abx * acy - aby * acx, + ); + if (a === b || b === c || a === c || area2 < 1e-12) removed += 1; + else kept.push(a, b, c); + } + if (removed === 0) return model; + if (kept.length === 0) + throw new Error("Repair would remove every triangle from the model."); + return repairNormals({ + ...model, + indices: new Uint32Array(kept), + warnings: [ + ...model.warnings, + `${removed.toLocaleString()} degenerate triangle(s) were removed.`, + ], + }); +} + function rotationMatrix(rotation: [number, number, number]): number[] { const x = (rotation[0] * Math.PI) / 180; const y = (rotation[1] * Math.PI) / 180; @@ -1179,7 +1635,7 @@ function formatted(value: number): string { export function exportObj(model: Model3D): string { const lines = [ - "# Exported by 3D Tools 0.1.0", + "# Exported by 3D Tools 0.2.0", "o " + safeObjName(model.name), ]; for (let index = 0; index < model.positions.length; index += 3) @@ -1209,6 +1665,45 @@ export function exportObj(model: Model3D): string { return lines.join("\n") + "\n"; } +export function exportAsciiPly(model: Model3D): string { + const lines = [ + "ply", + "format ascii 1.0", + "comment Exported by 3D Tools 0.2.0", + `element vertex ${model.positions.length / 3}`, + "property float x", + "property float y", + "property float z", + "property float nx", + "property float ny", + "property float nz", + `element face ${model.indices.length / 3}`, + "property list uchar uint vertex_indices", + "end_header", + ]; + for (let index = 0; index < model.positions.length; index += 3) + lines.push( + [ + model.positions[index] ?? 0, + model.positions[index + 1] ?? 0, + model.positions[index + 2] ?? 0, + model.normals[index] ?? 0, + model.normals[index + 1] ?? 0, + model.normals[index + 2] ?? 0, + ] + .map(formatted) + .join(" "), + ); + for (let index = 0; index < model.indices.length; index += 3) + lines.push( + `3 ${model.indices[index] ?? 0} ${model.indices[index + 1] ?? 0} ${model.indices[index + 2] ?? 0}`, + ); + const output = lines.join("\n") + "\n"; + if (output.length > 64 * 1024 * 1024) + throw new RangeError("ASCII PLY export is limited to 64 MiB of text."); + return output; +} + function safeObjName(value: string): string { return value.replace(/[\r\n\0]/gu, " ").slice(0, 200) || "model"; } @@ -1217,7 +1712,7 @@ export function exportBinaryStl(model: Model3D): Uint8Array { const triangles = model.indices.length / 3; const output = new Uint8Array(84 + triangles * 50); const header = new TextEncoder().encode( - "3D Tools 0.1.0 deterministic binary STL", + "3D Tools 0.2.0 deterministic binary STL", ); output.set(header.subarray(0, 80), 0); const view = new DataView(output.buffer); diff --git a/src/core/model.worker.ts b/src/core/model.worker.ts new file mode 100644 index 0000000..ba85d6e --- /dev/null +++ b/src/core/model.worker.ts @@ -0,0 +1,39 @@ +/// + +import { parseModel, type ModelFormat } from "./model"; + +interface Request { + id: number; + name: string; + bytes: ArrayBuffer; + format: ModelFormat; +} + +declare const self: DedicatedWorkerGlobalScope; + +self.onmessage = (event: MessageEvent) => { + const { id, name, bytes, format } = event.data; + try { + self.postMessage({ + id, + kind: "progress", + progress: 0.1, + stage: `Parsing ${format}`, + }); + const model = parseModel(name, new Uint8Array(bytes), format); + self.postMessage({ id, kind: "progress", progress: 1, stage: "Complete" }); + self.postMessage({ id, kind: "result", model }, [ + model.positions.buffer, + model.normals.buffer, + model.indices.buffer, + ]); + } catch (reason) { + self.postMessage({ + id, + kind: "error", + error: reason instanceof Error ? reason.message : "3D parsing failed.", + }); + } +}; + +export {}; diff --git a/src/styles.css b/src/styles.css index 5212066..34de190 100644 --- a/src/styles.css +++ b/src/styles.css @@ -133,6 +133,58 @@ details { border-radius: 0.62rem; } +.material-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); + gap: 0.65rem; + margin-top: 1rem; +} + +.material-grid article { + position: relative; + min-width: 0; + padding: 0.7rem 0.7rem 0.7rem 3.4rem; + border: 1px solid var(--toolbox-border); + border-radius: 0.62rem; + background: var(--toolbox-surface-soft); +} + +.material-swatch { + position: absolute; + top: 0.7rem; + left: 0.7rem; + width: 2rem; + aspect-ratio: 1; + border: 1px solid var(--toolbox-border); + border-radius: 0.4rem; + background-image: + linear-gradient(45deg, #bbb 25%, transparent 25%), + linear-gradient(-45deg, #bbb 25%, transparent 25%); +} + +.material-grid dl { + display: grid; + gap: 0.3rem; + margin: 0.5rem 0 0; + font-size: 0.72rem; +} + +.material-grid dl div { + display: flex; + justify-content: space-between; + gap: 0.5rem; +} + +.material-grid dt { + color: var(--toolbox-muted); +} + +.material-grid dd { + margin: 0; + overflow-wrap: anywhere; + text-align: right; +} + .workbench label { display: grid; gap: 0.34rem; diff --git a/src/toolbox/manifest.source.json b/src/toolbox/manifest.source.json index d369103..0140050 100644 --- a/src/toolbox/manifest.source.json +++ b/src/toolbox/manifest.source.json @@ -3,12 +3,12 @@ "schemaVersion": 1, "id": "de.add-ideas.3d-tools", "name": "3D Tools", - "version": "0.1.0", - "description": "Inspect and repair 3D meshes locally.", + "version": "0.2.0", + "description": "Inspect, transform, repair and export 3D meshes locally.", "entry": "./", "icon": "./favicon.svg", "categories": ["media", "developer", "design"], - "tags": ["3d", "obj", "stl", "ply", "gltf", "webgl"], + "tags": ["3d", "obj", "stl", "ply", "gltf", "pbr", "webgl"], "integration": { "contextVersion": 1, "launchModes": ["navigate", "new-tab"], @@ -21,6 +21,24 @@ "crossOriginIsolated": false, "topLevelContext": false }, + "io": { + "accepts": [ + { "mediaType": "model/obj", "extensions": [".obj"] }, + { "mediaType": "model/stl", "extensions": [".stl"] }, + { "mediaType": "model/ply", "extensions": [".ply"] }, + { "mediaType": "model/gltf+json", "extensions": [".gltf"] }, + { "mediaType": "model/gltf-binary", "extensions": [".glb"] } + ], + "produces": [ + { "mediaType": "model/obj", "extensions": [".obj"] }, + { "mediaType": "model/stl", "extensions": [".stl"] }, + { "mediaType": "model/ply", "extensions": [".ply"] } + ] + }, + "capabilities": { + "required": [], + "optional": ["workers", "webgl"] + }, "privacy": { "processing": "local", "fileUploads": true, diff --git a/src/version.ts b/src/version.ts index 76162f8..0ba6e37 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = "0.1.0"; +export const APP_VERSION = "0.2.0"; diff --git a/tests/browser/app.spec.ts b/tests/browser/app.spec.ts index 8279c43..54ee22d 100644 --- a/tests/browser/app.spec.ts +++ b/tests/browser/app.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { Buffer } from "node:buffer"; for (const path of ["/", "/deep/nested/3d/"]) { test("inspects and transforms locally at " + path, async ({ page }) => { @@ -21,6 +22,22 @@ for (const path of ["/", "/deep/nested/3d/"]) { }); } +test("parses and flattens glTF scene transforms in the worker", async ({ + page, +}) => { + await page.goto("/"); + await page.locator('input[type="file"]').setInputFiles({ + name: "translated.gltf", + mimeType: "model/gltf+json", + buffer: gltfFixture(), + }); + await expect(page.locator("p.status")).toContainText("glTF parsed locally"); + await expect( + page.getByRole("heading", { name: "translated.gltf" }), + ).toBeVisible(); + await expect(page.getByRole("row", { name: /X 10 12 2/u })).toBeVisible(); +}); + test("keeps the installed application available offline", async ({ page, context, @@ -40,3 +57,43 @@ test("keeps the installed application available offline", async ({ await context.setOffline(false); } }); + +function gltfFixture(): Buffer { + const binary = Buffer.alloc(42); + const view = new DataView( + binary.buffer, + binary.byteOffset, + binary.byteLength, + ); + [0, 0, 0, 1, 0, 0, 0, 1, 0].forEach((value, index) => + view.setFloat32(index * 4, value, true), + ); + view.setUint16(36, 0, true); + view.setUint16(38, 1, true); + view.setUint16(40, 2, true); + const document = { + asset: { version: "2.0" }, + scene: 0, + scenes: [{ nodes: [0] }], + buffers: [ + { + uri: `data:application/octet-stream;base64,${binary.toString("base64")}`, + byteLength: binary.length, + }, + ], + bufferViews: [ + { buffer: 0, byteOffset: 0, byteLength: 36 }, + { buffer: 0, byteOffset: 36, byteLength: 6 }, + ], + accessors: [ + { bufferView: 0, componentType: 5126, count: 3, type: "VEC3" }, + { bufferView: 1, componentType: 5123, count: 3, type: "SCALAR" }, + ], + meshes: [{ primitives: [{ attributes: { POSITION: 0 }, indices: 1 }] }], + nodes: [ + { name: "Parent", translation: [10, 0, 0], children: [1] }, + { name: "Scaled", mesh: 0, scale: [2, 1, 1] }, + ], + }; + return Buffer.from(JSON.stringify(document)); +} diff --git a/tests/browser/responsive.spec.ts b/tests/browser/responsive.spec.ts new file mode 100644 index 0000000..2daaa3a --- /dev/null +++ b/tests/browser/responsive.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from "@playwright/test"; + +test("keeps the primary workspace inside a narrow viewport", async ({ + page, +}) => { + await page.goto("/deep/nested/3d/"); + await expect(page.locator("main").first()).toBeVisible(); + await expect( + page.locator("main .loading, main .workbench-loading"), + ).toHaveCount(0); + + const widths = await page.evaluate(() => ({ + content: document.documentElement.scrollWidth, + viewport: document.documentElement.clientWidth, + })); + expect(widths.viewport).toBeLessThanOrEqual(430); + expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1); +}); diff --git a/tests/core/model.test.ts b/tests/core/model.test.ts index 37960e8..987fadd 100644 --- a/tests/core/model.test.ts +++ b/tests/core/model.test.ts @@ -2,9 +2,11 @@ import { bytesToBase64 } from "@add-ideas/toolbox-helpers"; import { describe, expect, it } from "vitest"; import { exportBinaryStl, + exportAsciiPly, exportObj, modelStats, parseModel, + removeDegenerateTriangles, transformModel, } from "../../src/core/model"; @@ -115,11 +117,76 @@ describe("bounded 3D parsing", () => { ); expect(model.indices).toEqual(Uint32Array.of(0, 1, 2)); expect(model.materials).toEqual(["Local"]); + expect(model.materialDetails?.[0]).toMatchObject({ + name: "Local", + baseColorFactor: [0.2, 0.4, 0.6, 1], + metallicFactor: 0.25, + roughnessFactor: 0.7, + alphaMode: "OPAQUE", + }); const glb = makeGlb(gltfDocument(undefined, buffer.length), buffer); expect(parseModel("triangle.glb", glb, "GLB").indices).toHaveLength(3); }); + it("flattens the selected glTF scene hierarchy and mesh instances", () => { + const buffer = gltfBuffer(); + const document = gltfDocument( + "data:application/octet-stream;base64," + bytesToBase64(buffer), + buffer.length, + ); + Object.assign(document, { + scene: 0, + scenes: [{ nodes: [0, 2] }, { nodes: [3] }], + nodes: [ + { name: "Parent", translation: [10, 0, 0], children: [1] }, + { name: "Scaled child", mesh: 0, scale: [2, 1, 1] }, + { name: "Second instance", mesh: 0, translation: [0, 5, 0] }, + { name: "Other scene", mesh: 0, translation: [100, 0, 0] }, + ], + }); + const model = parseModel( + "scene.gltf", + encode(JSON.stringify(document)), + "glTF", + ); + const stats = modelStats(model); + expect(stats).toMatchObject({ vertices: 6, triangles: 2, nodes: 3 }); + expect(stats.bounds.min).toEqual([0, 0, 0]); + expect(stats.bounds.max).toEqual([12, 6, 0]); + expect(stats.surfaceArea).toBeCloseTo(1.5); + expect(model.nodes).toEqual(["Parent", "Scaled child", "Second instance"]); + }); + + it("applies glTF matrices and repairs mirrored winding", () => { + const buffer = gltfBuffer(); + const document = gltfDocument( + "data:application/octet-stream;base64," + bytesToBase64(buffer), + buffer.length, + ); + Object.assign(document, { + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [ + { + mesh: 0, + matrix: [-1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 3, 0, 0, 1], + }, + ], + }); + const model = parseModel( + "mirror.gltf", + encode(JSON.stringify(document)), + "glTF", + ); + expect(model.indices).toEqual(Uint32Array.of(0, 2, 1)); + expect(modelStats(model).bounds).toMatchObject({ + min: [2, 0, 0], + max: [3, 1, 0], + }); + expect(model.normals[2]).toBeCloseTo(1); + }); + it("rejects remote glTF buffers and safely transforms normals/winding", () => { const remote = gltfDocument("https://example.invalid/model.bin", 42); expect(() => @@ -137,6 +204,27 @@ describe("bounded 3D parsing", () => { }); expect(modelStats(transformed).surfaceArea).toBeCloseTo(2); expect(transformed.indices).toEqual(Uint32Array.of(0, 2, 1)); + const nonUniform = transformModel(model, { + translate: [0, 0, 0], + rotate: [0, 0, 0], + scale: [2, 3, 1], + }); + expect(modelStats(nonUniform).surfaceArea).toBeCloseTo(3); + }); + + it("removes only degenerate triangles and exports bounded ASCII PLY", () => { + const model = parseModel( + "repair.obj", + encode("v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\nf 1 1 2\n"), + "OBJ", + ); + expect(modelStats(model).degenerateTriangles).toBe(1); + const repaired = removeDegenerateTriangles(model); + expect(modelStats(repaired)).toMatchObject({ + triangles: 1, + degenerateTriangles: 0, + }); + expect(exportAsciiPly(repaired)).toContain("element face 1"); }); }); @@ -164,7 +252,16 @@ function gltfDocument(uri: string | undefined, length: number) { { bufferView: 0, componentType: 5126, count: 3, type: "VEC3" }, { bufferView: 1, componentType: 5123, count: 3, type: "SCALAR" }, ], - materials: [{ name: "Local" }], + materials: [ + { + name: "Local", + pbrMetallicRoughness: { + baseColorFactor: [0.2, 0.4, 0.6, 1], + metallicFactor: 0.25, + roughnessFactor: 0.7, + }, + }, + ], meshes: [ { primitives: [{ attributes: { POSITION: 0 }, indices: 1, material: 0 }], diff --git a/tsconfig.app.json b/tsconfig.app.json index 0a14c25..1b152cb 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -4,7 +4,7 @@ "target": "ES2023", "lib": ["ES2023", "DOM", "DOM.Iterable"], "module": "ESNext", - "types": ["vite/client"], + "types": ["vite/client", "node"], "allowArbitraryExtensions": true, "skipLibCheck": true, "moduleResolution": "Bundler",