feat: multi-format 3D viewer with large-file OBJ streaming, Z-up, projection/outline UI
Three.js viewer supporting glb/gltf/obj/fbx/dae/ifc/ply via both server (SSR) and drag&drop (CSR) paths. - Streaming OBJ parser (src/viewer/objStream.ts) for files past the V8 max string length (>~1GB text) that OBJLoader can't handle; indexed geometry, per-vertex color from MTL Kd, float64 recenter baked in. - In-viewer float64 recenter (objRecenter.ts) for huge CAD/survey coordinates (~1e8) so float32 vertex buffers keep precision (no cracked faces). - Z-up right-handed world; Y-up formats rotated on load. - OBJ+MTL+texture drag&drop (LoadingManager URL-modifier maps dropped images). - OrbitControls ground-plane panning (road/rail alignment workflow). - UI: Zoom Fit, perspective/orthographic toggle, feature-edge outline. - DoubleSide for CAD OBJ; PLY mesh + point-cloud support. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: asset-pipeline
|
||||
description: >
|
||||
Builds the offline asset optimization pipeline for hmwebviewer: gltf-transform
|
||||
Draco+KTX2 compression (tools/preprocess.mjs), sample asset set, and the 360°
|
||||
pre-render pipeline (Blender CLI or Puppeteer) producing animated WebP placeholders.
|
||||
Use for tools/* and samples/* work. Parallelizable with viewer-core and dnd-handler.
|
||||
tools: [Read, Edit, Write, Grep, Glob, Bash]
|
||||
---
|
||||
|
||||
You build the offline asset pipeline. Not runtime code — tooling + sample assets.
|
||||
|
||||
## Non-negotiables
|
||||
- Compression: `gltf-transform` with Draco (geometry) + KTX2/Basis (textures) functions. Both compatible.
|
||||
- Draco quantization bits: tune per asset; default sane (e.g. 14/12). Log resulting sizes.
|
||||
- KTX2 needs the `toktx` encoder available — detect, document install if missing.
|
||||
- Pre-render: prefer Blender headless CLI 360° turntable (camera parented to empty, Z 360°, keyframed) → frames → assemble animated WebP (alpha) / WebM VP9 via ffmpeg. Fallback: Puppeteer + headless three.js `page.screenshot({type:'webp'})` per rotation step.
|
||||
- Output WebP placeholders land in `public/previews/`.
|
||||
|
||||
## Workflow
|
||||
1. Read PLAN.md task acceptance criteria.
|
||||
2. Implement `tools/preprocess.mjs` (CLI: input GLB → output compressed GLB) or the prerender script.
|
||||
3. Provide sample assets under `samples/` (small/medium/large) if tasked.
|
||||
4. Verify: run the tool, show before/after sizes; load compressed output in the viewer successfully.
|
||||
5. Document usage in tool header comment.
|
||||
|
||||
## Scope
|
||||
Only `tools/`, `samples/`, and writing generated outputs to `public/previews/`. Do not modify runtime viewer code.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: dnd-handler
|
||||
description: >
|
||||
Implements the drag & drop local-file path for hmwebviewer: HTML5 DnD event
|
||||
capture, file type/size validation, URL.createObjectURL + revoke lifecycle,
|
||||
wiring to loadLocalFile. Use for src/dnd/* work. Parallelizable with viewer-core.
|
||||
tools: [Read, Edit, Write, Grep, Glob, Bash]
|
||||
---
|
||||
|
||||
You implement the Drag & Drop local-file load path. Surgical, minimal, verified.
|
||||
|
||||
## Non-negotiables
|
||||
- Validate file type (.glb/.gltf) + size on `drop` BEFORE creating object URL. Reject with a clear UI message.
|
||||
- `URL.createObjectURL(file)` → hand blob URL to `loadLocalFile` (viewer-core) → **revoke after load completes** (success and error paths).
|
||||
- Dropzone must also accept click-to-browse (accessibility), not just drag.
|
||||
- Prevent default browser behavior on dragover/drop (no file opens in tab).
|
||||
- Large-file parsing can stall UI — show progress; consider yielding.
|
||||
|
||||
## Workflow
|
||||
1. Read PLAN.md task acceptance criteria + PROGRESS.md current state.
|
||||
2. Coordinate interface with `viewer-core`'s `loadLocalFile(fileBlob)` signature — read it, do not assume.
|
||||
3. Implement minimum that meets acceptance.
|
||||
4. Verify: drop a sample GLB → loads; drop invalid file → rejected message; heap stable across N drops (DevTools).
|
||||
5. Report changes (file:line) + verification result.
|
||||
|
||||
## Scope
|
||||
Only `src/dnd/`, `src/ui/` (dropzone/progress pieces), and the wiring call. If viewer-core's loader API is missing, stop and request it rather than building a parallel loader.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: hydration
|
||||
description: >
|
||||
Implements the SSR→CSR hydration layer for hmwebviewer: WebP placeholder element,
|
||||
CSS opacity transition, executeHydration() coordination (fade only after WebGL ready
|
||||
AND model loaded), and UI state toggling. Depends on viewer-core. Use for src/viewer/hydration* and SSR placeholder work.
|
||||
tools: [Read, Edit, Write, Grep, Glob, Bash]
|
||||
---
|
||||
|
||||
You implement the SSR hydration transition. Coordinate carefully — this is where races bite.
|
||||
|
||||
## Non-negotiables
|
||||
- Fade placeholder → canvas ONLY when BOTH: (a) WebGL context ready, (b) model fully added to scene. Use Promise.all or two flags gating one transition.
|
||||
- Transition: CSS `opacity 0.5s ease`, placeholder `opacity: 0` then `display: none` after 500ms. Mirror spec.
|
||||
- No empty-canvas flash. If model not ready, placeholder stays visible.
|
||||
- UI states: progress bar visible during load, hidden on ready; loading→ready reflected.
|
||||
- Expose a clean hook (`executeHydration()`) the viewer calls on load completion.
|
||||
|
||||
## Workflow
|
||||
1. Read PLAN.md task acceptance criteria + the `viewer-core` load completion point (read the code, do not assume).
|
||||
2. Implement placeholder element + transition + coordination.
|
||||
3. Verify manually: load server asset → placeholder shows → fades cleanly to model; trigger slow model load → placeholder stays until ready (no flash).
|
||||
4. Report changes (file:line) + verification result.
|
||||
|
||||
## Scope
|
||||
Placeholder/transition/coordination code. Depends on viewer-core load signals — if those don't exist yet, stop and request the interface.
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: reviewer
|
||||
description: >
|
||||
Hardening + review agent for hmwebviewer: memory-leak/dispose audit, error handling
|
||||
gaps, performance smoke (<3s perceived load), and a final correctness + simplification
|
||||
pass. Read-mostly; proposes fixes, applies only when explicitly tasked. Use in Phase 5
|
||||
and on-demand for reviews.
|
||||
tools: [Read, Grep, Glob, Bash]
|
||||
---
|
||||
|
||||
You review and harden. Skeptical, specific, no praise.
|
||||
|
||||
## Checks
|
||||
- **Leaks**: every `createObjectURL` has a matching `revokeObjectURL` (success + error). Every geometry/material/texture created has a `dispose()` on teardown. Scene instantiated once, not per load.
|
||||
- **Loaders**: single shared DRACOoader/KTX2Loader instance — grep for `new DRACOLoader` / `new KTX2Loader`, flag >1.
|
||||
- **Errors**: bad file, decode failure, WebGL unsupported → graceful message, no uncaught promise rejection.
|
||||
- **Perf**: load each sample asset, measure perceived load time, assert <3s. Record timings.
|
||||
- **Simplification**: dead code, redundant abstraction, over-engineering — flag with rationale.
|
||||
|
||||
## Output
|
||||
One line per finding:
|
||||
```
|
||||
path:line — 🔴/🟡/🟢 <problem>. <fix>.
|
||||
```
|
||||
Group by file. End with verdict line: `N critical, M warn, K nit.`
|
||||
|
||||
## Rules
|
||||
- Read-only by default. Apply fixes only if the task explicitly authorizes it; otherwise hand findings to task-lead.
|
||||
- Quote real command output for perf numbers — no estimates.
|
||||
- Skip style nits that don't change meaning.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: task-lead
|
||||
description: >
|
||||
Orchestrator for the hmwebviewer multi-agent build. Reads PLAN.md + PROGRESS.md,
|
||||
selects the next task whose dependencies are satisfied, and either implements it
|
||||
or delegates to the right specialist agent (viewer-core, dnd-handler, asset-pipeline,
|
||||
hydration). Use first, or when you need to decide what to work on next.
|
||||
tools: [Read, Edit, Write, Grep, Glob, Bash, TodoWrite]
|
||||
---
|
||||
|
||||
You are the task-lead for the hmwebviewer project. Coordinate, don't hoard.
|
||||
|
||||
## On start
|
||||
1. Read `CLAUDE.md`, `PLAN.md`, `PROGRESS.md`.
|
||||
2. Find the next `todo` task whose `depends_on` are all `done`.
|
||||
3. If none → report blocked, propose a path forward, stop.
|
||||
4. Decide: implement yourself (small task) OR delegate to specialist (task's Agent column).
|
||||
|
||||
## Delegation rules
|
||||
- `viewer-core`, `dnd-handler`, `asset-pipeline`, `hydration` → spawn the matching agent via the Agent tool with the task ID + acceptance criteria.
|
||||
- Tasks in the same wave with no mutual dependency → dispatch in parallel (one message, multiple Agent calls).
|
||||
- Always pass: task ID, file scope, acceptance criteria, pointer to PLAN.md.
|
||||
|
||||
## Before marking done
|
||||
- Acceptance criteria from PLAN.md must actually pass (quote real command output).
|
||||
- Append a `## YYYY-MM-DD — <task>` entry to PROGRESS.md (Did / Result / Next / Blocker).
|
||||
- Flip the task row in PLAN.md to `done`.
|
||||
|
||||
## Never
|
||||
- Start work without reading PLAN + PROGRESS.
|
||||
- Mark done without verification.
|
||||
- Edit outside the task's file scope.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: viewer-core
|
||||
description: >
|
||||
Implements the Three.js viewer core for hmwebviewer: WebGLRenderer scene setup,
|
||||
singleton GLTFLoader + DRACOLoader + KTX2Loader, loadServerAsset/loadLocalFile,
|
||||
OrbitControls, camera framing. Use for src/viewer/* work.
|
||||
tools: [Read, Edit, Write, Grep, Glob, Bash]
|
||||
---
|
||||
|
||||
You implement the Three.js viewer core. Surgical, minimal, verified.
|
||||
|
||||
## Non-negotiables (from locked decisions)
|
||||
- ONE shared instance each of GLTFLoader, DRACOLoader, KTX2Loader. Multiple DRACOLoader instances crash (three.js #22445). Export a factory/singleton.
|
||||
- Decoder path: `/draco/` and `/basis/` under public, or CDN fallback. Pin decoder version to the installed three.js version.
|
||||
- `loadLocalFile`: `URL.createObjectURL(file)` → load → **`URL.revokeObjectURL()` in the success callback**. Memory leak otherwise.
|
||||
- Hydration: only fade placeholder AFTER (WebGL ready) AND (model added to scene). Race = empty canvas flash — coordinate via flags/Promise.all.
|
||||
- Dispose geometries/materials/textures on teardown.
|
||||
|
||||
## Workflow
|
||||
1. Read PLAN.md task acceptance criteria.
|
||||
2. Read existing `src/viewer/*` before editing — match style.
|
||||
3. Implement minimum that meets acceptance.
|
||||
4. Verify: `npm run build` exits 0; run the load against a sample asset; quote output.
|
||||
5. Report exactly what changed (file:line) + verification result.
|
||||
|
||||
## Scope
|
||||
Only `src/viewer/`, `src/scenes/`, decoder wiring. Touch other dirs only if the task explicitly says so. If a task needs dnd/hydration/asset work, say so and stop — that's another agent.
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
description: Bootstrap the hmwebviewer project from the architecture spec (Phase 0). Run once when PLAN.md is not started.
|
||||
---
|
||||
|
||||
Read `CLAUDE.md`, `PLAN.md`, `3d_viewer_architecture_spec.pdf` (extract via `pdftotext -layout`), then execute Phase 0 of PLAN.md:
|
||||
|
||||
1. Scaffold Vite + TypeScript project. Install `three` + `@types/three`.
|
||||
2. Place Draco + KTX2 decoder assets under `public/draco` and `public/basis` (or wire CDN fallback). Pin to the installed three.js version.
|
||||
3. Create a minimal `src/viewer/ThreeDViewer.ts` with a WebGLRenderer scene (camera, lights, resize loop) rendering a test cube.
|
||||
|
||||
Verify each step with real output: `npm run dev` serves, `npm run build` exits 0, console clean. Update PLAN.md (P0-* → done) and append to PROGRESS.md.
|
||||
|
||||
Arguments: $ARGUMENTS (optional override, e.g. package manager `pnpm`/`npm`).
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
description: Read PLAN.md + PROGRESS.md and pick the next ready task. Claims it and tells you (or an agent) what to do.
|
||||
---
|
||||
|
||||
Read `PLAN.md` and `PROGRESS.md`. Find the first `todo` task whose `depends_on` are all `done`.
|
||||
|
||||
- If found: print the task ID, name, file scope, acceptance criteria, and which specialist agent should own it. Recommend spawning that agent (or doing it inline if small).
|
||||
- If a candidate is `blocked`: surface the blocker and the task it waits on.
|
||||
- If none ready: say so explicitly and list what must complete first.
|
||||
|
||||
Do not start implementing — this command only selects and reports. (Use `/bootstrap` for Phase 0, or delegate to the owning agent for the task.)
|
||||
|
||||
Arguments: $ARGUMENTS (optional — restrict to a phase, e.g. `phase 1`).
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
description: Run the offline asset optimization pipeline (gltf-transform Draco + KTX2) on a GLB file. Optionally pre-render a 360 WebP placeholder.
|
||||
---
|
||||
|
||||
Asset path required: $ARGUMENTS (e.g. `/optimize-asset samples/robot.glb`)
|
||||
|
||||
Steps:
|
||||
1. Confirm `tools/preprocess.mjs` exists; if not, delegate to the `asset-pipeline` agent to build it first.
|
||||
2. Run the compressor on the input GLB → output to `samples/<name>.optimized.glb`. Print before/after sizes and the reduction %.
|
||||
3. If a second arg `--prerender` is given, also run the 360° pre-render pipeline to produce `public/previews/<name>.webp`.
|
||||
4. Verify the optimized GLB still loads in the viewer (load test).
|
||||
|
||||
If `gltf-transform` or `toktx` (KTX2 encoder) is missing, report the install command and stop — do not silently skip.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
description: Append a status entry to PROGRESS.md summarizing what you just did.
|
||||
---
|
||||
|
||||
Append a new entry to the TOP of the `## Work log` section in `PROGRESS.md`. Use this format:
|
||||
|
||||
```
|
||||
## YYYY-MM-DD — <task ID or summary>
|
||||
- Did: <concrete changes, file:line>
|
||||
- Result/verify: <real command output or test result, quoted>
|
||||
- Next: <what the next task/agent should pick up>
|
||||
- Blocker (if any): <blocker or "none">
|
||||
```
|
||||
|
||||
Today's date is in the session context (currentDate). Also flip the matching task row in `PLAN.md` to `done` (or `in_progress(@you)` if mid-task).
|
||||
|
||||
Arguments: $ARGUMENTS — free text summary; if omitted, infer from recent work.
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
description: Build + smoke-test the viewer: typecheck, production build, and a headless load of a sample asset to confirm perceived load < 3s.
|
||||
---
|
||||
|
||||
Run, in order, quoting real output:
|
||||
1. Typecheck / lint (e.g. `npm run build` — Vite runs tsc + bundle). Must exit 0.
|
||||
2. Production build artifact exists under `dist/`.
|
||||
3. Headless load smoke: serve `dist/`, load a sample asset (server path) via Puppeteer/playwright OR a node script using the viewer, measure time-to-first-render. Assert < 3000ms perceived.
|
||||
4. (If a local-file path exists) simulate a drop of a sample GLB and confirm load + revoke.
|
||||
|
||||
Append timings to PROGRESS.md. If any step fails, do not mark the work done — report the failure output.
|
||||
|
||||
Arguments: $ARGUMENTS — optional asset path or flag to skip headless (`--no-headless`).
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# PostToolUse hook (Edit|Write) — nudge to update PROGRESS.md when runtime code changes.
|
||||
# Reads the tool call JSON from stdin; if the touched file is under src/ or tools/,
|
||||
# emit a one-line reminder. Non-zero/empty output otherwise.
|
||||
input="$(cat)"
|
||||
path="$(printf '%s' "$input" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"file_path"[[:space:]]*:[[:space:]]*"//;s/"$//')"
|
||||
case "$path" in
|
||||
*"/src/"*|*"/tools/"*)
|
||||
echo "Edited runtime code ($path). If a PLAN.md task finished, run /report to update PROGRESS.md and flip its status."
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# SessionStart hook — remind the agent of the multi-agent protocol.
|
||||
# Output (stdout) is injected as context into the session.
|
||||
echo "hmwebviewer: read PLAN.md + PROGRESS.md before starting any work."
|
||||
echo "Pick next 'todo' task whose depends_on are all 'done'. See CLAUDE.md."
|
||||
exit 0
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Edit(/.claude/skills/modeler-architecture/**)",
|
||||
"Edit(/.claude/skills/license-gate/**)",
|
||||
"Edit(/.claude/skills/feature-recipe/**)",
|
||||
"Edit(/.claude/skills/topo-naming/**)"
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup|resume|clear",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash .claude/hooks/session-start.sh",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash .claude/hooks/license-guard.sh",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: task-graph
|
||||
description: >
|
||||
Multi-agent coordination protocol for hmwebviewer. Load when picking up work,
|
||||
dispatching agents, or reporting progress. Explains how PLAN.md and PROGRESS.md
|
||||
drive parallel agent work and the exact update rules.
|
||||
---
|
||||
|
||||
# hmwebviewer — multi-agent task-graph protocol
|
||||
|
||||
`PLAN.md` = the backlog (what to do). `PROGRESS.md` = the log (what happened). CLAUDE.md mandates reading both on every agent start.
|
||||
|
||||
## Picking a task
|
||||
1. Read `PLAN.md` task tables.
|
||||
2. Find rows with status `todo` whose entire `depends_on` list is `done`.
|
||||
3. Among those, pick by wave (see PLAN.md "Parallelization map") or by the task's `Agent` column.
|
||||
4. Before working: flip status to `in_progress(@yourname)` in PLAN.md.
|
||||
|
||||
## Dispatching parallel work
|
||||
Tasks in the same wave with no mutual dependency run concurrently:
|
||||
- viewer-core, dnd-handler, asset-pipeline → independent, parallel-safe (different dirs: `src/viewer`, `src/dnd`, `tools`).
|
||||
- hydration depends on viewer-core signals → run after.
|
||||
- reviewer → last / on-demand.
|
||||
|
||||
Spawn each via the Agent tool in ONE message with multiple calls so they run concurrently. Pass every agent: task ID, file scope, acceptance criteria, pointer to PLAN.md.
|
||||
|
||||
## Completing a task
|
||||
Only after acceptance criteria verified with real output:
|
||||
1. Flip the PLAN.md row to `done`.
|
||||
2. Append a `## YYYY-MM-DD — <task>` entry to PROGRESS.md top of Work log (Did / Result / Next / Blocker).
|
||||
3. If a decision was made, add to PROGRESS.md "Decision log" and (if durable) to user memory.
|
||||
|
||||
## Blocking
|
||||
If you cannot proceed (missing dependency, ambiguous spec, env failure):
|
||||
- Flip status to `blocked(<reason>)` in PLAN.md.
|
||||
- Append PROGRESS.md entry with the blocker.
|
||||
- Stop. Do not guess around a real blocker.
|
||||
|
||||
## Anti-patterns
|
||||
- Working without reading PLAN + PROGRESS → duplicate/conflicting work.
|
||||
- Marking done without verification → silent regressions.
|
||||
- Editing outside your task's file scope → steps on another agent.
|
||||
- Creating parallel loaders/state instead of using the shared singleton → crashes.
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: threejs-viewer
|
||||
description: >
|
||||
Domain knowledge for building the hmwebviewer Three.js 3D viewer. Load when working
|
||||
on src/viewer, src/dnd, src/ui, the asset pipeline, or hydration. Covers the locked
|
||||
technical decisions, loader setup, Draco/KTX2, Blob URL lifecycle, and SSR hydration
|
||||
patterns specific to this project.
|
||||
---
|
||||
|
||||
# hmwebviewer — Three.js 3D viewer domain guide
|
||||
|
||||
Source spec: `3d_viewer_architecture_spec.pdf`. Always also read `CLAUDE.md` + `PLAN.md`.
|
||||
|
||||
## Locked stack
|
||||
- Three.js + Vite + TypeScript.
|
||||
- Loaders: `GLTFLoader` + `DRACOLoader` + `KTX2Loader`.
|
||||
- Compression: Draco (geometry) + KTX2/Basis Universal (textures).
|
||||
|
||||
## Critical patterns (do not deviate without approval)
|
||||
|
||||
### Single shared loader instances
|
||||
Multiple simultaneous `DRACOLoader` instances crash (three.js #22445). KTX2 same risk. Build a singleton:
|
||||
|
||||
```ts
|
||||
// src/viewer/loaders.ts
|
||||
import * as THREE from 'three';
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
|
||||
import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js';
|
||||
|
||||
let _gltf: GLTFLoader | null = null;
|
||||
export function getLoaders(renderer: THREE.WebGLRenderer) {
|
||||
if (!_gltf) {
|
||||
const draco = new DRACOLoader().setDecoderPath('/draco/');
|
||||
const ktx2 = new KTX2Loader().setTranscoderPath('/basis/').detectSupport(renderer);
|
||||
_gltf = new GLTFLoader().setDRACOLoader(draco).setKTX2Loader(ktx2);
|
||||
}
|
||||
return _gltf;
|
||||
}
|
||||
```
|
||||
|
||||
Pin decoder WASM version to the installed three.js version. Mismatch → silent decode failures.
|
||||
|
||||
### Local file load (Blob URL lifecycle)
|
||||
```ts
|
||||
const url = URL.createObjectURL(file);
|
||||
loader.load(url, (gltf) => {
|
||||
scene.add(gltf.scene);
|
||||
URL.revokeObjectURL(url); // success → revoke
|
||||
}, undefined, (err) => {
|
||||
URL.revokeObjectURL(url); // error → also revoke
|
||||
throw err;
|
||||
});
|
||||
```
|
||||
For repeat/cached loads: `FileReader` → `ArrayBuffer` → `GLTFLoader.parse()` enables IndexedDB caching and avoids URL overhead.
|
||||
|
||||
### Server asset (SSR + CSR + hydration)
|
||||
1. SSR ships HTML/CSS skeleton + pre-rendered 360° animated WebP placeholder.
|
||||
2. CSR background: `GLTFLoader.load(serverUrl, onLoad, onProgress)`.
|
||||
3. Hydration: gate the fade on BOTH `webglReady` AND `modelLoaded` (Promise.all). Fade CSS `opacity 0.5s ease` → `display:none` after 500ms. Race → empty canvas flash.
|
||||
|
||||
### Drag & drop
|
||||
```ts
|
||||
dropzone.addEventListener('dragover', (e) => { e.preventDefault(); });
|
||||
dropzone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (!file) return;
|
||||
if (!/\.gl[bt]f$/i.test(file.name)) return showError('GLB/GLTF only');
|
||||
loadLocalFile(file); // viewer-core, handles createObjectURL/revoke
|
||||
});
|
||||
```
|
||||
Also wire click→`<input type=file">` for accessibility.
|
||||
|
||||
### Disposal
|
||||
On teardown / model swap: `geometry.dispose()`, `material.dispose()` (and material maps), `texture.dispose()`. Revoke any lingering object URLs.
|
||||
|
||||
## Offline asset pipeline
|
||||
- Compress: `gltf-transform` CLI or programmatic functions — Draco + KTX2 in one pass.
|
||||
- KTX2 encoder binary `toktx` must be on PATH (gltf-transform fetches via `@ktx2/basis-transcoder`/platform binaries — confirm available).
|
||||
- Pre-render 360°: Blender headless `blender -b scene.blend -o //frame_### -f 1..N -F PNG` → ffmpeg → animated WebP. Fallback: Puppeteer + headless three.js screenshot per rotation.
|
||||
|
||||
## Common pitfalls
|
||||
- `setDecoderPath` wrong → worker fetch 404 in console. Verify `/draco/` resolves under `public/`.
|
||||
- Forgetting `e.preventDefault()` on dragover → browser opens the file.
|
||||
- Decoder version mismatch → model fails to decode with no obvious error.
|
||||
- Creating loaders per load → intermittent crashes + memory growth.
|
||||
|
||||
References in user memory `reference-threejs-resources.md`.
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
# dependencies / build
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
|
||||
# large sample binaries (keep small Box/Duck/Cube samples for demos)
|
||||
samples/Br1.obj
|
||||
samples/Br1.mtl
|
||||
samples/GirderObjs/
|
||||
samples/Avocado.glb
|
||||
samples/ABeautifulGame.ktx2.glb
|
||||
samples/sample.obj
|
||||
public/samples/Avocado.glb
|
||||
public/samples/ABeautifulGame.ktx2.glb
|
||||
|
||||
# diagnostic screenshots
|
||||
girder-smoke.png
|
||||
girder-zoom.png
|
||||
tex-test.png
|
||||
ply-test.png
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"liveServer.settings.port": 5501
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,103 @@
|
||||
# CLAUDE.md — hmwebviewer (Three.js 3D Viewer)
|
||||
|
||||
Hybrid SSR+CSR web 3D model viewer. Spec: `3d_viewer_architecture_spec.pdf`.
|
||||
|
||||
## ⚠️ First action on EVERY session / agent start
|
||||
|
||||
Read these two files before doing anything:
|
||||
1. [`PLAN.md`](./PLAN.md) — what work remains, who owns what, task breakdown.
|
||||
2. [`PROGRESS.md`](./PROGRESS.md) — what is done, current state, blockers, decisions log.
|
||||
|
||||
If either file is missing or empty, treat the project as not started and bootstrap from the spec.
|
||||
|
||||
## What we are building
|
||||
|
||||
A browser 3D model viewer with **two load paths**:
|
||||
|
||||
- **Path A — Server asset (SSR + CSR)**: server ships a pre-rendered 360° animated WebP/WebM placeholder + HTML/CSS skeleton. Three.js loads + decodes the real Draco/KTX2 model in the background. On ready, fade WebP → canvas (opacity 0.5s).
|
||||
- **Path B — Local file (Drag & Drop, CSR only)**: user drops a `.glb`/`.gltf`. `URL.createObjectURL(file)` → `GLTFLoader.load` → decode → add to scene → `URL.revokeObjectURL`.
|
||||
|
||||
Target: perceived load < 3s, no frame drops, smooth GPU.
|
||||
|
||||
## Locked technical decisions
|
||||
|
||||
These are decided. Do not relitigate without explicit user approval.
|
||||
|
||||
- **Three.js** + `GLTFLoader` + `DRACOLoader` + `KTX2Loader` on ONE shared loader instance each (multiple DRACOLoader instances crash — three.js #22445).
|
||||
- **Geometry**: Draco. **Textures**: KTX2 / Basis Universal. Both compatible on same loader.
|
||||
- **Decoder hosting**: CDN by default; copy WASM to `public/draco` + `public/basis` if offline/CSP required. Pin decoder version to the three.js version in use.
|
||||
- **Local file**: `createObjectURL` + revoke after load. For repeat/cached loads, `FileReader`→`ArrayBuffer`→`GLTFLoader.parse()`.
|
||||
- **Build**: Vite. Draco worker spawns via BLOB URL — decoder files must be reachable at the configured path at runtime.
|
||||
- **Asset pre-processing (offline)**: `gltf-transform` (Draco + KTX2) — preferred over `gltf-pipeline`. Meshopt is a viable Draco alternative.
|
||||
- **Pre-render pipeline (offline)**: Blender headless CLI 360° turntable frames → assemble to animated WebP (alpha) / WebM VP9. OR Puppeteer + headless Three.js `page.screenshot({type:'webp'})` per rotation step.
|
||||
- **Renderer**: WebGLRenderer (stable baseline). WebGPU renderer = future option, not now.
|
||||
|
||||
Full rationale in user memory: `~/.claude/projects/d--MYCLAUDE-PROJECT-hmwebviewer/memory/`.
|
||||
|
||||
## Architecture map (target)
|
||||
|
||||
```
|
||||
src/
|
||||
viewer/
|
||||
ThreeDViewer.ts # core class: init, loadServerAsset, loadLocalFile, executeHydration, toggleUI
|
||||
loaders.ts # singleton GLTFLoader + DRACOLoader + KTX2Loader setup
|
||||
hydration.ts # WebP placeholder -> canvas fade coordination
|
||||
dnd/
|
||||
dropzone.ts # HTML5 drag&drop, file validation, createObjectURL/revoke
|
||||
ui/
|
||||
progress.ts # load progress bar
|
||||
scenes/ # per-model scene configs
|
||||
public/
|
||||
draco/ # decoder WASM (if bundled)
|
||||
basis/ # KTX2 decoder WASM (if bundled)
|
||||
previews/ # pre-rendered WebP/WebM placeholders
|
||||
tools/
|
||||
preprocess.mjs # gltf-transform Draco+KTX2 pipeline
|
||||
prerender/ # Blender/Puppeteer turntable -> WebP
|
||||
```
|
||||
|
||||
## How to work here (rules)
|
||||
|
||||
- **Surgical changes.** Touch only what a task requires. Match existing style.
|
||||
- **Simplicity first.** Minimum code that solves the task. No speculative features/config.
|
||||
- **Verify before claiming done.** Run the relevant check (build / typecheck / load test) and quote real output.
|
||||
- **Keep PLAN.md + PROGRESS.md current.** Update the task status when you start/finish a unit of work. Other agents depend on it.
|
||||
- **Memory is durable; PLAN/PROGRESS are working state.** Stable decisions → memory (via the memory dir). Transient task state → PLAN/PROGRESS.
|
||||
|
||||
## Commands (see `.claude/commands/`)
|
||||
|
||||
- `/bootstrap` — scaffold project from spec (run once, when PLAN says not started).
|
||||
- `/next-task` — read PLAN + PROGRESS, pick the next ready task, assign self.
|
||||
- `/report` — append current status to PROGRESS.md.
|
||||
- `/optimize-asset <file>` — run gltf-transform Draco+KTX2 on an asset.
|
||||
- `/verify-viewer` — build + load smoke test.
|
||||
|
||||
## Agents (see `.claude/agents/`)
|
||||
|
||||
Specialized subagents for parallelizable work — `viewer-core`, `dnd-handler`, `asset-pipeline`, `hydration`, `reviewer`. Invoke via the Agent tool / Task delegation. Details in each agent file.
|
||||
|
||||
## Reinforced structure — agent ↔ skill ↔ command ↔ task map
|
||||
|
||||
| PLAN task(s) | Owning agent | Skill to load | Command |
|
||||
|---|---|---|---|
|
||||
| P0 | task-lead → setup | threejs-viewer | `/bootstrap` |
|
||||
| P1-1..P1-4 | viewer-core | threejs-viewer | `/verify-viewer` |
|
||||
| P2-1,P2-2 | dnd-handler | threejs-viewer | `/verify-viewer` |
|
||||
| P3-1..P3-3 | hydration | threejs-viewer | `/verify-viewer` |
|
||||
| P4-1..P4-3 | asset-pipeline | threejs-viewer | `/optimize-asset` |
|
||||
| P5-* | reviewer | threejs-viewer | `/verify-viewer`, `/report` |
|
||||
| (any) | task-lead | task-graph | `/next-task`, `/report` |
|
||||
|
||||
**Load the `threejs-viewer` skill** whenever touching runtime viewer/dnd/asset/hydration code — it holds the locked patterns (singleton loaders, Blob URL lifecycle, hydration gating). **Load `task-graph`** when picking up or dispatching work.
|
||||
|
||||
## Runbook — how a work session goes
|
||||
|
||||
1. SessionStart hook reminds you. Read `PLAN.md` + `PROGRESS.md`.
|
||||
2. Run `/next-task` (or have `task-lead` agent do it) → get the next ready task + owning agent.
|
||||
3. Dispatch the owning agent with task ID, scope, acceptance criteria. Dispatch independent agents in ONE message (parallel).
|
||||
4. On completion: owning agent flips PLAN row → `done`, runs `/report` to append PROGRESS.md.
|
||||
5. `reviewer` runs after functional waves; `/verify-viewer` gates release.
|
||||
|
||||
## Hooks
|
||||
- `SessionStart` → `.claude/hooks/session-start.sh` prints the "read PLAN + PROGRESS" reminder.
|
||||
- `progress-nudge.sh` exists under `.claude/hooks/` for PostToolUse nudge to update PROGRESS after runtime edits — add its `PostToolUse` entry to `settings.json` if desired (not auto-wired).
|
||||
@@ -0,0 +1,80 @@
|
||||
# PLAN.md — hmwebviewer task breakdown
|
||||
|
||||
> **Agents: read this + PROGRESS.md on start.** Pick the next `todo` task whose `depends_on` are all `done`. Set it to `in_progress` with your name before working. Move to `done` only when acceptance criteria pass and you updated PROGRESS.md.
|
||||
|
||||
## Status legend
|
||||
`todo` · `in_progress(@agent)` · `blocked(reason)` · `done` · `skipped(reason)`
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Bootstrap
|
||||
| ID | Task | Agent | Status | Depends | Acceptance |
|
||||
|----|------|-------|--------|---------|------------|
|
||||
| P0-1 | Scaffold Vite + TS project, install three + types | setup | done (build-verify pending) | — | `npm run dev` serves blank page; `npm run build` exits 0 |
|
||||
| P0-2 | Copy Draco + KTX2 decoder WASM to `public/`, wire CDN fallback | setup | done (auto-copies via postinstall) | P0-1 | `DRACOLoader.setDecoderPath('/draco/')` resolves; console clean |
|
||||
| P0-3 | Basic WebGLRenderer scene (camera, lights, resize loop) | viewer-core | done (build-verify pending) | P0-1 | canvas renders a test cube; resizes on window resize |
|
||||
|
||||
## Phase 1 — Core viewer (parallelizable)
|
||||
| ID | Task | Agent | Status | Depends | Acceptance |
|
||||
|----|------|-------|--------|---------|------------|
|
||||
| P1-1 | Singleton loaders (GLTFLoader + DRACOLoader + KTX2Loader, ONE each) | viewer-core | done (build-verify pending) | P0-2 | exported factory; reused across loads; no double-instance |
|
||||
| P1-2 | `loadServerAsset(url)` with progress bar wiring | viewer-core | done (build-verify pending) | P1-1 | loads sample Draco GLB, progress % updates, scene populated |
|
||||
| P1-3 | `loadLocalFile(blob)` — createObjectURL + revoke after load | viewer-core | done (build-verify pending) | P1-1 | drop GLB loads; URL revoked post-load; memory stable across N drops |
|
||||
| P1-4 | OrbitControls + camera framing (fit model to view) | viewer-core | done (build-verify pending) | P1-2 | model auto-framed; rotate/zoom works |
|
||||
|
||||
## Phase 2 — Drag & Drop (parallelizable with Phase 1)
|
||||
| ID | Task | Agent | Status | Depends | Acceptance |
|
||||
|----|------|-------|--------|---------|------------|
|
||||
| P2-1 | Dropzone UI + HTML5 DnD event capture, file type/size validation | dnd-handler | done (build-verify pending) | P0-1 | invalid files rejected with message; valid files accepted |
|
||||
| P2-2 | Wire dropzone → `loadLocalFile` | dnd-handler | done (build-verify pending) | P1-3, P2-1 | dropped file appears in scene |
|
||||
|
||||
## Phase 3 — SSR hydration (serial, depends on core)
|
||||
| ID | Task | Agent | Status | Depends | Acceptance |
|
||||
|----|------|-------|--------|---------|------------|
|
||||
| P3-1 | WebP placeholder element + CSS opacity transition scaffolding | hydration | done | P1-2 | placeholder shows, fades on command |
|
||||
| P3-2 | `executeHydration()` — coordinate WebGL-ready AND model-loaded → fade | hydration | done | P3-1 | no empty-canvas flash; race handled; transition 0.5s |
|
||||
| P3-3 | Toggle UI states (progress bar show/hide, loading→ready) | hydration | done | P3-2 | UI reflects each load phase |
|
||||
|
||||
## Phase 4 — Asset optimization pipeline (offline, parallelizable)
|
||||
| ID | Task | Agent | Status | Depends | Acceptance |
|
||||
|----|------|-------|--------|---------|------------|
|
||||
| P4-1 | `tools/preprocess.mjs` — gltf-transform Draco+KTX2 wrapper | asset-pipeline | done | — | input GLB → output compressed GLB, size reduced, loads in viewer |
|
||||
| P4-2 | Sample asset set (small/medium/large GLB) for testing | asset-pipeline | done | — | Box(1.6K)/Duck(118K)/Avocado(7.9M) + Duck.optimized(Draco) under `samples/` + `public/samples/` |
|
||||
| P4-3 | Pre-render pipeline (Blender CLI OR Puppeteer) → animated WebP | asset-pipeline | done | P4-1 | Duck → 24-frame animated WebP (54KB) at `public/previews/Duck.webp`, wired into `#preview` hydration |
|
||||
|
||||
## Phase 5 — Hardening (serial)
|
||||
| ID | Task | Agent | Status | Depends | Acceptance |
|
||||
|----|------|-------|--------|---------|------------|
|
||||
| P5-1 | Dispose pattern: geometry/material/texture + revoke on teardown | reviewer | done | P1, P2 | no leaks after 10 load/unload cycles (DevTools heap) |
|
||||
| P5-2 | Error handling: bad file, decode fail, WebGL unsupported | reviewer | done | P1-3, P2-1 | graceful message, no uncaught exception |
|
||||
| P5-3 | Perf smoke: load each sample, record time, assert < 3s perceived | reviewer | done | P4-2 | timings logged in PROGRESS.md |
|
||||
| P5-4 | Full review pass (correctness + simplification) | reviewer | done | all | `/review` clean; no high-severity findings |
|
||||
|
||||
## Phase 6 — Multi-format loaders (OBJ/FBX/DAE/IFC), SSR+CSR — via dynamic workflow `multiformat-viewer`
|
||||
| ID | Task | Agent | Status | Depends | Acceptance |
|
||||
|----|------|-------|--------|---------|------------|
|
||||
| P6-1 | Unified `src/viewer/modelLoader.ts` — `loadModel(url)` dispatch by ext, normalize each loader to `Object3D` | viewer-core | done | P1-1 | glb/gltf/obj/fbx/dae/ifc all resolve to scene-addable Object3D; GLB path unchanged |
|
||||
| P6-2 | OBJ/FBX/DAE via three example loaders, lazy dynamic-import (separate Vite chunks) | viewer-core | done | P6-1 | OBJLoader/FBXLoader/ColladaLoader code-split; load samples |
|
||||
| P6-3 | IFC via `web-ifc` IfcAPI (single-thread wasm `/web-ifc/`, no COOP/COEP) — StreamAllMeshes → BufferGeometry | viewer-core | done | P6-1 | Cube.ifc loads; geom.delete + CloseModel (no wasm leak) |
|
||||
| P6-4 | CSR: dropzone accepts all 6 exts via `extOf`; SSR: `loadServerAsset` + main.ts preview-base route any ext | viewer-core+dnd | done | P6-1, P2-1 | drop any of 6 loads; `?model=` + `/previews/<base>.webp` works per format |
|
||||
| P6-5 | Samples + 360° WebP previews per format (Cube.obj/dae/fbx/ifc) | asset-pipeline | done | P6-1, P4-3 | 4 samples in samples/ + public/samples/; 4 animated WebP (24 frames) in public/previews/ |
|
||||
|
||||
## Phase 7 — On-screen FPS + adaptive quality (<60fps → optimize)
|
||||
| ID | Task | Agent | Status | Depends | Acceptance |
|
||||
|----|------|-------|--------|---------|------------|
|
||||
| P7-1 | `src/ui/fps.ts` — on-screen FPS overlay (EMA, throttled DOM, color-coded) | viewer-core | done | P0-3 | FPS readout visible; green≥60/orange/red |
|
||||
| P7-2 | `src/viewer/adaptiveQuality.ts` — pixelRatio tier ladder + hysteresis; step down when sustained <60, recover with headroom | viewer-core | done | P7-1 | tier steps on sustained <60fps; no oscillation; logs tier change |
|
||||
| P7-3 | Wire fps.sample + adaptive.update into `ThreeDViewer.animate` | viewer-core | done | P7-1, P7-2 | both fed each frame; rotateTo (prerender) unaffected |
|
||||
|
||||
---
|
||||
|
||||
## Parallelization map
|
||||
- **Wave 1 (after P0):** P1-1 (core) then forks → P1-2/P1-3/P1-4 + P2-1 + P4-1/P4-2 can proceed in parallel.
|
||||
- **Wave 2:** P3-* depends on core; P2-2 depends on P1-3+P2-1.
|
||||
- **Wave 3:** P5-* after functional work lands.
|
||||
|
||||
Independent agents that may run concurrently: `viewer-core`, `dnd-handler`, `asset-pipeline`. `hydration` waits on `viewer-core`. `reviewer` runs last + on-demand.
|
||||
|
||||
## Validated extras (post-PLAN)
|
||||
- **KTX2 runtime decode** ✅ — KTX2Loader + DRACOLoader together decode the Khronos ABeautifulGame KTX2+Draco GLB (11.5MB) in 626ms perceived (perf-smoke). Runtime path confirmed.
|
||||
- **KTX2 production encoding** ⏸ — needs KTX-Software (`toktx`) installed; then `gltf-transform etc1s|uastc`. Not on this box. Documented in `tools/preprocess.mjs` header + user memory.
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
# PROGRESS.md — hmwebviewer running log
|
||||
|
||||
> Append-only log of what happened. Newest at top. Pair with [PLAN.md](./PLAN.md) for what's next. Agents: add an entry whenever you start or finish a task, hit a blocker, or make a decision.
|
||||
|
||||
## Current state
|
||||
- **Phase:** ALL PLAN tasks complete (P0–P7). GLB + OBJ/FBX/DAE/IFC loaders (SSR+CSR) + on-screen FPS + adaptive quality (<60fps→pixelRatio degrade). Build verified (tsc 0, vite exit 0).
|
||||
- **Last action:** Phase 6+7 via dynamic workflow `multiformat-viewer` (7 agents) — multi-format dispatch (src/viewer/modelLoader.ts), web-ifc single-thread IFC, FPS overlay + adaptive ladder. Samples+previews per format.
|
||||
- **Blockers:** none. KTX2 *production* encoding still needs KTX-Software/toktx (runtime decode verified). web-ifc multi-thread (web-ifc-mt.wasm) unused — needs COOP/COEP; single-thread chosen.
|
||||
- **Next:** optional in-browser per-format smoke (/verify-viewer .ifc/.fbx); OBJ/DAE geometry-only on blob: URL (accepted); document.hidden FPS guard.
|
||||
|
||||
## Work log
|
||||
- 2026-06-19 — KTX2 runtime path validated: toktx unavailable here (no native KTX-Software), so sourced the only single-file KTX2 GLB in Khronos — ABeautifulGame.glb (glTF-Binary-KTX-ETC1S-Draco, 11.5MB, 35 KHR_texture_basisu / 33 image/ktx2 / 17 Draco). perf-smoke load via viewer: **626ms PASS** (KTX2Loader + DRACOLoader decode confirmed). Corrected tools/preprocess.mjs doc: KTX2 CLI command is `gltf-transform etc1s|uastc` (needs KTX-Software 4.3+), not `ktx`. Samples now include KTX2+Draco variant.
|
||||
- 2026-06-18 — P4-3 prerender pipeline complete: added ThreeDViewer.rotateTo(azimuth) + `window.__viewer` exposure; wrote tools/prerender.mjs (puppeteer-core orbit capture → ffmpeg animated WebP). Fixed: MSYS path mangling of `/samples` argv; ffmpeg `libwebp_anim` broken in this build (1 frame) → switched to `-c:v libwebp -vsync vfr` (correct multi-frame). Produced public/previews/Duck.webp (24 frames, 54KB). Wired into main.ts: `?model=` sets `#preview` src to matching `/previews/<name>.webp` (onerror hides), hydration gate fades it on ready. P4-2 (3-size sample set) + P4-3 → done. Result/verify: `npm run build` exits 0; ANMF=24 confirmed.
|
||||
- 2026-06-18 — Asset pipeline validated + P5-3 perf smoke: fixed tools/preprocess.mjs (textureCompress ktx2 → webp; registered draco3d.encoder dependency + EXTTextureWebP). Ran on Duck.glb → 117.7KiB→31.2KiB (73.4% reduction, Draco+webp). Added `window` `hmw:ready` event in ThreeDViewer.onLoaded. Wrote tools/perf-smoke.mjs (puppeteer-core + system Chrome headless). Ran against preview: Box 102ms, Duck 94ms, Duck.optimized(Draco) 141ms, Avocado 271ms — all < 3000ms PASS. Draco runtime decoder path confirmed. P5-3 → done.
|
||||
- 2026-06-18 — Samples: Box(1.6K)/Duck(118K)/Avocado(7.9M) + Duck.optimized(Draco,31.2K) in samples/ + public/samples/.
|
||||
- 2026-06-18 — Phase 3 hydration + Phase 5 hardening: marked P3-1/2/3 done (placeholder+gate+UI toggle already in viewer-core/hydration.ts). Reviewer agent found 0 critical, 3 🟡 (error UX), 5 nit; P5-1 clean (single loaders, full dispose, revoke both paths). Applied: index.html #status + .status CSS; progress.ts setStatus(); ThreeDViewer onError param wired to both load-error callbacks; main.ts WebGL try/catch + status surfacing for init/dnd/load failures. vite.config manualChunks split three→vendor. Result/verify: `npm run build` exits 0 (three 518KB vendor + app 132KB; chunk warning is three's inherent size). P5-1/2/4 → done.
|
||||
- 2026-06-18 — Sample assets: downloaded Box.glb (1.6K) + Duck.glb (118K) → samples/ + public/samples/ (Path A `?model=/samples/Box.glb` testable).
|
||||
- 2026-06-18 — Phase 0 viewer-core inline (classifier blocked subagent spawn): wrote src/viewer/loaders.ts (singleton getLoaders), src/viewer/hydration.ts (createHydrationGate — fades on BOTH webgl-ready + model-loaded, idempotent), src/viewer/ThreeDViewer.ts (renderer+scene+camera+OrbitControls+lights, loadServerAsset w/ progress, loadLocalFile w/ createObjectURL+revoke on both paths, frameObject auto-fit, dispose traverse), rewrote src/main.ts (wire viewer + initDropzone + ?model= Path A). Added tools/copy-decoders.mjs + package.json postinstall so `npm install` auto-copies /draco + /basis decoders. P0-1/2/3 + P1-1..P1-4 → done (build-verify pending). Result/verify: code coherent against three r169 types; `npm run build` pending install. Next: install → build → smoke load.
|
||||
- 2026-06-18 — Phase 2 dnd-handler agent (parallel): src/ui/progress.ts + src/dnd/dropzone.ts. preventDefault on dragover, /\.(glb|gltf)$/i validation, click-to-browse. Build-verify pending.
|
||||
- 2026-06-18 — Phase 4 asset-pipeline agent (parallel): tools/preprocess.mjs (gltf-transform dedup+weld+quantize+draco+KTX2, before/after sizes, graceful missing-package) + tools/README.md. Runtime pending `npm install -D @gltf-transform/*`. P4-1 done; P4-2/P4-3 blocked(needs sample asset).
|
||||
- 2026-06-18 — Web research (loaders, Draco+KTX2, DnD, Vite, prerender). Findings saved to user memory.
|
||||
- 2026-06-18 — Authored CLAUDE.md, PLAN.md, PROGRESS.md, .claude agents/commands/skills/hooks.
|
||||
- 2026-06-18 — Phase 0 viewer-core inline (classifier blocked subagent spawn): wrote src/viewer/loaders.ts (singleton getLoaders), src/viewer/hydration.ts (createHydrationGate — fades on BOTH webgl-ready + model-loaded, idempotent), src/viewer/ThreeDViewer.ts (renderer+scene+camera+OrbitControls+lights, loadServerAsset w/ progress, loadLocalFile w/ createObjectURL+revoke on both paths, frameObject auto-fit, dispose traverse), rewrote src/main.ts (wire viewer + initDropzone + ?model= Path A). Added tools/copy-decoders.mjs + package.json postinstall so `npm install` auto-copies /draco + /basis decoders. P0-1/2/3 + P1-1..P1-4 → done (build-verify pending). Result/verify: code coherent against three r169 types; `npm run build` pending install. Next: install → build → smoke load.
|
||||
- 2026-06-18 — Phase 2 dnd-handler agent (parallel): src/ui/progress.ts + src/dnd/dropzone.ts. preventDefault on dragover, /\.(glb|gltf)$/i validation, click-to-browse. Build-verify pending.
|
||||
- 2026-06-18 — Phase 4 asset-pipeline agent (parallel): tools/preprocess.mjs (gltf-transform dedup+weld+quantize+draco+KTX2, before/after sizes, graceful missing-package) + tools/README.md. Runtime pending `npm install -D @gltf-transform/*`. P4-1 done; P4-2/P4-3 blocked(needs sample asset).
|
||||
- 2026-06-18 — Web research (loaders, Draco+KTX2, DnD, Vite, prerender). Findings saved to user memory.
|
||||
- 2026-06-18 — Authored CLAUDE.md, PLAN.md, PROGRESS.md, .claude agents/commands/skills/hooks.
|
||||
|
||||
## Decision log
|
||||
- 2026-06-18 — Three.js + Vite chosen. Draco + KTX2 both, single shared loader instances. CDN decoder default. [memory: project-threejs-tech-decisions]
|
||||
- 2026-06-18 — Multi-agent split: viewer-core / dnd-handler / asset-pipeline / hydration / reviewer. PLAN.md holds task graph.
|
||||
|
||||
## Work log
|
||||
- 2026-06-18 — Web research (loaders, Draco+KTX2, DnD, Vite, prerender). Findings saved to user memory.
|
||||
- 2026-06-18 — Authored CLAUDE.md, PLAN.md, PROGRESS.md, .claude agents/commands/skills/hooks.
|
||||
## 2026-06-18 — Phase 2 dnd-handler (P2-1, P2-2)
|
||||
- Did: wrote `src/ui/progress.ts` (showProgress/hideProgress/setProgress) and `src/dnd/dropzone.ts` (initDropzone + DropzoneOpts + ViewerHandle). Dragover/dragenter preventDefault + `drag` class; dragleave/drop removes it; drop takes first file, validates `/\.(glb|gltf)$/i`, forwards valid file to `viewer.loadLocalFile(file)`, else `onError('GLB/GLTF only')`. Click-to-browse wired to `#file-input`; `change` resets input value so repeat picks fire.
|
||||
- Result/verify: signatures match the viewer-core/main.ts contract exactly (DropzoneOpts.viewer.loadLocalFile, progress el.firstChild `.bar`). File scope respected — did not touch src/viewer/* or src/main.ts. **Build-verify pending `npm install`** (blocked by permission classifier this session).
|
||||
- Next: viewer-core wires `initDropzone` from main.ts once its ThreeDViewer.loadLocalFile lands; then `/verify-viewer` smoke test drops a sample GLB.
|
||||
- Blocker: none (source-only; build verification deferred).
|
||||
## 2026-06-18 — Phase 4 asset-pipeline (P4-1 done; P4-2, P4-3 blocked)
|
||||
- Did: wrote `tools/preprocess.mjs` (Node ESM CLI) + `tools/README.md`. Pipeline = read GLB → `dedup` + `weld` + `quantize` (POSITION per `--draco-bits`, default 14; NORMAL/TEXCOORD/COLOR fixed sane) + `prune` + `draco()` + `textureCompress({targetFormat:'ktx2'})` → write `.optimized.glb`. Registers `KHRDracoMeshCompression` + `KHRTextureBasisu` on WebIO. Prints before/after bytes + reduction %. KTX2 on by default (`--no-ktx2` to disable). Dynamic import of `@gltf-transform/{core,functions,extensions,cli}` — on missing package, prints the exact `npm install -D` command and exits 1 (no silent skip). Header documents usage + install + that gltf-transform fetches the platform basis encoder on first KTX2 run.
|
||||
- Result/verify: `node --check` → SYNTAX_OK. `node tools/preprocess.mjs --help` → prints usage, exit 0. `node tools/preprocess.mjs fake.glb` with deps absent → prints missing-package message + install command, exit 1 (graceful path confirmed). **Runtime execution (actual compression) pending `npm install -D @gltf-transform/core @gltf-transform/functions @gltf-transform/extensions @gltf-transform/cli`** — blocked by the permission classifier this session.
|
||||
- Next: once deps installed + a sample asset exists, run on it, confirm size reduction + viewer load, then P4-2 (commit samples/*.optimized.glb) and P4-3 (Blender/Puppeteer pre-render → public/previews/*.webp).
|
||||
- Blocker: P4-2 blocked(needs sample asset); P4-3 blocked(needs sample asset + runtime install). PLAN.md flipped: P4-1 done, P4-2/P4-3 blocked.
|
||||
|
||||
## 2026-06-19 — FIX: CSR drag&drop regression (blob URL has no extension) — all formats
|
||||
- Bug: user dropped samples/Avocado.glb → "Failed to load file"; ALL local drops failed. Root cause: P6 refactor routed loadLocalFile through `loadModel(url)` which dispatches by `extOf(url)`; Path B passes a `blob:` URL (no `.glb` suffix) → extOf=null → switch default throws "Unsupported". Per-format smoke had only tested Path A (`?model=`), so CSR blob path was never exercised.
|
||||
- Fix (surgical): `loadModel(url, renderer, onProgress, nameHint?)` — ext = `extOf(nameHint ?? url) ?? extOf(url)`. ThreeDViewer.loadLocalFile passes `file.name` as nameHint. (modelLoader.ts + ThreeDViewer.ts, 2 lines effective.)
|
||||
- Verify: NEW tools/dnd-smoke.mjs drives the real CSR path (fetch sample → `new File` → `window.__viewer.loadLocalFile`). tsc 0, build 7.85s exit 0. **7/7 local drops PASS**: Box/Duck/Avocado.glb + Cube.obj/fbx/dae/ifc all load via drag&drop. Closes the previously-untested CSR coverage gap.
|
||||
- Blocker: none.
|
||||
|
||||
## 2026-06-19 — Per-format in-browser load smoke (all 6 formats PASS)
|
||||
- Did: built dist + `vite preview` :4173; ran tools/perf-smoke.mjs against one sample per format (Box.glb, Cube.obj, Cube.fbx, Cube.dae, Cube.ifc, Duck.optimized.glb) in real headless Chrome — measured nav→`hmw:ready`.
|
||||
- Result/verify: ALL PASS <3s — Box.glb 229ms, Cube.obj 173ms, Cube.fbx 158ms, Cube.dae 178ms, **Cube.ifc 463ms** (incl web-ifc wasm Init), Duck.optimized.glb 182ms. Closes the per-format runtime gap (SSR `?model=` path renders every format).
|
||||
- Gotcha: Git-Bash mangled `--asset /samples/..` argv → `C:/Program Files/Git/samples/..` (MSYS path conversion). Fix: prefix `MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*'`.
|
||||
- Blocker: none.
|
||||
|
||||
## 2026-06-19 — P7 adaptive downstep empirically verified (tools/adaptive-smoke.mjs)
|
||||
- Did: built dist + `vite preview` :4173; ran new tools/adaptive-smoke.mjs — headless Chrome (swiftshader software GL) + CDP `Emulation.setCPUThrottlingRate` 6x, loaded heavy ABeautifulGame.ktx2.glb (11.5MB). Watched real #.fps overlay + captured genuine adaptiveQuality console logs (no logic patched).
|
||||
- Result/verify: FPS overlay rendered real values (19→4 FPS under throttle). Adaptive stepped **tier 0→1→2→3** (pr 1.5→1→0.75) on sustained <60fps; each step after ~60 slow frames per DOWN_FRAMES. Harness exit 0 PASS. The <60fps→optimize path fires on real frame measurement.
|
||||
- Note (honest): under CPU throttle the bottleneck is JS/geometry, not GPU fill, so lowering pixelRatio barely recovered fps (4→7) — the mechanism fired correctly but pixelRatio is a fill-rate knob; it pays off on GPU-bound scenes. Recovery-up not exercised (would need sustained >72fps).
|
||||
- Blocker: none.
|
||||
|
||||
## 2026-06-19 — Phase 6 + 7 via dynamic workflow `multiformat-viewer` (7 agents, 296k tok)
|
||||
- Did: extended viewer to OBJ/FBX/DAE/IFC (SSR+CSR) + on-screen FPS + adaptive quality. Run as one dynamic Workflow: Research(3 ‖ read-only: web-ifc API, adaptive-perf, three loader shapes) → Core(viewer-core) → Enhance(FPS ‖ Assets) → Verify(claude). Disjoint file sets per parallel wave (no git repo → no worktree isolation).
|
||||
- **P6** Core: NEW `src/viewer/modelLoader.ts` — `extOf`/`ACCEPT_EXT`/`loadModel(url,renderer,onProgress)` dispatch by ext, normalize each to `Object3D`. glb/gltf reuse singleton GLTFLoader (unchanged). obj/fbx/dae = lazy dynamic-import three example loaders (separate Vite chunks: OBJ 8.8K / Collada 41K / FBX 48K). ifc = `web-ifc`@0.0.77 IfcAPI, single-thread `/web-ifc/web-ifc.wasm` (SetWasmPath abs=true, Init(undefined,true), NO COOP/COEP), OpenModel(COORDINATE_TO_ORIGIN) → StreamAllMeshes → BufferGeometry per PlacedGeometry (interleaved stride-6 pos+normal, Uint32 index, color/opacity, flatTransformation), `geom.delete()` + `CloseModel` in finally (no wasm leak). ThreeDViewer.loadServerAsset(SSR) + loadLocalFile(CSR) both route through loadModel; onLoaded generalized to Object3D; Blob URL revoked in `.finally()`. dropzone via extOf (6 exts); index.html accept+text; main.ts preview-base widened; copy-decoders stages web-ifc.wasm.
|
||||
- **P7** FPS+adaptive: NEW `src/ui/fps.ts` (EMA fps, throttled 250ms DOM, color green≥60/orange/red, self-appended overlay). NEW `src/viewer/adaptiveQuality.ts` (pixelRatio ladder [min(DPR,2),1.5,1,0.75,0.5], asymmetric hysteresis: down after 60 frames <60fps, up after 180 frames >72fps, dead-band reset). Wired into ThreeDViewer.animate(now): fps.sample(now)+adaptive.update(fps()). Adaptive owns setPixelRatio (DPR cap from init). rotateTo (prerender) unaffected.
|
||||
- **Assets**: Cube.obj(793B, hand-authored)/Cube.dae(2.1K)/Cube.fbx(16.2K, three.js morph_test — assimp box.fbx failed in r0.169 FBXLoader)/Cube.ifc(2.3K, hand-authored IFC4 1-wall) in samples/ + public/samples/; 24-frame animated WebP previews per format in public/previews/. prerender.mjs base-name made format-aware.
|
||||
- Result/verify (independent main-thread re-run): `npx tsc --noEmit` → "No errors found" exit 0. `npm run build` → exit 0 (vite 5.4.21, 11.92s; chunks: app 138K, three vendor 533K, web-ifc 3.5M lazy/code-split — not on initial path). Confirmed via grep: 6 dispatch branches; dropzone EXT_RE all 6; animate wires fps+adaptive; loadServerAsset+loadLocalFile both call loadModel. web-ifc.wasm staged in public/web-ifc/.
|
||||
- Next: optional — in-browser smoke per format (.ifc/.fbx) via /verify-viewer; OBJ/DAE render geometry-only (external .mtl/textures can't resolve from blob: URL — accepted); document.hidden background-tab guard for FPS (noted, not done).
|
||||
- Blocker: none.
|
||||
|
||||
---
|
||||
<!-- Append new entries above this line. Format:
|
||||
## YYYY-MM-DD — <task ID or summary>
|
||||
- Did: ...
|
||||
- Result/verify: ...
|
||||
- Next: ...
|
||||
- Blocker (if any): ...
|
||||
-->
|
||||
@@ -0,0 +1,230 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>hmwebviewer — 아키텍처 / 구조 / 계획</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; padding: 32px; max-width: 1000px; margin: 0 auto;
|
||||
font: 15px/1.6 system-ui, "Segoe UI", "Malgun Gothic", sans-serif;
|
||||
background: #0f1115; color: #e6e9ef;
|
||||
}
|
||||
h1 { font-size: 26px; border-bottom: 2px solid #4aa3ff; padding-bottom: 10px; }
|
||||
h2 { font-size: 19px; margin-top: 36px; color: #4aa3ff; border-left: 4px solid #4aa3ff; padding-left: 10px; }
|
||||
h3 { font-size: 16px; margin-top: 24px; color: #cfe3ff; }
|
||||
code, pre { font-family: "Cascadia Code", Consolas, monospace; }
|
||||
code { background: #1c2230; padding: 1px 5px; border-radius: 4px; color: #ffd479; font-size: 13px; }
|
||||
pre { background: #161a24; border: 1px solid #262c3a; border-radius: 8px; padding: 14px; overflow-x: auto; font-size: 13px; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 13.5px; }
|
||||
th, td { border: 1px solid #2a3142; padding: 7px 10px; text-align: left; vertical-align: top; }
|
||||
th { background: #1a2030; color: #9fc4ff; }
|
||||
tr:nth-child(even) td { background: #141821; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.card { background: #161a24; border: 1px solid #262c3a; border-radius: 8px; padding: 14px 16px; }
|
||||
.badge { display: inline-block; padding: 2px 9px; border-radius: 10px; font-size: 12px; font-weight: 600; }
|
||||
.ok { background: #143a23; color: #57d98a; }
|
||||
.wip { background: #3a3014; color: #e4b35a; }
|
||||
.blk { background: #3a1414; color: #e48080; }
|
||||
.meta { color: #8b93a7; font-size: 13px; }
|
||||
.arrow { color: #4aa3ff; font-weight: 700; }
|
||||
.sep { border: 0; border-top: 1px solid #262c3a; margin: 28px 0; }
|
||||
.yes { color: #57d98a; font-weight: 700; }
|
||||
.no { color: #e48080; font-weight: 700; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>hmwebviewer — 3D 뷰어</h1>
|
||||
<p class="meta">Three.js 기반 하이브리드(SSR + CSR) 웹 3D 모델 뷰어. 설계 원본: <code>3d_viewer_architecture_spec.pdf</code></p>
|
||||
<p class="meta"><strong>지원 포맷:</strong> GLB · GLTF · OBJ · FBX · DAE(Collada) · IFC(BIM) | <strong>로드 경로:</strong> SSR + CSR | <strong>성능:</strong> 화면 FPS 표시 + 60fps 미만 시 적응형 품질 강등</p>
|
||||
<p class="meta"><strong>문서 갱신:</strong> <span id="updated">2026-06-19</span> — 매 작업 프롬프트 종료 시 이 파일을 함께 업데이트합니다.</p>
|
||||
|
||||
<h2>1. 개요 / 두 가지 로드 경로</h2>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h3>Path A — 서버 에셋 (SSR + CSR + Hydration)</h3>
|
||||
<p>서버가 미리 렌더링한 360° Animated WebP 자리표시자 + HTML/CSS 골조를 먼저 보냄(빠른 체감 로드). 뒤에서 <code>modelLoader.loadModel(url)</code>이 확장자로 디스패치하여 실제 모델을 비동기 로드/디코드. WebGL 준비 <strong>and</strong> 모델 로드 완료 둘 다 충족 시 WebP <span class="arrow">→</span> 캔버스로 opacity 0.5s 페이드.</p>
|
||||
<p class="meta">트리거: <code>?model=<url></code> · 프리뷰: <code>/previews/<base>.webp</code> (포맷 무관)</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Path B — 로컬 파일 (Drag & Drop, CSR)</h3>
|
||||
<p>사용자가 <code>.glb .gltf .obj .fbx .dae .ifc</code> 드롭. <code>URL.createObjectURL(file)</code> <span class="arrow">→</span> <code>loadModel</code> <span class="arrow">→</span> 디코드 <span class="arrow">→</span> 씬 추가 <span class="arrow">→</span> <code>URL.revokeObjectURL</code>(성공/에러 <code>.finally()</code> 양쪽). 검증: <code>extOf()</code> 확장자.</p>
|
||||
<p class="meta">트리거: 파일 드롭 / 클릭-탐색</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="meta">두 경로 모두 단일 디스패처 <code>src/viewer/modelLoader.ts</code>의 <code>loadModel(url, renderer, onProgress)</code>를 거쳐 <code>THREE.Object3D</code>로 정규화 → <code>ThreeDViewer.onLoaded()</code>는 원본 포맷을 알 필요 없음.</p>
|
||||
|
||||
<h2>2. 지원 파일 포맷</h2>
|
||||
<table>
|
||||
<tr><th>포맷</th><th>확장자</th><th>로더</th><th>비고</th><th>SSR</th><th>CSR</th><th>검증(체감 로드)</th></tr>
|
||||
<tr>
|
||||
<td>glTF (Binary/JSON)</td><td><code>.glb .gltf</code></td>
|
||||
<td>GLTFLoader<br>(+DRACO +KTX2 싱글톤)</td>
|
||||
<td>Draco 지오메트리 + KTX2/Basis·WebP 텍스처. 기준 경로.</td>
|
||||
<td class="yes">✔</td><td class="yes">✔</td><td><span class="badge ok">PASS 229ms</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Wavefront OBJ</td><td><code>.obj</code></td>
|
||||
<td>OBJLoader (지연 청크 8.8KB)</td>
|
||||
<td>지오메트리 전용. <code>blob:</code> URL에선 외부 <code>.mtl</code>/텍스처 미해결 → 기본 머티리얼.</td>
|
||||
<td class="yes">✔</td><td class="yes">✔</td><td><span class="badge ok">PASS 173ms</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Autodesk FBX</td><td><code>.fbx</code></td>
|
||||
<td>FBXLoader (지연 청크 47.8KB)</td>
|
||||
<td>바이너리(자체 포함). 애니메이션 가능(<code>.animations</code>) — 현재 정적 표시.</td>
|
||||
<td class="yes">✔</td><td class="yes">✔</td><td><span class="badge ok">PASS 158ms</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>COLLADA</td><td><code>.dae</code></td>
|
||||
<td>ColladaLoader (지연 청크 41KB)</td>
|
||||
<td>XML(DOMParser 필요). 외부 텍스처는 OBJ와 동일 제약.</td>
|
||||
<td class="yes">✔</td><td class="yes">✔</td><td><span class="badge ok">PASS 178ms</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>IFC (BIM)</td><td><code>.ifc</code></td>
|
||||
<td>web-ifc <code>IfcAPI</code><br>(단일스레드 wasm, 지연 청크 3.5MB)</td>
|
||||
<td><code>StreamAllMeshes</code> <span class="arrow">→</span> <code>BufferGeometry</code>(인터리브 stride-6 pos+normal). 단일스레드라 <strong>COOP/COEP 헤더 불필요</strong>. <code>geom.delete()</code>+<code>CloseModel</code>로 wasm 메모리 해제.</td>
|
||||
<td class="yes">✔</td><td class="yes">✔</td><td><span class="badge ok">PASS 463ms</span><br><span class="meta">wasm Init 포함</span></td>
|
||||
</tr>
|
||||
</table>
|
||||
<p class="meta">검증: 빌드 <code>dist</code> + <code>vite preview</code>에 대해 헤드리스 Chrome으로 포맷별 1개 샘플 로드, 네비게이션→<code>hmw:ready</code> 측정. 전체 <3000ms PASS (<code>tools/perf-smoke.mjs</code>).</p>
|
||||
|
||||
<h2>3. 기술 스택 (확정)</h2>
|
||||
<table>
|
||||
<tr><th>영역</th><th>선택</th><th>비고</th></tr>
|
||||
<tr><td>렌더러</td><td>three.js WebGLRenderer (r0.169)</td><td>WebGPU는 차후 옵션</td></tr>
|
||||
<tr><td>로더(기준)</td><td>GLTFLoader + DRACOLoader + KTX2Loader</td><td><strong>각 1개 싱글톤</strong> — 다중 인스턴스 크래시(#22445)</td></tr>
|
||||
<tr><td>로더(확장)</td><td>OBJ / FBX / Collada — three example loaders</td><td>지연 <code>import()</code> → 별도 Vite 청크(초기 번들 미증가)</td></tr>
|
||||
<tr><td>로더(IFC)</td><td>web-ifc <code>IfcAPI</code> (v0.0.77)</td><td>단일스레드 <code>web-ifc.wasm</code> 자가호스팅(<code>/web-ifc/</code>), <code>SetWasmPath(...,true)</code></td></tr>
|
||||
<tr><td>적응형 품질</td><td>FPS 측정 + pixelRatio 사다리</td><td>외부 의존성 0 — <code>ui/fps.ts</code> + <code>viewer/adaptiveQuality.ts</code></td></tr>
|
||||
<tr><td>지오메트리 압축</td><td>Draco</td><td>~73% 축소 검증됨</td></tr>
|
||||
<tr><td>텍스처 압축</td><td>WebP(런타임) / KTX2·Basis(고급)</td><td>KTX2는 별도 CLI 단계(<code>toktx</code>)</td></tr>
|
||||
<tr><td>빌드</td><td>Vite + TypeScript (strict)</td><td>three vendor + 포맷별 로더 청크 분리</td></tr>
|
||||
<tr><td>디코더 호스팅</td><td><code>public/draco</code> + <code>public/basis</code> + <code>public/web-ifc</code></td><td>postinstall로 자동 복사</td></tr>
|
||||
<tr><td>에셋 파이프라인</td><td>gltf-transform + draco3d</td><td><code>tools/preprocess.mjs</code> (GLB 전용)</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>4. 런타임 파일 구조</h2>
|
||||
<pre>src/
|
||||
main.ts # 진입점 — ThreeDViewer + Dropzone 와이어링, ?model= Path A
|
||||
viewer/
|
||||
loaders.ts # getLoaders() 싱글톤 (GLTF+DRACO+KTX2)
|
||||
modelLoader.ts # ★ loadModel() 포맷 디스패치 (glb/gltf/obj/fbx/dae/ifc) → Object3D
|
||||
ThreeDViewer.ts # 핵심 클래스: 렌더러/씬/카메라/OrbitControls/로드/dispose + FPS·적응형 와이어링
|
||||
adaptiveQuality.ts # ★ pixelRatio 사다리 + 히스테리시스 (60fps↓ 강등)
|
||||
hydration.ts # createHydrationGate() — 양쪽 마크 시 페이드
|
||||
dnd/dropzone.ts # HTML5 DnD + 클릭탐색 + extOf 검증 → loadLocalFile
|
||||
ui/
|
||||
progress.ts # showProgress/hideProgress/setProgress/setStatus
|
||||
fps.ts # ★ 화면 FPS 오버레이 (EMA, 색상코딩)
|
||||
public/
|
||||
draco/ basis/ # 디코더 WASM (자동 복사)
|
||||
web-ifc/ # ★ web-ifc.wasm (단일스레드, 자동 복사)
|
||||
samples/ # Box/Duck/Avocado/Duck.optimized + Cube.obj/dae/fbx/ifc
|
||||
previews/ # Duck.webp + Cube.{obj,dae,fbx,ifc}.webp (24프레임 360°)
|
||||
tools/
|
||||
preprocess.mjs # gltf-transform Draco+WebP 압축 (GLB 전용)
|
||||
copy-decoders.mjs # postinstall: draco+basis+web-ifc 복사
|
||||
prerender.mjs # 360° 턴테이블 → Animated WebP (포맷 무관)
|
||||
perf-smoke.mjs # 헤드리스 체감 로드 측정 (<3s)
|
||||
adaptive-smoke.mjs # ★ CPU 스로틀로 60fps↓ 강등 실증
|
||||
</pre>
|
||||
|
||||
<h2>5. 멀티에이전트 + 다이나믹 워크플로우</h2>
|
||||
<p>에이전트가 <code>PLAN.md</code> + <code>PROGRESS.md</code>를 매 시작 시 읽어 다음 작업을 결정. 병렬 안전(파일 스코프 분리).</p>
|
||||
<p class="card"><strong>Phase 6/7은 다이나믹 Workflow <code>multiformat-viewer</code>로 구현</strong> (7 에이전트, 296k 토큰): Research(3 병렬·읽기전용: web-ifc API / 적응형 성능 / three 로더 형태) <span class="arrow">→</span> Core(viewer-core) <span class="arrow">→</span> Enhance(FPS ‖ 에셋) <span class="arrow">→</span> Verify(빌드 green). git 미사용 환경이라 worktree 격리 대신 병렬 웨이브별 파일 스코프 분리로 충돌 방지.</p>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<h3>에이전트</h3>
|
||||
<table>
|
||||
<tr><th>이름</th><th>역할</th></tr>
|
||||
<tr><td>task-lead</td><td>PLAN/PROGRESS 읽고 다음 작업 분배</td></tr>
|
||||
<tr><td>viewer-core</td><td>src/viewer/* 씬·로더·하이드레이션·FPS</td></tr>
|
||||
<tr><td>dnd-handler</td><td>src/dnd/* 드래그드롭·검증</td></tr>
|
||||
<tr><td>asset-pipeline</td><td>tools/* 압축·프리렌더·샘플</td></tr>
|
||||
<tr><td>hydration</td><td>SSR 자리표시자→캔버스 페이드</td></tr>
|
||||
<tr><td>reviewer</td><td>누수/에러/성능 감사(읽기 위주)</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
<h3>명령 / 스킬 / 훅</h3>
|
||||
<p><strong>명령:</strong> <code>/bootstrap</code> <code>/next-task</code> <code>/report</code> <code>/optimize-asset</code> <code>/verify-viewer</code></p>
|
||||
<p><strong>스킬:</strong> <code>threejs-viewer</code>(로드 패턴), <code>task-graph</code>(조정 프로토콜)</p>
|
||||
<p><strong>훅:</strong> <code>session-start.sh</code>(PLAN/PROGRESS 읽기 알림), <code>progress-nudge.sh</code>(src 편집 시 알림)</p>
|
||||
<p class="meta">CLAUDE.md가 두 파일 선독을 강제 + 에이전트/스킬/명령 매핑표 + 세션 런북 포함.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>6. 계획 진행 상태 (PLAN.md)</h2>
|
||||
<table>
|
||||
<tr><th>Phase</th><th>범위</th><th>상태</th></tr>
|
||||
<tr><td>P0 Bootstrap</td><td>Vite/TS 셋업, 디코더, 기본 씬</td><td><span class="badge ok">done · 빌드 검증</span></td></tr>
|
||||
<tr><td>P1 Core 뷰어</td><td>싱글톤 로더, loadServer/Local, OrbitControls, 프레이밍</td><td><span class="badge ok">done · 빌드 검증</span></td></tr>
|
||||
<tr><td>P2 Drag & Drop</td><td>드롭존 UI, 검증, loadLocalFile 와이어링</td><td><span class="badge ok">done · 빌드 검증</span></td></tr>
|
||||
<tr><td>P3 Hydration</td><td>WebP 자리표시자, 게이트, UI 토글</td><td><span class="badge ok">done</span></td></tr>
|
||||
<tr><td>P4 에셋 파이프라인</td><td>preprocess.mjs, 샘플셋, 프리렌더</td><td><span class="badge ok">P4-1/2/3 done</span></td></tr>
|
||||
<tr><td>P5 Hardening</td><td>누수/에러 감사, 퍼포먼스, 리뷰</td><td><span class="badge ok">P5-1/2/3/4 done</span> 0 critical</td></tr>
|
||||
<tr><td>P6 멀티포맷 로더</td><td>OBJ/FBX/DAE/IFC, SSR+CSR, modelLoader 디스패치, 샘플+프리뷰</td><td><span class="badge ok">P6-1..5 done</span> 6포맷 검증</td></tr>
|
||||
<tr><td>P7 FPS + 적응형 품질</td><td>화면 FPS, pixelRatio 사다리, animate 와이어링</td><td><span class="badge ok">P7-1/2/3 done</span> 강등 실증</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>7. FPS 표시 + 적응형 품질 (<60fps → 최적화)</h2>
|
||||
<div class="card">
|
||||
<ul>
|
||||
<li><strong>FPS 오버레이</strong> (<code>ui/fps.ts</code>): 좌상단, 프레임 델타 EMA 평활, ~250ms 스로틀 DOM 갱신. 색상 — <span class="yes">≥60 녹색</span> / 30–59 주황 / <span class="no"><30 빨강</span>.</li>
|
||||
<li><strong>적응형 사다리</strong> (<code>viewer/adaptiveQuality.ts</code>): pixelRatio 5단계 <code>[min(DPR,2) · 1.5 · 1 · 0.75 · 0.5]</code>. 가장 고효율·저위험 런타임 노브(드로잉 버퍼 재할당, CSS 리플로우 없음).</li>
|
||||
<li><strong>히스테리시스</strong>(진동 방지): 평균 <60fps가 <strong>60프레임 지속</strong> 시 1단계 강등 / 평균 >72fps가 <strong>180프레임 지속</strong> 시 1단계 복귀 / 60–72 데드밴드는 카운터 리셋.</li>
|
||||
<li><strong>실증</strong>(<code>tools/adaptive-smoke.mjs</code>): 헤드리스 swiftshader + CDP CPU 스로틀 6×로 ABeautifulGame(11.5MB) 로드 → 실측 FPS <strong>19→4</strong>, 적응형 <strong>tier 0→1→2→3</strong> (pr 1.5→1→0.75) 로그 캡처. 로직 미패치, 실제 프레임 측정으로 발화.</li>
|
||||
</ul>
|
||||
<p class="meta">참고: CPU 스로틀 하에선 병목이 JS/지오메트리(필레이트 아님)라 pixelRatio 강등의 FPS 회복폭이 작음(4→7) — 메커니즘은 정상 발화하나, 이 노브는 GPU 필레이트 병목 씬에서 효과가 큼.</p>
|
||||
</div>
|
||||
|
||||
<h2>8. 현재 구현 상태</h2>
|
||||
<div class="card">
|
||||
<ul>
|
||||
<li><strong>빌드:</strong> <code>tsc --noEmit</code> 클린, <code>npm run build</code> 통과 (vite 5.4.21, 11.92s). 청크: 앱 138KB(gz 47) · three vendor 533KB(gz 136) · web-ifc 3.5MB(gz 398, <em>지연 로드</em>) · OBJ 8.8 / Collada 41 / FBX 48KB.</li>
|
||||
<li><strong>포맷별 인브라우저 로드(헤드리스 Chrome):</strong> GLB 229ms · OBJ 173ms · FBX 158ms · DAE 178ms · <strong>IFC 463ms</strong> · Duck.optimized 182ms — 전체 <3000ms PASS.</li>
|
||||
<li><strong>적응형 품질:</strong> 60fps 미만 지속 시 pixelRatio 3단계 강등 실측 확인(위 7절).</li>
|
||||
<li><strong>샘플:</strong> Box/Duck/Avocado/Duck.optimized(GLB) + Cube.obj(793B)/Cube.dae(2.1K)/Cube.fbx(16.2K)/Cube.ifc(2.3K). 각 24프레임 360° WebP 프리뷰.</li>
|
||||
<li><strong>KTX2 런타임 경로:</strong> KTX2Loader+DRACOLoader 동시 디코드 확인(ABeautifulGame, 626ms). 인코딩은 toktx 필요(미설치).</li>
|
||||
<li><strong>하드닝:</strong> WebGL 미지원 가드, 로드 실패 메시지(<code>#status</code>), DnD 검증 피드백, IFC wasm 메모리 해제 — reviewer 0 critical.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>9. 실행 명령</h2>
|
||||
<pre># 개발 서버 (HMR)
|
||||
npm run dev # → http://127.0.0.1:3333
|
||||
|
||||
# 프로덕션 빌드 + 미리보기
|
||||
npm run build # tsc --noEmit && vite build → dist/
|
||||
npm run preview # → http://127.0.0.1:4173
|
||||
|
||||
# 사용
|
||||
# CSR(Path B): .glb .gltf .obj .fbx .dae .ifc 를 드롭존에 드롭 / 클릭 선택
|
||||
# SSR(Path A): http://127.0.0.1:4173/?model=/samples/Cube.ifc
|
||||
|
||||
# 검증/도구 (Git-Bash는 MSYS_NO_PATHCONV=1 접두 필요)
|
||||
node tools/perf-smoke.mjs # 포맷별 체감 로드 <3s
|
||||
node tools/adaptive-smoke.mjs --throttle 6 --seconds 30 # 60fps↓ 강등 실증
|
||||
node tools/preprocess.mjs <file.glb> # Draco+WebP 압축(GLB)
|
||||
node tools/prerender.mjs # 360° WebP 프리뷰 생성
|
||||
</pre>
|
||||
|
||||
<h2>10. 다음 단계 (선택)</h2>
|
||||
<ul>
|
||||
<li>✅ 전체 PLAN 완료 (P0–P7). 6포맷 로드 · SSR+CSR · 화면 FPS · 적응형 품질 전부 인브라우저 실증.</li>
|
||||
<li>OBJ/DAE 외부 텍스처: 다중 파일 드롭(.obj+.mtl+이미지) 또는 zip 수용 — 현재는 <code>blob:</code> 제약으로 지오메트리 전용.</li>
|
||||
<li>FBX/Collada 애니메이션: <code>AnimationMixer</code> 재생(현재 정적). <code>document.hidden</code> 배경탭 FPS 가드.</li>
|
||||
<li>KTX2 인코딩 파이프라인: KTX-Software(toktx) 설치 후 <code>gltf-transform etc1s|uastc</code>.</li>
|
||||
<li>실서버 SSR 연결 + 실기기 체감 로드 측정(현재 수치는 헤드리스).</li>
|
||||
</ul>
|
||||
|
||||
<hr class="sep" />
|
||||
<p class="meta">작업 로그: <code>PROGRESS.md</code> · 작업 그래프: <code>PLAN.md</code> · 지침: <code>CLAUDE.md</code></p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>hmwebviewer — 3D Viewer</title>
|
||||
<link rel="stylesheet" href="/src/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- SSR placeholder: pre-rendered 360 WebP fades out on hydration -->
|
||||
<img id="preview" class="preview hidden" alt="3D preview placeholder" />
|
||||
<div id="viewer" class="viewer"></div>
|
||||
<div id="controls" class="controls">
|
||||
<button id="btn-fit" type="button">Zoom Fit</button>
|
||||
<button id="btn-persp" type="button" class="active">원근뷰</button>
|
||||
<button id="btn-ortho" type="button">직교뷰</button>
|
||||
<button id="btn-outline" type="button">테두리</button>
|
||||
</div>
|
||||
<div id="progress" class="progress hidden"><div class="bar"></div></div>
|
||||
<div id="dropzone" class="dropzone">
|
||||
<p>Drop a <strong>.glb .gltf .obj .fbx .dae .ifc .ply</strong> (+ .mtl) here, or click to browse</p>
|
||||
<p id="status" class="status"></p>
|
||||
<input id="file-input" type="file" accept=".glb,.gltf,.obj,.fbx,.dae,.ifc,.ply,.mtl" multiple hidden />
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+4304
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "hmwebviewer",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"postinstall": "node tools/copy-decoders.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"three": "^0.169.0",
|
||||
"web-ifc": "^0.0.77"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@gltf-transform/cli": "^4.4.0",
|
||||
"@gltf-transform/core": "^4.4.0",
|
||||
"@gltf-transform/extensions": "^4.4.0",
|
||||
"@gltf-transform/functions": "^4.4.0",
|
||||
"@types/three": "^0.169.0",
|
||||
"draco3d": "^1.5.7",
|
||||
"puppeteer-core": "^25.1.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# Basis Universal GPU Texture Compression
|
||||
|
||||
Basis Universal is a "[supercompressed](http://gamma.cs.unc.edu/GST/gst.pdf)"
|
||||
GPU texture and texture video compression system that outputs a highly
|
||||
compressed intermediate file format (.basis) that can be quickly transcoded to
|
||||
a wide variety of GPU texture compression formats.
|
||||
|
||||
[GitHub](https://github.com/BinomialLLC/basis_universal)
|
||||
|
||||
## Transcoders
|
||||
|
||||
Basis Universal texture data may be used in two different file formats:
|
||||
`.basis` and `.ktx2`, where `ktx2` is a standardized wrapper around basis texture data.
|
||||
|
||||
For further documentation about the Basis compressor and transcoder, refer to
|
||||
the [Basis GitHub repository](https://github.com/BinomialLLC/basis_universal).
|
||||
|
||||
The folder contains two files required for transcoding `.basis` or `.ktx2` textures:
|
||||
|
||||
* `basis_transcoder.js` — JavaScript wrapper for the WebAssembly transcoder.
|
||||
* `basis_transcoder.wasm` — WebAssembly transcoder.
|
||||
|
||||
Both are dependencies of `KTX2Loader`:
|
||||
|
||||
```js
|
||||
const ktx2Loader = new KTX2Loader();
|
||||
ktx2Loader.setTranscoderPath( 'examples/jsm/libs/basis/' );
|
||||
ktx2Loader.detectSupport( renderer );
|
||||
ktx2Loader.load( 'diffuse.ktx2', function ( texture ) {
|
||||
|
||||
const material = new THREE.MeshStandardMaterial( { map: texture } );
|
||||
|
||||
}, function () {
|
||||
|
||||
console.log( 'onProgress' );
|
||||
|
||||
}, function ( e ) {
|
||||
|
||||
console.error( e );
|
||||
|
||||
} );
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
[Apache License 2.0](https://github.com/BinomialLLC/basis_universal/blob/master/LICENSE)
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,116 @@
|
||||
var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.arrayIteratorImpl=function(h){var n=0;return function(){return n<h.length?{done:!1,value:h[n++]}:{done:!0}}};$jscomp.arrayIterator=function(h){return{next:$jscomp.arrayIteratorImpl(h)}};$jscomp.makeIterator=function(h){var n="undefined"!=typeof Symbol&&Symbol.iterator&&h[Symbol.iterator];return n?n.call(h):$jscomp.arrayIterator(h)};$jscomp.ASSUME_ES5=!1;$jscomp.ASSUME_NO_NATIVE_MAP=!1;$jscomp.ASSUME_NO_NATIVE_SET=!1;$jscomp.SIMPLE_FROUND_POLYFILL=!1;
|
||||
$jscomp.ISOLATE_POLYFILLS=!1;$jscomp.FORCE_POLYFILL_PROMISE=!1;$jscomp.FORCE_POLYFILL_PROMISE_WHEN_NO_UNHANDLED_REJECTION=!1;$jscomp.getGlobal=function(h){h=["object"==typeof globalThis&&globalThis,h,"object"==typeof window&&window,"object"==typeof self&&self,"object"==typeof global&&global];for(var n=0;n<h.length;++n){var k=h[n];if(k&&k.Math==Math)return k}throw Error("Cannot find global object");};$jscomp.global=$jscomp.getGlobal(this);
|
||||
$jscomp.defineProperty=$jscomp.ASSUME_ES5||"function"==typeof Object.defineProperties?Object.defineProperty:function(h,n,k){if(h==Array.prototype||h==Object.prototype)return h;h[n]=k.value;return h};$jscomp.IS_SYMBOL_NATIVE="function"===typeof Symbol&&"symbol"===typeof Symbol("x");$jscomp.TRUST_ES6_POLYFILLS=!$jscomp.ISOLATE_POLYFILLS||$jscomp.IS_SYMBOL_NATIVE;$jscomp.polyfills={};$jscomp.propertyToPolyfillSymbol={};$jscomp.POLYFILL_PREFIX="$jscp$";
|
||||
var $jscomp$lookupPolyfilledValue=function(h,n){var k=$jscomp.propertyToPolyfillSymbol[n];if(null==k)return h[n];k=h[k];return void 0!==k?k:h[n]};$jscomp.polyfill=function(h,n,k,p){n&&($jscomp.ISOLATE_POLYFILLS?$jscomp.polyfillIsolated(h,n,k,p):$jscomp.polyfillUnisolated(h,n,k,p))};
|
||||
$jscomp.polyfillUnisolated=function(h,n,k,p){k=$jscomp.global;h=h.split(".");for(p=0;p<h.length-1;p++){var l=h[p];if(!(l in k))return;k=k[l]}h=h[h.length-1];p=k[h];n=n(p);n!=p&&null!=n&&$jscomp.defineProperty(k,h,{configurable:!0,writable:!0,value:n})};
|
||||
$jscomp.polyfillIsolated=function(h,n,k,p){var l=h.split(".");h=1===l.length;p=l[0];p=!h&&p in $jscomp.polyfills?$jscomp.polyfills:$jscomp.global;for(var y=0;y<l.length-1;y++){var f=l[y];if(!(f in p))return;p=p[f]}l=l[l.length-1];k=$jscomp.IS_SYMBOL_NATIVE&&"es6"===k?p[l]:null;n=n(k);null!=n&&(h?$jscomp.defineProperty($jscomp.polyfills,l,{configurable:!0,writable:!0,value:n}):n!==k&&(void 0===$jscomp.propertyToPolyfillSymbol[l]&&(k=1E9*Math.random()>>>0,$jscomp.propertyToPolyfillSymbol[l]=$jscomp.IS_SYMBOL_NATIVE?
|
||||
$jscomp.global.Symbol(l):$jscomp.POLYFILL_PREFIX+k+"$"+l),$jscomp.defineProperty(p,$jscomp.propertyToPolyfillSymbol[l],{configurable:!0,writable:!0,value:n})))};
|
||||
$jscomp.polyfill("Promise",function(h){function n(){this.batch_=null}function k(f){return f instanceof l?f:new l(function(q,u){q(f)})}if(h&&(!($jscomp.FORCE_POLYFILL_PROMISE||$jscomp.FORCE_POLYFILL_PROMISE_WHEN_NO_UNHANDLED_REJECTION&&"undefined"===typeof $jscomp.global.PromiseRejectionEvent)||!$jscomp.global.Promise||-1===$jscomp.global.Promise.toString().indexOf("[native code]")))return h;n.prototype.asyncExecute=function(f){if(null==this.batch_){this.batch_=[];var q=this;this.asyncExecuteFunction(function(){q.executeBatch_()})}this.batch_.push(f)};
|
||||
var p=$jscomp.global.setTimeout;n.prototype.asyncExecuteFunction=function(f){p(f,0)};n.prototype.executeBatch_=function(){for(;this.batch_&&this.batch_.length;){var f=this.batch_;this.batch_=[];for(var q=0;q<f.length;++q){var u=f[q];f[q]=null;try{u()}catch(A){this.asyncThrow_(A)}}}this.batch_=null};n.prototype.asyncThrow_=function(f){this.asyncExecuteFunction(function(){throw f;})};var l=function(f){this.state_=0;this.result_=void 0;this.onSettledCallbacks_=[];this.isRejectionHandled_=!1;var q=this.createResolveAndReject_();
|
||||
try{f(q.resolve,q.reject)}catch(u){q.reject(u)}};l.prototype.createResolveAndReject_=function(){function f(A){return function(F){u||(u=!0,A.call(q,F))}}var q=this,u=!1;return{resolve:f(this.resolveTo_),reject:f(this.reject_)}};l.prototype.resolveTo_=function(f){if(f===this)this.reject_(new TypeError("A Promise cannot resolve to itself"));else if(f instanceof l)this.settleSameAsPromise_(f);else{a:switch(typeof f){case "object":var q=null!=f;break a;case "function":q=!0;break a;default:q=!1}q?this.resolveToNonPromiseObj_(f):
|
||||
this.fulfill_(f)}};l.prototype.resolveToNonPromiseObj_=function(f){var q=void 0;try{q=f.then}catch(u){this.reject_(u);return}"function"==typeof q?this.settleSameAsThenable_(q,f):this.fulfill_(f)};l.prototype.reject_=function(f){this.settle_(2,f)};l.prototype.fulfill_=function(f){this.settle_(1,f)};l.prototype.settle_=function(f,q){if(0!=this.state_)throw Error("Cannot settle("+f+", "+q+"): Promise already settled in state"+this.state_);this.state_=f;this.result_=q;2===this.state_&&this.scheduleUnhandledRejectionCheck_();
|
||||
this.executeOnSettledCallbacks_()};l.prototype.scheduleUnhandledRejectionCheck_=function(){var f=this;p(function(){if(f.notifyUnhandledRejection_()){var q=$jscomp.global.console;"undefined"!==typeof q&&q.error(f.result_)}},1)};l.prototype.notifyUnhandledRejection_=function(){if(this.isRejectionHandled_)return!1;var f=$jscomp.global.CustomEvent,q=$jscomp.global.Event,u=$jscomp.global.dispatchEvent;if("undefined"===typeof u)return!0;"function"===typeof f?f=new f("unhandledrejection",{cancelable:!0}):
|
||||
"function"===typeof q?f=new q("unhandledrejection",{cancelable:!0}):(f=$jscomp.global.document.createEvent("CustomEvent"),f.initCustomEvent("unhandledrejection",!1,!0,f));f.promise=this;f.reason=this.result_;return u(f)};l.prototype.executeOnSettledCallbacks_=function(){if(null!=this.onSettledCallbacks_){for(var f=0;f<this.onSettledCallbacks_.length;++f)y.asyncExecute(this.onSettledCallbacks_[f]);this.onSettledCallbacks_=null}};var y=new n;l.prototype.settleSameAsPromise_=function(f){var q=this.createResolveAndReject_();
|
||||
f.callWhenSettled_(q.resolve,q.reject)};l.prototype.settleSameAsThenable_=function(f,q){var u=this.createResolveAndReject_();try{f.call(q,u.resolve,u.reject)}catch(A){u.reject(A)}};l.prototype.then=function(f,q){function u(w,B){return"function"==typeof w?function(R){try{A(w(R))}catch(Z){F(Z)}}:B}var A,F,v=new l(function(w,B){A=w;F=B});this.callWhenSettled_(u(f,A),u(q,F));return v};l.prototype.catch=function(f){return this.then(void 0,f)};l.prototype.callWhenSettled_=function(f,q){function u(){switch(A.state_){case 1:f(A.result_);
|
||||
break;case 2:q(A.result_);break;default:throw Error("Unexpected state: "+A.state_);}}var A=this;null==this.onSettledCallbacks_?y.asyncExecute(u):this.onSettledCallbacks_.push(u);this.isRejectionHandled_=!0};l.resolve=k;l.reject=function(f){return new l(function(q,u){u(f)})};l.race=function(f){return new l(function(q,u){for(var A=$jscomp.makeIterator(f),F=A.next();!F.done;F=A.next())k(F.value).callWhenSettled_(q,u)})};l.all=function(f){var q=$jscomp.makeIterator(f),u=q.next();return u.done?k([]):new l(function(A,
|
||||
F){function v(R){return function(Z){w[R]=Z;B--;0==B&&A(w)}}var w=[],B=0;do w.push(void 0),B++,k(u.value).callWhenSettled_(v(w.length-1),F),u=q.next();while(!u.done)})};return l},"es6","es3");$jscomp.owns=function(h,n){return Object.prototype.hasOwnProperty.call(h,n)};$jscomp.assign=$jscomp.TRUST_ES6_POLYFILLS&&"function"==typeof Object.assign?Object.assign:function(h,n){for(var k=1;k<arguments.length;k++){var p=arguments[k];if(p)for(var l in p)$jscomp.owns(p,l)&&(h[l]=p[l])}return h};
|
||||
$jscomp.polyfill("Object.assign",function(h){return h||$jscomp.assign},"es6","es3");$jscomp.checkStringArgs=function(h,n,k){if(null==h)throw new TypeError("The 'this' value for String.prototype."+k+" must not be null or undefined");if(n instanceof RegExp)throw new TypeError("First argument to String.prototype."+k+" must not be a regular expression");return h+""};
|
||||
$jscomp.polyfill("String.prototype.startsWith",function(h){return h?h:function(n,k){var p=$jscomp.checkStringArgs(this,n,"startsWith");n+="";var l=p.length,y=n.length;k=Math.max(0,Math.min(k|0,p.length));for(var f=0;f<y&&k<l;)if(p[k++]!=n[f++])return!1;return f>=y}},"es6","es3");
|
||||
$jscomp.polyfill("Array.prototype.copyWithin",function(h){function n(k){k=Number(k);return Infinity===k||-Infinity===k?k:k|0}return h?h:function(k,p,l){var y=this.length;k=n(k);p=n(p);l=void 0===l?y:n(l);k=0>k?Math.max(y+k,0):Math.min(k,y);p=0>p?Math.max(y+p,0):Math.min(p,y);l=0>l?Math.max(y+l,0):Math.min(l,y);if(k<p)for(;p<l;)p in this?this[k++]=this[p++]:(delete this[k++],p++);else for(l=Math.min(l,y+p-k),k+=l-p;l>p;)--l in this?this[--k]=this[l]:delete this[--k];return this}},"es6","es3");
|
||||
$jscomp.typedArrayCopyWithin=function(h){return h?h:Array.prototype.copyWithin};$jscomp.polyfill("Int8Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Uint8Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Uint8ClampedArray.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Int16Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");
|
||||
$jscomp.polyfill("Uint16Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Int32Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Uint32Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Float32Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Float64Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");
|
||||
var DracoDecoderModule=function(){var h="undefined"!==typeof document&&document.currentScript?document.currentScript.src:void 0;"undefined"!==typeof __filename&&(h=h||__filename);return function(n){function k(e){return a.locateFile?a.locateFile(e,U):U+e}function p(e,b){if(e){var c=ia;var d=e+b;for(b=e;c[b]&&!(b>=d);)++b;if(16<b-e&&c.buffer&&ra)c=ra.decode(c.subarray(e,b));else{for(d="";e<b;){var g=c[e++];if(g&128){var t=c[e++]&63;if(192==(g&224))d+=String.fromCharCode((g&31)<<6|t);else{var aa=c[e++]&
|
||||
63;g=224==(g&240)?(g&15)<<12|t<<6|aa:(g&7)<<18|t<<12|aa<<6|c[e++]&63;65536>g?d+=String.fromCharCode(g):(g-=65536,d+=String.fromCharCode(55296|g>>10,56320|g&1023))}}else d+=String.fromCharCode(g)}c=d}}else c="";return c}function l(){var e=ja.buffer;a.HEAP8=W=new Int8Array(e);a.HEAP16=new Int16Array(e);a.HEAP32=ca=new Int32Array(e);a.HEAPU8=ia=new Uint8Array(e);a.HEAPU16=new Uint16Array(e);a.HEAPU32=Y=new Uint32Array(e);a.HEAPF32=new Float32Array(e);a.HEAPF64=new Float64Array(e)}function y(e){if(a.onAbort)a.onAbort(e);
|
||||
e="Aborted("+e+")";da(e);sa=!0;e=new WebAssembly.RuntimeError(e+". Build with -sASSERTIONS for more info.");ka(e);throw e;}function f(e){try{if(e==P&&ea)return new Uint8Array(ea);if(ma)return ma(e);throw"both async and sync fetching of the wasm failed";}catch(b){y(b)}}function q(){if(!ea&&(ta||fa)){if("function"==typeof fetch&&!P.startsWith("file://"))return fetch(P,{credentials:"same-origin"}).then(function(e){if(!e.ok)throw"failed to load wasm binary file at '"+P+"'";return e.arrayBuffer()}).catch(function(){return f(P)});
|
||||
if(na)return new Promise(function(e,b){na(P,function(c){e(new Uint8Array(c))},b)})}return Promise.resolve().then(function(){return f(P)})}function u(e){for(;0<e.length;)e.shift()(a)}function A(e){this.excPtr=e;this.ptr=e-24;this.set_type=function(b){Y[this.ptr+4>>2]=b};this.get_type=function(){return Y[this.ptr+4>>2]};this.set_destructor=function(b){Y[this.ptr+8>>2]=b};this.get_destructor=function(){return Y[this.ptr+8>>2]};this.set_refcount=function(b){ca[this.ptr>>2]=b};this.set_caught=function(b){W[this.ptr+
|
||||
12>>0]=b?1:0};this.get_caught=function(){return 0!=W[this.ptr+12>>0]};this.set_rethrown=function(b){W[this.ptr+13>>0]=b?1:0};this.get_rethrown=function(){return 0!=W[this.ptr+13>>0]};this.init=function(b,c){this.set_adjusted_ptr(0);this.set_type(b);this.set_destructor(c);this.set_refcount(0);this.set_caught(!1);this.set_rethrown(!1)};this.add_ref=function(){ca[this.ptr>>2]+=1};this.release_ref=function(){var b=ca[this.ptr>>2];ca[this.ptr>>2]=b-1;return 1===b};this.set_adjusted_ptr=function(b){Y[this.ptr+
|
||||
16>>2]=b};this.get_adjusted_ptr=function(){return Y[this.ptr+16>>2]};this.get_exception_ptr=function(){if(ua(this.get_type()))return Y[this.excPtr>>2];var b=this.get_adjusted_ptr();return 0!==b?b:this.excPtr}}function F(){function e(){if(!la&&(la=!0,a.calledRun=!0,!sa)){va=!0;u(oa);wa(a);if(a.onRuntimeInitialized)a.onRuntimeInitialized();if(a.postRun)for("function"==typeof a.postRun&&(a.postRun=[a.postRun]);a.postRun.length;)xa.unshift(a.postRun.shift());u(xa)}}if(!(0<ba)){if(a.preRun)for("function"==
|
||||
typeof a.preRun&&(a.preRun=[a.preRun]);a.preRun.length;)ya.unshift(a.preRun.shift());u(ya);0<ba||(a.setStatus?(a.setStatus("Running..."),setTimeout(function(){setTimeout(function(){a.setStatus("")},1);e()},1)):e())}}function v(){}function w(e){return(e||v).__cache__}function B(e,b){var c=w(b),d=c[e];if(d)return d;d=Object.create((b||v).prototype);d.ptr=e;return c[e]=d}function R(e){if("string"===typeof e){for(var b=0,c=0;c<e.length;++c){var d=e.charCodeAt(c);127>=d?b++:2047>=d?b+=2:55296<=d&&57343>=
|
||||
d?(b+=4,++c):b+=3}b=Array(b+1);c=0;d=b.length;if(0<d){d=c+d-1;for(var g=0;g<e.length;++g){var t=e.charCodeAt(g);if(55296<=t&&57343>=t){var aa=e.charCodeAt(++g);t=65536+((t&1023)<<10)|aa&1023}if(127>=t){if(c>=d)break;b[c++]=t}else{if(2047>=t){if(c+1>=d)break;b[c++]=192|t>>6}else{if(65535>=t){if(c+2>=d)break;b[c++]=224|t>>12}else{if(c+3>=d)break;b[c++]=240|t>>18;b[c++]=128|t>>12&63}b[c++]=128|t>>6&63}b[c++]=128|t&63}}b[c]=0}e=r.alloc(b,W);r.copy(b,W,e);return e}return e}function Z(e){if("object"===
|
||||
typeof e){var b=r.alloc(e,W);r.copy(e,W,b);return b}return e}function X(){throw"cannot construct a VoidPtr, no constructor in IDL";}function S(){this.ptr=za();w(S)[this.ptr]=this}function Q(){this.ptr=Aa();w(Q)[this.ptr]=this}function V(){this.ptr=Ba();w(V)[this.ptr]=this}function x(){this.ptr=Ca();w(x)[this.ptr]=this}function D(){this.ptr=Da();w(D)[this.ptr]=this}function G(){this.ptr=Ea();w(G)[this.ptr]=this}function H(){this.ptr=Fa();w(H)[this.ptr]=this}function E(){this.ptr=Ga();w(E)[this.ptr]=
|
||||
this}function T(){this.ptr=Ha();w(T)[this.ptr]=this}function C(){throw"cannot construct a Status, no constructor in IDL";}function I(){this.ptr=Ia();w(I)[this.ptr]=this}function J(){this.ptr=Ja();w(J)[this.ptr]=this}function K(){this.ptr=Ka();w(K)[this.ptr]=this}function L(){this.ptr=La();w(L)[this.ptr]=this}function M(){this.ptr=Ma();w(M)[this.ptr]=this}function N(){this.ptr=Na();w(N)[this.ptr]=this}function O(){this.ptr=Oa();w(O)[this.ptr]=this}function z(){this.ptr=Pa();w(z)[this.ptr]=this}function m(){this.ptr=
|
||||
Qa();w(m)[this.ptr]=this}n=void 0===n?{}:n;var a="undefined"!=typeof n?n:{},wa,ka;a.ready=new Promise(function(e,b){wa=e;ka=b});var Ra=!1,Sa=!1;a.onRuntimeInitialized=function(){Ra=!0;if(Sa&&"function"===typeof a.onModuleLoaded)a.onModuleLoaded(a)};a.onModuleParsed=function(){Sa=!0;if(Ra&&"function"===typeof a.onModuleLoaded)a.onModuleLoaded(a)};a.isVersionSupported=function(e){if("string"!==typeof e)return!1;e=e.split(".");return 2>e.length||3<e.length?!1:1==e[0]&&0<=e[1]&&5>=e[1]?!0:0!=e[0]||10<
|
||||
e[1]?!1:!0};var Ta=Object.assign({},a),ta="object"==typeof window,fa="function"==typeof importScripts,Ua="object"==typeof process&&"object"==typeof process.versions&&"string"==typeof process.versions.node,U="";if(Ua){var Va=require("fs"),pa=require("path");U=fa?pa.dirname(U)+"/":__dirname+"/";var Wa=function(e,b){e=e.startsWith("file://")?new URL(e):pa.normalize(e);return Va.readFileSync(e,b?void 0:"utf8")};var ma=function(e){e=Wa(e,!0);e.buffer||(e=new Uint8Array(e));return e};var na=function(e,
|
||||
b,c){e=e.startsWith("file://")?new URL(e):pa.normalize(e);Va.readFile(e,function(d,g){d?c(d):b(g.buffer)})};1<process.argv.length&&process.argv[1].replace(/\\/g,"/");process.argv.slice(2);a.inspect=function(){return"[Emscripten Module object]"}}else if(ta||fa)fa?U=self.location.href:"undefined"!=typeof document&&document.currentScript&&(U=document.currentScript.src),h&&(U=h),U=0!==U.indexOf("blob:")?U.substr(0,U.replace(/[?#].*/,"").lastIndexOf("/")+1):"",Wa=function(e){var b=new XMLHttpRequest;b.open("GET",
|
||||
e,!1);b.send(null);return b.responseText},fa&&(ma=function(e){var b=new XMLHttpRequest;b.open("GET",e,!1);b.responseType="arraybuffer";b.send(null);return new Uint8Array(b.response)}),na=function(e,b,c){var d=new XMLHttpRequest;d.open("GET",e,!0);d.responseType="arraybuffer";d.onload=function(){200==d.status||0==d.status&&d.response?b(d.response):c()};d.onerror=c;d.send(null)};a.print||console.log.bind(console);var da=a.printErr||console.warn.bind(console);Object.assign(a,Ta);Ta=null;var ea;a.wasmBinary&&
|
||||
(ea=a.wasmBinary);"object"!=typeof WebAssembly&&y("no native wasm support detected");var ja,sa=!1,ra="undefined"!=typeof TextDecoder?new TextDecoder("utf8"):void 0,W,ia,ca,Y,ya=[],oa=[],xa=[],va=!1,ba=0,qa=null,ha=null;var P="draco_decoder_gltf.wasm";P.startsWith("data:application/octet-stream;base64,")||(P=k(P));var pd=0,qd={b:function(e,b,c){(new A(e)).init(b,c);pd++;throw e;},a:function(){y("")},d:function(e,b,c){ia.copyWithin(e,b,b+c)},c:function(e){var b=ia.length;e>>>=0;if(2147483648<e)return!1;
|
||||
for(var c=1;4>=c;c*=2){var d=b*(1+.2/c);d=Math.min(d,e+100663296);var g=Math;d=Math.max(e,d);g=g.min.call(g,2147483648,d+(65536-d%65536)%65536);a:{d=ja.buffer;try{ja.grow(g-d.byteLength+65535>>>16);l();var t=1;break a}catch(aa){}t=void 0}if(t)return!0}return!1}};(function(){function e(g,t){a.asm=g.exports;ja=a.asm.e;l();oa.unshift(a.asm.f);ba--;a.monitorRunDependencies&&a.monitorRunDependencies(ba);0==ba&&(null!==qa&&(clearInterval(qa),qa=null),ha&&(g=ha,ha=null,g()))}function b(g){e(g.instance)}
|
||||
function c(g){return q().then(function(t){return WebAssembly.instantiate(t,d)}).then(function(t){return t}).then(g,function(t){da("failed to asynchronously prepare wasm: "+t);y(t)})}var d={a:qd};ba++;a.monitorRunDependencies&&a.monitorRunDependencies(ba);if(a.instantiateWasm)try{return a.instantiateWasm(d,e)}catch(g){da("Module.instantiateWasm callback failed with error: "+g),ka(g)}(function(){return ea||"function"!=typeof WebAssembly.instantiateStreaming||P.startsWith("data:application/octet-stream;base64,")||
|
||||
P.startsWith("file://")||Ua||"function"!=typeof fetch?c(b):fetch(P,{credentials:"same-origin"}).then(function(g){return WebAssembly.instantiateStreaming(g,d).then(b,function(t){da("wasm streaming compile failed: "+t);da("falling back to ArrayBuffer instantiation");return c(b)})})})().catch(ka);return{}})();var Xa=a._emscripten_bind_VoidPtr___destroy___0=function(){return(Xa=a._emscripten_bind_VoidPtr___destroy___0=a.asm.h).apply(null,arguments)},za=a._emscripten_bind_DecoderBuffer_DecoderBuffer_0=
|
||||
function(){return(za=a._emscripten_bind_DecoderBuffer_DecoderBuffer_0=a.asm.i).apply(null,arguments)},Ya=a._emscripten_bind_DecoderBuffer_Init_2=function(){return(Ya=a._emscripten_bind_DecoderBuffer_Init_2=a.asm.j).apply(null,arguments)},Za=a._emscripten_bind_DecoderBuffer___destroy___0=function(){return(Za=a._emscripten_bind_DecoderBuffer___destroy___0=a.asm.k).apply(null,arguments)},Aa=a._emscripten_bind_AttributeTransformData_AttributeTransformData_0=function(){return(Aa=a._emscripten_bind_AttributeTransformData_AttributeTransformData_0=
|
||||
a.asm.l).apply(null,arguments)},$a=a._emscripten_bind_AttributeTransformData_transform_type_0=function(){return($a=a._emscripten_bind_AttributeTransformData_transform_type_0=a.asm.m).apply(null,arguments)},ab=a._emscripten_bind_AttributeTransformData___destroy___0=function(){return(ab=a._emscripten_bind_AttributeTransformData___destroy___0=a.asm.n).apply(null,arguments)},Ba=a._emscripten_bind_GeometryAttribute_GeometryAttribute_0=function(){return(Ba=a._emscripten_bind_GeometryAttribute_GeometryAttribute_0=
|
||||
a.asm.o).apply(null,arguments)},bb=a._emscripten_bind_GeometryAttribute___destroy___0=function(){return(bb=a._emscripten_bind_GeometryAttribute___destroy___0=a.asm.p).apply(null,arguments)},Ca=a._emscripten_bind_PointAttribute_PointAttribute_0=function(){return(Ca=a._emscripten_bind_PointAttribute_PointAttribute_0=a.asm.q).apply(null,arguments)},cb=a._emscripten_bind_PointAttribute_size_0=function(){return(cb=a._emscripten_bind_PointAttribute_size_0=a.asm.r).apply(null,arguments)},db=a._emscripten_bind_PointAttribute_GetAttributeTransformData_0=
|
||||
function(){return(db=a._emscripten_bind_PointAttribute_GetAttributeTransformData_0=a.asm.s).apply(null,arguments)},eb=a._emscripten_bind_PointAttribute_attribute_type_0=function(){return(eb=a._emscripten_bind_PointAttribute_attribute_type_0=a.asm.t).apply(null,arguments)},fb=a._emscripten_bind_PointAttribute_data_type_0=function(){return(fb=a._emscripten_bind_PointAttribute_data_type_0=a.asm.u).apply(null,arguments)},gb=a._emscripten_bind_PointAttribute_num_components_0=function(){return(gb=a._emscripten_bind_PointAttribute_num_components_0=
|
||||
a.asm.v).apply(null,arguments)},hb=a._emscripten_bind_PointAttribute_normalized_0=function(){return(hb=a._emscripten_bind_PointAttribute_normalized_0=a.asm.w).apply(null,arguments)},ib=a._emscripten_bind_PointAttribute_byte_stride_0=function(){return(ib=a._emscripten_bind_PointAttribute_byte_stride_0=a.asm.x).apply(null,arguments)},jb=a._emscripten_bind_PointAttribute_byte_offset_0=function(){return(jb=a._emscripten_bind_PointAttribute_byte_offset_0=a.asm.y).apply(null,arguments)},kb=a._emscripten_bind_PointAttribute_unique_id_0=
|
||||
function(){return(kb=a._emscripten_bind_PointAttribute_unique_id_0=a.asm.z).apply(null,arguments)},lb=a._emscripten_bind_PointAttribute___destroy___0=function(){return(lb=a._emscripten_bind_PointAttribute___destroy___0=a.asm.A).apply(null,arguments)},Da=a._emscripten_bind_AttributeQuantizationTransform_AttributeQuantizationTransform_0=function(){return(Da=a._emscripten_bind_AttributeQuantizationTransform_AttributeQuantizationTransform_0=a.asm.B).apply(null,arguments)},mb=a._emscripten_bind_AttributeQuantizationTransform_InitFromAttribute_1=
|
||||
function(){return(mb=a._emscripten_bind_AttributeQuantizationTransform_InitFromAttribute_1=a.asm.C).apply(null,arguments)},nb=a._emscripten_bind_AttributeQuantizationTransform_quantization_bits_0=function(){return(nb=a._emscripten_bind_AttributeQuantizationTransform_quantization_bits_0=a.asm.D).apply(null,arguments)},ob=a._emscripten_bind_AttributeQuantizationTransform_min_value_1=function(){return(ob=a._emscripten_bind_AttributeQuantizationTransform_min_value_1=a.asm.E).apply(null,arguments)},pb=
|
||||
a._emscripten_bind_AttributeQuantizationTransform_range_0=function(){return(pb=a._emscripten_bind_AttributeQuantizationTransform_range_0=a.asm.F).apply(null,arguments)},qb=a._emscripten_bind_AttributeQuantizationTransform___destroy___0=function(){return(qb=a._emscripten_bind_AttributeQuantizationTransform___destroy___0=a.asm.G).apply(null,arguments)},Ea=a._emscripten_bind_AttributeOctahedronTransform_AttributeOctahedronTransform_0=function(){return(Ea=a._emscripten_bind_AttributeOctahedronTransform_AttributeOctahedronTransform_0=
|
||||
a.asm.H).apply(null,arguments)},rb=a._emscripten_bind_AttributeOctahedronTransform_InitFromAttribute_1=function(){return(rb=a._emscripten_bind_AttributeOctahedronTransform_InitFromAttribute_1=a.asm.I).apply(null,arguments)},sb=a._emscripten_bind_AttributeOctahedronTransform_quantization_bits_0=function(){return(sb=a._emscripten_bind_AttributeOctahedronTransform_quantization_bits_0=a.asm.J).apply(null,arguments)},tb=a._emscripten_bind_AttributeOctahedronTransform___destroy___0=function(){return(tb=
|
||||
a._emscripten_bind_AttributeOctahedronTransform___destroy___0=a.asm.K).apply(null,arguments)},Fa=a._emscripten_bind_PointCloud_PointCloud_0=function(){return(Fa=a._emscripten_bind_PointCloud_PointCloud_0=a.asm.L).apply(null,arguments)},ub=a._emscripten_bind_PointCloud_num_attributes_0=function(){return(ub=a._emscripten_bind_PointCloud_num_attributes_0=a.asm.M).apply(null,arguments)},vb=a._emscripten_bind_PointCloud_num_points_0=function(){return(vb=a._emscripten_bind_PointCloud_num_points_0=a.asm.N).apply(null,
|
||||
arguments)},wb=a._emscripten_bind_PointCloud___destroy___0=function(){return(wb=a._emscripten_bind_PointCloud___destroy___0=a.asm.O).apply(null,arguments)},Ga=a._emscripten_bind_Mesh_Mesh_0=function(){return(Ga=a._emscripten_bind_Mesh_Mesh_0=a.asm.P).apply(null,arguments)},xb=a._emscripten_bind_Mesh_num_faces_0=function(){return(xb=a._emscripten_bind_Mesh_num_faces_0=a.asm.Q).apply(null,arguments)},yb=a._emscripten_bind_Mesh_num_attributes_0=function(){return(yb=a._emscripten_bind_Mesh_num_attributes_0=
|
||||
a.asm.R).apply(null,arguments)},zb=a._emscripten_bind_Mesh_num_points_0=function(){return(zb=a._emscripten_bind_Mesh_num_points_0=a.asm.S).apply(null,arguments)},Ab=a._emscripten_bind_Mesh___destroy___0=function(){return(Ab=a._emscripten_bind_Mesh___destroy___0=a.asm.T).apply(null,arguments)},Ha=a._emscripten_bind_Metadata_Metadata_0=function(){return(Ha=a._emscripten_bind_Metadata_Metadata_0=a.asm.U).apply(null,arguments)},Bb=a._emscripten_bind_Metadata___destroy___0=function(){return(Bb=a._emscripten_bind_Metadata___destroy___0=
|
||||
a.asm.V).apply(null,arguments)},Cb=a._emscripten_bind_Status_code_0=function(){return(Cb=a._emscripten_bind_Status_code_0=a.asm.W).apply(null,arguments)},Db=a._emscripten_bind_Status_ok_0=function(){return(Db=a._emscripten_bind_Status_ok_0=a.asm.X).apply(null,arguments)},Eb=a._emscripten_bind_Status_error_msg_0=function(){return(Eb=a._emscripten_bind_Status_error_msg_0=a.asm.Y).apply(null,arguments)},Fb=a._emscripten_bind_Status___destroy___0=function(){return(Fb=a._emscripten_bind_Status___destroy___0=
|
||||
a.asm.Z).apply(null,arguments)},Ia=a._emscripten_bind_DracoFloat32Array_DracoFloat32Array_0=function(){return(Ia=a._emscripten_bind_DracoFloat32Array_DracoFloat32Array_0=a.asm._).apply(null,arguments)},Gb=a._emscripten_bind_DracoFloat32Array_GetValue_1=function(){return(Gb=a._emscripten_bind_DracoFloat32Array_GetValue_1=a.asm.$).apply(null,arguments)},Hb=a._emscripten_bind_DracoFloat32Array_size_0=function(){return(Hb=a._emscripten_bind_DracoFloat32Array_size_0=a.asm.aa).apply(null,arguments)},Ib=
|
||||
a._emscripten_bind_DracoFloat32Array___destroy___0=function(){return(Ib=a._emscripten_bind_DracoFloat32Array___destroy___0=a.asm.ba).apply(null,arguments)},Ja=a._emscripten_bind_DracoInt8Array_DracoInt8Array_0=function(){return(Ja=a._emscripten_bind_DracoInt8Array_DracoInt8Array_0=a.asm.ca).apply(null,arguments)},Jb=a._emscripten_bind_DracoInt8Array_GetValue_1=function(){return(Jb=a._emscripten_bind_DracoInt8Array_GetValue_1=a.asm.da).apply(null,arguments)},Kb=a._emscripten_bind_DracoInt8Array_size_0=
|
||||
function(){return(Kb=a._emscripten_bind_DracoInt8Array_size_0=a.asm.ea).apply(null,arguments)},Lb=a._emscripten_bind_DracoInt8Array___destroy___0=function(){return(Lb=a._emscripten_bind_DracoInt8Array___destroy___0=a.asm.fa).apply(null,arguments)},Ka=a._emscripten_bind_DracoUInt8Array_DracoUInt8Array_0=function(){return(Ka=a._emscripten_bind_DracoUInt8Array_DracoUInt8Array_0=a.asm.ga).apply(null,arguments)},Mb=a._emscripten_bind_DracoUInt8Array_GetValue_1=function(){return(Mb=a._emscripten_bind_DracoUInt8Array_GetValue_1=
|
||||
a.asm.ha).apply(null,arguments)},Nb=a._emscripten_bind_DracoUInt8Array_size_0=function(){return(Nb=a._emscripten_bind_DracoUInt8Array_size_0=a.asm.ia).apply(null,arguments)},Ob=a._emscripten_bind_DracoUInt8Array___destroy___0=function(){return(Ob=a._emscripten_bind_DracoUInt8Array___destroy___0=a.asm.ja).apply(null,arguments)},La=a._emscripten_bind_DracoInt16Array_DracoInt16Array_0=function(){return(La=a._emscripten_bind_DracoInt16Array_DracoInt16Array_0=a.asm.ka).apply(null,arguments)},Pb=a._emscripten_bind_DracoInt16Array_GetValue_1=
|
||||
function(){return(Pb=a._emscripten_bind_DracoInt16Array_GetValue_1=a.asm.la).apply(null,arguments)},Qb=a._emscripten_bind_DracoInt16Array_size_0=function(){return(Qb=a._emscripten_bind_DracoInt16Array_size_0=a.asm.ma).apply(null,arguments)},Rb=a._emscripten_bind_DracoInt16Array___destroy___0=function(){return(Rb=a._emscripten_bind_DracoInt16Array___destroy___0=a.asm.na).apply(null,arguments)},Ma=a._emscripten_bind_DracoUInt16Array_DracoUInt16Array_0=function(){return(Ma=a._emscripten_bind_DracoUInt16Array_DracoUInt16Array_0=
|
||||
a.asm.oa).apply(null,arguments)},Sb=a._emscripten_bind_DracoUInt16Array_GetValue_1=function(){return(Sb=a._emscripten_bind_DracoUInt16Array_GetValue_1=a.asm.pa).apply(null,arguments)},Tb=a._emscripten_bind_DracoUInt16Array_size_0=function(){return(Tb=a._emscripten_bind_DracoUInt16Array_size_0=a.asm.qa).apply(null,arguments)},Ub=a._emscripten_bind_DracoUInt16Array___destroy___0=function(){return(Ub=a._emscripten_bind_DracoUInt16Array___destroy___0=a.asm.ra).apply(null,arguments)},Na=a._emscripten_bind_DracoInt32Array_DracoInt32Array_0=
|
||||
function(){return(Na=a._emscripten_bind_DracoInt32Array_DracoInt32Array_0=a.asm.sa).apply(null,arguments)},Vb=a._emscripten_bind_DracoInt32Array_GetValue_1=function(){return(Vb=a._emscripten_bind_DracoInt32Array_GetValue_1=a.asm.ta).apply(null,arguments)},Wb=a._emscripten_bind_DracoInt32Array_size_0=function(){return(Wb=a._emscripten_bind_DracoInt32Array_size_0=a.asm.ua).apply(null,arguments)},Xb=a._emscripten_bind_DracoInt32Array___destroy___0=function(){return(Xb=a._emscripten_bind_DracoInt32Array___destroy___0=
|
||||
a.asm.va).apply(null,arguments)},Oa=a._emscripten_bind_DracoUInt32Array_DracoUInt32Array_0=function(){return(Oa=a._emscripten_bind_DracoUInt32Array_DracoUInt32Array_0=a.asm.wa).apply(null,arguments)},Yb=a._emscripten_bind_DracoUInt32Array_GetValue_1=function(){return(Yb=a._emscripten_bind_DracoUInt32Array_GetValue_1=a.asm.xa).apply(null,arguments)},Zb=a._emscripten_bind_DracoUInt32Array_size_0=function(){return(Zb=a._emscripten_bind_DracoUInt32Array_size_0=a.asm.ya).apply(null,arguments)},$b=a._emscripten_bind_DracoUInt32Array___destroy___0=
|
||||
function(){return($b=a._emscripten_bind_DracoUInt32Array___destroy___0=a.asm.za).apply(null,arguments)},Pa=a._emscripten_bind_MetadataQuerier_MetadataQuerier_0=function(){return(Pa=a._emscripten_bind_MetadataQuerier_MetadataQuerier_0=a.asm.Aa).apply(null,arguments)},ac=a._emscripten_bind_MetadataQuerier_HasEntry_2=function(){return(ac=a._emscripten_bind_MetadataQuerier_HasEntry_2=a.asm.Ba).apply(null,arguments)},bc=a._emscripten_bind_MetadataQuerier_GetIntEntry_2=function(){return(bc=a._emscripten_bind_MetadataQuerier_GetIntEntry_2=
|
||||
a.asm.Ca).apply(null,arguments)},cc=a._emscripten_bind_MetadataQuerier_GetIntEntryArray_3=function(){return(cc=a._emscripten_bind_MetadataQuerier_GetIntEntryArray_3=a.asm.Da).apply(null,arguments)},dc=a._emscripten_bind_MetadataQuerier_GetDoubleEntry_2=function(){return(dc=a._emscripten_bind_MetadataQuerier_GetDoubleEntry_2=a.asm.Ea).apply(null,arguments)},ec=a._emscripten_bind_MetadataQuerier_GetStringEntry_2=function(){return(ec=a._emscripten_bind_MetadataQuerier_GetStringEntry_2=a.asm.Fa).apply(null,
|
||||
arguments)},fc=a._emscripten_bind_MetadataQuerier_NumEntries_1=function(){return(fc=a._emscripten_bind_MetadataQuerier_NumEntries_1=a.asm.Ga).apply(null,arguments)},gc=a._emscripten_bind_MetadataQuerier_GetEntryName_2=function(){return(gc=a._emscripten_bind_MetadataQuerier_GetEntryName_2=a.asm.Ha).apply(null,arguments)},hc=a._emscripten_bind_MetadataQuerier___destroy___0=function(){return(hc=a._emscripten_bind_MetadataQuerier___destroy___0=a.asm.Ia).apply(null,arguments)},Qa=a._emscripten_bind_Decoder_Decoder_0=
|
||||
function(){return(Qa=a._emscripten_bind_Decoder_Decoder_0=a.asm.Ja).apply(null,arguments)},ic=a._emscripten_bind_Decoder_DecodeArrayToPointCloud_3=function(){return(ic=a._emscripten_bind_Decoder_DecodeArrayToPointCloud_3=a.asm.Ka).apply(null,arguments)},jc=a._emscripten_bind_Decoder_DecodeArrayToMesh_3=function(){return(jc=a._emscripten_bind_Decoder_DecodeArrayToMesh_3=a.asm.La).apply(null,arguments)},kc=a._emscripten_bind_Decoder_GetAttributeId_2=function(){return(kc=a._emscripten_bind_Decoder_GetAttributeId_2=
|
||||
a.asm.Ma).apply(null,arguments)},lc=a._emscripten_bind_Decoder_GetAttributeIdByName_2=function(){return(lc=a._emscripten_bind_Decoder_GetAttributeIdByName_2=a.asm.Na).apply(null,arguments)},mc=a._emscripten_bind_Decoder_GetAttributeIdByMetadataEntry_3=function(){return(mc=a._emscripten_bind_Decoder_GetAttributeIdByMetadataEntry_3=a.asm.Oa).apply(null,arguments)},nc=a._emscripten_bind_Decoder_GetAttribute_2=function(){return(nc=a._emscripten_bind_Decoder_GetAttribute_2=a.asm.Pa).apply(null,arguments)},
|
||||
oc=a._emscripten_bind_Decoder_GetAttributeByUniqueId_2=function(){return(oc=a._emscripten_bind_Decoder_GetAttributeByUniqueId_2=a.asm.Qa).apply(null,arguments)},pc=a._emscripten_bind_Decoder_GetMetadata_1=function(){return(pc=a._emscripten_bind_Decoder_GetMetadata_1=a.asm.Ra).apply(null,arguments)},qc=a._emscripten_bind_Decoder_GetAttributeMetadata_2=function(){return(qc=a._emscripten_bind_Decoder_GetAttributeMetadata_2=a.asm.Sa).apply(null,arguments)},rc=a._emscripten_bind_Decoder_GetFaceFromMesh_3=
|
||||
function(){return(rc=a._emscripten_bind_Decoder_GetFaceFromMesh_3=a.asm.Ta).apply(null,arguments)},sc=a._emscripten_bind_Decoder_GetTriangleStripsFromMesh_2=function(){return(sc=a._emscripten_bind_Decoder_GetTriangleStripsFromMesh_2=a.asm.Ua).apply(null,arguments)},tc=a._emscripten_bind_Decoder_GetTrianglesUInt16Array_3=function(){return(tc=a._emscripten_bind_Decoder_GetTrianglesUInt16Array_3=a.asm.Va).apply(null,arguments)},uc=a._emscripten_bind_Decoder_GetTrianglesUInt32Array_3=function(){return(uc=
|
||||
a._emscripten_bind_Decoder_GetTrianglesUInt32Array_3=a.asm.Wa).apply(null,arguments)},vc=a._emscripten_bind_Decoder_GetAttributeFloat_3=function(){return(vc=a._emscripten_bind_Decoder_GetAttributeFloat_3=a.asm.Xa).apply(null,arguments)},wc=a._emscripten_bind_Decoder_GetAttributeFloatForAllPoints_3=function(){return(wc=a._emscripten_bind_Decoder_GetAttributeFloatForAllPoints_3=a.asm.Ya).apply(null,arguments)},xc=a._emscripten_bind_Decoder_GetAttributeIntForAllPoints_3=function(){return(xc=a._emscripten_bind_Decoder_GetAttributeIntForAllPoints_3=
|
||||
a.asm.Za).apply(null,arguments)},yc=a._emscripten_bind_Decoder_GetAttributeInt8ForAllPoints_3=function(){return(yc=a._emscripten_bind_Decoder_GetAttributeInt8ForAllPoints_3=a.asm._a).apply(null,arguments)},zc=a._emscripten_bind_Decoder_GetAttributeUInt8ForAllPoints_3=function(){return(zc=a._emscripten_bind_Decoder_GetAttributeUInt8ForAllPoints_3=a.asm.$a).apply(null,arguments)},Ac=a._emscripten_bind_Decoder_GetAttributeInt16ForAllPoints_3=function(){return(Ac=a._emscripten_bind_Decoder_GetAttributeInt16ForAllPoints_3=
|
||||
a.asm.ab).apply(null,arguments)},Bc=a._emscripten_bind_Decoder_GetAttributeUInt16ForAllPoints_3=function(){return(Bc=a._emscripten_bind_Decoder_GetAttributeUInt16ForAllPoints_3=a.asm.bb).apply(null,arguments)},Cc=a._emscripten_bind_Decoder_GetAttributeInt32ForAllPoints_3=function(){return(Cc=a._emscripten_bind_Decoder_GetAttributeInt32ForAllPoints_3=a.asm.cb).apply(null,arguments)},Dc=a._emscripten_bind_Decoder_GetAttributeUInt32ForAllPoints_3=function(){return(Dc=a._emscripten_bind_Decoder_GetAttributeUInt32ForAllPoints_3=
|
||||
a.asm.db).apply(null,arguments)},Ec=a._emscripten_bind_Decoder_GetAttributeDataArrayForAllPoints_5=function(){return(Ec=a._emscripten_bind_Decoder_GetAttributeDataArrayForAllPoints_5=a.asm.eb).apply(null,arguments)},Fc=a._emscripten_bind_Decoder_SkipAttributeTransform_1=function(){return(Fc=a._emscripten_bind_Decoder_SkipAttributeTransform_1=a.asm.fb).apply(null,arguments)},Gc=a._emscripten_bind_Decoder_GetEncodedGeometryType_Deprecated_1=function(){return(Gc=a._emscripten_bind_Decoder_GetEncodedGeometryType_Deprecated_1=
|
||||
a.asm.gb).apply(null,arguments)},Hc=a._emscripten_bind_Decoder_DecodeBufferToPointCloud_2=function(){return(Hc=a._emscripten_bind_Decoder_DecodeBufferToPointCloud_2=a.asm.hb).apply(null,arguments)},Ic=a._emscripten_bind_Decoder_DecodeBufferToMesh_2=function(){return(Ic=a._emscripten_bind_Decoder_DecodeBufferToMesh_2=a.asm.ib).apply(null,arguments)},Jc=a._emscripten_bind_Decoder___destroy___0=function(){return(Jc=a._emscripten_bind_Decoder___destroy___0=a.asm.jb).apply(null,arguments)},Kc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_INVALID_TRANSFORM=
|
||||
function(){return(Kc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_INVALID_TRANSFORM=a.asm.kb).apply(null,arguments)},Lc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_NO_TRANSFORM=function(){return(Lc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_NO_TRANSFORM=a.asm.lb).apply(null,arguments)},Mc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_QUANTIZATION_TRANSFORM=function(){return(Mc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_QUANTIZATION_TRANSFORM=
|
||||
a.asm.mb).apply(null,arguments)},Nc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_OCTAHEDRON_TRANSFORM=function(){return(Nc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_OCTAHEDRON_TRANSFORM=a.asm.nb).apply(null,arguments)},Oc=a._emscripten_enum_draco_GeometryAttribute_Type_INVALID=function(){return(Oc=a._emscripten_enum_draco_GeometryAttribute_Type_INVALID=a.asm.ob).apply(null,arguments)},Pc=a._emscripten_enum_draco_GeometryAttribute_Type_POSITION=function(){return(Pc=a._emscripten_enum_draco_GeometryAttribute_Type_POSITION=
|
||||
a.asm.pb).apply(null,arguments)},Qc=a._emscripten_enum_draco_GeometryAttribute_Type_NORMAL=function(){return(Qc=a._emscripten_enum_draco_GeometryAttribute_Type_NORMAL=a.asm.qb).apply(null,arguments)},Rc=a._emscripten_enum_draco_GeometryAttribute_Type_COLOR=function(){return(Rc=a._emscripten_enum_draco_GeometryAttribute_Type_COLOR=a.asm.rb).apply(null,arguments)},Sc=a._emscripten_enum_draco_GeometryAttribute_Type_TEX_COORD=function(){return(Sc=a._emscripten_enum_draco_GeometryAttribute_Type_TEX_COORD=
|
||||
a.asm.sb).apply(null,arguments)},Tc=a._emscripten_enum_draco_GeometryAttribute_Type_GENERIC=function(){return(Tc=a._emscripten_enum_draco_GeometryAttribute_Type_GENERIC=a.asm.tb).apply(null,arguments)},Uc=a._emscripten_enum_draco_EncodedGeometryType_INVALID_GEOMETRY_TYPE=function(){return(Uc=a._emscripten_enum_draco_EncodedGeometryType_INVALID_GEOMETRY_TYPE=a.asm.ub).apply(null,arguments)},Vc=a._emscripten_enum_draco_EncodedGeometryType_POINT_CLOUD=function(){return(Vc=a._emscripten_enum_draco_EncodedGeometryType_POINT_CLOUD=
|
||||
a.asm.vb).apply(null,arguments)},Wc=a._emscripten_enum_draco_EncodedGeometryType_TRIANGULAR_MESH=function(){return(Wc=a._emscripten_enum_draco_EncodedGeometryType_TRIANGULAR_MESH=a.asm.wb).apply(null,arguments)},Xc=a._emscripten_enum_draco_DataType_DT_INVALID=function(){return(Xc=a._emscripten_enum_draco_DataType_DT_INVALID=a.asm.xb).apply(null,arguments)},Yc=a._emscripten_enum_draco_DataType_DT_INT8=function(){return(Yc=a._emscripten_enum_draco_DataType_DT_INT8=a.asm.yb).apply(null,arguments)},Zc=
|
||||
a._emscripten_enum_draco_DataType_DT_UINT8=function(){return(Zc=a._emscripten_enum_draco_DataType_DT_UINT8=a.asm.zb).apply(null,arguments)},$c=a._emscripten_enum_draco_DataType_DT_INT16=function(){return($c=a._emscripten_enum_draco_DataType_DT_INT16=a.asm.Ab).apply(null,arguments)},ad=a._emscripten_enum_draco_DataType_DT_UINT16=function(){return(ad=a._emscripten_enum_draco_DataType_DT_UINT16=a.asm.Bb).apply(null,arguments)},bd=a._emscripten_enum_draco_DataType_DT_INT32=function(){return(bd=a._emscripten_enum_draco_DataType_DT_INT32=
|
||||
a.asm.Cb).apply(null,arguments)},cd=a._emscripten_enum_draco_DataType_DT_UINT32=function(){return(cd=a._emscripten_enum_draco_DataType_DT_UINT32=a.asm.Db).apply(null,arguments)},dd=a._emscripten_enum_draco_DataType_DT_INT64=function(){return(dd=a._emscripten_enum_draco_DataType_DT_INT64=a.asm.Eb).apply(null,arguments)},ed=a._emscripten_enum_draco_DataType_DT_UINT64=function(){return(ed=a._emscripten_enum_draco_DataType_DT_UINT64=a.asm.Fb).apply(null,arguments)},fd=a._emscripten_enum_draco_DataType_DT_FLOAT32=
|
||||
function(){return(fd=a._emscripten_enum_draco_DataType_DT_FLOAT32=a.asm.Gb).apply(null,arguments)},gd=a._emscripten_enum_draco_DataType_DT_FLOAT64=function(){return(gd=a._emscripten_enum_draco_DataType_DT_FLOAT64=a.asm.Hb).apply(null,arguments)},hd=a._emscripten_enum_draco_DataType_DT_BOOL=function(){return(hd=a._emscripten_enum_draco_DataType_DT_BOOL=a.asm.Ib).apply(null,arguments)},id=a._emscripten_enum_draco_DataType_DT_TYPES_COUNT=function(){return(id=a._emscripten_enum_draco_DataType_DT_TYPES_COUNT=
|
||||
a.asm.Jb).apply(null,arguments)},jd=a._emscripten_enum_draco_StatusCode_OK=function(){return(jd=a._emscripten_enum_draco_StatusCode_OK=a.asm.Kb).apply(null,arguments)},kd=a._emscripten_enum_draco_StatusCode_DRACO_ERROR=function(){return(kd=a._emscripten_enum_draco_StatusCode_DRACO_ERROR=a.asm.Lb).apply(null,arguments)},ld=a._emscripten_enum_draco_StatusCode_IO_ERROR=function(){return(ld=a._emscripten_enum_draco_StatusCode_IO_ERROR=a.asm.Mb).apply(null,arguments)},md=a._emscripten_enum_draco_StatusCode_INVALID_PARAMETER=
|
||||
function(){return(md=a._emscripten_enum_draco_StatusCode_INVALID_PARAMETER=a.asm.Nb).apply(null,arguments)},nd=a._emscripten_enum_draco_StatusCode_UNSUPPORTED_VERSION=function(){return(nd=a._emscripten_enum_draco_StatusCode_UNSUPPORTED_VERSION=a.asm.Ob).apply(null,arguments)},od=a._emscripten_enum_draco_StatusCode_UNKNOWN_VERSION=function(){return(od=a._emscripten_enum_draco_StatusCode_UNKNOWN_VERSION=a.asm.Pb).apply(null,arguments)};a._malloc=function(){return(a._malloc=a.asm.Qb).apply(null,arguments)};
|
||||
a._free=function(){return(a._free=a.asm.Rb).apply(null,arguments)};var ua=function(){return(ua=a.asm.Sb).apply(null,arguments)};a.___start_em_js=11660;a.___stop_em_js=11758;var la;ha=function b(){la||F();la||(ha=b)};if(a.preInit)for("function"==typeof a.preInit&&(a.preInit=[a.preInit]);0<a.preInit.length;)a.preInit.pop()();F();v.prototype=Object.create(v.prototype);v.prototype.constructor=v;v.prototype.__class__=v;v.__cache__={};a.WrapperObject=v;a.getCache=w;a.wrapPointer=B;a.castObject=function(b,
|
||||
c){return B(b.ptr,c)};a.NULL=B(0);a.destroy=function(b){if(!b.__destroy__)throw"Error: Cannot destroy object. (Did you create it yourself?)";b.__destroy__();delete w(b.__class__)[b.ptr]};a.compare=function(b,c){return b.ptr===c.ptr};a.getPointer=function(b){return b.ptr};a.getClass=function(b){return b.__class__};var r={buffer:0,size:0,pos:0,temps:[],needed:0,prepare:function(){if(r.needed){for(var b=0;b<r.temps.length;b++)a._free(r.temps[b]);r.temps.length=0;a._free(r.buffer);r.buffer=0;r.size+=
|
||||
r.needed;r.needed=0}r.buffer||(r.size+=128,r.buffer=a._malloc(r.size),r.buffer||y(void 0));r.pos=0},alloc:function(b,c){r.buffer||y(void 0);b=b.length*c.BYTES_PER_ELEMENT;b=b+7&-8;r.pos+b>=r.size?(0<b||y(void 0),r.needed+=b,c=a._malloc(b),r.temps.push(c)):(c=r.buffer+r.pos,r.pos+=b);return c},copy:function(b,c,d){d>>>=0;switch(c.BYTES_PER_ELEMENT){case 2:d>>>=1;break;case 4:d>>>=2;break;case 8:d>>>=3}for(var g=0;g<b.length;g++)c[d+g]=b[g]}};X.prototype=Object.create(v.prototype);X.prototype.constructor=
|
||||
X;X.prototype.__class__=X;X.__cache__={};a.VoidPtr=X;X.prototype.__destroy__=X.prototype.__destroy__=function(){Xa(this.ptr)};S.prototype=Object.create(v.prototype);S.prototype.constructor=S;S.prototype.__class__=S;S.__cache__={};a.DecoderBuffer=S;S.prototype.Init=S.prototype.Init=function(b,c){var d=this.ptr;r.prepare();"object"==typeof b&&(b=Z(b));c&&"object"===typeof c&&(c=c.ptr);Ya(d,b,c)};S.prototype.__destroy__=S.prototype.__destroy__=function(){Za(this.ptr)};Q.prototype=Object.create(v.prototype);
|
||||
Q.prototype.constructor=Q;Q.prototype.__class__=Q;Q.__cache__={};a.AttributeTransformData=Q;Q.prototype.transform_type=Q.prototype.transform_type=function(){return $a(this.ptr)};Q.prototype.__destroy__=Q.prototype.__destroy__=function(){ab(this.ptr)};V.prototype=Object.create(v.prototype);V.prototype.constructor=V;V.prototype.__class__=V;V.__cache__={};a.GeometryAttribute=V;V.prototype.__destroy__=V.prototype.__destroy__=function(){bb(this.ptr)};x.prototype=Object.create(v.prototype);x.prototype.constructor=
|
||||
x;x.prototype.__class__=x;x.__cache__={};a.PointAttribute=x;x.prototype.size=x.prototype.size=function(){return cb(this.ptr)};x.prototype.GetAttributeTransformData=x.prototype.GetAttributeTransformData=function(){return B(db(this.ptr),Q)};x.prototype.attribute_type=x.prototype.attribute_type=function(){return eb(this.ptr)};x.prototype.data_type=x.prototype.data_type=function(){return fb(this.ptr)};x.prototype.num_components=x.prototype.num_components=function(){return gb(this.ptr)};x.prototype.normalized=
|
||||
x.prototype.normalized=function(){return!!hb(this.ptr)};x.prototype.byte_stride=x.prototype.byte_stride=function(){return ib(this.ptr)};x.prototype.byte_offset=x.prototype.byte_offset=function(){return jb(this.ptr)};x.prototype.unique_id=x.prototype.unique_id=function(){return kb(this.ptr)};x.prototype.__destroy__=x.prototype.__destroy__=function(){lb(this.ptr)};D.prototype=Object.create(v.prototype);D.prototype.constructor=D;D.prototype.__class__=D;D.__cache__={};a.AttributeQuantizationTransform=
|
||||
D;D.prototype.InitFromAttribute=D.prototype.InitFromAttribute=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return!!mb(c,b)};D.prototype.quantization_bits=D.prototype.quantization_bits=function(){return nb(this.ptr)};D.prototype.min_value=D.prototype.min_value=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return ob(c,b)};D.prototype.range=D.prototype.range=function(){return pb(this.ptr)};D.prototype.__destroy__=D.prototype.__destroy__=function(){qb(this.ptr)};G.prototype=
|
||||
Object.create(v.prototype);G.prototype.constructor=G;G.prototype.__class__=G;G.__cache__={};a.AttributeOctahedronTransform=G;G.prototype.InitFromAttribute=G.prototype.InitFromAttribute=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return!!rb(c,b)};G.prototype.quantization_bits=G.prototype.quantization_bits=function(){return sb(this.ptr)};G.prototype.__destroy__=G.prototype.__destroy__=function(){tb(this.ptr)};H.prototype=Object.create(v.prototype);H.prototype.constructor=H;H.prototype.__class__=
|
||||
H;H.__cache__={};a.PointCloud=H;H.prototype.num_attributes=H.prototype.num_attributes=function(){return ub(this.ptr)};H.prototype.num_points=H.prototype.num_points=function(){return vb(this.ptr)};H.prototype.__destroy__=H.prototype.__destroy__=function(){wb(this.ptr)};E.prototype=Object.create(v.prototype);E.prototype.constructor=E;E.prototype.__class__=E;E.__cache__={};a.Mesh=E;E.prototype.num_faces=E.prototype.num_faces=function(){return xb(this.ptr)};E.prototype.num_attributes=E.prototype.num_attributes=
|
||||
function(){return yb(this.ptr)};E.prototype.num_points=E.prototype.num_points=function(){return zb(this.ptr)};E.prototype.__destroy__=E.prototype.__destroy__=function(){Ab(this.ptr)};T.prototype=Object.create(v.prototype);T.prototype.constructor=T;T.prototype.__class__=T;T.__cache__={};a.Metadata=T;T.prototype.__destroy__=T.prototype.__destroy__=function(){Bb(this.ptr)};C.prototype=Object.create(v.prototype);C.prototype.constructor=C;C.prototype.__class__=C;C.__cache__={};a.Status=C;C.prototype.code=
|
||||
C.prototype.code=function(){return Cb(this.ptr)};C.prototype.ok=C.prototype.ok=function(){return!!Db(this.ptr)};C.prototype.error_msg=C.prototype.error_msg=function(){return p(Eb(this.ptr))};C.prototype.__destroy__=C.prototype.__destroy__=function(){Fb(this.ptr)};I.prototype=Object.create(v.prototype);I.prototype.constructor=I;I.prototype.__class__=I;I.__cache__={};a.DracoFloat32Array=I;I.prototype.GetValue=I.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Gb(c,
|
||||
b)};I.prototype.size=I.prototype.size=function(){return Hb(this.ptr)};I.prototype.__destroy__=I.prototype.__destroy__=function(){Ib(this.ptr)};J.prototype=Object.create(v.prototype);J.prototype.constructor=J;J.prototype.__class__=J;J.__cache__={};a.DracoInt8Array=J;J.prototype.GetValue=J.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Jb(c,b)};J.prototype.size=J.prototype.size=function(){return Kb(this.ptr)};J.prototype.__destroy__=J.prototype.__destroy__=function(){Lb(this.ptr)};
|
||||
K.prototype=Object.create(v.prototype);K.prototype.constructor=K;K.prototype.__class__=K;K.__cache__={};a.DracoUInt8Array=K;K.prototype.GetValue=K.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Mb(c,b)};K.prototype.size=K.prototype.size=function(){return Nb(this.ptr)};K.prototype.__destroy__=K.prototype.__destroy__=function(){Ob(this.ptr)};L.prototype=Object.create(v.prototype);L.prototype.constructor=L;L.prototype.__class__=L;L.__cache__={};a.DracoInt16Array=
|
||||
L;L.prototype.GetValue=L.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Pb(c,b)};L.prototype.size=L.prototype.size=function(){return Qb(this.ptr)};L.prototype.__destroy__=L.prototype.__destroy__=function(){Rb(this.ptr)};M.prototype=Object.create(v.prototype);M.prototype.constructor=M;M.prototype.__class__=M;M.__cache__={};a.DracoUInt16Array=M;M.prototype.GetValue=M.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Sb(c,b)};
|
||||
M.prototype.size=M.prototype.size=function(){return Tb(this.ptr)};M.prototype.__destroy__=M.prototype.__destroy__=function(){Ub(this.ptr)};N.prototype=Object.create(v.prototype);N.prototype.constructor=N;N.prototype.__class__=N;N.__cache__={};a.DracoInt32Array=N;N.prototype.GetValue=N.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Vb(c,b)};N.prototype.size=N.prototype.size=function(){return Wb(this.ptr)};N.prototype.__destroy__=N.prototype.__destroy__=function(){Xb(this.ptr)};
|
||||
O.prototype=Object.create(v.prototype);O.prototype.constructor=O;O.prototype.__class__=O;O.__cache__={};a.DracoUInt32Array=O;O.prototype.GetValue=O.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Yb(c,b)};O.prototype.size=O.prototype.size=function(){return Zb(this.ptr)};O.prototype.__destroy__=O.prototype.__destroy__=function(){$b(this.ptr)};z.prototype=Object.create(v.prototype);z.prototype.constructor=z;z.prototype.__class__=z;z.__cache__={};a.MetadataQuerier=
|
||||
z;z.prototype.HasEntry=z.prototype.HasEntry=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return!!ac(d,b,c)};z.prototype.GetIntEntry=z.prototype.GetIntEntry=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return bc(d,b,c)};z.prototype.GetIntEntryArray=z.prototype.GetIntEntryArray=function(b,c,d){var g=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===
|
||||
typeof c?c.ptr:R(c);d&&"object"===typeof d&&(d=d.ptr);cc(g,b,c,d)};z.prototype.GetDoubleEntry=z.prototype.GetDoubleEntry=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return dc(d,b,c)};z.prototype.GetStringEntry=z.prototype.GetStringEntry=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return p(ec(d,b,c))};z.prototype.NumEntries=z.prototype.NumEntries=function(b){var c=this.ptr;
|
||||
b&&"object"===typeof b&&(b=b.ptr);return fc(c,b)};z.prototype.GetEntryName=z.prototype.GetEntryName=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return p(gc(d,b,c))};z.prototype.__destroy__=z.prototype.__destroy__=function(){hc(this.ptr)};m.prototype=Object.create(v.prototype);m.prototype.constructor=m;m.prototype.__class__=m;m.__cache__={};a.Decoder=m;m.prototype.DecodeArrayToPointCloud=m.prototype.DecodeArrayToPointCloud=function(b,c,d){var g=
|
||||
this.ptr;r.prepare();"object"==typeof b&&(b=Z(b));c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return B(ic(g,b,c,d),C)};m.prototype.DecodeArrayToMesh=m.prototype.DecodeArrayToMesh=function(b,c,d){var g=this.ptr;r.prepare();"object"==typeof b&&(b=Z(b));c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return B(jc(g,b,c,d),C)};m.prototype.GetAttributeId=m.prototype.GetAttributeId=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&
|
||||
(c=c.ptr);return kc(d,b,c)};m.prototype.GetAttributeIdByName=m.prototype.GetAttributeIdByName=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return lc(d,b,c)};m.prototype.GetAttributeIdByMetadataEntry=m.prototype.GetAttributeIdByMetadataEntry=function(b,c,d){var g=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);d=d&&"object"===typeof d?d.ptr:R(d);return mc(g,b,c,d)};m.prototype.GetAttribute=
|
||||
m.prototype.GetAttribute=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return B(nc(d,b,c),x)};m.prototype.GetAttributeByUniqueId=m.prototype.GetAttributeByUniqueId=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return B(oc(d,b,c),x)};m.prototype.GetMetadata=m.prototype.GetMetadata=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return B(pc(c,b),T)};m.prototype.GetAttributeMetadata=m.prototype.GetAttributeMetadata=
|
||||
function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return B(qc(d,b,c),T)};m.prototype.GetFaceFromMesh=m.prototype.GetFaceFromMesh=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!rc(g,b,c,d)};m.prototype.GetTriangleStripsFromMesh=m.prototype.GetTriangleStripsFromMesh=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);
|
||||
return sc(d,b,c)};m.prototype.GetTrianglesUInt16Array=m.prototype.GetTrianglesUInt16Array=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!tc(g,b,c,d)};m.prototype.GetTrianglesUInt32Array=m.prototype.GetTrianglesUInt32Array=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!uc(g,b,c,d)};m.prototype.GetAttributeFloat=m.prototype.GetAttributeFloat=
|
||||
function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!vc(g,b,c,d)};m.prototype.GetAttributeFloatForAllPoints=m.prototype.GetAttributeFloatForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!wc(g,b,c,d)};m.prototype.GetAttributeIntForAllPoints=m.prototype.GetAttributeIntForAllPoints=function(b,c,d){var g=this.ptr;
|
||||
b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!xc(g,b,c,d)};m.prototype.GetAttributeInt8ForAllPoints=m.prototype.GetAttributeInt8ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!yc(g,b,c,d)};m.prototype.GetAttributeUInt8ForAllPoints=m.prototype.GetAttributeUInt8ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=
|
||||
b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!zc(g,b,c,d)};m.prototype.GetAttributeInt16ForAllPoints=m.prototype.GetAttributeInt16ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Ac(g,b,c,d)};m.prototype.GetAttributeUInt16ForAllPoints=m.prototype.GetAttributeUInt16ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&
|
||||
(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Bc(g,b,c,d)};m.prototype.GetAttributeInt32ForAllPoints=m.prototype.GetAttributeInt32ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Cc(g,b,c,d)};m.prototype.GetAttributeUInt32ForAllPoints=m.prototype.GetAttributeUInt32ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===
|
||||
typeof d&&(d=d.ptr);return!!Dc(g,b,c,d)};m.prototype.GetAttributeDataArrayForAllPoints=m.prototype.GetAttributeDataArrayForAllPoints=function(b,c,d,g,t){var aa=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);g&&"object"===typeof g&&(g=g.ptr);t&&"object"===typeof t&&(t=t.ptr);return!!Ec(aa,b,c,d,g,t)};m.prototype.SkipAttributeTransform=m.prototype.SkipAttributeTransform=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);Fc(c,
|
||||
b)};m.prototype.GetEncodedGeometryType_Deprecated=m.prototype.GetEncodedGeometryType_Deprecated=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Gc(c,b)};m.prototype.DecodeBufferToPointCloud=m.prototype.DecodeBufferToPointCloud=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return B(Hc(d,b,c),C)};m.prototype.DecodeBufferToMesh=m.prototype.DecodeBufferToMesh=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===
|
||||
typeof c&&(c=c.ptr);return B(Ic(d,b,c),C)};m.prototype.__destroy__=m.prototype.__destroy__=function(){Jc(this.ptr)};(function(){function b(){a.ATTRIBUTE_INVALID_TRANSFORM=Kc();a.ATTRIBUTE_NO_TRANSFORM=Lc();a.ATTRIBUTE_QUANTIZATION_TRANSFORM=Mc();a.ATTRIBUTE_OCTAHEDRON_TRANSFORM=Nc();a.INVALID=Oc();a.POSITION=Pc();a.NORMAL=Qc();a.COLOR=Rc();a.TEX_COORD=Sc();a.GENERIC=Tc();a.INVALID_GEOMETRY_TYPE=Uc();a.POINT_CLOUD=Vc();a.TRIANGULAR_MESH=Wc();a.DT_INVALID=Xc();a.DT_INT8=Yc();a.DT_UINT8=Zc();a.DT_INT16=
|
||||
$c();a.DT_UINT16=ad();a.DT_INT32=bd();a.DT_UINT32=cd();a.DT_INT64=dd();a.DT_UINT64=ed();a.DT_FLOAT32=fd();a.DT_FLOAT64=gd();a.DT_BOOL=hd();a.DT_TYPES_COUNT=id();a.OK=jd();a.DRACO_ERROR=kd();a.IO_ERROR=ld();a.INVALID_PARAMETER=md();a.UNSUPPORTED_VERSION=nd();a.UNKNOWN_VERSION=od()}va?b():oa.unshift(b)})();if("function"===typeof a.onModuleParsed)a.onModuleParsed();a.Decoder.prototype.GetEncodedGeometryType=function(b){if(b.__class__&&b.__class__===a.DecoderBuffer)return a.Decoder.prototype.GetEncodedGeometryType_Deprecated(b);
|
||||
if(8>b.byteLength)return a.INVALID_GEOMETRY_TYPE;switch(b[7]){case 0:return a.POINT_CLOUD;case 1:return a.TRIANGULAR_MESH;default:return a.INVALID_GEOMETRY_TYPE}};return n.ready}}();"object"===typeof exports&&"object"===typeof module?module.exports=DracoDecoderModule:"function"===typeof define&&define.amd?define([],function(){return DracoDecoderModule}):"object"===typeof exports&&(exports.DracoDecoderModule=DracoDecoderModule);
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
Binary file not shown.
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Cube.dae - hand-authored minimal COLLADA 1.4.1 unit cube for hmwebviewer ColladaLoader testing.
|
||||
Single <geometry> (8 verts, 12 triangles) in one <visual_scene>. Edge length 2 (range -1..1),
|
||||
centered at origin. Works for Path A (SSR ?model=) and Path B (drag & drop). No network required. -->
|
||||
<COLLADA xmlns="http://www.collada.org/2005/11/COLLADASchema" version="1.4.1">
|
||||
<asset>
|
||||
<contributor>
|
||||
<author>hmwebviewer</author>
|
||||
<authoring_tool>hand-authored</authoring_tool>
|
||||
</contributor>
|
||||
<created>2026-06-19T00:00:00Z</created>
|
||||
<modified>2026-06-19T00:00:00Z</modified>
|
||||
<unit name="meter" meter="1"/>
|
||||
<up_axis>Y_UP</up_axis>
|
||||
</asset>
|
||||
|
||||
<library_geometries>
|
||||
<geometry id="Cube-mesh" name="Cube">
|
||||
<mesh>
|
||||
<source id="Cube-positions">
|
||||
<float_array id="Cube-positions-array" count="24">
|
||||
-1 -1 -1 -1 -1 1 -1 1 -1 -1 1 1 1 -1 -1 1 -1 1 1 1 -1 1 1 1
|
||||
</float_array>
|
||||
<technique_common>
|
||||
<accessor source="#Cube-positions-array" count="8" stride="3">
|
||||
<param name="X" type="float"/>
|
||||
<param name="Y" type="float"/>
|
||||
<param name="Z" type="float"/>
|
||||
</accessor>
|
||||
</technique_common>
|
||||
</source>
|
||||
<vertices id="Cube-vertices">
|
||||
<input semantic="POSITION" source="#Cube-positions"/>
|
||||
</vertices>
|
||||
<triangles count="12">
|
||||
<input semantic="VERTEX" source="#Cube-vertices" offset="0"/>
|
||||
<p>
|
||||
0 1 3 0 3 2
|
||||
4 6 7 4 7 5
|
||||
0 4 5 0 5 1
|
||||
2 3 7 2 7 6
|
||||
0 2 6 0 6 4
|
||||
1 5 7 1 7 3
|
||||
</p>
|
||||
</triangles>
|
||||
</mesh>
|
||||
</geometry>
|
||||
</library_geometries>
|
||||
|
||||
<library_visual_scenes>
|
||||
<visual_scene id="Scene" name="Scene">
|
||||
<node id="Cube" name="Cube" type="NODE">
|
||||
<matrix sid="transform">1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1</matrix>
|
||||
<instance_geometry url="#Cube-mesh" name="Cube"/>
|
||||
</node>
|
||||
</visual_scene>
|
||||
</library_visual_scenes>
|
||||
|
||||
<scene>
|
||||
<instance_visual_scene url="#Scene"/>
|
||||
</scene>
|
||||
</COLLADA>
|
||||
Binary file not shown.
@@ -0,0 +1,59 @@
|
||||
ISO-10303-21;
|
||||
HEADER;
|
||||
/* hand-authored minimal IFC4 file for hmwebviewer web-ifc testing.
|
||||
One IfcWall (1m x 1m footprint, 1m high) as a swept-extruded solid placed in a
|
||||
site/building/storey spatial tree. Validates with web-ifc; loadable via Path A
|
||||
(SSR ?model=) and Path B (drag & drop). No network required. */
|
||||
FILE_DESCRIPTION(('ViewDefinition [ReferenceView_V1.2]'),'2;1');
|
||||
FILE_NAME('Cube.ifc','2026-06-19T00:00:00',(''),(''),'hmwebviewer','hand-authored','');
|
||||
FILE_SCHEMA(('IFC4'));
|
||||
ENDSEC;
|
||||
DATA;
|
||||
#1=IFCPERSON($,$,'',$,$,$,$,$);
|
||||
#2=IFCORGANIZATION($,'hmwebviewer',$,$,$);
|
||||
#3=IFCPERSONANDORGANIZATION(#1,#2,$);
|
||||
#4=IFCAPPLICATION(#2,'1.0','hmwebviewer','hmw');
|
||||
#5=IFCOWNERHISTORY(#3,#4,$,.ADDED.,$,$,$,0);
|
||||
|
||||
#6=IFCDIRECTION((1.,0.,0.));
|
||||
#7=IFCDIRECTION((0.,0.,1.));
|
||||
#8=IFCCARTESIANPOINT((0.,0.,0.));
|
||||
#9=IFCAXIS2PLACEMENT3D(#8,#7,#6);
|
||||
#10=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#9,$);
|
||||
|
||||
#11=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);
|
||||
#12=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.);
|
||||
#13=IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.);
|
||||
#14=IFCUNITASSIGNMENT((#11,#12,#13));
|
||||
|
||||
#15=IFCPROJECT('0Project00000000000000000',#5,'Project',$,$,$,$,(#10),#14);
|
||||
|
||||
#20=IFCLOCALPLACEMENT($,#9);
|
||||
#21=IFCSITE('0Site0000000000000000000',#5,'Site',$,$,#20,$,$,.ELEMENT.,$,$,$,$,$);
|
||||
#22=IFCLOCALPLACEMENT(#20,#9);
|
||||
#23=IFCBUILDING('0Building000000000000000',#5,'Building',$,$,#22,$,$,.ELEMENT.,$,$,$);
|
||||
#24=IFCLOCALPLACEMENT(#22,#9);
|
||||
#25=IFCBUILDINGSTOREY('0Storey00000000000000000',#5,'Storey',$,$,#24,$,$,.ELEMENT.,0.);
|
||||
|
||||
#30=IFCRELAGGREGATES('0RelAggProject0000000000',#5,$,$,#15,(#21));
|
||||
#31=IFCRELAGGREGATES('0RelAggSite000000000000 ',#5,$,$,#21,(#23));
|
||||
#32=IFCRELAGGREGATES('0RelAggBuilding00000000 ',#5,$,$,#23,(#25));
|
||||
|
||||
/* --- Wall geometry: 1m x 1m rectangle extruded 1m up --- */
|
||||
#40=IFCCARTESIANPOINT((-0.5,-0.5));
|
||||
#41=IFCCARTESIANPOINT((0.5,-0.5));
|
||||
#42=IFCCARTESIANPOINT((0.5,0.5));
|
||||
#43=IFCCARTESIANPOINT((-0.5,0.5));
|
||||
#44=IFCPOLYLINE((#40,#41,#42,#43,#40));
|
||||
#45=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#44);
|
||||
#46=IFCAXIS2PLACEMENT3D(#8,#7,#6);
|
||||
#47=IFCEXTRUDEDAREASOLID(#45,#46,#7,1.);
|
||||
#48=IFCSHAPEREPRESENTATION(#10,'Body','SweptSolid',(#47));
|
||||
#49=IFCPRODUCTDEFINITIONSHAPE($,$,(#48));
|
||||
|
||||
#50=IFCLOCALPLACEMENT(#24,#9);
|
||||
#51=IFCWALL('0Wall00000000000000000000',#5,'Wall',$,$,#50,#49,$,.SOLIDWALL.);
|
||||
|
||||
#60=IFCRELCONTAINEDINSPATIALSTRUCTURE('0RelContained0000000000 ',#5,$,$,(#51),#25);
|
||||
ENDSEC;
|
||||
END-ISO-10303-21;
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Cube.dae - hand-authored minimal COLLADA 1.4.1 unit cube for hmwebviewer ColladaLoader testing.
|
||||
Single <geometry> (8 verts, 12 triangles) in one <visual_scene>. Edge length 2 (range -1..1),
|
||||
centered at origin. Works for Path A (SSR ?model=) and Path B (drag & drop). No network required. -->
|
||||
<COLLADA xmlns="http://www.collada.org/2005/11/COLLADASchema" version="1.4.1">
|
||||
<asset>
|
||||
<contributor>
|
||||
<author>hmwebviewer</author>
|
||||
<authoring_tool>hand-authored</authoring_tool>
|
||||
</contributor>
|
||||
<created>2026-06-19T00:00:00Z</created>
|
||||
<modified>2026-06-19T00:00:00Z</modified>
|
||||
<unit name="meter" meter="1"/>
|
||||
<up_axis>Y_UP</up_axis>
|
||||
</asset>
|
||||
|
||||
<library_geometries>
|
||||
<geometry id="Cube-mesh" name="Cube">
|
||||
<mesh>
|
||||
<source id="Cube-positions">
|
||||
<float_array id="Cube-positions-array" count="24">
|
||||
-1 -1 -1 -1 -1 1 -1 1 -1 -1 1 1 1 -1 -1 1 -1 1 1 1 -1 1 1 1
|
||||
</float_array>
|
||||
<technique_common>
|
||||
<accessor source="#Cube-positions-array" count="8" stride="3">
|
||||
<param name="X" type="float"/>
|
||||
<param name="Y" type="float"/>
|
||||
<param name="Z" type="float"/>
|
||||
</accessor>
|
||||
</technique_common>
|
||||
</source>
|
||||
<vertices id="Cube-vertices">
|
||||
<input semantic="POSITION" source="#Cube-positions"/>
|
||||
</vertices>
|
||||
<triangles count="12">
|
||||
<input semantic="VERTEX" source="#Cube-vertices" offset="0"/>
|
||||
<p>
|
||||
0 1 3 0 3 2
|
||||
4 6 7 4 7 5
|
||||
0 4 5 0 5 1
|
||||
2 3 7 2 7 6
|
||||
0 2 6 0 6 4
|
||||
1 5 7 1 7 3
|
||||
</p>
|
||||
</triangles>
|
||||
</mesh>
|
||||
</geometry>
|
||||
</library_geometries>
|
||||
|
||||
<library_visual_scenes>
|
||||
<visual_scene id="Scene" name="Scene">
|
||||
<node id="Cube" name="Cube" type="NODE">
|
||||
<matrix sid="transform">1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1</matrix>
|
||||
<instance_geometry url="#Cube-mesh" name="Cube"/>
|
||||
</node>
|
||||
</visual_scene>
|
||||
</library_visual_scenes>
|
||||
|
||||
<scene>
|
||||
<instance_visual_scene url="#Scene"/>
|
||||
</scene>
|
||||
</COLLADA>
|
||||
Binary file not shown.
@@ -0,0 +1,59 @@
|
||||
ISO-10303-21;
|
||||
HEADER;
|
||||
/* hand-authored minimal IFC4 file for hmwebviewer web-ifc testing.
|
||||
One IfcWall (1m x 1m footprint, 1m high) as a swept-extruded solid placed in a
|
||||
site/building/storey spatial tree. Validates with web-ifc; loadable via Path A
|
||||
(SSR ?model=) and Path B (drag & drop). No network required. */
|
||||
FILE_DESCRIPTION(('ViewDefinition [ReferenceView_V1.2]'),'2;1');
|
||||
FILE_NAME('Cube.ifc','2026-06-19T00:00:00',(''),(''),'hmwebviewer','hand-authored','');
|
||||
FILE_SCHEMA(('IFC4'));
|
||||
ENDSEC;
|
||||
DATA;
|
||||
#1=IFCPERSON($,$,'',$,$,$,$,$);
|
||||
#2=IFCORGANIZATION($,'hmwebviewer',$,$,$);
|
||||
#3=IFCPERSONANDORGANIZATION(#1,#2,$);
|
||||
#4=IFCAPPLICATION(#2,'1.0','hmwebviewer','hmw');
|
||||
#5=IFCOWNERHISTORY(#3,#4,$,.ADDED.,$,$,$,0);
|
||||
|
||||
#6=IFCDIRECTION((1.,0.,0.));
|
||||
#7=IFCDIRECTION((0.,0.,1.));
|
||||
#8=IFCCARTESIANPOINT((0.,0.,0.));
|
||||
#9=IFCAXIS2PLACEMENT3D(#8,#7,#6);
|
||||
#10=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#9,$);
|
||||
|
||||
#11=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);
|
||||
#12=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.);
|
||||
#13=IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.);
|
||||
#14=IFCUNITASSIGNMENT((#11,#12,#13));
|
||||
|
||||
#15=IFCPROJECT('0Project00000000000000000',#5,'Project',$,$,$,$,(#10),#14);
|
||||
|
||||
#20=IFCLOCALPLACEMENT($,#9);
|
||||
#21=IFCSITE('0Site0000000000000000000',#5,'Site',$,$,#20,$,$,.ELEMENT.,$,$,$,$,$);
|
||||
#22=IFCLOCALPLACEMENT(#20,#9);
|
||||
#23=IFCBUILDING('0Building000000000000000',#5,'Building',$,$,#22,$,$,.ELEMENT.,$,$,$);
|
||||
#24=IFCLOCALPLACEMENT(#22,#9);
|
||||
#25=IFCBUILDINGSTOREY('0Storey00000000000000000',#5,'Storey',$,$,#24,$,$,.ELEMENT.,0.);
|
||||
|
||||
#30=IFCRELAGGREGATES('0RelAggProject0000000000',#5,$,$,#15,(#21));
|
||||
#31=IFCRELAGGREGATES('0RelAggSite000000000000 ',#5,$,$,#21,(#23));
|
||||
#32=IFCRELAGGREGATES('0RelAggBuilding00000000 ',#5,$,$,#23,(#25));
|
||||
|
||||
/* --- Wall geometry: 1m x 1m rectangle extruded 1m up --- */
|
||||
#40=IFCCARTESIANPOINT((-0.5,-0.5));
|
||||
#41=IFCCARTESIANPOINT((0.5,-0.5));
|
||||
#42=IFCCARTESIANPOINT((0.5,0.5));
|
||||
#43=IFCCARTESIANPOINT((-0.5,0.5));
|
||||
#44=IFCPOLYLINE((#40,#41,#42,#43,#40));
|
||||
#45=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#44);
|
||||
#46=IFCAXIS2PLACEMENT3D(#8,#7,#6);
|
||||
#47=IFCEXTRUDEDAREASOLID(#45,#46,#7,1.);
|
||||
#48=IFCSHAPEREPRESENTATION(#10,'Body','SweptSolid',(#47));
|
||||
#49=IFCPRODUCTDEFINITIONSHAPE($,$,(#48));
|
||||
|
||||
#50=IFCLOCALPLACEMENT(#24,#9);
|
||||
#51=IFCWALL('0Wall00000000000000000000',#5,'Wall',$,$,#50,#49,$,.SOLIDWALL.);
|
||||
|
||||
#60=IFCRELCONTAINEDINSPATIALSTRUCTURE('0RelContained0000000000 ',#5,$,$,(#51),#25);
|
||||
ENDSEC;
|
||||
END-ISO-10303-21;
|
||||
Binary file not shown.
Binary file not shown.
+7772
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Drag & drop local-file path (PLAN P2-1, P2-2).
|
||||
*
|
||||
* Captures HTML5 DnD events + click-to-browse on the #dropzone element,
|
||||
* validates the picked file is a supported model, and forwards valid files to
|
||||
* viewer-core's loadLocalFile. createObjectURL/revoke lifecycle is owned by
|
||||
* the viewer (per the locked Blob URL pattern in SKILL.md); this module only
|
||||
* validates + hands the File off.
|
||||
*/
|
||||
|
||||
import { extOf } from '../viewer/modelLoader';
|
||||
|
||||
/** Viewer surface this module depends on (viewer-core implements loadLocalFile). */
|
||||
export interface ViewerHandle {
|
||||
loadLocalFile: (file: File, sidecars?: File[]) => void;
|
||||
}
|
||||
|
||||
/** Options for initDropzone. */
|
||||
export interface DropzoneOpts {
|
||||
viewer: ViewerHandle;
|
||||
dropzone: HTMLElement;
|
||||
fileInput: HTMLInputElement;
|
||||
/** Optional error sink; defaults to console.warn. */
|
||||
onError?: (msg: string) => void;
|
||||
}
|
||||
|
||||
/** Accept any supported model extension by name (case-insensitive). */
|
||||
function isValidModel(file: File): boolean {
|
||||
return extOf(file.name) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire up drag/drop + click-to-browse on the given dropzone.
|
||||
* Idempotent-ish: attaches listeners; caller should call once per element.
|
||||
*/
|
||||
export function initDropzone(opts: DropzoneOpts): void {
|
||||
const { dropzone, fileInput, viewer } = opts;
|
||||
const onError = opts.onError ?? ((msg: string) => console.warn(msg));
|
||||
|
||||
// Accept multiple files at once: the first supported model is the primary,
|
||||
// the rest (e.g. a .mtl beside an .obj) ride along as sidecars.
|
||||
const handleFiles = (files: File[]): void => {
|
||||
if (!files.length) return;
|
||||
const model = files.find(isValidModel);
|
||||
if (!model) {
|
||||
onError("Unsupported file (use .glb .gltf .obj .fbx .dae .ifc)");
|
||||
return;
|
||||
}
|
||||
viewer.loadLocalFile(model, files.filter((f) => f !== model));
|
||||
};
|
||||
|
||||
// --- Drag & drop ---
|
||||
// preventDefault on dragover is MANDATORY or the browser navigates to the file.
|
||||
dropzone.addEventListener("dragover", (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.add("drag");
|
||||
});
|
||||
|
||||
dropzone.addEventListener("dragenter", (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.add("drag");
|
||||
});
|
||||
|
||||
dropzone.addEventListener("dragleave", () => {
|
||||
dropzone.classList.remove("drag");
|
||||
});
|
||||
|
||||
dropzone.addEventListener("drop", (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.remove("drag");
|
||||
handleFiles(Array.from(e.dataTransfer?.files ?? []));
|
||||
});
|
||||
|
||||
// --- Click to browse (accessibility) ---
|
||||
dropzone.addEventListener("click", () => {
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
fileInput.addEventListener("change", () => {
|
||||
handleFiles(Array.from(fileInput.files ?? []));
|
||||
// reset so picking the same file twice fires `change` again
|
||||
fileInput.value = "";
|
||||
});
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import { ThreeDViewer } from './viewer/ThreeDViewer';
|
||||
import { initDropzone } from './dnd/dropzone';
|
||||
import { setStatus } from './ui/progress';
|
||||
|
||||
/**
|
||||
* Entry point. Wires the ThreeDViewer (renderer + loaders + hydration) and the
|
||||
* Drag & Drop local-file path. Path A (server asset) is also available via the
|
||||
* `?model=<url>` query param so the SSR+CSR+hydration flow can be exercised
|
||||
* without extra UI.
|
||||
*/
|
||||
const viewerEl = document.getElementById('viewer');
|
||||
const progressEl = document.getElementById('progress');
|
||||
const previewEl = document.getElementById('preview') as HTMLImageElement | null;
|
||||
const dropzoneEl = document.getElementById('dropzone');
|
||||
const fileInputEl = document.getElementById('file-input') as HTMLInputElement | null;
|
||||
const statusEl = document.getElementById('status');
|
||||
|
||||
if (!viewerEl || !progressEl || !previewEl || !dropzoneEl || !fileInputEl || !statusEl) {
|
||||
throw new Error('hmwebviewer: required DOM elements missing');
|
||||
}
|
||||
|
||||
const onError = (msg: string) => setStatus(statusEl, msg);
|
||||
|
||||
let viewer: ThreeDViewer;
|
||||
try {
|
||||
viewer = new ThreeDViewer(viewerEl, progressEl, previewEl, onError);
|
||||
} catch (err) {
|
||||
console.error('[hmwebviewer] init failed', err);
|
||||
setStatus(statusEl, 'WebGL is required but unavailable in this browser.');
|
||||
throw err;
|
||||
}
|
||||
|
||||
initDropzone({
|
||||
viewer,
|
||||
dropzone: dropzoneEl,
|
||||
fileInput: fileInputEl,
|
||||
onError: (msg) => { console.warn('[hmwebviewer]', msg); setStatus(statusEl, msg); },
|
||||
});
|
||||
|
||||
// Viewer controls — Zoom Fit, projection toggle, outline overlay
|
||||
document.getElementById('btn-fit')?.addEventListener('click', () => viewer.fitView());
|
||||
|
||||
const perspBtn = document.getElementById('btn-persp');
|
||||
const orthoBtn = document.getElementById('btn-ortho');
|
||||
const setProjection = (mode: 'persp' | 'ortho') => {
|
||||
viewer.setProjection(mode);
|
||||
perspBtn?.classList.toggle('active', mode === 'persp');
|
||||
orthoBtn?.classList.toggle('active', mode === 'ortho');
|
||||
};
|
||||
perspBtn?.addEventListener('click', () => setProjection('persp'));
|
||||
orthoBtn?.addEventListener('click', () => setProjection('ortho'));
|
||||
|
||||
const outlineBtn = document.getElementById('btn-outline');
|
||||
outlineBtn?.addEventListener('click', () => {
|
||||
const on = viewer.toggleOutline();
|
||||
outlineBtn.classList.toggle('active', on);
|
||||
});
|
||||
|
||||
// Path A (server asset) — opt-in via ?model=<url>
|
||||
const modelUrl = new URLSearchParams(window.location.search).get('model');
|
||||
if (modelUrl) {
|
||||
// SSR placeholder: a pre-rendered 360° animated WebP (tools/prerender.mjs)
|
||||
// shows instantly, then the hydration gate fades it out once WebGL + model ready.
|
||||
const base = modelUrl.split('/').pop()!.replace(/\.(glb|gltf|obj|fbx|dae|ifc|ply)$/i, '');
|
||||
previewEl.src = '/previews/' + base + '.webp';
|
||||
previewEl.classList.remove('hidden');
|
||||
previewEl.onerror = () => previewEl.classList.add('hidden');
|
||||
viewer.loadServerAsset(modelUrl);
|
||||
}
|
||||
|
||||
// Exposed for tooling (tools/prerender.mjs captures rotating frames).
|
||||
(window as unknown as { __viewer?: ThreeDViewer }).__viewer = viewer;
|
||||
@@ -0,0 +1,51 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #app { margin: 0; height: 100%; }
|
||||
#app { position: relative; background: #111; color: #eee; overflow: hidden; }
|
||||
|
||||
.viewer { position: absolute; inset: 0; }
|
||||
.viewer canvas { display: block; width: 100%; height: 100%; }
|
||||
|
||||
.preview {
|
||||
position: absolute; inset: 0; width: 100%; height: 100%;
|
||||
object-fit: contain; background: #111; z-index: 2;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
.hidden { display: none !important; }
|
||||
|
||||
.progress {
|
||||
position: absolute; left: 0; right: 0; bottom: 0; height: 4px; z-index: 3;
|
||||
background: rgba(255,255,255,0.1);
|
||||
}
|
||||
.progress .bar { height: 100%; width: 0%; background: #4aa3ff; transition: width 0.1s linear; }
|
||||
|
||||
.fps {
|
||||
position: absolute; top: 8px; left: 8px; z-index: 4;
|
||||
font: 12px/1.4 ui-monospace, monospace; padding: 2px 6px;
|
||||
background: rgba(0,0,0,0.5); border-radius: 4px; pointer-events: none;
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: absolute; top: 8px; right: 8px; z-index: 4;
|
||||
display: flex; gap: 6px;
|
||||
}
|
||||
.controls button {
|
||||
font: 12px/1.4 system-ui, sans-serif; padding: 6px 10px;
|
||||
background: rgba(0,0,0,0.55); color: #eee; border: 1px solid #888;
|
||||
border-radius: 6px; cursor: pointer;
|
||||
}
|
||||
.controls button:hover { background: rgba(0,0,0,0.75); border-color: #4aa3ff; }
|
||||
.controls button.active { background: #4aa3ff; color: #00264d; border-color: #4aa3ff; }
|
||||
|
||||
.dropzone {
|
||||
position: absolute; left: 50%; bottom: 24px; transform: translateX(-50%);
|
||||
padding: 10px 18px; border: 1px dashed #555; border-radius: 10px;
|
||||
background: rgba(0,0,0,0.4); font-size: 13px; cursor: pointer; z-index: 3;
|
||||
}
|
||||
.dropzone.drag { border-color: #4aa3ff; background: rgba(74,163,255,0.15); }
|
||||
.dropzone p { margin: 0; }
|
||||
.dropzone .status { margin-top: 6px; color: #ff7676; font-size: 12px; min-height: 0; }
|
||||
.dropzone .status:empty { display: none; }
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* On-screen FPS meter.
|
||||
*
|
||||
* Driven from the viewer's animate loop: call `sample(now)` once per frame with
|
||||
* the requestAnimationFrame timestamp. It computes an instantaneous fps from the
|
||||
* inter-frame delta, smooths it into a rolling average (EMA), and throttles DOM
|
||||
* writes to ~every 250ms so updating the overlay doesn't itself cause jank.
|
||||
* Kept dependency-free in the src/ui/progress.ts style.
|
||||
*/
|
||||
|
||||
const DOM_INTERVAL_MS = 250;
|
||||
// EMA smoothing factor: higher = more responsive, lower = smoother.
|
||||
const EMA_ALPHA = 0.1;
|
||||
|
||||
export interface FpsMeter {
|
||||
/** Overlay element; the viewer appends this to its container. */
|
||||
el: HTMLElement;
|
||||
/** Feed one frame timestamp (DOMHighResTimeStamp). */
|
||||
sample(now: number): void;
|
||||
/** Current rolling-average fps. */
|
||||
fps(): number;
|
||||
}
|
||||
|
||||
/** Create an FPS overlay element + sampler. */
|
||||
export function createFpsMeter(): FpsMeter {
|
||||
const el = document.createElement("div");
|
||||
el.className = "fps";
|
||||
el.textContent = "-- FPS";
|
||||
|
||||
let last = 0;
|
||||
let avg = 0;
|
||||
let lastDom = 0;
|
||||
|
||||
return {
|
||||
el,
|
||||
sample(now: number): void {
|
||||
if (last === 0) {
|
||||
last = now;
|
||||
return;
|
||||
}
|
||||
const dt = now - last;
|
||||
last = now;
|
||||
if (dt <= 0) return;
|
||||
const inst = 1000 / dt;
|
||||
avg = avg === 0 ? inst : avg + EMA_ALPHA * (inst - avg);
|
||||
|
||||
if (now - lastDom >= DOM_INTERVAL_MS) {
|
||||
lastDom = now;
|
||||
const v = Math.round(avg);
|
||||
el.textContent = `${v} FPS`;
|
||||
el.style.color = v >= 60 ? "#5cff5c" : v >= 30 ? "#ffb74a" : "#ff5c5c";
|
||||
}
|
||||
},
|
||||
fps(): number {
|
||||
return avg;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Minimal progress-bar helper for the load lifecycle.
|
||||
*
|
||||
* The #progress container holds a single inner .bar element whose width is
|
||||
* driven as a percentage. show/hide just toggle the `hidden` class so CSS
|
||||
* controls visibility (see src/style.css). Kept dependency-free so both the
|
||||
* dropzone wiring and ThreeDViewer.loadServerAsset can use it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Reveal the progress container by removing the `hidden` class.
|
||||
* `el` is the #progress element.
|
||||
*/
|
||||
export function showProgress(el: HTMLElement): void {
|
||||
el.classList.remove("hidden");
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the progress container by adding the `hidden` class.
|
||||
*/
|
||||
export function hideProgress(el: HTMLElement): void {
|
||||
el.classList.add("hidden");
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the inner `.bar` width to `percent` (clamped to 0..100).
|
||||
* `el` is the #progress element; its first `.bar` descendant is resized.
|
||||
*/
|
||||
export function setProgress(el: HTMLElement, percent: number): void {
|
||||
const clamped = Math.max(0, Math.min(100, percent));
|
||||
const bar = el.querySelector<HTMLElement>(".bar");
|
||||
if (bar) bar.style.width = `${clamped}%`;
|
||||
}
|
||||
|
||||
/** Write a user-facing status/error message into the #status element. */
|
||||
export function setStatus(el: HTMLElement, msg: string): void {
|
||||
el.textContent = msg;
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||
import { getLoaders } from './loaders';
|
||||
import { loadModel, extOf } from './modelLoader';
|
||||
import { recenterObjFile } from './objRecenter';
|
||||
import { createHydrationGate } from './hydration';
|
||||
import { showProgress, hideProgress, setProgress } from '../ui/progress';
|
||||
import { createFpsMeter } from '../ui/fps';
|
||||
import { createAdaptiveQuality } from './adaptiveQuality';
|
||||
|
||||
// Formats authored Y-up; the viewer world is Z-up (structure convention), so
|
||||
// these are rotated +90° about X on load. OBJ/IFC are Z-up native (no rotate).
|
||||
const Y_UP_EXTS = new Set(['glb', 'gltf', 'fbx', 'dae']);
|
||||
function isYUp(name: string): boolean {
|
||||
const e = extOf(name);
|
||||
return e !== null && Y_UP_EXTS.has(e);
|
||||
}
|
||||
|
||||
export class ThreeDViewer {
|
||||
private readonly renderer: THREE.WebGLRenderer;
|
||||
private readonly scene = new THREE.Scene();
|
||||
private readonly perspCamera: THREE.PerspectiveCamera;
|
||||
private readonly orthoCamera: THREE.OrthographicCamera;
|
||||
private camera: THREE.PerspectiveCamera | THREE.OrthographicCamera;
|
||||
private projection: 'persp' | 'ortho' = 'persp';
|
||||
private readonly fov = 50;
|
||||
private readonly controls: OrbitControls;
|
||||
private readonly gate;
|
||||
private readonly onError;
|
||||
private readonly fps = createFpsMeter();
|
||||
private readonly adaptive;
|
||||
private raf = 0;
|
||||
private current: THREE.Object3D | null = null;
|
||||
private outline: THREE.Group | null = null;
|
||||
private outlineOn = false;
|
||||
|
||||
constructor(
|
||||
private readonly container: HTMLElement,
|
||||
private readonly progress: HTMLElement,
|
||||
preview: HTMLImageElement,
|
||||
onError?: (msg: string) => void,
|
||||
) {
|
||||
this.onError = onError ?? ((msg: string) => console.error('[hmwebviewer]', msg));
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
this.container.appendChild(this.renderer.domElement);
|
||||
// Adaptive quality owns renderer.setPixelRatio: tier 0 applies a DPR cap of
|
||||
// min(devicePixelRatio, 2) immediately (uncapped DPR on 3x devices is the
|
||||
// #1 cause of <60fps). It then steps the pixel ratio down/up with hysteresis.
|
||||
this.adaptive = createAdaptiveQuality(this.renderer);
|
||||
this.container.appendChild(this.fps.el);
|
||||
|
||||
this.perspCamera = new THREE.PerspectiveCamera(this.fov, 1, 0.1, 1000);
|
||||
this.orthoCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000);
|
||||
for (const c of [this.perspCamera, this.orthoCamera]) {
|
||||
c.up.set(0, 0, 1); // Z-up world (right-handed, structure convention)
|
||||
c.position.set(2, -3, 2);
|
||||
}
|
||||
this.camera = this.perspCamera;
|
||||
|
||||
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
||||
this.controls.enableDamping = true;
|
||||
// Pan in the ground (XY) plane, not the screen plane — keeps elevation
|
||||
// constant when panning along a road/rail alignment between turntable spins.
|
||||
this.controls.screenSpacePanning = false;
|
||||
|
||||
this.scene.background = new THREE.Color(0xcfcfcf);
|
||||
const hemi = new THREE.HemisphereLight(0xffffff, 0x444444, 1.2);
|
||||
hemi.position.set(0, 0, 1); // sky toward +Z
|
||||
this.scene.add(hemi);
|
||||
const dir = new THREE.DirectionalLight(0xffffff, 1.0);
|
||||
dir.position.set(2, -3, 5); // from above (+Z) and front
|
||||
this.scene.add(dir);
|
||||
|
||||
// Warm the shared singleton loaders so KTX2 detectSupport(renderer) runs at
|
||||
// init; loadModel() reuses the same singletons for GLB/GLTF.
|
||||
getLoaders(this.renderer);
|
||||
this.gate = createHydrationGate(preview);
|
||||
|
||||
window.addEventListener('resize', this.onResize);
|
||||
this.onResize();
|
||||
this.animate();
|
||||
this.gate.markWebGLReady();
|
||||
}
|
||||
|
||||
/** Path A — server asset (SSR placeholder already shown by host page). */
|
||||
loadServerAsset(url: string): void {
|
||||
showProgress(this.progress);
|
||||
loadModel(url, this.renderer, (loaded, total) => {
|
||||
setProgress(this.progress, (loaded / total) * 100);
|
||||
}).then(
|
||||
(obj) => this.onLoaded(obj, isYUp(url)),
|
||||
(err) => {
|
||||
hideProgress(this.progress);
|
||||
this.onError('Failed to load server asset.');
|
||||
console.error('[hmwebviewer] server asset load failed', err);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Path B — local file (Drag & Drop). Blob URL revoked on both paths.
|
||||
* `sidecars` carries companion files dropped alongside (e.g. a .mtl for OBJ);
|
||||
* the blob: URL can't resolve them, so their text is read and passed inline.
|
||||
*/
|
||||
loadLocalFile(file: File, sidecars: File[] = []): void {
|
||||
showProgress(this.progress);
|
||||
const mtlFile = sidecars.find((f) => /\.mtl$/i.test(f.name));
|
||||
const texFiles = sidecars.filter((f) => /\.(png|jpe?g|bmp|gif|webp|tga)$/i.test(f.name));
|
||||
let url: string | null = null;
|
||||
const isObj = extOf(file.name) === 'obj';
|
||||
// OBJLoader builds one giant string; past ~V8 max string length (~1GB of
|
||||
// text) it fails. Route large OBJ through the streaming parser instead.
|
||||
const huge = isObj && file.size > 300 * 1024 * 1024;
|
||||
const run = async (): Promise<THREE.Object3D> => {
|
||||
const mtlText = mtlFile ? await mtlFile.text() : undefined;
|
||||
// Small/medium OBJ: recenter in float64 BEFORE OBJLoader quantizes to
|
||||
// float32 (objRecenter.ts). Huge OBJ recenters inside the streaming parser.
|
||||
const source = (isObj && !huge)
|
||||
? await recenterObjFile(file, (l, t) => setProgress(this.progress, (l / t) * 100))
|
||||
: file;
|
||||
url = URL.createObjectURL(source);
|
||||
return loadModel(
|
||||
url,
|
||||
this.renderer,
|
||||
(loaded, total) => setProgress(this.progress, (loaded / total) * 100),
|
||||
file.name,
|
||||
{ mtlText, texFiles, stream: huge },
|
||||
);
|
||||
};
|
||||
run()
|
||||
.then(
|
||||
(obj) => this.onLoaded(obj, isYUp(file.name)),
|
||||
(err) => {
|
||||
hideProgress(this.progress);
|
||||
this.onError('Failed to load file (corrupt or unsupported?).');
|
||||
console.error('[hmwebviewer] local file load failed', err);
|
||||
},
|
||||
)
|
||||
.finally(() => { if (url) URL.revokeObjectURL(url); });
|
||||
}
|
||||
|
||||
private onLoaded(obj: THREE.Object3D, yUp = false): void {
|
||||
if (this.current) {
|
||||
this.scene.remove(this.current);
|
||||
this.disposeObject(this.current);
|
||||
}
|
||||
this.current = obj;
|
||||
if (yUp) obj.rotateX(Math.PI / 2); // Y-up source → Z-up world
|
||||
this.scene.add(this.current);
|
||||
this.frameObject(this.current);
|
||||
this.applyOutlineState();
|
||||
hideProgress(this.progress);
|
||||
this.gate.markModelLoaded();
|
||||
// observable hook for perf smoke tests (tools/perf-smoke.mjs)
|
||||
window.dispatchEvent(new CustomEvent('hmw:ready', { detail: performance.now() }));
|
||||
}
|
||||
|
||||
private frameObject(obj: THREE.Object3D): void {
|
||||
const box = new THREE.Box3().setFromObject(obj);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const maxDim = Math.max(size.x, size.y, size.z) || 1;
|
||||
const fovRad = (this.fov * Math.PI) / 180;
|
||||
const dist = (maxDim / 2) / Math.tan(fovRad / 2) * 2;
|
||||
const near = Math.max(maxDim / 1000, 0.01); // adaptive: fixed far=1000 clips large models
|
||||
const far = (dist + maxDim) * 4;
|
||||
const aspect = this.aspect;
|
||||
const dir = new THREE.Vector3(1, -1, 0.8).normalize(); // iso view, Z up
|
||||
const pos = center.clone().addScaledVector(dir, dist);
|
||||
|
||||
this.perspCamera.position.copy(pos);
|
||||
this.perspCamera.aspect = aspect;
|
||||
this.perspCamera.near = near;
|
||||
this.perspCamera.far = far;
|
||||
this.perspCamera.lookAt(center);
|
||||
this.perspCamera.updateProjectionMatrix();
|
||||
|
||||
// Ortho frustum half-height matches the perspective apparent size at `dist`,
|
||||
// so toggling projection doesn't jump the model scale.
|
||||
const halfH = dist * Math.tan(fovRad / 2);
|
||||
this.orthoCamera.position.copy(pos);
|
||||
this.orthoCamera.top = halfH;
|
||||
this.orthoCamera.bottom = -halfH;
|
||||
this.orthoCamera.left = -halfH * aspect;
|
||||
this.orthoCamera.right = halfH * aspect;
|
||||
this.orthoCamera.zoom = 1;
|
||||
this.orthoCamera.near = near;
|
||||
this.orthoCamera.far = far;
|
||||
this.orthoCamera.lookAt(center);
|
||||
this.orthoCamera.updateProjectionMatrix();
|
||||
|
||||
this.controls.target.copy(center);
|
||||
this.controls.update();
|
||||
}
|
||||
|
||||
/** Re-frame the current model to fit the view (Zoom Fit button). */
|
||||
fitView(): void {
|
||||
if (this.current) this.frameObject(this.current);
|
||||
}
|
||||
|
||||
private applyOutlineState(): void {
|
||||
this.clearOutline();
|
||||
if (this.outlineOn && this.current) this.buildOutline(this.current);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a feature-edge outline (EdgesGeometry, 30° threshold) — the object's
|
||||
* silhouette + sharp creases as black lines, far sparser than a wireframe.
|
||||
* Built lazily; one-time cost can be seconds on very large meshes.
|
||||
*/
|
||||
private buildOutline(obj: THREE.Object3D): void {
|
||||
obj.updateMatrixWorld(true);
|
||||
const group = new THREE.Group();
|
||||
const mat = new THREE.LineBasicMaterial({ color: 0x1a1a1a });
|
||||
obj.traverse((node) => {
|
||||
const mesh = node as THREE.Mesh;
|
||||
if (!mesh.isMesh || !mesh.geometry) return;
|
||||
const seg = new THREE.LineSegments(new THREE.EdgesGeometry(mesh.geometry, 30), mat);
|
||||
mesh.matrixWorld.decompose(seg.position, seg.quaternion, seg.scale);
|
||||
group.add(seg);
|
||||
});
|
||||
this.outline = group;
|
||||
this.scene.add(group);
|
||||
}
|
||||
|
||||
private clearOutline(): void {
|
||||
if (!this.outline) return;
|
||||
this.scene.remove(this.outline);
|
||||
this.outline.traverse((node) => {
|
||||
const seg = node as THREE.LineSegments;
|
||||
if (seg.geometry) seg.geometry.dispose();
|
||||
});
|
||||
const m = (this.outline.children[0] as THREE.LineSegments | undefined)?.material;
|
||||
if (m && !Array.isArray(m)) m.dispose();
|
||||
this.outline = null;
|
||||
}
|
||||
|
||||
/** Toggle the object outline overlay (테두리 button). */
|
||||
toggleOutline(): boolean {
|
||||
this.outlineOn = !this.outlineOn;
|
||||
this.applyOutlineState();
|
||||
return this.outlineOn;
|
||||
}
|
||||
|
||||
/** Switch between perspective and orthographic projection, preserving the view. */
|
||||
setProjection(mode: 'persp' | 'ortho'): void {
|
||||
if (mode === this.projection) return;
|
||||
const target = this.controls.target;
|
||||
const from = this.camera;
|
||||
const fovRad = (this.fov * Math.PI) / 180;
|
||||
const offset = from.position.clone().sub(target);
|
||||
const dist = offset.length() || 1;
|
||||
const aspect = this.aspect;
|
||||
|
||||
if (mode === 'ortho') {
|
||||
const halfH = dist * Math.tan(fovRad / 2);
|
||||
this.orthoCamera.position.copy(from.position);
|
||||
this.orthoCamera.up.copy(from.up);
|
||||
this.orthoCamera.top = halfH;
|
||||
this.orthoCamera.bottom = -halfH;
|
||||
this.orthoCamera.left = -halfH * aspect;
|
||||
this.orthoCamera.right = halfH * aspect;
|
||||
this.orthoCamera.zoom = 1;
|
||||
this.orthoCamera.near = from.near;
|
||||
this.orthoCamera.far = from.far;
|
||||
this.orthoCamera.lookAt(target);
|
||||
this.orthoCamera.updateProjectionMatrix();
|
||||
this.camera = this.orthoCamera;
|
||||
} else {
|
||||
// Place the perspective camera so apparent size matches the ortho view.
|
||||
const orthoHalfH = this.orthoCamera.top / this.orthoCamera.zoom;
|
||||
const d = orthoHalfH / Math.tan(fovRad / 2);
|
||||
this.perspCamera.position.copy(target).add(offset.setLength(d));
|
||||
this.perspCamera.up.copy(from.up);
|
||||
this.perspCamera.aspect = aspect;
|
||||
this.perspCamera.near = from.near;
|
||||
this.perspCamera.far = from.far;
|
||||
this.perspCamera.lookAt(target);
|
||||
this.perspCamera.updateProjectionMatrix();
|
||||
this.camera = this.perspCamera;
|
||||
}
|
||||
this.projection = mode;
|
||||
this.controls.object = this.camera;
|
||||
this.controls.update();
|
||||
}
|
||||
|
||||
private disposeObject(obj: THREE.Object3D): void {
|
||||
const texUrls = obj.userData?.__texUrls as string[] | undefined;
|
||||
if (texUrls) texUrls.forEach((u) => URL.revokeObjectURL(u));
|
||||
obj.traverse((node) => {
|
||||
const mesh = node as THREE.Mesh;
|
||||
if (mesh.geometry) mesh.geometry.dispose();
|
||||
const mat = mesh.material;
|
||||
if (Array.isArray(mat)) mat.forEach((m) => this.disposeMaterial(m));
|
||||
else if (mat) this.disposeMaterial(mat);
|
||||
});
|
||||
}
|
||||
|
||||
private disposeMaterial(mat: THREE.Material): void {
|
||||
for (const v of Object.values(mat)) {
|
||||
if (v instanceof THREE.Texture) v.dispose();
|
||||
}
|
||||
mat.dispose();
|
||||
}
|
||||
|
||||
private get aspect(): number {
|
||||
const w = this.container.clientWidth || window.innerWidth;
|
||||
const h = this.container.clientHeight || window.innerHeight;
|
||||
return w / h;
|
||||
}
|
||||
|
||||
private onResize = (): void => {
|
||||
const w = this.container.clientWidth || window.innerWidth;
|
||||
const h = this.container.clientHeight || window.innerHeight;
|
||||
this.renderer.setSize(w, h, false);
|
||||
const aspect = w / h;
|
||||
this.perspCamera.aspect = aspect;
|
||||
this.perspCamera.updateProjectionMatrix();
|
||||
const halfH = this.orthoCamera.top || 1;
|
||||
this.orthoCamera.left = -halfH * aspect;
|
||||
this.orthoCamera.right = halfH * aspect;
|
||||
this.orthoCamera.updateProjectionMatrix();
|
||||
};
|
||||
|
||||
private animate = (now: number = performance.now()): void => {
|
||||
this.raf = requestAnimationFrame(this.animate);
|
||||
this.fps.sample(now);
|
||||
this.adaptive.update(this.fps.fps());
|
||||
this.controls.update();
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
};
|
||||
|
||||
dispose(): void {
|
||||
cancelAnimationFrame(this.raf);
|
||||
window.removeEventListener('resize', this.onResize);
|
||||
this.controls.dispose();
|
||||
this.clearOutline();
|
||||
if (this.current) this.disposeObject(this.current);
|
||||
this.fps.el.remove();
|
||||
this.renderer.dispose();
|
||||
this.renderer.domElement.remove();
|
||||
}
|
||||
|
||||
/** Orbit the camera to azimuth (radians) around the model — used by tools/prerender.mjs. */
|
||||
rotateTo(azimuth: number): void {
|
||||
if (!this.current) return;
|
||||
const offset = new THREE.Vector3().subVectors(this.camera.position, this.controls.target);
|
||||
const radius = Math.max(offset.length(), 1e-3);
|
||||
const phi = Math.acos(THREE.MathUtils.clamp(offset.y / radius, -1, 1));
|
||||
const target = this.controls.target;
|
||||
this.camera.position.set(
|
||||
target.x + radius * Math.sin(phi) * Math.sin(azimuth),
|
||||
target.y + radius * Math.cos(phi),
|
||||
target.z + radius * Math.sin(phi) * Math.cos(azimuth),
|
||||
);
|
||||
this.camera.lookAt(target);
|
||||
this.controls.update();
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
/**
|
||||
* Adaptive-quality controller.
|
||||
*
|
||||
* The single highest-leverage, lowest-risk runtime knob in this codebase is
|
||||
* renderer.setPixelRatio: it re-allocates the drawing buffer to
|
||||
* floor(clientSize * pixelRatio) without touching CSS layout (onResize already
|
||||
* uses updateStyle=false), so there is zero reflow. We walk a tiered ladder of
|
||||
* pixel-ratio steps with hysteresis (asymmetric down/up thresholds + sustained
|
||||
* windows) so the tier cannot oscillate around the 60fps line.
|
||||
*
|
||||
* Antialias is deliberately NOT touched: it is fixed at WebGLRenderer
|
||||
* construction and changing it would force a new context + KTX2 detectSupport,
|
||||
* violating the singleton-loader invariant in src/viewer/loaders.ts.
|
||||
*/
|
||||
|
||||
const TIER_MIN = 0;
|
||||
const TIER_MAX = 4;
|
||||
// Sustained-sample windows (in frames at ~60fps): ~1s down, longer to recover.
|
||||
const DOWN_FRAMES = 60;
|
||||
const UP_FRAMES = 180;
|
||||
const DOWN_FPS = 60;
|
||||
const UP_FPS = 72;
|
||||
|
||||
export interface AdaptiveQuality {
|
||||
/** Feed the current rolling-average fps; may step the tier. */
|
||||
update(avgFps: number): void;
|
||||
/** Current tier index (0 = best). */
|
||||
tier(): number;
|
||||
}
|
||||
|
||||
export interface AdaptiveQualityOptions {
|
||||
/** Override the tier-0 (best) pixel ratio. Default min(devicePixelRatio, 2). */
|
||||
baseRatio?: number;
|
||||
}
|
||||
|
||||
/** Create the controller and apply tier 0 immediately. */
|
||||
export function createAdaptiveQuality(
|
||||
renderer: THREE.WebGLRenderer,
|
||||
opts: AdaptiveQualityOptions = {},
|
||||
): AdaptiveQuality {
|
||||
const base = opts.baseRatio ?? Math.min(window.devicePixelRatio, 2);
|
||||
// Ladder of pixel ratios, highest quality first. Tier 0 = capped DPR.
|
||||
const ratios = [base, 1.5, 1, 0.75, 0.5];
|
||||
|
||||
let tier = 0;
|
||||
let below = 0;
|
||||
let above = 0;
|
||||
|
||||
renderer.setPixelRatio(ratios[tier]);
|
||||
|
||||
function applyTier(next: number): void {
|
||||
tier = next;
|
||||
below = 0;
|
||||
above = 0;
|
||||
const pr = ratios[tier];
|
||||
renderer.setPixelRatio(pr);
|
||||
console.info(`[hmwebviewer] adaptive quality -> tier ${tier} (pr=${pr})`);
|
||||
}
|
||||
|
||||
return {
|
||||
update(avgFps: number): void {
|
||||
// Ignore warm-up / unmeasured frames.
|
||||
if (avgFps <= 0) return;
|
||||
|
||||
if (avgFps < DOWN_FPS) {
|
||||
below++;
|
||||
above = 0;
|
||||
if (below >= DOWN_FRAMES && tier < TIER_MAX) applyTier(tier + 1);
|
||||
} else if (avgFps > UP_FPS) {
|
||||
above++;
|
||||
below = 0;
|
||||
if (above >= UP_FRAMES && tier > TIER_MIN) applyTier(tier - 1);
|
||||
} else {
|
||||
// Dead band: reset both so transient blips don't accumulate.
|
||||
below = 0;
|
||||
above = 0;
|
||||
}
|
||||
},
|
||||
tier(): number {
|
||||
return tier;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// SSR -> CSR hydration gate. Fades the WebP placeholder to the Three.js canvas
|
||||
// ONLY after BOTH the WebGL context is ready AND the model is loaded.
|
||||
// Firing on just one condition = empty-canvas flash (race). Idempotent.
|
||||
|
||||
export interface HydrationGate {
|
||||
markWebGLReady(): void;
|
||||
markModelLoaded(): void;
|
||||
}
|
||||
|
||||
export function createHydrationGate(preview: HTMLImageElement): HydrationGate {
|
||||
let webglReady = false;
|
||||
let modelLoaded = false;
|
||||
let fired = false;
|
||||
|
||||
function maybeFire(): void {
|
||||
if (fired || !(webglReady && modelLoaded)) return;
|
||||
fired = true;
|
||||
preview.style.opacity = '0';
|
||||
window.setTimeout(() => {
|
||||
preview.classList.add('hidden');
|
||||
preview.style.display = 'none';
|
||||
}, 500);
|
||||
}
|
||||
|
||||
return {
|
||||
markWebGLReady() {
|
||||
webglReady = true;
|
||||
maybeFire();
|
||||
},
|
||||
markModelLoaded() {
|
||||
modelLoaded = true;
|
||||
maybeFire();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as THREE from 'three';
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
|
||||
import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js';
|
||||
|
||||
export interface ViewerLoaders {
|
||||
gltf: GLTFLoader;
|
||||
draco: DRACOLoader;
|
||||
ktx2: KTX2Loader;
|
||||
}
|
||||
|
||||
// SINGLETON — multiple DRACOLoader/KTX2Loader instances crash (three.js #22445).
|
||||
// Reuse across every load. Decoder WASM lives under public/draco + public/basis
|
||||
// (version-matched to the installed three.js).
|
||||
let _cache: ViewerLoaders | null = null;
|
||||
|
||||
export function getLoaders(renderer: THREE.WebGLRenderer): ViewerLoaders {
|
||||
if (_cache) return _cache;
|
||||
const draco = new DRACOLoader().setDecoderPath('/draco/');
|
||||
const ktx2 = new KTX2Loader().setTranscoderPath('/basis/').detectSupport(renderer);
|
||||
const gltf = new GLTFLoader().setDRACOLoader(draco).setKTX2Loader(ktx2);
|
||||
_cache = { gltf, draco, ktx2 };
|
||||
return _cache;
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import * as THREE from 'three';
|
||||
import { getLoaders } from './loaders';
|
||||
|
||||
/**
|
||||
* Multi-format model loader dispatch.
|
||||
*
|
||||
* GLB/GLTF go through the shared singleton loaders (loaders.ts — Draco + KTX2).
|
||||
* OBJ/FBX/Collada use three's example loaders, lazily imported as separate Vite
|
||||
* chunks so they never bloat the initial bundle. IFC uses web-ifc (single-thread
|
||||
* wasm under /web-ifc/, no COOP/COEP headers required).
|
||||
*
|
||||
* Every branch resolves to a plain THREE.Object3D ready to scene.add — the
|
||||
* caller (ThreeDViewer.onLoaded) does not need to know the source format.
|
||||
*/
|
||||
export type SupportedExt = 'glb' | 'gltf' | 'obj' | 'fbx' | 'dae' | 'ifc' | 'ply';
|
||||
|
||||
export const ACCEPT_EXT: SupportedExt[] = ['glb', 'gltf', 'obj', 'fbx', 'dae', 'ifc', 'ply'];
|
||||
|
||||
const EXT_RE = /\.(glb|gltf|obj|fbx|dae|ifc|ply)$/i;
|
||||
|
||||
/** Extract the supported extension from a file name / URL, or null. */
|
||||
export function extOf(name: string): SupportedExt | null {
|
||||
const m = EXT_RE.exec(name);
|
||||
return m ? (m[1].toLowerCase() as SupportedExt) : null;
|
||||
}
|
||||
|
||||
type ProgressCb = (loaded: number, total: number) => void;
|
||||
|
||||
// Lazy single instances for the example loaders — avoids re-import churn on
|
||||
// repeat loads. NOT the Draco/KTX2 singletons (no shared-loader constraint here).
|
||||
type OBJLoaderT = import('three/examples/jsm/loaders/OBJLoader.js').OBJLoader;
|
||||
type FBXLoaderT = import('three/examples/jsm/loaders/FBXLoader.js').FBXLoader;
|
||||
type ColladaLoaderT = import('three/examples/jsm/loaders/ColladaLoader.js').ColladaLoader;
|
||||
type PLYLoaderT = import('three/examples/jsm/loaders/PLYLoader.js').PLYLoader;
|
||||
|
||||
let _obj: OBJLoaderT | null = null;
|
||||
let _fbx: FBXLoaderT | null = null;
|
||||
let _dae: ColladaLoaderT | null = null;
|
||||
let _ply: PLYLoaderT | null = null;
|
||||
|
||||
async function getOBJ(): Promise<OBJLoaderT> {
|
||||
if (_obj) return _obj;
|
||||
const { OBJLoader } = await import('three/examples/jsm/loaders/OBJLoader.js');
|
||||
return (_obj = new OBJLoader());
|
||||
}
|
||||
|
||||
async function getFBX(): Promise<FBXLoaderT> {
|
||||
if (_fbx) return _fbx;
|
||||
const { FBXLoader } = await import('three/examples/jsm/loaders/FBXLoader.js');
|
||||
return (_fbx = new FBXLoader());
|
||||
}
|
||||
|
||||
async function getCollada(): Promise<ColladaLoaderT> {
|
||||
if (_dae) return _dae;
|
||||
const { ColladaLoader } = await import('three/examples/jsm/loaders/ColladaLoader.js');
|
||||
return (_dae = new ColladaLoader());
|
||||
}
|
||||
|
||||
async function getPLY(): Promise<PLYLoaderT> {
|
||||
if (_ply) return _ply;
|
||||
const { PLYLoader } = await import('three/examples/jsm/loaders/PLYLoader.js');
|
||||
return (_ply = new PLYLoader());
|
||||
}
|
||||
|
||||
/**
|
||||
* Load any supported model URL and resolve to a scene-addable Object3D.
|
||||
* `nameHint` carries the real file name for ext detection when `url` is a
|
||||
* blob: URL (drag & drop) — blob URLs have no extension.
|
||||
*/
|
||||
export interface LoadOpts {
|
||||
/** Raw .mtl text for OBJ loads (drag & drop supplies it; blob: URLs can't
|
||||
* resolve the sibling mtllib). Color-only MTLs apply without any texture fetch. */
|
||||
mtlText?: string;
|
||||
/** Image files dropped alongside an OBJ+MTL (textures the MTL's map_* lines
|
||||
* reference). Their blob: URLs are mapped onto the requested texture names so
|
||||
* textures resolve without a server. */
|
||||
texFiles?: File[];
|
||||
/** Parse the OBJ via the streaming parser (objStream.ts) instead of OBJLoader.
|
||||
* Set for files too large for OBJLoader's single-string parse (>~300MB). */
|
||||
stream?: boolean;
|
||||
}
|
||||
|
||||
export async function loadModel(
|
||||
url: string,
|
||||
renderer: THREE.WebGLRenderer,
|
||||
onProgress?: ProgressCb,
|
||||
nameHint?: string,
|
||||
opts?: LoadOpts,
|
||||
): Promise<THREE.Object3D> {
|
||||
const ext = extOf(nameHint ?? url) ?? extOf(url);
|
||||
const onXhr = (xhr: ProgressEvent): void => {
|
||||
if (onProgress && xhr.total > 0) onProgress(xhr.loaded, xhr.total);
|
||||
};
|
||||
|
||||
switch (ext) {
|
||||
case 'glb':
|
||||
case 'gltf': {
|
||||
const { gltf } = getLoaders(renderer);
|
||||
return new Promise<THREE.Object3D>((resolve, reject) => {
|
||||
gltf.load(url, (g) => resolve(g.scene), onXhr, (err) => reject(err));
|
||||
});
|
||||
}
|
||||
case 'obj': {
|
||||
if (opts?.stream) {
|
||||
const { loadObjStreaming } = await import('./objStream');
|
||||
return loadObjStreaming(url, opts.mtlText, onProgress);
|
||||
}
|
||||
const loader = await getOBJ();
|
||||
const texUrls: string[] = [];
|
||||
// Apply dropped .mtl (color-only) if present; otherwise clear any materials
|
||||
// left on the singleton loader from a previous load.
|
||||
if (opts?.mtlText) {
|
||||
const { MTLLoader } = await import('three/examples/jsm/loaders/MTLLoader.js');
|
||||
const manager = new THREE.LoadingManager();
|
||||
if (opts.texFiles && opts.texFiles.length) {
|
||||
// Map each dropped image's blob: URL onto the texture name the MTL
|
||||
// references (by basename), so map_Kd etc. resolve without a server.
|
||||
const byName = new Map<string, string>();
|
||||
for (const f of opts.texFiles) {
|
||||
const u = URL.createObjectURL(f);
|
||||
texUrls.push(u);
|
||||
byName.set(f.name.toLowerCase(), u);
|
||||
}
|
||||
manager.setURLModifier((u) => {
|
||||
const base = decodeURIComponent((u.split(/[\\/]/).pop() ?? '')).toLowerCase();
|
||||
return byName.get(base) ?? u;
|
||||
});
|
||||
}
|
||||
const mc = new MTLLoader(manager).parse(opts.mtlText, '');
|
||||
mc.preload();
|
||||
loader.setMaterials(mc);
|
||||
} else {
|
||||
(loader as unknown as { materials: unknown }).materials = null;
|
||||
}
|
||||
return new Promise<THREE.Object3D>((resolve, reject) => {
|
||||
loader.load(
|
||||
url,
|
||||
(group) => {
|
||||
group.traverse((node) => {
|
||||
const mat = (node as THREE.Mesh).material;
|
||||
if (!mat) return;
|
||||
(Array.isArray(mat) ? mat : [mat]).forEach((m) => {
|
||||
// CAD OBJ faces often have inconsistent winding; backface culling
|
||||
// tears flat surfaces. Render double-sided.
|
||||
m.side = THREE.DoubleSide;
|
||||
const map = (m as THREE.MeshPhongMaterial).map;
|
||||
if (map) map.colorSpace = THREE.SRGBColorSpace; // color textures are sRGB
|
||||
});
|
||||
});
|
||||
// Texture blob: URLs live with the object; revoked on dispose.
|
||||
if (texUrls.length) group.userData.__texUrls = texUrls;
|
||||
resolve(group);
|
||||
},
|
||||
onXhr,
|
||||
(err) => reject(err),
|
||||
);
|
||||
});
|
||||
}
|
||||
case 'fbx': {
|
||||
const loader = await getFBX();
|
||||
return new Promise<THREE.Object3D>((resolve, reject) => {
|
||||
loader.load(url, (group) => resolve(group), onXhr, (err) => reject(err));
|
||||
});
|
||||
}
|
||||
case 'dae': {
|
||||
const loader = await getCollada();
|
||||
return new Promise<THREE.Object3D>((resolve, reject) => {
|
||||
loader.load(url, (result) => resolve(result.scene), onXhr, (err) => reject(err));
|
||||
});
|
||||
}
|
||||
case 'ply': {
|
||||
const loader = await getPLY();
|
||||
return new Promise<THREE.Object3D>((resolve, reject) => {
|
||||
loader.load(
|
||||
url,
|
||||
(geometry) => {
|
||||
const hasColor = geometry.hasAttribute('color');
|
||||
if (geometry.index) {
|
||||
// Triangle mesh
|
||||
if (!geometry.hasAttribute('normal')) geometry.computeVertexNormals();
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: 0xffffff,
|
||||
vertexColors: hasColor,
|
||||
metalness: 0.0,
|
||||
roughness: 1.0,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
resolve(new THREE.Mesh(geometry, mat));
|
||||
} else {
|
||||
// No faces → point cloud
|
||||
const mat = new THREE.PointsMaterial({
|
||||
color: 0xffffff,
|
||||
vertexColors: hasColor,
|
||||
size: 1,
|
||||
sizeAttenuation: false,
|
||||
});
|
||||
resolve(new THREE.Points(geometry, mat));
|
||||
}
|
||||
},
|
||||
onXhr,
|
||||
(err) => reject(err),
|
||||
);
|
||||
});
|
||||
}
|
||||
case 'ifc':
|
||||
return loadIfc(url, onProgress);
|
||||
default:
|
||||
throw new Error('Unsupported model format: ' + url);
|
||||
}
|
||||
}
|
||||
|
||||
let _ifcApi: import('web-ifc').IfcAPI | null = null;
|
||||
|
||||
/** Parse an .ifc into a Group via web-ifc (single-thread wasm, no COOP/COEP). */
|
||||
async function loadIfc(url: string, onProgress?: ProgressCb): Promise<THREE.Object3D> {
|
||||
try {
|
||||
const WebIFC = await import('web-ifc');
|
||||
if (!_ifcApi) {
|
||||
_ifcApi = new WebIFC.IfcAPI();
|
||||
_ifcApi.SetWasmPath('/web-ifc/', true);
|
||||
await _ifcApi.Init(undefined, true);
|
||||
}
|
||||
const api = _ifcApi;
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error('fetch ' + res.status);
|
||||
const buf = await res.arrayBuffer();
|
||||
if (onProgress) onProgress(1, 1);
|
||||
const data = new Uint8Array(buf);
|
||||
|
||||
const modelID = api.OpenModel(data, { COORDINATE_TO_ORIGIN: true });
|
||||
if (modelID < 0) throw new Error('OpenModel returned -1');
|
||||
|
||||
const group = new THREE.Group();
|
||||
const matrix = new THREE.Matrix4();
|
||||
try {
|
||||
api.StreamAllMeshes(modelID, (mesh) => {
|
||||
const placedCount = mesh.geometries.size();
|
||||
for (let i = 0; i < placedCount; i++) {
|
||||
const placed = mesh.geometries.get(i);
|
||||
const geom = api.GetGeometry(modelID, placed.geometryExpressID);
|
||||
const verts = api.GetVertexArray(geom.GetVertexData(), geom.GetVertexDataSize());
|
||||
const indices = api.GetIndexArray(geom.GetIndexData(), geom.GetIndexDataSize());
|
||||
geom.delete();
|
||||
|
||||
// Interleaved [posX,posY,posZ, normX,normY,normZ] — stride 6.
|
||||
const interleaved = new THREE.InterleavedBuffer(verts, 6);
|
||||
const bg = new THREE.BufferGeometry();
|
||||
bg.setAttribute('position', new THREE.InterleavedBufferAttribute(interleaved, 3, 0));
|
||||
bg.setAttribute('normal', new THREE.InterleavedBufferAttribute(interleaved, 3, 3));
|
||||
bg.setIndex(new THREE.BufferAttribute(indices, 1));
|
||||
|
||||
const c = placed.color;
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color(c.x, c.y, c.z),
|
||||
metalness: 0.0,
|
||||
roughness: 1.0,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
if (c.w < 1) {
|
||||
mat.transparent = true;
|
||||
mat.opacity = c.w;
|
||||
}
|
||||
|
||||
const three = new THREE.Mesh(bg, mat);
|
||||
matrix.fromArray(placed.flatTransformation);
|
||||
three.applyMatrix4(matrix);
|
||||
group.add(three);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
api.CloseModel(modelID);
|
||||
}
|
||||
return group;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error('IFC load failed: ' + msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* In-viewer float64 recenter for OBJ files with huge absolute coordinates
|
||||
* (CAD/survey, ~1e8). Done at LOAD time, before OBJLoader quantizes to float32.
|
||||
*
|
||||
* Why here and not after parse: WebGL vertex buffers are float32; at ~1e8 the
|
||||
* float32 ulp is 16-64 units, so vertices snap to that grid, merge, and faces
|
||||
* crack. Subtracting the bbox center from the *source text* (float64) keeps
|
||||
* coords small (~±3e4) so float32 holds full detail — no preprocessed files,
|
||||
* no shader-side double emulation (RTE/two-float), which a single offset of
|
||||
* this magnitude makes unnecessary (relative precision after centering ~3e-5
|
||||
* vs float32 ~1e-7).
|
||||
*
|
||||
* Streamed in two passes (bbox, then rewrite) to keep memory bounded on the
|
||||
* 100-200MB OBJ files this targets.
|
||||
*/
|
||||
|
||||
const V = 118; // 'v'
|
||||
const SP = 32; // ' '
|
||||
const RECENTER_THRESHOLD = 1e4; // models nearer the origin than this are left as-is
|
||||
|
||||
/** Yield LF-normalized lines from a Blob's stream without buffering the whole file. */
|
||||
async function* lines(blob: Blob): AsyncGenerator<string> {
|
||||
const reader = blob.stream().pipeThrough(new TextDecoderStream()).getReader();
|
||||
let buf = '';
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += value;
|
||||
let i: number;
|
||||
while ((i = buf.indexOf('\n')) >= 0) {
|
||||
let ln = buf.slice(0, i);
|
||||
if (ln.charCodeAt(ln.length - 1) === 13) ln = ln.slice(0, -1); // strip CR
|
||||
yield ln;
|
||||
buf = buf.slice(i + 1);
|
||||
}
|
||||
}
|
||||
if (buf.length) yield buf;
|
||||
}
|
||||
|
||||
function fmt(n: number): string {
|
||||
return Number.isInteger(n) ? String(n) : String(Math.round(n * 1000) / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an origin-centered copy of `file` as a Blob, or the original `file`
|
||||
* unchanged when it already sits near the origin (small models pay only the
|
||||
* cheap first-pass scan). The returned Blob carries the same OBJ text minus a
|
||||
* per-file integer offset on every `v` line; vn/f/usemtl/mtllib are verbatim.
|
||||
*/
|
||||
export async function recenterObjFile(
|
||||
file: File,
|
||||
onProgress?: (loaded: number, total: number) => void,
|
||||
): Promise<Blob> {
|
||||
// Two streamed passes (bbox, then rewrite) over the file's bytes; report
|
||||
// progress across both, throttled so the DOM isn't touched per line.
|
||||
const total = file.size * 2;
|
||||
let read = 0, tick = 0;
|
||||
const report = (line: string): void => {
|
||||
read += line.length + 1;
|
||||
if (onProgress && (++tick & 0x3fff) === 0) onProgress(read, total); // every ~16k lines
|
||||
};
|
||||
|
||||
let xmin = Infinity, ymin = Infinity, zmin = Infinity;
|
||||
let xmax = -Infinity, ymax = -Infinity, zmax = -Infinity;
|
||||
for await (const line of lines(file)) {
|
||||
report(line);
|
||||
if (line.charCodeAt(0) !== V || line.charCodeAt(1) !== SP) continue;
|
||||
const p = line.split(/\s+/);
|
||||
const x = +p[1], y = +p[2], z = +p[3];
|
||||
if (x < xmin) xmin = x; if (x > xmax) xmax = x;
|
||||
if (y < ymin) ymin = y; if (y > ymax) ymax = y;
|
||||
if (z < zmin) zmin = z; if (z > zmax) zmax = z;
|
||||
}
|
||||
if (!Number.isFinite(xmin)) return file; // no vertices — let OBJLoader handle it
|
||||
|
||||
const cx = (xmin + xmax) / 2, cy = (ymin + ymax) / 2, cz = (zmin + zmax) / 2;
|
||||
if (Math.hypot(cx, cy, cz) < RECENTER_THRESHOLD) return file;
|
||||
|
||||
const ox = Math.round(cx), oy = Math.round(cy), oz = Math.round(cz);
|
||||
const enc = new TextEncoder();
|
||||
const parts: BlobPart[] = [];
|
||||
let out = '';
|
||||
for await (const line of lines(file)) {
|
||||
report(line);
|
||||
if (line.charCodeAt(0) === V && line.charCodeAt(1) === SP) {
|
||||
const p = line.split(/\s+/);
|
||||
out += `v ${fmt(+p[1] - ox)} ${fmt(+p[2] - oy)} ${fmt(+p[3] - oz)}\n`;
|
||||
} else {
|
||||
out += line + '\n';
|
||||
}
|
||||
if (out.length > (1 << 23)) { parts.push(enc.encode(out)); out = ''; } // flush ~8MB
|
||||
}
|
||||
if (out) parts.push(enc.encode(out));
|
||||
if (onProgress) onProgress(total, total);
|
||||
return new Blob(parts, { type: 'text/plain' });
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
/**
|
||||
* Streaming OBJ parser for files too large for OBJLoader.
|
||||
*
|
||||
* OBJLoader builds one giant string of the whole file; past ~512MB-1GB of text
|
||||
* that exceeds the V8 max string length and fails (empty geometry). This parses
|
||||
* the file in two streamed passes (count, then fill) so it never holds the whole
|
||||
* text, building an INDEXED BufferGeometry (positions indexed by vertex, normals
|
||||
* computed). Memory stays ~O(vertices + triangles) of typed arrays.
|
||||
*
|
||||
* Trade-offs vs OBJLoader (acceptable for huge structural CAD exports):
|
||||
* - smooth (computed) normals, not per-face-corner — hard edges soften.
|
||||
* - per-vertex color from the active material's Kd (one draw call, no textures).
|
||||
* - float64 recenter baked in (huge absolute coords don't crack float32).
|
||||
*/
|
||||
|
||||
const SP = 32; // ' '
|
||||
const V = 118; // 'v'
|
||||
const F = 102; // 'f'
|
||||
const U = 117; // 'u' (usemtl)
|
||||
const RECENTER_THRESHOLD = 1e4;
|
||||
|
||||
async function* streamLines(url: string): AsyncGenerator<string> {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok || !resp.body) throw new Error('fetch failed: ' + resp.status);
|
||||
const reader = resp.body.pipeThrough(new TextDecoderStream()).getReader();
|
||||
let buf = '';
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += value;
|
||||
let i: number;
|
||||
while ((i = buf.indexOf('\n')) >= 0) {
|
||||
let ln = buf.slice(0, i);
|
||||
if (ln.charCodeAt(ln.length - 1) === 13) ln = ln.slice(0, -1); // strip CR
|
||||
yield ln;
|
||||
buf = buf.slice(i + 1);
|
||||
}
|
||||
}
|
||||
if (buf.length) yield buf;
|
||||
}
|
||||
|
||||
function parseMtlColors(text: string): Map<string, [number, number, number]> {
|
||||
const map = new Map<string, [number, number, number]>();
|
||||
let cur = '';
|
||||
for (const raw of text.split('\n')) {
|
||||
const line = raw.trim();
|
||||
if (line.startsWith('newmtl ')) cur = line.slice(7).trim();
|
||||
else if (cur && line.startsWith('Kd ')) {
|
||||
const p = line.split(/\s+/);
|
||||
map.set(cur, [+p[1], +p[2], +p[3]]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function loadObjStreaming(
|
||||
url: string,
|
||||
mtlText?: string,
|
||||
onProgress?: (loaded: number, total: number) => void,
|
||||
): Promise<THREE.Object3D> {
|
||||
// PASS 1 — count vertices + triangles, accumulate bbox in float64.
|
||||
let nV = 0, nTri = 0;
|
||||
let xmin = Infinity, ymin = Infinity, zmin = Infinity;
|
||||
let xmax = -Infinity, ymax = -Infinity, zmax = -Infinity;
|
||||
for await (const line of streamLines(url)) {
|
||||
const c0 = line.charCodeAt(0);
|
||||
if (c0 === V && line.charCodeAt(1) === SP) {
|
||||
nV++;
|
||||
const p = line.split(/\s+/);
|
||||
const x = +p[1], y = +p[2], z = +p[3];
|
||||
if (x < xmin) xmin = x; if (x > xmax) xmax = x;
|
||||
if (y < ymin) ymin = y; if (y > ymax) ymax = y;
|
||||
if (z < zmin) zmin = z; if (z > zmax) zmax = z;
|
||||
} else if (c0 === F && line.charCodeAt(1) === SP) {
|
||||
let corners = 0;
|
||||
const p = line.split(/\s+/);
|
||||
for (let k = 1; k < p.length; k++) if (p[k]) corners++;
|
||||
if (corners >= 3) nTri += corners - 2;
|
||||
}
|
||||
}
|
||||
if (nV === 0) throw new Error('OBJ has no vertices');
|
||||
|
||||
const cx = (xmin + xmax) / 2, cy = (ymin + ymax) / 2, cz = (zmin + zmax) / 2;
|
||||
const far = Math.hypot(cx, cy, cz) >= RECENTER_THRESHOLD;
|
||||
const ox = far ? Math.round(cx) : 0, oy = far ? Math.round(cy) : 0, oz = far ? Math.round(cz) : 0;
|
||||
|
||||
// PASS 2 — fill typed arrays.
|
||||
const positions = new Float32Array(nV * 3);
|
||||
const index = new Uint32Array(nTri * 3);
|
||||
const colorMap = mtlText ? parseMtlColors(mtlText) : null;
|
||||
const colors = colorMap && colorMap.size ? new Float32Array(nV * 3) : null;
|
||||
let vi = 0, ii = 0;
|
||||
let cr = 0.8, cg = 0.8, cb = 0.8;
|
||||
const total = nV + nTri; let done = 0;
|
||||
|
||||
for await (const line of streamLines(url)) {
|
||||
const c0 = line.charCodeAt(0), c1 = line.charCodeAt(1);
|
||||
if (c0 === V && c1 === SP) {
|
||||
const p = line.split(/\s+/);
|
||||
positions[vi * 3] = +p[1] - ox;
|
||||
positions[vi * 3 + 1] = +p[2] - oy;
|
||||
positions[vi * 3 + 2] = +p[3] - oz;
|
||||
vi++;
|
||||
if ((++done & 0x3ffff) === 0) onProgress?.(done, total);
|
||||
} else if (c0 === F && c1 === SP) {
|
||||
const p = line.split(/\s+/);
|
||||
const vs: number[] = [];
|
||||
for (let k = 1; k < p.length; k++) {
|
||||
if (!p[k]) continue;
|
||||
const slash = p[k].indexOf('/');
|
||||
let idx = parseInt(slash >= 0 ? p[k].slice(0, slash) : p[k], 10);
|
||||
idx = idx < 0 ? nV + idx : idx - 1; // negative = relative; OBJ is 1-based
|
||||
vs.push(idx);
|
||||
}
|
||||
if (colors) for (const v of vs) { colors[v * 3] = cr; colors[v * 3 + 1] = cg; colors[v * 3 + 2] = cb; }
|
||||
for (let t = 1; t + 1 < vs.length; t++) { // fan triangulate
|
||||
index[ii++] = vs[0]; index[ii++] = vs[t]; index[ii++] = vs[t + 1];
|
||||
}
|
||||
if ((++done & 0x3ffff) === 0) onProgress?.(done, total);
|
||||
} else if (c0 === U && line.startsWith('usemtl ')) {
|
||||
const c = colorMap?.get(line.slice(7).trim());
|
||||
if (c) { cr = c[0]; cg = c[1]; cb = c[2]; }
|
||||
}
|
||||
}
|
||||
|
||||
const geom = new THREE.BufferGeometry();
|
||||
geom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
if (colors) geom.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||||
geom.setIndex(new THREE.BufferAttribute(index, 1));
|
||||
geom.computeVertexNormals();
|
||||
onProgress?.(total, total);
|
||||
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: colors ? 0xffffff : 0xcccccc,
|
||||
vertexColors: !!colors,
|
||||
metalness: 0.0,
|
||||
roughness: 1.0,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
return new THREE.Mesh(geom, mat);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
# tools/ — Offline asset pipeline
|
||||
|
||||
Offline tooling for preparing 3D assets served by the hmwebviewer. Nothing here
|
||||
ships to the browser at runtime; these produce the optimized assets and
|
||||
pre-rendered placeholders that the viewer consumes.
|
||||
|
||||
## preprocess.mjs — Draco + KTX2 compression
|
||||
|
||||
Compresses a `.glb`/`.gltf` into a single self-contained `.optimized.glb` using
|
||||
[gltf-transform](https://gltf-transform.dev/): Draco for geometry, KTX2/Basis
|
||||
Universal for textures, in one pass. Output loads directly in the viewer's
|
||||
`GLTFLoader` (with `DRACOLoader` + `KTX2Loader` wired on the shared loader).
|
||||
|
||||
### Install (once)
|
||||
|
||||
These dev dependencies are **not** in `package.json` yet. Run before first use:
|
||||
|
||||
```bash
|
||||
npm install -D @gltf-transform/core @gltf-transform/functions \
|
||||
@gltf-transform/extensions @gltf-transform/cli
|
||||
```
|
||||
|
||||
The CLI package provides the KTX2 encoder wiring. gltf-transform fetches the
|
||||
platform basis encoder automatically on first KTX2 run (network needed once).
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
node tools/preprocess.mjs <input.glb> [output.glb] [--draco-bits N] [--ktx2|--no-ktx2]
|
||||
```
|
||||
|
||||
- Default output: `<input>.optimized.glb` (e.g. `model.glb` → `model.optimized.glb`).
|
||||
- `--draco-bits N` — position quantization bits, 8..16 (default **14**). Lower =
|
||||
smaller file, lossier geometry. Tune per asset.
|
||||
- `--ktx2` / `--no-ktx2` — texture encoding toggle (default **on**).
|
||||
|
||||
Prints before/after byte sizes and reduction %.
|
||||
|
||||
### Outputs
|
||||
|
||||
- Compressed GLBs land in [`samples/`](../samples/) (`samples/*.optimized.glb`).
|
||||
- Pre-rendered WebP placeholders land in [`public/previews/`](../public/previews/)
|
||||
(`*.webp`) — see the pre-render section below.
|
||||
|
||||
If a required package is missing at runtime, the script prints the install
|
||||
command above and exits non-zero (no silent skip).
|
||||
|
||||
## Sample assets
|
||||
|
||||
Place source `.glb` files under `samples/` (suggested split: small / medium /
|
||||
large). Compress with `preprocess.mjs` and commit the `.optimized.glb` outputs.
|
||||
Samples are not committed yet — to be added when a sample asset is available.
|
||||
|
||||
## Pre-render pipeline (360° animated WebP placeholder)
|
||||
|
||||
For Path A (server asset) the server ships a pre-rendered 360° turntable so the
|
||||
page shows motion instantly while the real model decodes in the background.
|
||||
Output: `public/previews/<asset>.webp` (animated, with alpha).
|
||||
|
||||
**Status: to be implemented when a sample asset is available.** Two options:
|
||||
|
||||
1. **Blender headless CLI (preferred).** Camera parented to an empty at the
|
||||
model origin, Z axis 0→360° keyframed over N frames. Render frames:
|
||||
```bash
|
||||
blender -b scene.blend -o //frame_### -f 1..N -F PNG
|
||||
```
|
||||
Assemble to animated WebP (alpha) or WebM VP9 via ffmpeg:
|
||||
```bash
|
||||
ffmpeg -framerate 24 -i frame_%03d.png -loop 0 -plays 0 out.webp
|
||||
```
|
||||
|
||||
2. **Puppeteer + headless three.js (fallback).** Spin a minimal three.js page,
|
||||
rotate the model, and call `page.screenshot({ type: 'webp' })` per rotation
|
||||
step. Stitch frames into an animated WebP.
|
||||
|
||||
Both options need a committed sample asset first; blocked on P4-2.
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* tools/adaptive-smoke.mjs — empirically trigger the adaptive-quality downstep.
|
||||
*
|
||||
* Loads a heavy sample in headless Chrome (software GL = slow) + applies a CDP
|
||||
* CPU throttle so the REAL measured fps sustains < 60. Captures the genuine
|
||||
* `[hmwebviewer] adaptive quality -> tier N (pr=..)` console logs emitted by
|
||||
* src/viewer/adaptiveQuality.ts, plus the live #.fps overlay text. Proves the
|
||||
* <60fps → optimize path fires on real frame measurement (no logic patched).
|
||||
*
|
||||
* USAGE: node tools/adaptive-smoke.mjs [--url URL] [--asset PATH] [--throttle N] [--seconds S]
|
||||
*/
|
||||
import puppeteer from 'puppeteer-core';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const CHROME_CANDIDATES = [
|
||||
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
|
||||
];
|
||||
function findBrowser() {
|
||||
if (process.env.PUPPETEER_EXECUTABLE_PATH && existsSync(process.env.PUPPETEER_EXECUTABLE_PATH)) return process.env.PUPPETEER_EXECUTABLE_PATH;
|
||||
for (const p of CHROME_CANDIDATES) if (existsSync(p)) return p;
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const a = argv.slice(2);
|
||||
let url = 'http://127.0.0.1:4173', asset = '/samples/ABeautifulGame.ktx2.glb', throttle = 6, seconds = 25;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] === '--url') url = a[++i];
|
||||
else if (a[i] === '--asset') asset = a[++i];
|
||||
else if (a[i] === '--throttle') throttle = Number(a[++i]);
|
||||
else if (a[i] === '--seconds') seconds = Number(a[++i]);
|
||||
}
|
||||
return { url, asset, throttle, seconds };
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function main() {
|
||||
const o = parseArgs(process.argv);
|
||||
const exe = findBrowser();
|
||||
if (!exe) { console.error('[adaptive-smoke] No Chrome/Edge found.'); process.exit(1); }
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: exe,
|
||||
headless: 'new',
|
||||
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
|
||||
const tierLogs = [];
|
||||
page.on('console', (m) => {
|
||||
const t = m.text();
|
||||
if (t.includes('adaptive quality')) { tierLogs.push(t); console.log(' LOG ' + t); }
|
||||
});
|
||||
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
window.__hmwReady = null;
|
||||
window.addEventListener('hmw:ready', (e) => { window.__hmwReady = e.detail; });
|
||||
});
|
||||
|
||||
const target = o.url + '?model=' + o.asset;
|
||||
console.log('[adaptive-smoke] ' + target + ' throttle=' + o.throttle + 'x watch=' + o.seconds + 's browser=' + exe);
|
||||
await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForFunction('window.__hmwReady != null', { timeout: 60000 });
|
||||
console.log('[adaptive-smoke] model loaded; applying CPU throttle + watching fps...');
|
||||
|
||||
const client = await page.target().createCDPSession();
|
||||
await client.send('Emulation.setCPUThrottlingRate', { rate: o.throttle });
|
||||
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < o.seconds * 1000) {
|
||||
await sleep(2000);
|
||||
const state = await page.evaluate(() => {
|
||||
const el = document.querySelector('.fps');
|
||||
return { fps: el ? el.textContent : '(no overlay)', pr: window.__viewer ? undefined : undefined };
|
||||
});
|
||||
console.log(' t+' + Math.round((Date.now() - start) / 1000) + 's overlay=' + state.fps + ' tierSteps=' + tierLogs.length);
|
||||
if (tierLogs.length >= 3) break; // enough proof
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log('\n[adaptive-smoke] tier-change log lines captured: ' + tierLogs.length);
|
||||
tierLogs.forEach((l) => console.log(' ' + l));
|
||||
if (tierLogs.length === 0) {
|
||||
console.error('[adaptive-smoke] NO downstep observed — fps stayed >=60 (raise --throttle or use a heavier asset).');
|
||||
process.exit(2);
|
||||
}
|
||||
console.log('[adaptive-smoke] PASS — adaptive quality degraded on sustained <60fps.');
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('[adaptive-smoke] Fatal: ' + (e && e.stack ? e.stack : e)); process.exit(1); });
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copies Draco + KTX2/Basis decoder WASM from three.js into public/ so the
|
||||
// runtime paths /draco/ and /basis/ resolve (version-matched to installed three).
|
||||
// Also copies the web-ifc single-thread WASM to public/web-ifc/ for the IFC path.
|
||||
// Runs automatically via `npm install` (postinstall). Safe to re-run.
|
||||
import { cpSync, copyFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(__dirname, '..');
|
||||
|
||||
const libs = resolve(root, 'node_modules/three/examples/jsm/libs');
|
||||
const targets = [
|
||||
[resolve(libs, 'draco/gltf'), resolve(root, 'public/draco')],
|
||||
[resolve(libs, 'basis'), resolve(root, 'public/basis')],
|
||||
];
|
||||
|
||||
if (!existsSync(libs)) {
|
||||
console.warn('[copy-decoders] three not installed yet — skipping (will run on next install).');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
for (const [src, dest] of targets) {
|
||||
if (!existsSync(src)) {
|
||||
console.warn(`[copy-decoders] source missing: ${src}`);
|
||||
continue;
|
||||
}
|
||||
mkdirSync(dest, { recursive: true });
|
||||
cpSync(src, dest, { recursive: true });
|
||||
console.log(`[copy-decoders] ${src} -> ${dest}`);
|
||||
}
|
||||
|
||||
// web-ifc single-thread WASM (+ MT build if present) for the IFC load path.
|
||||
const ifcSrcDir = resolve(root, 'node_modules/web-ifc');
|
||||
const ifcDest = resolve(root, 'public/web-ifc');
|
||||
if (existsSync(ifcSrcDir)) {
|
||||
mkdirSync(ifcDest, { recursive: true });
|
||||
for (const wasm of ['web-ifc.wasm', 'web-ifc-mt.wasm']) {
|
||||
const src = resolve(ifcSrcDir, wasm);
|
||||
if (!existsSync(src)) continue;
|
||||
copyFileSync(src, resolve(ifcDest, wasm));
|
||||
console.log(`[copy-decoders] ${src} -> ${resolve(ifcDest, wasm)}`);
|
||||
}
|
||||
} else {
|
||||
console.warn('[copy-decoders] web-ifc not installed yet — skipping IFC wasm.');
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* tools/dnd-smoke.mjs — CSR (Path B / drag&drop) load smoke.
|
||||
*
|
||||
* The per-format perf-smoke only exercised Path A (?model=). This drives the
|
||||
* REAL local-file path: fetch each sample into a File, hand it to
|
||||
* window.__viewer.loadLocalFile(file) (exactly what the dropzone does), and wait
|
||||
* for hmw:ready. Catches the blob:-URL-has-no-extension class of bug.
|
||||
*
|
||||
* USAGE: node tools/dnd-smoke.mjs [--url URL] [--asset /samples/x ...]
|
||||
* (run `npm run build && npm run preview` first)
|
||||
*/
|
||||
import puppeteer from 'puppeteer-core';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const CHROME_CANDIDATES = [
|
||||
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
|
||||
];
|
||||
function findBrowser() {
|
||||
if (process.env.PUPPETEER_EXECUTABLE_PATH && existsSync(process.env.PUPPETEER_EXECUTABLE_PATH)) return process.env.PUPPETEER_EXECUTABLE_PATH;
|
||||
for (const p of CHROME_CANDIDATES) if (existsSync(p)) return p;
|
||||
return null;
|
||||
}
|
||||
function parseArgs(argv) {
|
||||
const a = argv.slice(2); let url = 'http://127.0.0.1:4173'; const assets = [];
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] === '--url') url = a[++i]; else if (a[i] === '--asset') assets.push(a[++i]);
|
||||
}
|
||||
if (!assets.length) assets.push('/samples/Box.glb', '/samples/Duck.glb', '/samples/Avocado.glb', '/samples/Cube.obj', '/samples/Cube.fbx', '/samples/Cube.dae', '/samples/Cube.ifc');
|
||||
return { url, assets };
|
||||
}
|
||||
|
||||
async function dropOne(page, base, asset) {
|
||||
await page.goto(base, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForFunction('window.__viewer != null', { timeout: 15000 });
|
||||
try {
|
||||
const res = await page.evaluate(async (assetPath) => {
|
||||
const name = assetPath.split('/').pop();
|
||||
const resp = await fetch(assetPath);
|
||||
if (!resp.ok) return { ok: false, err: 'fetch ' + resp.status };
|
||||
const blob = await resp.blob();
|
||||
const file = new File([blob], name, { type: blob.type || 'application/octet-stream' });
|
||||
const ready = new Promise((resolve) => {
|
||||
const onErr = (e) => resolve({ ok: false, err: 'viewer error: ' + (e.detail || 'unknown') });
|
||||
window.addEventListener('hmw:ready', () => resolve({ ok: true }), { once: true });
|
||||
// surface our own onError via console; also time out below
|
||||
window.__dndTimeout = setTimeout(() => resolve({ ok: false, err: 'timeout (no hmw:ready)' }), 20000);
|
||||
});
|
||||
window.__viewer.loadLocalFile(file);
|
||||
return ready;
|
||||
}, asset);
|
||||
return { asset, ...res };
|
||||
} catch (e) {
|
||||
return { asset, ok: false, err: (e && e.message) || String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const o = parseArgs(process.argv);
|
||||
const exe = findBrowser();
|
||||
if (!exe) { console.error('[dnd-smoke] No Chrome/Edge found.'); process.exit(1); }
|
||||
const browser = await puppeteer.launch({ executablePath: exe, headless: 'new', args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'] });
|
||||
const page = await browser.newPage();
|
||||
const errs = [];
|
||||
page.on('console', (m) => { const t = m.text(); if (t.includes('load failed') || t.includes('Failed to load')) errs.push(t); });
|
||||
|
||||
console.log('[dnd-smoke] CSR drag&drop path, base=' + o.url);
|
||||
const results = [];
|
||||
for (const asset of o.assets) {
|
||||
process.stdout.write(' drop ' + asset + ' ... ');
|
||||
const r = await dropOne(page, o.url, asset);
|
||||
results.push(r);
|
||||
console.log(r.ok ? 'OK' : 'FAIL (' + r.err + ')');
|
||||
}
|
||||
await browser.close();
|
||||
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
console.log('\n[dnd-smoke] ' + (results.length - failed.length) + '/' + results.length + ' passed');
|
||||
if (failed.length) { console.error('[dnd-smoke] FAILURES: ' + failed.map((f) => f.asset).join(', ')); process.exit(1); }
|
||||
console.log('[dnd-smoke] all local drops load.');
|
||||
}
|
||||
main().catch((e) => { console.error('[dnd-smoke] Fatal: ' + (e && e.stack ? e.stack : e)); process.exit(1); });
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Diagnostic: load a huge OBJ (+mtl) through the REAL drag&drop path in headless
|
||||
* Chrome, capture console ([recenter]/[hmw] bbox) and a screenshot. Confirms
|
||||
* whether the in-viewer recenter ran and whether faces crack.
|
||||
*
|
||||
* USAGE: node tools/girder-smoke.mjs [--url http://localhost:3333] [obj] [mtl]
|
||||
*/
|
||||
import puppeteer from 'puppeteer-core';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const CHROME = [
|
||||
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
|
||||
].find((p) => existsSync(p));
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
let url = 'http://localhost:3333';
|
||||
const files = [];
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--url') url = args[++i];
|
||||
else files.push(args[i]);
|
||||
}
|
||||
if (files.length === 0) {
|
||||
files.push('samples/GirderObjs/part01.obj', 'samples/GirderObjs/part01.mtl');
|
||||
}
|
||||
const paths = files.map((f) => resolve(f));
|
||||
for (const p of paths) if (!existsSync(p)) { console.error('missing', p); process.exit(1); }
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: CHROME,
|
||||
headless: 'new',
|
||||
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist',
|
||||
'--disable-dev-shm-usage', '--window-size=1400,900'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1400, height: 900 });
|
||||
page.on('console', (m) => console.log(' [browser]', m.text()));
|
||||
page.on('pageerror', (e) => console.log(' [pageerror]', e.message));
|
||||
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
window.__hmwReady = null;
|
||||
window.addEventListener('hmw:ready', (e) => { window.__hmwReady = e.detail; });
|
||||
});
|
||||
|
||||
console.log('goto', url);
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForSelector('#file-input', { timeout: 10000 });
|
||||
|
||||
const input = await page.$('#file-input');
|
||||
console.log('uploading', paths.join(', '));
|
||||
await input.uploadFile(...paths);
|
||||
await page.evaluate(() => document.getElementById('file-input')
|
||||
.dispatchEvent(new Event('change', { bubbles: true })));
|
||||
|
||||
console.log('waiting for hmw:ready (up to 240s)...');
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
await page.waitForFunction('window.__hmwReady != null', { timeout: 240000, polling: 1000 });
|
||||
console.log('READY in', ((Date.now() - t0) / 1000).toFixed(1) + 's');
|
||||
} catch {
|
||||
console.log('TIMEOUT after', ((Date.now() - t0) / 1000).toFixed(1) + 's — capturing anyway');
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
await page.screenshot({ path: resolve('girder-smoke.png') });
|
||||
console.log('screenshot -> girder-smoke.png (full)');
|
||||
|
||||
// Medium zoom (reproduce user's close inspection) via OrbitControls wheel.
|
||||
await page.mouse.move(700, 450);
|
||||
for (let i = 0; i < 14; i++) { await page.mouse.wheel({ deltaY: -200 }); await new Promise((r) => setTimeout(r, 20)); }
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
await page.screenshot({ path: resolve('girder-zoom.png') });
|
||||
console.log('screenshot -> girder-zoom.png (zoomed in)');
|
||||
|
||||
await browser.close();
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* tools/perf-smoke.mjs — Perceived-load smoke test for hmwebviewer (PLAN P5-3).
|
||||
*
|
||||
* Launches headless Chrome via puppeteer-core (uses the system Chrome; no
|
||||
* Chromium download). For each sample asset it loads `?model=<asset>` and
|
||||
* measures wall time from navigation-start to the viewer's `hmw:ready` event
|
||||
* (dispatched in ThreeDViewer.onLoaded). Asserts < 3000ms perceived.
|
||||
*
|
||||
* USAGE
|
||||
* node tools/perf-smoke.mjs [--url http://127.0.0.1:4173] [--asset /samples/Box.glb ...]
|
||||
*
|
||||
* Defaults: url = http://127.0.0.1:4173 (vite preview); assets = the sample set.
|
||||
* Run `npm run build && npm run preview` first (or `npm run dev` on its port).
|
||||
*
|
||||
* Browser auto-detected: Chrome, then Edge, then PUPPETEER_EXECUTABLE_PATH.
|
||||
*/
|
||||
import puppeteer from 'puppeteer-core';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const CHROME_CANDIDATES = [
|
||||
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
|
||||
];
|
||||
|
||||
function findBrowser() {
|
||||
if (process.env.PUPPETEER_EXECUTABLE_PATH && existsSync(process.env.PUPPETEER_EXECUTABLE_PATH)) {
|
||||
return process.env.PUPPETEER_EXECUTABLE_PATH;
|
||||
}
|
||||
for (const p of CHROME_CANDIDATES) if (existsSync(p)) return p;
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = argv.slice(2);
|
||||
let url = 'http://127.0.0.1:4173';
|
||||
const assets = [];
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--url') url = args[++i];
|
||||
else if (args[i] === '--asset') assets.push(args[++i]);
|
||||
else if (args[i] === '-h' || args[i] === '--help') return { help: true };
|
||||
}
|
||||
if (assets.length === 0) {
|
||||
assets.push('/samples/Box.glb', '/samples/Duck.glb', '/samples/Duck.optimized.glb', '/samples/Avocado.glb');
|
||||
}
|
||||
return { url, assets };
|
||||
}
|
||||
|
||||
const THRESHOLD_MS = 3000;
|
||||
|
||||
async function measureAsset(browser, baseUrl, asset) {
|
||||
const page = await browser.newPage();
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
window.__hmwReady = null;
|
||||
window.addEventListener('hmw:ready', (e) => { window.__hmwReady = e.detail; });
|
||||
});
|
||||
const target = baseUrl + '?model=' + asset;
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForFunction('window.__hmwReady != null', { timeout: 30000 });
|
||||
const data = await page.evaluate(() => ({ readyPerf: window.__hmwReady, origin: performance.timeOrigin }));
|
||||
const ms = (data.origin + data.readyPerf) - t0;
|
||||
return { asset, ms: Math.round(ms), ok: ms < THRESHOLD_MS };
|
||||
} catch (e) {
|
||||
return { asset, ms: null, ok: false, error: (e && e.message) ? e.message : String(e) };
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs(process.argv);
|
||||
if (opts.help) {
|
||||
console.error('Usage: node tools/perf-smoke.mjs [--url URL] [--asset PATH ...]');
|
||||
process.exit(0);
|
||||
}
|
||||
const exe = findBrowser();
|
||||
if (!exe) {
|
||||
console.error('[perf-smoke] No Chrome/Edge found. Set PUPPETEER_EXECUTABLE_PATH.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: exe,
|
||||
headless: 'new',
|
||||
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'],
|
||||
});
|
||||
|
||||
console.log('[perf-smoke] base=' + opts.url + ' browser=' + exe);
|
||||
console.log('[perf-smoke] threshold=' + THRESHOLD_MS + 'ms perceived');
|
||||
const results = [];
|
||||
for (const asset of opts.assets) {
|
||||
process.stdout.write(' ' + asset + ' ... ');
|
||||
const r = await measureAsset(browser, opts.url, asset);
|
||||
results.push(r);
|
||||
console.log(r.ms === null ? 'FAIL (' + (r.error || 'timeout') + ')' : (r.ms + 'ms ' + (r.ok ? 'OK' : 'OVER')));
|
||||
}
|
||||
await browser.close();
|
||||
|
||||
console.log('\n[perf-smoke] summary');
|
||||
for (const r of results) {
|
||||
console.log(' ' + r.asset.padEnd(34) + (r.ms === null ? 'FAIL' : (r.ms + 'ms').padEnd(8)) + (r.ok ? 'PASS' : 'FAIL'));
|
||||
}
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
if (failed.length) {
|
||||
console.error('[perf-smoke] ' + failed.length + ' asset(s) over threshold/failed.');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('[perf-smoke] all within ' + THRESHOLD_MS + 'ms.');
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('[perf-smoke] Fatal: ' + (e && e.stack ? e.stack : e));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env node
|
||||
/** Generate an ASCII PLY cube (vertex colors) and load it via the drag&drop path
|
||||
* in headless Chrome; screenshot to verify PLY support. */
|
||||
import puppeteer from 'puppeteer-core';
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const PLY = `ply
|
||||
format ascii 1.0
|
||||
element vertex 8
|
||||
property float x
|
||||
property float y
|
||||
property float z
|
||||
property uchar red
|
||||
property uchar green
|
||||
property uchar blue
|
||||
element face 12
|
||||
property list uchar int vertex_indices
|
||||
end_header
|
||||
-1 -1 -1 255 0 0
|
||||
1 -1 -1 0 255 0
|
||||
1 1 -1 0 0 255
|
||||
-1 1 -1 255 255 0
|
||||
-1 -1 1 255 0 255
|
||||
1 -1 1 0 255 255
|
||||
1 1 1 255 255 255
|
||||
-1 1 1 90 90 90
|
||||
3 0 1 2
|
||||
3 0 2 3
|
||||
3 4 5 6
|
||||
3 4 6 7
|
||||
3 0 4 7
|
||||
3 0 7 3
|
||||
3 1 5 6
|
||||
3 1 6 2
|
||||
3 3 2 6
|
||||
3 3 6 7
|
||||
3 0 1 5
|
||||
3 0 5 4
|
||||
`;
|
||||
const dir = resolve('samples/plytest');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(resolve(dir, 'cube.ply'), PLY);
|
||||
console.log('asset ->', resolve(dir, 'cube.ply'));
|
||||
|
||||
const url = process.argv.includes('--url') ? process.argv[process.argv.indexOf('--url') + 1] : 'http://localhost:3333';
|
||||
const CHROME = ['C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe'].find((p) => existsSync(p));
|
||||
const browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new',
|
||||
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist', '--window-size=900,700'] });
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 900, height: 700 });
|
||||
page.on('console', (m) => console.log(' [browser]', m.text()));
|
||||
page.on('pageerror', (e) => console.log(' [pageerror]', e.message));
|
||||
await page.evaluateOnNewDocument(() => { window.__r = null; addEventListener('hmw:ready', (e) => { window.__r = e.detail; }); });
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForSelector('#file-input');
|
||||
await (await page.$('#file-input')).uploadFile(resolve(dir, 'cube.ply'));
|
||||
await page.evaluate(() => document.getElementById('file-input').dispatchEvent(new Event('change', { bubbles: true })));
|
||||
try { await page.waitForFunction('window.__r != null', { timeout: 30000, polling: 500 }); console.log('READY'); }
|
||||
catch { console.log('TIMEOUT'); }
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
await page.screenshot({ path: resolve('ply-test.png') });
|
||||
console.log('screenshot -> ply-test.png');
|
||||
await browser.close();
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* tools/preprocess.mjs — Offline GLB optimization for hmwebviewer.
|
||||
*
|
||||
* SCOPE: GLB/glTF ONLY (gltf-transform pipeline). Other formats shipped as
|
||||
* samples (OBJ/DAE/FBX/IFC) are NOT processed here — they are consumed raw by
|
||||
* their respective three.js loaders; no offline Draco/KTX2 step applies.
|
||||
*
|
||||
* Compresses a glTF/GLB with Draco (geometry) + WebP texture compression in one
|
||||
* pass using gltf-transform. Output is a single self-contained .optimized.glb
|
||||
* ready for the viewer's GLTFLoader (+ DRACOLoader).
|
||||
*
|
||||
* KTX2/Basis texture encoding is NOT done here — it needs the `toktx` platform
|
||||
* encoder (KTX-Software 4.3+). gltf-transform exposes it via the CLI `etc1s`
|
||||
* (lossy, smaller) or `uastc` (higher quality) commands, e.g.:
|
||||
* gltf-transform etc1s <out.optimized.glb> <out.ktx2.glb>
|
||||
* Install KTX-Software from https://github.com/KhronosGroup/KTX-Software first.
|
||||
* Runtime: the viewer's KTX2Loader decodes these at load (verified with the
|
||||
* Khronos ABeautifulGame KTX2+Draco sample — 626ms perceived load).
|
||||
*
|
||||
* USAGE
|
||||
* node tools/preprocess.mjs <input.glb> [output.glb] [--draco-bits N] [--texture FMT]
|
||||
*
|
||||
* --draco-bits N Position quantization bits (default 14). 8..16; lower=smaller/lossier.
|
||||
* --texture FMT Texture re-encode: webp (default) | jpeg | png | none.
|
||||
*
|
||||
* OUTPUT
|
||||
* Default output = input with `.optimized.glb` suffix. Prints before/after
|
||||
* byte sizes + reduction %.
|
||||
*
|
||||
* INSTALL (these are devDependencies now; if absent the script prints the
|
||||
* install command and exits non-zero — no silent skip):
|
||||
* npm install -D @gltf-transform/core @gltf-transform/functions \
|
||||
* @gltf-transform/extensions @gltf-transform/cli
|
||||
*/
|
||||
|
||||
async function loadDeps() {
|
||||
let core, fns, ext;
|
||||
try {
|
||||
core = await import('@gltf-transform/core');
|
||||
fns = await import('@gltf-transform/functions');
|
||||
ext = await import('@gltf-transform/extensions');
|
||||
} catch (e) {
|
||||
const msg = (e && e.message) ? e.message : String(e);
|
||||
console.error('[preprocess] Missing required package: ' + msg);
|
||||
console.error('[preprocess] Install the toolchain with:');
|
||||
console.error(
|
||||
' npm install -D @gltf-transform/core @gltf-transform/functions ' +
|
||||
'@gltf-transform/extensions @gltf-transform/cli'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return { core, fns, ext };
|
||||
}
|
||||
|
||||
const TEXTURE_FORMATS = ['webp', 'jpeg', 'png', 'none'];
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = argv.slice(2);
|
||||
if (args.length === 0 || args[0] === '-h' || args[0] === '--help') {
|
||||
return { help: true };
|
||||
}
|
||||
const positional = [];
|
||||
let dracoBits = 14;
|
||||
let texture = 'webp';
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === '--draco-bits') {
|
||||
const v = Number(args[++i]);
|
||||
if (!Number.isInteger(v) || v < 8 || v > 16) {
|
||||
throw new Error('--draco-bits must be an integer 8..16');
|
||||
}
|
||||
dracoBits = v;
|
||||
} else if (a === '--texture') {
|
||||
const v = String(args[++i]).toLowerCase();
|
||||
if (!TEXTURE_FORMATS.includes(v)) {
|
||||
throw new Error('--texture must be one of: ' + TEXTURE_FORMATS.join(', '));
|
||||
}
|
||||
texture = v;
|
||||
} else if (a.startsWith('--')) {
|
||||
throw new Error('Unknown option: ' + a);
|
||||
} else {
|
||||
positional.push(a);
|
||||
}
|
||||
}
|
||||
if (positional.length === 0) {
|
||||
throw new Error('Missing <input.glb>');
|
||||
}
|
||||
const input = positional[0];
|
||||
let output = positional[1];
|
||||
if (!output) {
|
||||
output = input.replace(/\.glb$/i, '') + '.optimized.glb';
|
||||
}
|
||||
return { input, output, dracoBits, texture };
|
||||
}
|
||||
|
||||
function usage() {
|
||||
console.error(
|
||||
'Usage: node tools/preprocess.mjs <input.glb> [output.glb] [--draco-bits N] [--texture webp|jpeg|png|none]'
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs(process.argv);
|
||||
if (opts.help) {
|
||||
usage();
|
||||
return;
|
||||
}
|
||||
|
||||
const { core, fns, ext } = await loadDeps();
|
||||
const { WebIO } = core;
|
||||
const { quantize, dedup, weld, draco, textureCompress, prune } = fns;
|
||||
const { KHRDracoMeshCompression, EXTTextureWebP } = ext;
|
||||
|
||||
const { promises: fs } = await import('node:fs');
|
||||
const path = await import('node:path');
|
||||
|
||||
const inputPath = path.resolve(opts.input);
|
||||
const outputPath = path.resolve(opts.output);
|
||||
|
||||
let inputBytes;
|
||||
try {
|
||||
inputBytes = await fs.readFile(inputPath);
|
||||
} catch (e) {
|
||||
console.error('[preprocess] Cannot read input "' + inputPath + '": ' + e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
const beforeSize = inputBytes.byteLength;
|
||||
|
||||
// Draco encoder must be handed to the extension as a registered dependency.
|
||||
const draco3d = await import('draco3d');
|
||||
const dracoEncoder = await draco3d.createEncoderModule();
|
||||
const io = new WebIO()
|
||||
.registerExtensions([KHRDracoMeshCompression, EXTTextureWebP])
|
||||
.registerDependencies({ 'draco3d.encoder': dracoEncoder });
|
||||
|
||||
let doc;
|
||||
try {
|
||||
doc = await io.readBinary(inputBytes);
|
||||
} catch (e) {
|
||||
console.error('[preprocess] Failed to parse glTF: ' + e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const transforms = [];
|
||||
transforms.push(dedup());
|
||||
transforms.push(weld({ tolerance: 1e-4 }));
|
||||
transforms.push(
|
||||
quantize({
|
||||
quantizePosition: opts.dracoBits,
|
||||
quantizeNormal: 10,
|
||||
quantizeTexcoord: 12,
|
||||
quantizeColor: 8,
|
||||
})
|
||||
);
|
||||
transforms.push(prune());
|
||||
transforms.push(
|
||||
draco({
|
||||
encodeSpeed: 5,
|
||||
decodeSpeed: 5,
|
||||
quantizePosition: opts.dracoBits,
|
||||
quantizeNormal: 10,
|
||||
quantizeTexcoord: 12,
|
||||
quantizeColor: 8,
|
||||
})
|
||||
);
|
||||
if (opts.texture !== 'none') {
|
||||
transforms.push(textureCompress({ targetFormat: opts.texture, quality: 8 }));
|
||||
}
|
||||
|
||||
await doc.transform(...transforms);
|
||||
|
||||
const outGLB = await io.writeBinary(doc);
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await fs.writeFile(outputPath, outGLB);
|
||||
const afterSize = outGLB.byteLength;
|
||||
|
||||
const reduction = beforeSize > 0 ? ((1 - afterSize / beforeSize) * 100) : 0;
|
||||
const fmt = (n) => (n / 1024).toFixed(1) + ' KiB';
|
||||
console.log('[preprocess] ' + path.basename(inputPath) + ' -> ' + path.basename(outputPath));
|
||||
console.log('[preprocess] before: ' + beforeSize + ' bytes (' + fmt(beforeSize) + ')');
|
||||
console.log('[preprocess] after: ' + afterSize + ' bytes (' + fmt(afterSize) + ')');
|
||||
console.log(
|
||||
'[preprocess] reduction: ' + reduction.toFixed(1) + '% ' +
|
||||
'(draco-bits=' + opts.dracoBits + ', texture=' + opts.texture + ')'
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('[preprocess] Fatal: ' + (e && e.stack ? e.stack : e));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* tools/prerender.mjs — 360° turntable pre-render → animated WebP placeholder (PLAN P4-3).
|
||||
*
|
||||
* Loads the running viewer (?model=<asset>) in headless Chrome, orbits the
|
||||
* camera one full turn in N steps, screenshots each frame, then assembles the
|
||||
* PNG sequence into an animated WebP via ffmpeg. The result is an SSR
|
||||
* placeholder image that hydrates into the live canvas on load.
|
||||
*
|
||||
* USAGE
|
||||
* node tools/prerender.mjs --asset /samples/Duck.glb [--frames 36] [--size 512] \
|
||||
* [--framerate 20] [--out public/previews/Duck.webp] \
|
||||
* [--url http://127.0.0.1:4173]
|
||||
*
|
||||
* Requires: puppeteer-core (installed) + ffmpeg on PATH + the viewer built & served
|
||||
* (`npm run build && npm run preview`, or `npm run dev`).
|
||||
*/
|
||||
import puppeteer from 'puppeteer-core';
|
||||
import { existsSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(__dirname, '..');
|
||||
|
||||
const CHROME_CANDIDATES = [
|
||||
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
|
||||
];
|
||||
function findBrowser() {
|
||||
if (process.env.PUPPETEER_EXECUTABLE_PATH && existsSync(process.env.PUPPETEER_EXECUTABLE_PATH)) {
|
||||
return process.env.PUPPETEER_EXECUTABLE_PATH;
|
||||
}
|
||||
for (const p of CHROME_CANDIDATES) if (existsSync(p)) return p;
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const a = argv.slice(2);
|
||||
const o = { asset: null, frames: 36, size: 512, framerate: 20, out: null, url: 'http://127.0.0.1:4173' };
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const k = a[i], v = a[i + 1];
|
||||
if (k === '--asset') { o.asset = v; i++; }
|
||||
else if (k === '--frames') { o.frames = parseInt(v, 10); i++; }
|
||||
else if (k === '--size') { o.size = parseInt(v, 10); i++; }
|
||||
else if (k === '--framerate') { o.framerate = parseInt(v, 10); i++; }
|
||||
else if (k === '--out') { o.out = v; i++; }
|
||||
else if (k === '--url') { o.url = v; i++; }
|
||||
else if (k === '-h' || k === '--help') return { help: true };
|
||||
}
|
||||
if (!o.asset) return { help: true };
|
||||
if (!o.out) {
|
||||
const base = o.asset.split('/').pop().replace(/\.(glb|gltf|obj|fbx|dae|ifc)$/i, '');
|
||||
o.out = 'public/previews/' + base + '.webp';
|
||||
}
|
||||
// Undo MSYS/Git-Bash mangling of leading-slash args (/samples/x.glb -> C:/.../Git/samples/x.glb)
|
||||
o.asset = o.asset.replace(/^.*\/Program Files\/Git\//i, '/');
|
||||
if (!o.asset.startsWith('/')) o.asset = '/' + o.asset;
|
||||
return o;
|
||||
}
|
||||
|
||||
function ffmpegAvailable() {
|
||||
try { execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' }); return true; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs(process.argv);
|
||||
if (opts.help) {
|
||||
console.error('Usage: node tools/prerender.mjs --asset /samples/Duck.glb [--frames 36] [--size 512] [--framerate 20] [--out public/previews/Duck.webp] [--url URL]');
|
||||
process.exit(opts.asset ? 0 : 1);
|
||||
}
|
||||
|
||||
const exe = findBrowser();
|
||||
if (!exe) { console.error('[prerender] No Chrome/Edge found.'); process.exit(1); }
|
||||
const haveFfmpeg = ffmpegAvailable();
|
||||
|
||||
const baseName = opts.asset.split('/').pop().replace(/\.(glb|gltf|obj|fbx|dae|ifc)$/i, '');
|
||||
const frameDir = resolve(root, '.prerender-frames', baseName);
|
||||
rmSync(frameDir, { recursive: true, force: true });
|
||||
mkdirSync(frameDir, { recursive: true });
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: exe,
|
||||
headless: 'new',
|
||||
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'],
|
||||
defaultViewport: { width: opts.size, height: opts.size },
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
page.on('console', (m) => { if (m.type() === 'error') console.error('[page-console]', m.text()); });
|
||||
page.on('pageerror', (e) => console.error('[page-error]', e.message));
|
||||
page.on('requestfailed', (r) => console.error('[req-failed]', r.url(), r.failure()?.errorText));
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
window.__hmwReady = null;
|
||||
window.addEventListener('hmw:ready', (e) => { window.__hmwReady = e.detail; });
|
||||
});
|
||||
await page.goto(opts.url + '?model=' + opts.asset, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForFunction('window.__hmwReady != null', { timeout: 30000 });
|
||||
|
||||
for (let i = 0; i < opts.frames; i++) {
|
||||
const azimuth = (i / opts.frames) * Math.PI * 2;
|
||||
await page.evaluate((az) => window.__viewer && window.__viewer.rotateTo(az), azimuth);
|
||||
const file = resolve(frameDir, 'frame_' + String(i).padStart(3, '0') + '.png');
|
||||
await page.screenshot({ path: file, omitBackground: false });
|
||||
}
|
||||
await browser.close();
|
||||
const { readdirSync } = await import('node:fs');
|
||||
const frameCount = readdirSync(frameDir).filter((f) => f.endsWith('.png')).length;
|
||||
console.log('[prerender] captured ' + frameCount + '/' + opts.frames + ' frames -> ' + frameDir);
|
||||
|
||||
const outAbs = resolve(root, opts.out);
|
||||
mkdirSync(dirname(outAbs), { recursive: true });
|
||||
|
||||
if (!haveFfmpeg) {
|
||||
console.warn('[prerender] ffmpeg not found. Frames left in ' + frameDir);
|
||||
console.warn('[prerender] assemble manually:');
|
||||
console.warn(' ffmpeg -framerate ' + opts.framerate + ' -i ' + frameDir + '/frame_%03d.png -loop 0 ' + outAbs);
|
||||
return;
|
||||
}
|
||||
// Assemble animated WebP. NOTE: libwebp_anim is broken in this ffmpeg build
|
||||
// (outputs 1 frame); -c:v libwebp + -vsync vfr produces correct multi-frame WebP.
|
||||
execFileSync('ffmpeg', [
|
||||
'-y', '-framerate', String(opts.framerate), '-vsync', 'vfr',
|
||||
'-i', resolve(frameDir, 'frame_%03d.png'),
|
||||
'-vf', 'scale=' + opts.size + ':' + opts.size + ':flags=lanczos',
|
||||
'-c:v', 'libwebp', '-loop', '0', '-lossless', '0', '-q:v', '70',
|
||||
outAbs,
|
||||
], { stdio: 'inherit' });
|
||||
console.log('[prerender] animated WebP -> ' + opts.out);
|
||||
rmSync(frameDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('[prerender] Fatal: ' + (e && e.stack ? e.stack : e));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generate a textured cube (cube.obj + cube.mtl + checker.png), then load it
|
||||
* through the real drag&drop path (uploadFile of all 3) in headless Chrome and
|
||||
* screenshot — verifies OBJ texture support (map_Kd → dropped image blob URL).
|
||||
*
|
||||
* USAGE: node tools/tex-test.mjs [--url http://localhost:3333]
|
||||
*/
|
||||
import puppeteer from 'puppeteer-core';
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { deflateSync } from 'node:zlib';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
// ---- minimal PNG encoder (truecolor RGB, filter 0) ----
|
||||
const CRC = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; }
|
||||
return (buf) => { let c = 0xffffffff; for (let i = 0; i < buf.length; i++) c = t[(c ^ buf[i]) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
|
||||
})();
|
||||
function chunk(type, data) {
|
||||
const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0);
|
||||
const td = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
||||
const crc = Buffer.alloc(4); crc.writeUInt32BE(CRC(td), 0);
|
||||
return Buffer.concat([len, td, crc]);
|
||||
}
|
||||
function checkerPng(size = 64, cell = 8) {
|
||||
const raw = Buffer.alloc((size * 3 + 1) * size);
|
||||
for (let y = 0; y < size; y++) {
|
||||
let o = y * (size * 3 + 1); raw[o++] = 0; // filter byte
|
||||
for (let x = 0; x < size; x++) {
|
||||
const on = ((x / cell | 0) + (y / cell | 0)) & 1;
|
||||
raw[o++] = on ? 255 : 0; raw[o++] = on ? 0 : 255; raw[o++] = 255; // magenta / cyan
|
||||
}
|
||||
}
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(size, 0); ihdr.writeUInt32BE(size, 4);
|
||||
ihdr[8] = 8; ihdr[9] = 2; // bitdepth 8, colortype 2 (RGB)
|
||||
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
return Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
|
||||
}
|
||||
|
||||
const OBJ = `mtllib cube.mtl
|
||||
v -1 -1 -1
|
||||
v 1 -1 -1
|
||||
v 1 1 -1
|
||||
v -1 1 -1
|
||||
v -1 -1 1
|
||||
v 1 -1 1
|
||||
v 1 1 1
|
||||
v -1 1 1
|
||||
vt 0 0
|
||||
vt 1 0
|
||||
vt 1 1
|
||||
vt 0 1
|
||||
usemtl checker
|
||||
f 1/1 2/2 3/3
|
||||
f 1/1 3/3 4/4
|
||||
f 5/1 6/2 7/3
|
||||
f 5/1 7/3 8/4
|
||||
f 1/1 5/2 8/3
|
||||
f 1/1 8/3 4/4
|
||||
f 2/1 6/2 7/3
|
||||
f 2/1 7/3 3/4
|
||||
f 4/1 3/2 7/3
|
||||
f 4/1 7/3 8/4
|
||||
f 1/1 2/2 6/3
|
||||
f 1/1 6/3 5/4
|
||||
`;
|
||||
const MTL = `newmtl checker
|
||||
Kd 1 1 1
|
||||
map_Kd checker.png
|
||||
`;
|
||||
|
||||
const dir = resolve('samples/textest');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(resolve(dir, 'cube.obj'), OBJ);
|
||||
writeFileSync(resolve(dir, 'cube.mtl'), MTL);
|
||||
writeFileSync(resolve(dir, 'checker.png'), checkerPng());
|
||||
console.log('assets ->', dir);
|
||||
|
||||
const url = process.argv.includes('--url') ? process.argv[process.argv.indexOf('--url') + 1] : 'http://localhost:3333';
|
||||
const CHROME = [
|
||||
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
|
||||
].find((p) => existsSync(p));
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: CHROME, headless: 'new',
|
||||
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist', '--window-size=900,700'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 900, height: 700 });
|
||||
page.on('console', (m) => console.log(' [browser]', m.text()));
|
||||
page.on('pageerror', (e) => console.log(' [pageerror]', e.message));
|
||||
await page.evaluateOnNewDocument(() => { window.__r = null; addEventListener('hmw:ready', (e) => { window.__r = e.detail; }); });
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForSelector('#file-input');
|
||||
const input = await page.$('#file-input');
|
||||
await input.uploadFile(resolve(dir, 'cube.obj'), resolve(dir, 'cube.mtl'), resolve(dir, 'checker.png'));
|
||||
await page.evaluate(() => document.getElementById('file-input').dispatchEvent(new Event('change', { bubbles: true })));
|
||||
try { await page.waitForFunction('window.__r != null', { timeout: 30000, polling: 500 }); console.log('READY'); }
|
||||
catch { console.log('TIMEOUT'); }
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
await page.screenshot({ path: resolve('tex-test.png') });
|
||||
console.log('screenshot -> tex-test.png');
|
||||
await browser.close();
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"types": ["three"]
|
||||
},
|
||||
"include": ["src", "tools"]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
server: { host: '127.0.0.1', port: 3333, open: false, strictPort: true },
|
||||
build: {
|
||||
target: 'es2022',
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: { three: ['three'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user