feat(ui): rebuild the viewer UI with HMDesign components
New React UI around the unchanged engine (#1). GNB_ONLY page: - AppHeader: GNB + GNBBrand; sample drawings in GNBNav (NavigationMenu) and GNBMobileMenu (same data, real ?model= links); file open and about/notice in GNBUtilities. - DwgViewerCanvas: useRef/useEffect wrapper. new Viewer2D(host) on mount, option props diffed and pushed through the engine setters, onSelect/onPointerWorld/onViewChange forwarded to React. Viewer2D has no dispose(), so teardown stops the rAF loop / WebGL context from outside (also needed for StrictMode double mount). - ViewerToolbar: Model/Layout ToggleGroup (sample's visibility rule), Fit, 2.5D and canvas-theme Toggles, window Toggles. - FloatingPanel: HM Card in a fixed frame; drag/collapse via the ported makePanelDraggableAndCollapsible, CSS resize. - LayersPanel (FilterBar search, Switch per layer, show/hide all via LayerPanelManager), PropertiesPanel (formatEntityProperties → Badge + Table, zoom to selection), OptionsPanel (Switch/Slider/Input, IndexedDB through optionsStore, reset). - Load feedback per HMDesign: Progress for the measurable part (download bytes, stage steps) and LoadingScreen for the blocking WASM parse / Viewer2D.load(), painted before each blocking stage. - StatusBar with status line, zoom %, live CAD coordinates in the bottom-right corner; AboutDialog keeps the acadrust MPL-2.0 notice, THIRD-PARTY-NOTICES and the local patch reachable. - Drag & drop, ?model= auto-load, F shortcut, ResizeObserver resync. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
40befda905
commit
aaa0972bc1
18 files changed
+2163
No files matched your search
+435
@@ -0,0 +1,435 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"
|
||||
import { flushSync } from "react-dom"
|
||||
import { Input, Toaster, TooltipProvider, toast } from "@hm-design/ui"
|
||||
import type { Viewer2D } from "./engine/viewer2d/Viewer2D.js"
|
||||
import { listCadSpaces, pickDefaultSpace, type CadSpace } from "./engine/viewer2d/cadSpaces"
|
||||
import { is2D } from "./engine/viewer2d/ext2d"
|
||||
import { LayerPanelManager, type LayerItem } from "./engine/layerPanel"
|
||||
import { getAllOptions, setOption } from "./engine/optionsStore"
|
||||
import { nameFromUrl, parseCad, parserLabel } from "./lib/cad"
|
||||
import { fetchWithProgress, formatBytes, nextPaint } from "./lib/loadProgress"
|
||||
import type { SampleDrawing } from "./lib/samples"
|
||||
import { createValueStore } from "./lib/store"
|
||||
import { DwgViewerCanvas, type CadPoint, type ViewerDisplayOptions } from "./components/DwgViewerCanvas"
|
||||
import { AppHeader } from "./components/AppHeader"
|
||||
import { ViewerToolbar } from "./components/ViewerToolbar"
|
||||
import { StatusBar, type StatusMessage } from "./components/StatusBar"
|
||||
import { LayersPanel } from "./components/LayersPanel"
|
||||
import { PropertiesPanel } from "./components/PropertiesPanel"
|
||||
import { OptionsPanel, type EngineOptionState } from "./components/OptionsPanel"
|
||||
import { AboutDialog } from "./components/AboutDialog"
|
||||
import { DropOverlay, EmptyState, LoadingOverlay, type LoadState } from "./components/StageOverlays"
|
||||
|
||||
/** Persisted options (IndexedDB hmw-viewer/options). `undefined` = never set → engine default. */
|
||||
type Prefs = {
|
||||
lineweightScale?: number | null
|
||||
lineweightOn?: boolean | null
|
||||
grid: boolean
|
||||
zoomSpeed?: number
|
||||
zScale?: number
|
||||
}
|
||||
|
||||
type Source = { kind: "url"; url: string; name?: string } | { kind: "file"; file: File }
|
||||
|
||||
const positive = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v) && v > 0
|
||||
|
||||
export function App() {
|
||||
const [viewer, setViewer] = useState<Viewer2D | null>(null)
|
||||
const viewerRef = useRef<Viewer2D | null>(null)
|
||||
const layerManager = useRef<LayerPanelManager | null>(null)
|
||||
const stageRef = useRef<HTMLDivElement>(null)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const pointer = useMemo(() => createValueStore<CadPoint | null>(null), [])
|
||||
const zoom = useMemo(() => createValueStore<number | null>(null), [])
|
||||
|
||||
const [status, setStatus] = useState<StatusMessage>({ text: "준비 중…" })
|
||||
const [loadState, setLoadState] = useState<LoadState | null>(null)
|
||||
const [hasDrawing, setHasDrawing] = useState(false)
|
||||
const [spaces, setSpaces] = useState<CadSpace[]>([])
|
||||
const [activeSpace, setActiveSpace] = useState<string | null>(null)
|
||||
const [layers, setLayers] = useState<LayerItem[]>([])
|
||||
const [selected, setSelected] = useState<unknown>(null)
|
||||
const [panels, setPanels] = useState({ layers: false, props: false, options: false })
|
||||
const [aboutOpen, setAboutOpen] = useState(false)
|
||||
const aboutOpener = useRef<HTMLElement | null>(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [dark, setDark] = useState(true)
|
||||
const [view25D, setView25D] = useState(false)
|
||||
const [prefs, setPrefs] = useState<Prefs>({ grid: false })
|
||||
const [engine, setEngine] = useState<EngineOptionState>({
|
||||
lineweightEnabled: false,
|
||||
lineweightScale: 20,
|
||||
lineweightClasses: [],
|
||||
zoomSpeed: 1,
|
||||
zScale: 1,
|
||||
})
|
||||
|
||||
const displayOptions = useMemo<ViewerDisplayOptions>(
|
||||
() => ({
|
||||
dark,
|
||||
grid: prefs.grid,
|
||||
zoomSpeed: prefs.zoomSpeed,
|
||||
lineweightEnabled: prefs.lineweightOn,
|
||||
lineweightScale: prefs.lineweightScale,
|
||||
zScale: prefs.zScale,
|
||||
view25D,
|
||||
}),
|
||||
[dark, prefs, view25D],
|
||||
)
|
||||
|
||||
// ── engine → React (reads only) ─────────────────────────────────────────────
|
||||
/** main.ts syncOptions(): put the controls back to what the engine actually uses. */
|
||||
const readEngine = useCallback(() => {
|
||||
const v = viewerRef.current
|
||||
if (!v) return
|
||||
setEngine({
|
||||
lineweightEnabled: v.getLineweightEnabled(),
|
||||
lineweightScale: v.getLineweightScale(),
|
||||
lineweightClasses: v.getLineweightClasses(),
|
||||
zoomSpeed: v.getZoomSpeed(),
|
||||
zScale: v.getZScale(),
|
||||
})
|
||||
zoom.set(v.getZoomPercent())
|
||||
}, [zoom])
|
||||
|
||||
const fillLayers = useCallback(() => {
|
||||
const v = viewerRef.current
|
||||
if (!v) return
|
||||
layerManager.current?.update()
|
||||
setLayers(v.getLayerInfo())
|
||||
}, [])
|
||||
|
||||
const fillSpaces = useCallback(() => {
|
||||
const v = viewerRef.current
|
||||
if (!v) return
|
||||
setSpaces(v.getSpaces())
|
||||
setActiveSpace(v.getActiveSpace())
|
||||
}, [])
|
||||
|
||||
const onReady = useCallback((v: Viewer2D | null) => {
|
||||
viewerRef.current = v
|
||||
// The ported manager owns the hidden-layer set and calls Viewer2D.setHiddenLayers(); its own DOM rendering
|
||||
// goes to a detached element because the list is rendered by React (LayersPanel) from getLayerInfo().
|
||||
layerManager.current = v ? new LayerPanelManager(v, document.createElement("div")) : null
|
||||
setViewer(v)
|
||||
}, [])
|
||||
|
||||
const onSelect = useCallback((entity: unknown) => {
|
||||
if (!entity) {
|
||||
setStatus({ text: "선택 해제" })
|
||||
setSelected(null)
|
||||
return
|
||||
}
|
||||
// Show the properties window automatically on selection.
|
||||
setPanels((p) => ({ ...p, props: true }))
|
||||
setSelected(entity)
|
||||
const e = entity as { type?: string; layer?: string; handle?: { value?: string } }
|
||||
setStatus({ text: `선택 ${e.type ?? "?"} · 레이어 ${e.layer ?? "-"} · 핸들 ${e.handle?.value ?? "-"}` })
|
||||
}, [])
|
||||
|
||||
const onViewChange = useCallback(() => {
|
||||
zoom.set(viewerRef.current?.getZoomPercent() ?? null)
|
||||
}, [zoom])
|
||||
|
||||
// Theme changes re-render the drawing with theme-dependent layer colors (the sample re-filled the list too).
|
||||
// Child effects run first, so the canvas has already called setTheme() here.
|
||||
useEffect(() => {
|
||||
if (viewer) fillLayers()
|
||||
}, [viewer, dark, fillLayers])
|
||||
|
||||
// ── load pipeline (main.ts ④/⑤ with staged progress) ─────────────────────────
|
||||
const loadSeq = useRef(0)
|
||||
const run = useCallback(
|
||||
async (label: string, source: Source) => {
|
||||
const v = viewerRef.current
|
||||
if (!v) return
|
||||
const seq = ++loadSeq.current
|
||||
const stale = () => seq !== loadSeq.current
|
||||
setStatus({ text: `${label} 여는 중…` })
|
||||
setLoadState({ label: "도면 여는 중", value: 0, busy: true })
|
||||
try {
|
||||
let buf: ArrayBuffer
|
||||
let name: string
|
||||
if (source.kind === "url") {
|
||||
name = source.name ?? nameFromUrl(source.url)
|
||||
buf = await fetchWithProgress(source.url, (loaded, total) => {
|
||||
if (stale()) return
|
||||
const size = total ? `${formatBytes(loaded)} / ${formatBytes(total)}` : formatBytes(loaded)
|
||||
setLoadState({ label: `도면 내려받는 중 (${size})`, value: total ? Math.round(Math.min(1, loaded / total) * 50) : null, busy: true })
|
||||
})
|
||||
} else {
|
||||
name = source.file.name
|
||||
if (!is2D(name)) throw new Error(`.dwg / .dxf 파일이 아닙니다: ${name}`)
|
||||
setLoadState({ label: "파일 읽는 중", value: 10, busy: true })
|
||||
buf = await source.file.arrayBuffer()
|
||||
}
|
||||
if (stale()) return
|
||||
|
||||
// Parsing blocks the main thread (WASM / dxf-parser): commit and paint the busy state first.
|
||||
flushSync(() => setLoadState({ label: `도면 해석 중 · ${parserLabel(buf, name)}`, value: 55, busy: true }))
|
||||
await nextPaint()
|
||||
const result = await parseCad(buf, name)
|
||||
if (stale()) return
|
||||
|
||||
// Viewer2D.load() tessellates synchronously as well.
|
||||
flushSync(() => setLoadState({ label: "화면 구성 중", value: 85, busy: true }))
|
||||
await nextPaint()
|
||||
const allSpaces = listCadSpaces(result)
|
||||
const def = pickDefaultSpace(allSpaces)
|
||||
v.load(result, { spaceHandle: def?.handleHex ?? null })
|
||||
v.resize()
|
||||
readEngine()
|
||||
|
||||
const active = allSpaces.find((s) => s.handleHex === v.getActiveSpace())
|
||||
setStatus({ text: `✓ ${label} · 엔티티 ${result.entities?.length ?? 0}개${active ? ` · ${active.label}` : ""}` })
|
||||
setHasDrawing(true)
|
||||
setSelected(null)
|
||||
fillSpaces()
|
||||
fillLayers()
|
||||
v.fit()
|
||||
zoom.set(v.getZoomPercent())
|
||||
setLoadState({ label: "완료", value: 100, busy: false })
|
||||
window.setTimeout(() => {
|
||||
if (!stale()) setLoadState(null)
|
||||
}, 400)
|
||||
} catch (err) {
|
||||
if (stale()) return
|
||||
const message = (err as Error).message
|
||||
setStatus({ text: `${label}: ${message}`, error: true })
|
||||
setLoadState(null)
|
||||
toast.error(`${label} 열기 실패`, { description: message })
|
||||
console.error(err)
|
||||
}
|
||||
},
|
||||
[readEngine, fillSpaces, fillLayers, zoom],
|
||||
)
|
||||
const runRef = useRef(run)
|
||||
useEffect(() => {
|
||||
runRef.current = run
|
||||
}, [run])
|
||||
|
||||
const openAbout = useCallback(() => {
|
||||
aboutOpener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
setAboutOpen(true)
|
||||
}, [])
|
||||
|
||||
const loadSample = useCallback((s: SampleDrawing) => void run(s.name, { kind: "url", url: s.path, name: s.name }), [run])
|
||||
const openFile = useCallback(() => fileRef.current?.click(), [])
|
||||
const onFileChange = (ev: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = ev.target.files?.[0]
|
||||
if (!file) return
|
||||
void run(file.name, { kind: "file", file })
|
||||
ev.target.value = ""
|
||||
}
|
||||
|
||||
// `?model=` auto-load, once the engine exists.
|
||||
const booted = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!viewer || booted.current) return
|
||||
booted.current = true
|
||||
const model = new URLSearchParams(location.search).get("model")
|
||||
if (model) void runRef.current(model, { kind: "url", url: model })
|
||||
else setStatus({ text: "준비됨 · 도면을 열거나 화면에 끌어다 놓으세요" })
|
||||
}, [viewer])
|
||||
|
||||
// Restore saved options. IndexedDB is async; values are applied as soon as they arrive.
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
void getAllOptions().then((o) => {
|
||||
if (!alive) return
|
||||
setPrefs((p) => ({
|
||||
...p,
|
||||
...(positive(o.lineweightScale) ? { lineweightScale: o.lineweightScale } : {}),
|
||||
...(typeof o.lineweightOn === "boolean" ? { lineweightOn: o.lineweightOn } : {}),
|
||||
...(o.grid === true ? { grid: true } : {}),
|
||||
...(positive(o.zoomSpeed) ? { zoomSpeed: o.zoomSpeed } : {}),
|
||||
...(positive(o.zScale) ? { zScale: o.zScale } : {}),
|
||||
}))
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// F = fit (the sample's host shortcut; skipped while typing in an input).
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.key === "f" || e.key === "F") && (e.target as HTMLElement)?.tagName !== "INPUT") viewerRef.current?.fit()
|
||||
}
|
||||
window.addEventListener("keydown", onKey)
|
||||
return () => window.removeEventListener("keydown", onKey)
|
||||
}, [])
|
||||
|
||||
// Drag & drop anywhere on the page.
|
||||
useEffect(() => {
|
||||
let depth = 0
|
||||
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes("Files")
|
||||
const onOver = (e: DragEvent) => e.preventDefault()
|
||||
const onEnter = (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return
|
||||
depth++
|
||||
setDragging(true)
|
||||
}
|
||||
const onLeave = (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return
|
||||
depth = Math.max(0, depth - 1)
|
||||
if (!depth) setDragging(false)
|
||||
}
|
||||
const onDrop = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
depth = 0
|
||||
setDragging(false)
|
||||
const file = e.dataTransfer?.files?.[0]
|
||||
if (file) void runRef.current(file.name, { kind: "file", file })
|
||||
}
|
||||
window.addEventListener("dragover", onOver)
|
||||
window.addEventListener("dragenter", onEnter)
|
||||
window.addEventListener("dragleave", onLeave)
|
||||
window.addEventListener("drop", onDrop)
|
||||
return () => {
|
||||
window.removeEventListener("dragover", onOver)
|
||||
window.removeEventListener("dragenter", onEnter)
|
||||
window.removeEventListener("dragleave", onLeave)
|
||||
window.removeEventListener("drop", onDrop)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ── commands (React → engine) ───────────────────────────────────────────────
|
||||
const onSpace = (s: CadSpace) => {
|
||||
const v = viewerRef.current
|
||||
if (!v || s.handleHex === v.getActiveSpace()) return
|
||||
v.setSpace(s.handleHex)
|
||||
fillSpaces()
|
||||
fillLayers()
|
||||
v.fit()
|
||||
zoom.set(v.getZoomPercent())
|
||||
setStatus({ text: `공간 ${s.label} · 엔티티 ${s.entityCount}개` })
|
||||
}
|
||||
|
||||
const apply25D = (on: boolean) => {
|
||||
setView25D(on)
|
||||
setStatus({ text: on ? "2.5D 보기 · 왼쪽 드래그=회전, 가운데·오른쪽 드래그=이동" : "Top 뷰" })
|
||||
}
|
||||
|
||||
const onToggleLayer = (name: string) => {
|
||||
layerManager.current?.toggleLayer(name)
|
||||
setLayers(viewerRef.current?.getLayerInfo() ?? [])
|
||||
}
|
||||
const onShowAll = () => {
|
||||
layerManager.current?.showAllLayers()
|
||||
setLayers(viewerRef.current?.getLayerInfo() ?? [])
|
||||
}
|
||||
const onHideAll = () => {
|
||||
layerManager.current?.hideAllLayers()
|
||||
setLayers(viewerRef.current?.getLayerInfo() ?? [])
|
||||
}
|
||||
|
||||
// Options: state → canvas effect → engine; each change is saved to IndexedDB like the sample.
|
||||
const onLineweightScale = (v: number) => {
|
||||
setPrefs((p) => ({ ...p, lineweightScale: v }))
|
||||
void setOption("lineweightScale", v)
|
||||
}
|
||||
const onLineweightEnabled = (on: boolean) => {
|
||||
setPrefs((p) => ({ ...p, lineweightOn: on }))
|
||||
void setOption("lineweightOn", on)
|
||||
}
|
||||
const onGrid = (on: boolean) => {
|
||||
setPrefs((p) => ({ ...p, grid: on }))
|
||||
void setOption("grid", on)
|
||||
}
|
||||
const onZoomSpeed = (v: number) => {
|
||||
setPrefs((p) => ({ ...p, zoomSpeed: v }))
|
||||
void setOption("zoomSpeed", v)
|
||||
}
|
||||
const onZScale = (v: number) => {
|
||||
setPrefs((p) => ({ ...p, zScale: v }))
|
||||
void setOption("zScale", v)
|
||||
}
|
||||
const onReset = () => {
|
||||
// Engine default lineweight scale, the drawing's own LWDISPLAY, grid off, zoom ×1, Z ×1, top view.
|
||||
setPrefs({ lineweightScale: null, lineweightOn: null, grid: false, zoomSpeed: 1, zScale: 1 })
|
||||
apply25D(false)
|
||||
void setOption("lineweightScale", null)
|
||||
void setOption("lineweightOn", null)
|
||||
void setOption("grid", false)
|
||||
void setOption("zoomSpeed", 1)
|
||||
void setOption("zScale", 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="viewer-app">
|
||||
<AppHeader onOpenFile={openFile} onSample={loadSample} onAbout={openAbout} />
|
||||
<main className="viewer-main" aria-label="도면 뷰어">
|
||||
<ViewerToolbar
|
||||
spaces={spaces}
|
||||
activeSpace={activeSpace}
|
||||
onSpace={onSpace}
|
||||
hasDrawing={hasDrawing}
|
||||
onFit={() => viewerRef.current?.fit()}
|
||||
view25D={view25D}
|
||||
on25D={apply25D}
|
||||
dark={dark}
|
||||
onDark={setDark}
|
||||
layersOpen={panels.layers}
|
||||
onLayers={(open) => setPanels((p) => ({ ...p, layers: open }))}
|
||||
propsOpen={panels.props}
|
||||
onProps={(open) => setPanels((p) => ({ ...p, props: open }))}
|
||||
optionsOpen={panels.options}
|
||||
onOptions={(open) => setPanels((p) => ({ ...p, options: open }))}
|
||||
/>
|
||||
<div ref={stageRef} className="viewer-stage">
|
||||
<DwgViewerCanvas
|
||||
className="viewer-canvas"
|
||||
options={displayOptions}
|
||||
onReady={onReady}
|
||||
onSelect={onSelect}
|
||||
onPointerWorld={pointer.set}
|
||||
onViewChange={onViewChange}
|
||||
onOptionsApplied={readEngine}
|
||||
/>
|
||||
{!hasDrawing && !loadState ? <EmptyState onOpenFile={openFile} onSample={loadSample} /> : null}
|
||||
{loadState ? <LoadingOverlay state={loadState} /> : null}
|
||||
{dragging ? <DropOverlay /> : null}
|
||||
<LayersPanel
|
||||
open={panels.layers}
|
||||
onClose={() => setPanels((p) => ({ ...p, layers: false }))}
|
||||
anchorRef={stageRef}
|
||||
layers={layers}
|
||||
onToggle={onToggleLayer}
|
||||
onShowAll={onShowAll}
|
||||
onHideAll={onHideAll}
|
||||
/>
|
||||
<PropertiesPanel
|
||||
open={panels.props}
|
||||
onClose={() => setPanels((p) => ({ ...p, props: false }))}
|
||||
anchorRef={stageRef}
|
||||
entity={selected}
|
||||
onZoomToSelection={() => viewerRef.current?.zoomToSelection()}
|
||||
/>
|
||||
<OptionsPanel
|
||||
open={panels.options}
|
||||
onClose={() => setPanels((p) => ({ ...p, options: false }))}
|
||||
anchorRef={stageRef}
|
||||
engine={engine}
|
||||
grid={prefs.grid}
|
||||
view25D={view25D}
|
||||
onLineweightEnabled={onLineweightEnabled}
|
||||
onLineweightScale={onLineweightScale}
|
||||
onGrid={onGrid}
|
||||
onZoomSpeed={onZoomSpeed}
|
||||
on25D={apply25D}
|
||||
onZScale={onZScale}
|
||||
onReset={onReset}
|
||||
/>
|
||||
</div>
|
||||
<StatusBar status={status} pointer={pointer} zoom={zoom} onNotices={openAbout} />
|
||||
</main>
|
||||
{/* HM Input has no file-picker look of its own here; it stays hidden and the GNB / empty-state buttons open it. */}
|
||||
<Input ref={fileRef} type="file" accept=".dwg,.dxf" hidden aria-label="DWG / DXF 파일 선택" onChange={onFileChange} />
|
||||
<AboutDialog open={aboutOpen} onOpenChange={setAboutOpen} returnFocusRef={aboutOpener} />
|
||||
<Toaster />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
Reference in new issue
Block a user