From aaa0972bc14a4098e1beb704dc8d4ebf575db5f0 Mon Sep 17 00:00:00 2001 From: minsung Date: Mon, 12 Oct 2026 18:49:33 +0900 Subject: [PATCH] feat(ui): rebuild the viewer UI with HMDesign components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/App.tsx | 435 +++++++++++++++++++++++++++++ src/components/AboutDialog.tsx | 151 ++++++++++ src/components/AppHeader.tsx | 101 +++++++ src/components/DwgViewerCanvas.tsx | 151 ++++++++++ src/components/FloatingPanel.tsx | 116 ++++++++ src/components/LayersPanel.tsx | 82 ++++++ src/components/OptionsPanel.tsx | 148 ++++++++++ src/components/PropertiesPanel.tsx | 78 ++++++ src/components/SliderField.tsx | 70 +++++ src/components/StageOverlays.tsx | 79 ++++++ src/components/StatusBar.tsx | 63 +++++ src/components/ViewerToolbar.tsx | 121 ++++++++ src/hm-theme.css | 364 ++++++++++++++++++++++++ src/lib/cad.ts | 49 ++++ src/lib/loadProgress.ts | 80 ++++++ src/lib/samples.ts | 25 ++ src/lib/store.ts | 34 +++ src/main.tsx | 16 ++ 18 files changed, 2163 insertions(+) create mode 100644 src/App.tsx create mode 100644 src/components/AboutDialog.tsx create mode 100644 src/components/AppHeader.tsx create mode 100644 src/components/DwgViewerCanvas.tsx create mode 100644 src/components/FloatingPanel.tsx create mode 100644 src/components/LayersPanel.tsx create mode 100644 src/components/OptionsPanel.tsx create mode 100644 src/components/PropertiesPanel.tsx create mode 100644 src/components/SliderField.tsx create mode 100644 src/components/StageOverlays.tsx create mode 100644 src/components/StatusBar.tsx create mode 100644 src/components/ViewerToolbar.tsx create mode 100644 src/hm-theme.css create mode 100644 src/lib/cad.ts create mode 100644 src/lib/loadProgress.ts create mode 100644 src/lib/samples.ts create mode 100644 src/lib/store.ts create mode 100644 src/main.tsx diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..b2af253 --- /dev/null +++ b/src/App.tsx @@ -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(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) + 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) => { + 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. */} + + + +
+
+ ) +} diff --git a/src/components/AboutDialog.tsx b/src/components/AboutDialog.tsx new file mode 100644 index 0000000..bd86972 --- /dev/null +++ b/src/components/AboutDialog.tsx @@ -0,0 +1,151 @@ +import type { RefObject } from "react" +import { ExternalLinkIcon } from "lucide-react" +import { + Alert, + AlertDescription, + AlertTitle, + Button, + Dialog, + DialogBody, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@hm-design/ui" + +const base = import.meta.env.BASE_URL + +/** Served copies under public/licenses (→ /licenses/…). MPL-2.0 §3.2(b): recipients of the wasm must be told where the source is. */ +const NOTICE_LINKS = [ + { href: `${base}licenses/THIRD-PARTY-NOTICES.md`, label: "THIRD-PARTY-NOTICES" }, + { href: `${base}licenses/acadrust-MPL-2.0.txt`, label: "acadrust · MPL-2.0 원문" }, + { href: `${base}licenses/acadrust-0.4.1-read_mesh.patch`, label: "acadrust local patch" }, +] + +const COMPONENTS: [string, string][] = [ + ["acadrust 0.4.1 (WASM 내장, read_mesh 로컬 패치)", "MPL-2.0 · 수정본"], + ["dwg-wasm 래퍼 · Viewer2D · 앱 코드", "MIT"], + ["three.js · dxf-parser · opentype.js", "MIT"], + ["NanumGothic (한글 TEXT/MTEXT)", "SIL OFL 1.1"], + ["React · Radix UI · sonner · cmdk 등 UI 런타임", "MIT"], + ["lucide-react", "ISC"], + ["class-variance-authority", "Apache-2.0"], + ["Noto Sans KR (HMDesign 테마 글꼴)", "SIL OFL 1.1"], + ["HMDesign (@hm-design/ui, shadcn/ui 기반)", "사내 디자인 시스템 · shadcn/ui MIT"], +] + +const RECIPE: [string, string][] = [ + ["ext2d", ".dwg / .dxf 분기"], + ["acadrust-dwg · dxf-parser", "DWG(WASM) 또는 DXF 파싱"], + ["dxfAdapter", "DXF → 공통 스키마"], + ["Viewer2D + three", "WebGL 렌더"], + ["slugText + TTF", "TEXT/MTEXT 글리프 (NanumGothic)"], +] + +/** + * About + open-source notice. The acadrust MPL-2.0 notice and its local patch must stay reachable from the UI + * (legal requirement); the sample kept them in its bottom-left recipe panel, this app keeps them here and links + * this dialog from the GNB and the status bar. + */ +export function AboutDialog({ + open, + onOpenChange, + returnFocusRef, +}: { + open: boolean + onOpenChange: (open: boolean) => void + /** The control that opened the dialog (there are two openers, so no single DialogTrigger). */ + returnFocusRef: RefObject +}) { + return ( + + { + const opener = returnFocusRef.current + if (opener?.isConnected) { + event.preventDefault() + opener.focus() + } + }} + > + + 뷰어 정보 · 오픈소스 고지 + 이 뷰어를 구성하는 모듈과, 함께 배포되는 서드파티 구성요소의 라이선스입니다. + + +
+

오픈소스 고지

+ + acadrust 0.4.1 — Mozilla Public License 2.0 (수정본) + + DWG 파서 acadrust를 WASM으로 컴파일해 포함합니다. 큰 SubD 메쉬가 잘리지 않도록 read_mesh 배열 개수 상한을 고친 로컬 + 패치가 적용되어 있으며, MPL-2.0 §3.2에 따라 원본 소스(crates.io acadrust 0.4.1)와 아래 패치로 동일한 소스를 얻을 수 + 있습니다. + + + + + + + 구성요소 + 라이선스 + + + + {COMPONENTS.map(([name, license]) => ( + + {name} + {license} + + ))} + +
+
+
+

구성 모듈 (Module recipe)

+
    + {RECIPE.map(([mod, role]) => ( +
  1. + {mod} — {role} +
  2. + ))} +
+ +
+
+ + + + + +
+
+ ) +} diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx new file mode 100644 index 0000000..6fd57b2 --- /dev/null +++ b/src/components/AppHeader.tsx @@ -0,0 +1,101 @@ +import type { MouseEvent } from "react" +import { FolderOpenIcon, InfoIcon } from "lucide-react" +import { + Button, + GNB, + GNBBrand, + GNBMobileGroup, + GNBMobileLink, + GNBMobileMenu, + GNBNav, + GNBUtilities, + NavigationMenu, + NavigationMenuContent, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuList, + NavigationMenuTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@hm-design/ui" +import { SAMPLE_DRAWINGS, sampleHref, type SampleDrawing } from "../lib/samples" + +type Props = { + onOpenFile: () => void + onSample: (sample: SampleDrawing) => void + onAbout: () => void +} + +/** + * GNB_ONLY page header. The only destinations are the bundled sample drawings (each is also a real + * `?model=` link); desktop shows them in the GNB Navigation Menu, mobile in GNBMobileMenu — same data. + * File open and the about / license dialog are utilities (white ghost Buttons on the bar). + */ +export function AppHeader({ onOpenFile, onSample, onAbout }: Props) { + const select = (s: SampleDrawing) => (event: MouseEvent) => { + // Load in place; the href stays usable for "open in new tab". + if (event.metaKey || event.ctrlKey || event.shiftKey) return + event.preventDefault() + onSample(s) + } + + return ( + + + + {SAMPLE_DRAWINGS.map((s) => ( + + {s.label} + + ))} + + + + + HM CAD VIEWER + DWG · DXF 뷰어 + + + + + + + 샘플 도면 + +
    + {SAMPLE_DRAWINGS.map((s) => ( +
  • + + {s.label} + +
  • + ))} +
+
+
+
+
+
+ + + + + + .dwg / .dxf 파일 열기 (끌어다 놓기도 가능) + + + + + + 뷰어 정보 · 오픈소스 고지 + + +
+ ) +} diff --git a/src/components/DwgViewerCanvas.tsx b/src/components/DwgViewerCanvas.tsx new file mode 100644 index 0000000..11d212a --- /dev/null +++ b/src/components/DwgViewerCanvas.tsx @@ -0,0 +1,151 @@ +import { useEffect, useLayoutEffect, useRef, useState } from "react" +import { Viewer2D } from "../engine/viewer2d/Viewer2D.js" + +export type CadPoint = { x: number; y: number; z: number } + +/** + * Display options pushed into the engine. `undefined` = leave the engine's own default alone + * (e.g. Viewer2D restores its zoom speed from localStorage; lineweight follows the drawing's LWDISPLAY). + * `null` for the lineweight pair is meaningful and forwarded: "follow the drawing / engine default". + */ +export type ViewerDisplayOptions = { + dark: boolean + grid: boolean + zoomSpeed?: number + lineweightEnabled?: boolean | null + lineweightScale?: number | null + zScale?: number + view25D: boolean +} + +type Props = { + options: ViewerDisplayOptions + /** The instance after mount, `null` after unmount. Use it for imperative calls (load / fit / setSpace / layers). */ + onReady?: (viewer: Viewer2D | null) => void + onSelect?: (entity: unknown) => void + onPointerWorld?: (point: CadPoint | null) => void + onViewChange?: () => void + /** Fired after options were pushed into the engine, so the host can re-read engine state. */ + onOptionsApplied?: () => void + className?: string +} + +/** + * React wrapper around the imperative Viewer2D engine (Monaco / Mapbox GL style). + * + * - mount: `new Viewer2D(container)` in an effect; the engine appends its own to the empty host div, + * so React never renders children into that div. + * - updates: option props are diffed against what was last applied and forwarded with the engine's setters. + * Data only flows down (props → engine) and up through the callbacks; React state is never written into + * the engine's internals. + * - unmount: Viewer2D has no dispose(). `releaseViewer2D` stops it from the outside without touching the + * engine source (also needed because React StrictMode mounts effects twice in development). + */ +export function DwgViewerCanvas({ options, onReady, onSelect, onPointerWorld, onViewChange, onOptionsApplied, className }: Props) { + const hostRef = useRef(null) + const [viewer, setViewer] = useState(null) + const callbacks = useRef({ onReady, onSelect, onPointerWorld, onViewChange, onOptionsApplied }) + useLayoutEffect(() => { + callbacks.current = { onReady, onSelect, onPointerWorld, onViewChange, onOptionsApplied } + }) + // What the engine currently has for options whose getter cannot tell (null = "follow drawing"). + const applied = useRef<{ dark: boolean; grid: boolean; lineweightEnabled?: boolean | null; lineweightScale?: number | null }>({ + dark: true, + grid: false, + }) + + useEffect(() => { + const host = hostRef.current + if (!host) return + const instance = new Viewer2D(host) + // Engine defaults right after construction: dark scene, grid off. + applied.current = { dark: true, grid: false } + instance.onSelect((entity) => callbacks.current.onSelect?.(entity)) + instance.onPointerWorld((p) => callbacks.current.onPointerWorld?.(p)) + instance.onViewChange(() => callbacks.current.onViewChange?.()) + + // The engine listens to window resize itself; the host also changes size when the toolbar wraps or the + // layout shifts without a window resize, so observe the container too. + const observer = new ResizeObserver(() => instance.resize()) + observer.observe(host) + + setViewer(instance) + callbacks.current.onReady?.(instance) + return () => { + observer.disconnect() + callbacks.current.onReady?.(null) + setViewer(null) + releaseViewer2D(instance) + } + }, []) + + useEffect(() => { + if (!viewer) return + const a = applied.current + if (a.dark !== options.dark) { + viewer.setTheme(options.dark) + a.dark = options.dark + } + if (a.grid !== options.grid) { + viewer.setGrid(options.grid) + a.grid = options.grid + } + if (options.zoomSpeed !== undefined && viewer.getZoomSpeed() !== options.zoomSpeed) { + viewer.setZoomSpeed(options.zoomSpeed) + } + if (options.lineweightEnabled !== undefined && a.lineweightEnabled !== options.lineweightEnabled) { + viewer.setLineweightEnabled(options.lineweightEnabled) + a.lineweightEnabled = options.lineweightEnabled + } + if (options.lineweightScale !== undefined && a.lineweightScale !== options.lineweightScale) { + viewer.setLineweightScale(options.lineweightScale) + a.lineweightScale = options.lineweightScale + } + if (options.zScale !== undefined && viewer.getZScale() !== options.zScale) { + viewer.setZScale(options.zScale) + } + if (viewer.is25D() !== options.view25D) { + viewer.set25D(options.view25D) + } + callbacks.current.onOptionsApplied?.() + }, [ + viewer, + options.dark, + options.grid, + options.zoomSpeed, + options.lineweightEnabled, + options.lineweightScale, + options.zScale, + options.view25D, + ]) + + return
+} + +/** + * Best-effort teardown for an engine that has no dispose(): end the requestAnimationFrame loop, detach + * OrbitControls, release the WebGL context and remove the canvas. The window keydown (F → fit) and resize + * listeners Viewer2D registers in its constructor are anonymous and cannot be removed, so the methods they + * call are turned into no-ops on this dead instance. + */ +function releaseViewer2D(viewer: Viewer2D) { + const v = viewer as unknown as { + _animate?: () => void + _onResize?: () => void + fit?: () => void + _onSelectCb?: unknown + _onPointerWorldCb?: unknown + _controls?: { dispose?: () => void } + _renderer?: { dispose?: () => void; forceContextLoss?: () => void; domElement?: HTMLElement } + } + const noop = () => {} + v._animate = noop + v._onResize = noop + v.fit = noop + v._onSelectCb = null + v._onPointerWorldCb = null + v._controls?.dispose?.() + v._renderer?.dispose?.() + v._renderer?.forceContextLoss?.() + v._renderer?.domElement?.remove() +} diff --git a/src/components/FloatingPanel.tsx b/src/components/FloatingPanel.tsx new file mode 100644 index 0000000..43183d4 --- /dev/null +++ b/src/components/FloatingPanel.tsx @@ -0,0 +1,116 @@ +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react" +import { ChevronDownIcon, ChevronRightIcon, XIcon } from "lucide-react" +import { Button, Card, CardAction, CardHeader, CardTitle, Tooltip, TooltipContent, TooltipTrigger } from "@hm-design/ui" +import { makePanelDraggableAndCollapsible } from "../engine/panelUtils" + +type Props = { + title: string + icon: ReactNode + /** Extra header content after the title (count badge…). */ + meta?: ReactNode + /** Extra header buttons before collapse / close. */ + actions?: ReactNode + open: boolean + onClose: () => void + /** Initial slot from the right edge of the anchor (0 = right-most). */ + slot: number + /** Area the window is first placed in (the canvas stage). */ + anchorRef: RefObject + /** Fixed starting height; omitted = fit content (capped by CSS). */ + size?: "tall" | "auto" + children: ReactNode +} + +const GAP = 12 + +/** + * A floating tool window over the canvas: HM Card inside a positioned frame. + * + * Drag (header), collapse (header double-click / button) and focus-to-front come from the ported + * `makePanelDraggableAndCollapsible` (src/engine/panelUtils.ts, unchanged); resizing is CSS `resize: both` on the + * frame, as in the sample. The frame keeps a static className because panelUtils toggles `collapsed` on it, and + * React would overwrite a changing className. Open/closed only touches `style.display`. + */ +export function FloatingPanel({ title, icon, meta, actions, open, onClose, slot, anchorRef, size = "tall", children }: Props) { + const frameRef = useRef(null) + const headerRef = useRef(null) + const bodyRef = useRef(null) + const control = useRef<{ toggleCollapse: () => void } | null>(null) + const placed = useRef(false) + const [collapsed, setCollapsed] = useState(false) + + useEffect(() => { + const frame = frameRef.current + // panelUtils has no teardown; attach once per element (StrictMode runs effects twice in development, + // and two dblclick listeners would cancel each other out). + if (!frame || frame.dataset.panelReady) return + frame.dataset.panelReady = "1" + control.current = makePanelDraggableAndCollapsible(frame, { + handleEl: headerRef.current, + bodyEl: bodyRef.current, + onCollapseToggle: setCollapsed, + }) + }, []) + + // First open: place the window inside the canvas stage, stacked from the right edge. + useLayoutEffect(() => { + const frame = frameRef.current + const anchor = anchorRef.current + if (!open || placed.current || !frame || !anchor) return + placed.current = true + const r = anchor.getBoundingClientRect() + const w = frame.offsetWidth + const left = Math.max(r.left + GAP, r.right - GAP - (slot + 1) * w - slot * GAP) + frame.style.left = `${Math.round(left)}px` + frame.style.top = `${Math.round(r.top + GAP)}px` + }, [open, slot, anchorRef]) + + return ( +
+ + + + + {title} + {meta} + + + {actions} + + + + + {collapsed ? "펼치기" : "접기"} (제목 더블클릭) + + + + + + 닫기 + + + +
+ {children} +
+
+
+ ) +} diff --git a/src/components/LayersPanel.tsx b/src/components/LayersPanel.tsx new file mode 100644 index 0000000..333a3de --- /dev/null +++ b/src/components/LayersPanel.tsx @@ -0,0 +1,82 @@ +import { useId, useState, type RefObject } from "react" +import { EyeIcon, EyeOffIcon, LayersIcon } from "lucide-react" +import { Badge, Button, FilterBar, FilterBarActions, FilterBarSearch, Label, ScrollArea, Switch } from "@hm-design/ui" +import type { LayerItem } from "../engine/layerPanel" +import { FloatingPanel } from "./FloatingPanel" + +type Props = { + open: boolean + onClose: () => void + anchorRef: RefObject + /** Snapshot of Viewer2D.getLayerInfo() (visibility included). */ + layers: LayerItem[] + onToggle: (name: string) => void + onShowAll: () => void + onHideAll: () => void +} + +/** + * Layers window. Visibility state lives in the engine: toggles go through the ported LayerPanelManager + * (src/engine/layerPanel.ts) and the list is re-read from Viewer2D.getLayerInfo(). + * Search is the HM FilterBar applied on every keystroke (the sample filtered on `input`, same substring rule). + */ +export function LayersPanel({ open, onClose, anchorRef, layers, onToggle, onShowAll, onHideAll }: Props) { + const [query, setQuery] = useState("") + const idBase = useId() + const q = query.trim().toLowerCase() + const filtered = layers.filter((l) => l.name.toLowerCase().includes(q)) + + return ( + } + meta={{layers.length}개} + open={open} + onClose={onClose} + slot={1} + anchorRef={anchorRef} + > +
+ + + + + + + +
+ {!layers.length ? ( +

도면에 레이어 정보가 없습니다

+ ) : !filtered.length ? ( +

검색 결과가 없습니다 ("{q}")

+ ) : ( + +
    + {filtered.map((l, i) => { + const id = `${idBase}-layer-${i}` + return ( +
  • + {/* Layer color comes from the drawing (ACI → hex), not from the theme. */} +
  • + ) + })} +
+
+ )} +
+ ) +} diff --git a/src/components/OptionsPanel.tsx b/src/components/OptionsPanel.tsx new file mode 100644 index 0000000..a5a6fc4 --- /dev/null +++ b/src/components/OptionsPanel.tsx @@ -0,0 +1,148 @@ +import { useId, type RefObject } from "react" +import { RotateCcwIcon, Settings2Icon } from "lucide-react" +import { Badge, Button, Label, Separator, Switch, Tooltip, TooltipContent, TooltipTrigger } from "@hm-design/ui" +import { lineweightPx } from "../lib/cad" +import { FloatingPanel } from "./FloatingPanel" +import { SliderField } from "./SliderField" + +/** Values read back from the engine (Viewer2D getters) after every change / load. */ +export type EngineOptionState = { + lineweightEnabled: boolean + lineweightScale: number + lineweightClasses: number[] + zoomSpeed: number + zScale: number +} + +type Props = { + open: boolean + onClose: () => void + anchorRef: RefObject + engine: EngineOptionState + grid: boolean + view25D: boolean + onLineweightEnabled: (on: boolean) => void + onLineweightScale: (pxPerMm: number) => void + onGrid: (on: boolean) => void + onZoomSpeed: (speed: number) => void + on25D: (on: boolean) => void + onZScale: (k: number) => void + onReset: () => void +} + +/** + * Options window: every display option of the viewer core. Values persist in IndexedDB (hmw-viewer/options) + * through the ported optionsStore — the host does the persisting; this component only renders and reports. + * Lineweight scale is applied without re-parsing, so dragging the slider updates the drawing live. + */ +export function OptionsPanel(props: Props) { + const { open, onClose, anchorRef, engine, grid, view25D } = props + const id = useId() + const s = engine.lineweightScale + + return ( + } + actions={ + + + + + 모든 옵션을 기본값으로 + + } + open={open} + onClose={onClose} + slot={2} + anchorRef={anchorRef} + size="auto" + > +
+
+

선가중치 (Lineweight)

+
+ + +
+ +
+ {engine.lineweightClasses.length ? ( + engine.lineweightClasses.map((mm) => ( + + {mm.toFixed(2)}mm → {lineweightPx(mm, s)}px + + )) + ) : ( + 이 도면에는 굵은 선이 없습니다 (0.50mm라면 {lineweightPx(0.5, s)}px) + )} +
+

+ AutoCAD "선가중치 설정 → 표시 배율"과 같습니다. 모델 공간에서 선가중치는 줌과 무관한 화면 픽셀로 그려지므로, 화면 DPI에 + 맞춰 이 값을 조정합니다. +

+
+ + + +
+

화면 (Display)

+
+ + +
+ +
+ + + +
+

2.5D 보기

+
+ + +
+ +

+ 도면은 이미 CAD 표고(Z)를 그대로 갖고 있습니다. 2.5D는 같은 직교 카메라를 남동 등각(SE Isometric)으로 돌려 그 높낮이를 + 보여 줍니다 — 재파싱 없음. 부지가 넓고 기복이 작아 평평하게 보이면 Z 과장을 올립니다.{" "} + 왼쪽 드래그 = 회전, 가운데·오른쪽 드래그 = 이동. +

+
+
+
+ ) +} diff --git a/src/components/PropertiesPanel.tsx b/src/components/PropertiesPanel.tsx new file mode 100644 index 0000000..786100f --- /dev/null +++ b/src/components/PropertiesPanel.tsx @@ -0,0 +1,78 @@ +import type { RefObject } from "react" +import { ClipboardListIcon, MousePointerClickIcon, ZoomInIcon } from "lucide-react" +import { Badge, Button, ScrollArea, Table, TableBody, TableCell, TableHead, TableRow } from "@hm-design/ui" +import { formatEntityProperties } from "../engine/propertyInspector" +import { FloatingPanel } from "./FloatingPanel" + +type Props = { + open: boolean + onClose: () => void + anchorRef: RefObject + /** Entity from Viewer2D.onSelect (null = nothing selected). */ + entity: unknown + onZoomToSelection: () => void +} + +/** + * Properties window. Formatting (labels, units, per-type geometry) is the ported formatEntityProperties + * (src/engine/propertyInspector.ts, unchanged); only the presentation is HM (Badge + Table). + */ +export function PropertiesPanel({ open, onClose, anchorRef, entity, onZoomToSelection }: Props) { + const prop = entity ? formatEntityProperties(entity) : null + + return ( + } + actions={ + prop ? ( + + ) : null + } + open={open} + onClose={onClose} + slot={0} + anchorRef={anchorRef} + > + {!prop ? ( +
+
+ ) : ( + +
+ {prop.id} + + {prop.geometry.length > 0 ? : null} +
+
+ )} +
+ ) +} + +function PropertyTable({ caption, rows }: { caption: string; rows: [string, string][] }) { + return ( +
+

{caption}

+ + + {rows.map(([k, v], i) => ( + + + {k} + + {v} + + ))} + +
+
+ ) +} diff --git a/src/components/SliderField.tsx b/src/components/SliderField.tsx new file mode 100644 index 0000000..965beea --- /dev/null +++ b/src/components/SliderField.tsx @@ -0,0 +1,70 @@ +import { useEffect, useState } from "react" +import { Input, Label, Slider } from "@hm-design/ui" + +type Range = { min: number; max: number; step: number } + +type Props = { + id: string + label: string + value: number + /** Slider range (coarse). */ + slider: Range + /** Number input range (wider, as in the sample). */ + input: Range + unit?: string + disabled?: boolean + /** Called with finite values > 0 only (the sample ignored anything else). */ + onChange: (value: number) => void +} + +/** + * Slider + number input bound to one value, both applying on every change like the sample's `input` listeners. + * The number field keeps its own draft text so a half-typed value ("1.") is not overwritten by the re-render. + */ +export function SliderField({ id, label, value, slider, input, unit, disabled, onChange }: Props) { + const [draft, setDraft] = useState(String(value)) + useEffect(() => { + setDraft((d) => (parseFloat(d) === value ? d : String(value))) + }, [value]) + + const commit = (v: number) => { + if (!Number.isFinite(v) || v <= 0) return + onChange(v) + } + + return ( +
+ +
+ commit(v ?? NaN)} + /> + { + setDraft(e.target.value) + commit(parseFloat(e.target.value)) + }} + /> + {unit ? {unit} : null} +
+
+ ) +} diff --git a/src/components/StageOverlays.tsx b/src/components/StageOverlays.tsx new file mode 100644 index 0000000..6f326ca --- /dev/null +++ b/src/components/StageOverlays.tsx @@ -0,0 +1,79 @@ +import { FileUpIcon, FolderOpenIcon } from "lucide-react" +import { Button, Card, CardContent, CardHeader, CardTitle, LoadingScreen, Progress } from "@hm-design/ui" +import { SAMPLE_DRAWINGS, type SampleDrawing } from "../lib/samples" + +/** Shown on the empty canvas: the page's single brand action (open a file) plus the bundled samples. */ +export function EmptyState({ onOpenFile, onSample }: { onOpenFile: () => void; onSample: (s: SampleDrawing) => void }) { + return ( +
+ + + 도면을 열어 주세요 + + +

+ DWG · DXF 파일을 선택하거나 이 화면에 끌어다 놓습니다. 주소에 ?model=경로를 붙이면 자동으로 엽니다. +

+ +
+

샘플 도면

+
+ {SAMPLE_DRAWINGS.map((s) => ( + + ))} +
+
+
+
+
+ ) +} + +export type LoadState = { + /** Text for the LoadingScreen label and the status line. */ + label: string + /** 0–100 when measurable, null when not (no Content-Length). */ + value: number | null + /** A stage is running (LoadingScreen shown); false only for the brief "done" frame of the Progress bar. */ + busy: boolean +} + +/** + * Load feedback, following HMDesign's split (loading-screen.md §2): + * - Progress (top edge of the canvas) — the measurable part: download bytes, then stage steps. + * - LoadingScreen (centre) — the unmeasurable part. WASM parsing and Viewer2D.load() block the main + * thread; LoadingScreen's spinner is a CSS opacity keyframe animation the compositor can keep running. + */ +export function LoadingOverlay({ state }: { state: LoadState }) { + return ( + <> + {state.value != null ? ( +
+ +
+ ) : null} + {state.busy ? ( +
+ {state.label} +
+ ) : null} + + ) +} + +/** Drop target hint while a file is dragged over the window. */ +export function DropOverlay() { + return ( + + ) +} diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx new file mode 100644 index 0000000..2ba9cf7 --- /dev/null +++ b/src/components/StatusBar.tsx @@ -0,0 +1,63 @@ +import { ScaleIcon } from "lucide-react" +import { Button, Separator, Tooltip, TooltipContent, TooltipTrigger } from "@hm-design/ui" +import { formatCadCoord } from "../lib/cad" +import { useValueStore, type ValueStore } from "../lib/store" +import type { CadPoint } from "./DwgViewerCanvas" + +export type StatusMessage = { text: string; error?: boolean } + +type Props = { + status: StatusMessage + pointer: ValueStore + zoom: ValueStore + onNotices: () => void +} + +/** + * Bottom status bar. Left: the status line (load / select / space messages). Right end — the screen's + * bottom-right corner — the live CAD coordinates, so they stay in a fixed place. + */ +export function StatusBar({ status, pointer, zoom, onNotices }: Props) { + return ( +
+

+ {status.text} +

+ + + + + +
+ ) +} + +function ZoomReadout({ store }: { store: ValueStore }) { + const z = useValueStore(store) + return ( + + Zoom + {z == null ? "—" : `${z}%`} + + ) +} + +function CoordinateReadout({ store }: { store: ValueStore }) { + const p = useValueStore(store) + return ( + + + + X + {p ? formatCadCoord(p.x) : "—"} + Y + {p ? formatCadCoord(p.y) : "—"} + + + 마우스 위치 CAD 좌표 (Model = UCS, Layout = Paper 도면 단위) + + ) +} diff --git a/src/components/ViewerToolbar.tsx b/src/components/ViewerToolbar.tsx new file mode 100644 index 0000000..e555c6c --- /dev/null +++ b/src/components/ViewerToolbar.tsx @@ -0,0 +1,121 @@ +import { BoxIcon, ClipboardListIcon, LayersIcon, MaximizeIcon, MoonIcon, Settings2Icon } from "lucide-react" +import { + Button, + Separator, + Toggle, + ToggleGroup, + ToggleGroupItem, + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@hm-design/ui" +import type { CadSpace } from "../engine/viewer2d/cadSpaces" + +type Props = { + spaces: CadSpace[] + activeSpace: string | null + onSpace: (space: CadSpace) => void + hasDrawing: boolean + onFit: () => void + view25D: boolean + on25D: (on: boolean) => void + dark: boolean + onDark: (dark: boolean) => void + layersOpen: boolean + onLayers: (open: boolean) => void + propsOpen: boolean + onProps: (open: boolean) => void + optionsOpen: boolean + onOptions: (open: boolean) => void +} + +/** The sample's rule: hide the strip only when there are fewer than two spaces and the first one is empty. */ +export function shouldShowSpaces(spaces: CadSpace[]): boolean { + return !(spaces.length < 2 && !spaces[0]?.entityCount) +} + +/** + * View controls between the GNB and the canvas: Model/Layout switch (ToggleGroup, only when the drawing has + * spaces to switch), Fit, 2.5D, canvas theme, and the three tool windows (Toggles = open state). + */ +export function ViewerToolbar(p: Props) { + const showSpaces = shouldShowSpaces(p.spaces) + + return ( +
+ {showSpaces ? ( + <> + { + const s = p.spaces.find((x) => x.handleHex === hex) + if (s) p.onSpace(s) + }} + > + {p.spaces.map((s) => ( + + {s.label} + {s.entityCount ? ` (${s.entityCount})` : ""} + + ))} + + + + ) : null} + +
+ + + + + 전체 도면 맞춤 (단축키 F) + + {/* Toggle keeps its data-slot only when it is not a Slot child, so the tooltip anchors on a wrapper. */} + + + + + + + + Top 뷰를 풀고 기울여 높낮이(Z)를 봅니다 · 왼쪽 드래그=회전, 가운데/오른쪽 드래그=이동 + + + + + + + + + 도면 캔버스 테마 (어두운 / 밝은 배경) + +
+ +
+ + + + + + +
+
+ ) +} diff --git a/src/hm-theme.css b/src/hm-theme.css new file mode 100644 index 0000000..1e50111 --- /dev/null +++ b/src/hm-theme.css @@ -0,0 +1,364 @@ +/* HM Theme (public package entry) + this app's page layout. + App rules below only lay out plain elements (frames, rows, overlays) with HM Semantic tokens. They do not + target HM component slots; HM components are customised only through their documented tokens (--hm-table-*, + --hm-filter-bar-*) on app-owned wrappers. */ +@import "@hm-design/ui/theme.css"; + +/* ── Page: GNB_ONLY — GNB + content surface (gnb.md §10 content contract) ─────────────────────────────── */ +.viewer-app { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + height: 100dvh; + min-width: 320px; + background: var(--hm-sem-bg-content); + color: var(--hm-sem-fg-primary); + font-family: var(--hm-font-family-base); +} + +.viewer-main { + position: relative; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + border-radius: var(--hm-gnb-join) var(--hm-gnb-join) 0 0; + background: var(--hm-sem-bg-content); +} + +/* ── Toolbar ───────────────────────────────────────────────────────────────────────────────────── */ +.viewer-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 14px var(--hm-gnb-padding-x) 10px; +} +.viewer-toolbar-group { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} +.viewer-toolbar-end { + margin-left: auto; +} +.viewer-toolbar-sep { + align-self: stretch; +} + +/* ── Stage: canvas + overlays + floating windows ───────────────────────────────────────────────── */ +.viewer-stage { + position: relative; + flex: 1; + min-height: 0; + margin: 0 var(--hm-gnb-padding-x); + border: 1px solid var(--hm-sem-stroke-input-default); + border-radius: var(--hm-radius-container); + overflow: hidden; + /* Own stacking context: the floating windows' growing z-index (panelUtils) stays below portaled HM popups. */ + isolation: isolate; +} +.viewer-canvas { + position: absolute; + inset: 0; +} +.viewer-canvas > canvas { + display: block; +} + +.viewer-stage-center { + position: absolute; + inset: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + pointer-events: none; +} +.viewer-stage-center > * { + pointer-events: auto; +} +.viewer-stage-progress { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 11; +} +.viewer-loading { + min-width: min(320px, 100%); +} +.viewer-empty-card { + width: min(440px, 100%); +} +.viewer-drop { + z-index: 12; + background: var(--hm-sem-overlay-background); +} +.viewer-drop-box { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 28px 36px; + border: 2px dashed var(--hm-sem-action-primary); + border-radius: var(--hm-radius-container); + background: var(--hm-sem-bg-surface); + color: var(--hm-sem-action-primary); + font-weight: 700; +} + +/* ── Floating tool windows (FloatingPanel) ─────────────────────────────────────────────────────── */ +.viewer-window { + position: fixed; + z-index: 30; + display: flex; + flex-direction: column; + width: min(340px, calc(100vw - 24px)); + height: min(460px, calc(100dvh - 160px)); + min-width: 240px; + min-height: 120px; + max-width: calc(100vw - 20px); + max-height: calc(100dvh - 20px); + resize: both; + overflow: hidden; + border-radius: var(--hm-radius-container); + box-shadow: var(--hm-sem-floating-shadow); +} +.viewer-window[data-size="auto"] { + height: auto; + max-height: min(78dvh, 760px); +} +/* panelUtils writes an inline 42px height when collapsing; let the card header define it instead. */ +.viewer-window.collapsed { + height: auto !important; + min-height: 0 !important; + resize: none; +} +.viewer-window-card { + flex: 1; + min-height: 0; + overflow: hidden; +} +.viewer-window-body { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} +.viewer-window-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; +} +.viewer-window-section { + padding-inline: var(--card-spacing); +} +.viewer-window-section.border-b { + padding-bottom: var(--card-spacing); +} +.viewer-empty-note { + margin: 0; + padding: 28px 16px; + text-align: center; + color: var(--hm-sem-fg-secondary); + font-size: 13px; + line-height: 1.6; +} +.viewer-section-title { + margin: 0; + color: var(--hm-sem-fg-secondary); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; +} + +/* Layers */ +.viewer-layer-filter { + --hm-filter-bar-search-width: 100%; +} +.viewer-layer-list { + margin: 0; + padding: 0; + list-style: none; +} +.viewer-layer-row { + display: flex; + align-items: center; + gap: 10px; + min-height: 40px; + padding: 4px var(--card-spacing); + border-bottom: 1px solid var(--hm-sem-stroke-input-default); +} +.viewer-layer-row[data-visible="false"] > :is(.viewer-swatch, .viewer-layer-name) { + opacity: 0.45; +} +.viewer-swatch { + flex-shrink: 0; + width: 12px; + height: 12px; + border-radius: 50%; + box-shadow: 0 0 0 1px var(--hm-sem-stroke-default); +} +.viewer-layer-name { + display: block; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} + +/* Properties */ +.viewer-prop-id { + justify-self: start; + max-width: 100%; +} +.viewer-prop-table { + --hm-table-min-width: 0; + --hm-table-font-size: 12px; + --hm-table-line-height: 18px; + --hm-table-header-font-size: 12px; + --hm-table-cell-padding-y: 6px; + --hm-table-cell-padding-x: 8px; +} +.viewer-prop-key { + width: 42%; +} +.viewer-prop-value { + word-break: break-all; + user-select: all; + text-align: right; +} + +/* Options */ +.viewer-options { + display: grid; + align-content: start; + gap: 16px; + padding: 0 var(--card-spacing) var(--card-spacing); +} +.viewer-option-section { + display: grid; + gap: 10px; +} +.viewer-option-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 6px 12px; +} +/* The Slider / Input show their own disabled look; dim only the plain text of the row. */ +.viewer-option-row[data-disabled] > .viewer-option-label, +.viewer-option-row[data-disabled] .viewer-unit { + opacity: 0.55; +} +.viewer-option-label { + flex: 1 0 auto; +} +.viewer-option-control { + display: flex; + flex: 1 1 100%; + align-items: center; + gap: 10px; +} +.viewer-number { + flex: 0 0 76px; + text-align: right; +} +.viewer-unit { + min-width: 36px; + color: var(--hm-sem-fg-secondary); + font-size: 12px; +} +.viewer-lw-preview { + display: flex; + flex-wrap: wrap; + gap: 6px; + color: var(--hm-sem-action-primary); + font-size: 12px; +} +.viewer-help { + margin: 0; + color: var(--hm-sem-fg-secondary); + font-size: 12px; + line-height: 1.55; + word-break: keep-all; +} + +/* About dialog */ +.viewer-about { + width: min(760px, calc(100vw - 32px)); +} +.viewer-recipe { + margin: 0; + padding-left: 20px; + line-height: 1.7; +} +.viewer-recipe code, +.viewer-empty-card code { + font-size: 0.9em; +} + +/* ── Status bar (coordinates sit in the bottom-right corner) ───────────────────────────────────── */ +.viewer-statusbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 12px; + min-height: 40px; + padding: 4px var(--hm-gnb-padding-x); + color: var(--hm-sem-fg-secondary); + font-size: 12px; +} +.viewer-status-text { + flex: 1 1 200px; + min-width: 0; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.viewer-status-text[data-error] { + color: var(--hm-sem-status-error); +} +.viewer-statusbar-sep { + align-self: stretch; + margin-block: 8px; +} +.viewer-readout { + display: inline-flex; + align-items: baseline; + gap: 6px; + color: var(--hm-sem-fg-primary); + font-family: ui-monospace, "Cascadia Code", Consolas, monospace; + font-size: 13px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.viewer-readout-label { + color: var(--hm-sem-action-primary); + font-weight: 700; +} +.viewer-coords { + margin-left: auto; +} +.viewer-coord-value { + min-width: 6.5em; + text-align: right; +} + +@media (max-width: 767px) { + .viewer-status-text { + flex-basis: 100%; + } + .viewer-statusbar-sep { + display: none; + } + .viewer-coord-value { + min-width: 0; + } +} diff --git a/src/lib/cad.ts b/src/lib/cad.ts new file mode 100644 index 0000000..b39f903 --- /dev/null +++ b/src/lib/cad.ts @@ -0,0 +1,49 @@ +/** + * Host glue around the ported engine (src/engine/viewer2d), lifted from dwg-dxf-viewer-sample/src/main.ts + * blocks ④ (parser composition) and the coordinate readout. Logic unchanged. + * + * File/URL → ext2d (isDwg / isDxf) → parseDwgBuffer | parseDxfBuffer → CadParseResult → Viewer2D.load() + */ +import { parseDwgBuffer, type CadParseResult } from "../engine/viewer2d/dwgParser" +import { parseDxfBuffer } from "../engine/viewer2d/parseDxf" +import { isDxf, isDwg } from "../engine/viewer2d/ext2d" + +export type { CadParseResult } + +/** Pick the parser by extension, falling back to the DWG magic ("AC10xx") and then DXF. */ +export async function parseCad(buf: ArrayBuffer, name: string): Promise { + if (isDxf(name)) return parseDxfBuffer(buf) + if (isDwg(name)) return parseDwgBuffer(new Uint8Array(buf)) + const head = new TextDecoder("latin1").decode( + new Uint8Array(buf, 0, Math.min(6, buf.byteLength)), + ) + if (head.startsWith("AC")) return parseDwgBuffer(new Uint8Array(buf)) + return parseDxfBuffer(buf) +} + +/** Which parser parseCad() will use — only for the loading label. */ +export function parserLabel(buf: ArrayBuffer, name: string): string { + if (isDxf(name)) return "dxf-parser" + if (isDwg(name)) return "acadrust WASM" + const head = new TextDecoder("latin1").decode(new Uint8Array(buf, 0, Math.min(6, buf.byteLength))) + return head.startsWith("AC") ? "acadrust WASM" : "dxf-parser" +} + +/** File name of a URL (query stripped), as main.ts loadUrl() derived it. */ +export function nameFromUrl(url: string): string { + return url.split("?")[0]!.split("/").pop() ?? url +} + +/** CAD coordinate text: fewer decimals for larger magnitudes (Model = UCS, Layout = paper units). */ +export function formatCadCoord(v: number): string { + const a = Math.abs(v) + if (a >= 1e6) return v.toFixed(1) + if (a >= 1e3) return v.toFixed(2) + if (a >= 1) return v.toFixed(3) + return v.toFixed(4) +} + +/** Lineweight classes → "0.30mm → 3.3px" (same rounding as main.ts syncOptions). */ +export function lineweightPx(mm: number, pxPerMm: number): number { + return Math.round(mm * pxPerMm * 10) / 10 +} diff --git a/src/lib/loadProgress.ts b/src/lib/loadProgress.ts new file mode 100644 index 0000000..2015128 --- /dev/null +++ b/src/lib/loadProgress.ts @@ -0,0 +1,80 @@ +/** + * Load-pipeline helpers, ported from dwg-dxf-viewer-sample/src/topProgress.ts. + * + * The sample drove its own NProgress-style bar from that file. Here the visual part is HMDesign's + * (Progress for the measurable download, LoadingScreen for the synchronous parse/render stages — see + * components/LoadingOverlay.tsx); only the non-visual helpers are kept, unchanged. + * + * Why the paint wait matters: `parse_dwg_json` on the WASM heap and `Viewer2D.load()` are SYNCHRONOUS. + * While they run the main thread is blocked — no rAF, no React commit, no repaint. The loading UI for a + * blocking stage therefore has to be committed AND painted before the stage starts (`nextPaint`). After + * that only compositor-driven animation keeps moving; LoadingScreen's spinner is a CSS opacity keyframe + * animation, which is the kind the compositor can run on its own. + */ + +/** Wait until the browser has actually painted the pending style change. */ +export function nextPaint(): Promise { + return new Promise((resolve) => { + let settled = false + const finish = () => { + if (settled) return + settled = true + resolve() + } + // Two frames: the first callback still runs BEFORE paint, the second one + // only fires once the frame carrying our change has been composited. + requestAnimationFrame(() => requestAnimationFrame(finish)) + // Backgrounded tabs never fire rAF — don't stall the load behind it. + setTimeout(finish, 150) + }) +} + +/** 1.4 MB / 812 kB — short byte label for the download readout. */ +export function formatBytes(n: number): string { + if (!Number.isFinite(n) || n < 0) return "?" + if (n < 1024) return `${n} B` + if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} kB` + return `${(n / 1024 / 1024).toFixed(1)} MB` +} + +/** + * fetch() that reports download progress. + * + * Falls back to a plain `arrayBuffer()` when the response has no body stream + * or no Content-Length (then `total` is 0 and the caller should show an + * indeterminate state). `loaded` is decompressed bytes while Content-Length is + * the compressed size, so the ratio is clamped to 1. + */ +export async function fetchWithProgress( + url: string, + onProgress: (loaded: number, total: number) => void, +): Promise { + const res = await fetch(url) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const total = Number(res.headers.get("content-length")) || 0 + if (!res.body || !total) { + const buf = await res.arrayBuffer() + onProgress(buf.byteLength, buf.byteLength) + return buf + } + + const reader = res.body.getReader() + const chunks: Uint8Array[] = [] + let loaded = 0 + for (;;) { + const { done, value } = await reader.read() + if (done) break + if (!value) continue + chunks.push(value) + loaded += value.length + onProgress(loaded, total) + } + + const out = new Uint8Array(loaded) + let offset = 0 + for (const c of chunks) { + out.set(c, offset) + offset += c.length + } + return out.buffer +} diff --git a/src/lib/samples.ts b/src/lib/samples.ts new file mode 100644 index 0000000..4eb0764 --- /dev/null +++ b/src/lib/samples.ts @@ -0,0 +1,25 @@ +/** Bundled sample drawings (public/samples). Same three entry points as the sample's toolbar. */ +export type SampleDrawing = { + id: string + /** Menu label. */ + label: string + /** Which pipeline it exercises (shown as the item's title). */ + hint: string + /** Path under the app base, also usable as `?model=`. */ + path: string + /** Name used for parser selection and the status line. */ + name: string +} + +const base = import.meta.env.BASE_URL + +export const SAMPLE_DRAWINGS: SampleDrawing[] = [ + { id: "dwg", label: "Sample DWG", hint: "acadrust-dwg WASM", path: `${base}samples/BasicSample.dwg`, name: "BasicSample.dwg" }, + { id: "civil", label: "평면및종단면도.dwg", hint: "Civil Plan & Profile DWG", path: `${base}samples/civil.dwg`, name: "C0060202-001-평면및종단면도.dwg" }, + { id: "dxf", label: "Sample DXF", hint: "dxf-parser", path: `${base}samples/simple.dxf`, name: "simple.dxf" }, +] + +/** `?model=` link for a sample, so the menu item also works as a plain link (new tab, copy link). */ +export function sampleHref(s: SampleDrawing): string { + return `?model=${encodeURIComponent(s.path)}` +} diff --git a/src/lib/store.ts b/src/lib/store.ts new file mode 100644 index 0000000..18dc355 --- /dev/null +++ b/src/lib/store.ts @@ -0,0 +1,34 @@ +import { useSyncExternalStore } from "react" + +/** + * Minimal external store for values the viewer emits at pointer-move / camera-change rate (CAD coordinates, + * zoom %). Keeping them out of App state means only the small readout re-renders, not the whole screen. + */ +export type ValueStore = { + get: () => T + set: (value: T) => void + subscribe: (listener: () => void) => () => void +} + +export function createValueStore(initial: T): ValueStore { + let value = initial + const listeners = new Set<() => void>() + return { + get: () => value, + set: (next) => { + if (Object.is(next, value)) return + value = next + listeners.forEach((l) => l()) + }, + subscribe: (listener) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, + } +} + +export function useValueStore(store: ValueStore): T { + return useSyncExternalStore(store.subscribe, store.get, store.get) +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..efda9c1 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,16 @@ +import { StrictMode } from "react" +import { createRoot } from "react-dom/client" +import "./index.css" +import "./hm-theme.css" +import { setDefaultFontUrl } from "./engine/viewer2d/slugText" +import { App } from "./App" + +// Host static asset for TEXT/MTEXT glyphs (Hangul + Latin). Must be set before the first Viewer2D is +// constructed: its constructor warms SlugTextEngine with this URL. +setDefaultFontUrl(`${import.meta.env.BASE_URL}fonts/NanumGothic-Regular.ttf`) + +createRoot(document.getElementById("root")!).render( + + + , +)