19 Commits
Author SHA1 Message Date
minsungandClaude Opus 5 1f80442f6c feat(ui): options panel with an IndexedDB-backed lineweight display scale
Set the default lineweight scale to 11 px/mm: the value the user matched
against AutoCAD on the actual screen. The 20 px/mm derived from their
screenshot was too heavy in practice - the capture and the monitor do
not share a DPI - so the constant is now only a starting point.

Move the lineweight controls off the toolbar into an Options panel:
display toggle, scale slider plus number box, a readout of what the
drawing's own weights come out to, grid toggle, zoom speed, and a reset.
Settings persist in IndexedDB (hmw-viewer/options) rather than
localStorage, so values keep their type and the store has room to grow
per-drawing later; every call degrades quietly if storage is blocked.

Scale changes still apply to the live materials - each fat batch now
remembers its weight in mm - so dragging the slider retunes a 37MB
drawing instantly.

Verified headless: 0.35mm renders 3-4px at scale 11 and 8-9px at 25,
matching the readout, and the value survives a reload via IndexedDB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 09:00:37 +09:00
minsungandClaude Opus 5 a924b1ec34 feat(viewer2d): make the lineweight display scale adjustable from the toolbar
The px-per-mm mapping was a hard-coded guess twice over, and the right
value depends on the monitor - AutoCAD has the same knob as the Adjust
Display Scale slider. Expose it: a slider plus a number box next to the
lineweight toggle, with a readout of what the drawing's own weights come
out to (0.35mm -> 7px, 0.50mm -> 10px at the 20 px/mm default). The
value persists in localStorage.

Retuning applies to the live materials instead of rebuilding the scene.
The hairline cut-off is a millimetre value, so changing the scale moves
every weight by the same factor without changing which bucket a segment
belongs to - each fat batch now remembers its weight in mm, and only
material.linewidth is rewritten. That keeps a drag on the slider instant
even on the 37 MB road drawing.

Measured headless on its 0.35mm gutter line: 2-3px at scale 8, 7px at
20, 14px at 40 - matching the readout in each case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 08:47:59 +09:00
minsungandClaude Opus 5 cf1298749b fix(viewer2d): show lineweight at AutoCAD's display scale (20 px/mm)
Hand test against an AutoCAD capture: the 0.50mm road edge is ~10px
there but only ~4px here. Lineweight is zoom-independent screen pixels,
so the two captures compare directly - the 8 px/mm mapping was 2.5x too
small. AutoCAD's default display scale is one pixel per 0.05mm, so use
20 px/mm (0.35mm = 7px, 0.50mm = 10px, 2.11mm = 42px) and raise the
clamp to 48px.

Add setLineweightScale()/getLineweightScale() so a host can retune it -
AutoCAD exposes the same thing as the Adjust Display Scale slider.

Re-measured headless: the 0.35mm road-gutter line now renders ~7px, so
both weight classes land on the same scale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 20:34:27 +09:00
minsungandClaude Opus 5 ce3859c487 docs: ship the acadrust MPL-2.0 notice, license text and local patch
The deployed site serves a .wasm with acadrust compiled into it, and our
vendored copy carries one modified MPL file
(src/io/dwg/dwg_stream_readers/object_reader/entities.rs, the read_mesh
array-count bounds fix). That is Executable Form distribution of modified
Covered Software, so MPL-2.0 3.2 requires the Source Code Form to be
available and recipients to be told how to get it. Nothing shipped said
so: no license text, no notice, no obtainable source - the upstream
mirror repos are private.

Add THIRD-PARTY-NOTICES.md plus public/licenses/ (MPL-2.0 text, the
read_mesh patch against the pristine 0.4.1 crate, and a served copy of
the notice), and link them from the page so recipients can actually find
them. Original crate download plus the patch reproduces the exact source
compiled into the wasm.

MPL does not require upstreaming, and the lineweight work did not touch
any MPL file - dwg-wasm/src/lib.rs is our MIT wrapper. docs/license-mpl2-
acadrust.md records the analysis and the checklist for future changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:59:10 +09:00
minsungandClaude Opus 5 c67a803e4d feat(viewer2d): render DWG lineweight in screen pixels
Every line was drawn 1px regardless of its CAD lineweight. Two causes:
the dwg-wasm parseResult never carried the field (acadrust reads
EntityCommon::line_weight but it was dropped in serialization), and the
renderer merged all segments into one LineSegments whose
LineBasicMaterial.linewidth WebGL ignores.

Resolve ByLayer/ByBlock/Default per entity and bucket segments by
dash|lineweight. Weighted buckets get their own LineSegments2 +
LineMaterial with worldUnits:false, so width stays constant in pixels
while zooming - AutoCAD LWDISPLAY semantics - at 8 px/mm (0.25mm = 2px).
Anything at or below the 0.25mm default stays in the hairline batch so
drawings that never assigned a weight render exactly as before.

Fat batches are instanced, so layer masking compacts the instance buffer
and lowers instanceCount instead of rewriting an index; slotOf tracks
where each segment moved so the selection highlight still finds its
vertices. Per-entity color spans (meta.spans) let the highlight repaint
across the hairline batch and every bucket it touched. Buckets past
400k segments fall back to hairline to bound GPU/heap cost.

Display follows the drawing's LWDISPLAY and can be forced from the
toolbar; the property panel now shows the entity lineweight.

Verified headless at a fixed camera on the 65k-entity road drawing:
cyan road edge 2px -> 5px, layer hide/restore returns the exact baseline
pixel counts, and a 0.35mm LWPOLYLINE selects and highlights.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:25:11 +09:00
minsungandClaude Opus 5 eec33820a6 feat(dwg): parse MESH (AcDbSubDMesh) and document the fix
The road-surface drawing (대산당진 2공구_노면) rendered nothing for its
노면/부체도로면 geometry. The cause was not the renderer: the parseResult
contained zero MESH entities.

Two faults, both in the wasm parser, fixed upstream in hmwebviewer 35f5b1c:

1. dwg-wasm never serialized MESH. acadrust reads the entity in full; only the
   JSON arm was missing.

2. acadrust's read_mesh clamped its array counts with the blanket 100_000-item
   corrupt-data guard. This drawing's road mesh has 105_497 vertices, so the
   count was truncated, the remaining vertex bits were never consumed, and the
   face/edge/crease lists after them decoded from the wrong bit offset —
   producing a plausible-looking vertex count next to a nonsense 14-face list.
   Counts are now bounded by the bits actually left in the object stream.

       before  verts=100000 faces=14      face sizes [64, 0, 64, 23, 0, 0, 0, 15]
       after   verts=105497 faces=185444  face sizes [3, 3, 3, ...]

This commit carries the vendored side of that work:

- the rebuilt wasm, which now emits MESH as flat arrays (vertices [x,y,z,...],
  faceList in DXF group-93 layout, deduplicated edges). The sample drawing goes
  from 116 MB to 130 MB of parseResult with parse time unchanged at ~3.2 s.
- a MESH case in the property inspector (vertex/face/edge counts, subdivision
  level, bbox and elevation range)
- docs/subdmesh-rendering.md, plus README pointers
- the acadrust licence notice, which can no longer say "consumed unmodified" —
  read_mesh now carries a local patch. MPL-2.0 is file-level copyleft, so the
  patched file stays under MPL and its source ships in hmwebviewer.

Verified: 9 MESH entities parsed from the drawing; entity histograms over six
other sample drawings are byte-identical to the previous wasm, so nothing else
moved.

Two deliberate omissions, both because a lineweight/fat-line refactor is in
flight in this working tree:

- Viewer2D.js is not included. Its _meshSegs helper and three MESH dispatch
  sites share hunks with that refactor and cannot be separated; the same
  renderer code is committed upstream in hmwebviewer 35f5b1c and will land here
  with the refactor. docs/subdmesh-rendering.md records this.
- dist2/ is not rebuilt here. The current build embeds the half-wired
  lineweight button and progress-bar markup; deploy per
  docs/deploy-dist2-static-build.md once that work lands.

The wasm binary also carries the new lineweight parser fields (lwdisplay,
celweight, layer and entity lineWeight), since it was built from a tree that
already had them. They are additive JSON keys with no consumer in this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:05:39 +09:00
minsungandClaude Opus 5 6f200ef692 docs: record the dist2 static-build deploy procedure
This repository has no deploy script or CI: package.json only builds to
dist/ (gitignored), yet dist2/ is committed and is what actually gets
served. The procedure was folklore reconstructed from commit history each
time someone deployed.

Write it down, including the two traps found while deploying fb8f7ca:

- vite's emptyOutDir defaults to true when outDir sits inside the project
  root, so a bare `vite build --outDir dist2` deletes the hand-placed test
  assets in dist2/samples (configBak/, dwg_18.3mb.dwg). Always pass
  --emptyOutDir false.
- npx vite can fail here with `Missing script: "vite"`; call
  ./node_modules/.bin/vite directly.

Also notes the open issues: no build:dist2 script, old hashed bundles
accumulating in dist2/assets, and test fixtures living inside the build
output directory.

Link it from the README issue table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 15:21:10 +09:00
minsungandClaude Opus 5 3480945b5a perf(viewer2d): toggle layers by visibility instead of reloading the drawing
setHiddenLayers() called load(result, {keepView:true}), so every layer
on/off re-parsed and re-tessellated the whole drawing — arc sampling,
hatch triangulation, complex linetypes and the Slug text batch all rebuilt
from scratch. On an 18 MB DWG a single eye-icon click took 5 s+.

Build every layer once, then switch geometry on and off:

- Merged LineSegments buffers get an index buffer plus a per-segment layer
  id (_registerIndexed). A toggle rewrites the index and setDrawRange;
  positions, colors and lineDistances are untouched, so draw order, dash
  phase and the selection-highlight offsets in meta.colStart stay valid.
- Per-entity meshes (hatch fills, SOLID quads, arrowheads, OLE images,
  sprites) are tagged by layer via _addObj and toggled with .visible.
- Slug text builds one merged mesh per CAD layer instead of one per
  draw order, so text hides with a visible flag. The shader source is
  identical across batches, so three still shares one WebGLProgram.
- Hidden geometry stays in the pick buffers, so _onClick now skips it via
  the _metaHidden mask.
- Fit keeps framing only what is drawn: per-layer Box3 + fit samples are
  recorded at build and unioned over the visible layers.
- getLayerInfo() caches the O(entities) name/color/count pass per load;
  only the visible flag is recomputed.

Layer panel: one delegated click listener instead of re-binding a handler
per row, and a toggle patches the affected row's opacity/icon in place
rather than regenerating the whole list's innerHTML.

Trade-off: hidden layers are now built and kept in memory, so loading a
file with layers already off costs what a full load costs.

Rebuilds dist2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 14:08:59 +09:00
minsung fb8f7ca4eb fix(viewer2d): correct Slug GPU text scale to match DWG cap-height
SlugTextBatch.add() scaled em-unit glyph outlines by the raw DWG
`height` value, but glyph ink only reaches capHeightEm (~0.74 em) of
the em box. Rendered cap height ended up ~26% short of spec (e.g. a
3.6-height TABLE cell text rendered at ~2.68), while the canvas-sprite
fallback already corrected for this via measured ascent. Divide by
capHeightEm so both paths agree.

Rebuilds dist2 (was stale since before the Entity selection/Property
Inspector/Layer panel work landed).
2026-08-03 15:26:06 +09:00
minsungandClaude Opus 5 b047dc44e2 fix(viewer2d): sync dwg-wasm fix for large-DWG load and hatch arc artifacts
Mirrors hmwebviewer's rust/dwg-wasm change into the sample: the wasm
artifact plus the parser wrapper, which now goes through parse_dwg_json
and JSON.parse instead of the JsValue-returning parse_dwg.

Building the whole result as a serde_json::Value tree pushed the live
wasm heap near 2.9GB for a 19MB drawing, and the allocator's cost grows
with that heap, so parsing went quadratic — 354s, with the tab frozen
throughout. Streaming one entity at a time keeps it linear: 2.5s.

The same build also fixes three bugs that drew stray 4km grey shapes
where two CF-PATT SOLID hatches should be. Max hatch span 4655 -> 110.

Adds two docs recording both investigations, in the format of the
existing ones: measurements, the hypotheses that were ruled out, the
fixes, regression checks, and the MPL-2.0 position (acadrust is
unmodified, so no new source-disclosure obligation arises).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 13:55:34 +09:00
minsung 7a6edd4347 docs: Entity 선택, Property Inspector, Layer 관리 패널 및 UI 패널 가이드 문서 추가 2026-07-31 14:25:01 +09:00
minsung 199317d575 feat: Entity 선택기능, Property Inspector 패널, Layer 관리 패널, 드래그/접기/크기조절 UI 구현 2026-07-31 13:38:14 +09:00
minsung f7564d4abf feat: 배경 테마(다크/라이트)에 따른 ACI 7번 색상 동적 변경 적용 2026-07-31 10:06:11 +09:00
minsung 14d1f06927 feat: DWG 내장 복합 라인타입 동적 바인딩 및 텍스트 갭 정렬 보정 2026-07-31 09:24:48 +09:00
minsung 3d3e052f3e feat: enhance complex linetype rendering with DWG text style data, gap midpoint centering, and theme defaults 2026-07-30 17:59:08 +09:00
minsung 1a664f677a feat(viewer2d): add complex linetype rendering & LIN pattern parser support 2026-07-30 17:06:38 +09:00
minsung 76f3e20582 fix(parser): dynamically extract OLE2FRAME 4-corner coordinates from binary OLE stream header; remove hardcoded coordinates 2026-07-30 16:10:43 +09:00
minsung 963a90319e fix: replace BMP img.src decoder with pure-JS BMP parser for Brave/Firefox cross-browser support 2026-07-30 15:48:37 +09:00
minsung be8d52fcd5 feat: implement OLE2Frame embedded image parsing and exact coordinate rendering 2026-07-30 14:17:56 +09:00
198 changed files with 105456 additions and 15877 deletions
-5
View File
@@ -1,9 +1,4 @@
node_modules/
dist/
dist-subpath/
*.log
.DS_Store
.serena/
.playwright-mcp/
playwright-report/
test-results/
-96
View File
@@ -1,96 +0,0 @@
Copyright (c) 2010, NHN Corporation (http://www.nhncorp.com),
with Reserved Font Name Nanum, Naver Nanum, NanumGothic, Naver
NanumGothic, NanumMyeongjo, Naver NanumMyeongjo, NanumBrush, Naver
NanumBrush, NanumPen, Naver NanumPen.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+101 -507
View File
@@ -1,541 +1,135 @@
# dwg-dxf-viewer-sample
DWG/DXF 2D viewer module과 다중 포맷 3D viewer sample을 함께 관리하는
Three.js 기반 npm workspace입니다.
**hmwebviewer 형제 폴더**에 분리된 DWG/DXF 2D 뷰어 샘플입니다.
현재 구현은 파일을 브라우저에서 파싱하고 화면에 표시하는 기술 검증용입니다.
좌표, 단위, 원점, 형상 허용오차, topology와 CAD/BIM 의미 정보의 보존을
보장하는 운영용 engineering viewer로는 아직 검증되지 않습니다.
`docs/modules-dwg-dxf.html` 모듈 지도의 카드
(`acadrust-dwg`, `dxf-parser`, `Viewer2D` …)를 **직접 조합**해 동작하는 독립 프로젝트입니다.
`hmwebviewer` 저장소 안에 의존하지 않습니다.
## 검토 기준
```
D:\MYCLAUDE_PROJECT\
hmwebviewer\ ← 본 제품
dwg-dxf-viewer-sample\ ← 이 샘플 (형제)
```
| 항목 | 내용 |
|---|---|
| 기준일 | 2026-07-29 |
| 기준 commit | `cadbd5fb60d300dbb3fdc891fdbbabfbcd5b9109` |
| 기준 tree | 위 commit 이후 이 README 변경 포함 |
| 대상 | `kimminsung/dwg-dxf-viewer-sample` |
| 참조 | 형제 저장소 `hmwebviewer/README.md`의 분석 구조와 3D asset provenance |
| 검토 범위 | workspace 구조, 2D/3D 기능, source·test·build 설정, 입력 경계, npm dependency, WASM·font·decoder·fixture license |
| 기능 판정 | **내부 개발·기능 검증 정확도 및 품질 게이트 무시할 수 있으면 GO** |
| 외부 배포 판정 | **NO-GO — license·source 제공·asset provenance blocker 존재** |
---
외부 배포 판정은 기술적인 compliance 준비 상태를 뜻합니다. 법률 자문이 아니며,
실제 계약·특허·상표와 배포 승인은 조직의 법무·오픈소스 정책으로 확정해야 합니다.
업무용 프로젝트라기보다 취미용 개발 프로젝트에 가까운 성과물입니다.
## 실행
## 현재 판정
```bash
cd D:\MYCLAUDE_PROJECT\dwg-dxf-viewer-sample
npm install
npm run dev
# → http://localhost:5173
```
운영 release에는 format별 정확도 contract, 3D 입력 제한·cancellation·Worker 격리,
프로젝트 license, MPL source 제공, third-party 고지와 sample provenance blocker가
남아 있습니다. 특히 “상업 사용 가능한 dependency”와 “현재 artifact를 조건 없이
상업 배포할 수 있음”은 같은 뜻이 아닙니다.
| UI | 모듈 경로 |
|----|-----------|
| **Sample DWG** | `ext2d`**acadrust-dwg WASM**`Viewer2D` |
| **Sample DXF** | `ext2d`**dxf-parser + hatch + adapter**`Viewer2D` |
| 파일 드롭 / Open | 확장자로 위 둘 중 하나 |
모듈 지도: 브라우저에서 [`docs/modules-dwg-dxf.html`](./docs/modules-dwg-dxf.html)
조합 코드: [`src/main.ts`](./src/main.ts)
## Workspace 구성
---
| Workspace | 역할 | 주요 runtime |
|---|---|---|
| `apps/viewer-2d-sample` | DWG/DXF sample UI와 입력 정책 | `@hmwebviewer/viewer2d`, Three.js |
| `packages/viewer2d` | 재사용 가능한 2D parser·renderer module | `dxf-parser`, `opentype.js`, Three.js peer, acadrust WASM |
| `apps/viewer-3d` | 다중 포맷 3D static web application | Three.js, `web-ifc` |
## 레이아웃
```text
```
dwg-dxf-viewer-sample/
├─ apps/
│ ├─ viewer-2d-sample/ # 2D sample application
│ └─ viewer-3d/ # 3D sample application과 offline 도구
├─ packages/
│ └─ viewer2d/ # 2D public module
├─ tests/ # Vitest와 Playwright
├─ LICENSES/ # 현재 MPL-2.0, OFL-1.1 전문
├─ THIRD_PARTY_NOTICES.md
└─ docs/PROVENANCE.md
docs/modules-dwg-dxf.html # 모듈 조합 레시피 HTML
public/
fonts/NanumGothic-Regular.ttf
samples/BasicSample.dwg
samples/simple.dxf
src/
main.ts # 모듈을 import 해서 조립
viewer2d/ # 재사용 모듈 본체
acadrust-dwg/ # WASM
Viewer2D.js
dwgParser.ts
parseDxf.ts
dxfAdapter.js
dxfHatchHandler.js
ext2d.ts
slugText.ts
package.json
vite.config.ts
```
세 workspace는 `"private": true`이며 npm registry 배포용 package가 아닙니다.
`@hmwebviewer/viewer2d`의 export도 현재 TypeScript source를 직접 가리키므로,
독립 배포 package가 아니라 이 workspace 안에서 재사용하는 module입니다.
---
## 현재 제공 기능
## 조합 파이프라인
### DWG/DXF 2D viewer
- `.dwg`, `.dxf` URL과 로컬 파일 load
- 파일 선택, Drag & Drop, `?model=<url>` 진입 경로
- DWG는 acadrust WebAssembly parser, DXF는 `dxf-parser`와 adapter 사용
- Three.js orthographic renderer와 pan·zoom
- Zoom Fit, dark/light theme, layer별 표시·숨김
- entity click 선택과 type·layer·handle 표시
- TEXT/MTEXT glyph용 NanumGothic TTF load
- module API의 grid, zoom speed, view-change 구독, entity/block 통계,
WebP snapshot, 2점 거리 측정, accent color
- page 종료 시 RAF, listener, WebGL resource와 canvas dispose
renderer에는 LINE, CIRCLE, ARC, ELLIPSE, POINT, POLYLINE/LWPOLYLINE,
SPLINE, TEXT/MTEXT/ATTRIB, INSERT, HATCH, DIMENSION, LEADER, XLINE/RAY 등
여러 entity 경로가 있습니다. 모든 DWG/DXF version과 entity 조합의 정확도를
보장한다는 의미는 아닙니다.
2D 입력 경계는 application layer에서 다음과 같이 제한합니다.
- URL은 HTTP(S)와 viewer 동일 origin만 허용
- `Content-Length`와 streaming 누적 크기를 모두 확인
- 로컬·URL 입력 최대 50 MiB
- URL fetch timeout 15초
### 다중 포맷 3D viewer
- `?model=<url>` server asset load와 로컬 Drag & Drop
- GLB, GLTF, OBJ, FBX, DAE, IFC, PLY load
- OBJ와 함께 드롭한 MTL 및 PNG/JPEG/BMP/GIF/WebP/TGA texture 연결
- 300 MiB 초과 OBJ의 2-pass streaming parser
- 큰 절대 좌표를 가진 OBJ의 float64 source 단계 재중심화
- Draco geometry와 KTX2/Basis texture runtime decode
- IFC single-thread WebAssembly parse와 geometry 변환
- PLY mesh·point cloud 처리 및 Float64 attribute의 Float32 정규화
- OrbitControls, Zoom Fit, 원근·직교 projection, outline 표시
- FPS overlay와 지속적인 저 FPS에서 pixel ratio를 낮추는 adaptive quality
- model URL과 이름이 같은 360° WebP preview를 먼저 표시한 뒤 canvas로 전환
- Vite `BASE_URL`을 따르는 decoder·preview 경로와 subpath 배포
- 교체 model의 geometry·material·texture와 Blob URL 정리
WebP preview는 미리 생성된 정적 파일입니다. Vite가 HTML shell을 제공한 뒤
브라우저에서 preview와 model을 load하므로 server-side 3D rendering 또는 SSR
구현은 없습니다.
## 실행 구조
```mermaid
flowchart TD
A["2D File / same-origin URL"] --> B["cadInputPolicy"]
B --> C["parseCad"]
C --> D["acadrust WASM<br/>DWG"]
C --> E["dxf-parser + adapter<br/>DXF"]
D --> F["CadParseResult"]
E --> F
F --> G["Viewer2D / Three.js"]
H["3D File / ?model URL"] --> I["ThreeDViewer"]
I --> J["modelLoader"]
J --> K["Three.js format loaders"]
J --> L["web-ifc JS + WASM"]
K --> M["THREE.Object3D"]
L --> M
M --> N["WebGLRenderer"]
```
File/URL
→ ext2d (isDwg / isDxf)
→ parseDwgBuffer | parseDxfBuffer
│ │
└ acadrust-dwg ├ decodeDxf
├ dxf-parser + dxfHatchHandler
└ dxfAdapter
→ CadParseResult
→ Viewer2D.load() (+ three, slugText/font)
```
2D application은 `packages/viewer2d` 내부 파일을 직접 import하지 않고
`@hmwebviewer/viewer2d` public interface를 사용합니다. 3D `modelLoader`
확장자로 loader를 선택하고 결과를 `THREE.Object3D`로 정규화합니다.
---
Three.js의 Draco·Basis decoder는 loader import가 생성하는 version 일치 Vite
hashed asset을 사용합니다. `npm ci`의 root `postinstall`은 현재 code path가
사용하는 single-thread `web-ifc.wasm``apps/viewer-3d/public/web-ifc` 아래에
staging합니다. COOP/COEP가 필요한 `web-ifc-mt.wasm`은 배포하지 않습니다.
## 의존성
## 지원 범위와 알려진 제한
| 패키지 | 역할 |
|--------|------|
| `three` | WebGL 렌더 / OrbitControls |
| `dxf-parser` | DXF 텍스트 파싱 |
| `opentype.js` | TTF 글리프 (slugText) |
### 2D
WASM(`acadrust_dwg_bg.wasm`)은 `src/viewer2d/acadrust-dwg/` 에 포함되어 있으며
Vite `?url` import 로 로드됩니다.
| 포맷 | URL | 로컬 파일 | 현재 검증과 제한 |
|---|---:|---:|---|
| DWG | 동일 origin 지원 | 지원 | `BasicSample.dwg` 1개 fixture 중심. acadrust WASM wrapper의 재현 build source가 없음 |
| DXF | 동일 origin 지원 | 지원 | `simple.dxf`와 public parser contract 검증. binary DXF 및 전체 entity fidelity 미검증 |
---
### 3D
## 다른 앱에 이식
| 포맷 | `?model` URL | 로컬 파일 | 알려진 제한 |
|---|---:|---:|---|
| GLB | 지원 | 지원 | Draco/KTX2 지원. 손실 압축 허용오차와 원본 대비 fidelity 기준 없음 |
| GLTF | 지원 | 제한적 | 로컬 `.bin`·texture sidecar를 함께 해석하는 file-set session 없음 |
| OBJ | 지원 | 지원 | URL 경로의 MTL 자동 load 없음. 로컬만 MTL·texture sidecar 지원 |
| OBJ > 300 MiB | 제한적 | 지원 | URL에는 size 기반 streaming 분기가 없음. 로컬 streaming path는 smooth normal과 MTL `Kd` color만 사용하고 texture·hard edge fidelity를 포기 |
| FBX | 지원 | 지원 | 축·단위·animation·외부 texture 보존 계약 없음 |
| DAE | 지원 | 제한적 | 로컬 외부 texture sidecar 미지원 |
| IFC | 지원 | 지원 | geometry와 color만 Three.js object로 변환. property/semantic API와 원점 이동 기록 미보존 |
| PLY | 지원 | 지원 | mesh 또는 point cloud. double attribute는 GPU용 Float32로 변환되어 정밀도 손실 가능 |
1. `src/viewer2d/` 폴더 복사
2. `npm i three dxf-parser opentype.js`
3. 폰트 `public/fonts/NanumGothic-Regular.ttf`
4. Vite: `assetsInclude: ['**/*.wasm']`
5. `src/main.ts` 의 ①~④ 블록을 호스트에 붙이고 ⑤ UI만 교체
여기서 “지원”은 sample을 파싱해 renderable object를 만들 수 있다는 뜻입니다.
CAD/BIM 원본과의 좌표·단위·원점·topology·재질·속성 일치를 의미하지 않습니다.
소스 동기화(선택): 본가 `hmwebviewer/src/viewer2d` 가 업데이트되면 해당 파일을
이 프로젝트 `src/viewer2d` 로 다시 복사하면 됩니다.
## 신뢰 경계와 운영 위험
---
2D 경로는 same-origin, 50 MiB, 15초 제한을 적용하지만 magic bytes, MIME,
압축 해제 후 entity 수와 geometry 복잡도 quota는 없습니다.
## 알려진 이슈 / 원본 반영 메모
3D `?model`은 URL scheme·origin·크기·timeout을 제한하지 않습니다. browser CORS가
허용하면 외부 origin도 요청할 수 있고, GLTF/DAE/FBX/OBJ가 참조하는 URI가 추가
network request를 만들 수 있습니다. 로컬 3D 파일에도 최대 크기와 decode 후
vertex·texture·memory quota가 없습니다.
| 이슈 | 문서 |
|------|------|
| 구형 한글 DWG TEXT 깨짐 (CP949→Latin-1 mojibake) | [`docs/korean-dwg-text-encoding.md`](./docs/korean-dwg-text-encoding.md) |
| Model + Paper 동시 렌더로 글자 겹침 / Layout 탭 | [`docs/model-paper-space.md`](./docs/model-paper-space.md) |
| `dist2` 정적 빌드 배포 절차 (공식 배포 스크립트 없음) | [`docs/deploy-dist2-static-build.md`](./docs/deploy-dist2-static-build.md) |
| SubDMesh(MESH) 미표시 — parseResult 누락 + 큰 메쉬 잘림 | [`docs/subdmesh-rendering.md`](./docs/subdmesh-rendering.md) |
| 선가중치(Lineweight) 미적용 — parseResult 누락 + WebGL 1px 한계 | [`docs/lineweight-rendering.md`](./docs/lineweight-rendering.md) |
| acadrust MPL-2.0 고지 누락 (수정본 wasm 공개 배포) | [`docs/license-mpl2-acadrust.md`](./docs/license-mpl2-acadrust.md) |
| ↳ 위 둘의 측정 결과 + 상호작용(메쉬 선분이 굵기 버킷 상한을 넘길 위험) | [`docs/improvement-results-2026-08-04.md`](./docs/improvement-results-2026-08-04.md) |
두 viewer 모두 parser와 상당한 geometry 처리를 browser main thread에서 수행합니다.
운영 배포에는 format별 Worker 격리, URL allowlist, CSP, MIME·signature 검사,
streaming byte limit, decode complexity quota, timeout과 cancellation이 필요합니다.
샘플 패치: `fixDwgKoreanText`, `cadSpaces` + Viewer2D 공간 필터, Model/Layout 탭.
원본 `hmwebviewer` 반영 절차는 각 문서를 따른다.
동시에 여러 load를 시작했을 때 이전 요청을 취소하거나 latest-wins를 보장하는
session 규약도 없습니다. 느린 이전 요청이 나중 요청 뒤에 완료되어 화면을
덮을 수 있습니다.
---
현재 E2E는 sample render, finite vertex, canvas lifecycle과 subpath decoder URL을
검사합니다. 공식 validator, golden geometry, 좌표·단위 왕복, topology,
material·IFC property contract와 장시간 memory soak는 release gate에 없습니다.
## 라이선스 메모
## 외부 의존성·라이선스 감사
| 조각 | 라이선스 |
|------|----------|
| Viewer2D 포트 · 샘플 글루 | MIT (hmwebviewer 계열) |
| acadrust (WASM 내부, `read_mesh` 로컬 패치 포함) | **MPL-2.0 — 수정본** |
| dxf-parser · three · opentype.js | MIT |
| NanumGothic | OFL |
> **배포 준비 판정: 외부 demo·고객 전달·public CDN·commercial SaaS `NO-GO`,
> 조직 내부 개발·검증 조건부 `GO`**
>
> dependency 자체는 대부분 상업 사용 가능한 license이지만, 프로젝트 소유 코드의
> license, MPL source 제공, Apache/MIT 고지와 sample provenance가 완결되지 않았습니다.
감사 범위는 `package-lock.json``node_modules/*` entry, 실제 browser bundle,
`public` 정적 파일, bundled WASM·font, sample과 파생 preview, repository가
호출하거나 언급하는 system tool입니다.
lockfile에는 142개 `node_modules/*` entry가 있습니다. 이 중 3개는 이 저장소의
workspace link이고 제3자 package는 139개입니다. 제3자 entry의 license metadata
누락은 0개입니다. runtime entry는 5개, build/dev entry는 134개이며 그중 57개는
platform별 optional package입니다.
### Browser runtime과 직접 배포되는 구성요소
| 구성요소 | 버전·출처 | 역할·배포 위치 | License | 현재 compliance 상태 |
|---|---|---|---|---|
| Three.js | `0.185.1` | 두 viewer의 JS bundle, loader·controls | MIT | package license는 확인. 배포 artifact에 통합 MIT notice 없음 |
| dxf-parser | `1.1.2` | DXF parser JS bundle | MIT | package license는 확인. 통합 notice 없음 |
| loglevel | `1.9.2`, `dxf-parser` transitive | DXF runtime logging | MIT | lockfile·package license 확인 |
| opentype.js | `2.0.0` | TTF glyph 처리 JS bundle | MIT | package license는 확인. 통합 notice 없음 |
| acadrust | WASM 내부 `0.4.1` | `acadrust_dwg_bg.wasm`, DWG parse | MPL-2.0 | MPL 전문과 checksum은 있음. wrapper source·toolchain과 재현 build가 없음 |
| NanumGothic | Google Fonts 원본 checksum 일치 | `NanumGothic-Regular.ttf` | OFL-1.1 | 전문과 checksum은 있음. root license 파일이 app dist에는 자동 포함되지 않음 |
| web-ifc | `0.0.77` | dynamic JS bundle, `/web-ifc/*.wasm` | MPL-2.0 | package 전문은 확인. exact covered source 취득 안내가 배포물에 없음 |
| Draco decoder | Three.js r185 배포본 | Vite `/assets/draco_*` hashed asset | Apache-2.0 | 설치된 Three.js source에서 build. Apache 전문·NOTICE가 app dist에 없음 |
| Basis Universal transcoder | Three.js r185 배포본 | Vite `/assets/basis_*` hashed asset | Apache-2.0 | 설치된 Three.js source에서 build. Apache 전문·NOTICE가 app dist에 없음 |
저장소의 [`THIRD_PARTY_NOTICES.md`](./THIRD_PARTY_NOTICES.md)는 acadrust,
NanumGothic과 주요 runtime library를 기록하고, [`LICENSES/`](./LICENSES/)에는
MPL-2.0과 OFL-1.1 전문이 있습니다. 그러나 이 파일들은 두 Vite application의
`public` 아래에 있지 않아 현재 `npm run build` 결과에는 포함되지 않습니다.
### 전체 npm license 집계
| License | 제3자 package entry 수 | 주 사용 범위 | 상업 사용 | source 공개 영향 |
|---|---:|---|---:|---|
| MIT | 86 | runtime 4개와 build/dev | 가능 | 프로젝트 source 공개 의무 없음. copyright·license notice 보존 |
| Apache-2.0 | 31 | TypeScript, Playwright, Puppeteer와 platform package | 가능 | 프로젝트 source 공개 의무 없음. license·변경·NOTICE·patent 조건 적용 |
| MPL-2.0 | 13 | `web-ifc` runtime 1개, Lightning CSS 계열 dev 12개 | 가능 | 외부 executable 배포 시 covered source와 취득 안내 필요 |
| ISC | 6 | build/dev | 가능 | copyright·permission notice 보존 |
| BSD-3-Clause | 2 | build/dev | 가능 | notice·면책 보존, 이름을 홍보에 사용하지 않음 |
| 0BSD | 1 | optional build/dev | 가능 | source 공개와 attribution 의무 없음 |
위 집계에는 npm 밖의 acadrust WASM, NanumGothic, Draco, Basis와 sample asset
license가 포함되지 않습니다. `UNKNOWN` 3개처럼 보이는 lockfile entry는
`@hmwebviewer/*` first-party workspace link이며 제3자 license 누락이 아닙니다.
#### MPL-2.0 npm package 전체 목록
`package-lock.json`에서 `license: "MPL-2.0"`으로 선언된 13개 entry를 전부
나열하면 다음과 같습니다. 이는 **13개의 독립 codebase**를 의미하지 않습니다.
`web-ifc` 1개와, 동일한 Lightning CSS source에서 배포되는 core package 1개 및
platform별 optional native package 11개로 구성됩니다.
| npm package | Version | 설치·실행 범위 | 공식 source repository |
|---|---:|---|---|
| `web-ifc` | `0.0.77` | production runtime. IFC parser JS가 bundle되고 single-thread WASM이 application public asset으로 staging됨 | [ThatOpen/engine_web-ifc](https://github.com/ThatOpen/engine_web-ifc) |
| `lightningcss` | `1.33.0` | Vite build dependency. CSS parse·transform·minify core와 현재 platform용 native binding 선택 | [parcel-bundler/lightningcss v1.33.0](https://github.com/parcel-bundler/lightningcss/tree/v1.33.0) |
| `lightningcss-android-arm64` | `1.33.0` | optional dev package, Android ARM64 native binding | [parcel-bundler/lightningcss v1.33.0](https://github.com/parcel-bundler/lightningcss/tree/v1.33.0) |
| `lightningcss-darwin-arm64` | `1.33.0` | optional dev package, macOS ARM64 native binding | [parcel-bundler/lightningcss v1.33.0](https://github.com/parcel-bundler/lightningcss/tree/v1.33.0) |
| `lightningcss-darwin-x64` | `1.33.0` | optional dev package, macOS x86_64 native binding | [parcel-bundler/lightningcss v1.33.0](https://github.com/parcel-bundler/lightningcss/tree/v1.33.0) |
| `lightningcss-freebsd-x64` | `1.33.0` | optional dev package, FreeBSD x86_64 native binding | [parcel-bundler/lightningcss v1.33.0](https://github.com/parcel-bundler/lightningcss/tree/v1.33.0) |
| `lightningcss-linux-arm-gnueabihf` | `1.33.0` | optional dev package, Linux ARM hard-float GNU native binding | [parcel-bundler/lightningcss v1.33.0](https://github.com/parcel-bundler/lightningcss/tree/v1.33.0) |
| `lightningcss-linux-arm64-gnu` | `1.33.0` | optional dev package, Linux ARM64 glibc native binding | [parcel-bundler/lightningcss v1.33.0](https://github.com/parcel-bundler/lightningcss/tree/v1.33.0) |
| `lightningcss-linux-arm64-musl` | `1.33.0` | optional dev package, Linux ARM64 musl native binding | [parcel-bundler/lightningcss v1.33.0](https://github.com/parcel-bundler/lightningcss/tree/v1.33.0) |
| `lightningcss-linux-x64-gnu` | `1.33.0` | optional dev package, Linux x86_64 glibc native binding. 현재 표준 container 대상 | [parcel-bundler/lightningcss v1.33.0](https://github.com/parcel-bundler/lightningcss/tree/v1.33.0) |
| `lightningcss-linux-x64-musl` | `1.33.0` | optional dev package, Linux x86_64 musl native binding | [parcel-bundler/lightningcss v1.33.0](https://github.com/parcel-bundler/lightningcss/tree/v1.33.0) |
| `lightningcss-win32-arm64-msvc` | `1.33.0` | optional dev package, Windows ARM64 MSVC native binding | [parcel-bundler/lightningcss v1.33.0](https://github.com/parcel-bundler/lightningcss/tree/v1.33.0) |
| `lightningcss-win32-x64-msvc` | `1.33.0` | optional dev package, Windows x86_64 MSVC native binding | [parcel-bundler/lightningcss v1.33.0](https://github.com/parcel-bundler/lightningcss/tree/v1.33.0) |
Lightning CSS의 12개 entry는 모두 동일한 MPL-2.0 source tree와 release version을
가리키지만 lockfile 및 SBOM에서는 서로 다른 배포 package로 유지합니다. 실제 install
시 npm은 host OS·CPU·libc와 맞는 optional binding만 선택합니다. 이 project의
production browser artifact에는 Lightning CSS code가 포함되지 않지만, build
container 또는 `node_modules`를 제3자에게 전달한다면 선택된 binding과 covered
source 제공 방법을 함께 관리해야 합니다.
`web-ifc@0.0.77` package가 선언한 공식 repository는 위 root URL이며 package
metadata에는 exact source commit(`gitHead`)이 없습니다. 따라서 외부 배포 전에
registry tarball과 대응하는 source commit/archive를 별도로 고정하고, 수정 여부와
covered source 취득 방법을 배포 고지에 기록해야 합니다.
### Build·test·offline 도구
| 구성요소 | lockfile 기준 | 역할 | License·배포 판단 |
|---|---:|---|---|
| Vite | `8.1.5` | dev server와 production build | MIT. build output에는 runtime third-party notice를 별도로 넣어야 함 |
| TypeScript | `7.0.2` | typecheck | Apache-2.0. compiler를 고객에게 함께 전달하지 않으면 제품 source 공개 영향 없음 |
| Vitest | `4.1.10` | unit test | MIT, 제품 runtime 아님 |
| Playwright | `1.62.0` | browser E2E | Apache-2.0, 제품 runtime 아님 |
| Puppeteer Core | `25.4.0` resolved | 3D smoke·preview capture | Apache-2.0. Chrome binary를 포함하지 않음 |
| Lightning CSS | `1.33.0` 계열 12 entry | Vite build transitive | MPL-2.0. build host에서만 사용하며 browser runtime에는 포함되지 않음 |
| Node.js·npm | Node >=24, npm >=11 요구 | install, build, tool runtime | 함께 재배포하면 각 runtime과 bundled dependency license를 별도 감사 |
| Chrome | system executable | Playwright/Puppeteer 실행 | repository가 bundle하지 않음. CI image·설치형 배포에 포함하면 vendor 조건 별도 검토 |
| ffmpeg | optional, lockfile 밖 | PNG frame을 animated WebP로 결합 | binary를 bundle할 때 실제 build option의 LGPL/GPL/nonfree 조건 확인 |
| KTX-Software `toktx` | 도입 안내만 존재 | 선택적 KTX2 encoding | 현재 dependency가 아님. 도입 시 exact binary BOM과 license 재검토 |
`apps/viewer-3d/tools/preprocess.mjs``@gltf-transform/core`,
`@gltf-transform/functions`, `@gltf-transform/extensions`, `draco3d`를 import하지만
현재 `package.json`과 lockfile에는 이 package들이 없습니다. 따라서 clean install의
정식 toolchain으로 재현되지 않습니다.
현재 script가 구현한 texture output은 WebP/JPEG/PNG이며 KTX2 encoding은 하지
않습니다. KTX2는 별도 glTF Transform CLI와 `toktx`가 필요합니다.
하위 `tools/README.md`의 “KTX2 default” 설명은 현재 code와 일치하지 않습니다.
### MPL·Apache·OFL 적용 범위
MPL-2.0은 file-level copyleft입니다. 별도 파일로 결합한 이 application 전체를
MPL로 공개할 필요는 없지만, 조직 밖으로 browser JS/WASM executable을 전달하면
수령자가 해당 MPL covered source와 수정분을 합리적인 방법으로 얻을 수 있게
알려야 합니다. browser로 전송되는 client code도 배포에 해당합니다.
- `web-ifc@0.0.77`: exact source archive 또는 고정 commit/tag URL과 수정 여부,
build 가능한 covered source 취득 안내가 필요
- acadrust DWG WASM: crate source만으로는 현재 binary wrapper를 재현할 수 없으므로
wrapper source·Cargo/wasm-bindgen 설정·toolchain을 복구하기 전 외부 배포 보류
- Lightning CSS: `node_modules`나 build container를 외부에 전달하지 않고 build
host에서만 실행하면 application source 공개 범위를 만들지 않음
Apache-2.0의 Draco와 Basis는 source 공개 의무는 없지만 license 사본, 기존
attribution과 upstream NOTICE, 수정 시 변경 고지를 보존해야 합니다. OFL font는
software와 bundle할 수 있지만 copyright와 OFL 전문을 함께 제공해야 하며,
Reserved Font Name 조건을 지켜야 합니다.
참조:
- [Mozilla MPL 2.0 FAQ](https://www.mozilla.org/en-US/MPL/2.0/FAQ/)
- [acadrust upstream](https://github.com/hakanaktt/acadrust)
- [web-ifc upstream](https://github.com/ThatOpen/engine_web-ifc)
- [Three.js r185 MIT license](https://github.com/mrdoob/three.js/blob/r185/LICENSE)
- [Google Draco Apache-2.0 license](https://github.com/google/draco/blob/main/LICENSE)
- [Basis Universal](https://github.com/BinomialLLC/basis_universal)
- [NanumGothic OFL](https://github.com/google/fonts/blob/main/ofl/nanumgothic/OFL.txt)
### Sample asset과 파생 preview
Sample과 preview는 software dependency license와 별개의 저작물입니다.
optimized model과 회전 preview도 원본 asset의 license·attribution·변경 표시를
계승해야 합니다.
| 자산 | 확인된 출처·license | 현재 판단 |
|---|---|---|
| `BasicSample.dwg` | 2D standalone initial commit에서 이관, 원출처 불명 | 내부 회귀 test 전용. 외부 demo 배포 보류 |
| `simple.dxf` | 2D standalone initial commit의 최소 fixture | 권리자·license 명시가 없어 외부 재배포 승인 필요 |
| `Box.glb` | Khronos sample, Cesium 제공, CC BY 4.0 | 상업 사용 가능하나 Cesium attribution, license link와 변경 여부 고지가 현재 배포물에 없음 |
| `Duck.glb` | Copyright 2006 Sony Computer Entertainment Inc., SCEA Shared Source License 1.0 | 비표준 license. 내부 법무 승인 전 외부 배포 보류 |
| `Duck.optimized.glb`, `Duck.webp` | Duck의 Draco/WebP·preview 파생물 | 원본 SCEA 승인과 derivative 변경 표시에 종속 |
| `Cube.obj`, `Cube.dae`, `Cube.ifc`, `DoublePrecision.ply` | 파일·commit에 hand-authored/project fixture 정황 | 프로젝트 권리자와 first-party license가 선언되지 않아 외부 배포 보류 |
| `Cube.fbx`, `sample.mtl` | history에는 Three.js `morph_test`와 Blender 정황만 있고 exact 원본 URL·asset license 없음 | provenance 복구 또는 제거 전 외부 배포 보류 |
| `Cube.*.webp` | 각 Cube model에서 생성한 24-frame preview | 원본 model의 권리·license와 변경 표시에 종속 |
Box와 Duck의 upstream license:
- [Khronos Box — CC BY 4.0](https://github.com/KhronosGroup/glTF-Sample-Models/blob/main/2.0/Box/README.md)
- [Khronos Duck — SCEA Shared Source License 1.0](https://github.com/KhronosGroup/glTF-Sample-Models/blob/main/2.0/Duck/README.md)
fixture checksum과 source history는 [`docs/PROVENANCE.md`](./docs/PROVENANCE.md),
acadrust·font checksum은 [`THIRD_PARTY_NOTICES.md`](./THIRD_PARTY_NOTICES.md)를
기준으로 관리합니다. 현재 provenance 문서는 2D fixture 중심이며 3D asset과
preview derivative chain은 아직 통합되지 않았습니다.
### 프로젝트 license와 외부 release gate
루트에는 프로젝트 소유 코드에 적용되는 `LICENSE`와 copyright holder 선언이
없습니다. `package.json``"private": true`는 npm publish 방지 설정일 뿐
외부 이용·수정·재배포 권한을 부여하지 않습니다. 하위 과거 README의
“Viewer2D 포트·샘플 글루 MIT” 문구도 현재 repository의 license grant로 볼 수
없습니다.
다음 조건을 완료하기 전에는 외부 demo, 고객 전달, 설치형 package, public CDN과
commercial SaaS 배포를 승인하지 않습니다.
- 프로젝트 소유 코드의 권리자와 proprietary/open-source license 결정
- 2D·3D build artifact에 runtime `THIRD_PARTY_NOTICES`와 필요한 license 전문 포함
- acadrust wrapper source·toolchain 복구와 exact WASM 재현 build
- acadrust 및 `web-ifc@0.0.77` covered source의 고정 archive/URL과 취득 안내 제공
- Draco·Basis Apache license와 upstream NOTICE 보존
- Box attribution과 sample별 원본 URL·권리자·license·checksum·derivative chain 기록
- Duck의 내부 법무 승인 또는 승인된 CC0/CC BY asset으로 교체
- `BasicSample.dwg`, `Cube.fbx`, `sample.mtl` 등 출처 미완료 fixture의 승인 또는 제거
- runtime/build/sample을 구분한 SBOM과 CI의 unknown-license/provenance gate
## 개발 스택 및 구성 정책
이 저장소의 표준 개발 대상은 **Linux x86_64 container**입니다. Windows host에서는
Docker Desktop 또는 WSL2를 container runtime 진입점으로만 사용하며, Windows drive
path와 host의 `node_modules`를 표준 build 입력으로 사용하지 않습니다.
| 구분 | 표준 stack | 구성 정책 |
|---|---|---|
| Host·container | Linux x86_64, glibc 기반 image | Chrome과 native npm package 호환성이 확인될 때까지 Debian/Ubuntu 계열을 기준으로 하며 Alpine/musl은 별도 검증 없이 사용하지 않음 |
| JavaScript runtime | Node.js 24 이상, npm 11 이상 | `package.json#engines`가 지원 하한이며 container와 CI에서는 24.x·11.x의 exact version 및 base image digest를 고정 |
| Package 관리 | npm workspaces, `package-lock.json` | clean environment는 `npm ci`만 사용하고 lockfile을 canonical dependency graph로 취급하며 host `node_modules`를 container에 mount하지 않음 |
| Application | Vite 8, TypeScript 7, Three.js r185 | `apps/*`는 실행 application, `packages/*`는 workspace 내부 module로 유지하며 browser runtime asset은 Vite `BASE_URL`을 따름 |
| Unit·contract test | Vitest 4 | root `tests/**/*.test.ts`를 기준으로 하고 변경 시 관련 test부터 실행한 뒤 전체 suite를 실행 |
| Browser E2E | Playwright 1.62, system Chrome/Chromium | browser binary와 Linux system library를 E2E image에 고정하고 `PLAYWRIGHT_CHROMIUM_EXECUTABLE`로 경로를 주입 |
| Runtime decoder | acadrust DWG WASM, Draco, Basis, web-ifc | Draco·Basis는 Three.js import의 hashed asset을 사용하고 `postinstall`은 single-thread `web-ifc.wasm`만 staging함 |
| Optional asset tool | Puppeteer Core, ffmpeg, Blender, `toktx` | application build의 필수 dependency가 아니며 별도 asset-tool image/profile과 별도 license BOM으로 격리 |
공통 구성 원칙은 다음과 같습니다.
- tracked text file과 shell script는 LF를 사용하고 canonical command는 POSIX shell
문법과 repository-relative path로 작성합니다.
- development, production build, E2E, optional asset processing은 서로 다른
container target 또는 profile로 분리합니다. production artifact에 compiler,
browser, test 전용 fixture와 asset tool을 포함하지 않습니다. `public/samples`
demo asset은 의도된 runtime content이므로 provenance·license gate를 별도로 적용합니다.
- container process는 non-root user로 실행하고 source는 read-only mount를
우선합니다. `npm ci`의 postinstall decoder staging은 writable image build layer에서
끝내고, runtime container에서는 완성된 artifact만 read-only로 제공합니다.
dependency cache와 build output만 별도 writable volume을 사용합니다.
- host에서 직접 실행할 때 Vite는 loopback에만 bind합니다. container에서 port를
publish할 때만 container 전용 설정으로 `0.0.0.0`에 bind하고 공개 network에
노출하지 않습니다.
- Node.js, npm, Chrome/Chromium과 base image는 floating tag가 아니라 exact
version으로 고정합니다. Renovate 같은 자동 갱신을 사용하더라도 `npm run verify`
통과 후에만 lock과 image pin을 갱신합니다.
- source build/test image와 외부 배포 artifact의 license·provenance gate는
동일하지 않습니다. 아래 `외부 의존성·라이선스 감사``NO-GO` 항목을 container
전환만으로 해소된 것으로 간주하지 않습니다.
## Linux 컨테이너 전환 검증
2026-07-29 기준 installed dependency 상태에서 typecheck, unit test 8개, production
build와 Playwright E2E 17개가 Linux/WSL2 x86_64에서 다시 통과했습니다. tracked
text file은 LF이며 lockfile에는 Linux glibc와 musl용 native optional package가
포함되어 있습니다. 따라서 application code의 Windows 종속성은 현재 검증 범위에서
발견되지 않았지만, clean `npm ci`부터 시작하는 재현 가능한 container 개발 환경의
정의와 검증은 아직 완료되지 않았습니다.
| 항목 | 현재 상태 | container 전환 판단 |
|---|---|---|
| Container 정의 | Dockerfile, `.dockerignore`, Compose/devcontainer 없음 | **Blocker** — 동일 image를 개발·CI에서 재현할 수 없음 |
| Dev server network | 2D·3D Vite가 `127.0.0.1`에 고정 | **Blocker** — container port publish 후 host에서 접근할 별도 bind 설정 필요 |
| E2E browser | `/usr/bin/google-chrome`을 기본 경로로 가정 | **Blocker** — Chrome/Chromium과 shared library를 image에 설치·고정해야 함 |
| Native ABI | Linux glibc/musl package가 lockfile에 모두 존재 | **Decision** — 우선 glibc를 baseline으로 고정하고 musl은 별도 test matrix로 검증 |
| Clean container gate | 현재 container CI 없음 | **Blocker** — clean `npm ci && npm run verify`를 release gate로 추가해야 함 |
| File·path 규칙 | tracked text는 LF, runtime code는 Node path API 사용 | 통과. 단, 하위 2D README의 Windows drive path는 정리 필요 |
| Offline asset pipeline | ffmpeg·Blender·`toktx`가 core lockfile 밖에 있음 | core 개발은 통과. asset-tool image와 version/license pin은 후속 작업 |
권장 구현 순서는 다음과 같습니다.
1. Node.js 24 기반 glibc image의 digest와 non-root user를 고정한 multi-stage
Dockerfile 및 `.dockerignore`를 추가합니다.
2. host loopback 기본값은 유지하고 container script에서만 2D `5173`, 3D `3333`
port를 `0.0.0.0`에 bind합니다.
3. Chrome/Chromium과 필요한 Linux library를 포함한 E2E target을 만들고
`PLAYWRIGHT_CHROMIUM_EXECUTABLE`을 명시합니다.
4. container 안에서 `npm ci`, `npm run verify`를 수행하는 CI job을 추가하고
host `node_modules`가 섞이지 않는지 확인합니다.
5. ffmpeg·Blender·`toktx`가 필요한 3D asset pipeline은 별도 image/profile로
분리하고 core application image에는 포함하지 않습니다.
6. `apps/viewer-2d-sample/README.md`의 standalone Windows layout과 실행 명령을
현재 npm workspace 및 Linux container 기준으로 갱신합니다.
## 설치와 실행
요구 환경은 Node.js 24 이상, npm 11 이상과 WebGL을 지원하는 browser입니다.
```bash
npm ci
```
`npm ci`는 workspace dependency를 설치한 뒤 single-thread IFC WASM을 staging합니다.
Draco·Basis decoder는 production build가 설치된 Three.js에서 hashed asset으로
생성합니다.
```bash
npm run dev:2d
# http://127.0.0.1:5173
npm run dev:3d
# http://127.0.0.1:3333
```
URL load 예시:
```text
http://127.0.0.1:5173/?model=/samples/simple.dxf
http://127.0.0.1:3333/?model=/samples/Box.glb
```
Production build와 preview:
```bash
npm run build
npm --workspace @hmwebviewer/viewer-2d-sample run preview
npm --workspace @hmwebviewer/viewer-3d run preview
```
## 검증
```bash
npm run typecheck
npm run test:unit
npm run build
npm run test:artifact
npm run test:e2e
```
전체 순차 검증:
```bash
npm run verify
```
2026-07-29 실제 검증 결과:
- workspace typecheck 통과
- Vitest 5개 file, 8개 test 통과
- 2D·3D production build 통과
- production artifact gate 통과:
- 2D: 8개 file, 4,302,677 bytes, 50 kB 이상 중복 0개
- 3D: 27개 file, 8,149,402 bytes, 50 kB 이상 중복 0개
- 3D `dist`는 경량화 전 24,945,531 bytes에서 16,796,129 bytes(67.3%) 감소
- Playwright 17개 E2E 통과, 24.5초
- build 실패는 없지만 2D main bundle, Three.js, web-ifc와 decoder JS에
500 kB 초과 chunk 경고가 있음
현재 test가 확인하는 범위:
- 2D public package interface의 DXF parse
- 2D same-origin·50 MiB 입력 정책
- canonical module map 문서 drift
- single-thread IFC WASM의 installed dependency 대비 checksum과 실행 권한
- production source map, MT IFC WASM, public decoder 중복과 9 MB artifact budget
- Vite subpath의 public asset URL
- 2D DWG/DXF sample과 local file load
- 3D GLB, Draco GLB, OBJ, FBX, DAE, IFC, PLY sample render
- 2D/3D canvas dispose와 반복 page transition
- production subpath의 Draco·Basis KTX2·IFC decoder request와 실제 decode
- 2D DWG/DXF와 3D GLB/PLY evidence screenshot
이 검증은 sample이 화면에 나타나고 runtime error가 없다는 smoke/contract 수준입니다.
engineering 정확도와 외부 배포 compliance를 승인하는 test는 아닙니다.
## 문서
- [Third-party notices](./THIRD_PARTY_NOTICES.md)
- [Source와 fixture provenance](./docs/PROVENANCE.md)
- [DWG/DXF module map](./docs/modules-dwg-dxf.html)
- [2D sample 안내](./apps/viewer-2d-sample/README.md)
- [3D architecture](./apps/viewer-3d/docs/architecture.html)
- [3D 구현 계획](./apps/viewer-3d/PLAN.md)
- [3D 검증 기록](./apps/viewer-3d/PROGRESS.md)
- [3D asset 도구](./apps/viewer-3d/tools/README.md)
- [초기 3D 설계서](./apps/viewer-3d/3d_viewer_architecture_spec.pdf)
하위 2D README에는 통합 전 standalone layout이, 3D tools README에는 과거 KTX2
계획이 남아 있습니다. 현재 workspace의 canonical 개요와 배포 판단은 이 문서를
기준으로 하며, 하위 문서는 후속 정합성 정리가 필요합니다.
acadrust 는 파일 단위 카피레프트라 **패치한 파일의 소스를 배포 대상자에게 제공할 의무**가
있다. 고지·MPL 원문·패치는 [`THIRD-PARTY-NOTICES.md`](./THIRD-PARTY-NOTICES.md) 와
`public/licenses/`(배포 시 `/licenses/`)에 동봉한다.
의무 범위와 조치 근거: [`docs/license-mpl2-acadrust.md`](./docs/license-mpl2-acadrust.md)
+70
View File
@@ -0,0 +1,70 @@
# Third-party notices
This viewer bundles third-party code. The notices below apply to the source
tree **and** to the built site (`dist2/`, deployed at
`https://dwg-dxf-viewer-sample.pages.dev/`), because the build embeds the
components listed here.
Served copies of the license texts live under
[`public/licenses/`](./public/licenses/) → `/licenses/…` on the deployed site.
---
## acadrust 0.4.1 — Mozilla Public License 2.0 — **MODIFIED**
| | |
|---|---|
| Component | `acadrust` (DWG/DXF parser, Rust) |
| Version | 0.4.1 |
| License | MPL-2.0 — full text: [`public/licenses/acadrust-MPL-2.0.txt`](./public/licenses/acadrust-MPL-2.0.txt) (`/licenses/acadrust-MPL-2.0.txt`) |
| Upstream | https://github.com/hakanaktt/acadrust · https://crates.io/crates/acadrust/0.4.1 |
| Upstream commit | `f249c2f816acf36ee51cd5533716bdd443c2517e` (from the crate's `.cargo_vcs_info.json`) |
| Author | Hakan AK |
| Shipped as | compiled into `assets/acadrust_dwg_bg-*.wasm` (Executable Form) |
**This copy is modified.** One file differs from the published crate:
```
src/io/dwg/dwg_stream_readers/object_reader/entities.rs (read_mesh array-count bounds)
```
The modification bounds `read_mesh`'s array counts by the bits remaining in the
object stream instead of the blanket 100 000-item guard, which truncated and
thereby corrupted large SubD meshes.
### How to obtain the Source Code Form (MPL-2.0 §3.2)
1. Get the unmodified crate source — `cargo vendor`, or
`https://crates.io/api/v1/crates/acadrust/0.4.1/download`, or the upstream
commit above.
2. Apply [`public/licenses/acadrust-0.4.1-read_mesh.patch`](./public/licenses/acadrust-0.4.1-read_mesh.patch)
(`/licenses/acadrust-0.4.1-read_mesh.patch` on the deployed site):
```bash
tar xf acadrust-0.4.1.crate && cd acadrust-0.4.1
patch -p1 < acadrust-0.4.1-read_mesh.patch
```
The result is the exact Source Code Form of the acadrust code compiled into the
shipped `.wasm`. Both the original and the modified file remain under MPL-2.0.
---
## dwg-wasm — MIT
The wasm-bindgen wrapper that turns acadrust output into this viewer's
`parseResult` (`hmwebviewer/rust/dwg-wasm/`). Our own code, MIT. MPL-2.0 §3.3
permits distributing this Larger Work under different terms as long as the
MPL-covered files above keep their license — which they do.
---
## Other components
| Component | License |
|---|---|
| three.js | MIT |
| dxf-parser | MIT |
| opentype.js | MIT |
| NanumGothic (`public/fonts/`) | SIL Open Font License 1.1 |
| Viewer2D / sample glue | MIT |
-75
View File
@@ -1,75 +0,0 @@
# Third-party notices
이 문서는 저장소에 직접 포함되어 배포되는 주요 third-party 자산의 출처와
라이선스를 기록합니다. package manager가 설치하는 전체 dependency 목록은
lockfile을 기준으로 별도 감사합니다.
## acadrust 0.4.1 / DWG WASM
- 역할: DWG binary parsing
- upstream: <https://github.com/hakanaktt/acadrust>
- crate source: <https://crates.io/crates/acadrust/0.4.1>
- license: Mozilla Public License 2.0
- license text: [`LICENSES/MPL-2.0.txt`](./LICENSES/MPL-2.0.txt)
- shipped WASM SHA-256:
`aeb211f98e7e4aacfeb2d8430a1ca0fb14b507885f17af13137bd24ed0eca7a3`
WASM binary의 embedded source path에서 `acadrust-0.4.1` 사용을 확인했습니다.
현재 binary를 최초 생성한 Rust wrapper source와 toolchain은 기존 두 upstream
repository에 남아 있지 않습니다. 이 저장소는 해당 binary를 standalone commit
`95fa6a452b50394ff11fba60db482dd038fe2355`에서 checksum을 유지한 채 이관했습니다.
재현 build와 외부 package 배포는 Gitea issue #3에서 별도로 관리합니다.
## NanumGothic-Regular.ttf
- 역할: DWG/DXF TEXT와 MTEXT glyph rendering
- upstream: <https://github.com/google/fonts/tree/main/ofl/nanumgothic>
- copyright: Copyright 2010 The Nanum Project Authors
- license: SIL Open Font License 1.1
- license text: [`LICENSES/OFL-1.1.txt`](./LICENSES/OFL-1.1.txt)
- shipped font SHA-256:
`76f45ef4a6bcff344c837c95a7dcc26e017e38b5846d5ae0cdcb5b86be2e2d31`
위 checksum은 Google Fonts의 현재 `NanumGothic-Regular.ttf`와 동일함을
2026-07-29에 확인했습니다.
## Draco decoder
- 역할: glTF geometry decompression
- upstream: <https://github.com/google/draco>
- license: Apache License 2.0
- license text: <https://github.com/google/draco/blob/main/LICENSE>
- distribution: 설치된 Three.js의 `DRACO_GLTF_CONFIG` import가 production build에
version 일치 hashed JavaScript/WASM asset으로 포함합니다.
## Basis Universal transcoder
- 역할: KTX2/Basis Universal texture transcoding
- upstream: <https://github.com/BinomialLLC/basis_universal>
- license: Apache License 2.0
- license text: <https://github.com/BinomialLLC/basis_universal/blob/master/LICENSE>
- distribution: 설치된 Three.js의 `KTX2Loader` import가 production build에
version 일치 hashed JavaScript/WASM asset으로 포함합니다.
## KTX2 E2E fixture
- 역할: production subpath에서 Basis Universal transcoder 실제 실행 검증
- upstream:
<https://github.com/mrdoob/three.js/blob/42f66a1620be72e497a0bb3bdc7663d21d43169a/examples/textures/ktx2/2d_etc1s.ktx2>
- license: MIT,
<https://github.com/mrdoob/three.js/blob/42f66a1620be72e497a0bb3bdc7663d21d43169a/LICENSE>
- local path: `tests/fixtures/viewer3d/BasisTexture.ktx2`
- SHA-256:
`e56ddcc757fc73ff06bb0dac2a3533ce79c1e196ad895a3ff7dcc4d9de6b9d5d`
- distribution: E2E가 same-origin request로만 제공하며 production artifact에는
포함하지 않습니다.
## Runtime libraries
- Three.js — MIT, <https://github.com/mrdoob/three.js>
- dxf-parser — MIT, <https://github.com/gdsestimating/dxf-parser>
- opentype.js — MIT, <https://github.com/opentypejs/opentype.js>
- web-ifc — MPL-2.0, <https://github.com/ThatOpen/engine_web-ifc>
각 library의 license text는 clean install 후 해당 package에 포함된 license
파일에서도 확인할 수 있습니다.
-113
View File
@@ -1,113 +0,0 @@
# dwg-dxf-viewer-sample
**hmwebviewer 형제 폴더**에 분리된 DWG/DXF 2D 뷰어 샘플입니다.
`docs/modules-dwg-dxf.html` 모듈 지도의 카드
(`acadrust-dwg`, `dxf-parser`, `Viewer2D` …)를 **직접 조합**해 동작하는 독립 프로젝트입니다.
`hmwebviewer` 저장소 안에 의존하지 않습니다.
```
D:\MYCLAUDE_PROJECT\
hmwebviewer\ ← 본 제품
dwg-dxf-viewer-sample\ ← 이 샘플 (형제)
```
---
## 실행
```bash
cd D:\MYCLAUDE_PROJECT\dwg-dxf-viewer-sample
npm install
npm run dev
# → http://localhost:5173
```
| UI | 모듈 경로 |
|----|-----------|
| **Sample DWG** | `ext2d`**acadrust-dwg WASM**`Viewer2D` |
| **Sample DXF** | `ext2d`**dxf-parser + hatch + adapter**`Viewer2D` |
| 파일 드롭 / Open | 확장자로 위 둘 중 하나 |
모듈 지도: 브라우저에서 [`docs/modules-dwg-dxf.html`](./docs/modules-dwg-dxf.html)
조합 코드: [`src/main.ts`](./src/main.ts)
---
## 레이아웃
```
dwg-dxf-viewer-sample/
docs/modules-dwg-dxf.html # 모듈 조합 레시피 HTML
public/
fonts/NanumGothic-Regular.ttf
samples/BasicSample.dwg
samples/simple.dxf
src/
main.ts # 모듈을 import 해서 조립
viewer2d/ # 재사용 모듈 본체
acadrust-dwg/ # WASM
Viewer2D.js
dwgParser.ts
parseDxf.ts
dxfAdapter.js
dxfHatchHandler.js
ext2d.ts
slugText.ts
package.json
vite.config.ts
```
---
## 조합 파이프라인
```
File/URL
→ ext2d (isDwg / isDxf)
→ parseDwgBuffer | parseDxfBuffer
│ │
└ acadrust-dwg ├ decodeDxf
├ dxf-parser + dxfHatchHandler
└ dxfAdapter
→ CadParseResult
→ Viewer2D.load() (+ three, slugText/font)
```
---
## 의존성
| 패키지 | 역할 |
|--------|------|
| `three` | WebGL 렌더 / OrbitControls |
| `dxf-parser` | DXF 텍스트 파싱 |
| `opentype.js` | TTF 글리프 (slugText) |
WASM(`acadrust_dwg_bg.wasm`)은 `src/viewer2d/acadrust-dwg/` 에 포함되어 있으며
Vite `?url` import 로 로드됩니다.
---
## 다른 앱에 이식
1. `src/viewer2d/` 폴더 복사
2. `npm i three dxf-parser opentype.js`
3. 폰트 `public/fonts/NanumGothic-Regular.ttf`
4. Vite: `assetsInclude: ['**/*.wasm']`
5. `src/main.ts` 의 ①~④ 블록을 호스트에 붙이고 ⑤ UI만 교체
소스 동기화(선택): 본가 `hmwebviewer/src/viewer2d` 가 업데이트되면 해당 파일을
이 프로젝트 `src/viewer2d` 로 다시 복사하면 됩니다.
---
## 라이선스 메모
| 조각 | 라이선스 |
|------|----------|
| Viewer2D 포트 · 샘플 글루 | MIT (hmwebviewer 계열) |
| acadrust (WASM 내부) | MPL-2.0 |
| dxf-parser · three | MIT |
| NanumGothic | OFL |
-100
View File
@@ -1,100 +0,0 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E" />
<title>DWG/DXF · 모듈 조합 샘플</title>
<style>
:root {
--bg: #0d1117; --panel: #161b22; --ink: #e6edf3; --muted: #8b949e;
--accent: #3fb950; --wasm: #f0a03c; --line: #30363d; --danger: #f85149;
--mono: ui-monospace, "Cascadia Code", Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Malgun Gothic", sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink); font-family: var(--sans); }
#app { position: fixed; inset: 0; }
#toolbar {
position: fixed; top: 12px; left: 12px; right: 12px; z-index: 10;
display: flex; flex-wrap: wrap; gap: 8px; align-items: center; pointer-events: none;
}
#toolbar > * { pointer-events: auto; }
button, label.btn {
background: var(--panel); color: var(--ink); border: 1px solid var(--line);
border-radius: 8px; padding: 8px 12px; font: 13px var(--sans); cursor: pointer;
}
button:hover, label.btn:hover { border-color: var(--accent); }
button.primary { background: #238636; border-color: #2ea043; }
button.wasm { border-color: var(--wasm); color: var(--wasm); }
label.btn input { display: none; }
#status {
margin-left: auto; font: 12px var(--mono); color: var(--muted);
background: rgba(22,27,34,.85); border: 1px solid var(--line);
border-radius: 8px; padding: 8px 12px; max-width: min(48vw, 480px);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
#status.err { color: var(--danger); }
#recipe {
position: fixed; bottom: 14px; left: 14px; z-index: 10; max-width: 340px;
background: rgba(22,27,34,.94); border: 1px solid var(--line); border-radius: 10px;
padding: 12px 14px; font-size: 12px; line-height: 1.45; color: var(--muted);
box-shadow: 0 8px 24px rgba(0,0,0,.35);
}
#recipe h3 {
margin: 0 0 8px; font: 600 11px var(--mono); letter-spacing: .1em;
text-transform: uppercase; color: var(--accent);
}
#recipe ol { margin: 0; padding-left: 18px; }
#recipe li { margin-bottom: 4px; }
#recipe code { font-family: var(--mono); font-size: 11px; color: var(--ink); }
#recipe .mod-w { color: var(--wasm); }
#recipe .mod-j { color: var(--accent); }
#recipe a { color: var(--accent); font-family: var(--mono); font-size: 11px; }
#layers {
position: fixed; top: 56px; right: 12px; width: 200px; max-height: 50vh;
overflow: auto; z-index: 10; background: rgba(22,27,34,.95);
border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px;
font-size: 12px; display: none;
}
#layers h3 { margin: 0 0 8px; font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
#layers label { display: flex; gap: 8px; align-items: center; margin: 4px 0; cursor: pointer; }
#layers .swatch { width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0; }
</style>
</head>
<body>
<div id="app"></div>
<div id="toolbar">
<label class="btn">
Open file
<input id="file" type="file" accept=".dwg,.dxf" />
</label>
<button id="sample" class="primary wasm" type="button" title="acadrust-dwg WASM">Sample DWG</button>
<button id="sampleDxf" class="primary" type="button" title="dxf-parser">Sample DXF</button>
<button id="fit" type="button">Fit (F)</button>
<button id="theme" type="button">Theme</button>
<button id="layersBtn" type="button">Layers</button>
<div id="status">ready</div>
</div>
<div id="layers"><h3>Layers</h3><div id="layerList"></div></div>
<aside id="recipe">
<h3>Module recipe</h3>
<ol>
<li><code class="mod-j">ext2d</code> — .dwg / .dxf 분기</li>
<li><code class="mod-w">acadrust-dwg</code> 또는 <code class="mod-j">dxf-parser</code></li>
<li><code class="mod-j">dxfAdapter</code> — DXF → 공통 스키마</li>
<li><code class="mod-j">Viewer2D</code> + <code class="mod-j">three</code> 렌더</li>
<li><code class="mod-j">slugText</code> + TTF 폰트</li>
</ol>
<p style="margin:10px 0 0">
코드: <code>src/main.ts</code> ·
지도: <a href="./docs/modules-dwg-dxf.html" target="_blank">docs/modules-dwg-dxf.html</a>
</p>
</aside>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
-19
View File
@@ -1,19 +0,0 @@
{
"name": "@hmwebviewer/viewer-2d-sample",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1 --port 5173 --strictPort",
"build": "tsc --noEmit && vite build",
"build:subpath:e2e": "vite build --base=/viewer-2d/ --outDir=dist-subpath",
"preview": "vite preview",
"preview:subpath:e2e": "vite preview --base=/viewer-2d/ --outDir=dist-subpath --host 127.0.0.1 --port 45173 --strictPort",
"serve:subpath:e2e": "npm run build:subpath:e2e && npm run preview:subpath:e2e",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hmwebviewer/viewer2d": "*",
"three": "0.185.1"
}
}
@@ -1,72 +0,0 @@
export const MAX_CAD_BYTES = 50 * 1024 * 1024;
export const CAD_FETCH_TIMEOUT_MS = 15_000;
export function assertCadByteLength(byteLength: number): void {
if (!Number.isFinite(byteLength) || byteLength < 0) {
throw new Error('CAD 파일 크기를 확인할 수 없습니다.');
}
if (byteLength > MAX_CAD_BYTES) {
throw new Error('CAD 파일은 50 MiB 이하여야 합니다.');
}
}
export function resolveCadUrl(input: string, base: URL): URL {
const url = new URL(input, base);
if (!['http:', 'https:'].includes(url.protocol)) {
throw new Error('CAD URL은 HTTP(S)만 허용합니다.');
}
if (url.origin !== base.origin) {
throw new Error('CAD URL은 viewer와 같은 origin만 허용합니다.');
}
return url;
}
export async function fetchCadBuffer(url: URL): Promise<ArrayBuffer> {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), CAD_FETCH_TIMEOUT_MS);
try {
const response = await fetch(url, {
credentials: 'same-origin',
signal: controller.signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const declaredLength = Number(response.headers.get('content-length'));
if (Number.isFinite(declaredLength) && declaredLength > 0) {
assertCadByteLength(declaredLength);
}
if (!response.body) {
const buffer = await response.arrayBuffer();
assertCadByteLength(buffer.byteLength);
return buffer;
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
assertCadByteLength(total);
chunks.push(value);
}
const data = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
data.set(chunk, offset);
offset += chunk.byteLength;
}
return data.buffer;
} catch (error) {
if (controller.signal.aborted) {
throw new Error('CAD 파일 요청 시간이 초과되었습니다.');
}
throw error;
} finally {
window.clearTimeout(timeout);
}
}
-183
View File
@@ -1,183 +0,0 @@
/**
* ═══════════════════════════════════════════════════════════════════
* DWG/DXF viewer sample — 모듈 조합 레시피
* (docs/modules-dwg-dxf.html 에 정리된 모듈을 그대로 조립)
*
* 이 프로젝트는 hmwebviewer 와 형제 폴더로 분리된 독립 샘플입니다.
* ═══════════════════════════════════════════════════════════════════
*
* ┌─ 모듈 지도 카드 ──────────────┬─ 이 파일에서의 역할 ──────────┐
* │ acadrust-dwg (WASM) │ DWG 바이트 → parseResult │
* │ dxf-parser + dxfHatchHandler │ DXF 텍스트 → parseResult │
* │ dxfAdapter │ dxf-parser 출력을 공통 스키마로 │
* │ Viewer2D │ parseResult → three 씬 렌더 │
* │ three (peer) │ WebGLRenderer / OrbitControls │
* │ slugText + NanumGothic.ttf │ TEXT/MTEXT 한글·라틴 표시 │
* │ ext2d │ .dwg / .dxf 확장자 판별 │
* └───────────────────────────────┴───────────────────────────────┘
*
* 흐름: File/URL → (확장자 분기) → 파서 → CadParseResult → Viewer2D.load()
*/
// ── ① 모듈 지도의 각 카드 (src/viewer2d/*) ────────────────────────
import {
createViewer2D,
isCad2DFile,
loadCad,
type CadLoadSummary,
} from '@hmwebviewer/viewer2d';
import {
assertCadByteLength,
fetchCadBuffer,
resolveCadUrl,
} from './cadInputPolicy';
// parseDxfBuffer 내부:
// dxf-parser → HatchHandler → dxfAdapter
// parseDwgBuffer 내부:
// acadrustParser → acadrust-dwg WASM
// ── ② 호스트 정적 자산 ──────────────────────────────────────────
const appBase = new URL(import.meta.env.BASE_URL, window.location.origin);
const publicUrl = (path: string) => new URL(path, appBase).href;
// ── ③ Viewer2D 마운트 ───────────────────────────────────────────
const app = document.getElementById('app')!;
const statusEl = document.getElementById('status')!;
const layerList = document.getElementById('layerList')!;
const layersPanel = document.getElementById('layers')!;
function setStatus(msg: string, err = false) {
statusEl.textContent = msg;
statusEl.classList.toggle('err', err);
}
const viewer = createViewer2D(app, {
fontUrl: publicUrl('fonts/NanumGothic-Regular.ttf'),
});
declare global {
interface Window {
__viewer2d?: typeof viewer;
}
}
window.__viewer2d = viewer;
viewer.onSelect((entity: unknown) => {
if (!entity) {
setStatus('deselected');
return;
}
const e = entity as { type?: string; layer?: string; handle?: { value?: string } };
setStatus(`select ${e.type ?? '?'} · layer=${e.layer ?? '-'} · h=${e.handle?.value ?? '-'}`);
});
// ── ④ 파서 조합 ─────────────────────────────────────────────────
async function loadAndRender(buf: ArrayBuffer, name: string): Promise<CadLoadSummary> {
return loadCad(viewer, { data: buf, name });
}
async function loadUrl(url: string, nameHint?: string) {
const resolved = resolveCadUrl(url, new URL(window.location.href));
const name = nameHint ?? resolved.pathname.split('/').pop() ?? resolved.href;
return loadAndRender(await fetchCadBuffer(resolved), name);
}
async function loadFile(file: File) {
if (!isCad2DFile(file.name)) throw new Error(`not .dwg/.dxf: ${file.name}`);
assertCadByteLength(file.size);
return loadAndRender(await file.arrayBuffer(), file.name);
}
// ── ⑤ 호스트 UI 글루 ────────────────────────────────────────────
function fillLayers() {
layerList.innerHTML = '';
for (const L of viewer.getLayerInfo()) {
const label = document.createElement('label');
label.dataset.name = L.name;
const sw = document.createElement('span');
sw.className = 'swatch';
sw.style.background = L.colorHex || '#888';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = L.visible;
cb.addEventListener('change', () => {
const next = new Set<string>();
layerList.querySelectorAll('label').forEach((row) => {
const input = row.querySelector('input') as HTMLInputElement;
if (!input.checked) next.add(row.dataset.name!);
});
viewer.setHiddenLayers(next);
});
const text = document.createElement('span');
text.textContent = `${L.name} (${L.count})`;
label.append(sw, cb, text);
layerList.append(label);
}
}
async function afterLoad(name: string, result: CadLoadSummary) {
setStatus(`${name} · ${result.entityCount} entities`);
fillLayers();
viewer.fit();
}
async function run(label: string, job: () => Promise<CadLoadSummary>) {
setStatus(`loading ${label}`);
try {
await afterLoad(label, await job());
} catch (err) {
setStatus(`${label}: ${(err as Error).message}`, true);
console.error(err);
}
}
document.getElementById('sample')!.addEventListener('click', () => {
void run('BasicSample.dwg', () =>
loadUrl(publicUrl('samples/BasicSample.dwg'), 'BasicSample.dwg'),
);
});
document.getElementById('sampleDxf')!.addEventListener('click', () => {
void run('simple.dxf', () => loadUrl(publicUrl('samples/simple.dxf'), 'simple.dxf'));
});
document.getElementById('file')!.addEventListener('change', (ev) => {
const file = (ev.target as HTMLInputElement).files?.[0];
if (!file) return;
void run(file.name, () => loadFile(file));
(ev.target as HTMLInputElement).value = '';
});
document.getElementById('fit')!.addEventListener('click', () => viewer.fit());
let dark = true;
document.getElementById('theme')!.addEventListener('click', () => {
dark = !dark;
viewer.setTheme(dark);
});
document.getElementById('layersBtn')!.addEventListener('click', () => {
layersPanel.style.display = layersPanel.style.display === 'block' ? 'none' : 'block';
});
window.addEventListener('dragover', (e) => e.preventDefault());
window.addEventListener('drop', (e) => {
e.preventDefault();
const file = e.dataTransfer?.files?.[0];
if (file) void run(file.name, () => loadFile(file));
});
const model = new URLSearchParams(location.search).get('model');
if (model) {
try {
const resolved = resolveCadUrl(model, new URL(window.location.href));
void run(model, () => loadUrl(resolved.href));
} catch (error) {
setStatus((error as Error).message, true);
}
} else {
setStatus('ready · Sample DWG / DXF 또는 파일 드롭');
}
window.addEventListener(
'pagehide',
() => {
viewer.dispose();
delete window.__viewer2d;
},
{ once: true },
);
-4
View File
@@ -1,4 +0,0 @@
{
"extends": "../../tsconfig.json",
"include": ["src/**/*", "vite.config.ts"]
}
@@ -1,28 +0,0 @@
---
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.
@@ -1,27 +0,0 @@
---
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.
@@ -1,26 +0,0 @@
---
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.
-30
View File
@@ -1,30 +0,0 @@
---
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.
@@ -1,32 +0,0 @@
---
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.
@@ -1,27 +0,0 @@
---
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.
@@ -1,13 +0,0 @@
---
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`).
@@ -1,13 +0,0 @@
---
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`).
@@ -1,13 +0,0 @@
---
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.
-17
View File
@@ -1,17 +0,0 @@
---
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.
@@ -1,13 +0,0 @@
---
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`).
@@ -1,12 +0,0 @@
#!/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
@@ -1,6 +0,0 @@
#!/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
-36
View File
@@ -1,36 +0,0 @@
{
"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
}
]
}
]
}
}
@@ -1,43 +0,0 @@
---
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.
@@ -1,89 +0,0 @@
---
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
View File
@@ -1,20 +0,0 @@
# 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
-3
View File
@@ -1,3 +0,0 @@
{
"liveServer.settings.port": 5501
}
Binary file not shown.
-104
View File
@@ -1,104 +0,0 @@
# 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**: use Vite hashed assets generated from the installed Three.js loader imports. Stage only single-thread `web-ifc.wasm` under `public/web-ifc`. 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/
web-ifc/ # single-thread IFC decoder WASM
samples/ # 배포 demo model
previews/ # pre-rendered WebP/WebM placeholders
dist/assets/ # Vite hashed Draco/Basis decoder assets
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).
-80
View File
@@ -1,80 +0,0 @@
# 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 | Resolve Draco + KTX2 decoder WASM from Three.js loader imports | setup | done (Vite hashed assets) | P0-1 | production/subpath decoder requests resolve; 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.
-82
View File
@@ -1,82 +0,0 @@
# 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 (P0P7). 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:** Gitea #7 runtime artifact 경량화 — Three.js hashed decoder 통합, single-thread IFC staging, production artifact budget와 Draco/Basis/IFC subpath E2E 검증.
- **Blockers:** none. KTX2 *production* encoding still needs KTX-Software/toktx (runtime decode verified). web-ifc multi-thread는 COOP/COEP가 필요해 배포에서 제외하고 single-thread를 사용합니다.
- **Next:** OBJ/DAE geometry-only on blob: URL (accepted); document.hidden FPS guard.
## Work log
- 2026-07-29 — Gitea #7 runtime artifact 경량화: version 중복 5종은 incompatible/optional transitive dependency라 강제 override하지 않음. Three.js bundled hashed Draco/Basis asset으로 전환하고 `public/draco`, `public/basis`, `web-ifc-mt.wasm`, production source map 제거. 2D/3D artifact gate 추가. 3D dist 24,945,531→8,149,402 bytes(-67.3%), 2D dist 4,302,677 bytes, duplicate 0. 966-byte official Three.js ETC1S KTX2 test asset으로 production subpath에서 Basis transcoder 실제 실행을 검증. Vitest 8, typecheck/build, Playwright E2E 17개 통과.
- 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): ...
-->
-1
View File
@@ -1 +0,0 @@
https://share.gemini.google/rErWUzD9BBdH 읽어라.
-230
View File
@@ -1,230 +0,0 @@
<!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) &nbsp;|&nbsp; <strong>로드 경로:</strong> SSR + CSR &nbsp;|&nbsp; <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=&lt;url&gt;</code> · 프리뷰: <code>/previews/&lt;base&gt;.webp</code> (포맷 무관)</p>
</div>
<div class="card">
<h3>Path B — 로컬 파일 (Drag &amp; 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> 측정. 전체 &lt;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 (r185)</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>Vite hashed Draco·Basis asset + <code>public/web-ifc</code></td><td>Three.js import로 생성, single-thread IFC WASM만 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/
web-ifc/ # ★ web-ifc.wasm (단일스레드, 자동 복사)
samples/ # 배포 demo: Box/Duck/Avocado/Duck.optimized + Cube.obj/dae/fbx/ifc
previews/ # Duck.webp + Cube.{obj,dae,fbx,ifc}.webp (24프레임 360°)
dist/assets/ # ★ Vite hashed Draco/Basis decoder JS/WASM
tools/
preprocess.mjs # gltf-transform Draco+WebP 압축 (GLB 전용)
copy-decoders.mjs # postinstall: single-thread web-ifc WASM 복사
prerender.mjs # 360° 턴테이블 → Animated WebP (포맷 무관)
perf-smoke.mjs # 헤드리스 체감 로드 측정 (&lt;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 &amp; 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 표시 + 적응형 품질 (&lt;60fps → 최적화)</h2>
<div class="card">
<ul>
<li><strong>FPS 오버레이</strong> (<code>ui/fps.ts</code>): 좌상단, 프레임 델타 EMA 평활, ~250ms 스로틀 DOM 갱신. 색상 — <span class="yes">≥60 녹색</span> / 3059 주황 / <span class="no">&lt;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>(진동 방지): 평균 &lt;60fps가 <strong>60프레임 지속</strong> 시 1단계 강등 / 평균 &gt;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> 클린, Vite 8 production build 통과. 3D artifact 27개, 8,149,402 bytes, 50KB 이상 중복 0개.</li>
<li><strong>포맷별 인브라우저 로드(헤드리스 Chrome):</strong> GLB 229ms · OBJ 173ms · FBX 158ms · DAE 178ms · <strong>IFC 463ms</strong> · Duck.optimized 182ms — 전체 &lt;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> official Three.js ETC1S KTX2 fixture를 production subpath에서 로드하고 hashed Basis transcoder WASM request와 model ready를 E2E로 확인. 인코딩은 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 &lt;file.glb&gt; # Draco+WebP 압축(GLB)
node tools/prerender.mjs # 360° WebP 프리뷰 생성
</pre>
<h2>10. 다음 단계 (선택)</h2>
<ul>
<li>✅ 전체 PLAN 완료 (P0P7). 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>
-30
View File
@@ -1,30 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E" />
<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>
-23
View File
@@ -1,23 +0,0 @@
{
"name": "@hmwebviewer/viewer-3d",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"build:subpath:e2e": "vite build --base=/viewer-3d/ --outDir=dist-subpath",
"preview": "vite preview",
"preview:subpath:e2e": "vite preview --base=/viewer-3d/ --outDir=dist-subpath --host 127.0.0.1 --port 43174 --strictPort",
"serve:subpath:e2e": "npm run build:subpath:e2e && npm run preview:subpath:e2e",
"typecheck": "tsc --noEmit",
"stage:decoders": "node tools/copy-decoders.mjs"
},
"dependencies": {
"three": "0.185.1",
"web-ifc": "0.0.77"
},
"devDependencies": {
"puppeteer-core": "^25.1.0"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.
-62
View File
@@ -1,62 +0,0 @@
<?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.
-59
View File
@@ -1,59 +0,0 @@
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;
-16
View File
@@ -1,16 +0,0 @@
# Hand-authored cube fixture
o Cube
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
f 1 4 3 2
f 5 6 7 8
f 1 2 6 5
f 2 3 7 6
f 3 4 8 7
f 4 1 5 8
@@ -1,17 +0,0 @@
ply
format ascii 1.0
comment Three.js r185 Float64 normalization fixture
element vertex 3
property double x
property double y
property double z
property uchar red
property uchar green
property uchar blue
element face 1
property list uchar int vertex_indices
end_header
0.0 0.0 0.0 255 0 0
100.0 0.0 0.0 0 255 0
0.0 100.0 0.0 0 0 255
3 0 1 2
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-62
View File
@@ -1,62 +0,0 @@
<?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.
-59
View File
@@ -1,59 +0,0 @@
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.
File diff suppressed because it is too large Load Diff
-84
View File
@@ -1,84 +0,0 @@
/**
* 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 = "";
});
}
-83
View File
@@ -1,83 +0,0 @@
import { ThreeDViewer } from './viewer/ThreeDViewer';
import { initDropzone } from './dnd/dropzone';
import { setStatus } from './ui/progress';
import { publicAssetUrl } from './runtimeBase';
/**
* 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 = publicAssetUrl(`previews/${base}.webp`);
previewEl.classList.remove('hidden');
previewEl.onerror = () => previewEl.classList.add('hidden');
viewer.loadServerAsset(modelUrl);
}
// tools/prerender.mjs가 회전 frame을 캡처할 때 사용하는 공개 reference입니다.
const viewerWindow = window as Window & { __viewer?: ThreeDViewer };
viewerWindow.__viewer = viewer;
window.addEventListener(
'pagehide',
() => {
viewer.dispose();
delete viewerWindow.__viewer;
},
{ once: true },
);
-12
View File
@@ -1,12 +0,0 @@
/**
* Vite의 배포 base 아래에 있는 public asset의 절대 URL을 반환합니다.
*/
export function publicAssetUrl(
path: string,
base = import.meta.env.BASE_URL,
origin = window.location.origin,
): string {
const normalizedBase = base.endsWith('/') ? base : `${base}/`;
const normalizedPath = path.replace(/^\/+/, '');
return new URL(normalizedPath, new URL(normalizedBase, `${origin}/`)).href;
}
-51
View File
@@ -1,51 +0,0 @@
: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; }
-58
View File
@@ -1,58 +0,0 @@
/**
* 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;
},
};
}
-38
View File
@@ -1,38 +0,0 @@
/**
* 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;
}
-368
View File
@@ -1,368 +0,0 @@
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 disposed = false;
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 => {
if (this.disposed) return;
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 {
if (this.disposed) return;
this.disposed = true;
cancelAnimationFrame(this.raf);
this.raf = 0;
window.removeEventListener('resize', this.onResize);
this.controls.dispose();
this.clearOutline();
if (this.current) this.disposeObject(this.current);
this.current = null;
this.fps.el.remove();
this.renderer.dispose();
this.renderer.forceContextLoss();
this.renderer.domElement.remove();
this.scene.clear();
}
/** 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);
}
}
@@ -1,85 +0,0 @@
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;
},
};
}
-35
View File
@@ -1,35 +0,0 @@
// 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();
},
};
}
-26
View File
@@ -1,26 +0,0 @@
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import {
DRACOLoader,
DRACO_GLTF_CONFIG,
} 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;
}
// 여러 DRACOLoader/KTX2Loader instance를 만들면 충돌할 수 있으므로 하나를 공유합니다.
// decoder URL은 Three.js import가 생성하는 version 일치 hashed asset을 사용합니다.
let _cache: ViewerLoaders | null = null;
export function getLoaders(renderer: THREE.WebGLRenderer): ViewerLoaders {
if (_cache) return _cache;
const draco = new DRACOLoader().setDecoderPath(DRACO_GLTF_CONFIG);
const ktx2 = new KTX2Loader().detectSupport(renderer);
const gltf = new GLTFLoader().setDRACOLoader(draco).setKTX2Loader(ktx2);
_cache = { gltf, draco, ktx2 };
return _cache;
}
-307
View File
@@ -1,307 +0,0 @@
import * as THREE from 'three';
import { publicAssetUrl } from '../runtimeBase';
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());
}
function normalizePlyAttributes(geometry: THREE.BufferGeometry): void {
for (const name of ['position', 'normal', 'uv', 'color']) {
const attribute = geometry.getAttribute(name);
if (!attribute || !(attribute.array instanceof Float64Array)) continue;
geometry.setAttribute(
name,
new THREE.Float32BufferAttribute(
new Float32Array(attribute.array),
attribute.itemSize,
attribute.normalized,
),
);
}
}
/**
* 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) => {
if (!result) {
reject(new Error('Collada loader returned no scene'));
return;
}
resolve(result.scene);
},
onXhr,
(err) => reject(err),
);
});
}
case 'ply': {
const loader = await getPLY();
return new Promise<THREE.Object3D>((resolve, reject) => {
loader.load(
url,
(geometry) => {
normalizePlyAttributes(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(publicAssetUrl('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);
}
}
-96
View File
@@ -1,96 +0,0 @@
/**
* 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' });
}
-143
View File
@@ -1,143 +0,0 @@
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);
}
-76
View File
@@ -1,76 +0,0 @@
# 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.
-95
View File
@@ -1,95 +0,0 @@
#!/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); });
-30
View File
@@ -1,30 +0,0 @@
// IFC single-thread WASM을 public/에 staging합니다.
// `npm install`의 postinstall에서 자동 실행되며 반복 실행해도 같은 결과를 만듭니다.
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
rmSync,
} 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, '..');
// 현재 IFC 경로는 COOP/COEP가 필요 없는 single-thread WASM만 사용합니다.
const ifcEntry = fileURLToPath(import.meta.resolve('web-ifc'));
const ifcSrcDir = dirname(ifcEntry);
const ifcDest = resolve(root, 'public/web-ifc');
if (existsSync(ifcSrcDir)) {
rmSync(ifcDest, { force: true, recursive: true });
mkdirSync(ifcDest, { recursive: true });
const src = resolve(ifcSrcDir, 'web-ifc.wasm');
const dest = resolve(ifcDest, 'web-ifc.wasm');
copyFileSync(src, dest);
chmodSync(dest, 0o644);
console.log(`[copy-decoders] ${src} -> ${dest}`);
} else {
console.warn('[copy-decoders] web-ifc not installed yet — skipping IFC wasm.');
}
-84
View File
@@ -1,84 +0,0 @@
#!/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); });
-78
View File
@@ -1,78 +0,0 @@
#!/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();
-117
View File
@@ -1,117 +0,0 @@
#!/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);
});
-65
View File
@@ -1,65 +0,0 @@
#!/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();
-192
View File
@@ -1,192 +0,0 @@
#!/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);
});
-138
View File
@@ -1,138 +0,0 @@
#!/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);
});
-106
View File
@@ -1,106 +0,0 @@
#!/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();
-20
View File
@@ -1,20 +0,0 @@
{
"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", "vite/client"]
},
"include": ["src", "tools"]
}
-16
View File
@@ -1,16 +0,0 @@
import { defineConfig } from 'vite';
export default defineConfig({
server: { host: '127.0.0.1', port: 3333, open: false, strictPort: true },
build: {
target: 'es2022',
sourcemap: false,
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('/node_modules/three/')) return 'three';
},
},
},
},
});
+1
View File
@@ -0,0 +1 @@
function A(t){const e=x(t,s.__wbindgen_malloc),n=w,r=s.parse_dwg(e,n);if(r[2])throw h(r[1]);return h(r[0])}function W(){return{__proto__:null,"./acadrust_dwg_bg.js":{__proto__:null,__wbg_Error_92b29b0548f8b746:function(e,n){return Error(g(e,n))},__wbg_String_8564e559799eccda:function(e,n){const r=String(n),i=T(r,s.__wbindgen_malloc,s.__wbindgen_realloc),d=w;m().setInt32(e+4,d,!0),m().setInt32(e+0,i,!0)},__wbg___wbindgen_is_string_ea5e6cc2e4141dfe:function(e){return typeof e=="string"},__wbg___wbindgen_throw_344f42d3211c4765:function(e,n){throw new Error(g(e,n))},__wbg_new_32b398fb48b6d94a:function(){return new Array},__wbg_new_7796ffc7ed656783:function(){return new Map},__wbg_new_da52cf8fe3429cb2:function(){return new Object},__wbg_set_575dd786d51585f8:function(e,n,r){return e.set(n,r)},__wbg_set_6be42768c690e380:function(e,n,r){e[n]=r},__wbg_set_8a16b38e4805b298:function(e,n,r){e[n>>>0]=r},__wbindgen_cast_0000000000000001:function(e){return e},__wbindgen_cast_0000000000000002:function(e){return e},__wbindgen_cast_0000000000000003:function(e,n){return g(e,n)},__wbindgen_cast_0000000000000004:function(e){return BigInt.asUintN(64,e)},__wbindgen_init_externref_table:function(){const e=s.__wbindgen_externrefs,n=e.grow(4);e.set(0,void 0),e.set(n+0,void 0),e.set(n+1,null),e.set(n+2,!0),e.set(n+3,!1)}}}}let a=null;function m(){return(a===null||a.buffer.detached===!0||a.buffer.detached===void 0&&a.buffer!==s.memory.buffer)&&(a=new DataView(s.memory.buffer)),a}function g(t,e){return R(t>>>0,e)}let u=null;function _(){return(u===null||u.byteLength===0)&&(u=new Uint8Array(s.memory.buffer)),u}function x(t,e){const n=e(t.length*1,1)>>>0;return _().set(t,n/1),w=t.length,n}function T(t,e,n){if(n===void 0){const o=b.encode(t),f=e(o.length,1)>>>0;return _().subarray(f,f+o.length).set(o),w=o.length,f}let r=t.length,i=e(r,1)>>>0;const d=_();let c=0;for(;c<r;c++){const o=t.charCodeAt(c);if(o>127)break;d[i+c]=o}if(c!==r){c!==0&&(t=t.slice(c)),i=n(i,r,r=c+t.length*3,1)>>>0;const o=_().subarray(i+c,i+r),f=b.encodeInto(t,o);c+=f.written,i=n(i,r,c,1)>>>0}return w=c,i}function h(t){const e=s.__wbindgen_externrefs.get(t);return s.__externref_table_dealloc(t),e}let l=new TextDecoder("utf-8",{ignoreBOM:!0,fatal:!0});l.decode();const E=2146435072;let y=0;function R(t,e){return y+=e,y>=E&&(l=new TextDecoder("utf-8",{ignoreBOM:!0,fatal:!0}),l.decode(),y=e),l.decode(_().subarray(t,t+e))}const b=new TextEncoder;"encodeInto"in b||(b.encodeInto=function(t,e){const n=b.encode(t);return e.set(n),{read:t.length,written:n.length}});let w=0,s;function M(t,e){return s=t.exports,a=null,u=null,s.__wbindgen_start(),s}async function O(t,e){if(typeof Response=="function"&&t instanceof Response){if(typeof WebAssembly.instantiateStreaming=="function")try{return await WebAssembly.instantiateStreaming(t,e)}catch(i){if(t.ok&&n(t.type)&&t.headers.get("Content-Type")!=="application/wasm")console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n",i);else throw i}const r=await t.arrayBuffer();return await WebAssembly.instantiate(r,e)}else{const r=await WebAssembly.instantiate(t,e);return r instanceof WebAssembly.Instance?{instance:r,module:t}:r}function n(r){switch(r){case"basic":case"cors":case"default":return!0}return!1}}async function D(t){if(s!==void 0)return s;t!==void 0&&(Object.getPrototypeOf(t)===Object.prototype?{module_or_path:t}=t:console.warn("using deprecated parameters for the initialization function; pass a single object instead")),t===void 0&&(t=new URL("/assets/acadrust_dwg_bg-Bw7hOWaW.wasm",import.meta.url));const e=W();(typeof t=="string"||typeof Request=="function"&&t instanceof Request||typeof URL=="function"&&t instanceof URL)&&(t=fetch(t));const{instance:n,module:r}=await O(await t,e);return M(n)}const S="/assets/acadrust_dwg_bg-Bw7hOWaW.wasm";let p=null;function I(){return p||(p=D({module_or_path:S}).then(()=>{})),p}function B(t){return A(t)}export{I as initAcadrustParser,B as parseDwgAcadrust};
+1
View File
@@ -0,0 +1 @@
function h(t){let e,n;try{const f=x(t,i.__wbindgen_malloc),c=w,o=i.parse_dwg_json(f,c);var r=o[0],s=o[1];if(o[3])throw r=0,s=0,T(o[2]);return e=r,n=s,l(r,s)}finally{i.__wbindgen_free(e,n,1)}}function A(){return{__proto__:null,"./acadrust_dwg_bg.js":{__proto__:null,__wbg_Error_92b29b0548f8b746:function(e,n){return Error(l(e,n))},__wbg_String_8564e559799eccda:function(e,n){const r=String(n),s=E(r,i.__wbindgen_malloc,i.__wbindgen_realloc),f=w;m().setInt32(e+4,f,!0),m().setInt32(e+0,s,!0)},__wbg___wbindgen_is_string_ea5e6cc2e4141dfe:function(e){return typeof e=="string"},__wbg___wbindgen_throw_344f42d3211c4765:function(e,n){throw new Error(l(e,n))},__wbg_new_32b398fb48b6d94a:function(){return new Array},__wbg_new_7796ffc7ed656783:function(){return new Map},__wbg_new_da52cf8fe3429cb2:function(){return new Object},__wbg_set_575dd786d51585f8:function(e,n,r){return e.set(n,r)},__wbg_set_6be42768c690e380:function(e,n,r){e[n]=r},__wbg_set_8a16b38e4805b298:function(e,n,r){e[n>>>0]=r},__wbindgen_cast_0000000000000001:function(e){return e},__wbindgen_cast_0000000000000002:function(e){return e},__wbindgen_cast_0000000000000003:function(e,n){return l(e,n)},__wbindgen_cast_0000000000000004:function(e){return BigInt.asUintN(64,e)},__wbindgen_init_externref_table:function(){const e=i.__wbindgen_externrefs,n=e.grow(4);e.set(0,void 0),e.set(n+0,void 0),e.set(n+1,null),e.set(n+2,!0),e.set(n+3,!1)}}}}let a=null;function m(){return(a===null||a.buffer.detached===!0||a.buffer.detached===void 0&&a.buffer!==i.memory.buffer)&&(a=new DataView(i.memory.buffer)),a}function l(t,e){return R(t>>>0,e)}let u=null;function b(){return(u===null||u.byteLength===0)&&(u=new Uint8Array(i.memory.buffer)),u}function x(t,e){const n=e(t.length*1,1)>>>0;return b().set(t,n/1),w=t.length,n}function E(t,e,n){if(n===void 0){const o=d.encode(t),_=e(o.length,1)>>>0;return b().subarray(_,_+o.length).set(o),w=o.length,_}let r=t.length,s=e(r,1)>>>0;const f=b();let c=0;for(;c<r;c++){const o=t.charCodeAt(c);if(o>127)break;f[s+c]=o}if(c!==r){c!==0&&(t=t.slice(c)),s=n(s,r,r=c+t.length*3,1)>>>0;const o=b().subarray(s+c,s+r),_=d.encodeInto(t,o);c+=_.written,s=n(s,r,c,1)>>>0}return w=c,s}function T(t){const e=i.__wbindgen_externrefs.get(t);return i.__externref_table_dealloc(t),e}let g=new TextDecoder("utf-8",{ignoreBOM:!0,fatal:!0});g.decode();const D=2146435072;let y=0;function R(t,e){return y+=e,y>=D&&(g=new TextDecoder("utf-8",{ignoreBOM:!0,fatal:!0}),g.decode(),y=e),g.decode(b().subarray(t,t+e))}const d=new TextEncoder;"encodeInto"in d||(d.encodeInto=function(t,e){const n=d.encode(t);return e.set(n),{read:t.length,written:n.length}});let w=0,i;function W(t,e){return i=t.exports,a=null,u=null,i.__wbindgen_start(),i}async function M(t,e){if(typeof Response=="function"&&t instanceof Response){if(typeof WebAssembly.instantiateStreaming=="function")try{return await WebAssembly.instantiateStreaming(t,e)}catch(s){if(t.ok&&n(t.type)&&t.headers.get("Content-Type")!=="application/wasm")console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n",s);else throw s}const r=await t.arrayBuffer();return await WebAssembly.instantiate(r,e)}else{const r=await WebAssembly.instantiate(t,e);return r instanceof WebAssembly.Instance?{instance:r,module:t}:r}function n(r){switch(r){case"basic":case"cors":case"default":return!0}return!1}}async function S(t){if(i!==void 0)return i;t!==void 0&&(Object.getPrototypeOf(t)===Object.prototype?{module_or_path:t}=t:console.warn("using deprecated parameters for the initialization function; pass a single object instead")),t===void 0&&(t=new URL("/assets/acadrust_dwg_bg-DmCEpFBu.wasm",import.meta.url));const e=A();(typeof t=="string"||typeof Request=="function"&&t instanceof Request||typeof URL=="function"&&t instanceof URL)&&(t=fetch(t));const{instance:n,module:r}=await M(await t,e);return W(n)}const O="/assets/acadrust_dwg_bg-DmCEpFBu.wasm";let p=null;function v(){return p||(p=S({module_or_path:O}).then(()=>{})),p}function I(t){return JSON.parse(h(t))}export{v as initAcadrustParser,I as parseDwgAcadrust};
+1
View File
@@ -0,0 +1 @@
function A(t){let e,n;try{const f=x(t,i.__wbindgen_malloc),c=w,o=i.parse_dwg_json(f,c);var r=o[0],s=o[1];if(o[3])throw r=0,s=0,T(o[2]);return e=r,n=s,l(r,s)}finally{i.__wbindgen_free(e,n,1)}}function h(){return{__proto__:null,"./acadrust_dwg_bg.js":{__proto__:null,__wbg_Error_92b29b0548f8b746:function(e,n){return Error(l(e,n))},__wbg_String_8564e559799eccda:function(e,n){const r=String(n),s=S(r,i.__wbindgen_malloc,i.__wbindgen_realloc),f=w;m().setInt32(e+4,f,!0),m().setInt32(e+0,s,!0)},__wbg___wbindgen_is_string_ea5e6cc2e4141dfe:function(e){return typeof e=="string"},__wbg___wbindgen_throw_344f42d3211c4765:function(e,n){throw new Error(l(e,n))},__wbg_new_32b398fb48b6d94a:function(){return new Array},__wbg_new_7796ffc7ed656783:function(){return new Map},__wbg_new_da52cf8fe3429cb2:function(){return new Object},__wbg_set_575dd786d51585f8:function(e,n,r){return e.set(n,r)},__wbg_set_6be42768c690e380:function(e,n,r){e[n]=r},__wbg_set_8a16b38e4805b298:function(e,n,r){e[n>>>0]=r},__wbindgen_cast_0000000000000001:function(e){return e},__wbindgen_cast_0000000000000002:function(e){return e},__wbindgen_cast_0000000000000003:function(e,n){return l(e,n)},__wbindgen_cast_0000000000000004:function(e){return BigInt.asUintN(64,e)},__wbindgen_init_externref_table:function(){const e=i.__wbindgen_externrefs,n=e.grow(4);e.set(0,void 0),e.set(n+0,void 0),e.set(n+1,null),e.set(n+2,!0),e.set(n+3,!1)}}}}let a=null;function m(){return(a===null||a.buffer.detached===!0||a.buffer.detached===void 0&&a.buffer!==i.memory.buffer)&&(a=new DataView(i.memory.buffer)),a}function l(t,e){return E(t>>>0,e)}let u=null;function b(){return(u===null||u.byteLength===0)&&(u=new Uint8Array(i.memory.buffer)),u}function x(t,e){const n=e(t.length*1,1)>>>0;return b().set(t,n/1),w=t.length,n}function S(t,e,n){if(n===void 0){const o=d.encode(t),_=e(o.length,1)>>>0;return b().subarray(_,_+o.length).set(o),w=o.length,_}let r=t.length,s=e(r,1)>>>0;const f=b();let c=0;for(;c<r;c++){const o=t.charCodeAt(c);if(o>127)break;f[s+c]=o}if(c!==r){c!==0&&(t=t.slice(c)),s=n(s,r,r=c+t.length*3,1)>>>0;const o=b().subarray(s+c,s+r),_=d.encodeInto(t,o);c+=_.written,s=n(s,r,c,1)>>>0}return w=c,s}function T(t){const e=i.__wbindgen_externrefs.get(t);return i.__externref_table_dealloc(t),e}let g=new TextDecoder("utf-8",{ignoreBOM:!0,fatal:!0});g.decode();const D=2146435072;let y=0;function E(t,e){return y+=e,y>=D&&(g=new TextDecoder("utf-8",{ignoreBOM:!0,fatal:!0}),g.decode(),y=e),g.decode(b().subarray(t,t+e))}const d=new TextEncoder;"encodeInto"in d||(d.encodeInto=function(t,e){const n=d.encode(t);return e.set(n),{read:t.length,written:n.length}});let w=0,i;function R(t,e){return i=t.exports,a=null,u=null,i.__wbindgen_start(),i}async function W(t,e){if(typeof Response=="function"&&t instanceof Response){if(typeof WebAssembly.instantiateStreaming=="function")try{return await WebAssembly.instantiateStreaming(t,e)}catch(s){if(t.ok&&n(t.type)&&t.headers.get("Content-Type")!=="application/wasm")console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n",s);else throw s}const r=await t.arrayBuffer();return await WebAssembly.instantiate(r,e)}else{const r=await WebAssembly.instantiate(t,e);return r instanceof WebAssembly.Instance?{instance:r,module:t}:r}function n(r){switch(r){case"basic":case"cors":case"default":return!0}return!1}}async function M(t){if(i!==void 0)return i;t!==void 0&&(Object.getPrototypeOf(t)===Object.prototype?{module_or_path:t}=t:console.warn("using deprecated parameters for the initialization function; pass a single object instead")),t===void 0&&(t=new URL("/assets/acadrust_dwg_bg-Dt2ifJSA.wasm",import.meta.url));const e=h();(typeof t=="string"||typeof Request=="function"&&t instanceof Request||typeof URL=="function"&&t instanceof URL)&&(t=fetch(t));const{instance:n,module:r}=await W(await t,e);return R(n)}const O="/assets/acadrust_dwg_bg-Dt2ifJSA.wasm";let p=null;function v(){return p||(p=M({module_or_path:O}).then(()=>{})),p}function I(t){return JSON.parse(A(t))}export{v as initAcadrustParser,I as parseDwgAcadrust};
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More