1 Commits
Author SHA1 Message Date
zemion f838480278 Release 3D Tools 0.2.0
Verify / verify (push) Canceled after 0s
2026-09-02 09:56:02 +02:00
31 changed files with 1347 additions and 145 deletions
+39
View File
@@ -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
+10
View File
@@ -1,5 +1,15 @@
# Changelog # 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 ## 0.1.0 - 2026-09-01
- Added bounded OBJ, STL, PLY and glTF/GLB triangle parsers. - Added bounded OBJ, STL, PLY and glTF/GLB triangle parsers.
+14 -5
View File
@@ -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. - 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. - 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. - Mesh, material, scene-node, bound, surface-area and degenerate-triangle statistics.
- Responsive drag-to-rotate WebGL preview capped independently from full-file analysis. - Responsive drag-to-rotate WebGL preview capped independently from full-file analysis.
- Area-weighted normal repair and finite translation, rotation and uniform-scale baking. - Area-weighted normal repair, degenerate-triangle removal and finite translation,
- Deterministic normalized OBJ and binary STL exports with explicit format-loss notes. 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. - Offline PWA and shared Toolbox system/light/dark shell.
## Supported subset and limits ## 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 ## 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 ## Licence
+12 -2
View File
@@ -1,5 +1,15 @@
# Security policy # 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.
+2 -2
View File
@@ -1,10 +1,10 @@
# Source identity # Source identity
- Project: 3D Tools - Project: 3D Tools
- Version: 0.1.0 - Version: 0.2.0
- Repository: https://git.add-ideas.de/lotobo/3d-tools - Repository: https://git.add-ideas.de/lotobo/3d-tools
- Licence: GPL-3.0-or-later - Licence: GPL-3.0-or-later
- Build: Node.js 22+, npm 11, `npm ci && npm run release:artifact` - 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/`. The lockfile pins dependencies exactly. Generated release manifests repeat this source identity and releases include detected runtime licence texts under `LICENSES/`.
+5 -1
View File
@@ -1,3 +1,7 @@
# Third-party notices # 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`.
+16 -1
View File
@@ -1,6 +1,21 @@
# Architecture # 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. 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.
+20 -20
View File
@@ -1,22 +1,22 @@
{ {
"name": "3d-tools", "name": "3d-tools",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "3d-tools", "name": "3d-tools",
"version": "0.1.0", "version": "0.2.0",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.1.0", "@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
@@ -42,24 +42,24 @@
} }
}, },
"node_modules/@add-ideas/toolbox-contract": { "node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.3", "version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz", "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-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==", "integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@add-ideas/toolbox-helpers": { "node_modules/@add-ideas/toolbox-helpers": {
"version": "0.1.0", "version": "0.2.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.1.0/toolbox-helpers-0.1.0.tgz", "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-UKl1Oxekedf8D2df86VrnVA53AcMhrnh6iUPXY+k8frirBXotb0yd8SGT+IF/3hcqYwcYe/v9WVFuSgKtIYVnw==", "integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
"license": "GPL-3.0-or-later" "license": "GPL-3.0-or-later"
}, },
"node_modules/@add-ideas/toolbox-shell-react": { "node_modules/@add-ideas/toolbox-shell-react": {
"version": "0.2.3", "version": "0.3.0",
"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", "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-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==", "integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=18 <20", "react": ">=18 <20",
@@ -67,13 +67,13 @@
} }
}, },
"node_modules/@add-ideas/toolbox-testkit": { "node_modules/@add-ideas/toolbox-testkit": {
"version": "0.2.3", "version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.3/toolbox-testkit-0.2.3.tgz", "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-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==", "integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"bin": { "bin": {
"toolbox-check": "dist/cli.js" "toolbox-check": "dist/cli.js"
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "3d-tools", "name": "3d-tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Inspect, preview, repair and export bounded 3D meshes locally in the browser.", "description": "Inspect, preview, repair and export bounded 3D meshes locally in the browser.",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"author": "Albrecht Degering", "author": "Albrecht Degering",
@@ -39,14 +39,14 @@
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force" "release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
}, },
"dependencies": { "dependencies": {
"@add-ideas/toolbox-helpers": "0.1.0", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
+20 -2
View File
@@ -15,7 +15,25 @@ export default defineConfig({
timeout: 180_000, timeout: 180_000,
}, },
projects: [ 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"] },
},
], ],
}); });
+10
View File
@@ -1,5 +1,15 @@
# Changelog # 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 ## 0.1.0 - 2026-09-01
- Added bounded OBJ, STL, PLY and glTF/GLB triangle parsers. - Added bounded OBJ, STL, PLY and glTF/GLB triangle parsers.
+3 -3
View File
@@ -1,5 +1,5 @@
============================================================================== ==============================================================================
@add-ideas/toolbox-contract@0.2.3 @add-ideas/toolbox-contract@0.3.0
Declared licence: Apache-2.0 Declared licence: Apache-2.0
============================================================================== ==============================================================================
--- LICENSE --- --- 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 Declared licence: GPL-3.0-or-later
============================================================================== ==============================================================================
--- LICENSE --- --- 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 Declared licence: Apache-2.0
============================================================================== ==============================================================================
--- LICENSE --- --- LICENSE ---
+14 -5
View File
@@ -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. - 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. - 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. - Mesh, material, scene-node, bound, surface-area and degenerate-triangle statistics.
- Responsive drag-to-rotate WebGL preview capped independently from full-file analysis. - Responsive drag-to-rotate WebGL preview capped independently from full-file analysis.
- Area-weighted normal repair and finite translation, rotation and uniform-scale baking. - Area-weighted normal repair, degenerate-triangle removal and finite translation,
- Deterministic normalized OBJ and binary STL exports with explicit format-loss notes. 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. - Offline PWA and shared Toolbox system/light/dark shell.
## Supported subset and limits ## 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 ## 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 ## Licence
+12 -2
View File
@@ -1,5 +1,15 @@
# Security policy # 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.
+2 -2
View File
@@ -1,10 +1,10 @@
# Source identity # Source identity
- Project: 3D Tools - Project: 3D Tools
- Version: 0.1.0 - Version: 0.2.0
- Repository: https://git.add-ideas.de/lotobo/3d-tools - Repository: https://git.add-ideas.de/lotobo/3d-tools
- Licence: GPL-3.0-or-later - Licence: GPL-3.0-or-later
- Build: Node.js 22+, npm 11, `npm ci && npm run release:artifact` - 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/`. The lockfile pins dependencies exactly. Generated release manifests repeat this source identity and releases include detected runtime licence texts under `LICENSES/`.
+5 -1
View File
@@ -1,3 +1,7 @@
# Third-party notices # 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`.
+16 -1
View File
@@ -1,6 +1,21 @@
# Architecture # 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. 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.
+1 -1
View File
@@ -1,5 +1,5 @@
const CACHE_PREFIX = "3d-tools-shell-"; 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"]; const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil( event.waitUntil(
+18 -3
View File
@@ -3,12 +3,12 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.3d-tools", "id": "de.add-ideas.3d-tools",
"name": "3D Tools", "name": "3D Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Inspect and repair 3D meshes locally.", "description": "Inspect, transform, repair and export 3D meshes locally.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
"categories": ["media", "developer", "design"], "categories": ["media", "developer", "design"],
"tags": ["3d", "obj", "stl", "ply", "gltf", "webgl"], "tags": ["3d", "obj", "stl", "ply", "gltf", "pbr", "webgl"],
"integration": { "integration": {
"contextVersion": 1, "contextVersion": 1,
"launchModes": ["navigate", "new-tab"], "launchModes": ["navigate", "new-tab"],
@@ -21,6 +21,21 @@
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": 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": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": true, "fileUploads": true,
+12 -1
View File
@@ -52,8 +52,9 @@ export function ModelPreview({ model }: { model: Model3D }) {
[ [
"precision mediump float;", "precision mediump float;",
"varying float vLight;", "varying float vLight;",
"uniform vec3 uBaseColor;",
"void main() {", "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"), ].join("\n"),
); );
@@ -98,6 +99,10 @@ export function ModelPreview({ model }: { model: Model3D }) {
const scaleLocation = gl.getUniformLocation(program, "uScale"); const scaleLocation = gl.getUniformLocation(program, "uScale");
const rotationLocation = gl.getUniformLocation(program, "uRotation"); const rotationLocation = gl.getUniformLocation(program, "uRotation");
const aspectLocation = gl.getUniformLocation(program, "uAspect"); 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 yaw = 0.55;
let pitch = -0.35; let pitch = -0.35;
let dragging = false; let dragging = false;
@@ -127,6 +132,12 @@ export function ModelPreview({ model }: { model: Model3D }) {
gl.uniform1f(scaleLocation, 1.35 / extent); gl.uniform1f(scaleLocation, 1.35 / extent);
gl.uniform2f(rotationLocation, yaw, pitch); gl.uniform2f(rotationLocation, yaw, pitch);
gl.uniform1f(aspectLocation, width / height); 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); gl.drawArrays(gl.TRIANGLES, 0, triangleCount * 3);
}; };
const resize = new ResizeObserver(draw); const resize = new ResizeObserver(draw);
+175 -11
View File
@@ -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 { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
import { import {
detectFormat, detectFormat,
exportAsciiPly,
exportBinaryStl, exportBinaryStl,
exportObj, exportObj,
MAX_FILE_BYTES, MAX_FILE_BYTES,
modelStats, modelStats,
parseModel, parseModel,
removeDegenerateTriangles,
repairNormals, repairNormals,
transformModel, transformModel,
type Model3D, type Model3D,
type ModelFormat, type ModelFormat,
} from "../core/model"; } from "../core/model";
import {
parseModelInWorker,
type ModelWorkerJob,
} from "../core/model-worker-client";
import { ModelPreview } from "./ModelPreview"; import { ModelPreview } from "./ModelPreview";
const EXAMPLE_TEXT = [ const EXAMPLE_TEXT = [
@@ -43,6 +49,10 @@ export function Workbench() {
"Example loaded. Choose a local OBJ, STL, PLY, glTF or GLB file.", "Example loaded. Choose a local OBJ, STL, PLY, glTF or GLB file.",
); );
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [progress, setProgress] = useState<{
value: number;
stage: string;
}>();
const [transform, setTransform] = useState({ const [transform, setTransform] = useState({
tx: "0", tx: "0",
ty: "0", ty: "0",
@@ -51,20 +61,59 @@ export function Workbench() {
ry: "0", ry: "0",
rz: "0", rz: "0",
scale: "1", scale: "1",
sx: "1",
sy: "1",
sz: "1",
}); });
const fileInput = useRef<HTMLInputElement>(null); const fileInput = useRef<HTMLInputElement>(null);
const parseJob = useRef<ModelWorkerJob | undefined>(undefined);
const parseRevision = useRef(0);
const stats = useMemo(() => modelStats(model), [model]); 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<void> { async function openFile(file: File | undefined): Promise<void> {
if (!file) return; if (!file) return;
const revision = ++parseRevision.current;
parseJob.current?.cancel();
setBusy(true); setBusy(true);
setProgress({ value: 0, stage: "Reading local file" });
try { try {
if (file.size > MAX_FILE_BYTES) if (file.size > MAX_FILE_BYTES)
throw new RangeError("3D files are limited to 32 MiB."); 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 = const selected =
format === "auto" ? detectFormat(file.name, bytes) : format; 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); setModel(next);
setStatus( setStatus(
selected + selected +
@@ -75,9 +124,17 @@ export function Workbench() {
" triangles.", " triangles.",
); );
} catch (caught) { } 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 { } finally {
setBusy(false); if (parseRevision.current === revision) {
parseJob.current = undefined;
setBusy(false);
setProgress(undefined);
}
if (fileInput.current) fileInput.current.value = ""; if (fileInput.current) fileInput.current.value = "";
} }
} }
@@ -90,7 +147,11 @@ export function Workbench() {
const next = transformModel(model, { const next = transformModel(model, {
translate: [values.tx, values.ty, values.tz], translate: [values.tx, values.ty, values.tz],
rotate: [values.rx, values.ry, values.rz], 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); setModel(next);
setTransform({ setTransform({
@@ -101,6 +162,9 @@ export function Workbench() {
ry: "0", ry: "0",
rz: "0", rz: "0",
scale: "1", scale: "1",
sx: "1",
sy: "1",
sz: "1",
}); });
setStatus("Transform baked into geometry and normals."); setStatus("Transform baked into geometry and normals.");
} catch (caught) { } catch (caught) {
@@ -114,7 +178,7 @@ export function Workbench() {
safeBase(model.name) + ".obj", safeBase(model.name) + ".obj",
); );
setStatus( 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 ( return (
<main className="workbench"> <main className="workbench">
<section className="hero"> <section className="hero">
@@ -176,10 +254,11 @@ export function Workbench() {
disabled={busy} disabled={busy}
onClick={() => fileInput.current?.click()} onClick={() => fileInput.current?.click()}
> >
{busy ? "Parsing…" : "Choose local file"} Choose local file
</button> </button>
<button <button
type="button" type="button"
disabled={busy}
onClick={() => { onClick={() => {
setModel(EXAMPLE); setModel(EXAMPLE);
setStatus("Built-in tetrahedron example restored."); setStatus("Built-in tetrahedron example restored.");
@@ -187,10 +266,16 @@ export function Workbench() {
> >
Restore example Restore example
</button> </button>
{busy ? (
<button type="button" onClick={() => cancelParse()}>
Cancel parsing
</button>
) : null}
<input <input
ref={fileInput} ref={fileInput}
className="sr-only" className="sr-only"
type="file" type="file"
aria-label="Open 3D model file"
accept=".obj,.stl,.ply,.gltf,.glb,model/*" accept=".obj,.stl,.ply,.gltf,.glb,model/*"
onChange={(event) => void openFile(event.target.files?.[0])} onChange={(event) => void openFile(event.target.files?.[0])}
/> />
@@ -199,6 +284,11 @@ export function Workbench() {
<p className="status" role="status" aria-live="polite"> <p className="status" role="status" aria-live="polite">
{status} {status}
</p> </p>
{progress ? (
<label>
{progress.stage} <progress value={progress.value} max="1" />
</label>
) : null}
</section> </section>
<section className="viewer-grid"> <section className="viewer-grid">
@@ -277,6 +367,9 @@ export function Workbench() {
["ry", "Rotate Y (°)"], ["ry", "Rotate Y (°)"],
["rz", "Rotate Z (°)"], ["rz", "Rotate Z (°)"],
["scale", "Uniform scale"], ["scale", "Uniform scale"],
["sx", "Axis scale X"],
["sy", "Axis scale Y"],
["sz", "Axis scale Z"],
] as const ] as const
).map(([key, label]) => ( ).map(([key, label]) => (
<label key={key}> <label key={key}>
@@ -298,12 +391,14 @@ export function Workbench() {
<button <button
type="button" type="button"
className="primary-button" className="primary-button"
disabled={busy}
onClick={applyTransform} onClick={applyTransform}
> >
Apply transform Apply transform
</button> </button>
<button <button
type="button" type="button"
disabled={busy}
onClick={() => { onClick={() => {
setModel(repairNormals(model)); setModel(repairNormals(model));
setStatus("Area-weighted vertex normals recalculated."); setStatus("Area-weighted vertex normals recalculated.");
@@ -311,6 +406,25 @@ export function Workbench() {
> >
Recalculate normals Recalculate normals
</button> </button>
<button
type="button"
disabled={busy || stats.degenerateTriangles === 0}
onClick={() => {
try {
const count = stats.degenerateTriangles;
setModel(removeDegenerateTriangles(model));
setStatus(
`${count.toLocaleString()} degenerate triangle(s) removed and normals recalculated.`,
);
} catch (caught) {
setStatus(
caught instanceof Error ? caught.message : String(caught),
);
}
}}
>
Remove degenerate triangles
</button>
</div> </div>
</section> </section>
@@ -324,15 +438,19 @@ export function Workbench() {
<p className="muted"> <p className="muted">
OBJ preserves the current positions, normals and triangle topology. OBJ preserves the current positions, normals and triangle topology.
Binary STL stores only triangles and face normals. Neither export 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.
</p> </p>
<div className="button-row"> <div className="button-row">
<button type="button" onClick={saveObj}> <button type="button" disabled={busy} onClick={saveObj}>
Download OBJ Download OBJ
</button> </button>
<button type="button" onClick={saveStl}> <button type="button" disabled={busy} onClick={saveStl}>
Download binary STL Download binary STL
</button> </button>
<button type="button" disabled={busy} onClick={savePly}>
Download ASCII PLY
</button>
</div> </div>
{model.warnings.length > 0 && ( {model.warnings.length > 0 && (
<> <>
@@ -360,6 +478,52 @@ export function Workbench() {
)} )}
</details> </details>
)} )}
{(model.materialDetails?.length ?? 0) > 0 && (
<div className="material-grid" aria-label="glTF PBR materials">
{model.materialDetails!.slice(0, 100).map((material, index) => (
<article key={`${material.name}-${index}`}>
<span
className="material-swatch"
style={{
backgroundColor: `rgb(${material.baseColorFactor
.slice(0, 3)
.map((value) => Math.round(value * 255))
.join(" ")} / ${material.baseColorFactor[3]})`,
}}
aria-hidden="true"
/>
<h3>{material.name}</h3>
<dl>
<div>
<dt>Metallic / roughness</dt>
<dd>
{material.metallicFactor} / {material.roughnessFactor}
</dd>
</div>
<div>
<dt>Alpha</dt>
<dd>
{material.alphaMode}
{material.alphaMode === "MASK"
? ` @ ${material.alphaCutoff}`
: ""}
</dd>
</div>
<div>
<dt>Faces</dt>
<dd>
{material.doubleSided ? "Double-sided" : "Culled"}
</dd>
</div>
<div>
<dt>Texture slots</dt>
<dd>{material.textures.join(", ") || "None"}</dd>
</div>
</dl>
</article>
))}
</div>
)}
</section> </section>
</section> </section>
</main> </main>
+83
View File
@@ -0,0 +1,83 @@
import { parseModel, type Model3D, type ModelFormat } from "./model";
export interface ModelWorkerJob {
promise: Promise<Model3D>;
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<Model3D>((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");
}
+566 -71
View File
@@ -15,11 +15,24 @@ export interface Model3D {
normals: Float32Array; normals: Float32Array;
indices: Uint32Array; indices: Uint32Array;
materials: string[]; materials: string[];
materialDetails?: MaterialInfo[];
nodes: string[]; nodes: string[];
warnings: string[]; warnings: string[];
sourceVertices: number; 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 { export interface ModelStats {
vertices: number; vertices: number;
triangles: number; triangles: number;
@@ -647,6 +660,8 @@ function plyRecordsToModel(
interface Gltf { interface Gltf {
asset?: { version?: unknown }; asset?: { version?: unknown };
scene?: unknown;
scenes?: Array<{ name?: unknown; nodes?: unknown }>;
buffers?: Array<{ uri?: unknown; byteLength?: unknown }>; buffers?: Array<{ uri?: unknown; byteLength?: unknown }>;
bufferViews?: Array<{ bufferViews?: Array<{
buffer?: unknown; buffer?: unknown;
@@ -672,10 +687,27 @@ interface Gltf {
mode?: unknown; 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<{ nodes?: Array<{
name?: unknown; name?: unknown;
mesh?: unknown; mesh?: unknown;
children?: unknown;
matrix?: unknown; matrix?: unknown;
translation?: unknown; translation?: unknown;
rotation?: unknown; rotation?: unknown;
@@ -683,6 +715,8 @@ interface Gltf {
}>; }>;
} }
type GltfNode = NonNullable<Gltf["nodes"]>[number];
function parseGltf(name: string, bytes: Uint8Array, binary: boolean): Model3D { function parseGltf(name: string, bytes: Uint8Array, binary: boolean): Model3D {
let jsonText: string; let jsonText: string;
let binaryChunk: Uint8Array | undefined; let binaryChunk: Uint8Array | undefined;
@@ -738,12 +772,17 @@ function parseGltf(name: string, bytes: Uint8Array, binary: boolean): Model3D {
); );
return decoded.subarray(0, declaredLength); return decoded.subarray(0, declaredLength);
}); });
const positions: number[] = []; interface PrimitiveGeometry {
const normals: number[] = []; positions: number[];
const indices: number[] = []; normals?: number[];
indices: number[];
material?: number;
}
const meshGeometry: PrimitiveGeometry[][] = [];
const warnings = new Set<string>(); const warnings = new Set<string>();
let completeNormals = true; let sourceVertices = 0;
for (const mesh of gltf.meshes ?? []) { for (const mesh of gltf.meshes ?? []) {
const primitives: PrimitiveGeometry[] = [];
for (const primitive of mesh.primitives ?? []) { for (const primitive of mesh.primitives ?? []) {
const mode = numberField(primitive.mode, 4, "primitive mode"); const mode = numberField(primitive.mode, 4, "primitive mode");
if (mode !== 4) { if (mode !== 4) {
@@ -762,26 +801,19 @@ function parseGltf(name: string, bytes: Uint8Array, binary: boolean): Model3D {
"VEC3", "VEC3",
); );
const vertexCount = positionValues.length / 3; const vertexCount = positionValues.length / 3;
if (positions.length / 3 + vertexCount > MAX_VERTICES) sourceVertices += vertexCount;
throw new RangeError("glTF exceeds 1,000,000 vertices."); if (sourceVertices > MAX_VERTICES)
const base = positions.length / 3; throw new RangeError(
positions.push(...positionValues); "glTF source geometry exceeds 1,000,000 vertices.",
const normalIndex = primitive.attributes?.NORMAL;
if (Number.isSafeInteger(normalIndex)) {
const normalValues = readAccessor(
gltf,
buffers,
Number(normalIndex),
"VEC3",
); );
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) if (normalValues.length !== positionValues.length)
throw new SyntaxError( throw new SyntaxError(
"glTF normal count differs from POSITION count.", "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) const localIndices = Number.isSafeInteger(primitive.indices)
? readIndices(gltf, buffers, Number(primitive.indices)) ? readIndices(gltf, buffers, Number(primitive.indices))
@@ -790,51 +822,435 @@ function parseGltf(name: string, bytes: Uint8Array, binary: boolean): Model3D {
throw new SyntaxError( throw new SyntaxError(
"glTF triangle index count is not divisible by three.", "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) { for (const index of localIndices) {
if (index < 0 || index >= vertexCount) if (index < 0 || index >= vertexCount)
throw new SyntaxError("glTF index is outside its POSITION accessor."); 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 ?? []; const nodeList = gltf.nodes ?? [];
if (nodeList.length > MAX_SCENE_NODES) if (nodeList.length > MAX_SCENE_NODES)
throw new RangeError("glTF scene exceeds 10,000 nodes."); throw new RangeError("glTF scene exceeds 10,000 nodes.");
if (
nodeList.some( const positions: number[] = [];
(node) => const normals: number[] = [];
node.matrix !== undefined || const indices: number[] = [];
node.translation !== undefined || const selectedNodes: string[] = [];
node.rotation !== undefined || const selectedMaterials = new Set<number>();
node.scale !== undefined, let completeNormals = true;
) const appendMesh = (meshIndex: number, matrix: number[]) => {
) const primitives = meshGeometry[meshIndex];
warnings.add( if (!primitives)
"Scene-node transforms are inspected but not baked into exported mesh geometry.", 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<number>();
const visited = new Set<number>();
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({ return finishModel({
name, name,
format: binary ? "GLB" : "glTF", format: binary ? "GLB" : "glTF",
positions: new Float32Array(positions), positions: new Float32Array(positions),
...(completeNormals ? { normals: new Float32Array(normals) } : {}), ...(completeNormals ? { normals: new Float32Array(normals) } : {}),
indices: new Uint32Array(indices), indices: new Uint32Array(indices),
materials: (gltf.materials ?? []).map((material, index) => materials: materialDetails.map((material) => material.name),
typeof material.name === "string" materialDetails,
? material.name nodes: selectedNodes,
: "Material " + (index + 1),
),
nodes: nodeList.map((node, index) =>
typeof node.name === "string" ? node.name : "Node " + (index + 1),
),
warnings: [...warnings], warnings: [...warnings],
sourceVertices: positions.length / 3, sourceVertices,
}); });
} }
function gltfMaterial(
material: NonNullable<Gltf["materials"]>[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<number>(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 } { function parseGlb(bytes: Uint8Array): { json: Uint8Array; bin?: Uint8Array } {
if (bytes.length < 20) throw new SyntaxError("GLB header is truncated."); if (bytes.length < 20) throw new SyntaxError("GLB header is truncated.");
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
@@ -1031,13 +1447,16 @@ export function transformModel(
transform: { transform: {
translate: [number, number, number]; translate: [number, number, number];
rotate: [number, number, number]; rotate: [number, number, number];
scale: number; scale: number | [number, number, number];
}, },
): Model3D { ): 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 ( if (
values.some((value) => !Number.isFinite(value)) || values.some((value) => !Number.isFinite(value)) ||
Math.abs(transform.scale) < 1e-9 scale.some((value) => Math.abs(value) < 1e-9)
) )
throw new TypeError( throw new TypeError(
"Transform values must be finite and scale must be non-zero.", "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); const normals = new Float32Array(model.normals.length);
for (let index = 0; index < model.positions.length; index += 3) { for (let index = 0; index < model.positions.length; index += 3) {
const point = multiply3(rotation, [ const point = multiply3(rotation, [
(model.positions[index] ?? 0) * transform.scale, (model.positions[index] ?? 0) * scale[0]!,
(model.positions[index + 1] ?? 0) * transform.scale, (model.positions[index + 1] ?? 0) * scale[1]!,
(model.positions[index + 2] ?? 0) * transform.scale, (model.positions[index + 2] ?? 0) * scale[2]!,
]); ]);
positions[index] = point[0] + transform.translate[0]; positions[index] = point[0] + transform.translate[0];
positions[index + 1] = point[1] + transform.translate[1]; positions[index + 1] = point[1] + transform.translate[1];
positions[index + 2] = point[2] + transform.translate[2]; positions[index + 2] = point[2] + transform.translate[2];
const normal = multiply3(rotation, [ const normal = multiply3(rotation, [
model.normals[index] ?? 0, (model.normals[index] ?? 0) / scale[0]!,
model.normals[index + 1] ?? 0, (model.normals[index + 1] ?? 0) / scale[1]!,
model.normals[index + 2] ?? 0, (model.normals[index + 2] ?? 0) / scale[2]!,
]); ]);
const length = Math.hypot(...normal) || 1; const length = Math.hypot(...normal) || 1;
const orientation = transform.scale < 0 ? -1 : 1; normals[index] = normal[0] / length;
normals[index] = (normal[0] / length) * orientation; normals[index + 1] = normal[1] / length;
normals[index + 1] = (normal[1] / length) * orientation; normals[index + 2] = normal[2] / length;
normals[index + 2] = (normal[2] / length) * orientation;
} }
const indices = const mirrored = scale[0]! * scale[1]! * scale[2]! < 0;
transform.scale < 0 const indices = mirrored
? Uint32Array.from(model.indices, (_, index) => { ? Uint32Array.from(model.indices, (_, index) => {
const within = index % 3; const within = index % 3;
return ( return (
model.indices[ model.indices[
within === 1 ? index + 1 : within === 2 ? index - 1 : index within === 1 ? index + 1 : within === 2 ? index - 1 : index
] ?? 0 ] ?? 0
); );
}) })
: new Uint32Array(model.indices); : new Uint32Array(model.indices);
return { ...model, positions, normals, 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[] { function rotationMatrix(rotation: [number, number, number]): number[] {
const x = (rotation[0] * Math.PI) / 180; const x = (rotation[0] * Math.PI) / 180;
const y = (rotation[1] * 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 { export function exportObj(model: Model3D): string {
const lines = [ const lines = [
"# Exported by 3D Tools 0.1.0", "# Exported by 3D Tools 0.2.0",
"o " + safeObjName(model.name), "o " + safeObjName(model.name),
]; ];
for (let index = 0; index < model.positions.length; index += 3) 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"; 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 { function safeObjName(value: string): string {
return value.replace(/[\r\n\0]/gu, " ").slice(0, 200) || "model"; 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 triangles = model.indices.length / 3;
const output = new Uint8Array(84 + triangles * 50); const output = new Uint8Array(84 + triangles * 50);
const header = new TextEncoder().encode( 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); output.set(header.subarray(0, 80), 0);
const view = new DataView(output.buffer); const view = new DataView(output.buffer);
+39
View File
@@ -0,0 +1,39 @@
/// <reference lib="webworker" />
import { parseModel, type ModelFormat } from "./model";
interface Request {
id: number;
name: string;
bytes: ArrayBuffer;
format: ModelFormat;
}
declare const self: DedicatedWorkerGlobalScope;
self.onmessage = (event: MessageEvent<Request>) => {
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 {};
+52
View File
@@ -133,6 +133,58 @@ details {
border-radius: 0.62rem; 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 { .workbench label {
display: grid; display: grid;
gap: 0.34rem; gap: 0.34rem;
+21 -3
View File
@@ -3,12 +3,12 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.3d-tools", "id": "de.add-ideas.3d-tools",
"name": "3D Tools", "name": "3D Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Inspect and repair 3D meshes locally.", "description": "Inspect, transform, repair and export 3D meshes locally.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
"categories": ["media", "developer", "design"], "categories": ["media", "developer", "design"],
"tags": ["3d", "obj", "stl", "ply", "gltf", "webgl"], "tags": ["3d", "obj", "stl", "ply", "gltf", "pbr", "webgl"],
"integration": { "integration": {
"contextVersion": 1, "contextVersion": 1,
"launchModes": ["navigate", "new-tab"], "launchModes": ["navigate", "new-tab"],
@@ -21,6 +21,24 @@
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": 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": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": true, "fileUploads": true,
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0"; export const APP_VERSION = "0.2.0";
+57
View File
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { Buffer } from "node:buffer";
for (const path of ["/", "/deep/nested/3d/"]) { for (const path of ["/", "/deep/nested/3d/"]) {
test("inspects and transforms locally at " + path, async ({ page }) => { 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 ({ test("keeps the installed application available offline", async ({
page, page,
context, context,
@@ -40,3 +57,43 @@ test("keeps the installed application available offline", async ({
await context.setOffline(false); 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));
}
+18
View File
@@ -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);
});
+98 -1
View File
@@ -2,9 +2,11 @@ import { bytesToBase64 } from "@add-ideas/toolbox-helpers";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
exportBinaryStl, exportBinaryStl,
exportAsciiPly,
exportObj, exportObj,
modelStats, modelStats,
parseModel, parseModel,
removeDegenerateTriangles,
transformModel, transformModel,
} from "../../src/core/model"; } from "../../src/core/model";
@@ -115,11 +117,76 @@ describe("bounded 3D parsing", () => {
); );
expect(model.indices).toEqual(Uint32Array.of(0, 1, 2)); expect(model.indices).toEqual(Uint32Array.of(0, 1, 2));
expect(model.materials).toEqual(["Local"]); 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); const glb = makeGlb(gltfDocument(undefined, buffer.length), buffer);
expect(parseModel("triangle.glb", glb, "GLB").indices).toHaveLength(3); 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", () => { it("rejects remote glTF buffers and safely transforms normals/winding", () => {
const remote = gltfDocument("https://example.invalid/model.bin", 42); const remote = gltfDocument("https://example.invalid/model.bin", 42);
expect(() => expect(() =>
@@ -137,6 +204,27 @@ describe("bounded 3D parsing", () => {
}); });
expect(modelStats(transformed).surfaceArea).toBeCloseTo(2); expect(modelStats(transformed).surfaceArea).toBeCloseTo(2);
expect(transformed.indices).toEqual(Uint32Array.of(0, 2, 1)); 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: 0, componentType: 5126, count: 3, type: "VEC3" },
{ bufferView: 1, componentType: 5123, count: 3, type: "SCALAR" }, { 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: [ meshes: [
{ {
primitives: [{ attributes: { POSITION: 0 }, indices: 1, material: 0 }], primitives: [{ attributes: { POSITION: 0 }, indices: 1, material: 0 }],
+1 -1
View File
@@ -4,7 +4,7 @@
"target": "ES2023", "target": "ES2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"], "lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"types": ["vite/client"], "types": ["vite/client", "node"],
"allowArbitraryExtensions": true, "allowArbitraryExtensions": true,
"skipLibCheck": true, "skipLibCheck": true,
"moduleResolution": "Bundler", "moduleResolution": "Bundler",