feat: Entity 선택기능, Property Inspector 패널, Layer 관리 패널, 드래그/접기/크기조절 UI 구현
This commit is contained in:
+99
-1
@@ -96,6 +96,68 @@
|
||||
}
|
||||
#coords .lbl { color: var(--accent); margin-right: 6px; font-weight: 600; }
|
||||
#coords .sep { color: var(--line); margin: 0 12px; }
|
||||
/* 공통 패널 윈도우 스타일 (Properties & Layers - Draggable & Resizable) */
|
||||
.cad-panel {
|
||||
position: fixed; top: 56px; width: 320px; height: 480px;
|
||||
min-width: 250px; min-height: 140px; max-width: 90vw; max-height: 90vh;
|
||||
z-index: 15; background: rgba(22,27,34,.96); backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 10px 30px rgba(0,0,0,.5);
|
||||
display: flex; flex-direction: column; overflow: hidden; resize: both;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.cad-panel.collapsed {
|
||||
height: 42px !important;
|
||||
min-height: 42px !important;
|
||||
max-height: 42px !important;
|
||||
overflow: hidden !important;
|
||||
resize: none !important;
|
||||
}
|
||||
.cad-panel .hdr {
|
||||
padding: 10px 14px; border-bottom: 1px solid var(--line);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
background: rgba(13,17,23,.7); position: sticky; top: 0; z-index: 5;
|
||||
cursor: grab; user-select: none; flex-shrink: 0;
|
||||
}
|
||||
.cad-panel .hdr:active { cursor: grabbing; }
|
||||
.cad-panel .hdr h3 { margin: 0; font-size: 13px; font-weight: 700; color: var(--ink); display: flex; align-items: center; gap: 6px; }
|
||||
.cad-panel .hdr-actions { display: flex; gap: 4px; align-items: center; }
|
||||
.cad-panel .hdr-btn {
|
||||
background: none; border: none; color: var(--muted); font-size: 13px;
|
||||
cursor: pointer; padding: 2px 6px; border-radius: 4px; display: inline-flex;
|
||||
align-items: center; justify-content: center; transition: all .15s;
|
||||
}
|
||||
.cad-panel .hdr-btn:hover { color: var(--ink); background: rgba(255,255,255,.1); }
|
||||
|
||||
#properties { right: 12px; }
|
||||
#layers { right: 344px; display: none; }
|
||||
|
||||
#propBody { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
|
||||
#layerBody { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
|
||||
#layerList { flex: 1; overflow-y: auto; }
|
||||
|
||||
#zoomSelBtn {
|
||||
font: 11px var(--sans); padding: 3px 8px; border-radius: 6px;
|
||||
border: 1px solid var(--accent); background: rgba(63,185,80,.15); color: var(--accent);
|
||||
cursor: pointer; display: none; align-items: center; gap: 4px; transition: all .15s;
|
||||
}
|
||||
#zoomSelBtn:hover { background: var(--accent); color: #fff; }
|
||||
|
||||
.layer-search-wrap { padding: 8px 10px; border-bottom: 1px solid var(--line); background: rgba(13,17,23,.3); }
|
||||
.layer-search-input {
|
||||
width: 100%; background: var(--bg); border: 1px solid var(--line);
|
||||
border-radius: 6px; padding: 6px 10px; color: var(--ink); font-size: 12px;
|
||||
outline: none; transition: border-color .15s;
|
||||
}
|
||||
.layer-search-input:focus { border-color: var(--accent); }
|
||||
.layer-sub-actions {
|
||||
display: flex; gap: 6px; padding: 6px 10px; border-bottom: 1px solid var(--line);
|
||||
background: rgba(22,27,34,.5);
|
||||
}
|
||||
.layer-sub-btn {
|
||||
font-size: 11px; padding: 2px 8px; border-radius: 4px; border: 1px solid var(--line);
|
||||
background: transparent; color: var(--muted); cursor: pointer; transition: all .15s;
|
||||
}
|
||||
.layer-sub-btn:hover { color: var(--ink); border-color: var(--accent); background: rgba(63,185,80,.1); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -113,6 +175,7 @@
|
||||
<button id="fit" type="button">Fit (F)</button>
|
||||
<button id="theme" type="button">Theme</button>
|
||||
<button id="layersBtn" type="button">Layers</button>
|
||||
<button id="propsBtn" type="button">Props (속성)</button>
|
||||
<div id="status">ready</div>
|
||||
</div>
|
||||
|
||||
@@ -122,7 +185,42 @@
|
||||
<span class="lbl">Y</span><span id="coordY">—</span>
|
||||
</div>
|
||||
|
||||
<div id="layers"><h3>Layers</h3><div id="layerList"></div></div>
|
||||
<!-- 레이어 (Layers) 관리 패널 -->
|
||||
<div id="layers" class="cad-panel">
|
||||
<div class="hdr">
|
||||
<h3>
|
||||
<span>🎨</span> 레이어 (Layers)
|
||||
<span id="layerCount" style="font-family:var(--mono);font-size:11px;color:var(--muted);background:rgba(255,255,255,.08);padding:1px 6px;border-radius:4px;font-weight:400"></span>
|
||||
</h3>
|
||||
<div class="hdr-actions">
|
||||
<button id="collapseLayersBtn" class="hdr-btn collapse-btn" type="button" title="접기">▾</button>
|
||||
<button id="closeLayersBtn" class="hdr-btn" type="button" title="닫기">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="layerBody">
|
||||
<div class="layer-search-wrap">
|
||||
<input id="layerSearch" class="layer-search-input" type="text" placeholder="🔍 레이어 검색..." />
|
||||
</div>
|
||||
<div class="layer-sub-actions">
|
||||
<button id="layerShowAll" class="layer-sub-btn" type="button">전체 켜기</button>
|
||||
<button id="layerHideAll" class="layer-sub-btn" type="button">전체 끄기</button>
|
||||
</div>
|
||||
<div id="layerList"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 속성 (Properties) 패널 -->
|
||||
<div id="properties" class="cad-panel">
|
||||
<div class="hdr">
|
||||
<h3><span>📋</span> 속성 (Properties)</h3>
|
||||
<div class="hdr-actions">
|
||||
<button id="zoomSelBtn" type="button" title="선택 요소 위치로 확대">🔍 선택 확대</button>
|
||||
<button id="collapsePropsBtn" class="hdr-btn collapse-btn" type="button" title="접기">▾</button>
|
||||
<button id="closePropsBtn" class="hdr-btn" type="button" title="닫기">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="propBody"></div>
|
||||
</div>
|
||||
|
||||
<aside id="recipe">
|
||||
<h3>Module recipe</h3>
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* 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());
|
||||
}
|
||||
}
|
||||
|
||||
/** 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.render();
|
||||
}
|
||||
|
||||
public hideAllLayers(): void {
|
||||
const layers = this.viewer.getLayerInfo() || [];
|
||||
for (const l of layers) {
|
||||
this.hiddenLayers.add(l.name);
|
||||
}
|
||||
this.viewer.setHiddenLayers(this.hiddenLayers);
|
||||
this.render();
|
||||
}
|
||||
|
||||
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.render();
|
||||
}
|
||||
|
||||
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('');
|
||||
|
||||
// Bind eye button click events
|
||||
this.listEl.querySelectorAll('.eye-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const layerName = (btn as HTMLElement).dataset.toggle;
|
||||
if (layerName) {
|
||||
this.toggleLayer(layerName);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Also toggle when clicking row
|
||||
this.listEl.querySelectorAll('.layer-row').forEach((row) => {
|
||||
row.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('.eye-btn')) return;
|
||||
const layerName = (row as HTMLElement).dataset.name;
|
||||
if (layerName) {
|
||||
this.toggleLayer(layerName);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
+60
-26
@@ -26,6 +26,9 @@ import { parseDxfBuffer } from './viewer2d/parseDxf';
|
||||
import { is2D, isDxf, isDwg } from './viewer2d/ext2d';
|
||||
import { listCadSpaces, pickDefaultSpace } from './viewer2d/cadSpaces';
|
||||
import { setDefaultFontUrl } from './viewer2d/slugText';
|
||||
import { renderPropertyInspector } from './propertyInspector';
|
||||
import { LayerPanelManager } from './layerPanel';
|
||||
import { makePanelDraggableAndCollapsible } from './panelUtils';
|
||||
|
||||
// parseDxfBuffer 내부:
|
||||
// dxf-parser → HatchHandler → dxfAdapter
|
||||
@@ -38,21 +41,58 @@ setDefaultFontUrl('/fonts/NanumGothic-Regular.ttf');
|
||||
// ── ③ Viewer2D 마운트 ───────────────────────────────────────────
|
||||
const app = document.getElementById('app')!;
|
||||
const statusEl = document.getElementById('status')!;
|
||||
const layerList = document.getElementById('layerList')!;
|
||||
const layersPanel = document.getElementById('layers')!;
|
||||
const spacesBar = document.getElementById('spaces')!;
|
||||
|
||||
// Layer panel elements
|
||||
const layersPanel = document.getElementById('layers')!;
|
||||
const layerList = document.getElementById('layerList')!;
|
||||
const layerCount = document.getElementById('layerCount')!;
|
||||
const layerSearch = document.getElementById('layerSearch') as HTMLInputElement;
|
||||
const layerShowAll = document.getElementById('layerShowAll')!;
|
||||
const layerHideAll = document.getElementById('layerHideAll')!;
|
||||
const closeLayersBtn = document.getElementById('closeLayersBtn')!;
|
||||
const collapseLayersBtn = document.getElementById('collapseLayersBtn')!;
|
||||
|
||||
// Properties panel elements
|
||||
const propsPanel = document.getElementById('properties')!;
|
||||
const propBody = document.getElementById('propBody')!;
|
||||
const propsBtn = document.getElementById('propsBtn')!;
|
||||
const zoomSelBtn = document.getElementById('zoomSelBtn')!;
|
||||
const closePropsBtn = document.getElementById('closePropsBtn')!;
|
||||
const collapsePropsBtn = document.getElementById('collapsePropsBtn')!;
|
||||
|
||||
function setStatus(msg: string, err = false) {
|
||||
statusEl.textContent = msg;
|
||||
statusEl.classList.toggle('err', err);
|
||||
}
|
||||
|
||||
// Make floating UI panels draggable & collapsible
|
||||
makePanelDraggableAndCollapsible(propsPanel, { collapseBtnEl: collapsePropsBtn });
|
||||
makePanelDraggableAndCollapsible(layersPanel, { collapseBtnEl: collapseLayersBtn });
|
||||
|
||||
// Initial Property Inspector rendering (empty state)
|
||||
renderPropertyInspector(propBody, null, { zoomBtnEl: zoomSelBtn });
|
||||
|
||||
const viewer = new Viewer2D(app);
|
||||
|
||||
// Layer Panel Manager
|
||||
const layerManager = new LayerPanelManager(viewer, layerList, {
|
||||
countEl: layerCount,
|
||||
searchInputEl: layerSearch,
|
||||
showAllBtnEl: layerShowAll,
|
||||
hideAllBtnEl: layerHideAll,
|
||||
});
|
||||
|
||||
viewer.onSelect((entity: unknown) => {
|
||||
if (!entity) {
|
||||
setStatus('deselected');
|
||||
renderPropertyInspector(propBody, null, { zoomBtnEl: zoomSelBtn });
|
||||
return;
|
||||
}
|
||||
// Show properties panel automatically on selection
|
||||
propsPanel.style.display = 'flex';
|
||||
renderPropertyInspector(propBody, entity, { zoomBtnEl: zoomSelBtn });
|
||||
|
||||
const e = entity as { type?: string; layer?: string; handle?: { value?: string } };
|
||||
setStatus(`select ${e.type ?? '?'} · layer=${e.layer ?? '-'} · h=${e.handle?.value ?? '-'}`);
|
||||
});
|
||||
@@ -139,29 +179,7 @@ async function loadFile(file: File) {
|
||||
|
||||
// ── ⑤ 호스트 UI 글루 ────────────────────────────────────────────
|
||||
function fillLayers() {
|
||||
layerList.innerHTML = '';
|
||||
for (const L of viewer.getLayerInfo()) {
|
||||
const label = document.createElement('label');
|
||||
label.dataset.name = L.name;
|
||||
const sw = document.createElement('span');
|
||||
sw.className = 'swatch';
|
||||
sw.style.background = L.colorHex || '#888';
|
||||
const cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.checked = L.visible;
|
||||
cb.addEventListener('change', () => {
|
||||
const next = new Set<string>();
|
||||
layerList.querySelectorAll('label').forEach((row) => {
|
||||
const input = row.querySelector('input') as HTMLInputElement;
|
||||
if (!input.checked) next.add(row.dataset.name!);
|
||||
});
|
||||
viewer.setHiddenLayers(next);
|
||||
});
|
||||
const text = document.createElement('span');
|
||||
text.textContent = `${L.name} (${L.count})`;
|
||||
label.append(sw, cb, text);
|
||||
layerList.append(label);
|
||||
}
|
||||
layerManager.update();
|
||||
}
|
||||
|
||||
async function afterLoad(name: string, result: CadParseResult) {
|
||||
@@ -212,7 +230,22 @@ document.getElementById('theme')!.addEventListener('click', () => {
|
||||
fillLayers();
|
||||
});
|
||||
document.getElementById('layersBtn')!.addEventListener('click', () => {
|
||||
layersPanel.style.display = layersPanel.style.display === 'block' ? 'none' : 'block';
|
||||
layersPanel.style.display = layersPanel.style.display === 'flex' ? 'none' : 'flex';
|
||||
});
|
||||
closeLayersBtn.addEventListener('click', () => {
|
||||
layersPanel.style.display = 'none';
|
||||
});
|
||||
|
||||
propsBtn.addEventListener('click', () => {
|
||||
propsPanel.style.display = propsPanel.style.display === 'none' ? 'flex' : 'none';
|
||||
});
|
||||
|
||||
zoomSelBtn.addEventListener('click', () => {
|
||||
viewer.zoomToSelection();
|
||||
});
|
||||
|
||||
closePropsBtn.addEventListener('click', () => {
|
||||
propsPanel.style.display = 'none';
|
||||
});
|
||||
|
||||
window.addEventListener('keydown', (e) => {
|
||||
@@ -237,3 +270,4 @@ if (model) {
|
||||
|
||||
window.addEventListener('resize', () => viewer.resize());
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
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 general: [string, string][] = [
|
||||
['유형 (Type)', type],
|
||||
['핸들 (Handle)', String(handle)],
|
||||
['레이어 (Layer)', String(layer)],
|
||||
['색상 (Color)', color],
|
||||
['선종류 (Linetype)', String(linetype)],
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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>`;
|
||||
}
|
||||
Vendored
+3
@@ -11,6 +11,9 @@ export class Viewer2D {
|
||||
/** 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;
|
||||
getLayerInfo(): { name: string; colorHex: string; count: number; visible: boolean }[];
|
||||
|
||||
@@ -3535,6 +3535,52 @@ export class Viewer2D {
|
||||
if (this._selMeta) { const m = this._selMeta; this._selMeta = null; this._highlight(m); }
|
||||
}
|
||||
|
||||
/** Get currently selected CAD entity (or null). */
|
||||
getSelectedEntity() {
|
||||
return this._selMeta ? this._selMeta.entity : null;
|
||||
}
|
||||
|
||||
/** Zoom extents to the currently selected CAD entity. */
|
||||
zoomToSelection() {
|
||||
if (!this._selMeta) return;
|
||||
const idx = this._entityMeta.indexOf(this._selMeta);
|
||||
if (idx < 0) return;
|
||||
const box = new THREE.Box3();
|
||||
const S = this._pickSegs, SM = this._pickSegMeta;
|
||||
if (S && SM) {
|
||||
for (let i = 0, j = 0; i < S.length; i += 4, j++) {
|
||||
if (SM[j] === idx) {
|
||||
box.expandByPoint(new THREE.Vector3(S[i], S[i + 1], 0));
|
||||
box.expandByPoint(new THREE.Vector3(S[i + 2], S[i + 3], 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this._pickFills) {
|
||||
for (const f of this._pickFills) {
|
||||
if (f.metaIdx === idx) {
|
||||
for (const loop of f.loops || []) {
|
||||
for (const p of loop) {
|
||||
box.expandByPoint(new THREE.Vector3(p.x, p.y, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (box.isEmpty() && this._selMeta.entity) {
|
||||
const e = this._selMeta.entity;
|
||||
const d = e.data || e;
|
||||
const pt = d.start || d.center || d.pos || d.insertionPoint || d.insertionPt;
|
||||
if (pt) {
|
||||
const pad = 10;
|
||||
box.expandByPoint(new THREE.Vector3(pt.x - pad, pt.y - pad, 0));
|
||||
box.expandByPoint(new THREE.Vector3(pt.x + pad, pt.y + pad, 0));
|
||||
}
|
||||
}
|
||||
if (!box.isEmpty()) {
|
||||
this._fit(box);
|
||||
}
|
||||
}
|
||||
|
||||
_distToBounds(wx, wy, b) {
|
||||
if (b.type === 'point') return Math.hypot(wx - b.cx, wy - b.cy);
|
||||
if (b.type === 'circle') return Math.abs(Math.hypot(wx - b.cx, wy - b.cy) - b.r);
|
||||
|
||||
Reference in New Issue
Block a user