test: verify bundled decoders and artifact budgets (#7)

This commit is contained in:
2026-07-29 11:20:46 +09:00
parent 0c656808e0
commit 49d6b0e687
10 changed files with 285 additions and 66 deletions
+16 -12
View File
@@ -392,9 +392,12 @@ path와 host의 `node_modules`를 표준 build 입력으로 사용하지 않습
문법과 repository-relative path로 작성합니다. 문법과 repository-relative path로 작성합니다.
- development, production build, E2E, optional asset processing은 서로 다른 - development, production build, E2E, optional asset processing은 서로 다른
container target 또는 profile로 분리합니다. production artifact에 compiler, container target 또는 profile로 분리합니다. production artifact에 compiler,
browser, fixture와 asset tool을 포함하지 않습니다. browser, test 전용 fixture와 asset tool을 포함하지 않습니다. `public/samples`
demo asset은 의도된 runtime content이므로 provenance·license gate를 별도로 적용합니다.
- container process는 non-root user로 실행하고 source는 read-only mount를 - container process는 non-root user로 실행하고 source는 read-only mount를
우선합니다. dependency cache와 build output만 별도 writable volume을 사용합니다. 우선합니다. `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를 - host에서 직접 실행할 때 Vite는 loopback에만 bind합니다. container에서 port를
publish할 때만 container 전용 설정으로 `0.0.0.0`에 bind하고 공개 network에 publish할 때만 container 전용 설정으로 `0.0.0.0`에 bind하고 공개 network에
노출하지 않습니다. 노출하지 않습니다.
@@ -407,13 +410,12 @@ path와 host의 `node_modules`를 표준 build 입력으로 사용하지 않습
## Linux 컨테이너 전환 검증 ## Linux 컨테이너 전환 검증
2026-07-29 기준 installed dependency 상태에서 typecheck, unit test 8개 production 2026-07-29 기준 installed dependency 상태에서 typecheck, unit test 8개, production
build Linux/WSL2 x86_64에서 다시 통과했습니다. 기존 검증 기록의 Playwright E2E build와 Playwright E2E 17개가 Linux/WSL2 x86_64에서 다시 통과했습니다. tracked
16개 통과 결과는 있으나 이번 문서 변경에 대한 E2E 재실행은 요청에 따라 뒤로 text file은 LF이며 lockfile에는 Linux glibc와 musl용 native optional package가
미뤘습니다. tracked text file은 LF이며 lockfile에는 Linux glibc와 musl용 native 포함되어 있습니다. 따라서 application code의 Windows 종속성은 현재 검증 범위에서
optional package가 포함되어 있습니다. 따라서 application code를 Windows 전용 발견되지 않았지만, clean `npm ci`부터 시작하는 재현 가능한 container 개발 환경의
구현에서 이식하는 작업은 거의 끝났지만, clean `npm ci`부터 시작하는 재현 가능한 정의와 검증은 아직 완료되지 않았습니다.
container 개발 환경의 정의와 현재 E2E 재검증은 아직 완료되지 않았습니다.
| 항목 | 현재 상태 | container 전환 판단 | | 항목 | 현재 상태 | container 전환 판단 |
|---|---|---| |---|---|---|
@@ -497,9 +499,11 @@ npm run verify
- workspace typecheck 통과 - workspace typecheck 통과
- Vitest 5개 file, 8개 test 통과 - Vitest 5개 file, 8개 test 통과
- 2D·3D production build 통과 - 2D·3D production build 통과
- production artifact gate 통과: 27개 file, 8,149,402 bytes, 50 kB 이상 중복 0개 - 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%) 감소 - 3D `dist`는 경량화 전 24,945,531 bytes에서 16,796,129 bytes(67.3%) 감소
- Playwright 17개 E2E 통과, 21.9 - Playwright 17개 E2E 통과, 24.5
- build 실패는 없지만 2D main bundle, Three.js, web-ifc와 decoder JS에 - build 실패는 없지만 2D main bundle, Three.js, web-ifc와 decoder JS에
500 kB 초과 chunk 경고가 있음 500 kB 초과 chunk 경고가 있음
@@ -514,7 +518,7 @@ npm run verify
- 2D DWG/DXF sample과 local file load - 2D DWG/DXF sample과 local file load
- 3D GLB, Draco GLB, OBJ, FBX, DAE, IFC, PLY sample render - 3D GLB, Draco GLB, OBJ, FBX, DAE, IFC, PLY sample render
- 2D/3D canvas dispose와 반복 page transition - 2D/3D canvas dispose와 반복 page transition
- production subpath의 Draco·IFC decoder request - production subpath의 Draco·Basis KTX2·IFC decoder request와 실제 decode
- 2D DWG/DXF와 3D GLB/PLY evidence screenshot - 2D DWG/DXF와 3D GLB/PLY evidence screenshot
이 검증은 sample이 화면에 나타나고 runtime error가 없다는 smoke/contract 수준입니다. 이 검증은 sample이 화면에 나타나고 runtime error가 없다는 smoke/contract 수준입니다.
+31
View File
@@ -33,6 +33,37 @@ repository에 남아 있지 않습니다. 이 저장소는 해당 binary를 stan
위 checksum은 Google Fonts의 현재 `NanumGothic-Regular.ttf`와 동일함을 위 checksum은 Google Fonts의 현재 `NanumGothic-Regular.ttf`와 동일함을
2026-07-29에 확인했습니다. 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 ## Runtime libraries
- Three.js — MIT, <https://github.com/mrdoob/three.js> - Three.js — MIT, <https://github.com/mrdoob/three.js>
+3 -2
View File
@@ -48,9 +48,10 @@ src/
progress.ts # load progress bar progress.ts # load progress bar
scenes/ # per-model scene configs scenes/ # per-model scene configs
public/ public/
draco/ # decoder WASM (if bundled) web-ifc/ # single-thread IFC decoder WASM
basis/ # KTX2 decoder WASM (if bundled) samples/ # 배포 demo model
previews/ # pre-rendered WebP/WebM placeholders previews/ # pre-rendered WebP/WebM placeholders
dist/assets/ # Vite hashed Draco/Basis decoder assets
tools/ tools/
preprocess.mjs # gltf-transform Draco+KTX2 pipeline preprocess.mjs # gltf-transform Draco+KTX2 pipeline
prerender/ # Blender/Puppeteer turntable -> WebP prerender/ # Blender/Puppeteer turntable -> WebP
+3 -3
View File
@@ -4,12 +4,12 @@
## Current state ## 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). - **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:** Phase 6+7 via dynamic workflow `multiformat-viewer` (7 agents) — multi-format dispatch (src/viewer/modelLoader.ts), web-ifc single-thread IFC, FPS overlay + adaptive ladder. Samples+previews per format. - **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를 사용합니다. - **Blockers:** none. KTX2 *production* encoding still needs KTX-Software/toktx (runtime decode verified). web-ifc multi-thread는 COOP/COEP가 필요해 배포에서 제외하고 single-thread를 사용합니다.
- **Next:** optional in-browser per-format smoke (/verify-viewer .ifc/.fbx); OBJ/DAE geometry-only on blob: URL (accepted); document.hidden FPS guard. - **Next:** OBJ/DAE geometry-only on blob: URL (accepted); document.hidden FPS guard.
## Work log ## 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 제거. artifact gate 추가. 3D dist 24,945,531→8,149,402 bytes(-67.3%), duplicate 0. Vitest 8, typecheck/build, Playwright E2E 17개 통과. - 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-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 — 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 — 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.
+5 -4
View File
@@ -96,7 +96,7 @@
<h2>3. 기술 스택 (확정)</h2> <h2>3. 기술 스택 (확정)</h2>
<table> <table>
<tr><th>영역</th><th>선택</th><th>비고</th></tr> <tr><th>영역</th><th>선택</th><th>비고</th></tr>
<tr><td>렌더러</td><td>three.js WebGLRenderer (r0.169)</td><td>WebGPU는 차후 옵션</td></tr> <tr><td>렌더러</td><td>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>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>로더(확장)</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>로더(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>
@@ -123,8 +123,9 @@
fps.ts # ★ 화면 FPS 오버레이 (EMA, 색상코딩) fps.ts # ★ 화면 FPS 오버레이 (EMA, 색상코딩)
public/ public/
web-ifc/ # ★ web-ifc.wasm (단일스레드, 자동 복사) web-ifc/ # ★ web-ifc.wasm (단일스레드, 자동 복사)
samples/ # Box/Duck/Avocado/Duck.optimized + Cube.obj/dae/fbx/ifc samples/ # 배포 demo: Box/Duck/Avocado/Duck.optimized + Cube.obj/dae/fbx/ifc
previews/ # Duck.webp + Cube.{obj,dae,fbx,ifc}.webp (24프레임 360°) previews/ # Duck.webp + Cube.{obj,dae,fbx,ifc}.webp (24프레임 360°)
dist/assets/ # ★ Vite hashed Draco/Basis decoder JS/WASM
tools/ tools/
preprocess.mjs # gltf-transform Draco+WebP 압축 (GLB 전용) preprocess.mjs # gltf-transform Draco+WebP 압축 (GLB 전용)
copy-decoders.mjs # postinstall: single-thread web-ifc WASM 복사 copy-decoders.mjs # postinstall: single-thread web-ifc WASM 복사
@@ -185,11 +186,11 @@ tools/
<h2>8. 현재 구현 상태</h2> <h2>8. 현재 구현 상태</h2>
<div class="card"> <div class="card">
<ul> <ul>
<li><strong>빌드:</strong> <code>tsc --noEmit</code> 클린, <code>npm run build</code> 통과 (vite 5.4.21, 11.92s). 청크: 앱 138KB(gz 47) · three vendor 533KB(gz 136) · web-ifc 3.5MB(gz 398, <em>지연 로드</em>) · OBJ 8.8 / Collada 41 / FBX 48KB.</li> <li><strong>빌드:</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>포맷별 인브라우저 로드(헤드리스 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> 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>샘플:</strong> Box/Duck/Avocado/Duck.optimized(GLB) + Cube.obj(793B)/Cube.dae(2.1K)/Cube.fbx(16.2K)/Cube.ifc(2.3K). 각 24프레임 360° WebP 프리뷰.</li>
<li><strong>KTX2 런타임 경로:</strong> KTX2Loader+DRACOLoader 동시 디코드 확인(ABeautifulGame, 626ms). 인코딩은 toktx 필요(미설치).</li> <li><strong>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> <li><strong>하드닝:</strong> WebGL 미지원 가드, 로드 실패 메시지(<code>#status</code>), DnD 검증 피드백, IFC wasm 메모리 해제 — reviewer 0 critical.</li>
</ul> </ul>
</div> </div>
+7
View File
@@ -25,6 +25,13 @@
원출처가 확인되지 않은 fixture는 내부 회귀 test에만 사용합니다. 공개 배포 전에 원출처가 확인되지 않은 fixture는 내부 회귀 test에만 사용합니다. 공개 배포 전에
소유권이 확인된 fixture로 교체하거나 명시적 재배포 승인을 기록해야 합니다. 소유권이 확인된 fixture로 교체하거나 명시적 재배포 승인을 기록해야 합니다.
## 3D test fixture
| Fixture | SHA-256 | 상태 |
|---|---|---|
| `BasisTexture.ktx2` | `e56ddcc757fc73ff06bb0dac2a3533ce79c1e196ad895a3ff7dcc4d9de6b9d5d` | Three.js commit `42f66a1620be72e497a0bb3bdc7663d21d43169a``examples/textures/ktx2/2d_etc1s.ktx2`, MIT. E2E에서만 제공하며 production artifact에는 미포함 |
| `BasisTexture.gltf` | repository text fixture | 위 KTX2를 적용한 최소 triangle을 이 저장소에서 작성. E2E 전용 |
## Policy ## Policy
- source와 binary는 exact commit 또는 SHA-256으로 고정합니다. - source와 binary는 exact commit 또는 SHA-256으로 고정합니다.
+36 -3
View File
@@ -1,16 +1,44 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { readFileSync } from 'node:fs';
test('viewer3d resolves hashed Draco and staged IFC decoders under a production subpath', async ({ const basisFixture = readFileSync(
new URL('../fixtures/viewer3d/BasisTexture.gltf', import.meta.url),
);
const basisTexture = readFileSync(
new URL('../fixtures/viewer3d/BasisTexture.ktx2', import.meta.url),
);
test('viewer3d resolves bundled model decoders under a production subpath', async ({
page, page,
}) => { }) => {
const failedRequests: string[] = []; const failedRequests: string[] = [];
const responseUrls: string[] = []; const responseUrls: string[] = [];
page.on('requestfailed', (request) => failedRequests.push(request.url())); page.on('requestfailed', (request) => failedRequests.push(request.url()));
page.on('response', (response) => responseUrls.push(response.url())); page.on('response', (response) => responseUrls.push(response.url()));
await page.route(
/^https?:\/\/[^/?]+\/viewer-3d\/e2e-fixtures\/BasisTexture\.gltf(?:\?.*)?$/,
(route) =>
route.fulfill({
body: basisFixture,
contentType: 'model/gltf+json',
}),
);
await page.route(
/^https?:\/\/[^/?]+\/viewer-3d\/e2e-fixtures\/BasisTexture\.ktx2(?:\?.*)?$/,
(route) =>
route.fulfill({
body: basisTexture,
contentType: 'image/ktx2',
}),
);
for (const fixture of ['Duck.optimized.glb', 'Cube.ifc']) { for (const fixture of [
'samples/Duck.optimized.glb',
'e2e-fixtures/BasisTexture.gltf',
'samples/Cube.ifc',
]) {
await page.goto( await page.goto(
`http://127.0.0.1:43174/viewer-3d/?model=/viewer-3d/samples/${fixture}`, `http://127.0.0.1:43174/viewer-3d/?model=/viewer-3d/${fixture}`,
); );
await page.waitForFunction( await page.waitForFunction(
() => () =>
@@ -31,6 +59,11 @@ test('viewer3d resolves hashed Draco and staged IFC decoders under a production
/\/viewer-3d\/assets\/draco_decoder-.+\.wasm$/.test(new URL(url).pathname), /\/viewer-3d\/assets\/draco_decoder-.+\.wasm$/.test(new URL(url).pathname),
), ),
).toBe(true); ).toBe(true);
expect(
responseUrls.some((url) =>
/\/viewer-3d\/assets\/basis_transcoder-.+\.wasm$/.test(new URL(url).pathname),
),
).toBe(true);
expect(responseUrls.some((url) => url.endsWith('/viewer-3d/web-ifc/web-ifc.wasm'))).toBe(true); expect(responseUrls.some((url) => url.endsWith('/viewer-3d/web-ifc/web-ifc.wasm'))).toBe(true);
expect(failedRequests).toEqual([]); expect(failedRequests).toEqual([]);
}); });
+107
View File
@@ -0,0 +1,107 @@
{
"asset": {
"version": "2.0",
"generator": "hmwebviewer KTX2 E2E fixture"
},
"extensionsUsed": ["KHR_texture_basisu"],
"extensionsRequired": ["KHR_texture_basisu"],
"scene": 0,
"scenes": [
{
"nodes": [0]
}
],
"nodes": [
{
"mesh": 0
}
],
"meshes": [
{
"primitives": [
{
"attributes": {
"POSITION": 0,
"TEXCOORD_0": 1
},
"indices": 2,
"material": 0
}
]
}
],
"materials": [
{
"pbrMetallicRoughness": {
"baseColorTexture": {
"index": 0
},
"metallicFactor": 0,
"roughnessFactor": 1
}
}
],
"textures": [
{
"extensions": {
"KHR_texture_basisu": {
"source": 0
}
}
}
],
"images": [
{
"uri": "BasisTexture.ktx2",
"mimeType": "image/ktx2"
}
],
"buffers": [
{
"uri": "data:application/octet-stream;base64,AACAvwAAgL8AAAAAAACAPwAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAD8AAIA/AAABAAIA",
"byteLength": 66
}
],
"bufferViews": [
{
"buffer": 0,
"byteOffset": 0,
"byteLength": 36,
"target": 34962
},
{
"buffer": 0,
"byteOffset": 36,
"byteLength": 24,
"target": 34962
},
{
"buffer": 0,
"byteOffset": 60,
"byteLength": 6,
"target": 34963
}
],
"accessors": [
{
"bufferView": 0,
"componentType": 5126,
"count": 3,
"type": "VEC3",
"min": [-1, -1, 0],
"max": [1, 1, 0]
},
{
"bufferView": 1,
"componentType": 5126,
"count": 3,
"type": "VEC2"
},
{
"bufferView": 2,
"componentType": 5123,
"count": 3,
"type": "SCALAR"
}
]
}
Binary file not shown.
+70 -35
View File
@@ -4,8 +4,24 @@ import { dirname, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const distRoot = resolve(projectRoot, 'apps/viewer-3d/dist'); const distSpecs = [
const maxDistBytes = 9_000_000; {
name: '2D',
root: resolve(projectRoot, 'apps/viewer-2d-sample/dist'),
maxBytes: 4_500_000,
required: [/^assets\/acadrust_dwg_bg-.+\.wasm$/],
},
{
name: '3D',
root: resolve(projectRoot, 'apps/viewer-3d/dist'),
maxBytes: 9_000_000,
required: [
/^assets\/draco_decoder-.+\.wasm$/,
/^assets\/basis_transcoder-.+\.wasm$/,
/^web-ifc\/web-ifc\.wasm$/,
],
},
];
function listFiles(directory) { function listFiles(directory) {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
@@ -14,55 +30,69 @@ function listFiles(directory) {
}); });
} }
function toRelative(path) { function toRelative(root, path) {
return relative(distRoot, path).split(sep).join('/'); return relative(root, path).split(sep).join('/');
} }
if (!existsSync(distRoot)) {
throw new Error('3D production dist가 없습니다. 먼저 npm run build를 실행하세요.');
}
const files = listFiles(distRoot);
const relativeFiles = files.map(toRelative);
const totalBytes = files.reduce((sum, path) => sum + statSync(path).size, 0);
const failures = []; const failures = [];
const results = [];
for (const path of relativeFiles) { for (const spec of distSpecs) {
if (path.endsWith('.map')) failures.push(`production source map 포함: ${path}`); if (!existsSync(spec.root)) {
if (path === 'web-ifc/web-ifc-mt.wasm') { failures.push(`${spec.name} production dist가 없습니다. 먼저 npm run build를 실행하세요.`);
continue;
}
const files = listFiles(spec.root);
const relativeFiles = files.map((path) => toRelative(spec.root, path));
const totalBytes = files.reduce((sum, path) => sum + statSync(path).size, 0);
for (const path of relativeFiles) {
if (path.endsWith('.map')) {
failures.push(`${spec.name} production source map 포함: ${path}`);
}
if (spec.name === '3D' && path === 'web-ifc/web-ifc-mt.wasm') {
failures.push(`미사용 multi-thread IFC WASM 포함: ${path}`); failures.push(`미사용 multi-thread IFC WASM 포함: ${path}`);
} }
if (path.startsWith('draco/') || path.startsWith('basis/')) { if (
spec.name === '3D' &&
(path.startsWith('draco/') || path.startsWith('basis/'))
) {
failures.push(`Three.js bundled decoder의 public 중복 포함: ${path}`); failures.push(`Three.js bundled decoder의 public 중복 포함: ${path}`);
} }
}
for (const pattern of [
/^assets\/draco_decoder-.+\.wasm$/,
/^assets\/basis_transcoder-.+\.wasm$/,
/^web-ifc\/web-ifc\.wasm$/,
]) {
if (!relativeFiles.some((path) => pattern.test(path))) {
failures.push(`필수 runtime artifact 누락: ${pattern}`);
} }
}
const hashes = new Map(); for (const pattern of spec.required) {
for (const path of files) { if (!relativeFiles.some((path) => pattern.test(path))) {
failures.push(`${spec.name} 필수 runtime artifact 누락: ${pattern}`);
}
}
const hashes = new Map();
for (const path of files) {
if (statSync(path).size < 50_000) continue; if (statSync(path).size < 50_000) continue;
const hash = createHash('sha256').update(readFileSync(path)).digest('hex'); const hash = createHash('sha256').update(readFileSync(path)).digest('hex');
const paths = hashes.get(hash) ?? []; const paths = hashes.get(hash) ?? [];
paths.push(toRelative(path)); paths.push(toRelative(spec.root, path));
hashes.set(hash, paths); hashes.set(hash, paths);
} }
for (const paths of hashes.values()) { const duplicates = [...hashes.values()].filter((paths) => paths.length > 1);
if (paths.length > 1) failures.push(`50 kB 이상 중복 artifact: ${paths.join(', ')}`); for (const paths of duplicates) {
} failures.push(`${spec.name} 50 kB 이상 중복 artifact: ${paths.join(', ')}`);
}
if (totalBytes > maxDistBytes) { if (totalBytes > spec.maxBytes) {
failures.push( failures.push(
`3D production dist budget 초과: ${totalBytes} bytes > ${maxDistBytes} bytes`, `${spec.name} production dist budget 초과: ${totalBytes} bytes > ${spec.maxBytes} bytes`,
); );
}
results.push({
name: spec.name,
files: relativeFiles.length,
totalBytes,
duplicates: duplicates.length,
});
} }
if (failures.length > 0) { if (failures.length > 0) {
@@ -70,6 +100,11 @@ if (failures.length > 0) {
process.exitCode = 1; process.exitCode = 1;
} else { } else {
console.log( console.log(
`[artifact-check] ${relativeFiles.length} files, ${totalBytes} bytes, duplicate 0`, results
.map(
({ name, files, totalBytes, duplicates }) =>
`[artifact-check] ${name}: ${files} files, ${totalBytes} bytes, duplicate ${duplicates}`,
)
.join('\n'),
); );
} }