feat: React19+HMDesign shell with ported DWG/DXF viewer engine

Scaffolds the new app (Vite+React19+Tailwind v4) on HMDesign via git
submodule + npm workspace, ports src/viewer2d/* and optionsStore.ts
unchanged, and rebuilds the vanilla UI (toolbar/layers/properties/
options panels, coord readout, license dialog, load progress) as HM
Design System components. Panel drag/collapse ported from
panelUtils.ts as a React hook; Measure UI intentionally left
unwired per original app parity.

Typecheck and dev-server module serving verified; in-browser
rendering not yet verified in this session (no browser tool
available) — needs manual check.

Refs #1
This commit is contained in:
minsung committed 2026-10-12 17:42:58 +09:00
1 parent 1fd0b00df3
commit b114581c07
54 files changed
+16494

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
function formatCadCoord(v: number | null): string {
if (v === null || !Number.isFinite(v)) return '—';
const abs = Math.abs(v);
const digits = abs >= 1e6 ? 1 : abs >= 1e3 ? 2 : abs >= 1 ? 3 : 4;
return v.toFixed(digits);
}
export interface CoordReadoutProps {
point: { x: number; y: number; z: number } | null;
spaceKind: 'model' | 'paper' | 'other' | null;
}
export function CoordReadout({ point, spaceKind }: CoordReadoutProps) {
const unitLabel = spaceKind === 'paper' ? 'Paper' : 'UCS';
return (
<div
className="pointer-events-none fixed bottom-3 right-3 z-10 rounded-md border bg-card/90 px-3 py-1.5 font-mono text-xs text-muted-foreground shadow-sm backdrop-blur"
title={`마우스 위치 CAD 좌표 (${spaceKind === 'paper' ? 'Layout=Paper' : 'Model=UCS'} 도면단위)`}
>
X {formatCadCoord(point?.x ?? null)} · Y {formatCadCoord(point?.y ?? null)}{' '}
<span className="opacity-60">({unitLabel})</span>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
import type { ReactNode } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardAction } from '@hm-design/ui';
import { Button } from '@hm-design/ui';
import { ChevronDown, ChevronRight, X } from 'lucide-react';
import { useDraggablePanel } from '../hooks/useDraggablePanel';
export interface DraggablePanelProps {
title: string;
icon?: ReactNode;
initial: { left: number; top: number };
width?: number;
height?: number;
open: boolean;
onClose: () => void;
headerActions?: ReactNode;
children: ReactNode;
}
export function DraggablePanel({
title,
icon,
initial,
width = 320,
height = 420,
open,
onClose,
headerActions,
children,
}: DraggablePanelProps) {
const { panelRef, style, collapsed, toggleCollapse, headerProps } = useDraggablePanel(initial);
if (!open) return null;
return (
<Card
ref={panelRef}
size="sm"
className="fixed shadow-lg overflow-hidden"
style={{
...style,
width,
height: collapsed ? 'auto' : height,
minWidth: 250,
minHeight: collapsed ? 'auto' : 140,
maxWidth: '90vw',
maxHeight: '90vh',
resize: collapsed ? 'none' : 'both',
}}
>
<CardHeader className="select-none border-b py-2" {...headerProps}>
<CardTitle className="flex items-center gap-2 text-sm">
{icon}
{title}
</CardTitle>
<CardAction className="flex items-center gap-1">
{headerActions}
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={collapsed ? '펼치기' : '접기'}
title={collapsed ? '펼치기' : '접기'}
onClick={toggleCollapse}
>
{collapsed ? <ChevronRight /> : <ChevronDown />}
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="닫기"
title="닫기"
onClick={onClose}
>
<X />
</Button>
</CardAction>
</CardHeader>
{!collapsed && (
<CardContent className="overflow-auto" style={{ height: 'calc(100% - 44px)' }}>
{children}
</CardContent>
)}
</Card>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { useMemo, useState } from 'react';
import { Badge, Button, FilterBar, FilterBarActions, FilterBarSearch } from '@hm-design/ui';
import { Eye, EyeOff, Layers } from 'lucide-react';
import { DraggablePanel } from './DraggablePanel';
import type { LayerRow } from '../hooks/useDwgViewer';
export interface LayerPanelProps {
open: boolean;
onClose: () => void;
layers: LayerRow[];
hiddenLayers: Set<string>;
onToggleLayer: (name: string) => void;
onShowAll: () => void;
onHideAll: () => void;
}
export function LayerPanel({ open, onClose, layers, hiddenLayers, onToggleLayer, onShowAll, onHideAll }: LayerPanelProps) {
const [query, setQuery] = useState('');
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return layers;
return layers.filter((l) => l.name.toLowerCase().includes(q));
}, [layers, query]);
return (
<DraggablePanel
title={`레이어 (${layers.length}개)`}
icon={<Layers className="size-4" />}
initial={{ left: window.innerWidth - 344, top: 56 }}
open={open}
onClose={onClose}
>
<div className="space-y-3">
<FilterBar>
<FilterBarSearch value={query} onValueChange={setQuery} placeholder="레이어 검색" size="small" />
<FilterBarActions className="ml-2 gap-1">
<Button type="button" variant="outline" size="xs" onClick={onShowAll}>
전체 켜기
</Button>
<Button type="button" variant="outline" size="xs" onClick={onHideAll}>
전체 끄기
</Button>
</FilterBarActions>
</FilterBar>
{layers.length === 0 ? (
<p className="py-6 text-center text-xs text-muted-foreground">도면에 레이어 정보가 없습니다</p>
) : filtered.length === 0 ? (
<p className="py-6 text-center text-xs text-muted-foreground">검색 결과가 없습니다 ("{query}")</p>
) : (
<ul className="space-y-1">
{filtered.map((layer) => {
const visible = !hiddenLayers.has(layer.name);
return (
<li
key={layer.name}
className="flex items-center gap-2 rounded-md px-1.5 py-1 text-sm hover:bg-muted/50"
style={{ opacity: visible ? 1 : 0.45 }}
>
<span
className="size-2.5 shrink-0 rounded-full border"
style={{ background: layer.colorHex }}
aria-hidden
/>
<span className="min-w-0 flex-1 truncate font-mono text-xs" title={layer.name}>
{layer.name}
</span>
<Badge variant="secondary">{layer.count}</Badge>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={visible ? '숨기기' : '표시하기'}
title={visible ? '숨기기' : '표시하기'}
onClick={() => onToggleLayer(layer.name)}
>
{visible ? <Eye /> : <EyeOff />}
</Button>
</li>
);
})}
</ul>
)}
</div>
</DraggablePanel>
);
}
+50
View File
@@ -0,0 +1,50 @@
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@hm-design/ui';
import { Info } from 'lucide-react';
const PIPELINE_STEPS = [
'파일 로드 (.dwg → acadrust WASM, .dxf → dxf-parser)',
'CAD 파싱 결과 정규화 (레이어 / 스페이스 / 엔티티)',
'Three.js 씬 구성 및 렌더링',
];
export function LicenseNotice() {
return (
<Dialog>
<DialogTrigger asChild>
<Button type="button" variant="ghost" size="icon-xs" aria-label="정보 및 라이선스" title="정보 및 라이선스">
<Info />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>모듈 구성 · 라이선스 고지</DialogTitle>
<DialogDescription>이 뷰어가 사용하는 처리 파이프라인과 서드파티 라이선스 고지입니다.</DialogDescription>
</DialogHeader>
<ol className="list-decimal space-y-1 pl-5 text-sm text-foreground">
{PIPELINE_STEPS.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
<div className="flex flex-col gap-1 border-t pt-3 text-sm">
<a className="text-primary underline-offset-4 hover:underline" href="/licenses/THIRD-PARTY-NOTICES.md" target="_blank" rel="noreferrer">
THIRD-PARTY-NOTICES.md
</a>
<a className="text-primary underline-offset-4 hover:underline" href="/licenses/acadrust-MPL-2.0.txt" target="_blank" rel="noreferrer">
acadrust MPL-2.0 전문
</a>
<a className="text-primary underline-offset-4 hover:underline" href="/licenses/acadrust-0.4.1-read_mesh.patch" target="_blank" rel="noreferrer">
acadrust 0.4.1 read_mesh 패치
</a>
</div>
</DialogContent>
</Dialog>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { Progress } from '@hm-design/ui';
export interface LoadProgressProps {
active: boolean;
value: number | null;
error?: boolean;
}
export function LoadProgress({ active, value, error }: LoadProgressProps) {
if (!active) return null;
return (
<Progress
value={value ?? undefined}
className={`fixed inset-x-0 top-0 z-50 h-1 rounded-none ${
value === null ? 'animate-pulse' : ''
} ${error ? '[&>*]:bg-destructive' : ''}`}
/>
);
}
+168
View File
@@ -0,0 +1,168 @@
import { Button, Input, Label, Slider, Switch } from '@hm-design/ui';
import { RotateCcw } from 'lucide-react';
import { DraggablePanel } from './DraggablePanel';
import { SlidersHorizontal } from 'lucide-react';
import type { PersistedOptions } from '../hooks/useDwgViewer';
function SliderNumberRow({
label,
value,
min,
max,
step,
unit,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
step: number;
unit?: string;
onChange: (v: number) => void;
}) {
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">{label}</Label>
<div className="flex items-center gap-1">
<Input
type="number"
min={min}
max={max}
step={step}
value={value}
size="small"
className="w-20"
onChange={(e) => {
const v = Number(e.target.value);
if (Number.isFinite(v)) onChange(v);
}}
/>
{unit && <span className="text-xs text-muted-foreground">{unit}</span>}
</div>
</div>
<Slider
value={[value]}
min={min}
max={max}
step={step}
aria-label={label}
onValueChange={([v]) => onChange(v)}
/>
</div>
);
}
export interface OptionsPanelProps {
open: boolean;
onClose: () => void;
options: PersistedOptions;
is25D: boolean;
lineweightClasses: number[];
onSetGrid: (on: boolean) => void;
onSetZoomSpeed: (v: number) => void;
onSetZScale: (v: number) => void;
onSetLineweightOn: (on: boolean | null) => void;
onSetLineweightScale: (v: number | null) => void;
onSet25D: (on: boolean) => void;
onReset: () => void;
}
export function OptionsPanel({
open,
onClose,
options,
is25D,
lineweightClasses,
onSetGrid,
onSetZoomSpeed,
onSetZScale,
onSetLineweightOn,
onSetLineweightScale,
onSet25D,
onReset,
}: OptionsPanelProps) {
const lwScale = options.lineweightScale ?? 11;
const lwPreview =
lineweightClasses.length > 0
? lineweightClasses.map((mm) => `${mm.toFixed(2)}mm → ${(mm * lwScale).toFixed(1)}px`).join(' · ')
: `이 도면에는 굵은 선이 없습니다 (0.50mm 였다면 ${(0.5 * lwScale).toFixed(1)}px)`;
return (
<DraggablePanel
title="옵션"
icon={<SlidersHorizontal className="size-4" />}
initial={{ left: window.innerWidth - 676, top: 56 }}
width={340}
height={520}
open={open}
onClose={onClose}
headerActions={
<Button type="button" variant="ghost" size="icon-xs" title="기본값으로" aria-label="기본값으로" onClick={onReset}>
<RotateCcw />
</Button>
}
>
<div className="space-y-5">
<section className="space-y-3">
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">선가중치</h4>
<label className="flex items-center justify-between text-sm">
표시 사용
<Switch
checked={options.lineweightOn ?? false}
onCheckedChange={(on) => onSetLineweightOn(on)}
/>
</label>
<SliderNumberRow
label="표시 배율 (px/mm)"
value={lwScale}
min={2}
max={60}
step={0.5}
onChange={onSetLineweightScale}
/>
<p className="text-xs text-muted-foreground">{lwPreview}</p>
<p className="text-xs text-muted-foreground">AutoCAD의 "선가중치 설정 → 표시 배율"과 동일합니다.</p>
</section>
<section className="space-y-3 border-t pt-4">
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">표시</h4>
<label className="flex items-center justify-between text-sm">
그리드
<Switch checked={options.grid} onCheckedChange={onSetGrid} />
</label>
<SliderNumberRow
label="줌 속도"
value={options.zoomSpeed}
min={0.2}
max={3}
step={0.1}
onChange={onSetZoomSpeed}
/>
</section>
<section className="space-y-3 border-t pt-4">
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">2.5D</h4>
<label className="flex items-center justify-between text-sm">
2.5D 보기
<Switch checked={is25D} onCheckedChange={onSet25D} />
</label>
<div className={is25D ? '' : 'pointer-events-none opacity-50'}>
<SliderNumberRow
label="Z 높이 배율"
value={options.zScale}
min={1}
max={50}
step={0.5}
onChange={onSetZScale}
/>
</div>
<p className="text-xs text-muted-foreground">
좌클릭 드래그로 회전, 중간/우클릭 드래그로 이동합니다.
</p>
</section>
</div>
</DraggablePanel>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { Badge, Button } from '@hm-design/ui';
import { Info, MousePointerClick, ZoomIn } from 'lucide-react';
import { DraggablePanel } from './DraggablePanel';
import { formatEntityProperties } from '../lib/propertyFormat';
function PropertyRows({ rows }: { rows: [string, string][] }) {
return (
<div className="divide-y">
{rows.map(([k, v]) => (
<div key={k} className="flex items-center justify-between gap-3 py-1.5 text-xs">
<span className="shrink-0 text-muted-foreground">{k}</span>
<span className="truncate text-right font-mono text-foreground" style={{ userSelect: 'all' }} title={v}>
{v}
</span>
</div>
))}
</div>
);
}
export interface PropertiesPanelProps {
open: boolean;
onClose: () => void;
entity: unknown;
onZoomToSelection: () => void;
}
export function PropertiesPanel({ open, onClose, entity, onZoomToSelection }: PropertiesPanelProps) {
const prop = formatEntityProperties(entity);
return (
<DraggablePanel
title="속성"
icon={<Info className="size-4" />}
initial={{ left: window.innerWidth - 12 - 320, top: 56 }}
open={open}
onClose={onClose}
headerActions={
entity ? (
<Button type="button" variant="ghost" size="icon-xs" title="선택 확대" aria-label="선택 확대" onClick={onZoomToSelection}>
<ZoomIn />
</Button>
) : null
}
>
{!entity ? (
<div className="flex flex-col items-center gap-2 py-10 text-center text-xs text-muted-foreground">
<MousePointerClick className="size-6 opacity-50" />
도면에서 객체(엔티티)를 클릭하면
<br />
상세 속성이 표시됩니다
</div>
) : (
<div className="space-y-4">
<Badge variant="outline" className="font-mono">
{prop.id}
</Badge>
<div>
<h4 className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
일반 정보 (General)
</h4>
<PropertyRows rows={prop.general} />
</div>
{prop.geometry.length > 0 && (
<div>
<h4 className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-primary">
기하 속성 (Geometry)
</h4>
<PropertyRows rows={prop.geometry} />
</div>
)}
</div>
)}
</DraggablePanel>
);
}
+122
View File
@@ -0,0 +1,122 @@
import { useRef } from 'react';
import { Button, Switch, Tabs, TabsList, TabsTrigger, Badge, Toggle } from '@hm-design/ui';
import { FolderOpen, Scan, Layers, SlidersHorizontal, Info as InfoIcon } from 'lucide-react';
import type { CadSpace } from '../viewer2d/cadSpaces';
import { LicenseNotice } from './LicenseNotice';
export interface ToolbarProps {
status: string;
statusError: boolean;
spaces: CadSpace[];
activeSpace: string | null;
onSelectSpace: (handleHex: string | null) => void;
onOpenFile: (file: File) => void;
onLoadSample: (url: string, label: string) => void;
onFit: () => void;
is25D: boolean;
onToggle25D: (on: boolean) => void;
canvasDark: boolean;
onToggleTheme: () => void;
layersOpen: boolean;
onToggleLayers: () => void;
optionsOpen: boolean;
onToggleOptions: () => void;
propsOpen: boolean;
onToggleProps: () => void;
}
export function Toolbar({
status,
statusError,
spaces,
activeSpace,
onSelectSpace,
onOpenFile,
onLoadSample,
onFit,
is25D,
onToggle25D,
canvasDark,
onToggleTheme,
layersOpen,
onToggleLayers,
optionsOpen,
onToggleOptions,
propsOpen,
onToggleProps,
}: ToolbarProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const showSpaceTabs = spaces.length > 1 || (spaces[0]?.entityCount ?? 0) > 0;
return (
<div className="pointer-events-none fixed inset-x-0 top-0 z-20 flex items-center gap-2 border-b bg-card/90 px-3 py-2 backdrop-blur [&>*]:pointer-events-auto">
<input
ref={fileInputRef}
type="file"
accept=".dwg,.dxf"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onOpenFile(file);
e.currentTarget.value = '';
}}
/>
<Button type="button" variant="outline" size="sm" onClick={() => fileInputRef.current?.click()}>
<FolderOpen /> 파일 열기
</Button>
<Button type="button" variant="secondary" size="sm" title="acadrust-dwg WASM" onClick={() => onLoadSample('/samples/BasicSample.dwg', 'BasicSample.dwg')}>
Sample DWG
</Button>
<Button type="button" variant="secondary" size="sm" onClick={() => onLoadSample('/samples/civil.dwg', 'C0060202-001-평면및종단면도.dwg')}>
Sample Civil
</Button>
<Button type="button" variant="secondary" size="sm" title="dxf-parser" onClick={() => onLoadSample('/samples/simple.dxf', 'simple.dxf')}>
Sample DXF
</Button>
{showSpaceTabs && (
<Tabs value={activeSpace ?? undefined} onValueChange={(v) => onSelectSpace(v)}>
<TabsList>
{spaces.map((s) => (
<TabsTrigger key={s.handleHex} value={s.handleHex} title={`${s.name} · ${s.handleHex}`}>
{s.label}
{s.entityCount > 0 ? ` (${s.entityCount})` : ''}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
<Button type="button" variant="ghost" size="sm" onClick={onFit} title="Fit (F)">
<Scan /> Fit
</Button>
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Switch checked={is25D} onCheckedChange={onToggle25D} />
2.5D
</label>
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Switch checked={canvasDark} onCheckedChange={onToggleTheme} />
캔버스 테마
</label>
<div className="ml-auto flex items-center gap-1">
<Toggle pressed={layersOpen} onPressedChange={onToggleLayers} size="sm" aria-label="레이어 패널" title="Layers">
<Layers />
</Toggle>
<Toggle pressed={optionsOpen} onPressedChange={onToggleOptions} size="sm" aria-label="옵션 패널" title="Options">
<SlidersHorizontal />
</Toggle>
<Toggle pressed={propsOpen} onPressedChange={onToggleProps} size="sm" aria-label="속성 패널" title="Properties">
<InfoIcon />
</Toggle>
<LicenseNotice />
</div>
<Badge variant={statusError ? 'destructive' : 'secondary'} className="max-w-[320px] truncate">
{status}
</Badge>
</div>
);
}