diff --git a/README.md b/README.md index 917ca1e..a306f01 100644 --- a/README.md +++ b/README.md @@ -392,9 +392,12 @@ path와 host의 `node_modules`를 표준 build 입력으로 사용하지 않습 문법과 repository-relative path로 작성합니다. - development, production build, E2E, optional asset processing은 서로 다른 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를 - 우선합니다. 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를 publish할 때만 container 전용 설정으로 `0.0.0.0`에 bind하고 공개 network에 노출하지 않습니다. @@ -407,13 +410,12 @@ path와 host의 `node_modules`를 표준 build 입력으로 사용하지 않습 ## Linux 컨테이너 전환 검증 -2026-07-29 기준 installed dependency 상태에서 typecheck, unit test 8개와 production -build는 Linux/WSL2 x86_64에서 다시 통과했습니다. 기존 검증 기록의 Playwright E2E -16개 통과 결과는 있으나 이번 문서 변경에 대한 E2E 재실행은 요청에 따라 뒤로 -미뤘습니다. tracked text file은 LF이며 lockfile에는 Linux glibc와 musl용 native -optional package가 포함되어 있습니다. 따라서 application code를 Windows 전용 -구현에서 이식하는 작업은 거의 끝났지만, clean `npm ci`부터 시작하는 재현 가능한 -container 개발 환경의 정의와 현재 E2E 재검증은 아직 완료되지 않았습니다. +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 전환 판단 | |---|---|---| @@ -497,9 +499,11 @@ npm run verify - workspace typecheck 통과 - Vitest 5개 file, 8개 test 통과 - 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%) 감소 -- Playwright 17개 E2E 통과, 21.9초 +- Playwright 17개 E2E 통과, 24.5초 - build 실패는 없지만 2D main bundle, Three.js, web-ifc와 decoder JS에 500 kB 초과 chunk 경고가 있음 @@ -514,7 +518,7 @@ npm run verify - 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·IFC decoder request +- production subpath의 Draco·Basis KTX2·IFC decoder request와 실제 decode - 2D DWG/DXF와 3D GLB/PLY evidence screenshot 이 검증은 sample이 화면에 나타나고 runtime error가 없다는 smoke/contract 수준입니다. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 672913a..4bc8a20 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -33,6 +33,37 @@ repository에 남아 있지 않습니다. 이 저장소는 해당 binary를 stan 위 checksum은 Google Fonts의 현재 `NanumGothic-Regular.ttf`와 동일함을 2026-07-29에 확인했습니다. +## Draco decoder + +- 역할: glTF geometry decompression +- upstream: +- license: Apache License 2.0 +- license text: +- distribution: 설치된 Three.js의 `DRACO_GLTF_CONFIG` import가 production build에 + version 일치 hashed JavaScript/WASM asset으로 포함합니다. + +## Basis Universal transcoder + +- 역할: KTX2/Basis Universal texture transcoding +- upstream: +- license: Apache License 2.0 +- license text: +- distribution: 설치된 Three.js의 `KTX2Loader` import가 production build에 + version 일치 hashed JavaScript/WASM asset으로 포함합니다. + +## KTX2 E2E fixture + +- 역할: production subpath에서 Basis Universal transcoder 실제 실행 검증 +- upstream: + +- license: MIT, + +- local path: `tests/fixtures/viewer3d/BasisTexture.ktx2` +- SHA-256: + `e56ddcc757fc73ff06bb0dac2a3533ce79c1e196ad895a3ff7dcc4d9de6b9d5d` +- distribution: E2E가 same-origin request로만 제공하며 production artifact에는 + 포함하지 않습니다. + ## Runtime libraries - Three.js — MIT, diff --git a/apps/viewer-3d/CLAUDE.md b/apps/viewer-3d/CLAUDE.md index 041491e..2cc6151 100644 --- a/apps/viewer-3d/CLAUDE.md +++ b/apps/viewer-3d/CLAUDE.md @@ -48,9 +48,10 @@ src/ progress.ts # load progress bar scenes/ # per-model scene configs public/ - draco/ # decoder WASM (if bundled) - basis/ # KTX2 decoder WASM (if bundled) + 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 diff --git a/apps/viewer-3d/PROGRESS.md b/apps/viewer-3d/PROGRESS.md index d2bb01d..5411c1e 100644 --- a/apps/viewer-3d/PROGRESS.md +++ b/apps/viewer-3d/PROGRESS.md @@ -4,12 +4,12 @@ ## Current state - **Phase:** ALL PLAN tasks complete (P0–P7). GLB + OBJ/FBX/DAE/IFC loaders (SSR+CSR) + on-screen FPS + adaptive quality (<60fps→pixelRatio degrade). Build verified (tsc 0, vite exit 0). -- **Last action:** Phase 6+7 via dynamic workflow `multiformat-viewer` (7 agents) — multi-format dispatch (src/viewer/modelLoader.ts), web-ifc single-thread IFC, FPS overlay + adaptive ladder. Samples+previews per format. +- **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:** 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 -- 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-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/.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. diff --git a/apps/viewer-3d/docs/architecture.html b/apps/viewer-3d/docs/architecture.html index de0d014..27b34e5 100644 --- a/apps/viewer-3d/docs/architecture.html +++ b/apps/viewer-3d/docs/architecture.html @@ -96,7 +96,7 @@

3. 기술 스택 (확정)

- + @@ -123,8 +123,9 @@ fps.ts # ★ 화면 FPS 오버레이 (EMA, 색상코딩) public/ 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°) +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 복사 @@ -185,11 +186,11 @@ tools/

8. 현재 구현 상태

    -
  • 빌드: tsc --noEmit 클린, npm run build 통과 (vite 5.4.21, 11.92s). 청크: 앱 138KB(gz 47) · three vendor 533KB(gz 136) · web-ifc 3.5MB(gz 398, 지연 로드) · OBJ 8.8 / Collada 41 / FBX 48KB.
  • +
  • 빌드: tsc --noEmit 클린, Vite 8 production build 통과. 3D artifact 27개, 8,149,402 bytes, 50KB 이상 중복 0개.
  • 포맷별 인브라우저 로드(헤드리스 Chrome): GLB 229ms · OBJ 173ms · FBX 158ms · DAE 178ms · IFC 463ms · Duck.optimized 182ms — 전체 <3000ms PASS.
  • 적응형 품질: 60fps 미만 지속 시 pixelRatio 3단계 강등 실측 확인(위 7절).
  • 샘플: Box/Duck/Avocado/Duck.optimized(GLB) + Cube.obj(793B)/Cube.dae(2.1K)/Cube.fbx(16.2K)/Cube.ifc(2.3K). 각 24프레임 360° WebP 프리뷰.
  • -
  • KTX2 런타임 경로: KTX2Loader+DRACOLoader 동시 디코드 확인(ABeautifulGame, 626ms). 인코딩은 toktx 필요(미설치).
  • +
  • KTX2 런타임 경로: official Three.js ETC1S KTX2 fixture를 production subpath에서 로드하고 hashed Basis transcoder WASM request와 model ready를 E2E로 확인. 인코딩은 toktx 필요(미설치).
  • 하드닝: WebGL 미지원 가드, 로드 실패 메시지(#status), DnD 검증 피드백, IFC wasm 메모리 해제 — reviewer 0 critical.
diff --git a/docs/PROVENANCE.md b/docs/PROVENANCE.md index 5d8b0db..b37f2cf 100644 --- a/docs/PROVENANCE.md +++ b/docs/PROVENANCE.md @@ -25,6 +25,13 @@ 원출처가 확인되지 않은 fixture는 내부 회귀 test에만 사용합니다. 공개 배포 전에 소유권이 확인된 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 - source와 binary는 exact commit 또는 SHA-256으로 고정합니다. diff --git a/tests/e2e/viewer3d.subpath.spec.ts b/tests/e2e/viewer3d.subpath.spec.ts index cb721bc..d9a9613 100644 --- a/tests/e2e/viewer3d.subpath.spec.ts +++ b/tests/e2e/viewer3d.subpath.spec.ts @@ -1,16 +1,44 @@ 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, }) => { const failedRequests: string[] = []; const responseUrls: string[] = []; page.on('requestfailed', (request) => failedRequests.push(request.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( - `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( () => @@ -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), ), ).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(failedRequests).toEqual([]); }); diff --git a/tests/fixtures/viewer3d/BasisTexture.gltf b/tests/fixtures/viewer3d/BasisTexture.gltf new file mode 100644 index 0000000..0abe339 --- /dev/null +++ b/tests/fixtures/viewer3d/BasisTexture.gltf @@ -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" + } + ] +} diff --git a/tests/fixtures/viewer3d/BasisTexture.ktx2 b/tests/fixtures/viewer3d/BasisTexture.ktx2 new file mode 100644 index 0000000..d6c1b00 Binary files /dev/null and b/tests/fixtures/viewer3d/BasisTexture.ktx2 differ diff --git a/tools/check-production-artifacts.mjs b/tools/check-production-artifacts.mjs index 5bad8a3..0bfd342 100644 --- a/tools/check-production-artifacts.mjs +++ b/tools/check-production-artifacts.mjs @@ -4,8 +4,24 @@ import { dirname, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const distRoot = resolve(projectRoot, 'apps/viewer-3d/dist'); -const maxDistBytes = 9_000_000; +const distSpecs = [ + { + 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) { return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { @@ -14,55 +30,69 @@ function listFiles(directory) { }); } -function toRelative(path) { - return relative(distRoot, path).split(sep).join('/'); +function toRelative(root, path) { + 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 results = []; -for (const path of relativeFiles) { - if (path.endsWith('.map')) failures.push(`production source map 포함: ${path}`); - if (path === 'web-ifc/web-ifc-mt.wasm') { - failures.push(`미사용 multi-thread IFC WASM 포함: ${path}`); +for (const spec of distSpecs) { + if (!existsSync(spec.root)) { + failures.push(`${spec.name} production dist가 없습니다. 먼저 npm run build를 실행하세요.`); + continue; } - if (path.startsWith('draco/') || path.startsWith('basis/')) { - failures.push(`Three.js bundled decoder의 public 중복 포함: ${path}`); + + 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}`); + } + if ( + spec.name === '3D' && + (path.startsWith('draco/') || path.startsWith('basis/')) + ) { + 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}`); + for (const pattern of spec.required) { + 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; - const hash = createHash('sha256').update(readFileSync(path)).digest('hex'); - const paths = hashes.get(hash) ?? []; - paths.push(toRelative(path)); - hashes.set(hash, paths); -} -for (const paths of hashes.values()) { - if (paths.length > 1) failures.push(`50 kB 이상 중복 artifact: ${paths.join(', ')}`); -} + const hashes = new Map(); + for (const path of files) { + if (statSync(path).size < 50_000) continue; + const hash = createHash('sha256').update(readFileSync(path)).digest('hex'); + const paths = hashes.get(hash) ?? []; + paths.push(toRelative(spec.root, path)); + hashes.set(hash, paths); + } + const duplicates = [...hashes.values()].filter((paths) => paths.length > 1); + for (const paths of duplicates) { + failures.push(`${spec.name} 50 kB 이상 중복 artifact: ${paths.join(', ')}`); + } -if (totalBytes > maxDistBytes) { - failures.push( - `3D production dist budget 초과: ${totalBytes} bytes > ${maxDistBytes} bytes`, - ); + if (totalBytes > spec.maxBytes) { + failures.push( + `${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) { @@ -70,6 +100,11 @@ if (failures.length > 0) { process.exitCode = 1; } else { 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'), ); }
영역선택비고
렌더러three.js WebGLRenderer (r0.169)WebGPU는 차후 옵션
렌더러three.js WebGLRenderer (r185)WebGPU는 차후 옵션
로더(기준)GLTFLoader + DRACOLoader + KTX2Loader각 1개 싱글톤 — 다중 인스턴스 크래시(#22445)
로더(확장)OBJ / FBX / Collada — three example loaders지연 import() → 별도 Vite 청크(초기 번들 미증가)
로더(IFC)web-ifc IfcAPI (v0.0.77)단일스레드 web-ifc.wasm 자가호스팅(/web-ifc/), SetWasmPath(...,true)