feat(engine): port the viewer engine and panel logic unchanged

Copy the vanilla engine from dwg-dxf-viewer-sample verbatim (#1):

- src/engine/viewer2d/* (Viewer2D.js incl. the 2.5D view, parsers,
  acadrust-dwg WASM, cadSpaces, slugText, linParser/linData,
  fixDwgKoreanText, ...)
- src/engine/{layerPanel,panelUtils,propertyInspector,optionsStore}.ts

The relative layout is kept (engine/viewer2d next to the panel files),
so not even import paths changed; `diff -r --strip-trailing-cr` against
the sample is empty. public/ (NanumGothic, samples, licenses, module
map) is copied as is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
minsungandClaude Opus 5.5 committed 2026-10-12 18:48:56 +09:00
1 parent 948021c7ac
commit bc8875770b
38 files changed
+8480

No files matched your search

+195
View File
@@ -0,0 +1,195 @@
/**
* Layer Panel manager for dwg-dxf-viewer-sample.
* Renders layer items, color swatches, entity counts, search filters, and visibility toggles.
*/
import type { Viewer2D } from './viewer2d/Viewer2D';
export interface LayerItem {
name: string;
colorHex: string;
count: number;
visible: boolean;
}
export class LayerPanelManager {
private viewer: Viewer2D;
private listEl: HTMLElement;
private countEl: HTMLElement | null;
private searchInputEl: HTMLInputElement | null;
private hiddenLayers: Set<string> = new Set();
private searchQuery: string = '';
constructor(
viewer: Viewer2D,
listEl: HTMLElement,
options?: {
countEl?: HTMLElement | null;
searchInputEl?: HTMLInputElement | null;
showAllBtnEl?: HTMLElement | null;
hideAllBtnEl?: HTMLElement | null;
}
) {
this.viewer = viewer;
this.listEl = listEl;
this.countEl = options?.countEl || null;
this.searchInputEl = options?.searchInputEl || null;
if (this.searchInputEl) {
this.searchInputEl.addEventListener('input', (e) => {
this.searchQuery = (e.target as HTMLInputElement).value.trim().toLowerCase();
this.render();
});
}
if (options?.showAllBtnEl) {
options.showAllBtnEl.addEventListener('click', () => this.showAllLayers());
}
if (options?.hideAllBtnEl) {
options.hideAllBtnEl.addEventListener('click', () => this.hideAllLayers());
}
// Delegated click handling: bound once, so render() never has to re-attach
// a listener per row (that was O(layers) DOM work on every single toggle).
this.listEl.addEventListener('click', (e) => {
const row = (e.target as HTMLElement)?.closest?.('.layer-row') as HTMLElement | null;
if (!row) return;
const layerName = row.dataset.name;
if (layerName) this.toggleLayer(layerName);
});
}
/** Refresh and re-render layer list from Viewer2D state */
public update(): void {
this.hiddenLayers.clear();
const layers = this.viewer.getLayerInfo() || [];
for (const l of layers) {
if (!l.visible) {
this.hiddenLayers.add(l.name);
}
}
this.render();
}
public showAllLayers(): void {
this.hiddenLayers.clear();
this.viewer.setHiddenLayers(this.hiddenLayers);
this.syncRows();
}
public hideAllLayers(): void {
const layers = this.viewer.getLayerInfo() || [];
for (const l of layers) {
this.hiddenLayers.add(l.name);
}
this.viewer.setHiddenLayers(this.hiddenLayers);
this.syncRows();
}
public toggleLayer(name: string): void {
if (this.hiddenLayers.has(name)) {
this.hiddenLayers.delete(name);
} else {
this.hiddenLayers.add(name);
}
this.viewer.setHiddenLayers(this.hiddenLayers);
this.syncRow(name);
}
/** Patch one row in place — a full render() would rebuild the whole list. */
private syncRow(name: string): void {
const row = this.listEl?.querySelector<HTMLElement>(
`.layer-row[data-name="${cssEscape(name)}"]`
);
if (!row) return;
this.paintRow(row, !this.hiddenLayers.has(name));
}
/** Patch every row (show-all / hide-all) without touching the DOM structure. */
private syncRows(): void {
if (!this.listEl) return;
this.listEl.querySelectorAll<HTMLElement>('.layer-row').forEach((row) => {
const name = row.dataset.name;
if (name != null) this.paintRow(row, !this.hiddenLayers.has(name));
});
}
private paintRow(row: HTMLElement, visible: boolean): void {
row.style.opacity = visible ? '1' : '0.4';
const btn = row.querySelector<HTMLElement>('.eye-btn');
if (btn) {
btn.textContent = visible ? '👁️' : '🕶️';
btn.title = visible ? '숨기기' : '표시하기';
}
}
public render(): void {
if (!this.listEl) return;
const layers: LayerItem[] = this.viewer.getLayerInfo() || [];
if (this.countEl) {
this.countEl.textContent = `${layers.length}개`;
}
if (!layers.length) {
this.listEl.innerHTML = `
<div style="padding:30px 16px;text-align:center;color:var(--muted);font-size:12px">
도면에 레이어 정보가 없습니다
</div>`;
return;
}
const filtered = layers.filter((l) =>
l.name.toLowerCase().includes(this.searchQuery)
);
if (!filtered.length) {
this.listEl.innerHTML = `
<div style="padding:24px 16px;text-align:center;color:var(--muted);font-size:12px">
검색 결과가 없습니다 ("${escapeHtml(this.searchQuery)}")
</div>`;
return;
}
this.listEl.innerHTML = filtered
.map((l) => {
const isVisible = !this.hiddenLayers.has(l.name);
const opacity = isVisible ? '1' : '0.4';
const eyeIcon = isVisible ? '👁️' : '🕶️';
const eyeTitle = isVisible ? '숨기기' : '표시하기';
return `
<div class="layer-row" data-name="${escapeHtml(l.name)}"
style="display:flex;align-items:center;gap:10px;padding:7px 10px;border-bottom:1px solid var(--line);opacity:${opacity};transition:opacity .15s;user-select:none">
<span style="width:10px;height:10px;border-radius:3px;background:${l.colorHex || '#888'};flex-shrink:0;box-shadow:0 0 0 1px rgba(255,255,255,.15)"></span>
<span class="mono" style="flex:1;font-size:12px;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${escapeHtml(l.name)}">
${escapeHtml(l.name)}
</span>
<span class="mono" style="font-size:11px;color:var(--muted);background:rgba(255,255,255,.06);padding:2px 6px;border-radius:4px;flex-shrink:0">
${l.count}
</span>
<button type="button" class="eye-btn" data-toggle="${escapeHtml(l.name)}" title="${eyeTitle}"
style="background:none;border:none;cursor:pointer;font-size:13px;padding:2px 4px;border-radius:4px;color:var(--ink);transition:transform .1s">
${eyeIcon}
</button>
</div>`;
})
.join('');
// Clicks are handled by the delegated listener bound in the constructor.
}
}
/** Escape a layer name for use inside a double-quoted CSS attribute selector. */
function cssEscape(str: string): string {
return str.replace(/["\\]/g, '\\$&');
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
+75
View File
@@ -0,0 +1,75 @@
/**
* 뷰어 옵션 영구 저장소 (IndexedDB).
*
* localStorage 대신 IndexedDB 를 쓰는 이유: 값이 문자열로 강제되지 않고(숫자/불린/
* 객체 그대로), 용량 제한이 사실상 없으며, 나중에 도면별 설정처럼 구조가 커져도
* 같은 저장소를 그대로 쓸 수 있다.
*
* DB 하나(`hmw-viewer`) 안에 key-value 스토어(`options`) 하나만 둔다.
* 실패(사생활 보호 모드, 스토리지 차단 등)해도 앱이 죽지 않도록 모든 API 는
* 조용히 기본값으로 떨어진다.
*/
const DB_NAME = 'hmw-viewer';
const DB_VERSION = 1;
const STORE = 'options';
let dbPromise: Promise<IDBDatabase | null> | null = null;
function openDb(): Promise<IDBDatabase | null> {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve) => {
if (typeof indexedDB === 'undefined') return resolve(null);
let req: IDBOpenDBRequest;
try {
req = indexedDB.open(DB_NAME, DB_VERSION);
} catch {
return resolve(null);
}
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(null);
req.onblocked = () => resolve(null);
});
return dbPromise;
}
function tx<T>(mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest): Promise<T | null> {
return openDb().then(
(db) =>
new Promise<T | null>((resolve) => {
if (!db) return resolve(null);
try {
const t = db.transaction(STORE, mode);
const req = run(t.objectStore(STORE));
req.onsuccess = () => resolve(req.result as T);
req.onerror = () => resolve(null);
} catch {
resolve(null);
}
}),
);
}
/** 저장된 옵션 하나. 없으면 `fallback`. */
export async function getOption<T>(key: string, fallback: T): Promise<T> {
const v = await tx<T>('readonly', (s) => s.get(key));
return v === null || v === undefined ? fallback : v;
}
/** 옵션 하나 저장. 저장이 막혀 있어도 예외를 던지지 않는다. */
export async function setOption(key: string, value: unknown): Promise<void> {
await tx('readwrite', (s) => s.put(value, key));
}
/** 전체 옵션을 한 번에 읽는다 (기동 시 1회 호출용). */
export async function getAllOptions(): Promise<Record<string, unknown>> {
const keys = (await tx<IDBValidKey[]>('readonly', (s) => s.getAllKeys())) ?? [];
const vals = (await tx<unknown[]>('readonly', (s) => s.getAll())) ?? [];
const out: Record<string, unknown> = {};
keys.forEach((k, i) => { out[String(k)] = vals[i]; });
return out;
}
+149
View File
@@ -0,0 +1,149 @@
/**
* Utility helper to make floating UI panels draggable and collapsible.
*/
let globalMaxZIndex = 30;
export interface PanelControlOptions {
handleEl?: HTMLElement | null;
collapseBtnEl?: HTMLElement | null;
bodyEl?: HTMLElement | null;
onCollapseToggle?: (isCollapsed: boolean) => void;
}
export function makePanelDraggableAndCollapsible(
panelEl: HTMLElement,
options?: PanelControlOptions
): { toggleCollapse: () => void; isCollapsed: () => boolean } {
if (!panelEl) return { toggleCollapse: () => {}, isCollapsed: () => false };
const handleEl = (options?.handleEl || panelEl.querySelector('.hdr') || panelEl) as HTMLElement;
const collapseBtnEl = (options?.collapseBtnEl || panelEl.querySelector('.collapse-btn')) as HTMLElement | null;
const bodyEl = (options?.bodyEl || panelEl.querySelector('#propBody') || panelEl.querySelector('#layerBody') || panelEl.children[1]) as HTMLElement | null;
let isCollapsed = false;
let isDragging = false;
let startX = 0;
let startY = 0;
let initialLeft = 0;
let initialTop = 0;
// Set initial drag cursor
if (handleEl) {
handleEl.style.cursor = 'grab';
handleEl.style.userSelect = 'none';
}
// Focus panel to top z-index when clicked anywhere
panelEl.addEventListener('pointerdown', () => {
globalMaxZIndex++;
panelEl.style.zIndex = String(globalMaxZIndex);
});
// Dragging handler
const onPointerDown = (e: PointerEvent) => {
// Ignore clicks on buttons, inputs, or interactive controls inside the header
const target = e.target as HTMLElement;
if (target.closest('button, input, select, a, label, .hdr-actions')) {
return;
}
isDragging = true;
globalMaxZIndex++;
panelEl.style.zIndex = String(globalMaxZIndex);
if (handleEl) handleEl.style.cursor = 'grabbing';
const rect = panelEl.getBoundingClientRect();
startX = e.clientX;
startY = e.clientY;
initialLeft = rect.left;
initialTop = rect.top;
// Convert CSS right/bottom to explicit left/top positioning on first drag
panelEl.style.left = `${initialLeft}px`;
panelEl.style.top = `${initialTop}px`;
panelEl.style.right = 'auto';
panelEl.style.bottom = 'auto';
document.addEventListener('pointermove', onPointerMove);
document.addEventListener('pointerup', onPointerUp);
e.preventDefault();
};
const onPointerMove = (e: PointerEvent) => {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
let newLeft = initialLeft + dx;
let newTop = initialTop + dy;
// Clamp within window boundaries so panel doesn't disappear off-screen
const maxLeft = Math.max(10, window.innerWidth - panelEl.offsetWidth - 10);
const maxTop = Math.max(10, window.innerHeight - panelEl.offsetHeight - 10);
newLeft = Math.max(10, Math.min(newLeft, maxLeft));
newTop = Math.max(10, Math.min(newTop, maxTop));
panelEl.style.left = `${newLeft}px`;
panelEl.style.top = `${newTop}px`;
};
const onPointerUp = () => {
if (!isDragging) return;
isDragging = false;
if (handleEl) handleEl.style.cursor = 'grab';
document.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('pointerup', onPointerUp);
};
if (handleEl) {
handleEl.addEventListener('pointerdown', onPointerDown as EventListener);
// Double-click on header to toggle collapse/expand
handleEl.addEventListener('dblclick', (e) => {
const target = e.target as HTMLElement;
if (target.closest('button, input, select, a, label')) return;
toggleCollapseState();
});
}
let lastHeight = '';
// Collapse / Expand toggle function
const toggleCollapseState = () => {
isCollapsed = !isCollapsed;
panelEl.classList.toggle('collapsed', isCollapsed);
if (isCollapsed) {
lastHeight = panelEl.style.height || `${panelEl.offsetHeight}px`;
panelEl.style.height = '42px';
if (bodyEl) bodyEl.style.display = 'none';
} else {
panelEl.style.height = lastHeight || '480px';
if (bodyEl) bodyEl.style.display = 'flex';
}
if (collapseBtnEl) {
collapseBtnEl.textContent = isCollapsed ? '▸' : '▾';
collapseBtnEl.setAttribute('title', isCollapsed ? '펼치기' : '접기');
}
if (options?.onCollapseToggle) {
options.onCollapseToggle(isCollapsed);
}
};
if (collapseBtnEl) {
collapseBtnEl.addEventListener('click', (e) => {
e.stopPropagation();
toggleCollapseState();
});
}
return {
toggleCollapse: toggleCollapseState,
isCollapsed: () => isCollapsed,
};
}
+358
View File
@@ -0,0 +1,358 @@
/**
* Entity Property Inspector module for dwg-dxf-viewer-sample.
* Formats CAD entity properties and renders detailed property UI panels.
*/
export interface FormattedProperties {
id: string;
type: string;
handle: string;
layer: string;
general: [string, string][];
geometry: [string, string][];
}
const f2 = (v: any): string => (typeof v === 'number' ? v.toFixed(2) : (v ?? '-'));
const f3 = (v: any): string => (typeof v === 'number' ? v.toFixed(3) : (v ?? '-'));
const deg = (r: any): string => {
if (typeof r !== 'number') return '-';
let d = (r * 180) / Math.PI;
while (d < 0) d += 360;
while (d >= 360) d -= 360;
return d.toFixed(1) + '°';
};
const pt = (p: any): string => {
if (!p) return '-';
const x = typeof p.x === 'number' ? f2(p.x) : '0.00';
const y = typeof p.y === 'number' ? f2(p.y) : '0.00';
const z = typeof p.z === 'number' && Math.abs(p.z) > 1e-6 ? `, ${f2(p.z)}` : '';
return `${x}, ${y}${z}`;
};
function formatColor(colorVal: any): string {
if (colorVal == null) return 'BYLAYER';
if (typeof colorVal === 'number') {
if (colorVal === 256) return 'BYLAYER (256)';
if (colorVal === 0) return 'BYBLOCK (0)';
return `ACI ${colorVal}`;
}
return String(colorVal);
}
/** DWG lineweight: i16 in 1/100 mm, with -1/-2/-3 reserved for the inherit modes. */
function formatLineweight(raw: unknown): string {
if (typeof raw !== 'number') return 'BYLAYER';
if (raw === -1) return 'BYLAYER';
if (raw === -2) return 'BYBLOCK';
if (raw === -3) return 'DEFAULT (0.25mm)';
if (raw < 0) return String(raw);
return `${(raw / 100).toFixed(2)}mm`;
}
export function formatEntityProperties(entity: any): FormattedProperties {
if (!entity) {
return { id: '', type: '', handle: '', layer: '', general: [], geometry: [] };
}
const d = entity.data || entity;
const type = (entity.type || entity.typeName || 'ENTITY').toUpperCase();
const handle = entity.handle?.value ?? entity.handle ?? '-';
const layer = entity.layer ?? entity.layerName ?? '0';
const color = formatColor(entity.color ?? d.color);
const linetype = entity.lineType ?? entity.linetype ?? d.lineType ?? 'BYLAYER';
const lineweight = formatLineweight(
entity.entityHeader?.lineWeight ?? entity.lineWeight ?? entity.lineweight ?? d.lineweight,
);
const general: [string, string][] = [
['유형 (Type)', type],
['핸들 (Handle)', String(handle)],
['레이어 (Layer)', String(layer)],
['색상 (Color)', color],
['선종류 (Linetype)', String(linetype)],
['선가중치 (Lineweight)', lineweight],
];
const geometry: [string, string][] = [];
switch (type) {
case 'LINE': {
if (d.start && d.end) {
const dx = d.end.x - d.start.x;
const dy = d.end.y - d.start.y;
const dz = (d.end.z || 0) - (d.start.z || 0);
const len = Math.hypot(dx, dy, dz);
const angle = Math.atan2(dy, dx);
geometry.push(['시작점 (Start)', pt(d.start)]);
geometry.push(['끝점 (End)', pt(d.end)]);
geometry.push(['증분 (ΔX, ΔY)', `${f2(dx)}, ${f2(dy)}`]);
geometry.push(['길이 (Length)', f3(len)]);
geometry.push(['각도 (Angle)', deg(angle)]);
}
break;
}
case 'CIRCLE': {
if (d.center && d.radius != null) {
const r = d.radius;
const diameter = r * 2;
const circumference = 2 * Math.PI * r;
const area = Math.PI * r * r;
geometry.push(['중심점 (Center)', pt(d.center)]);
geometry.push(['반지름 (Radius)', f3(r)]);
geometry.push(['지름 (Diameter)', f3(diameter)]);
geometry.push(['둘레 (Circumference)', f3(circumference)]);
geometry.push(['넓이 (Area)', f3(area)]);
}
break;
}
case 'ARC': {
if (d.center && d.radius != null) {
const r = d.radius;
const sa = d.startAngle ?? 0;
const ea = d.endAngle ?? Math.PI * 2;
let sweep = ea - sa;
while (sweep < 0) sweep += Math.PI * 2;
const arcLen = sweep * r;
geometry.push(['중심점 (Center)', pt(d.center)]);
geometry.push(['반지름 (Radius)', f3(r)]);
geometry.push(['시작각 (Start Angle)', deg(sa)]);
geometry.push(['끝각 (End Angle)', deg(ea)]);
geometry.push(['중심각 (Sweep Angle)', deg(sweep)]);
geometry.push(['호 길이 (Arc Length)', f3(arcLen)]);
}
break;
}
case 'LWPOLYLINE':
case 'POLYLINE': {
const rawPts = d.points ?? d.vertices ?? [];
const isClosed = !!(d.closed || (d.flags & 1));
let totalLen = 0;
for (let i = 0; i < rawPts.length - (isClosed ? 0 : 1); i++) {
const p1 = rawPts[i];
const p2 = rawPts[(i + 1) % rawPts.length];
if (p1 && p2) {
totalLen += Math.hypot(p2.x - p1.x, p2.y - p1.y);
}
}
geometry.push(['정점 개수 (Vertices)', String(rawPts.length)]);
geometry.push(['닫힘 여부 (Closed)', isClosed ? '예 (Closed)' : '아니오 (Open)']);
geometry.push(['총 길이 (Total Length)', f3(totalLen)]);
if (isClosed && rawPts.length >= 3) {
let shoelace = 0;
for (let i = 0; i < rawPts.length; i++) {
const p1 = rawPts[i];
const p2 = rawPts[(i + 1) % rawPts.length];
shoelace += p1.x * p2.y - p2.x * p1.y;
}
geometry.push(['면적 (Area)', f3(Math.abs(shoelace) / 2)]);
}
if (rawPts.length > 0) {
const sampleCount = Math.min(rawPts.length, 4);
for (let i = 0; i < sampleCount; i++) {
geometry.push([`정점 P${i + 1}`, pt(rawPts[i])]);
}
if (rawPts.length > sampleCount) {
geometry.push(['...', `외 ${rawPts.length - sampleCount}개 정점`]);
}
}
break;
}
// MESH (AcDbSubDMesh). dwg-wasm emits flat arrays: vertices [x,y,z,…],
// faceList [n, i0 … i(n-1)] repeated, edges [a,b,…].
case 'MESH': {
const verts: number[] = d.vertices ?? [];
const vCount = d.vertexCount ?? Math.floor(verts.length / 3);
const fCount = d.faceCount ?? 0;
const eCount = Math.floor((d.edges?.length ?? 0) / 2);
const sub = d.subdivisionLevel ?? 0;
geometry.push(['정점 개수 (Vertices)', String(vCount)]);
geometry.push(['면 개수 (Faces)', String(fCount)]);
geometry.push(['모서리 개수 (Edges)', String(eCount)]);
geometry.push(['세분 레벨 (Subdivision)', String(sub)]);
geometry.push(['크리스 혼합 (Blend Crease)', d.blendCrease ? '예 (Yes)' : '아니오 (No)']);
if (vCount > 0) {
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
for (let i = 0; i + 2 < verts.length; i += 3) {
const x = verts[i], y = verts[i + 1], z = verts[i + 2];
if (x < minX) minX = x; if (x > maxX) maxX = x;
if (y < minY) minY = y; if (y > maxY) maxY = y;
if (z < minZ) minZ = z; if (z > maxZ) maxZ = z;
}
geometry.push(['최소점 (Min)', pt({ x: minX, y: minY, z: minZ })]);
geometry.push(['최대점 (Max)', pt({ x: maxX, y: maxY, z: maxZ })]);
geometry.push(['표고 범위 (Z Range)', `${f3(minZ)} ~ ${f3(maxZ)}`]);
}
break;
}
case 'TEXT':
case 'MTEXT':
case 'ATTRIB': {
const content = d.text ?? entity.text ?? d.textValue ?? '-';
const height = d.textHeight ?? d.height ?? entity.textHeight;
const rot = d.rotationAngle ?? d.rotation ?? 0;
const pos = d.insertionPoint ?? d.insertionPt ?? d.alignmentPt ?? d.pos;
geometry.push(['텍스트 내용 (Content)', String(content)]);
if (height != null) geometry.push(['높이 (Height)', f3(height)]);
geometry.push(['회전각 (Rotation)', deg(rot)]);
if (pos) geometry.push(['삽입점 (Position)', pt(pos)]);
break;
}
case 'INSERT': {
const name = d.name ?? d.blockName ?? entity.name ?? '-';
const pos = d.insertionPoint ?? d.insertionPt ?? entity.insertionPt;
const sx = d.xScale ?? d.scale?.x ?? 1;
const sy = d.yScale ?? d.scale?.y ?? 1;
const sz = d.zScale ?? d.scale?.z ?? 1;
const rot = d.rotation ?? 0;
geometry.push(['블록 이름 (Block)', String(name)]);
if (pos) geometry.push(['삽입점 (Position)', pt(pos)]);
geometry.push(['축척 (Scale X,Y,Z)', `${f2(sx)}, ${f2(sy)}, ${f2(sz)}`]);
geometry.push(['회전각 (Rotation)', deg(rot)]);
break;
}
case 'HATCH': {
const isSolid = d.solidFill ?? entity.solidFill ?? false;
const pattern = d.patternName ?? d.name ?? (isSolid ? 'SOLID' : 'HATCH');
const paths = d.paths ?? entity.paths ?? [];
geometry.push(['패턴 (Pattern)', String(pattern)]);
geometry.push(['채우기 유형 (Type)', isSolid ? '단색 채우기 (Solid)' : '패턴 (Pattern)']);
geometry.push(['경계 루프 수 (Loops)', String(paths.length)]);
break;
}
case 'DIMENSION':
case 'DIMENSION_LINEAR':
case 'DIMENSION_ALIGNED':
case 'DIMENSION_RADIUS':
case 'DIMENSION_DIAMETER':
case 'DIMENSION_ANG_3PT':
case 'DIMENSION_ANG_2LN':
case 'DIMENSION_ORDINATE': {
const meas = d.actualMeasurement ?? entity.actualMeasurement;
const textVal = d.userText || (meas != null ? f3(meas) : '-');
const midPt = d.textMidPt ?? entity.textMidPt;
geometry.push(['치수 유형 (Dim Type)', type]);
if (meas != null) geometry.push(['측정값 (Measurement)', f3(meas)]);
geometry.push(['표시 텍스트 (Text)', String(textVal)]);
if (midPt) geometry.push(['텍스트 위치 (Text Mid)', pt(midPt)]);
break;
}
case 'ELLIPSE': {
if (d.center) {
geometry.push(['중심점 (Center)', pt(d.center)]);
if (d.majorAxis) geometry.push(['주축 벡터 (Major Axis)', pt(d.majorAxis)]);
if (d.axisRatio != null || d.ratio != null) {
geometry.push(['단축 비율 (Ratio)', f3(d.axisRatio ?? d.ratio)]);
}
}
break;
}
default: {
let count = 0;
for (const [k, v] of Object.entries(d)) {
if (typeof v === 'object' || v == null || typeof v === 'function') continue;
geometry.push([k, String(v)]);
count++;
if (count >= 12) break;
}
}
}
const id = `${type}${handle !== '-' ? ' · #' + handle : ''}`;
return { id, type, handle: String(handle), layer: String(layer), general, geometry };
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export function renderPropertyInspector(
bodyEl: HTMLElement,
entity: any,
options?: { onZoomToSelection?: () => void; zoomBtnEl?: HTMLElement | null }
): void {
if (!bodyEl) return;
if (!entity) {
if (options?.zoomBtnEl) options.zoomBtnEl.style.display = 'none';
bodyEl.innerHTML = `
<div style="padding:40px 20px;text-align:center;color:var(--muted);font-size:12px;line-height:1.6">
<div style="font-size:24px;margin-bottom:8px;opacity:.5">🔍</div>
도면에서 객체(엔티티)를 클릭하면<br/>상세 속성이 표시됩니다
</div>`;
return;
}
if (options?.zoomBtnEl) options.zoomBtnEl.style.display = 'inline-flex';
const prop = formatEntityProperties(entity);
const renderRows = (rows: [string, string][]) =>
rows
.map(
([k, v]) => `
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;padding:6px 0;border-bottom:1px solid var(--line)">
<span style="font-size:12px;color:var(--muted);flex-shrink:0">${escapeHtml(k)}</span>
<span style="font-family:var(--mono);font-size:12px;color:var(--ink);text-align:right;word-break:break-all;user-select:all">${escapeHtml(v)}</span>
</div>`
)
.join('');
bodyEl.innerHTML = `
<div style="padding:14px">
<!-- Entity ID Tag -->
<div style="display:flex;align-items:center;gap:8px;margin-bottom:14px;background:rgba(63,185,80,.1);border:1px solid rgba(63,185,80,.25);padding:8px 10px;border-radius:6px">
<span style="width:8px;height:8px;border-radius:50%;background:var(--accent);flex-shrink:0"></span>
<span style="font-family:var(--mono);font-size:13px;font-weight:700;color:var(--accent)">${escapeHtml(prop.id)}</span>
</div>
<!-- General Section -->
<div style="margin-bottom:14px">
<div style="font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);margin-bottom:6px">
일반 정보 (General)
</div>
${renderRows(prop.general)}
</div>
<!-- Geometry Section -->
${
prop.geometry.length > 0
? `
<div>
<div style="font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--accent);margin-bottom:6px">
기하 속성 (Geometry)
</div>
${renderRows(prop.geometry)}
</div>`
: ''
}
</div>`;
}
+50
View File
@@ -0,0 +1,50 @@
// Types for the ported JS 2D engine (Viewer2D.js). Colocated so tsc resolves
// `./Viewer2D.js` imports without allowJs. Only the methods the app uses are typed.
import type { CadSpace } from './cadSpaces';
export class Viewer2D {
constructor(container: HTMLElement);
/** Render a DWG/DXF parseResult. `keepView` preserves pan/zoom on re-render. */
load(result: unknown, opts?: { keepView?: boolean; keepTheme?: boolean; spaceHandle?: string | number | null }): void;
/** Zoom-extents to the loaded drawing. */
fit(): void;
/** Resync renderer/camera to the container size (call after un-hiding). */
resize(): void;
onSelect(cb: (entity: unknown) => void): void;
getSelectedEntity(): unknown;
zoomToSelection(): void;
setAccent(hex: string): void;
setTheme(dark: boolean): void;
setGrid(visible: boolean): void;
setZoomSpeed(speed: number): void;
getZoomSpeed(): number;
/** 2.5D (tilted 3D) view: orbit the drawing so entity elevation (Z) is visible. */
set25D(on: boolean, opts?: { keepView?: boolean }): void;
is25D(): boolean;
/** Height exaggeration for 2.5D (1 = true scale); ignored in top view. */
setZScale(k: number): void;
getZScale(): number;
/** Lineweight display (AutoCAD LWDISPLAY); null follows the drawing's own flag. */
setLineweightEnabled(on: boolean | null): void;
getLineweightEnabled(): boolean;
/** Lineweight display scale in px per mm (AutoCAD's display-scale slider). Default 20. */
setLineweightScale(pxPerMm: number | null): void;
getLineweightScale(): number;
/** Distinct lineweights (mm) present in the loaded drawing, ascending. */
getLineweightClasses(): number[];
getLayerInfo(): { name: string; colorHex: string; count: number; visible: boolean }[];
setHiddenLayers(nameSet: Set<string>): void;
/** Model / Paper (Layout) spaces for the loaded drawing. */
getSpaces(): CadSpace[];
getActiveSpace(): string | null;
/** Switch Model ↔ Layout; re-renders (fit unless keepView). */
setSpace(spaceHandle: string | number | null, opts?: { keepView?: boolean }): void;
getZoomPercent(): number;
onViewChange(cb: () => void): void;
/** Screen client coords → drawing world (Z=0 plane). */
screenToWorld(clientX: number, clientY: number): { x: number; y: number; z: number } | null;
/** Live CAD coords under the cursor; null when pointer leaves the canvas. */
onPointerWorld(cb: ((p: { x: number; y: number; z: number } | null) => void) | null): void;
startMeasure(cb: (r: { phase: string; ax: number; ay: number; az: number; bx: number; by: number; bz: number; d: number }) => void): void;
stopMeasure(): void;
}
File diff suppressed because it is too large. Load diff
+53
View File
@@ -0,0 +1,53 @@
/* tslint:disable */
/* eslint-disable */
export function parse_dwg(bytes: Uint8Array): any;
/**
* The same parseResult as [`parse_dwg`], returned as a JSON string for the
* caller to hand to `JSON.parse`. Prefer this on anything but small drawings.
*
* Materializing the whole result as a `serde_json::Value` is what makes big
* files unopenable. The tree costs ~150× the DWG on the heap (a 19 MB drawing
* peaks near 2 GB), and the wasm allocator's per-allocation cost rises with the
* live heap, so conversion goes quadratic — measured 352 s in wasm for work
* that takes 2.5 s natively. Converting one entity at a time and appending its
* JSON text keeps the live heap flat and the cost linear.
*/
export function parse_dwg_json(bytes: Uint8Array): string;
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
export interface InitOutput {
readonly memory: WebAssembly.Memory;
readonly parse_dwg: (a: number, b: number) => [number, number, number];
readonly parse_dwg_json: (a: number, b: number) => [number, number, number, number];
readonly __wbindgen_malloc: (a: number, b: number) => number;
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
readonly __wbindgen_externrefs: WebAssembly.Table;
readonly __externref_table_dealloc: (a: number) => void;
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
readonly __wbindgen_start: () => void;
}
export type SyncInitInput = BufferSource | WebAssembly.Module;
/**
* Instantiates the given `module`, which can either be bytes or
* a precompiled `WebAssembly.Module`.
*
* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
*
* @returns {InitOutput}
*/
export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput;
/**
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
* for everything else, calls `WebAssembly.instantiate` directly.
*
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
*
* @returns {Promise<InitOutput>}
*/
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;
@@ -0,0 +1,320 @@
/* @ts-self-types="./acadrust_dwg.d.ts" */
/**
* @param {Uint8Array} bytes
* @returns {any}
*/
export function parse_dwg(bytes) {
const ptr0 = passArray8ToWasm0(bytes, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.parse_dwg(ptr0, len0);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
}
/**
* The same parseResult as [`parse_dwg`], returned as a JSON string for the
* caller to hand to `JSON.parse`. Prefer this on anything but small drawings.
*
* Materializing the whole result as a `serde_json::Value` is what makes big
* files unopenable. The tree costs ~150× the DWG on the heap (a 19 MB drawing
* peaks near 2 GB), and the wasm allocator's per-allocation cost rises with the
* live heap, so conversion goes quadratic — measured 352 s in wasm for work
* that takes 2.5 s natively. Converting one entity at a time and appending its
* JSON text keeps the live heap flat and the cost linear.
* @param {Uint8Array} bytes
* @returns {string}
*/
export function parse_dwg_json(bytes) {
let deferred3_0;
let deferred3_1;
try {
const ptr0 = passArray8ToWasm0(bytes, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.parse_dwg_json(ptr0, len0);
var ptr2 = ret[0];
var len2 = ret[1];
if (ret[3]) {
ptr2 = 0; len2 = 0;
throw takeFromExternrefTable0(ret[2]);
}
deferred3_0 = ptr2;
deferred3_1 = len2;
return getStringFromWasm0(ptr2, len2);
} finally {
wasm.__wbindgen_free(deferred3_0, deferred3_1, 1);
}
}
function __wbg_get_imports() {
const import0 = {
__proto__: null,
__wbg_Error_92b29b0548f8b746: function(arg0, arg1) {
const ret = Error(getStringFromWasm0(arg0, arg1));
return ret;
},
__wbg_String_8564e559799eccda: function(arg0, arg1) {
const ret = String(arg1);
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
},
__wbg___wbindgen_is_string_ea5e6cc2e4141dfe: function(arg0) {
const ret = typeof(arg0) === 'string';
return ret;
},
__wbg___wbindgen_throw_344f42d3211c4765: function(arg0, arg1) {
throw new Error(getStringFromWasm0(arg0, arg1));
},
__wbg_new_32b398fb48b6d94a: function() {
const ret = new Array();
return ret;
},
__wbg_new_7796ffc7ed656783: function() {
const ret = new Map();
return ret;
},
__wbg_new_da52cf8fe3429cb2: function() {
const ret = new Object();
return ret;
},
__wbg_set_575dd786d51585f8: function(arg0, arg1, arg2) {
const ret = arg0.set(arg1, arg2);
return ret;
},
__wbg_set_6be42768c690e380: function(arg0, arg1, arg2) {
arg0[arg1] = arg2;
},
__wbg_set_8a16b38e4805b298: function(arg0, arg1, arg2) {
arg0[arg1 >>> 0] = arg2;
},
__wbindgen_cast_0000000000000001: function(arg0) {
// Cast intrinsic for `F64 -> Externref`.
const ret = arg0;
return ret;
},
__wbindgen_cast_0000000000000002: function(arg0) {
// Cast intrinsic for `I64 -> Externref`.
const ret = arg0;
return ret;
},
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
// Cast intrinsic for `Ref(String) -> Externref`.
const ret = getStringFromWasm0(arg0, arg1);
return ret;
},
__wbindgen_cast_0000000000000004: function(arg0) {
// Cast intrinsic for `U64 -> Externref`.
const ret = BigInt.asUintN(64, arg0);
return ret;
},
__wbindgen_init_externref_table: function() {
const table = wasm.__wbindgen_externrefs;
const offset = table.grow(4);
table.set(0, undefined);
table.set(offset + 0, undefined);
table.set(offset + 1, null);
table.set(offset + 2, true);
table.set(offset + 3, false);
},
};
return {
__proto__: null,
"./acadrust_dwg_bg.js": import0,
};
}
let cachedDataViewMemory0 = null;
function getDataViewMemory0() {
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
}
return cachedDataViewMemory0;
}
function getStringFromWasm0(ptr, len) {
return decodeText(ptr >>> 0, len);
}
let cachedUint8ArrayMemory0 = null;
function getUint8ArrayMemory0() {
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
}
return cachedUint8ArrayMemory0;
}
function passArray8ToWasm0(arg, malloc) {
const ptr = malloc(arg.length * 1, 1) >>> 0;
getUint8ArrayMemory0().set(arg, ptr / 1);
WASM_VECTOR_LEN = arg.length;
return ptr;
}
function passStringToWasm0(arg, malloc, realloc) {
if (realloc === undefined) {
const buf = cachedTextEncoder.encode(arg);
const ptr = malloc(buf.length, 1) >>> 0;
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
WASM_VECTOR_LEN = buf.length;
return ptr;
}
let len = arg.length;
let ptr = malloc(len, 1) >>> 0;
const mem = getUint8ArrayMemory0();
let offset = 0;
for (; offset < len; offset++) {
const code = arg.charCodeAt(offset);
if (code > 0x7F) break;
mem[ptr + offset] = code;
}
if (offset !== len) {
if (offset !== 0) {
arg = arg.slice(offset);
}
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
const ret = cachedTextEncoder.encodeInto(arg, view);
offset += ret.written;
ptr = realloc(ptr, len, offset, 1) >>> 0;
}
WASM_VECTOR_LEN = offset;
return ptr;
}
function takeFromExternrefTable0(idx) {
const value = wasm.__wbindgen_externrefs.get(idx);
wasm.__externref_table_dealloc(idx);
return value;
}
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
const MAX_SAFARI_DECODE_BYTES = 2146435072;
let numBytesDecoded = 0;
function decodeText(ptr, len) {
numBytesDecoded += len;
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
numBytesDecoded = len;
}
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
}
const cachedTextEncoder = new TextEncoder();
if (!('encodeInto' in cachedTextEncoder)) {
cachedTextEncoder.encodeInto = function (arg, view) {
const buf = cachedTextEncoder.encode(arg);
view.set(buf);
return {
read: arg.length,
written: buf.length
};
};
}
let WASM_VECTOR_LEN = 0;
let wasmModule, wasmInstance, wasm;
function __wbg_finalize_init(instance, module) {
wasmInstance = instance;
wasm = instance.exports;
wasmModule = module;
cachedDataViewMemory0 = null;
cachedUint8ArrayMemory0 = null;
wasm.__wbindgen_start();
return wasm;
}
async function __wbg_load(module, imports) {
if (typeof Response === 'function' && module instanceof Response) {
if (typeof WebAssembly.instantiateStreaming === 'function') {
try {
return await WebAssembly.instantiateStreaming(module, imports);
} catch (e) {
const validResponse = module.ok && expectedResponseType(module.type);
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
} else { throw e; }
}
}
const bytes = await module.arrayBuffer();
return await WebAssembly.instantiate(bytes, imports);
} else {
const instance = await WebAssembly.instantiate(module, imports);
if (instance instanceof WebAssembly.Instance) {
return { instance, module };
} else {
return instance;
}
}
function expectedResponseType(type) {
switch (type) {
case 'basic': case 'cors': case 'default': return true;
}
return false;
}
}
function initSync(module) {
if (wasm !== undefined) return wasm;
if (module !== undefined) {
if (Object.getPrototypeOf(module) === Object.prototype) {
({module} = module)
} else {
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
}
}
const imports = __wbg_get_imports();
if (!(module instanceof WebAssembly.Module)) {
module = new WebAssembly.Module(module);
}
const instance = new WebAssembly.Instance(module, imports);
return __wbg_finalize_init(instance, module);
}
async function __wbg_init(module_or_path) {
if (wasm !== undefined) return wasm;
if (module_or_path !== undefined) {
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
({module_or_path} = module_or_path)
} else {
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
}
}
if (module_or_path === undefined) {
module_or_path = new URL('acadrust_dwg_bg.wasm', import.meta.url);
}
const imports = __wbg_get_imports();
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
module_or_path = fetch(module_or_path);
}
const { instance, module } = await __wbg_load(await module_or_path, imports);
return __wbg_finalize_init(instance, module);
}
export { initSync, __wbg_init as default };
@@ -0,0 +1,11 @@
/* tslint:disable */
/* eslint-disable */
export const memory: WebAssembly.Memory;
export const parse_dwg: (a: number, b: number) => [number, number, number];
export const parse_dwg_json: (a: number, b: number) => [number, number, number, number];
export const __wbindgen_malloc: (a: number, b: number) => number;
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
export const __wbindgen_externrefs: WebAssembly.Table;
export const __externref_table_dealloc: (a: number) => void;
export const __wbindgen_free: (a: number, b: number, c: number) => void;
export const __wbindgen_start: () => void;
@@ -0,0 +1,17 @@
{
"name": "dwg-wasm",
"type": "module",
"description": "WASM wrapper around acadrust (MPL-2.0, used unmodified) emitting the hmwebviewer parseResult shape",
"version": "0.1.0",
"license": "MIT",
"files": [
"acadrust_dwg_bg.wasm",
"acadrust_dwg.js",
"acadrust_dwg.d.ts"
],
"main": "acadrust_dwg.js",
"types": "acadrust_dwg.d.ts",
"sideEffects": [
"./snippets/*"
]
}
+28
View File
@@ -0,0 +1,28 @@
/**
* acadrust DWG parser wrapper — loads the wasm built from rust/dwg-wasm
* (wasm-pack --target web, committed under ./acadrust-dwg).
*/
import init, { parse_dwg_json } from './acadrust-dwg/acadrust_dwg.js';
import wasmUrl from './acadrust-dwg/acadrust_dwg_bg.wasm?url';
import type { CadParseResult } from './dwgParser';
let ready: Promise<void> | null = null;
/** Initialize the acadrust WASM module once (idempotent). */
export function initAcadrustParser(): Promise<void> {
if (!ready) ready = init({ module_or_path: wasmUrl }).then(() => undefined);
return ready;
}
/**
* Parse a DWG buffer. Requires initAcadrustParser() first.
*
* Goes through `parse_dwg_json` + `JSON.parse` rather than the `parse_dwg`
* export that hands back a JsValue. The latter keeps the entire result live on
* the wasm heap as it builds, and the allocator's cost grows with that heap —
* a 19 MB drawing took ~354 s and froze the tab. Streaming the result out as
* text keeps it linear: the same file now lands in ~2.5 s.
*/
export function parseDwgAcadrust(bytes: Uint8Array): CadParseResult {
return JSON.parse(parse_dwg_json(bytes)) as CadParseResult;
}
+64
View File
@@ -0,0 +1,64 @@
/**
* AutoCAD Color Index (ACI) → hex RGB integer.
* Returns null for BYBLOCK (0) and BYLAYER (256) — caller uses layer/default color.
*/
function hsl2hex(h, s, l) {
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
let r, g, b;
if (h < 60) { r = c; g = x; b = 0; }
else if (h < 120) { r = x; g = c; b = 0; }
else if (h < 180) { r = 0; g = c; b = x; }
else if (h < 240) { r = 0; g = x; b = c; }
else if (h < 300) { r = x; g = 0; b = c; }
else { r = c; g = 0; b = x; }
return Math.round((r + m) * 255) * 65536
+ Math.round((g + m) * 255) * 256
+ Math.round((b + m) * 255);
}
// ACI 0-9: exact fixed colors
const FIXED = [
null, // 0: BYBLOCK
0xFF0000, // 1: red
0xFFFF00, // 2: yellow
0x00FF00, // 3: green
0x00FFFF, // 4: cyan
0x0000FF, // 5: blue
0xFF00FF, // 6: magenta
0xFFFFFF, // 7: white
0x414141, // 8: dark gray
0x808080, // 9: gray
];
// ACI 10-249: 24 hue groups × 10 saturation/lightness variants
const VARIANTS_SL = [
[1.00, 0.50], // 0: full
[1.00, 0.28], // 1: dark
[0.65, 0.65], // 2: light pastel
[0.65, 0.38], // 3: pastel dark
[0.35, 0.80], // 4: pale
[0.35, 0.48], // 5: pale mid
[0.18, 0.68], // 6: faint light
[0.18, 0.38], // 7: faint dark
[0.00, 0.55], // 8: light gray
[0.00, 0.30], // 9: dark gray
];
export function aciToHex(aci, isDark = true) {
if (aci == null || aci === 0 || aci === 256) return null;
if (aci === 7) return isDark ? 0xFFFFFF : 0x000000;
if (aci <= 9) return FIXED[aci] ?? null;
if (aci >= 250 && aci <= 255) {
const v = Math.round(40 + (aci - 250) * 43);
return v * 65536 + v * 256 + v;
}
if (aci >= 10 && aci <= 249) {
const idx = aci - 10;
const [s, l] = VARIANTS_SL[idx % 10];
return hsl2hex(Math.floor(idx / 10) * 15, s, l);
}
return 0xC9D1D9;
}
+166
View File
@@ -0,0 +1,166 @@
/**
* Model Space / Paper Space (Layout) discovery for CadParseResult.
*
* DWG entities are owned by a block header handle. System blocks
* *Model_Space / *Paper_Space (and *Paper_Space0…) are the two spaces
* AutoCAD switches between. Viewer2D must render only one at a time —
* drawing both stacks survey-model geometry on top of layout sheet
* geometry and produces the "all text piled in the center" glitch.
*/
export type CadSpaceKind = 'model' | 'paper' | 'other';
export type CadSpace = {
/** Stable id for UI (hex handle string, same as ownerHandle.value.toString(16)). */
id: string;
/** Display label, e.g. "Model" / "Layout" / raw block name. */
label: string;
/** Raw block name from tables.blocks. */
name: string;
/** Block handle as number when available. */
handle: number | null;
/** Hex string matching entity ownerHandle.value.toString(16). */
handleHex: string;
kind: CadSpaceKind;
entityCount: number;
};
function handleToHex(handle: unknown): string | null {
if (handle == null) return null;
if (typeof handle === 'object' && handle !== null && 'value' in handle) {
const v = (handle as { value?: unknown }).value;
if (typeof v === 'number' && Number.isFinite(v)) return v.toString(16);
if (typeof v === 'string' && v.length) {
const n = Number(v);
return Number.isFinite(n) ? n.toString(16) : v.toLowerCase();
}
return null;
}
if (typeof handle === 'number' && Number.isFinite(handle)) return handle.toString(16);
if (typeof handle === 'string' && handle.length) {
const n = Number(handle);
return Number.isFinite(n) ? n.toString(16) : handle.toLowerCase();
}
return null;
}
function ownerHex(entity: { ownerHandle?: unknown }): string | null {
return handleToHex(entity.ownerHandle);
}
function classifySpaceName(raw: string): CadSpaceKind | null {
const name = raw.toLowerCase().replace(/\*/g, '').trim();
if (!name) return null;
if (name === 'model_space' || name === 'ms' || name.startsWith('model')) return 'model';
if (name === 'paper_space' || name === 'ps' || name.startsWith('paper')) return 'paper';
return null;
}
function spaceLabel(kind: CadSpaceKind, name: string, paperIndex: number): string {
if (kind === 'model') return 'Model';
if (kind === 'paper') {
// First paper space → "Layout"; further ones keep a disambiguator.
if (paperIndex <= 1) return 'Layout';
return `Layout (${name.replace(/^\*/, '')})`;
}
return name;
}
/**
* List model/paper space blocks that own at least zero entities.
* Always includes Model/Paper headers found in tables.blocks.
*/
export function listCadSpaces(result: {
entities?: unknown[];
tables?: { blocks?: Array<{ name?: string; handle?: unknown }> };
} | null | undefined): CadSpace[] {
const blocks = result?.tables?.blocks ?? [];
const entities = (result?.entities ?? []) as Array<{ ownerHandle?: unknown }>;
const counts = new Map<string, number>();
for (const e of entities) {
const hex = ownerHex(e);
if (!hex) continue;
counts.set(hex, (counts.get(hex) || 0) + 1);
}
const spaces: CadSpace[] = [];
let paperIndex = 0;
for (const b of blocks) {
const kind = classifySpaceName(b.name ?? '');
if (!kind) continue;
const hex = handleToHex(b.handle);
if (!hex) continue;
if (kind === 'paper') paperIndex += 1;
const handleNum =
typeof b.handle === 'number'
? b.handle
: typeof b.handle === 'object' && b.handle && 'value' in b.handle
? Number((b.handle as { value: unknown }).value)
: null;
spaces.push({
id: hex,
label: spaceLabel(kind, b.name ?? '', paperIndex),
name: b.name ?? '',
handle: Number.isFinite(handleNum as number) ? (handleNum as number) : null,
handleHex: hex,
kind,
entityCount: counts.get(hex) || 0,
});
}
// Stable order: Model first, then papers in table order.
spaces.sort((a, b) => {
if (a.kind !== b.kind) {
if (a.kind === 'model') return -1;
if (b.kind === 'model') return 1;
}
return 0;
});
return spaces;
}
/**
* Default tab when a file is opened:
* - Paper/Layout with entities if any (civil sheet drawings often leave Layout active)
* - else Model
* - else first listed space
*/
export function pickDefaultSpace(spaces: CadSpace[]): CadSpace | null {
if (!spaces.length) return null;
const paperWithContent = spaces.find((s) => s.kind === 'paper' && s.entityCount > 0);
if (paperWithContent) return paperWithContent;
const model = spaces.find((s) => s.kind === 'model');
if (model) return model;
return spaces[0] ?? null;
}
/**
* Owners allowed when filtering to one space: the space handle itself plus
* handles of entities that live under it (e.g. INSERT → ATTRIB children).
*/
export function buildAllowedOwnerHexes(
entities: Array<{ handle?: unknown; ownerHandle?: unknown }>,
activeSpaceHex: string,
rounds = 3,
): Set<string> {
const allowed = new Set<string>([activeSpaceHex]);
for (let pass = 0; pass < rounds; pass++) {
let grew = false;
for (const ent of entities) {
const oh = ownerHex(ent);
if (!oh || !allowed.has(oh)) continue;
const h = handleToHex(ent.handle);
if (h && !allowed.has(h)) {
allowed.add(h);
grew = true;
}
}
if (!grew) break;
}
return allowed;
}
export function isSystemSpaceBlockName(name: string): boolean {
return classifySpaceName(name) != null;
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Decode a DXF buffer to text.
*
* Modern CAD exports are UTF-8 but often keep a legacy $DWGCODEPAGE
* (e.g. ANSI_949) that no longer matches the bytes — so the header is
* unreliable. Strategy: try UTF-8 strictly (self-validating); on failure
* fall back to the header's single-byte codepage. The DXF group/value
* structure is ASCII regardless, so this only affects TEXT/MTEXT strings.
*/
export function decodeDxf(buf: ArrayBuffer): string {
try {
return new TextDecoder('utf-8', { fatal: true }).decode(buf);
} catch {
/* not valid UTF-8 → legacy single-byte codepage */
}
const head = new TextDecoder('latin1').decode(
new Uint8Array(buf, 0, Math.min(buf.byteLength, 4096)),
);
const cp = head
.match(/\$DWGCODEPAGE[\s\S]*?\n\s*3\s*\n\s*([^\r\n]+)/i)?.[1]
?.trim()
.toLowerCase();
let label = 'windows-1252';
if (cp) {
if (/949/.test(cp)) label = 'euc-kr';
else if (/932/.test(cp)) label = 'shift_jis';
else if (/936/.test(cp)) label = 'gbk';
else if (/950/.test(cp)) label = 'big5';
}
try {
return new TextDecoder(label).decode(buf);
} catch {
return new TextDecoder('utf-8').decode(buf);
}
}
+31
View File
@@ -0,0 +1,31 @@
/**
* DWG parser — rust/dwg-wasm wrapper around the acadrust crate (MPL-2.0,
* vendored with a local read_mesh patch; see hmwebviewer rust/dwg-wasm). Emits
* the parseResult shape Viewer2D.load() consumes. Lazy-imported so the wasm is
* only fetched when a DWG is actually opened.
*
* History: until 2026-07-10 this module selected between acadrust and
* @horu2day/pure-cad-parser (?dwgparser= toggle). The old parser was removed
* after side-by-side validation (PROGRESS.md Phase 15).
*
* Post-process: fixDwgKoreanText repairs CP949→Latin-1 mojibake common in
* older Korean DWGs (see fixDwgKoreanText.ts). Unicode-correct drawings
* (e.g. BasicSample) are left unchanged.
*/
import { fixDwgKoreanText } from './fixDwgKoreanText';
export type CadParseResult = {
version?: string;
vars?: Record<string, unknown>;
entities?: unknown[];
tables?: Record<string, unknown>;
stats?: Record<string, unknown>;
};
/** Parse a DWG buffer. Returns a parseResult for Viewer2D.load(). */
export async function parseDwgBuffer(bytes: Uint8Array): Promise<CadParseResult> {
const { initAcadrustParser, parseDwgAcadrust } = await import('./acadrustParser');
await initAcadrustParser();
return fixDwgKoreanText(parseDwgAcadrust(bytes));
}
+7
View File
@@ -0,0 +1,7 @@
/** Convert dxf-parser output into the parseResult shape Viewer2D.load() consumes. */
export function dxfToParseResult(dxf: unknown): {
version: string;
vars: Record<string, unknown>;
entities: unknown[];
tables: { blocks: unknown[]; layers: unknown[] };
};
+160
View File
@@ -0,0 +1,160 @@
/**
* dxfAdapter — convert dxf-parser (MIT) output into the parseResult shape that
* Viewer2D.load() consumes, so the existing renderer (bulge arcs, dimension
* block geometry, arrowhead blocks, text alignment) is reused unchanged for DXF.
*
* Why an adapter and not dxf-parser's handles directly: dxf-parser exposes the
* raw DXF handle graph where a BLOCK entity's handle differs from the BLOCK_RECORD
* handle its child entities are owned by, and INSERT/DIMENSION reference their
* block by NAME, not handle. Viewer2D matches blocks by handle. So we discard the
* incoherent handles and synthesize one consistent integer handle PER BLOCK NAME:
* - block table entry handle = {value: H}
* - every child entity ownerHandle = {value: H}
* - INSERT.blockHeaderHandle = {value: H of insert.name}
* - DIMENSION.blockHeaderHandle = {value: H of dim.block}
* Model-space entities are owned by the synthetic '*MODEL_SPACE' handle so
* Viewer2D's space-handle filter keeps them visible.
*
* Units: dxf-parser gives ARC/ELLIPSE angles in radians (pass through) but
* INSERT/TEXT/MTEXT rotation in DEGREES → converted to radians here.
*
* Not supported by dxf-parser 1.1.x: HATCH (no handler) → hatches are absent.
*/
const DEG2RAD = Math.PI / 180;
const vec = (p) => (p ? { x: p.x, y: p.y, z: p.z || 0 } : p);
/** Map one dxf-parser entity → Viewer2D field names. Returns a shallow copy. */
function adaptEntity(e, nameToH) {
const o = { ...e };
delete o.color; // dxf-parser's `color` is a 24-bit RGB int; let colorIndex/layer drive ACI
const t = (e.type || '').toUpperCase();
switch (t) {
case 'LINE':
if (e.vertices?.length >= 2) { o.start = vec(e.vertices[0]); o.end = vec(e.vertices[1]); }
break;
case 'POLYLINE':
case 'LWPOLYLINE':
o.type = 'LWPOLYLINE';
o.points = (e.vertices || []).map(vec);
o.bulges = (e.vertices || []).map((v) => v.bulge || 0);
o.closed = !!e.shape || ((e.flags || 0) & 1) === 1;
break;
case 'ARC':
case 'CIRCLE':
o.center = vec(e.center); // radius + (radian) angles already match
break;
case 'ELLIPSE':
o.center = vec(e.center);
o.majorAxis = vec(e.majorAxisEndPoint);
o.ratio = e.axisRatio;
o.startParam = e.startAngle;
o.endParam = e.endAngle;
break;
case 'POINT':
o.position = vec(e.position);
break;
case 'SOLID':
case '3DFACE': {
const p = e.points || [];
if (p[0]) o.corner1 = vec(p[0]);
if (p[1]) o.corner2 = vec(p[1]);
if (p[2]) o.corner3 = vec(p[2]);
if (p[3]) o.corner4 = vec(p[3]);
o.type = 'SOLID';
break;
}
case 'TEXT':
case 'ATTRIB':
case 'ATTDEF':
o.insertionPoint = vec(e.startPoint || e.position);
o.alignmentPt = vec(e.endPoint);
o.rotationAngle = (e.rotation || 0) * DEG2RAD;
o.horizAlignment = e.halign || 0;
o.vertAlignment = e.valign || 0;
o.textHeight = e.textHeight ?? e.height;
break;
case 'MTEXT':
o.insertionPoint = vec(e.position);
o.textHeight = e.height ?? e.textHeight;
if (e.directionVector) o.xAxisDir = vec(e.directionVector);
else if (e.rotation != null) o.xAxisDir = { x: Math.cos(e.rotation * DEG2RAD), y: Math.sin(e.rotation * DEG2RAD) };
break;
case 'INSERT': {
o.insertionPoint = vec(e.position);
o.scaleX = e.xScale ?? 1;
o.scaleY = e.yScale ?? 1;
o.rotation = (e.rotation || 0) * DEG2RAD;
const h = nameToH.get(e.name);
if (h != null) o.blockHeaderHandle = { value: h };
break;
}
case 'DIMENSION': {
const h = nameToH.get(e.block);
if (h != null) o.blockHeaderHandle = { value: h };
break;
}
default:
break;
}
return o;
}
/**
* Convert a full dxf-parser result → Viewer2D parseResult.
* @param {object} dxf output of `new DxfParser().parseSync(text)`
* @returns {{version:string, vars:object, entities:object[], tables:{blocks:object[], layers:object[]}}}
*/
export function dxfToParseResult(dxf) {
const blocks = dxf.blocks || {};
const names = Object.keys(blocks);
const nameToH = new Map();
names.forEach((n, i) => nameToH.set(n, i + 1)); // 1-based synthetic handles
const msName = names.find((n) => /model_space/i.test(n)) ?? names.find((n) => /model/i.test(n));
const msH = msName != null ? nameToH.get(msName) : 0;
const entities = [];
// Model-space entities (top-level array) → owned by the synthetic *MODEL_SPACE handle.
for (const e of (dxf.entities || [])) {
const o = adaptEntity(e, nameToH);
o.ownerHandle = { value: msH };
entities.push(o);
}
// Block-definition entities → owned by their block's synthetic handle.
for (const n of names) {
const H = nameToH.get(n);
for (const e of (blocks[n].entities || [])) {
const o = adaptEntity(e, nameToH);
o.ownerHandle = { value: H };
entities.push(o);
}
}
const blockTable = names.map((n) => ({
name: n,
handle: { value: nameToH.get(n) },
basePoint: vec(blocks[n].position) || { x: 0, y: 0, z: 0 },
}));
const layerSrc = dxf.tables?.layer?.layers || {};
const layerTable = Object.values(layerSrc).map((l) => ({ name: l.name, colorIndex: l.colorIndex }));
return {
version: 'dxf',
vars: dxf.header || {},
entities,
tables: { blocks: blockTable, layers: layerTable },
};
}
+5
View File
@@ -0,0 +1,5 @@
/** HATCH entity handler for dxf-parser's registerEntityHandler(). */
export default class HatchHandler {
ForEntityName: string;
parseEntity(scanner: unknown, curr: { code: number; value: unknown }): unknown;
}
+190
View File
@@ -0,0 +1,190 @@
/**
* HATCH entity handler for dxf-parser (which ships none — dxf-parser 1.1.x drops
* HATCH silently). Registered via the library's public `registerEntityHandler()`,
* so dxf-parser itself stays unmodified (MIT-clean).
*
* Emits the shape Viewer2D's HATCH renderer consumes:
* entity = { type:'HATCH', solidFill:boolean, paths:[{ points:[{x,y}], bulges:[number] }] }
* Viewer2D fills solid hatches (with island/hole nesting) and draws boundary
* outlines; bulge arcs on polyline boundaries are honored.
*
* Boundary kinds handled:
* - polyline boundary (92 flag bit 1) → vertices 10/20 (+ bulge 42)
* - edge boundary: LINE(72=1), ARC(72=2), → tessellated to points
* ELLIPSE(72=3), SPLINE(72=4, control-point polyline)
*
* Pattern fill (non-SOLID) is not expanded into hatch lines — Viewer2D draws the
* boundary outline as a proxy, matching its DWG behavior.
*/
const DEG2RAD = Math.PI / 180;
const ARC_STEPS = 48;
function tessArc(cx, cy, r, a0, a1, ccw, out) {
let span = a1 - a0;
if (ccw) { while (span <= 0) span += Math.PI * 2; }
else { while (span >= 0) span -= Math.PI * 2; }
const steps = Math.max(2, Math.ceil(Math.abs(span) / (Math.PI * 2) * ARC_STEPS));
for (let i = 0; i <= steps; i++) {
const a = a0 + (span * i) / steps;
out.push({ x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) });
}
}
export default class HatchHandler {
constructor() {
this.ForEntityName = 'HATCH';
}
parseEntity(scanner, curr) {
const entity = { type: curr.value, paths: [], solidFill: false };
curr = scanner.next();
while (!scanner.isEOF()) {
if (curr.code === 0) break;
switch (curr.code) {
case 70: // solid fill flag
entity.solidFill = curr.value !== 0;
break;
case 2: // pattern name
entity.patternName = curr.value;
break;
case 8: // layer
entity.layer = curr.value;
break;
case 62: // color index
entity.colorIndex = curr.value;
break;
case 91: { // number of boundary paths → parse them by count
const n = curr.value;
let g = scanner.next();
for (let i = 0; i < n && !scanner.isEOF(); i++) {
g = this._readPath(scanner, entity, g);
}
curr = g; // g is the group following the last path's data
continue; // curr already advanced — skip the trailing next()
}
default:
break;
}
curr = scanner.next();
}
return entity;
}
/** Parse one boundary path starting at its 92 group; return the next group after it. */
_readPath(scanner, entity, g) {
// g.code should be 92 (path type flag); if not, skip until we find it.
while (!scanner.isEOF() && g.code !== 92) g = scanner.next();
const flag = g.value | 0;
const isPolyline = (flag & 2) !== 0;
const path = { points: [], bulges: [] };
let cur = scanner.next();
if (isPolyline) {
let hasBulge = false;
while (cur.code === 72 || cur.code === 73) {
if (cur.code === 72) hasBulge = cur.value !== 0;
cur = scanner.next();
}
let numVerts = 0;
if (cur.code === 93) { numVerts = cur.value; cur = scanner.next(); }
for (let v = 0; v < numVerts && !scanner.isEOF(); v++) {
let x = 0, y = 0, b = 0;
if (cur.code === 10) { x = cur.value; cur = scanner.next(); }
if (cur.code === 20) { y = cur.value; cur = scanner.next(); }
if (hasBulge && cur.code === 42) { b = cur.value; cur = scanner.next(); }
path.points.push({ x, y });
path.bulges.push(b);
}
} else {
let numEdges = 0;
if (cur.code === 93) { numEdges = cur.value; cur = scanner.next(); }
for (let e = 0; e < numEdges && !scanner.isEOF(); e++) {
let edgeType = 0;
if (cur.code === 72) { edgeType = cur.value; cur = scanner.next(); }
cur = this._readEdge(scanner, path, edgeType, cur);
}
}
// Consume the per-path trailing "source boundary objects" block (97 + 330×count).
if (cur.code === 97) {
const srcN = cur.value | 0;
cur = scanner.next();
for (let s = 0; s < srcN && !scanner.isEOF(); s++) {
if (cur.code === 330) cur = scanner.next();
else break;
}
}
if (path.points.length >= 2) entity.paths.push(path);
return cur;
}
/** Tessellate one edge into path.points; return the next group. */
_readEdge(scanner, path, edgeType, cur) {
if (edgeType === 1) {
// LINE: 10/20 start, 11/21 end
let sx = 0, sy = 0, ex = 0, ey = 0;
if (cur.code === 10) { sx = cur.value; cur = scanner.next(); }
if (cur.code === 20) { sy = cur.value; cur = scanner.next(); }
if (cur.code === 11) { ex = cur.value; cur = scanner.next(); }
if (cur.code === 21) { ey = cur.value; cur = scanner.next(); }
path.points.push({ x: sx, y: sy });
path.points.push({ x: ex, y: ey }); // last edge's end closes the loop
} else if (edgeType === 2) {
// ARC: 10/20 center, 40 radius, 50 start deg, 51 end deg, 73 ccw
let cx = 0, cy = 0, r = 0, a0 = 0, a1 = 0, ccw = 1;
if (cur.code === 10) { cx = cur.value; cur = scanner.next(); }
if (cur.code === 20) { cy = cur.value; cur = scanner.next(); }
if (cur.code === 40) { r = cur.value; cur = scanner.next(); }
if (cur.code === 50) { a0 = cur.value * DEG2RAD; cur = scanner.next(); }
if (cur.code === 51) { a1 = cur.value * DEG2RAD; cur = scanner.next(); }
if (cur.code === 73) { ccw = cur.value; cur = scanner.next(); }
tessArc(cx, cy, r, a0, a1, ccw !== 0, path.points);
} else if (edgeType === 3) {
// ELLIPSE: 10/20 center, 11/21 major-axis endpoint, 40 ratio, 50/51 param deg, 73 ccw
let cx = 0, cy = 0, mx = 1, my = 0, ratio = 1, a0 = 0, a1 = Math.PI * 2, ccw = 1;
if (cur.code === 10) { cx = cur.value; cur = scanner.next(); }
if (cur.code === 20) { cy = cur.value; cur = scanner.next(); }
if (cur.code === 11) { mx = cur.value; cur = scanner.next(); }
if (cur.code === 21) { my = cur.value; cur = scanner.next(); }
if (cur.code === 40) { ratio = cur.value; cur = scanner.next(); }
if (cur.code === 50) { a0 = cur.value * DEG2RAD; cur = scanner.next(); }
if (cur.code === 51) { a1 = cur.value * DEG2RAD; cur = scanner.next(); }
if (cur.code === 73) { ccw = cur.value; cur = scanner.next(); }
const major = Math.hypot(mx, my);
const minor = major * ratio;
const rot = Math.atan2(my, mx);
let span = a1 - a0;
if (ccw !== 0) { while (span <= 0) span += Math.PI * 2; }
else { while (span >= 0) span -= Math.PI * 2; }
const steps = Math.max(4, Math.ceil(Math.abs(span) / (Math.PI * 2) * 72));
for (let i = 0; i <= steps; i++) {
const t = a0 + (span * i) / steps;
const ex = major * Math.cos(t), ey = minor * Math.sin(t);
path.points.push({ x: cx + ex * Math.cos(rot) - ey * Math.sin(rot), y: cy + ex * Math.sin(rot) + ey * Math.cos(rot) });
}
} else if (edgeType === 4) {
// SPLINE: approximate by its control points (10/20). Skip knots/weights.
// 94 degree, 73 rational, 74 periodic, 95 numKnots, 96 numCtrl, 40 knots, 10/20 ctrl, 42 weights
let numKnots = 0, numCtrl = 0;
const readInt = () => { const v = cur.value | 0; cur = scanner.next(); return v; };
while (!scanner.isEOF()) {
if (cur.code === 95) { numKnots = readInt(); }
else if (cur.code === 96) { numCtrl = readInt(); }
else if (cur.code === 94 || cur.code === 73 || cur.code === 74) { cur = scanner.next(); }
else break;
}
for (let k = 0; k < numKnots && cur.code === 40 && !scanner.isEOF(); k++) cur = scanner.next();
for (let c = 0; c < numCtrl && !scanner.isEOF(); c++) {
let x = 0, y = 0;
if (cur.code === 10) { x = cur.value; cur = scanner.next(); }
if (cur.code === 20) { y = cur.value; cur = scanner.next(); }
if (cur.code === 42) cur = scanner.next(); // weight
path.points.push({ x, y });
}
} else {
// Unknown edge type: advance one group to avoid stalling.
cur = scanner.next();
}
return cur;
}
}
+25
View File
@@ -0,0 +1,25 @@
/**
* 2D file-format detection for the 2D+3D merge dispatch.
*
* 2D = CAD drawings (dwg/dxf) → rendered by Viewer2D via the WASM parser.
* Everything else (glb/gltf/obj/fbx/dae/ifc/ply/3ds/3dm) is 3D → ThreeDViewer.
* Kept dependency-free so both the dropzone and the controller can import it
* without pulling in either viewer.
*/
export const DWG_EXT = /\.dwg$/i;
export const DXF_EXT = /\.dxf$/i;
/** True if the name is a binary DWG (parsed by the WASM parser). */
export function isDwg(name: string): boolean {
return DWG_EXT.test(name);
}
/** True if the name is a text DXF (parsed by the dxf-parser JS lib). */
export function isDxf(name: string): boolean {
return DXF_EXT.test(name);
}
/** True if the file/URL name is a 2D CAD drawing (dwg or dxf) → Viewer2D. */
export function is2D(name: string): boolean {
return DWG_EXT.test(name) || DXF_EXT.test(name);
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Repair Korean text that acadrust emits as Latin-1 mojibake of CP949/EUC-KR.
*
* Older Korean DWGs store strings in code page 949. When those bytes are
* decoded as ISO-8859-1/Windows-1252, Hangul becomes garbled Latin
* (e.g. "½Ã°ø½Ã" instead of "시공시"). Unicode-correct drawings
* (already containing Hangul syllables) are left unchanged.
*
* Apply after parse_dwg, before Viewer2D.load().
*/
const HANGUL_SYLLABLE = /[\uAC00-\uD7A3]/;
const HANGUL_JAMO = /[\u1100-\u11FF\u3130-\u318F]/;
/** Labels tried in order; first supported decoder wins. */
const KOREAN_LABELS = ['cp949', 'windows-949', 'euc-kr'] as const;
let koreanDecoder: TextDecoder | null | undefined;
function getKoreanDecoder(): TextDecoder | null {
if (koreanDecoder !== undefined) return koreanDecoder;
for (const label of KOREAN_LABELS) {
try {
koreanDecoder = new TextDecoder(label);
return koreanDecoder;
} catch {
/* label not available in this runtime */
}
}
koreanDecoder = null;
return null;
}
/**
* If `s` looks like CP949 bytes misread as Latin-1, return the Hangul
* form; otherwise return `s` unchanged.
*/
export function fixCp949Mojibake(s: string): string {
if (!s) return s;
// Already real Hangul → leave alone (e.g. BasicSample.dwg).
if (HANGUL_SYLLABLE.test(s) || HANGUL_JAMO.test(s)) return s;
let hasHigh = false;
const bytes = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) {
const code = s.charCodeAt(i);
if (code > 255) return s; // true Unicode beyond Latin-1
if (code > 127) hasHigh = true;
bytes[i] = code;
}
if (!hasHigh) return s; // pure ASCII
const decoder = getKoreanDecoder();
if (!decoder) return s;
let fixed: string;
try {
fixed = decoder.decode(bytes);
} catch {
return s;
}
// Accept only when we gain Hangul and the decode is clean.
if (
(HANGUL_SYLLABLE.test(fixed) || HANGUL_JAMO.test(fixed)) &&
!fixed.includes('\uFFFD')
) {
return fixed;
}
return s;
}
/**
* Deep-walk a CadParseResult (or any JSON-like tree) and fix string fields
* in place. Returns the same object for chaining.
*/
export function fixDwgKoreanText<T>(root: T): T {
if (root == null || typeof root !== 'object') return root;
const seen = new WeakSet<object>();
const walk = (value: unknown): void => {
if (value == null || typeof value !== 'object') return;
if (seen.has(value as object)) return;
seen.add(value as object);
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const item = value[i];
if (typeof item === 'string') value[i] = fixCp949Mojibake(item);
else walk(item);
}
return;
}
const obj = value as Record<string, unknown>;
for (const key of Object.keys(obj)) {
if (key === 'imageDataUrl') continue;
const item = obj[key];
if (typeof item === 'string') obj[key] = fixCp949Mojibake(item);
else walk(item);
}
};
walk(root);
return root;
}
+3
View File
@@ -0,0 +1,3 @@
// Auto-generated standard LIN file definitions for Icad.lin and KOSDIC.lin
export const ICAD_LIN = "; IntelliCAD Linetypes\r\n;\r\n; icad.lin\r\n; Release 1.0 \r\n; \r\n; .............................................................................\r\n;\r\n; Copyright (c) 1998 by Visio Corporation. All rights reserved.\r\n;\r\n; The Software is subject to the license agreement that accompanies \r\n; or is included with the Software, which specifies the permitted \r\n; and prohibited uses of the Software. Any unauthorized duplication \r\n; or use of Visio Corporation Software, in whole or in part, in print, \r\n; or in any other storage and retrieval system is prohibited.\r\n; \r\n; To the maximum extent permitted by applicable law, Visio Corporation\r\n; and its suppliers disclaim any and all warranties and conditions,\r\n; either express or implied, including, without limitation, implied\r\n; warranties of merchantability, fitness for a particular purpose,\r\n; title, and non-infringement, and those arising out of usage of trade\r\n; or course of dealing, concerning these materials. These materials\r\n; are provided \"as is\" without warranty of any kind.\r\n; .............................................................................\r\n\r\n\r\n\r\n*BORDER,__ __ . __ __ . __ __ . __ __ . __ __ . __ __ . __ __ . __ __ . \r\nA,0.50,-0.250,0.50,-0.250,0,-0.250\r\n*BORDER2,__.__.__.__.__.__.__.__.__.__.__.__.__.__.__.__.__.__.__.__.__ \r\nA,0.25,-0.125,0.25,-0.125,0,-0.125\r\n*BORDERX2,____ ____ . ____ ____ . ____ ____ . ____ ____ . \r\nA,1.0,-0.5,1.0,-0.5,0,-0.5\r\n\r\n*CENTER,____ _ ____ _ ____ _ ____ _ ____ _ ____ _ ____ _ ____ _ ____ _\r\nA,1.250,-0.25,0.25,-0.25\r\n*CENTER2,___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ _\r\nA,0.75,-0.1250,0.1250,-0.125\r\n*CENTERX2,________ __ ________ __ ________ __ ________ __ ____\r\nA,2.5,-0.50,0.50,-0.50\r\n\r\n*DASHDOT,__ . __ . __ . __ . __ . __ . __ . __ . __ . __ . __ . __ . _\r\nA,0.50,-0.250,0,-0.250\r\n*DASHDOT2,_._._._._._._._._._._._._._._._._._._._._._._._._._._._._._.\r\nA,0.25,-0.125,0,-0.125\r\n*DASHDOTX2,____ . ____ . ____ . ____ . ____ . ____ . ____ \r\nA,1.0,-0.5,0,-0.5\r\n\r\n*DASHED,__ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __\r\nA,0.50,-0.250\r\n*DASHED2,_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _\r\nA,0.250,-0.1250\r\n*DASHEDX2,____ ____ ____ ____ ____ ____ ____ ____ ____ ____ \r\nA,1.0,-0.5\r\n\r\n*DIVIDE,____ . . ____ . . ____ . . ____ . . ____ . . ____ . . ____ . .\r\nA,0.50,-0.25,0,-0.25,0,-0.25\r\n*DIVIDE2,__..__..__..__..__..__..__..__..__..__..__..__..__..__..__..__..\r\nA,0.25,-0.125,0,-0.125,0,-0.125\r\n*DIVIDEX2,________ . . ________ . . ________ . . ________ . .\r\nA,1.0,-0.5,0,-0.5,0,-0.50\r\n\r\n*DOT,. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\r\nA,0,-0.25\r\n*DOT2,................................................................\r\nA,0,-0.125\r\n*DOTX2,. . . . . . . . . . . . . . . . . . . . . \r\nA,0,-0.5\r\n\r\n*HIDDEN,__ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ \r\nA,0.250,-0.1250\r\n*HIDDEN2,_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _\r\nA,0.125,-0.06250\r\n*HIDDENX2,____ ____ ____ ____ ____ ____ ____ ____ ____ ____ ____ ____ \r\nA,0.5,-0.25\r\n\r\n*PHANTOM,______ __ __ ______ __ __ ______ __ __ ______ __ __\r\nA,1.25,-0.25,0.250,-0.25,0.25,-0.25\r\n*PHANTOM2,___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ _ _\r\nA,0.625,-0.125,0.1250,-0.125,0.125,-0.1250\r\n*PHANTOMX2,____________ ____ ____ ____________ ____ ____ \r\nA,2.5,-0.5,0.5,-0.5,0.50,-0.5\r\n\r\n*PHANTOM4,___ _ _ ___ _ _ (1/2 of Phantom2)__ _ _ ___ _ _ ___ _ _ ___ _ _\r\nA,0.3125,-0.0625,0.0625,-0.0625,0.0625,-0.0625\r\n\r\n*PHANTOM8,___ _ _ ___ _ _ (1/2 of Phantom4)__ _ _ ___ _ _ ___ _ _ ___ _ _\r\nA,0.15625,-0.03125,0.03125,-0.03125,0.03125,-0.03125\r\n\r\n*BORDER4,__.__.__.__.__.__._ (1/2 of Border2) _.__.__.__.__.__.__.__.__ \r\nA,0.125,-0.0625,0.125,-0.0625,0,-0.0625\r\n\r\n*CENTER4,___ _ ___ _ ___ _ __ (1/2 of Center2)__ _ ___ _ ___ _ ___ _ _\r\nA,0.375,-0.0625,0.0625,-0.0625\r\n*CENTER8,___ _ ___ _ ___ _ __ (1/2 of Center4)__ _ ___ _ ___ _ ___ _ _\r\nA,0.1875,-0.03125,0.03125,-0.03125\r\n\r\n*CENTERF,_____ _ _____ (Finer short dashes than Center) _____\r\nA,1.25,-0.125,0.125,-0.125\r\n*CENTERF2,___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ _\r\nA,0.75,-0.0625,0.0625,-0.0625\r\n;; Auto-Arch additions for finer linetypes.\r\n*CENTERF4,___ _ ___ _ ___ _ __ (1/2 of CenterF2)__ _ ___ _ ___ _ ___ _ _\r\nA,0.375,-0.03125,0.03125,-0.03125\r\n*CENTERF8,___ _ ___ _ ___ _ __ (1/2 of CenterF4)__ _ ___ _ ___ _ ___ _ _\r\nA,0.1875,-0.0015625,0.0015625,-0.0015625\r\n\r\n*DASHED4,_ _ _ _ _ _ _ _ (1/2 of Dashed2)_ _ _ _ _ _ _ _ _ _ _ _ _ _ _\r\nA,0.125,-0.0625\r\n*DASHED8,_ _ _ _ _ _ _ _ (1/2 of Dashed4)_ _ _ _ _ _ _ _ _ _ _ _ _ _ _\r\nA,0.0625,-0.03125\r\n\r\n*DEMOx2,____ ____ ____ ____ Line truncated
export const KOSDIC_LIN = ";; ------------------------------------�� �� �� ��------------------------------------\r\n;; �� �� ���� ���� ����(KOSDIC.lin)�� �Ǽ�CALS/EC ���ڵ��� �ۼ�ǥ���� �� ��Ͽ� ���ǵ� �� ������\r\n;; AutoCAD ����ڿ��� �����ϱ� ���� ���۵Ǿ����ϴ�.\r\n;; \r\n;; ǥ�ع��� : �Ǽ�CALS/EC ���ڵ��� �ۼ�ǥ�� V1.1(2006.12)\r\n;; ������� : �Ǽ�CALS/EC ������ �ѱ��Ǽ����������\r\n;;\r\n;; �����Ģ : 1/1 ��ô�� ����ũ�� 2.0mm�� ��� ������ ũ�⸦ ������\r\n;; LIB ��Ģ : AutoCAD�� ANSI����(���� ��������� ����)�� ����Ű ���� 1/10�� ������\r\n;; (��) ���� �� ���� ������ 2mm�̸� 0.2unit�� ����\r\n;; ����Ģ : �ǹ����鿡 ����� ��� �������� ��Ģ�� ���յǷ��� ��������ô�� 10�踦 ������\r\n;; (��) LTSCALE=10 (���� 8~10��), ������ô�� 1/5 �̸� LTSCALE=5x10��=50\r\n;;\r\n;; ISO���� �԰� 15��\r\n// ���� AutoCAD ISO�� 25.4����� ������ �� ����\r\n*A-ISONS01,ISO �����԰� 01 Continuous ________________________________\r\nA,1,1\r\n*A-ISONS02,ISO �����԰� 02 dash __ __ __ __ __ __ __ __ __ __ __ __ __\r\nA,.47244,-.118111\r\n*A-ISONS03,ISO �����԰� 03 dash space __ __ __ __ __ __\r\nA,.47244,-.7086614\r\n*A-ISONS04,ISO �����԰� 04 long-dash dot ____ . ____ . ____ . ____ . _\r\nA,.94488,-.11811,0,-.11811\r\n*A-ISONS05,ISO �����԰� 05 long-dash double-dot ____ .. ____ .. ____ . \r\nA,.94488,-.11811,0,-.11811,0,-.11811\r\n*A-ISONS06,ISO �����԰� 06 long-dash triple-dot ____ ... ____ ... ____\r\nA,.94488,-.11811,0,-.11811,0,-.11811,0,-.11811\r\n*A-ISONS07,ISO �����԰� 07 dot . . . . . . . . . . . . . . . . . . . . \r\nA,0,-.11811\r\n*A-ISONS08,ISO �����԰� 08 long-dash short-dash ____ __ ____ __ ____ _\r\nA,.94488,-.11811,.94488,-.11811\r\n*A-ISONS09,ISO �����԰� 09 long-dash double-short-dash ____ __ __ ____\r\nA,.94488,-.11811,.94488,-.11811,.94488,-.11811\r\n*A-ISONS10,ISO �����԰� 10 dash dot __ . __ . __ . __ . __ . __ . __ . \r\nA,.47244,-.11811,0,-.11811\r\n*A-ISONS11,ISO �����԰� 11 double-dash dot __ __ . __ __ . __ __ . __ _\r\nA,.47244,-.11811,.47244,-.11811,0,-.11811\r\n*A-ISONS12,ISO �����԰� 12 dash double-dot __ . . __ . . __ . . __ . . \r\nA,.47244,-.11811,0,-.11811,0,-.11811\r\n*A-ISONS13,ISO �����԰� 13 double-dash double-dot __ __ . . __ __ . . _\r\nA,.47244,-.11811,.47244,-.11811,0,-.11811,0,-.11811\r\n*A-ISONS14,ISO �����԰� 14 dash triple-dot __ . . . __ . . . __ . . . _\r\nA,.47244,-.11811,0,-.11811,0,-.11811,0,-.11811\r\n*A-ISONS15,ISO �����԰� 15 double-dash triple-dot __ __ . . . __ __ . .\r\nA,.47244,-.11811,.47244,-.11811,0,-.11811,0,-.11811,0,-.11811\r\n\r\n;; ���CAD ����\r\n*A-BORDR01,�ܰ���(�Ϲ�) Border __ __ . __ __ . __ __ . __ __ . __ __ .\r\nA,.5,-.25,.5,-.25,0,-.25\r\n*A-BORDR01H,�ܰ���(�ݹ��) Border (.5x) __.__.__.__.__.__.__.__.__.__.__.\r\nA,.25,-.125,.25,-.125,0,-.125\r\n*A-BORDR01X,�ܰ���(���) Border (2x) ____ ____ . ____ ____ . ___\r\nA,1.0,-.5,1.0,-.5,0,-.5\r\n*A-CENTR01,�߽ɼ�(�Ϲ�) Center ____ _ ____ _ ____ _ ____ _ ____ _ ____\r\nA,1.25,-.25,.25,-.25\r\n*A-CENTR01H,�߽ɼ�(�ݹ��) Center (.5x) ___ _ ___ _ ___ _ ___ _ ___ _ ___\r\nA,.75,-.125,.125,-.125\r\n*A-CENTR01X,�߽ɼ�(���) Center (2x) ________ __ ________ __ _____\r\nA,2.5,-.5,.5,-.5\r\n*A-DASDT01,�Ĵ�����(�Ϲ�) Dash dot __ . __ . __ . __ . __ . __ . __ . __\r\nA,.5,-.25,0,-.25\r\n*A-DASDT01H,�Ĵ�����(�ݹ��) Dash dot (.5x) _._._._._._._._._._._._._._._.\r\nA,.25,-.125,0,-.125\r\n*A-DASDT01X,�Ĵ�����(���) Dash dot (2x) ____ . ____ . ____ . ___\r\nA,1.0,-.5,0,-.5\r\n*A-DASHD01,�ļ�(�Ϲ�) Dashed __ __ __ __ __ __ __ __ __ __ __ __ __ _\r\nA,.5,-.25\r\n*A-DASHD01H,�ļ�(�ݹ��) Dashed (.5x) _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ \r\nA,.25,-.125\r\n*A-DASHD01X,�ļ�(���) Dashed (2x) ____ ____ ____ ____ ____ ___\r\nA,1.0,-.5\r\n*A-DIVID01,���Ҽ�(�Ϲ�) Divide ____ . . ____ . . ____ . . ____ . . ____\r\nA,.5,-.25,0,-.25,0,-.25\r\n*A-DIVID01H,���Ҽ�(�ݹ��) Divide (.5x) __..__..__..__..__..__..__..__.._\r\nA,.25,-.125,0,-.125,0,-.125\r\n*A-DIVID01X,��� Line truncated
+390
View File
@@ -0,0 +1,390 @@
/**
* LIN File Parser & Complex Linetype Registry
* Parses AutoCAD LIN file definitions (including text and shape descriptors).
*/
import { ICAD_LIN, KOSDIC_LIN } from './linData';
export interface LinElement {
type: 'dash' | 'gap' | 'shape' | 'text';
val?: number; // dash length (+) or gap length (-)
shapeName?: string; // e.g. KSC35, CIRC1, BOX, TRACK1, BAT, ZIG
shxFile?: string; // e.g. kosdic.shx, ltypeshp.shx
text?: string; // e.g. "L2", "GAS", "HW"
style?: string; // e.g. Standard
scale: number; // s=... (default 1.0)
rotation: number; // r=... (angle in degrees)
isAbsoluteAngle: boolean;// a=...
isUprightAngle: boolean; // u=...
xOffset: number; // x=...
yOffset: number; // y=...
}
export interface LinPattern {
name: string;
description: string;
elements: LinElement[];
patternLength: number; // total length sum of dashes + abs(gaps)
}
function parseBracketElement(inner: string): LinElement {
const parts: string[] = [];
let cur = '';
let inQuote = false;
for (let i = 0; i < inner.length; i++) {
const c = inner[i];
if (c === '"') inQuote = !inQuote;
if (c === ',' && !inQuote) {
parts.push(cur.trim());
cur = '';
} else {
cur += c;
}
}
if (cur.trim()) parts.push(cur.trim());
let type: 'shape' | 'text' = 'shape';
let shapeName: string | undefined;
let shxFile: string | undefined;
let text: string | undefined;
let style: string | undefined;
let scale = 1.0;
let rotation = 0;
let isAbsoluteAngle = false;
let isUprightAngle = false;
let xOffset = 0;
let yOffset = 0;
if (parts.length > 0) {
const first = parts[0];
if (first.startsWith('"') && first.endsWith('"')) {
type = 'text';
text = first.slice(1, -1);
if (parts.length > 1 && !parts[1].includes('=')) style = parts[1];
} else {
type = 'shape';
shapeName = first;
if (parts.length > 1 && !parts[1].includes('=')) shxFile = parts[1];
}
for (let i = 1; i < parts.length; i++) {
const part = parts[i];
const eqIdx = part.indexOf('=');
if (eqIdx > 0) {
const key = part.substring(0, eqIdx).trim().toLowerCase();
const valStr = part.substring(eqIdx + 1).trim();
const val = parseFloat(valStr);
if (!isNaN(val)) {
if (key === 's') scale = val;
else if (key === 'r') { rotation = val; }
else if (key === 'a') { rotation = val; isAbsoluteAngle = true; }
else if (key === 'u') { rotation = val; isUprightAngle = true; }
else if (key === 'x') xOffset = val;
else if (key === 'y') yOffset = val;
}
}
}
}
return {
type,
shapeName,
shxFile,
text,
style,
scale,
rotation,
isAbsoluteAngle,
isUprightAngle,
xOffset,
yOffset,
};
}
function tokenizeLinPattern(patternStr: string): string[] {
const s = patternStr.replace(/^A\s*,\s*/i, '').trim();
const tokens: string[] = [];
let current = '';
let inBracket = false;
let inQuote = false;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (ch === '"') inQuote = !inQuote;
if (ch === '[' && !inQuote) inBracket = true;
if (ch === ']' && !inQuote) inBracket = false;
if (ch === ',' && !inBracket && !inQuote) {
if (current.trim()) tokens.push(current.trim());
current = '';
} else {
current += ch;
}
}
if (current.trim()) tokens.push(current.trim());
return tokens;
}
export function parseLinContent(content: string): Map<string, LinPattern> {
const result = new Map<string, LinPattern>();
const lines = content.split(/\r?\n/);
let currentName: string | null = null;
let currentDesc = '';
for (let line of lines) {
line = line.trim();
if (!line || line.startsWith(';')) continue;
if (line.startsWith('*')) {
const commaIdx = line.indexOf(',');
if (commaIdx > 0) {
currentName = line.substring(1, commaIdx).trim();
currentDesc = line.substring(commaIdx + 1).trim();
} else {
currentName = line.substring(1).trim();
currentDesc = '';
}
} else if (currentName && line.toUpperCase().startsWith('A,')) {
const tokens = tokenizeLinPattern(line);
const elements: LinElement[] = [];
let totalLength = 0;
for (const tok of tokens) {
if (tok.startsWith('[') && tok.endsWith(']')) {
const elem = parseBracketElement(tok.slice(1, -1));
elements.push(elem);
} else {
const val = parseFloat(tok);
if (!isNaN(val)) {
if (val > 1e-6) {
elements.push({
type: 'dash',
val,
scale: 1,
rotation: 0,
isAbsoluteAngle: false,
isUprightAngle: false,
xOffset: 0,
yOffset: 0,
});
totalLength += val;
} else if (val < -1e-6) {
const absVal = Math.abs(val);
elements.push({
type: 'gap',
val: -absVal,
scale: 1,
rotation: 0,
isAbsoluteAngle: false,
isUprightAngle: false,
xOffset: 0,
yOffset: 0,
});
totalLength += absVal;
} else {
// Dot (val === 0)
elements.push({
type: 'dash',
val: 0.05,
scale: 1,
rotation: 0,
isAbsoluteAngle: false,
isUprightAngle: false,
xOffset: 0,
yOffset: 0,
});
totalLength += 0.05;
}
}
}
}
if (elements.length > 0) {
result.set(currentName.toUpperCase(), {
name: currentName,
description: currentDesc,
elements,
patternLength: Math.max(totalLength, 0.1),
});
}
currentName = null;
}
}
return result;
}
// Global Linetype Registry initialized with Icad.lin and KOSDIC.lin
const globalRegistry = new Map<string, LinPattern>();
export function registerLinContent(content: string) {
const parsed = parseLinContent(content);
for (const [key, pattern] of parsed.entries()) {
globalRegistry.set(key, pattern);
}
}
// Warm up registry with bundled LIN files
registerLinContent(ICAD_LIN);
registerLinContent(KOSDIC_LIN);
const CIVIL_LT_TEXT_MAP: Record<string, string> = {
'H-DICHL02': 'L2-2/0',
'H-DICHL02R': 'L2-2',
'H-DICHL01': 'L2-3/0',
'H-DICHL01R': 'L2-3',
'H-DICHL06': 'L5-1',
'H-DICHL06R': 'L5-1',
'H-DICHL05': 'L5',
'H-DICHL05R': 'L5',
'H-DICHU05': 'U5',
'H-DICHU05R': 'U5',
'H-DICHB01': 'B1',
'H-DICHB01R': 'B1',
'H-DICHM03': 'M3',
'H-DICHM03R': 'M3',
'H-DICHV01': 'V1',
'H-DICHV01R': 'V1',
'H-DICHO01': 'O1',
'H-DICHO01R': 'O1',
'H-DICHL03': 'L3',
'H-DICHL03R': 'L3',
'H-DICHL04': 'L4',
'H-DICHL04R': 'L4',
'H-DICHL08': 'L8',
'H-DICHL08R': 'L8',
};
const NO_SHAPE_LINETYPES = new Set([
'H-DICHL05',
'H-DICHL05R',
'H-DICHU05',
'H-DICHU05R',
'H-DICHB01',
'H-DICHB01R',
'H-DICHM03',
'H-DICHM03R',
'H-DICHV01',
'H-DICHV01R',
'H-DICHO01',
'H-DICHO01R',
]);
export function getLinPattern(name: string): LinPattern | undefined {
if (!name) return undefined;
const key = name.toUpperCase().trim();
const pattern = globalRegistry.get(key);
if (!pattern) return undefined;
let elements = pattern.elements;
// Filter out shape/arrowhead elements for L5, U5, B1, etc. as specified in official CAD spec
if (NO_SHAPE_LINETYPES.has(key)) {
elements = elements.filter((elem) => elem.type !== 'shape');
}
const overrideText = CIVIL_LT_TEXT_MAP[key];
if (overrideText) {
elements = elements.map((elem) =>
elem.type === 'text' ? { ...elem, text: overrideText } : elem
);
}
return {
...pattern,
elements,
};
}
export function getAllRegisteredPatterns(): Map<string, LinPattern> {
return globalRegistry;
}
export function mergeDwgLinetypePattern(
ltName: string,
dwgPattern: number[],
description?: string
): LinPattern | undefined {
if (!ltName) return undefined;
const base = getLinPattern(ltName);
if (!dwgPattern || !dwgPattern.length) return base;
if (!base) {
const elements: LinElement[] = [];
let totalLength = 0;
for (const val of dwgPattern) {
if (val > 1e-6) {
elements.push({
type: 'dash',
val,
scale: 1,
rotation: 0,
isAbsoluteAngle: false,
isUprightAngle: false,
xOffset: 0,
yOffset: 0,
});
totalLength += val;
} else if (val < -1e-6) {
const absVal = Math.abs(val);
elements.push({
type: 'gap',
val: -absVal,
scale: 1,
rotation: 0,
isAbsoluteAngle: false,
isUprightAngle: false,
xOffset: 0,
yOffset: 0,
});
totalLength += absVal;
}
}
return {
name: ltName,
description: description || '',
elements,
patternLength: Math.max(totalLength, 0.1),
};
}
// Merge DWG numeric dash/gap values into base pattern elements
const newElements: LinElement[] = [];
let dwgValIdx = 0;
let totalLength = 0;
for (const elem of base.elements) {
if (elem.type === 'dash') {
const dwgVal =
dwgValIdx < dwgPattern.length && dwgPattern[dwgValIdx] > 0
? dwgPattern[dwgValIdx]
: elem.val || 0.05;
if (dwgValIdx < dwgPattern.length && dwgPattern[dwgValIdx] > 0) {
dwgValIdx++;
}
newElements.push({ ...elem, val: dwgVal });
totalLength += dwgVal;
} else if (elem.type === 'gap') {
const dwgVal =
dwgValIdx < dwgPattern.length && dwgPattern[dwgValIdx] < 0
? dwgPattern[dwgValIdx]
: elem.val;
if (dwgValIdx < dwgPattern.length && dwgPattern[dwgValIdx] < 0) {
dwgValIdx++;
}
const absVal = Math.abs(dwgVal || 0.1);
newElements.push({ ...elem, val: -absVal });
totalLength += absVal;
} else {
newElements.push({ ...elem });
}
}
return {
...base,
description: description || base.description,
elements: newElements,
patternLength: Math.max(totalLength, 0.1),
};
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Parse DXF text/buffer into the same CadParseResult shape as DWG.
*/
import DxfParser from 'dxf-parser';
import HatchHandler from './dxfHatchHandler.js';
import { dxfToParseResult } from './dxfAdapter.js';
import { decodeDxf } from './decodeDxf';
import type { CadParseResult } from './dwgParser';
/** Parse a DXF ArrayBuffer (encoding-aware) → CadParseResult. */
export function parseDxfBuffer(buf: ArrayBuffer): CadParseResult {
return parseDxfText(decodeDxf(buf));
}
/** Parse DXF text already decoded as a string. */
export function parseDxfText(text: string): CadParseResult {
const parser = new DxfParser();
// dxf-parser ships no HATCH handler; its EntityName union excludes 'HATCH'.
parser.registerEntityHandler(
HatchHandler as unknown as Parameters<DxfParser['registerEntityHandler']>[0],
);
const dxf = parser.parseSync(text);
return dxfToParseResult(dxf);
}
+20
View File
@@ -0,0 +1,20 @@
declare module 'dxf-parser' {
export default class DxfParser {
parseSync(text: string): unknown;
registerEntityHandler(handler: { ForEntityName: string; parseEntity: (...args: unknown[]) => unknown }): void;
}
}
declare module 'opentype.js' {
export function parse(buffer: ArrayBuffer): unknown;
}
declare module '*.wasm?url' {
const url: string;
export default url;
}
declare module '*.wasm' {
const url: string;
export default url;
}
+403
View File
@@ -0,0 +1,403 @@
/**
* slugText — GPU-resident vector text for the 2D viewer, after Eric Lengyel's
* Slug algorithm ("GPU-Centered Font Rendering Directly from Glyph Outlines",
* JCGT 2017; reference shaders at github.com/EricLengyel/Slug — patent
* dedicated to the public domain, attribution retained here).
*
* Replaces the per-string CanvasTexture sprites: TrueType quadratic Bézier
* outlines are uploaded once per UNIQUE GLYPH into a float data texture, and
* the fragment shader computes an antialiased winding number per pixel by
* intersecting horizontal + vertical rays with the glyph's curves (curves
* pre-sorted into 8 bands per axis to bound the per-pixel work). Text stays
* razor sharp at any zoom and memory scales with glyph count, not text count.
*
* Font: public/fonts/NanumGothic-Regular.ttf (OFL) — TrueType glyf outlines
* (quadratic only), Latin + full Hangul.
*/
import * as THREE from 'three';
import { parse as parseFont } from 'opentype.js';
const TEX_W = 1024; // data texture width in texels (RGBA32F)
const BANDS = 8; // bands per axis per glyph
const PAD = 0.08; // em padding around glyph quads (AA spill room)
interface Curve { x0: number; y0: number; cx: number; cy: number; x1: number; y1: number }
interface GlyphRec {
base: number; // texel index of the glyph header (-1 when no ink, e.g. space)
advance: number; // em units
}
/** Default TrueType font URL (host must serve Hangul-capable TTF). */
let defaultFontUrl = '/fonts/NanumGothic-Regular.ttf';
/** Override the default font path before creating CadViewer / Viewer2D. */
export function setDefaultFontUrl(url: string): void {
defaultFontUrl = url;
// reset shared engine so the next shared() uses the new URL
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(SlugTextEngine as any)._shared = null;
}
/** One shared font engine (font fetch + parse happens once). */
export class SlugTextEngine {
private static _shared: Promise<SlugTextEngine> | null = null;
static shared(url: string = defaultFontUrl): Promise<SlugTextEngine> {
if (!this._shared) {
this._shared = (async () => {
const buf = await (await fetch(url)).arrayBuffer();
return new SlugTextEngine(parseFont(buf));
})();
// allow a retry on transient fetch failure instead of caching the rejection
this._shared.catch(() => { (SlugTextEngine as any)._shared = null; });
}
return this._shared;
}
readonly capHeightEm: number;
readonly ascentEm: number;
readonly descentEm: number;
private font: any;
private upem: number;
private glyphs = new Map<number, GlyphRec>();
private data = new Float32Array(TEX_W * 4 * 64); // grows ×2 as needed
private used = 0; // texels consumed
private _texture: THREE.DataTexture | null = null;
private _texelsUploaded = 0;
private constructor(font: any) {
this.font = font;
this.upem = font.unitsPerEm;
this.capHeightEm = (font.tables?.os2?.sCapHeight || font.ascender * 0.88) / this.upem;
this.ascentEm = font.ascender / this.upem;
this.descentEm = font.descender / this.upem; // negative
}
/** Layout width of one line, in em units. */
measureEm(line: string): number {
let w = 0;
for (const ch of line) w += this.ensureGlyph(ch.codePointAt(0)!).advance;
return w;
}
ensureGlyph(cp: number): GlyphRec {
let rec = this.glyphs.get(cp);
if (rec) return rec;
const glyph = this.font.charToGlyph(String.fromCodePoint(cp));
const advance = (glyph.advanceWidth ?? this.upem * 0.5) / this.upem;
// getPath(0,0,1) → coordinates scaled to em units but y-DOWN; flip y.
const path = glyph.getPath(0, 0, 1);
const curves: Curve[] = [];
let sx = 0, sy = 0, px = 0, py = 0;
for (const c of path.commands) {
switch (c.type) {
case 'M': px = sx = c.x; py = sy = -c.y; break;
case 'L': curves.push(lineCurve(px, py, c.x, -c.y)); px = c.x; py = -c.y; break;
case 'Q': curves.push({ x0: px, y0: py, cx: c.x1, cy: -c.y1, x1: c.x, y1: -c.y }); px = c.x; py = -c.y; break;
case 'C': { // glyf fonts shouldn't emit cubics; approximate defensively
const mx = (c.x1 + c.x2) / 2, my = (-c.y1 + -c.y2) / 2;
curves.push({ x0: px, y0: py, cx: mx, cy: my, x1: c.x, y1: -c.y });
px = c.x; py = -c.y; break;
}
case 'Z': if (px !== sx || py !== sy) curves.push(lineCurve(px, py, sx, sy)); px = sx; py = sy; break;
}
}
if (!curves.length) {
rec = { base: -1, advance };
this.glyphs.set(cp, rec);
return rec;
}
// Conservative bbox from all points (control points included).
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const q of curves) {
x0 = Math.min(x0, q.x0, q.cx, q.x1); x1 = Math.max(x1, q.x0, q.cx, q.x1);
y0 = Math.min(y0, q.y0, q.cy, q.y1); y1 = Math.max(y1, q.y0, q.cy, q.y1);
}
const w = Math.max(x1 - x0, 1e-6), h = Math.max(y1 - y0, 1e-6);
// Band membership (conservative, via control-point extents).
const hBands: number[][] = Array.from({ length: BANDS }, () => []);
const vBands: number[][] = Array.from({ length: BANDS }, () => []);
curves.forEach((q, i) => {
const cy0 = Math.min(q.y0, q.cy, q.y1), cy1 = Math.max(q.y0, q.cy, q.y1);
const cx0 = Math.min(q.x0, q.cx, q.x1), cx1 = Math.max(q.x0, q.cx, q.x1);
const hb0 = clampBand((cy0 - y0) / h), hb1 = clampBand((cy1 - y0) / h);
for (let b = hb0; b <= hb1; b++) hBands[b].push(i);
const vb0 = clampBand((cx0 - x0) / w), vb1 = clampBand((cx1 - x0) / w);
for (let b = vb0; b <= vb1; b++) vBands[b].push(i);
});
// ── Serialize: header(2) + bands(16) + index lists + curves(2/curve) ──
const listTexels = (l: number[]) => Math.ceil(l.length / 4);
const idxTexels = hBands.reduce((s, l) => s + listTexels(l), 0)
+ vBands.reduce((s, l) => s + listTexels(l), 0);
const total = 2 + BANDS * 2 + idxTexels + curves.length * 2;
const base = this.alloc(total);
const d = this.data;
const put = (t: number, a: number, b: number, c: number, e: number) => {
d[t * 4] = a; d[t * 4 + 1] = b; d[t * 4 + 2] = c; d[t * 4 + 3] = e;
};
const curveBase = base + 2 + BANDS * 2 + idxTexels;
put(base, x0, y0, x1, y1);
put(base + 1, base + 2, base + 2 + BANDS, 0, 0); // hBandBase, vBandBase
let listCursor = base + 2 + BANDS * 2;
const writeBands = (bands: number[][], texel: number) => {
for (let b = 0; b < BANDS; b++) {
put(texel + b, listCursor, bands[b].length, 0, 0);
const l = bands[b];
for (let j = 0; j < l.length; j += 4) {
put(listCursor++,
curveBase + l[j] * 2,
j + 1 < l.length ? curveBase + l[j + 1] * 2 : 0,
j + 2 < l.length ? curveBase + l[j + 2] * 2 : 0,
j + 3 < l.length ? curveBase + l[j + 3] * 2 : 0);
}
}
};
writeBands(hBands, base + 2);
writeBands(vBands, base + 2 + BANDS);
curves.forEach((q, i) => {
put(curveBase + i * 2, q.x0, q.y0, q.cx, q.cy);
put(curveBase + i * 2 + 1, q.x1, q.y1, 0, 0);
});
rec = { base, advance };
this.glyphs.set(cp, rec);
return rec;
}
bboxOf(rec: GlyphRec): [number, number, number, number] {
const t = rec.base * 4;
return [this.data[t], this.data[t + 1], this.data[t + 2], this.data[t + 3]];
}
private alloc(texels: number): number {
const need = (this.used + texels) * 4;
if (need > this.data.length) {
let cap = this.data.length;
while (cap < need) cap *= 2;
const next = new Float32Array(cap);
next.set(this.data);
this.data = next;
}
const at = this.used;
this.used += texels;
return at;
}
/** Data texture with all glyph data uploaded (recreated when it grew). */
texture(): THREE.DataTexture {
const rows = Math.max(1, Math.ceil(this.used / TEX_W));
if (!this._texture || this._texelsUploaded < this.used) {
this._texture?.dispose();
const buf = new Float32Array(TEX_W * rows * 4);
buf.set(this.data.subarray(0, Math.min(this.data.length, TEX_W * rows * 4)));
const tex = new THREE.DataTexture(buf, TEX_W, rows, THREE.RGBAFormat, THREE.FloatType);
tex.minFilter = THREE.NearestFilter;
tex.magFilter = THREE.NearestFilter;
tex.generateMipmaps = false;
tex.needsUpdate = true;
this._texture = tex;
this._texelsUploaded = this.used;
}
return this._texture;
}
}
function lineCurve(x0: number, y0: number, x1: number, y1: number): Curve {
return { x0, y0, cx: (x0 + x1) / 2, cy: (y0 + y1) / 2, x1, y1 };
}
function clampBand(f: number): number {
return Math.min(BANDS - 1, Math.max(0, Math.floor(f * BANDS)));
}
// ── Shader (classic syntax; three's GLSL3 prefix maps attribute/varying/gl_FragColor) ──
const VERT = /* glsl */`
attribute vec2 emuv;
attribute float gbase;
attribute vec3 tcolor;
varying vec2 vEm;
flat varying int vBase;
varying vec3 vColor;
void main() {
vEm = emuv;
vBase = int(gbase);
vColor = tcolor;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const FRAG = /* glsl */`
precision highp float;
uniform sampler2D dataTex;
varying vec2 vEm;
flat varying int vBase;
varying vec3 vColor;
layout(location = 0) out vec4 fragOut;
vec4 T(int i) { return texelFetch(dataTex, ivec2(i % ${TEX_W}, i / ${TEX_W}), 0); }
// Lengyel's banded winding-number coverage for one ray along +x from p.
// Curves are fetched via the band's index list; 'swap' mirrors x/y for the
// vertical (+y) ray, whose winding sign flips (handedness reversal).
float rayCoverage(vec2 p, float ppem, int bandTexel, bool swap) {
vec4 band = T(bandTexel);
int count = int(band.y);
float cov = 0.0;
for (int j = 0; j < 256; j++) {
if (j >= count) break;
vec4 idx4 = T(int(band.x) + (j >> 2));
int cb = int(j % 4 == 0 ? idx4.x : j % 4 == 1 ? idx4.y : j % 4 == 2 ? idx4.z : idx4.w);
vec4 A = T(cb);
vec2 e = T(cb + 1).xy;
// Swap the CURVE points before subtracting: p is already in swapped
// coordinates for the vertical ray ((A.xy - p).yx would be wrong).
vec2 p1 = (swap ? A.yx : A.xy) - p;
vec2 p2 = (swap ? A.wz : A.zw) - p;
vec2 p3 = (swap ? e.yx : e.xy) - p;
uint code = (0x2E74u >> ((p1.y > 0.0 ? 2u : 0u) + (p2.y > 0.0 ? 4u : 0u) + (p3.y > 0.0 ? 8u : 0u))) & 3u;
if (code != 0u) {
vec2 a = p1 - p2 * 2.0 + p3;
vec2 b = p1 - p2;
float t1, t2;
if (abs(a.y) < 1e-6) {
float t = p1.y / (2.0 * b.y);
t1 = t; t2 = t;
} else {
float d = sqrt(max(b.y * b.y - a.y * p1.y, 0.0));
t1 = (b.y - d) / a.y;
t2 = (b.y + d) / a.y;
}
if ((code & 1u) != 0u) {
float x = (a.x * t1 - b.x * 2.0) * t1 + p1.x;
cov += clamp(x * ppem + 0.5, 0.0, 1.0);
}
if (code > 1u) {
float x = (a.x * t2 - b.x * 2.0) * t2 + p1.x;
cov -= clamp(x * ppem + 0.5, 0.0, 1.0);
}
}
}
return cov;
}
void main() {
vec4 bbox = T(vBase);
vec4 bases = T(vBase + 1);
vec2 fw = fwidth(vEm);
vec2 span = max(bbox.zw - bbox.xy, vec2(1e-6));
int hb = clamp(int((vEm.y - bbox.y) / span.y * float(${BANDS})), 0, ${BANDS - 1});
int vb = clamp(int((vEm.x - bbox.x) / span.x * float(${BANDS})), 0, ${BANDS - 1});
float covH = rayCoverage(vEm, 1.0 / max(fw.x, 1e-9), int(bases.x) + hb, false);
float covV = -rayCoverage(vEm.yx, 1.0 / max(fw.y, 1e-9), int(bases.y) + vb, true);
float alpha = clamp((covH + covV) * 0.5, 0.0, 1.0);
if (alpha < 0.004) discard;
fragOut = vec4(vColor, alpha);
}
`;
/** Accumulates positioned strings and builds one merged mesh (single draw call). */
export class SlugTextBatch {
private pos: number[] = [];
private emuv: number[] = [];
private gbase: number[] = [];
private color: number[] = [];
private index: number[] = [];
constructor(private engine: SlugTextEngine) {}
/**
* alignH: 0/3/5=left datum, 1/4=center, 2=right.
* alignV: 0=baseline, 1=bottom, 2=middle, 3=top (cap-height datum, matching
* the previous canvas-sprite behaviour). Multi-line via '\n'.
*/
add(text: string, pos: { x: number; y: number }, height: number, rotation: number,
colorInt: number, alignH: number, alignV: number): void {
const eng = this.engine;
const lines = String(text).split('\n');
// Glyph outlines are em-scaled (getPath(0,0,1)), so raw cap ink height is
// capHeightEm em, not 1em. Divide by it so the rendered cap height equals
// the DWG `height` field, matching the canvas-sprite fallback's ascent fit.
const scale = height / eng.capHeightEm;
const lineStep = height * 5 / 3; // CAD default MTEXT line spacing
const blockH = (lines.length - 1) * lineStep + height;
// DXF group 72 = 4 ("Middle") centers both axes on the alignment point;
// group 73 (vertical) is ignored. alignH already centers horizontally (below);
// force vertical to middle here too, else vA=0 text (block-internal plain
// TEXT, e.g. titleblock labels) renders half a line too high.
if (alignH === 4) alignV = 2;
let baseline0: number;
if (alignV === 3) baseline0 = -height; // top datum
else if (alignV === 2) baseline0 = blockH / 2 - height; // middle
else baseline0 = (lines.length - 1) * lineStep; // baseline/bottom
const cos = Math.cos(rotation), sin = Math.sin(rotation);
const r = ((colorInt >> 16) & 0xFF) / 255, g = ((colorInt >> 8) & 0xFF) / 255, b = (colorInt & 0xFF) / 255;
for (let li = 0; li < lines.length; li++) {
const line = lines[li];
if (!line) continue;
const wEm = eng.measureEm(line);
const ox = (alignH === 1 || alignH === 4) ? -wEm * scale / 2 : alignH === 2 ? -wEm * scale : 0;
const oy = baseline0 - li * lineStep;
let pen = 0;
for (const ch of line) {
const rec = eng.ensureGlyph(ch.codePointAt(0)!);
if (rec.base >= 0) {
const [x0, y0, x1, y1] = this.engine.bboxOf(rec);
const qx0 = x0 - PAD, qy0 = y0 - PAD, qx1 = x1 + PAD, qy1 = y1 + PAD;
const v = this.pos.length / 3;
for (const [ex, ey] of [[qx0, qy0], [qx1, qy0], [qx1, qy1], [qx0, qy1]] as const) {
const lx = ox + (pen + ex) * scale;
const ly = oy + ey * scale;
this.pos.push(pos.x + lx * cos - ly * sin, pos.y + lx * sin + ly * cos, 1);
this.emuv.push(ex, ey);
this.gbase.push(rec.base);
this.color.push(r, g, b);
}
this.index.push(v, v + 1, v + 2, v, v + 2, v + 3);
}
pen += rec.advance;
}
}
}
/** Build the merged mesh; null when nothing was added. */
build(): THREE.Mesh | null {
if (!this.index.length) return null;
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(this.pos, 3));
geo.setAttribute('emuv', new THREE.Float32BufferAttribute(this.emuv, 2));
geo.setAttribute('gbase', new THREE.Float32BufferAttribute(this.gbase, 1));
geo.setAttribute('tcolor', new THREE.Float32BufferAttribute(this.color, 3));
geo.setIndex(this.index);
const mat = new THREE.ShaderMaterial({
glslVersion: THREE.GLSL3,
vertexShader: VERT,
fragmentShader: FRAG,
uniforms: { dataTex: { value: this.engine.texture() } },
transparent: true,
depthTest: false,
depthWrite: false,
side: THREE.DoubleSide,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.frustumCulled = true;
return mesh;
}
}