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:
1 parent
1fd0b00df3
commit
b114581c07
54 files changed
+16494
No files matched your search
@@ -0,0 +1,336 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Viewer2D } from '../viewer2d/Viewer2D';
|
||||
import { parseDwgBuffer } from '../viewer2d/dwgParser';
|
||||
import { parseDxfBuffer } from '../viewer2d/parseDxf';
|
||||
import { isDwg, isDxf, is2D } from '../viewer2d/ext2d';
|
||||
import { listCadSpaces, pickDefaultSpace, type CadSpace } from '../viewer2d/cadSpaces';
|
||||
import { setDefaultFontUrl } from '../viewer2d/slugText';
|
||||
import { getAllOptions, setOption } from '../lib/optionsStore';
|
||||
|
||||
setDefaultFontUrl('/fonts/NanumGothic-Regular.ttf');
|
||||
|
||||
export interface LayerRow {
|
||||
name: string;
|
||||
colorHex: string;
|
||||
count: number;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export type LoadStatus = 'idle' | 'loading' | 'ready' | 'error';
|
||||
|
||||
export interface PersistedOptions {
|
||||
grid: boolean;
|
||||
zoomSpeed: number;
|
||||
zScale: number;
|
||||
lineweightOn: boolean | null;
|
||||
lineweightScale: number | null;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: PersistedOptions = {
|
||||
grid: false,
|
||||
zoomSpeed: 1,
|
||||
zScale: 1,
|
||||
lineweightOn: null,
|
||||
lineweightScale: null,
|
||||
};
|
||||
|
||||
async function parseByName(name: string, buf: ArrayBuffer) {
|
||||
if (isDwg(name)) return parseDwgBuffer(new Uint8Array(buf));
|
||||
if (isDxf(name)) return parseDxfBuffer(buf);
|
||||
if (!is2D(name)) throw new Error('지원하지 않는 파일 형식입니다 (.dwg/.dxf만 지원)');
|
||||
return parseDxfBuffer(buf);
|
||||
}
|
||||
|
||||
export function useDwgViewer() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewerRef = useRef<Viewer2D | null>(null);
|
||||
|
||||
const [status, setStatus] = useState<LoadStatus>('idle');
|
||||
const [statusMessage, setStatusMessage] = useState('준비됨');
|
||||
const [loadingProgress, setLoadingProgress] = useState<number | null>(null);
|
||||
|
||||
const [selectedEntity, setSelectedEntity] = useState<unknown>(null);
|
||||
const [pointerWorld, setPointerWorld] = useState<{ x: number; y: number; z: number } | null>(null);
|
||||
|
||||
const [spaces, setSpaces] = useState<CadSpace[]>([]);
|
||||
const [activeSpace, setActiveSpace] = useState<string | null>(null);
|
||||
|
||||
const [layers, setLayers] = useState<LayerRow[]>([]);
|
||||
const [hiddenLayers, setHiddenLayersState] = useState<Set<string>>(new Set());
|
||||
|
||||
const [theme, setThemeState] = useState<'dark' | 'light'>('dark');
|
||||
const [is25D, setIs25DState] = useState(false);
|
||||
const [options, setOptions] = useState<PersistedOptions>(DEFAULT_OPTIONS);
|
||||
|
||||
const refreshLayers = useCallback(() => {
|
||||
const viewer = viewerRef.current;
|
||||
if (!viewer) return;
|
||||
const info = viewer.getLayerInfo();
|
||||
setLayers(info.map((l) => ({ name: l.name, colorHex: l.colorHex, count: l.count, visible: l.visible })));
|
||||
}, []);
|
||||
|
||||
const refreshSpaces = useCallback(() => {
|
||||
const viewer = viewerRef.current;
|
||||
if (!viewer) return;
|
||||
setSpaces(viewer.getSpaces());
|
||||
setActiveSpace(viewer.getActiveSpace());
|
||||
}, []);
|
||||
|
||||
// mount Viewer2D once
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const viewer = new Viewer2D(containerRef.current);
|
||||
viewerRef.current = viewer;
|
||||
viewer.setTheme(true);
|
||||
viewer.onSelect((entity) => setSelectedEntity(entity));
|
||||
viewer.onPointerWorld((p) => setPointerWorld(p));
|
||||
|
||||
const onResize = () => viewer.resize();
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
(async () => {
|
||||
const saved = await getAllOptions();
|
||||
const next: PersistedOptions = {
|
||||
grid: typeof saved.grid === 'boolean' ? saved.grid : DEFAULT_OPTIONS.grid,
|
||||
zoomSpeed: typeof saved.zoomSpeed === 'number' ? saved.zoomSpeed : DEFAULT_OPTIONS.zoomSpeed,
|
||||
zScale: typeof saved.zScale === 'number' ? saved.zScale : DEFAULT_OPTIONS.zScale,
|
||||
lineweightOn:
|
||||
saved.lineweightOn === true || saved.lineweightOn === false ? saved.lineweightOn : null,
|
||||
lineweightScale: typeof saved.lineweightScale === 'number' ? saved.lineweightScale : null,
|
||||
};
|
||||
setOptions(next);
|
||||
viewer.setGrid(next.grid);
|
||||
viewer.setZoomSpeed(next.zoomSpeed);
|
||||
viewer.setZScale(next.zScale);
|
||||
viewer.setLineweightEnabled(next.lineweightOn);
|
||||
viewer.setLineweightScale(next.lineweightScale);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
viewer.onPointerWorld(null);
|
||||
viewerRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const loadParseResult = useCallback(
|
||||
(result: unknown) => {
|
||||
const viewer = viewerRef.current;
|
||||
if (!viewer) return;
|
||||
const spaceList = listCadSpaces(result as any);
|
||||
const def = pickDefaultSpace(spaceList);
|
||||
viewer.load(result, { spaceHandle: def?.handleHex ?? null });
|
||||
refreshSpaces();
|
||||
refreshLayers();
|
||||
setHiddenLayersState(new Set());
|
||||
setSelectedEntity(null);
|
||||
},
|
||||
[refreshSpaces, refreshLayers],
|
||||
);
|
||||
|
||||
const loadArrayBuffer = useCallback(
|
||||
async (name: string, buf: ArrayBuffer) => {
|
||||
setStatus('loading');
|
||||
setStatusMessage(`불러오는 중… (${name})`);
|
||||
setLoadingProgress(null);
|
||||
try {
|
||||
const result = await parseByName(name, buf);
|
||||
loadParseResult(result);
|
||||
setStatus('ready');
|
||||
setStatusMessage(`로드 완료: ${name}`);
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
setStatusMessage(err instanceof Error ? err.message : '파일을 불러오지 못했습니다');
|
||||
} finally {
|
||||
setLoadingProgress(null);
|
||||
}
|
||||
},
|
||||
[loadParseResult],
|
||||
);
|
||||
|
||||
const loadFile = useCallback(
|
||||
async (file: File) => {
|
||||
const buf = await file.arrayBuffer();
|
||||
await loadArrayBuffer(file.name, buf);
|
||||
},
|
||||
[loadArrayBuffer],
|
||||
);
|
||||
|
||||
const loadUrl = useCallback(
|
||||
async (url: string, label?: string) => {
|
||||
const name = label ?? url.split('/').pop() ?? url;
|
||||
setStatus('loading');
|
||||
setStatusMessage(`다운로드 중… (${name})`);
|
||||
setLoadingProgress(0);
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`다운로드 실패 (${res.status})`);
|
||||
const total = Number(res.headers.get('content-length') ?? 0);
|
||||
let buf: ArrayBuffer;
|
||||
if (total > 0 && res.body) {
|
||||
const reader = res.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let received = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
chunks.push(value);
|
||||
received += value.length;
|
||||
setLoadingProgress(Math.min(99, Math.round((received / total) * 100)));
|
||||
}
|
||||
}
|
||||
const merged = new Uint8Array(received);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
buf = merged.buffer;
|
||||
} else {
|
||||
setLoadingProgress(null);
|
||||
buf = await res.arrayBuffer();
|
||||
}
|
||||
await loadArrayBuffer(name, buf);
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
setStatusMessage(err instanceof Error ? err.message : '다운로드하지 못했습니다');
|
||||
setLoadingProgress(null);
|
||||
}
|
||||
},
|
||||
[loadArrayBuffer],
|
||||
);
|
||||
|
||||
const fit = useCallback(() => viewerRef.current?.fit(), []);
|
||||
|
||||
const zoomToSelection = useCallback(() => viewerRef.current?.zoomToSelection(), []);
|
||||
|
||||
const selectSpace = useCallback(
|
||||
(handleHex: string | null) => {
|
||||
viewerRef.current?.setSpace(handleHex);
|
||||
refreshSpaces();
|
||||
refreshLayers();
|
||||
},
|
||||
[refreshSpaces, refreshLayers],
|
||||
);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setThemeState((prev) => {
|
||||
const next = prev === 'dark' ? 'light' : 'dark';
|
||||
viewerRef.current?.setTheme(next === 'dark');
|
||||
refreshLayers();
|
||||
return next;
|
||||
});
|
||||
}, [refreshLayers]);
|
||||
|
||||
const setGrid = useCallback((on: boolean) => {
|
||||
viewerRef.current?.setGrid(on);
|
||||
setOptions((o) => ({ ...o, grid: on }));
|
||||
void setOption('grid', on);
|
||||
}, []);
|
||||
|
||||
const setZoomSpeed = useCallback((v: number) => {
|
||||
viewerRef.current?.setZoomSpeed(v);
|
||||
setOptions((o) => ({ ...o, zoomSpeed: v }));
|
||||
void setOption('zoomSpeed', v);
|
||||
}, []);
|
||||
|
||||
const setZScale = useCallback((v: number) => {
|
||||
viewerRef.current?.setZScale(v);
|
||||
setOptions((o) => ({ ...o, zScale: v }));
|
||||
void setOption('zScale', v);
|
||||
}, []);
|
||||
|
||||
const setLineweightOn = useCallback((on: boolean | null) => {
|
||||
viewerRef.current?.setLineweightEnabled(on);
|
||||
setOptions((o) => ({ ...o, lineweightOn: on }));
|
||||
void setOption('lineweightOn', on);
|
||||
}, []);
|
||||
|
||||
const setLineweightScale = useCallback((v: number | null) => {
|
||||
viewerRef.current?.setLineweightScale(v);
|
||||
setOptions((o) => ({ ...o, lineweightScale: v }));
|
||||
void setOption('lineweightScale', v);
|
||||
}, []);
|
||||
|
||||
const set25D = useCallback((on: boolean) => {
|
||||
viewerRef.current?.set25D(on);
|
||||
setIs25DState(on);
|
||||
}, []);
|
||||
|
||||
const resetOptions = useCallback(() => {
|
||||
setLineweightScale(null);
|
||||
setLineweightOn(null);
|
||||
setGrid(false);
|
||||
setZoomSpeed(1);
|
||||
setZScale(1);
|
||||
set25D(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const toggleLayer = useCallback((name: string) => {
|
||||
setHiddenLayersState((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
viewerRef.current?.setHiddenLayers(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const showAllLayers = useCallback(() => {
|
||||
setHiddenLayersState(() => {
|
||||
const next = new Set<string>();
|
||||
viewerRef.current?.setHiddenLayers(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const hideAllLayers = useCallback(() => {
|
||||
setHiddenLayersState(() => {
|
||||
const next = new Set(layers.map((l) => l.name));
|
||||
viewerRef.current?.setHiddenLayers(next);
|
||||
return next;
|
||||
});
|
||||
}, [layers]);
|
||||
|
||||
const getLineweightClasses = useCallback(() => viewerRef.current?.getLineweightClasses() ?? [], []);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
viewerRef,
|
||||
status,
|
||||
statusMessage,
|
||||
setStatusMessage,
|
||||
loadingProgress,
|
||||
selectedEntity,
|
||||
pointerWorld,
|
||||
spaces,
|
||||
activeSpace,
|
||||
layers,
|
||||
hiddenLayers,
|
||||
theme,
|
||||
is25D,
|
||||
options,
|
||||
loadFile,
|
||||
loadUrl,
|
||||
fit,
|
||||
zoomToSelection,
|
||||
selectSpace,
|
||||
toggleTheme,
|
||||
set25D,
|
||||
setGrid,
|
||||
setZoomSpeed,
|
||||
setZScale,
|
||||
setLineweightOn,
|
||||
setLineweightScale,
|
||||
resetOptions,
|
||||
toggleLayer,
|
||||
showAllLayers,
|
||||
hideAllLayers,
|
||||
getLineweightClasses,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseDwgViewer = ReturnType<typeof useDwgViewer>;
|
||||
Reference in new issue
Block a user