Files
dwg-dxf-viewer-sample/src/propertyInspector.ts
T

314 lines
11 KiB
TypeScript

/**
* 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, '&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>`;
}