Viewer2D (ported unchanged) has no dispose/teardown API — it attaches permanent window listeners and starts its own rAF render loop in the constructor. React StrictMode's dev-only mount→unmount→remount cycle was constructing it twice into the same container with no way to tear the first instance down, leaving two overlapping canvases and broken rendering (load reported success but nothing appeared on screen). Also defensively clear the container's children before construction so a future remount (e.g. Fast Refresh) can't repeat this. Refs #1
343 lines
11 KiB
TypeScript
343 lines
11 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { Viewer2D } from '../viewer2d/Viewer2D';
|
|
import { parseDwgBuffer } from '../viewer2d/dwgParser';
|
|
import { parseDxfBuffer } from '../viewer2d/parseDxf';
|
|
import { isDwg, isDxf, is2D } from '../viewer2d/ext2d';
|
|
import { listCadSpaces, pickDefaultSpace, type CadSpace } from '../viewer2d/cadSpaces';
|
|
import { setDefaultFontUrl } from '../viewer2d/slugText';
|
|
import { getAllOptions, setOption } from '../lib/optionsStore';
|
|
|
|
setDefaultFontUrl('/fonts/NanumGothic-Regular.ttf');
|
|
|
|
export interface LayerRow {
|
|
name: string;
|
|
colorHex: string;
|
|
count: number;
|
|
visible: boolean;
|
|
}
|
|
|
|
export type LoadStatus = 'idle' | 'loading' | 'ready' | 'error';
|
|
|
|
export interface PersistedOptions {
|
|
grid: boolean;
|
|
zoomSpeed: number;
|
|
zScale: number;
|
|
lineweightOn: boolean | null;
|
|
lineweightScale: number | null;
|
|
}
|
|
|
|
const DEFAULT_OPTIONS: PersistedOptions = {
|
|
grid: false,
|
|
zoomSpeed: 1,
|
|
zScale: 1,
|
|
lineweightOn: null,
|
|
lineweightScale: null,
|
|
};
|
|
|
|
async function parseByName(name: string, buf: ArrayBuffer) {
|
|
if (isDwg(name)) return parseDwgBuffer(new Uint8Array(buf));
|
|
if (isDxf(name)) return parseDxfBuffer(buf);
|
|
if (!is2D(name)) throw new Error('지원하지 않는 파일 형식입니다 (.dwg/.dxf만 지원)');
|
|
return parseDxfBuffer(buf);
|
|
}
|
|
|
|
export function useDwgViewer() {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const viewerRef = useRef<Viewer2D | null>(null);
|
|
|
|
const [status, setStatus] = useState<LoadStatus>('idle');
|
|
const [statusMessage, setStatusMessage] = useState('준비됨');
|
|
const [loadingProgress, setLoadingProgress] = useState<number | null>(null);
|
|
|
|
const [selectedEntity, setSelectedEntity] = useState<unknown>(null);
|
|
const [pointerWorld, setPointerWorld] = useState<{ x: number; y: number; z: number } | null>(null);
|
|
|
|
const [spaces, setSpaces] = useState<CadSpace[]>([]);
|
|
const [activeSpace, setActiveSpace] = useState<string | null>(null);
|
|
|
|
const [layers, setLayers] = useState<LayerRow[]>([]);
|
|
const [hiddenLayers, setHiddenLayersState] = useState<Set<string>>(new Set());
|
|
|
|
const [theme, setThemeState] = useState<'dark' | 'light'>('dark');
|
|
const [is25D, setIs25DState] = useState(false);
|
|
const [options, setOptions] = useState<PersistedOptions>(DEFAULT_OPTIONS);
|
|
|
|
const refreshLayers = useCallback(() => {
|
|
const viewer = viewerRef.current;
|
|
if (!viewer) return;
|
|
const info = viewer.getLayerInfo();
|
|
setLayers(info.map((l) => ({ name: l.name, colorHex: l.colorHex, count: l.count, visible: l.visible })));
|
|
}, []);
|
|
|
|
const refreshSpaces = useCallback(() => {
|
|
const viewer = viewerRef.current;
|
|
if (!viewer) return;
|
|
setSpaces(viewer.getSpaces());
|
|
setActiveSpace(viewer.getActiveSpace());
|
|
}, []);
|
|
|
|
// mount Viewer2D once
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
// Viewer2D has no dispose/teardown API (permanent window/canvas
|
|
// listeners, its own rAF loop) — defensively clear any leftover canvas
|
|
// from a prior construction (e.g. a dev Fast Refresh remount) so it
|
|
// never stacks two live WebGL contexts in the same container.
|
|
container.replaceChildren();
|
|
const viewer = new Viewer2D(container);
|
|
viewerRef.current = viewer;
|
|
viewer.setTheme(true);
|
|
viewer.onSelect((entity) => setSelectedEntity(entity));
|
|
viewer.onPointerWorld((p) => setPointerWorld(p));
|
|
|
|
const onResize = () => viewer.resize();
|
|
window.addEventListener('resize', onResize);
|
|
|
|
(async () => {
|
|
const saved = await getAllOptions();
|
|
const next: PersistedOptions = {
|
|
grid: typeof saved.grid === 'boolean' ? saved.grid : DEFAULT_OPTIONS.grid,
|
|
zoomSpeed: typeof saved.zoomSpeed === 'number' ? saved.zoomSpeed : DEFAULT_OPTIONS.zoomSpeed,
|
|
zScale: typeof saved.zScale === 'number' ? saved.zScale : DEFAULT_OPTIONS.zScale,
|
|
lineweightOn:
|
|
saved.lineweightOn === true || saved.lineweightOn === false ? saved.lineweightOn : null,
|
|
lineweightScale: typeof saved.lineweightScale === 'number' ? saved.lineweightScale : null,
|
|
};
|
|
setOptions(next);
|
|
viewer.setGrid(next.grid);
|
|
viewer.setZoomSpeed(next.zoomSpeed);
|
|
viewer.setZScale(next.zScale);
|
|
viewer.setLineweightEnabled(next.lineweightOn);
|
|
viewer.setLineweightScale(next.lineweightScale);
|
|
})();
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', onResize);
|
|
viewer.onPointerWorld(null);
|
|
viewerRef.current = null;
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const loadParseResult = useCallback(
|
|
(result: unknown) => {
|
|
const viewer = viewerRef.current;
|
|
if (!viewer) return;
|
|
const spaceList = listCadSpaces(result as any);
|
|
const def = pickDefaultSpace(spaceList);
|
|
viewer.load(result, { spaceHandle: def?.handleHex ?? null });
|
|
refreshSpaces();
|
|
refreshLayers();
|
|
setHiddenLayersState(new Set());
|
|
setSelectedEntity(null);
|
|
},
|
|
[refreshSpaces, refreshLayers],
|
|
);
|
|
|
|
const loadArrayBuffer = useCallback(
|
|
async (name: string, buf: ArrayBuffer) => {
|
|
setStatus('loading');
|
|
setStatusMessage(`불러오는 중… (${name})`);
|
|
setLoadingProgress(null);
|
|
try {
|
|
const result = await parseByName(name, buf);
|
|
loadParseResult(result);
|
|
setStatus('ready');
|
|
setStatusMessage(`로드 완료: ${name}`);
|
|
} catch (err) {
|
|
setStatus('error');
|
|
setStatusMessage(err instanceof Error ? err.message : '파일을 불러오지 못했습니다');
|
|
} finally {
|
|
setLoadingProgress(null);
|
|
}
|
|
},
|
|
[loadParseResult],
|
|
);
|
|
|
|
const loadFile = useCallback(
|
|
async (file: File) => {
|
|
const buf = await file.arrayBuffer();
|
|
await loadArrayBuffer(file.name, buf);
|
|
},
|
|
[loadArrayBuffer],
|
|
);
|
|
|
|
const loadUrl = useCallback(
|
|
async (url: string, label?: string) => {
|
|
const name = label ?? url.split('/').pop() ?? url;
|
|
setStatus('loading');
|
|
setStatusMessage(`다운로드 중… (${name})`);
|
|
setLoadingProgress(0);
|
|
try {
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error(`다운로드 실패 (${res.status})`);
|
|
const total = Number(res.headers.get('content-length') ?? 0);
|
|
let buf: ArrayBuffer;
|
|
if (total > 0 && res.body) {
|
|
const reader = res.body.getReader();
|
|
const chunks: Uint8Array[] = [];
|
|
let received = 0;
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
if (value) {
|
|
chunks.push(value);
|
|
received += value.length;
|
|
setLoadingProgress(Math.min(99, Math.round((received / total) * 100)));
|
|
}
|
|
}
|
|
const merged = new Uint8Array(received);
|
|
let offset = 0;
|
|
for (const chunk of chunks) {
|
|
merged.set(chunk, offset);
|
|
offset += chunk.length;
|
|
}
|
|
buf = merged.buffer;
|
|
} else {
|
|
setLoadingProgress(null);
|
|
buf = await res.arrayBuffer();
|
|
}
|
|
await loadArrayBuffer(name, buf);
|
|
} catch (err) {
|
|
setStatus('error');
|
|
setStatusMessage(err instanceof Error ? err.message : '다운로드하지 못했습니다');
|
|
setLoadingProgress(null);
|
|
}
|
|
},
|
|
[loadArrayBuffer],
|
|
);
|
|
|
|
const fit = useCallback(() => viewerRef.current?.fit(), []);
|
|
|
|
const zoomToSelection = useCallback(() => viewerRef.current?.zoomToSelection(), []);
|
|
|
|
const selectSpace = useCallback(
|
|
(handleHex: string | null) => {
|
|
viewerRef.current?.setSpace(handleHex);
|
|
refreshSpaces();
|
|
refreshLayers();
|
|
},
|
|
[refreshSpaces, refreshLayers],
|
|
);
|
|
|
|
const toggleTheme = useCallback(() => {
|
|
setThemeState((prev) => {
|
|
const next = prev === 'dark' ? 'light' : 'dark';
|
|
viewerRef.current?.setTheme(next === 'dark');
|
|
refreshLayers();
|
|
return next;
|
|
});
|
|
}, [refreshLayers]);
|
|
|
|
const setGrid = useCallback((on: boolean) => {
|
|
viewerRef.current?.setGrid(on);
|
|
setOptions((o) => ({ ...o, grid: on }));
|
|
void setOption('grid', on);
|
|
}, []);
|
|
|
|
const setZoomSpeed = useCallback((v: number) => {
|
|
viewerRef.current?.setZoomSpeed(v);
|
|
setOptions((o) => ({ ...o, zoomSpeed: v }));
|
|
void setOption('zoomSpeed', v);
|
|
}, []);
|
|
|
|
const setZScale = useCallback((v: number) => {
|
|
viewerRef.current?.setZScale(v);
|
|
setOptions((o) => ({ ...o, zScale: v }));
|
|
void setOption('zScale', v);
|
|
}, []);
|
|
|
|
const setLineweightOn = useCallback((on: boolean | null) => {
|
|
viewerRef.current?.setLineweightEnabled(on);
|
|
setOptions((o) => ({ ...o, lineweightOn: on }));
|
|
void setOption('lineweightOn', on);
|
|
}, []);
|
|
|
|
const setLineweightScale = useCallback((v: number | null) => {
|
|
viewerRef.current?.setLineweightScale(v);
|
|
setOptions((o) => ({ ...o, lineweightScale: v }));
|
|
void setOption('lineweightScale', v);
|
|
}, []);
|
|
|
|
const set25D = useCallback((on: boolean) => {
|
|
viewerRef.current?.set25D(on);
|
|
setIs25DState(on);
|
|
}, []);
|
|
|
|
const resetOptions = useCallback(() => {
|
|
setLineweightScale(null);
|
|
setLineweightOn(null);
|
|
setGrid(false);
|
|
setZoomSpeed(1);
|
|
setZScale(1);
|
|
set25D(false);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const toggleLayer = useCallback((name: string) => {
|
|
setHiddenLayersState((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(name)) next.delete(name);
|
|
else next.add(name);
|
|
viewerRef.current?.setHiddenLayers(next);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const showAllLayers = useCallback(() => {
|
|
setHiddenLayersState(() => {
|
|
const next = new Set<string>();
|
|
viewerRef.current?.setHiddenLayers(next);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const hideAllLayers = useCallback(() => {
|
|
setHiddenLayersState(() => {
|
|
const next = new Set(layers.map((l) => l.name));
|
|
viewerRef.current?.setHiddenLayers(next);
|
|
return next;
|
|
});
|
|
}, [layers]);
|
|
|
|
const getLineweightClasses = useCallback(() => viewerRef.current?.getLineweightClasses() ?? [], []);
|
|
|
|
return {
|
|
containerRef,
|
|
viewerRef,
|
|
status,
|
|
statusMessage,
|
|
setStatusMessage,
|
|
loadingProgress,
|
|
selectedEntity,
|
|
pointerWorld,
|
|
spaces,
|
|
activeSpace,
|
|
layers,
|
|
hiddenLayers,
|
|
theme,
|
|
is25D,
|
|
options,
|
|
loadFile,
|
|
loadUrl,
|
|
fit,
|
|
zoomToSelection,
|
|
selectSpace,
|
|
toggleTheme,
|
|
set25D,
|
|
setGrid,
|
|
setZoomSpeed,
|
|
setZScale,
|
|
setLineweightOn,
|
|
setLineweightScale,
|
|
resetOptions,
|
|
toggleLayer,
|
|
showAllLayers,
|
|
hideAllLayers,
|
|
getLineweightClasses,
|
|
};
|
|
}
|
|
|
|
export type UseDwgViewer = ReturnType<typeof useDwgViewer>;
|