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
337 lines
10 KiB
TypeScript
337 lines
10 KiB
TypeScript
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>;
|