feat: Entity 선택기능, Property Inspector 패널, Layer 관리 패널, 드래그/접기/크기조절 UI 구현
This commit is contained in:
@@ -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>`;
|
||||
}
|
||||
Reference in New Issue
Block a user