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(null); const viewerRef = useRef(null); const [status, setStatus] = useState('idle'); const [statusMessage, setStatusMessage] = useState('준비됨'); const [loadingProgress, setLoadingProgress] = useState(null); const [selectedEntity, setSelectedEntity] = useState(null); const [pointerWorld, setPointerWorld] = useState<{ x: number; y: number; z: number } | null>(null); const [spaces, setSpaces] = useState([]); const [activeSpace, setActiveSpace] = useState(null); const [layers, setLayers] = useState([]); const [hiddenLayers, setHiddenLayersState] = useState>(new Set()); const [theme, setThemeState] = useState<'dark' | 'light'>('dark'); const [is25D, setIs25DState] = useState(false); const [options, setOptions] = useState(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(); 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;