fix: resolve workspace integration blockers (#5)
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
<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 {
|
||||
@@ -90,7 +91,7 @@
|
||||
</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>
|
||||
지도: <a href="./docs/modules-dwg-dxf.html" target="_blank">docs/modules-dwg-dxf.html</a>
|
||||
</p>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5173 --strictPort",
|
||||
"dev": "vite --host 127.0.0.1 --port 5173 --strictPort",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,11 @@ import {
|
||||
parseCad,
|
||||
type CadParseResult,
|
||||
} from '@hmwebviewer/viewer2d';
|
||||
import {
|
||||
assertCadByteLength,
|
||||
fetchCadBuffer,
|
||||
resolveCadUrl,
|
||||
} from './cadInputPolicy';
|
||||
|
||||
// parseDxfBuffer 내부:
|
||||
// dxf-parser → HatchHandler → dxfAdapter
|
||||
@@ -50,6 +55,12 @@ function setStatus(msg: string, err = false) {
|
||||
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');
|
||||
@@ -68,14 +79,14 @@ async function loadAndRender(buf: ArrayBuffer, name: string): Promise<CadParseRe
|
||||
}
|
||||
|
||||
async function loadUrl(url: string, nameHint?: string) {
|
||||
const name = nameHint ?? url.split('?')[0]!.split('/').pop() ?? url;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return loadAndRender(await res.arrayBuffer(), name);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -154,7 +165,22 @@ window.addEventListener('drop', (e) => {
|
||||
});
|
||||
|
||||
const model = new URLSearchParams(location.search).get('model');
|
||||
if (model) void run(model, () => loadUrl(model));
|
||||
else setStatus('ready · Sample DWG / DXF 또는 파일 드롭');
|
||||
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('resize', () => viewer.resize());
|
||||
window.addEventListener(
|
||||
'pagehide',
|
||||
() => {
|
||||
viewer.dispose();
|
||||
delete window.__viewer2d;
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<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>
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# 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
|
||||
Executable → Regular
Executable → Regular
@@ -1,6 +1,7 @@
|
||||
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
|
||||
@@ -62,11 +63,21 @@ if (modelUrl) {
|
||||
// SSR placeholder: a pre-rendered 360° animated WebP (tools/prerender.mjs)
|
||||
// shows instantly, then the hydration gate fades it out once WebGL + model ready.
|
||||
const base = modelUrl.split('/').pop()!.replace(/\.(glb|gltf|obj|fbx|dae|ifc|ply)$/i, '');
|
||||
previewEl.src = '/previews/' + base + '.webp';
|
||||
previewEl.src = publicAssetUrl(`previews/${base}.webp`);
|
||||
previewEl.classList.remove('hidden');
|
||||
previewEl.onerror = () => previewEl.classList.add('hidden');
|
||||
viewer.loadServerAsset(modelUrl);
|
||||
}
|
||||
|
||||
// Exposed for tooling (tools/prerender.mjs captures rotating frames).
|
||||
(window as unknown as { __viewer?: ThreeDViewer }).__viewer = viewer;
|
||||
// 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 },
|
||||
);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
import { publicAssetUrl } from '../runtimeBase';
|
||||
|
||||
export interface ViewerLoaders {
|
||||
gltf: GLTFLoader;
|
||||
@@ -16,8 +17,10 @@ let _cache: ViewerLoaders | null = null;
|
||||
|
||||
export function getLoaders(renderer: THREE.WebGLRenderer): ViewerLoaders {
|
||||
if (_cache) return _cache;
|
||||
const draco = new DRACOLoader().setDecoderPath('/draco/');
|
||||
const ktx2 = new KTX2Loader().setTranscoderPath('/basis/').detectSupport(renderer);
|
||||
const draco = new DRACOLoader().setDecoderPath(publicAssetUrl('draco/'));
|
||||
const ktx2 = new KTX2Loader()
|
||||
.setTranscoderPath(publicAssetUrl('basis/'))
|
||||
.detectSupport(renderer);
|
||||
const gltf = new GLTFLoader().setDRACOLoader(draco).setKTX2Loader(ktx2);
|
||||
_cache = { gltf, draco, ktx2 };
|
||||
return _cache;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as THREE from 'three';
|
||||
import { publicAssetUrl } from '../runtimeBase';
|
||||
import { getLoaders } from './loaders';
|
||||
|
||||
/**
|
||||
@@ -244,7 +245,7 @@ async function loadIfc(url: string, onProgress?: ProgressCb): Promise<THREE.Obje
|
||||
const WebIFC = await import('web-ifc');
|
||||
if (!_ifcApi) {
|
||||
_ifcApi = new WebIFC.IfcAPI();
|
||||
_ifcApi.SetWasmPath('/web-ifc/', true);
|
||||
_ifcApi.SetWasmPath(publicAssetUrl('web-ifc/'), true);
|
||||
await _ifcApi.Init(undefined, true);
|
||||
}
|
||||
const api = _ifcApi;
|
||||
|
||||
@@ -2,13 +2,30 @@
|
||||
// runtime paths /draco/ and /basis/ resolve (version-matched to installed three).
|
||||
// Also copies the web-ifc single-thread WASM to public/web-ifc/ for the IFC path.
|
||||
// Runs automatically via `npm install` (postinstall). Safe to re-run.
|
||||
import { cpSync, copyFileSync, existsSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import {
|
||||
chmodSync,
|
||||
cpSync,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
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, '..');
|
||||
|
||||
function normalizePublicModes(directory) {
|
||||
chmodSync(directory, 0o755);
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
const path = resolve(directory, entry.name);
|
||||
if (entry.isDirectory()) normalizePublicModes(path);
|
||||
else if (entry.isFile()) chmodSync(path, 0o644);
|
||||
}
|
||||
}
|
||||
|
||||
const threeEntry = fileURLToPath(import.meta.resolve('three'));
|
||||
const threeRoot = resolve(dirname(threeEntry), '..');
|
||||
const libs = resolve(threeRoot, 'examples/jsm/libs');
|
||||
@@ -30,6 +47,7 @@ for (const [src, dest] of targets) {
|
||||
rmSync(dest, { force: true, recursive: true });
|
||||
mkdirSync(dest, { recursive: true });
|
||||
cpSync(src, dest, { recursive: true });
|
||||
normalizePublicModes(dest);
|
||||
console.log(`[copy-decoders] ${src} -> ${dest}`);
|
||||
}
|
||||
|
||||
@@ -42,8 +60,10 @@ if (existsSync(ifcSrcDir)) {
|
||||
for (const wasm of ['web-ifc.wasm', 'web-ifc-mt.wasm']) {
|
||||
const src = resolve(ifcSrcDir, wasm);
|
||||
if (!existsSync(src)) continue;
|
||||
copyFileSync(src, resolve(ifcDest, wasm));
|
||||
console.log(`[copy-decoders] ${src} -> ${resolve(ifcDest, wasm)}`);
|
||||
const dest = resolve(ifcDest, 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.');
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"types": ["three"]
|
||||
"types": ["three", "vite/client"]
|
||||
},
|
||||
"include": ["src", "tools"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user