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
|
# 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.
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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/`.
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
Generated
+20
-20
@@ -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
@@ -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
@@ -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"] },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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/`.
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
if (
|
||||||
|
parseRevision.current === revision &&
|
||||||
|
!(caught instanceof DOMException && caught.name === "AbortError")
|
||||||
|
)
|
||||||
setStatus(caught instanceof Error ? caught.message : String(caught));
|
setStatus(caught instanceof Error ? caught.message : String(caught));
|
||||||
} finally {
|
} finally {
|
||||||
|
if (parseRevision.current === revision) {
|
||||||
|
parseJob.current = undefined;
|
||||||
setBusy(false);
|
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>
|
||||||
|
|||||||
@@ -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;
|
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,26 +1470,25 @@ 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 (
|
||||||
@@ -1083,6 +1501,44 @@ export function transformModel(
|
|||||||
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);
|
||||||
|
|||||||
@@ -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;
|
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;
|
||||||
|
|||||||
@@ -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
@@ -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 { 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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { 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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user