feat(ui): rebuild the viewer UI with HMDesign components
New React UI around the unchanged engine (#1). GNB_ONLY page: - AppHeader: GNB + GNBBrand; sample drawings in GNBNav (NavigationMenu) and GNBMobileMenu (same data, real ?model= links); file open and about/notice in GNBUtilities. - DwgViewerCanvas: useRef/useEffect wrapper. new Viewer2D(host) on mount, option props diffed and pushed through the engine setters, onSelect/onPointerWorld/onViewChange forwarded to React. Viewer2D has no dispose(), so teardown stops the rAF loop / WebGL context from outside (also needed for StrictMode double mount). - ViewerToolbar: Model/Layout ToggleGroup (sample's visibility rule), Fit, 2.5D and canvas-theme Toggles, window Toggles. - FloatingPanel: HM Card in a fixed frame; drag/collapse via the ported makePanelDraggableAndCollapsible, CSS resize. - LayersPanel (FilterBar search, Switch per layer, show/hide all via LayerPanelManager), PropertiesPanel (formatEntityProperties → Badge + Table, zoom to selection), OptionsPanel (Switch/Slider/Input, IndexedDB through optionsStore, reset). - Load feedback per HMDesign: Progress for the measurable part (download bytes, stage steps) and LoadingScreen for the blocking WASM parse / Viewer2D.load(), painted before each blocking stage. - StatusBar with status line, zoom %, live CAD coordinates in the bottom-right corner; AboutDialog keeps the acadrust MPL-2.0 notice, THIRD-PARTY-NOTICES and the local patch reachable. - Drag & drop, ?model= auto-load, F shortcut, ResizeObserver resync. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
40befda905
commit
aaa0972bc1
18 files changed
+2163
No files matched your search
+435
@@ -0,0 +1,435 @@
|
||||
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<Viewer2D | null>(null)
|
||||
const viewerRef = useRef<Viewer2D | null>(null)
|
||||
const layerManager = useRef<LayerPanelManager | null>(null)
|
||||
const stageRef = useRef<HTMLDivElement>(null)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const pointer = useMemo(() => createValueStore<CadPoint | null>(null), [])
|
||||
const zoom = useMemo(() => createValueStore<number | null>(null), [])
|
||||
|
||||
const [status, setStatus] = useState<StatusMessage>({ text: "준비 중…" })
|
||||
const [loadState, setLoadState] = useState<LoadState | null>(null)
|
||||
const [hasDrawing, setHasDrawing] = useState(false)
|
||||
const [spaces, setSpaces] = useState<CadSpace[]>([])
|
||||
const [activeSpace, setActiveSpace] = useState<string | null>(null)
|
||||
const [layers, setLayers] = useState<LayerItem[]>([])
|
||||
const [selected, setSelected] = useState<unknown>(null)
|
||||
const [panels, setPanels] = useState({ layers: false, props: false, options: false })
|
||||
const [aboutOpen, setAboutOpen] = useState(false)
|
||||
const aboutOpener = useRef<HTMLElement | null>(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [dark, setDark] = useState(true)
|
||||
const [view25D, setView25D] = useState(false)
|
||||
const [prefs, setPrefs] = useState<Prefs>({ grid: false })
|
||||
const [engine, setEngine] = useState<EngineOptionState>({
|
||||
lineweightEnabled: false,
|
||||
lineweightScale: 20,
|
||||
lineweightClasses: [],
|
||||
zoomSpeed: 1,
|
||||
zScale: 1,
|
||||
})
|
||||
|
||||
const displayOptions = useMemo<ViewerDisplayOptions>(
|
||||
() => ({
|
||||
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)
|
||||
buf = await fetchWithProgress(source.url, (loaded, total) => {
|
||||
if (stale()) return
|
||||
const size = total ? `${formatBytes(loaded)} / ${formatBytes(total)}` : formatBytes(loaded)
|
||||
setLoadState({ label: `도면 내려받는 중 (${size})`, 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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<TooltipProvider>
|
||||
<div className="viewer-app">
|
||||
<AppHeader onOpenFile={openFile} onSample={loadSample} onAbout={openAbout} />
|
||||
<main className="viewer-main" aria-label="도면 뷰어">
|
||||
<ViewerToolbar
|
||||
spaces={spaces}
|
||||
activeSpace={activeSpace}
|
||||
onSpace={onSpace}
|
||||
hasDrawing={hasDrawing}
|
||||
onFit={() => 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 }))}
|
||||
/>
|
||||
<div ref={stageRef} className="viewer-stage">
|
||||
<DwgViewerCanvas
|
||||
className="viewer-canvas"
|
||||
options={displayOptions}
|
||||
onReady={onReady}
|
||||
onSelect={onSelect}
|
||||
onPointerWorld={pointer.set}
|
||||
onViewChange={onViewChange}
|
||||
onOptionsApplied={readEngine}
|
||||
/>
|
||||
{!hasDrawing && !loadState ? <EmptyState onOpenFile={openFile} onSample={loadSample} /> : null}
|
||||
{loadState ? <LoadingOverlay state={loadState} /> : null}
|
||||
{dragging ? <DropOverlay /> : null}
|
||||
<LayersPanel
|
||||
open={panels.layers}
|
||||
onClose={() => setPanels((p) => ({ ...p, layers: false }))}
|
||||
anchorRef={stageRef}
|
||||
layers={layers}
|
||||
onToggle={onToggleLayer}
|
||||
onShowAll={onShowAll}
|
||||
onHideAll={onHideAll}
|
||||
/>
|
||||
<PropertiesPanel
|
||||
open={panels.props}
|
||||
onClose={() => setPanels((p) => ({ ...p, props: false }))}
|
||||
anchorRef={stageRef}
|
||||
entity={selected}
|
||||
onZoomToSelection={() => viewerRef.current?.zoomToSelection()}
|
||||
/>
|
||||
<OptionsPanel
|
||||
open={panels.options}
|
||||
onClose={() => 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}
|
||||
/>
|
||||
</div>
|
||||
<StatusBar status={status} pointer={pointer} zoom={zoom} onNotices={openAbout} />
|
||||
</main>
|
||||
{/* HM Input has no file-picker look of its own here; it stays hidden and the GNB / empty-state buttons open it. */}
|
||||
<Input ref={fileRef} type="file" accept=".dwg,.dxf" hidden aria-label="DWG / DXF 파일 선택" onChange={onFileChange} />
|
||||
<AboutDialog open={aboutOpen} onOpenChange={setAboutOpen} returnFocusRef={aboutOpener} />
|
||||
<Toaster />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user