Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f838480278 |
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+12
-2
@@ -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.
|
||||
|
||||
@@ -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/`.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
+16
-1
@@ -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.
|
||||
|
||||
|
||||
Generated
+20
-20
@@ -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"
|
||||
|
||||
+5
-5
@@ -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",
|
||||
|
||||
+20
-2
@@ -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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
+14
-5
@@ -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
|
||||
|
||||
|
||||
+12
-2
@@ -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.
|
||||
|
||||
+2
-2
@@ -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/`.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+18
-3
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
const parseJob = useRef<ModelWorkerJob | undefined>(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<void> {
|
||||
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) {
|
||||
if (
|
||||
parseRevision.current === revision &&
|
||||
!(caught instanceof DOMException && caught.name === "AbortError")
|
||||
)
|
||||
setStatus(caught instanceof Error ? caught.message : String(caught));
|
||||
} finally {
|
||||
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 (
|
||||
<main className="workbench">
|
||||
<section className="hero">
|
||||
@@ -176,10 +254,11 @@ export function Workbench() {
|
||||
disabled={busy}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>
|
||||
{busy ? "Parsing…" : "Choose local file"}
|
||||
Choose local file
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setModel(EXAMPLE);
|
||||
setStatus("Built-in tetrahedron example restored.");
|
||||
@@ -187,10 +266,16 @@ export function Workbench() {
|
||||
>
|
||||
Restore example
|
||||
</button>
|
||||
{busy ? (
|
||||
<button type="button" onClick={() => cancelParse()}>
|
||||
Cancel parsing
|
||||
</button>
|
||||
) : null}
|
||||
<input
|
||||
ref={fileInput}
|
||||
className="sr-only"
|
||||
type="file"
|
||||
aria-label="Open 3D model file"
|
||||
accept=".obj,.stl,.ply,.gltf,.glb,model/*"
|
||||
onChange={(event) => void openFile(event.target.files?.[0])}
|
||||
/>
|
||||
@@ -199,6 +284,11 @@ export function Workbench() {
|
||||
<p className="status" role="status" aria-live="polite">
|
||||
{status}
|
||||
</p>
|
||||
{progress ? (
|
||||
<label>
|
||||
{progress.stage} <progress value={progress.value} max="1" />
|
||||
</label>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="viewer-grid">
|
||||
@@ -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]) => (
|
||||
<label key={key}>
|
||||
@@ -298,12 +391,14 @@ export function Workbench() {
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
disabled={busy}
|
||||
onClick={applyTransform}
|
||||
>
|
||||
Apply transform
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setModel(repairNormals(model));
|
||||
setStatus("Area-weighted vertex normals recalculated.");
|
||||
@@ -311,6 +406,25 @@ export function Workbench() {
|
||||
>
|
||||
Recalculate normals
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@@ -324,15 +438,19 @@ export function Workbench() {
|
||||
<p className="muted">
|
||||
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.
|
||||
</p>
|
||||
<div className="button-row">
|
||||
<button type="button" onClick={saveObj}>
|
||||
<button type="button" disabled={busy} onClick={saveObj}>
|
||||
Download OBJ
|
||||
</button>
|
||||
<button type="button" onClick={saveStl}>
|
||||
<button type="button" disabled={busy} onClick={saveStl}>
|
||||
Download binary STL
|
||||
</button>
|
||||
<button type="button" disabled={busy} onClick={savePly}>
|
||||
Download ASCII PLY
|
||||
</button>
|
||||
</div>
|
||||
{model.warnings.length > 0 && (
|
||||
<>
|
||||
@@ -360,6 +478,52 @@ export function Workbench() {
|
||||
)}
|
||||
</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>
|
||||
</main>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
+557
-62
@@ -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<Gltf["nodes"]>[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<string>();
|
||||
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<number>();
|
||||
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<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({
|
||||
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<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 } {
|
||||
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,26 +1470,25 @@ 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
|
||||
const mirrored = scale[0]! * scale[1]! * scale[2]! < 0;
|
||||
const indices = mirrored
|
||||
? Uint32Array.from(model.indices, (_, index) => {
|
||||
const within = index % 3;
|
||||
return (
|
||||
@@ -1083,6 +1501,44 @@ export function transformModel(
|
||||
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);
|
||||
|
||||
@@ -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 {};
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
export const APP_VERSION = "0.2.0";
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 }],
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user