import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react" import { flushSync } from "react-dom" import { Input, Toaster, TooltipProvider, toast } from "@hm-design/ui" import type { Viewer2D } from "./engine/viewer2d/Viewer2D.js" import { listCadSpaces, pickDefaultSpace, type CadSpace } from "./engine/viewer2d/cadSpaces" import { is2D } from "./engine/viewer2d/ext2d" import { LayerPanelManager, type LayerItem } from "./engine/layerPanel" import { getAllOptions, setOption } from "./engine/optionsStore" import { nameFromUrl, parseCad, parserLabel } from "./lib/cad" import { fetchWithProgress, formatBytes, nextPaint } from "./lib/loadProgress" import type { SampleDrawing } from "./lib/samples" import { createValueStore } from "./lib/store" import { DwgViewerCanvas, type CadPoint, type ViewerDisplayOptions } from "./components/DwgViewerCanvas" import { AppHeader } from "./components/AppHeader" import { ViewerToolbar } from "./components/ViewerToolbar" import { StatusBar, type StatusMessage } from "./components/StatusBar" import { LayersPanel } from "./components/LayersPanel" import { PropertiesPanel } from "./components/PropertiesPanel" import { OptionsPanel, type EngineOptionState } from "./components/OptionsPanel" import { AboutDialog } from "./components/AboutDialog" import { DropOverlay, EmptyState, LoadingOverlay, type LoadState } from "./components/StageOverlays" /** Persisted options (IndexedDB hmw-viewer/options). `undefined` = never set → engine default. */ type Prefs = { lineweightScale?: number | null lineweightOn?: boolean | null grid: boolean zoomSpeed?: number zScale?: number } type Source = { kind: "url"; url: string; name?: string } | { kind: "file"; file: File } const positive = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v) && v > 0 export function App() { const [viewer, setViewer] = useState(null) const viewerRef = useRef(null) const layerManager = useRef(null) const stageRef = useRef(null) const fileRef = useRef(null) const pointer = useMemo(() => createValueStore(null), []) const zoom = useMemo(() => createValueStore(null), []) const [status, setStatus] = useState({ text: "준비 중…" }) const [loadState, setLoadState] = useState(null) const [hasDrawing, setHasDrawing] = useState(false) const [spaces, setSpaces] = useState([]) const [activeSpace, setActiveSpace] = useState(null) const [layers, setLayers] = useState([]) const [selected, setSelected] = useState(null) const [panels, setPanels] = useState({ layers: false, props: false, options: false }) const [aboutOpen, setAboutOpen] = useState(false) const aboutOpener = useRef(null) const [dragging, setDragging] = useState(false) const [dark, setDark] = useState(true) const [view25D, setView25D] = useState(false) const [prefs, setPrefs] = useState({ grid: false }) const [engine, setEngine] = useState({ lineweightEnabled: false, lineweightScale: 20, lineweightClasses: [], zoomSpeed: 1, zScale: 1, }) const displayOptions = useMemo( () => ({ dark, grid: prefs.grid, zoomSpeed: prefs.zoomSpeed, lineweightEnabled: prefs.lineweightOn, lineweightScale: prefs.lineweightScale, zScale: prefs.zScale, view25D, }), [dark, prefs, view25D], ) // ── engine → React (reads only) ───────────────────────────────────────────── /** main.ts syncOptions(): put the controls back to what the engine actually uses. */ const readEngine = useCallback(() => { const v = viewerRef.current if (!v) return setEngine({ lineweightEnabled: v.getLineweightEnabled(), lineweightScale: v.getLineweightScale(), lineweightClasses: v.getLineweightClasses(), zoomSpeed: v.getZoomSpeed(), zScale: v.getZScale(), }) zoom.set(v.getZoomPercent()) }, [zoom]) const fillLayers = useCallback(() => { const v = viewerRef.current if (!v) return layerManager.current?.update() setLayers(v.getLayerInfo()) }, []) const fillSpaces = useCallback(() => { const v = viewerRef.current if (!v) return setSpaces(v.getSpaces()) setActiveSpace(v.getActiveSpace()) }, []) const onReady = useCallback((v: Viewer2D | null) => { viewerRef.current = v // The ported manager owns the hidden-layer set and calls Viewer2D.setHiddenLayers(); its own DOM rendering // goes to a detached element because the list is rendered by React (LayersPanel) from getLayerInfo(). layerManager.current = v ? new LayerPanelManager(v, document.createElement("div")) : null setViewer(v) }, []) const onSelect = useCallback((entity: unknown) => { if (!entity) { setStatus({ text: "선택 해제" }) setSelected(null) return } // Show the properties window automatically on selection. setPanels((p) => ({ ...p, props: true })) setSelected(entity) const e = entity as { type?: string; layer?: string; handle?: { value?: string } } setStatus({ text: `선택 ${e.type ?? "?"} · 레이어 ${e.layer ?? "-"} · 핸들 ${e.handle?.value ?? "-"}` }) }, []) const onViewChange = useCallback(() => { zoom.set(viewerRef.current?.getZoomPercent() ?? null) }, [zoom]) // Theme changes re-render the drawing with theme-dependent layer colors (the sample re-filled the list too). // Child effects run first, so the canvas has already called setTheme() here. useEffect(() => { if (viewer) fillLayers() }, [viewer, dark, fillLayers]) // ── load pipeline (main.ts ④/⑤ with staged progress) ───────────────────────── const loadSeq = useRef(0) const run = useCallback( async (label: string, source: Source) => { const v = viewerRef.current if (!v) return const seq = ++loadSeq.current const stale = () => seq !== loadSeq.current setStatus({ text: `${label} 여는 중…` }) setLoadState({ label: "도면 여는 중", value: 0, busy: true }) try { let buf: ArrayBuffer let name: string if (source.kind === "url") { name = source.name ?? nameFromUrl(source.url) let lastLabel = "" buf = await fetchWithProgress(source.url, (loaded, total) => { if (stale()) return const size = total ? `${formatBytes(loaded)} / ${formatBytes(total)}` : formatBytes(loaded) const label = `도면 내려받는 중 (${size})` if (label === lastLabel) return // one update per visible change, not per network chunk lastLabel = label setLoadState({ label, value: total ? Math.round(Math.min(1, loaded / total) * 50) : null, busy: true }) }) } else { name = source.file.name if (!is2D(name)) throw new Error(`.dwg / .dxf 파일이 아닙니다: ${name}`) setLoadState({ label: "파일 읽는 중", value: 10, busy: true }) buf = await source.file.arrayBuffer() } if (stale()) return // Parsing blocks the main thread (WASM / dxf-parser): commit and paint the busy state first. flushSync(() => setLoadState({ label: `도면 해석 중 · ${parserLabel(buf, name)}`, value: 55, busy: true })) await nextPaint() const result = await parseCad(buf, name) if (stale()) return // Viewer2D.load() tessellates synchronously as well. flushSync(() => setLoadState({ label: "화면 구성 중", value: 85, busy: true })) await nextPaint() const allSpaces = listCadSpaces(result) const def = pickDefaultSpace(allSpaces) v.load(result, { spaceHandle: def?.handleHex ?? null }) v.resize() readEngine() const active = allSpaces.find((s) => s.handleHex === v.getActiveSpace()) setStatus({ text: `✓ ${label} · 엔티티 ${result.entities?.length ?? 0}개${active ? ` · ${active.label}` : ""}` }) setHasDrawing(true) setSelected(null) fillSpaces() fillLayers() v.fit() zoom.set(v.getZoomPercent()) setLoadState({ label: "완료", value: 100, busy: false }) window.setTimeout(() => { if (!stale()) setLoadState(null) }, 400) } catch (err) { if (stale()) return const message = (err as Error).message setStatus({ text: `${label}: ${message}`, error: true }) setLoadState(null) toast.error(`${label} 열기 실패`, { description: message }) console.error(err) } }, [readEngine, fillSpaces, fillLayers, zoom], ) const runRef = useRef(run) useEffect(() => { runRef.current = run }, [run]) const openAbout = useCallback(() => { aboutOpener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null setAboutOpen(true) }, []) const loadSample = useCallback((s: SampleDrawing) => void run(s.name, { kind: "url", url: s.path, name: s.name }), [run]) const openFile = useCallback(() => fileRef.current?.click(), []) const onFileChange = (ev: ChangeEvent) => { const file = ev.target.files?.[0] if (!file) return void run(file.name, { kind: "file", file }) ev.target.value = "" } // `?model=` auto-load, once the engine exists. const booted = useRef(false) useEffect(() => { if (!viewer || booted.current) return booted.current = true const model = new URLSearchParams(location.search).get("model") if (model) void runRef.current(model, { kind: "url", url: model }) else setStatus({ text: "준비됨 · 도면을 열거나 화면에 끌어다 놓으세요" }) }, [viewer]) // Restore saved options. IndexedDB is async; values are applied as soon as they arrive. useEffect(() => { let alive = true void getAllOptions().then((o) => { if (!alive) return setPrefs((p) => ({ ...p, ...(positive(o.lineweightScale) ? { lineweightScale: o.lineweightScale } : {}), ...(typeof o.lineweightOn === "boolean" ? { lineweightOn: o.lineweightOn } : {}), ...(o.grid === true ? { grid: true } : {}), ...(positive(o.zoomSpeed) ? { zoomSpeed: o.zoomSpeed } : {}), ...(positive(o.zScale) ? { zScale: o.zScale } : {}), })) }) return () => { alive = false } }, []) // F = fit (the sample's host shortcut; skipped while typing in an input). useEffect(() => { const onKey = (e: KeyboardEvent) => { if ((e.key === "f" || e.key === "F") && (e.target as HTMLElement)?.tagName !== "INPUT") viewerRef.current?.fit() } window.addEventListener("keydown", onKey) return () => window.removeEventListener("keydown", onKey) }, []) // Drag & drop anywhere on the page. useEffect(() => { let depth = 0 const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes("Files") const onOver = (e: DragEvent) => e.preventDefault() const onEnter = (e: DragEvent) => { if (!hasFiles(e)) return depth++ setDragging(true) } const onLeave = (e: DragEvent) => { if (!hasFiles(e)) return depth = Math.max(0, depth - 1) if (!depth) setDragging(false) } const onDrop = (e: DragEvent) => { e.preventDefault() depth = 0 setDragging(false) const file = e.dataTransfer?.files?.[0] if (file) void runRef.current(file.name, { kind: "file", file }) } window.addEventListener("dragover", onOver) window.addEventListener("dragenter", onEnter) window.addEventListener("dragleave", onLeave) window.addEventListener("drop", onDrop) return () => { window.removeEventListener("dragover", onOver) window.removeEventListener("dragenter", onEnter) window.removeEventListener("dragleave", onLeave) window.removeEventListener("drop", onDrop) } }, []) // ── commands (React → engine) ─────────────────────────────────────────────── const onSpace = (s: CadSpace) => { const v = viewerRef.current if (!v || s.handleHex === v.getActiveSpace()) return v.setSpace(s.handleHex) fillSpaces() fillLayers() v.fit() zoom.set(v.getZoomPercent()) setStatus({ text: `공간 ${s.label} · 엔티티 ${s.entityCount}개` }) } const apply25D = (on: boolean) => { setView25D(on) setStatus({ text: on ? "2.5D 보기 · 왼쪽 드래그=회전, 가운데·오른쪽 드래그=이동" : "Top 뷰" }) } const onToggleLayer = (name: string) => { layerManager.current?.toggleLayer(name) setLayers(viewerRef.current?.getLayerInfo() ?? []) } const onShowAll = () => { layerManager.current?.showAllLayers() setLayers(viewerRef.current?.getLayerInfo() ?? []) } const onHideAll = () => { layerManager.current?.hideAllLayers() setLayers(viewerRef.current?.getLayerInfo() ?? []) } // Options: state → canvas effect → engine; each change is saved to IndexedDB like the sample. const onLineweightScale = (v: number) => { setPrefs((p) => ({ ...p, lineweightScale: v })) void setOption("lineweightScale", v) } const onLineweightEnabled = (on: boolean) => { setPrefs((p) => ({ ...p, lineweightOn: on })) void setOption("lineweightOn", on) } const onGrid = (on: boolean) => { setPrefs((p) => ({ ...p, grid: on })) void setOption("grid", on) } const onZoomSpeed = (v: number) => { setPrefs((p) => ({ ...p, zoomSpeed: v })) void setOption("zoomSpeed", v) } const onZScale = (v: number) => { setPrefs((p) => ({ ...p, zScale: v })) void setOption("zScale", v) } const onReset = () => { // Engine default lineweight scale, the drawing's own LWDISPLAY, grid off, zoom ×1, Z ×1, top view. setPrefs({ lineweightScale: null, lineweightOn: null, grid: false, zoomSpeed: 1, zScale: 1 }) apply25D(false) void setOption("lineweightScale", null) void setOption("lineweightOn", null) void setOption("grid", false) void setOption("zoomSpeed", 1) void setOption("zScale", 1) } return (
viewerRef.current?.fit()} view25D={view25D} on25D={apply25D} dark={dark} onDark={setDark} layersOpen={panels.layers} onLayers={(open) => setPanels((p) => ({ ...p, layers: open }))} propsOpen={panels.props} onProps={(open) => setPanels((p) => ({ ...p, props: open }))} optionsOpen={panels.options} onOptions={(open) => setPanels((p) => ({ ...p, options: open }))} />
{!hasDrawing && !loadState ? : null} {loadState ? : null} {dragging ? : null} setPanels((p) => ({ ...p, layers: false }))} anchorRef={stageRef} layers={layers} onToggle={onToggleLayer} onShowAll={onShowAll} onHideAll={onHideAll} /> setPanels((p) => ({ ...p, props: false }))} anchorRef={stageRef} entity={selected} onZoomToSelection={() => viewerRef.current?.zoomToSelection()} /> setPanels((p) => ({ ...p, options: false }))} anchorRef={stageRef} engine={engine} grid={prefs.grid} view25D={view25D} onLineweightEnabled={onLineweightEnabled} onLineweightScale={onLineweightScale} onGrid={onGrid} onZoomSpeed={onZoomSpeed} on25D={apply25D} onZScale={onZScale} onReset={onReset} />
{/* HM Input has no file-picker look of its own here; it stays hidden and the GNB / empty-state buttons open it. */}
) }