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:
minsungandClaude Opus 5.5 committed 2026-10-12 18:49:33 +09:00
1 parent 40befda905
commit aaa0972bc1
18 files changed
+2163

No files matched your search

+435
View File
@@ -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>
)
}
+151
View File
@@ -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<HTMLElement | null>
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="viewer-about"
onCloseAutoFocus={(event) => {
const opener = returnFocusRef.current
if (opener?.isConnected) {
event.preventDefault()
opener.focus()
}
}}
>
<DialogHeader>
<DialogTitle>뷰어 정보 · 오픈소스 고지</DialogTitle>
<DialogDescription>이 뷰어를 구성하는 모듈과, 함께 배포되는 서드파티 구성요소의 라이선스입니다.</DialogDescription>
</DialogHeader>
<DialogBody className="grid gap-6">
<section className="grid gap-2">
<h3 className="viewer-section-title">오픈소스 고지</h3>
<Alert>
<AlertTitle>acadrust 0.4.1 — Mozilla Public License 2.0 (수정본)</AlertTitle>
<AlertDescription>
DWG 파서 acadrust를 WASM으로 컴파일해 포함합니다. 큰 SubD 메쉬가 잘리지 않도록 read_mesh 배열 개수 상한을 고친 로컬
패치가 적용되어 있으며, MPL-2.0 §3.2에 따라 원본 소스(crates.io acadrust 0.4.1)와 아래 패치로 동일한 소스를 얻을 수
있습니다.
</AlertDescription>
</Alert>
<ul className="flex flex-wrap gap-x-4 gap-y-1">
{NOTICE_LINKS.map((l) => (
<li key={l.href}>
<Button asChild variant="link" size="sm">
<a href={l.href} target="_blank" rel="noopener noreferrer">
{l.label}
<ExternalLinkIcon aria-hidden="true" />
</a>
</Button>
</li>
))}
</ul>
<Table aria-label="서드파티 구성요소와 라이선스" className="viewer-prop-table">
<TableHeader>
<TableRow>
<TableHead scope="col">구성요소</TableHead>
<TableHead scope="col">라이선스</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{COMPONENTS.map(([name, license]) => (
<TableRow key={name}>
<TableCell>{name}</TableCell>
<TableCell>{license}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</section>
<section className="grid gap-2">
<h3 className="viewer-section-title">구성 모듈 (Module recipe)</h3>
<ol className="viewer-recipe">
{RECIPE.map(([mod, role]) => (
<li key={mod}>
<code>{mod}</code> — {role}
</li>
))}
</ol>
<Button asChild variant="link" size="sm" className="justify-self-start">
<a href={`${base}docs/modules-dwg-dxf.html`} target="_blank" rel="noopener noreferrer">
모듈 지도 (docs/modules-dwg-dxf.html)
<ExternalLinkIcon aria-hidden="true" />
</a>
</Button>
</section>
</DialogBody>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="cancel" appearance="solid">
닫기
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+101
View File
@@ -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 (
<GNB>
<GNBMobileMenu title="도면 메뉴">
<GNBMobileGroup label="샘플 도면">
{SAMPLE_DRAWINGS.map((s) => (
<GNBMobileLink key={s.id} href={sampleHref(s)} title={s.hint} onClick={select(s)}>
{s.label}
</GNBMobileLink>
))}
</GNBMobileGroup>
</GNBMobileMenu>
<GNBBrand href={import.meta.env.BASE_URL}>
<span>
<small>HM CAD VIEWER</small>
<strong>DWG · DXF 뷰어</strong>
</span>
</GNBBrand>
<GNBNav>
<NavigationMenu viewport={false} aria-label="주 메뉴">
<NavigationMenuList>
<NavigationMenuItem value="samples">
<NavigationMenuTrigger>샘플 도면</NavigationMenuTrigger>
<NavigationMenuContent>
<ul>
{SAMPLE_DRAWINGS.map((s) => (
<li key={s.id}>
<NavigationMenuLink href={sampleHref(s)} title={s.hint} onClick={select(s)}>
{s.label}
</NavigationMenuLink>
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</GNBNav>
<GNBUtilities>
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" variant="ghost" onClick={onOpenFile}>
<FolderOpenIcon aria-hidden="true" />
파일 열기
</Button>
</TooltipTrigger>
<TooltipContent>.dwg / .dxf 파일 열기 (끌어다 놓기도 가능)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" variant="ghost" size="icon" aria-label="뷰어 정보 · 오픈소스 고지" onClick={onAbout}>
<InfoIcon aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>뷰어 정보 · 오픈소스 고지</TooltipContent>
</Tooltip>
</GNBUtilities>
</GNB>
)
}
+151
View File
@@ -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 <canvas> 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<HTMLDivElement>(null)
const [viewer, setViewer] = useState<Viewer2D | null>(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 <div ref={hostRef} className={className} />
}
/**
* 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()
}
+116
View File
@@ -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<HTMLElement | null>
/** 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<HTMLElement>(null)
const headerRef = useRef<HTMLDivElement>(null)
const bodyRef = useRef<HTMLDivElement>(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 (
<section
ref={frameRef}
className="viewer-window"
data-size={size}
aria-label={title}
style={{ display: open ? undefined : "none" }}
>
<Card size="sm" className="viewer-window-card">
<CardHeader ref={headerRef} className="viewer-window-header border-b">
<CardTitle className="flex min-w-0 items-center gap-2">
<span aria-hidden="true" className="flex text-muted-foreground">{icon}</span>
<span className="truncate">{title}</span>
{meta}
</CardTitle>
<CardAction className="flex items-center gap-1">
{actions}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={collapsed ? `${title} 펼치기` : `${title} 접기`}
aria-expanded={!collapsed}
onClick={() => control.current?.toggleCollapse()}
>
{collapsed ? <ChevronRightIcon aria-hidden="true" /> : <ChevronDownIcon aria-hidden="true" />}
</Button>
</TooltipTrigger>
<TooltipContent>{collapsed ? "펼치기" : "접기"} (제목 더블클릭)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" variant="ghost" size="icon-sm" aria-label={`${title} 닫기`} onClick={onClose}>
<XIcon aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>닫기</TooltipContent>
</Tooltip>
</CardAction>
</CardHeader>
<div ref={bodyRef} className="viewer-window-body">
{children}
</div>
</Card>
</section>
)
}
+82
View File
@@ -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<HTMLElement | null>
/** 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 (
<FloatingPanel
title="레이어"
icon={<LayersIcon className="size-4" />}
meta={<Badge variant="secondary">{layers.length}개</Badge>}
open={open}
onClose={onClose}
slot={1}
anchorRef={anchorRef}
>
<div className="viewer-window-section border-b">
<FilterBar className="viewer-layer-filter">
<FilterBarSearch value={query} onValueChange={setQuery} placeholder="레이어 검색" size="small" />
<FilterBarActions>
<Button type="button" variant="primary" appearance="outline" size="sm" onClick={onShowAll} disabled={!layers.length}>
<EyeIcon aria-hidden="true" />
전체 켜기
</Button>
<Button type="button" variant="cancel" appearance="outline" size="sm" onClick={onHideAll} disabled={!layers.length}>
<EyeOffIcon aria-hidden="true" />
전체 끄기
</Button>
</FilterBarActions>
</FilterBar>
</div>
{!layers.length ? (
<p className="viewer-empty-note">도면에 레이어 정보가 없습니다</p>
) : !filtered.length ? (
<p className="viewer-empty-note">검색 결과가 없습니다 ("{q}")</p>
) : (
<ScrollArea className="viewer-window-scroll">
<ul className="viewer-layer-list" aria-label="레이어 목록">
{filtered.map((l, i) => {
const id = `${idBase}-layer-${i}`
return (
<li key={l.name} className="viewer-layer-row" data-visible={l.visible ? "true" : "false"}>
{/* Layer color comes from the drawing (ACI → hex), not from the theme. */}
<span aria-hidden="true" className="viewer-swatch" style={{ background: l.colorHex || undefined }} />
<Label htmlFor={id} className="viewer-layer-name" title={l.name}>
{l.name}
</Label>
<Badge variant="outline" title={`엔티티 ${l.count}개`}>
{l.count}
</Badge>
<Switch id={id} checked={l.visible} onCheckedChange={() => onToggle(l.name)} />
</li>
)
})}
</ul>
</ScrollArea>
)}
</FloatingPanel>
)
}
+148
View File
@@ -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<HTMLElement | null>
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 (
<FloatingPanel
title="옵션"
icon={<Settings2Icon className="size-4" />}
actions={
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" variant="cancel" appearance="outline" size="sm" onClick={props.onReset}>
<RotateCcwIcon aria-hidden="true" />
기본값
</Button>
</TooltipTrigger>
<TooltipContent>모든 옵션을 기본값으로</TooltipContent>
</Tooltip>
}
open={open}
onClose={onClose}
slot={2}
anchorRef={anchorRef}
size="auto"
>
<div className="viewer-window-scroll viewer-options">
<section className="viewer-option-section" aria-labelledby={`${id}-lw`}>
<h3 id={`${id}-lw`} className="viewer-section-title">선가중치 (Lineweight)</h3>
<div className="viewer-option-row">
<Label htmlFor={`${id}-lw-on`} className="viewer-option-label">
표시 (LWDISPLAY)
</Label>
<Switch id={`${id}-lw-on`} checked={engine.lineweightEnabled} onCheckedChange={props.onLineweightEnabled} />
</div>
<SliderField
id={`${id}-lw-scale`}
label="표시 배율"
unit="px/mm"
value={s}
slider={{ min: 2, max: 60, step: 0.5 }}
input={{ min: 1, max: 80, step: 0.5 }}
onChange={props.onLineweightScale}
/>
<div className="viewer-lw-preview" aria-live="polite">
{engine.lineweightClasses.length ? (
engine.lineweightClasses.map((mm) => (
<Badge key={mm} variant="outline">
{mm.toFixed(2)}mm → {lineweightPx(mm, s)}px
</Badge>
))
) : (
<span>이 도면에는 굵은 선이 없습니다 (0.50mm라면 {lineweightPx(0.5, s)}px)</span>
)}
</div>
<p className="viewer-help">
AutoCAD "선가중치 설정 → 표시 배율"과 같습니다. 모델 공간에서 선가중치는 줌과 무관한 화면 픽셀로 그려지므로, 화면 DPI에
맞춰 이 값을 조정합니다.
</p>
</section>
<Separator />
<section className="viewer-option-section" aria-labelledby={`${id}-display`}>
<h3 id={`${id}-display`} className="viewer-section-title">화면 (Display)</h3>
<div className="viewer-option-row">
<Label htmlFor={`${id}-grid`} className="viewer-option-label">
그리드 표시
</Label>
<Switch id={`${id}-grid`} checked={grid} onCheckedChange={props.onGrid} />
</div>
<SliderField
id={`${id}-zoom`}
label="줌 속도"
value={engine.zoomSpeed}
slider={{ min: 0.2, max: 3, step: 0.1 }}
input={{ min: 0.1, max: 5, step: 0.1 }}
onChange={props.onZoomSpeed}
/>
</section>
<Separator />
<section className="viewer-option-section" aria-labelledby={`${id}-25d`}>
<h3 id={`${id}-25d`} className="viewer-section-title">2.5D 보기</h3>
<div className="viewer-option-row">
<Label htmlFor={`${id}-25d-on`} className="viewer-option-label">
기울여 보기 (Top 해제)
</Label>
<Switch id={`${id}-25d-on`} checked={view25D} onCheckedChange={props.on25D} />
</div>
<SliderField
id={`${id}-z`}
label="높이 과장 (Z)"
unit="×"
value={engine.zScale}
slider={{ min: 1, max: 50, step: 0.5 }}
input={{ min: 1, max: 200, step: 0.5 }}
disabled={!view25D}
onChange={props.onZScale}
/>
<p className="viewer-help">
도면은 이미 CAD 표고(Z)를 그대로 갖고 있습니다. 2.5D는 같은 직교 카메라를 남동 등각(SE Isometric)으로 돌려 그 높낮이를
보여 줍니다 — 재파싱 없음. 부지가 넓고 기복이 작아 평평하게 보이면 Z 과장을 올립니다.{" "}
<strong>왼쪽 드래그 = 회전, 가운데·오른쪽 드래그 = 이동.</strong>
</p>
</section>
</div>
</FloatingPanel>
)
}
+78
View File
@@ -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<HTMLElement | null>
/** 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 (
<FloatingPanel
title="속성"
icon={<ClipboardListIcon className="size-4" />}
actions={
prop ? (
<Button type="button" variant="primary" appearance="outline" size="sm" onClick={onZoomToSelection}>
<ZoomInIcon aria-hidden="true" />
선택 확대
</Button>
) : null
}
open={open}
onClose={onClose}
slot={0}
anchorRef={anchorRef}
>
{!prop ? (
<div className="viewer-empty-note">
<MousePointerClickIcon aria-hidden="true" className="mx-auto mb-2 size-6" />
도면에서 객체(엔티티)를 클릭하면
<br />
상세 속성이 표시됩니다
</div>
) : (
<ScrollArea className="viewer-window-scroll">
<div className="viewer-window-section grid gap-4">
<Badge className="viewer-prop-id">{prop.id}</Badge>
<PropertyTable caption="일반 정보 (General)" rows={prop.general} />
{prop.geometry.length > 0 ? <PropertyTable caption="기하 속성 (Geometry)" rows={prop.geometry} /> : null}
</div>
</ScrollArea>
)}
</FloatingPanel>
)
}
function PropertyTable({ caption, rows }: { caption: string; rows: [string, string][] }) {
return (
<section className="grid gap-1.5">
<h3 className="viewer-section-title">{caption}</h3>
<Table aria-label={caption} className="viewer-prop-table">
<TableBody>
{rows.map(([k, v], i) => (
<TableRow key={`${k}-${i}`}>
<TableHead scope="row" className="viewer-prop-key">
{k}
</TableHead>
<TableCell className="viewer-prop-value">{v}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</section>
)
}
+70
View File
@@ -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 (
<div className="viewer-option-row" data-disabled={disabled ? "true" : undefined}>
<Label htmlFor={id} className="viewer-option-label">
{label}
</Label>
<div className="viewer-option-control">
<Slider
aria-label={label}
min={slider.min}
max={slider.max}
step={slider.step}
value={[value]}
disabled={disabled}
onValueChange={([v]) => commit(v ?? NaN)}
/>
<Input
id={id}
type="number"
size="small"
inputMode="decimal"
min={input.min}
max={input.max}
step={input.step}
value={draft}
disabled={disabled}
className="viewer-number"
onChange={(e) => {
setDraft(e.target.value)
commit(parseFloat(e.target.value))
}}
/>
{unit ? <span className="viewer-unit">{unit}</span> : null}
</div>
</div>
)
}
+79
View File
@@ -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 (
<div className="viewer-stage-center">
<Card className="viewer-empty-card">
<CardHeader>
<CardTitle>도면을 열어 주세요</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<p className="text-muted-foreground">
DWG · DXF 파일을 선택하거나 이 화면에 끌어다 놓습니다. 주소에 <code>?model=경로</code>를 붙이면 자동으로 엽니다.
</p>
<Button type="button" size="lg" onClick={onOpenFile}>
<FolderOpenIcon aria-hidden="true" />
파일 열기
</Button>
<div className="grid gap-2">
<p className="viewer-section-title">샘플 도면</p>
<div className="flex flex-wrap gap-2">
{SAMPLE_DRAWINGS.map((s) => (
<Button key={s.id} type="button" variant="primary" appearance="outline" size="sm" title={s.hint} onClick={() => onSample(s)}>
{s.label}
</Button>
))}
</div>
</div>
</CardContent>
</Card>
</div>
)
}
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 ? (
<div className="viewer-stage-progress">
<Progress value={state.value} aria-label="도면 불러오기 진행률" />
</div>
) : null}
{state.busy ? (
<div className="viewer-stage-center viewer-stage-busy">
<LoadingScreen className="viewer-loading">{state.label}</LoadingScreen>
</div>
) : null}
</>
)
}
/** Drop target hint while a file is dragged over the window. */
export function DropOverlay() {
return (
<div className="viewer-stage-center viewer-drop" aria-hidden="true">
<div className="viewer-drop-box">
<FileUpIcon className="size-8" />
<span>여기에 놓아 도면 열기 (.dwg / .dxf)</span>
</div>
</div>
)
}
+63
View File
@@ -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<CadPoint | null>
zoom: ValueStore<number | null>
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 (
<footer className="viewer-statusbar">
<p role="status" className="viewer-status-text" data-error={status.error ? "true" : undefined} title={status.text}>
{status.text}
</p>
<Button type="button" variant="link" size="sm" onClick={onNotices} className="viewer-notice-link">
<ScaleIcon aria-hidden="true" />
오픈소스 고지 (MPL-2.0 포함)
</Button>
<Separator orientation="vertical" className="viewer-statusbar-sep" />
<ZoomReadout store={zoom} />
<Separator orientation="vertical" className="viewer-statusbar-sep" />
<CoordinateReadout store={pointer} />
</footer>
)
}
function ZoomReadout({ store }: { store: ValueStore<number | null> }) {
const z = useValueStore(store)
return (
<span className="viewer-readout" title="확대 배율 (맞춤 = 100%)">
<span className="viewer-readout-label">Zoom</span>
{z == null ? "—" : `${z}%`}
</span>
)
}
function CoordinateReadout({ store }: { store: ValueStore<CadPoint | null> }) {
const p = useValueStore(store)
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="viewer-readout viewer-coords" tabIndex={0} aria-label="마우스 위치 CAD 좌표">
<span className="viewer-readout-label">X</span>
<span className="viewer-coord-value">{p ? formatCadCoord(p.x) : "—"}</span>
<span className="viewer-readout-label">Y</span>
<span className="viewer-coord-value">{p ? formatCadCoord(p.y) : "—"}</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">마우스 위치 CAD 좌표 (Model = UCS, Layout = Paper 도면 단위)</TooltipContent>
</Tooltip>
)
}
+121
View File
@@ -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 (
<div className="viewer-toolbar">
{showSpaces ? (
<>
<ToggleGroup
type="single"
size="sm"
variant="outline"
value={p.activeSpace ?? ""}
aria-label="Model / Layout 공간"
onValueChange={(hex) => {
const s = p.spaces.find((x) => x.handleHex === hex)
if (s) p.onSpace(s)
}}
>
{p.spaces.map((s) => (
<ToggleGroupItem key={s.handleHex} value={s.handleHex} title={`${s.name} · handle ${s.handleHex}`}>
{s.label}
{s.entityCount ? ` (${s.entityCount})` : ""}
</ToggleGroupItem>
))}
</ToggleGroup>
<Separator orientation="vertical" className="viewer-toolbar-sep" />
</>
) : null}
<div className="viewer-toolbar-group">
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" variant="primary" appearance="outline" size="sm" onClick={p.onFit} disabled={!p.hasDrawing}>
<MaximizeIcon aria-hidden="true" />
맞춤 보기
</Button>
</TooltipTrigger>
<TooltipContent>전체 도면 맞춤 (단축키 F)</TooltipContent>
</Tooltip>
{/* Toggle keeps its data-slot only when it is not a Slot child, so the tooltip anchors on a wrapper. */}
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Toggle size="sm" variant="outline" pressed={p.view25D} onPressedChange={p.on25D}>
<BoxIcon aria-hidden="true" />
2.5D 보기
</Toggle>
</span>
</TooltipTrigger>
<TooltipContent>Top 뷰를 풀고 기울여 높낮이(Z)를 봅니다 · 왼쪽 드래그=회전, 가운데/오른쪽 드래그=이동</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Toggle size="sm" variant="outline" pressed={p.dark} onPressedChange={p.onDark}>
<MoonIcon aria-hidden="true" />
어두운 배경
</Toggle>
</span>
</TooltipTrigger>
<TooltipContent>도면 캔버스 테마 (어두운 / 밝은 배경)</TooltipContent>
</Tooltip>
</div>
<div className="viewer-toolbar-group viewer-toolbar-end">
<Toggle size="sm" variant="outline" pressed={p.layersOpen} onPressedChange={p.onLayers}>
<LayersIcon aria-hidden="true" />
레이어
</Toggle>
<Toggle size="sm" variant="outline" pressed={p.propsOpen} onPressedChange={p.onProps}>
<ClipboardListIcon aria-hidden="true" />
속성
</Toggle>
<Toggle size="sm" variant="outline" pressed={p.optionsOpen} onPressedChange={p.onOptions}>
<Settings2Icon aria-hidden="true" />
옵션
</Toggle>
</div>
</div>
)
}
+364
View File
@@ -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;
}
}
+49
View File
@@ -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<CadParseResult> {
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
}
+80
View File
@@ -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<void> {
return new Promise<void>((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<ArrayBuffer> {
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
}
+25
View File
@@ -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)}`
}
+34
View File
@@ -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<T> = {
get: () => T
set: (value: T) => void
subscribe: (listener: () => void) => () => void
}
export function createValueStore<T>(initial: T): ValueStore<T> {
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<T>(store: ValueStore<T>): T {
return useSyncExternalStore(store.subscribe, store.get, store.get)
}
+16
View File
@@ -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(
<StrictMode>
<App />
</StrictMode>,
)