Files
dwg-dxf-viewer-sample/src/viewer2d/Viewer2D.js
T
minsung 95fa6a452b Initial public release: DWG/DXF 2D viewer sample
Standalone Vite sample composing acadrust-dwg WASM, dxf-parser, and Viewer2D.
2026-07-15 10:58:04 +09:00

2384 lines
109 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Viewer2D — three.js renderer for DWG/DXF parseResult (orthographic 2D drawing).
* Ported from webviewer/src/viewer/Viewer3D.js (class renamed 3D→2D) for the
* 2D+3D merge. Renders CAD entities as LineSegments / Mesh / CanvasTexture sprites.
* Supported: LINE, CIRCLE, ARC, LWPOLYLINE, POLYLINE, POINT, ELLIPSE, SOLID,
* TEXT, MTEXT, INSERT (ownerHandle block expansion), HATCH (solid+outline+bulge),
* DIMENSION_LINEAR/ALIGNED/RADIUS/DIAMETER/ANG_3PT/ANG_2LN/ORDINATE
*/
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { aciToHex } from './aciColors.js';
import { SlugTextEngine, SlugTextBatch } from './slugText';
const DEFAULT_COLOR = 0xc9d1d9;
const ARC_SEGS = 64;
const ELLIPSE_SEGS = 72;
const CLICK_THRESHOLD_PX = 14;
function stripMText(s) {
if (!s) return '';
return s
.replace(/\\A\d+;/g, '') // \A1; vertical alignment
.replace(/\\p[^;]*;/g, '') // \p...; paragraph properties (lowercase, has ;)
.replace(/\\P/g, '\n') // \P paragraph break → newline (capital, no ;)
.replace(/\\[a-zA-Z][^;]*;/g, '') // remaining inline codes \f \H \C \W \Q \T...
.replace(/\{[^{}]*\}/g, m => stripMText(m.slice(1, -1)))
.replace(/%%d/gi, '°').replace(/%%p/gi, '±').replace(/%%c/gi, 'Ø')
.replace(/[{}]/g, '')
.replace(/[ \t]{2,}/g, ' ') // collapse runs of spaces
.replace(/^[ \t]+|[ \t]+$/gm, '') // trim each line's edges (keeps interior \n)
.replace(/\n{3,}/g, '\n\n')
.replace(/^\n+|\n+$/g, ''); // drop leading/trailing blank lines
}
export class Viewer2D {
constructor(container) {
this._container = container;
this._entityMeta = [];
this._layerByHandle = new Map();
this._layerByName = new Map();
this._onSelectCb = null;
this._measureActive = false;
this._measureCb = null;
this._measurePtA = null;
this._measureMarkers = [];
this._textGen = 0; // invalidation token for the async Slug text flush
SlugTextEngine.shared().catch(() => {}); // warm the font load; sprite fallback covers failure
this._scene = new THREE.Scene();
this._scene.background = new THREE.Color(0x0d1117);
const w = container.clientWidth || 800;
const h = container.clientHeight || 600;
this._camera = new THREE.OrthographicCamera(-w/2, w/2, h/2, -h/2, -10000, 10000);
this._camera.position.set(0, 0, 100);
this._renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
this._renderer.setPixelRatio(window.devicePixelRatio);
this._renderer.setSize(w, h);
container.appendChild(this._renderer.domElement);
this._controls = new OrbitControls(this._camera, this._renderer.domElement);
this._controls.enableRotate = false;
this._controls.screenSpacePanning = true;
this._controls.zoomToCursor = true;
this._controls.mouseButtons = { LEFT: THREE.MOUSE.PAN, MIDDLE: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.PAN };
this._controls.touches = { ONE: THREE.TOUCH.PAN, TWO: THREE.TOUCH.DOLLY_PAN };
const savedSpeed = localStorage.getItem('hmw:zoom-speed');
if (savedSpeed) {
const speed = parseFloat(savedSpeed);
if (!isNaN(speed)) {
this._controls.zoomSpeed = speed;
}
}
window.addEventListener('keydown', (e) => { if (e.key === 'f' || e.key === 'F') this.fit(); });
this._group = new THREE.Group();
this._scene.add(this._group);
window.addEventListener('resize', () => this._onResize());
// Prevent browser auto-scroll popup when middle-button is pressed on the WebGL canvas
this._renderer.domElement.addEventListener('pointerdown', (e) => {
if (e.button === 1) e.preventDefault();
});
this._renderer.domElement.addEventListener('mousedown', (e) => {
this._downXY = [e.clientX, e.clientY];
if (e.button === 1) e.preventDefault();
});
this._renderer.domElement.addEventListener('click', (e) => this._onClick(e));
this._selColor = { r: 0xd8 / 255, g: 0x3a / 255, b: 0x2f / 255 };
this._gridVisible = false;
this._gridMesh = null;
this._controls.addEventListener('change', () => { if (this._gridVisible) this._rebuildGrid(); });
this._animate();
}
onSelect(cb) { this._onSelectCb = cb; }
resize() { this._onResize(); }
setZoomSpeed(speed) { if (this._controls) this._controls.zoomSpeed = speed; }
getZoomSpeed() { return this._controls ? this._controls.zoomSpeed : 1.0; }
fit() {
const box = new THREE.Box3();
this._group.traverse(obj => { if (obj.geometry) box.expandByObject(obj); });
if (!box.isEmpty()) this._fit(box);
}
load(result, opts = {}) {
this._lastResult = result;
this._selMeta = null;
this._clear();
this._entityMeta = [];
this._buildLayerMap(result?.tables?.layers);
this._buildLinetypeMap(result);
// Sheet orientation: the active model-space viewport's VIEWTWIST rotates the
// view so a drawing stored tilted in WCS (rotated survey sheets — header UCS
// stays identity) displays with its title border upright. Applied as a
// camera-up rotation in _fit (geometry untouched → picking stays exact).
this._viewTwist = this._readViewTwist(result);
// Geometric pick index — the ACTUAL rendered geometry, not a bounds proxy.
// _pickSegs: flat [ax,ay,bx,by,…] of every line segment (lines, arcs, circles,
// polylines, block wires, dimension leaders/arrows) with a parallel owner-meta
// index. _pickFills: filled regions (hatch loops, SOLID quads, text quads)
// tested by even-odd point-in-polygon. _onClick raycasts these directly.
this._pickSegs = [];
this._pickSegMeta = [];
this._pickFills = [];
this._pickCurIdx = -1;
const entities = result?.entities || [];
const lineVerts = [];
const lineColors = [];
// Dashed-linetype segments go into separate buckets keyed by dash|gap size;
// each becomes its own LineDashedMaterial LineSegments at assembly. Solid
// (Continuous / ByLayer→Continuous) segments stay in lineVerts/lineColors.
const dashBuckets = new Map();
let curDash = null; // {key,dash,gap} for the entity currently being emitted
const box = new THREE.Box3();
const _tmp = new THREE.Vector3();
const pendingTexts = [];
// Build set of user block definition handles.
// Block-def entities have ownerHandle = their block's handle. They're rendered
// via INSERT (_insertEntities) and must be SKIPPED in the main loop to avoid
// expanding the bounding box with block-local coordinates (near 0,0), which
// would make the real drawing appear as a tiny speck on zoom-extents.
const blockDefHandles = new Set();
// Also track model/paper space handles to verify correctness
const spaceHandles = new Set();
// Block header handle (hex) → basePoint, for nested INSERT transform composition.
const blockBaseByHex = new Map();
const blockNameByHex = new Map();
for (const b of (result?.tables?.blocks ?? [])) {
const name = (b.name ?? '').toLowerCase().replace(/\*/g, '').trim();
const h = typeof b.handle === 'object' ? b.handle?.value?.toString(16) : b.handle?.toString(16);
if (!h) continue;
if (b.basePoint) blockBaseByHex.set(h, b.basePoint);
blockNameByHex.set(h, (b.name ?? '').toLowerCase());
// System space blocks: any of these names → entity owner, render directly
if (name === 'model_space' || name === 'paper_space' ||
name === 'ms' || name === 'ps' ||
name.startsWith('model') || name.startsWith('paper') ||
name === '') {
spaceHandles.add(h);
} else {
blockDefHandles.add(h);
}
}
// Safety: if spaceHandles is empty we couldn't identify model space →
// disable the filter entirely to avoid hiding everything.
const useBlockFilter = spaceHandles.size > 0;
this._blockCount = blockDefHandles.size;
// Pre-group all entities by ownerHandle hex string for O(1) lookup.
// Without this, every INSERT/DIMENSION does entities.filter() = O(n) per call = O(n²) total.
const entsByOwner = new Map();
for (const ent of entities) {
const key = ent.ownerHandle?.value?.toString(16);
if (key != null) {
if (!entsByOwner.has(key)) entsByOwner.set(key, []);
entsByOwner.get(key).push(ent);
}
}
const expand = (x, y, z = 0) => box.expandByPoint(_tmp.set(x, y, z));
const pushSeg = (ax, ay, bx, by, z, color) => {
const r = ((color >> 16) & 0xFF) / 255;
const g = ((color >> 8) & 0xFF) / 255;
const b = (color & 0xFF) / 255;
if (curDash) {
let bk = dashBuckets.get(curDash.key);
if (!bk) { bk = { dash: curDash.dash, gap: curDash.gap, verts: [], colors: [] }; dashBuckets.set(curDash.key, bk); }
bk.verts.push(ax, ay, z, bx, by, z);
bk.colors.push(r, g, b, r, g, b);
} else {
lineVerts.push(ax, ay, z, bx, by, z);
lineColors.push(r, g, b, r, g, b);
}
if (this._pickCurIdx >= 0) { this._pickSegs.push(ax, ay, bx, by); this._pickSegMeta.push(this._pickCurIdx); }
expand(ax, ay, z); expand(bx, by, z);
};
for (const e of entities) {
// Skip block-definition entities — they live at block-local coords and are
// rendered at correct world positions via INSERT / _insertEntities.
// Only apply filter when we've successfully identified model/paper space handles.
const ownerHex = e.ownerHandle?.value?.toString(16);
if (useBlockFilter && ownerHex && blockDefHandles.has(ownerHex)) continue;
if (this._hiddenLayers?.size) {
const lh = e.layerHandle?.value ?? e.layerHandle;
const lname = (lh != null && this._layerNameByHandle.get(String(lh))) ?? e.layer ?? e.layerName;
if (lname && this._hiddenLayers.has(lname)) continue;
}
const d = e.data || e;
const type = (e.type || e.typeName || '').toUpperCase();
const color = this._entityColor(e);
curDash = this._resolveDash(e);
const meta = { entity: e, type, bounds: null, colStart: lineColors.length };
this._entityMeta.push(meta);
this._pickCurIdx = this._entityMeta.length - 1; // owner for pick geometry emitted below
try {
switch (type) {
// ── Basic geometry ────────────────────────────────────────────────
case 'LINE':
if (d.start && d.end) {
pushSeg(d.start.x, d.start.y, d.end.x, d.end.y, d.start.z || 0, color);
meta.bounds = { type:'line', x1:d.start.x, y1:d.start.y, x2:d.end.x, y2:d.end.y };
}
break;
case 'CIRCLE':
if (d.center && d.radius != null) {
this._arcSegs(d.center, d.radius, 0, Math.PI*2, d.center.z||0, color, pushSeg);
meta.bounds = { type:'circle', cx:d.center.x, cy:d.center.y, r:d.radius };
}
break;
case 'ARC':
if (d.center && d.radius != null) {
this._arcSegs(d.center, d.radius, d.startAngle??0, d.endAngle??Math.PI*2, d.center.z||0, color, pushSeg);
meta.bounds = { type:'circle', cx:d.center.x, cy:d.center.y, r:d.radius };
}
break;
case 'LWPOLYLINE':
if (d.points?.length) {
const hasBulge = d.bulges?.some(b => Math.abs(b) >= 1e-6);
const closed = !!(d.closed || (d.flags & 1));
const nSeg = closed ? d.points.length : d.points.length - 1;
if (this._polyHasWidth(e, nSeg, 1)) {
this._widePolyMesh(d.points, d.bulges, closed, e, color, 1, expand);
} else if (hasBulge) {
this._bulgePolySegs(d.points, d.bulges, closed, color, pushSeg, expand);
} else {
this._polylineSegs(d.points, closed, d.elevation||0, color, pushSeg);
d.points.forEach(p => expand(p.x, p.y, d.elevation||0));
}
// Note: uncovered-poly fill is only applied in _insertEntities (block
// glyphs). Model-space polylines can be huge parcels — never fill those.
meta.bounds = { type:'point', cx:d.points[0].x, cy:d.points[0].y };
}
break;
case 'POLYLINE':
this._polylineSegs(d.vertices||d.points, d.closed||(d.flags&1), 0, color, pushSeg);
break;
case 'POLYLINE_2D': {
// Old-style 2D polyline: vertices are separate VERTEX_2D entities
// owned by this polyline (point/bulge at top level, not under .data).
const kids = (entsByOwner.get(e.handle?.value?.toString(16)) ?? [])
.filter(v => (v.type||'').toUpperCase().startsWith('VERTEX') && v.point);
if (kids.length >= 2) {
const pts = kids.map(v => v.point);
const bulges = kids.map(v => v.bulge || 0);
const closed = (d.flags & 1) === 1;
const elev = d.elevation || 0;
if (bulges.some(b => Math.abs(b) >= 1e-6)) {
this._bulgePolySegs(pts, bulges, closed, color, pushSeg, expand);
} else {
this._polylineSegs(pts, closed, elev, color, pushSeg);
pts.forEach(p => expand(p.x, p.y, elev));
}
meta.bounds = { type:'point', cx: pts[0].x, cy: pts[0].y };
}
break;
}
case 'POINT': {
const p = d.position || d;
if (typeof p.x === 'number') {
const s = 1;
pushSeg(p.x-s, p.y, p.x+s, p.y, p.z||0, color);
pushSeg(p.x, p.y-s, p.x, p.y+s, p.z||0, color);
meta.bounds = { type:'point', cx:p.x, cy:p.y };
}
break;
}
case 'ELLIPSE':
if (d.center) {
this._ellipseSegs(d, color, pushSeg);
meta.bounds = { type:'point', cx:d.center.x, cy:d.center.y };
}
break;
case 'SPLINE': {
const cps = d.controlPoints ?? d.fitPoints;
if (cps?.length >= 2) {
const pts = cps.length >= 3
? (() => {
const segs = cps.length * 10;
const result = [];
for (let i = 0; i <= segs; i++) {
const t = i / segs;
result.push(this._catmullRom(cps, t));
}
return result;
})()
: cps;
this._polylineSegs(pts, false, 0, color, pushSeg);
pts.forEach(p => expand(p.x, p.y, p.z || 0));
meta.bounds = { type:'point', cx:pts[0].x, cy:pts[0].y };
}
break;
}
case 'ATTRIB':
case 'ATTDEF': {
if ((d.flags ?? 0) & 1) break; // invisible attribute
const raw = e.text ?? d.text ?? d.textValue ?? d.defaultValue ?? '';
const text = stripMText(raw);
const height = e.textHeight ?? e.height ?? d.textHeight ?? d.height ?? 2.5;
const rotation = e.rotationAngle ?? d.rotationAngle ?? 0;
const alignH = e.horizAlignment ?? d.horizAlignment ?? 0;
const alignV = e.vertAlignment ?? d.vertAlignment ?? 0;
// When aligned (non-default H/V), the alignmentPt is the true anchor;
// insertionPt is the "first point" and differs for centered/right text.
const useAlignPt = alignH !== 0 || alignV !== 0;
const pos = useAlignPt
? (e.alignmentPt ?? d.alignmentPt ?? e.insertionPt ?? d.insertionPt ?? d.insertionPoint ?? { x:0, y:0 })
: (e.insertionPt ?? d.insertionPt ?? d.insertionPoint ?? { x:0, y:0 });
if (text) {
// Force vertical center for ATTRIB — attribute values (e.g. node tags) appear centered
pendingTexts.push({ text, pos, height, rotation, color, alignH, alignV: 2 });
meta.bounds = { type:'point', cx:pos.x, cy:pos.y };
this._addTextPick(text, pos, height, alignH, 2);
expand(pos.x, pos.y, 0);
}
break;
}
case 'XLINE':
case 'RAY': {
// Infinite/semi-infinite lines — render as very long line segment
const bp = d.basePoint ?? d.point ?? d.startPoint;
const dir = d.direction ?? d.unitDir ?? d.vector;
if (bp && dir) {
const len = 1e6;
const ex = bp.x + dir.x * len, ey = bp.y + dir.y * len;
if (type === 'XLINE') {
pushSeg(bp.x - dir.x * len, bp.y - dir.y * len, ex, ey, 0, color);
} else {
pushSeg(bp.x, bp.y, ex, ey, 0, color);
}
meta.bounds = { type:'point', cx:bp.x, cy:bp.y };
expand(bp.x, bp.y);
}
break;
}
case 'SOLID': {
const corners = [d.corner1||d.pt1||d.point1, d.corner2||d.pt2||d.point2,
d.corner3||d.pt3||d.point3, d.corner4||d.pt4||d.point4].filter(Boolean);
if (corners.length >= 3) {
this._solidMesh(corners, color);
meta.bounds = { type:'point', cx:corners[0].x, cy:corners[0].y };
}
break;
}
// ── Text ──────────────────────────────────────────────────────────
case 'TEXT':
case 'MTEXT': {
const raw = e.text ?? d.text ?? '';
const text = stripMText(raw);
const height = e.textHeight ?? e.height ?? d.textHeight ?? d.height ?? 2.5;
let rotation = 0, alignH = 0, alignV = 0, pos;
if (type === 'TEXT') {
rotation = e.rotationAngle ?? d.rotationAngle ?? 0;
alignH = e.horizAlignment ?? d.horizAlignment ?? 0;
alignV = e.vertAlignment ?? d.vertAlignment ?? 0;
// DXF group 72 = 4 ("Middle") centers the text both horizontally AND
// vertically on the alignment point — group 73 (vertical) is ignored.
// Without this, vA=0 (baseline) placed such text half a line too high
// (titleblock labels 노선이정/설계사/… floated up).
if (alignH === 4) alignV = 2;
// DWG: when alignment is non-default, alignmentPt is the actual anchor
// (insertionPt is the "first point" which may differ from anchor)
const useAlignPt = alignH !== 0 || alignV !== 0;
pos = useAlignPt
? (e.alignmentPt ?? d.alignmentPt ?? e.insertionPt ?? d.insertionPt ?? { x:0, y:0 })
: (e.insertionPt ?? d.insertionPt ?? d.insertionPoint ?? { x:0, y:0 });
} else {
const xd = e.xAxisDir ?? d.xAxisDir;
if (xd) rotation = Math.atan2(xd.y, xd.x);
// MTEXT: attachmentPoint 1-3=top, 4-6=mid, 7-9=bottom
// (parsers emit the field as `attachment`; keep both names)
const ap = e.attachment ?? d.attachment ?? e.attachmentPoint ?? d.attachmentPoint ?? 5;
alignH = ([1,4,7].includes(ap) ? 0 : [3,6,9].includes(ap) ? 2 : 1);
alignV = ([1,2,3].includes(ap) ? 3 : [7,8,9].includes(ap) ? 1 : 2);
pos = e.insertionPt ?? d.insertionPt ?? d.insertionPoint ?? { x:0, y:0 };
}
if (text) {
const entry = { text, pos, height, rotation, color, alignH, alignV };
if (type === 'MTEXT') {
entry.linespacingFactor = e.linespacingFactor ?? d.linespacingFactor ?? 1;
entry.rectWidth = e.rectWidth ?? d.rectWidth ?? 0;
entry.rectHeight = e.rectHeight ?? d.rectHeight ?? 0;
// Background mask (DXF 90/45/63): bit0=use fill color, bit1=window color, bit4=frame
entry.bgFillFlags = e.bgFillFlags ?? d.bgFillFlags ?? 0;
entry.bgScale = e.bgScale ?? d.bgScale ?? 1.5;
entry.bgColorIndex = e.bgColorIndex ?? d.bgColorIndex;
entry.bgColorRgb = e.bgColorRgb ?? d.bgColorRgb;
}
pendingTexts.push(entry);
meta.bounds = { type:'point', cx:pos.x, cy:pos.y };
this._addTextPick(text, pos, height, alignH, alignV);
expand(pos.x, pos.y, 0);
}
break;
}
// ── INSERT (block reference) ──────────────────────────────────────
case 'INSERT': {
const ip = e.insertionPt ?? d.insertionPt ?? d.insertionPoint;
const sx = d.scaleX ?? d.scale?.x ?? e.scale?.x ?? 1;
const sy = d.scaleY ?? d.scale?.y ?? e.scale?.y ?? 1;
const rot = d.rotation ?? e.rotation ?? 0;
const bhVal = e.blockHeaderHandle?.value ?? d.blockHeaderHandle?.value;
if (bhVal != null) {
// Block definition entities sit in the flat entities array,
// identified by ownerHandle.value === block header handle
const bHandleHex = bhVal.toString(16);
const bEnts = entsByOwner.get(bHandleHex) ?? [];
if (bEnts.length > 0) {
const bh = result?.tables?.blocks?.find(b => {
const bh2 = typeof b.handle === 'object' ? b.handle?.value : b.handle;
return bh2 === bhVal;
});
this._insertEntities(
bEnts,
{ insertionPoint: ip ?? {x:0,y:0,z:0}, xScale:sx, yScale:sy, rotation:rot, basePoint: bh?.basePoint },
color, pushSeg, pendingTexts,
{ entsByOwner, blockBase: blockBaseByHex }
);
}
}
if (ip) { meta.bounds = { type:'point', cx:ip.x, cy:ip.y }; expand(ip.x, ip.y, 0); }
break;
}
// ── HATCH ─────────────────────────────────────────────────────────
case 'HATCH': {
const paths = d.paths ?? e.paths;
const solidFill = d.solidFill ?? e.solidFill ?? false;
if (!paths?.length) break;
let firstPt = null;
for (const path of paths) {
if (!path.points?.length || path.points.length < 2) continue;
if (!firstPt) firstPt = path.points[0];
const hasBulge = path.bulges?.some(b => Math.abs(b) >= 1e-6);
if (hasBulge) {
this._bulgePolySegs(path.points, path.bulges, true, color, pushSeg, expand);
} else {
this._polylineSegs(path.points, true, 0, color, pushSeg);
path.points.forEach(p => expand(p.x, p.y, 0));
}
}
if (solidFill) this._hatchFill(paths, color);
if (firstPt) meta.bounds = { type:'point', cx:firstPt.x, cy:firstPt.y };
break;
}
// ── DIMENSION (all subtypes) ───────────────────────────────────────
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 bhVal = e.blockHeaderHandle?.value ?? d.blockHeaderHandle?.value;
let dimEnts = [];
if (bhVal != null) {
const hex = bhVal.toString(16);
dimEnts = entsByOwner.get(hex) ?? [];
}
if (dimEnts.length > 0) {
// Render pre-built dimension block geometry
for (const child of dimEnts) {
const ct = (child.type || child.typeName || '').toUpperCase();
const cd = child.data || child;
const cc = this._entityColor(child) || color;
if (ct === 'LINE' && cd.start && cd.end) {
pushSeg(cd.start.x, cd.start.y, cd.end.x, cd.end.y, 0, cc);
expand(cd.start.x, cd.start.y); expand(cd.end.x, cd.end.y);
} else if (ct === 'SOLID') {
const crs = [cd.corner1||cd.pt1, cd.corner2||cd.pt2,
cd.corner3||cd.pt3, cd.corner4||cd.pt4].filter(Boolean);
if (crs.length >= 3) { this._solidMesh(crs, cc); crs.forEach(c => expand(c.x, c.y)); }
} else if (ct === 'ARC' && cd.center && cd.radius != null) {
this._arcSegs(cd.center, cd.radius, cd.startAngle??0, cd.endAngle??Math.PI*2, 0, cc, pushSeg);
} else if ((ct === 'TEXT' || ct === 'MTEXT')) {
const raw = child.text ?? cd.text ?? cd.textValue ?? '';
const text = stripMText(raw);
const pos = child.insertionPt ?? cd.insertionPt ?? cd.insertionPoint ?? {x:0,y:0};
const ht = cd.textHeight ?? cd.height ?? child.textHeight ?? 2.5;
if (text) { pendingTexts.push({ text, pos, height:ht, rotation: cd.rotationAngle??0, color:cc, alignH:1, alignV:2 }); expand(pos.x, pos.y); }
} else if (ct === 'INSERT') {
// Arrowhead block INSERT within the dimension block: render the real
// arrow geometry; fall back to a named default for geometry-less
// system blocks (_ClosedFilled / _Dot / _Oblique / _Open / …).
const cip = child.insertionPt ?? cd.insertionPt ?? cd.insertionPoint ?? {x:0,y:0};
const crot = cd.rotation ?? child.rotation ?? 0;
const csx = cd.scaleX ?? cd.scale?.x ?? child.scale?.x ?? 1;
const csy = cd.scaleY ?? cd.scale?.y ?? child.scale?.y ?? csx;
const abh = child.blockHeaderHandle?.value ?? cd.blockHeaderHandle?.value;
const aHex = abh?.toString(16);
const aname = blockNameByHex.get(aHex) ?? '';
const arrowEnts = aHex ? (entsByOwner.get(aHex) ?? []) : [];
if (aname.includes('dot')) {
// Dot arrowhead → filled disc (radius from the block's geometry × scale)
this._dotMesh(cip, this._arrowDotRadius(arrowEnts) * Math.abs(csx) || Math.abs(csx) * 0.5, cc);
} else if (arrowEnts.length) {
this._insertEntities(
arrowEnts,
{ insertionPoint: cip, xScale: csx, yScale: csy, rotation: crot, basePoint: blockBaseByHex.get(aHex) },
cc, pushSeg, pendingTexts,
{ entsByOwner, blockBase: blockBaseByHex }
);
} else {
this._defaultArrow(aname, cip, crot, Math.abs(csx) || 1, cc, pushSeg);
}
expand(cip.x, cip.y);
}
}
const fl = dimEnts.find(c => (c.type||c.typeName||'').toUpperCase() === 'LINE');
const fd = fl?.data || fl;
if (fd?.start) meta.bounds = { type:'point', cx:fd.start.x, cy:fd.start.y };
} else {
// Fallback: reconstruct from entity properties
this._renderDimFallback(e, d, type, color, pushSeg, pendingTexts, expand);
const pt10 = e.pt10 ?? d.pt10;
if (pt10) { meta.bounds = { type:'point', cx:pt10.x, cy:pt10.y }; expand(pt10.x, pt10.y); }
}
break;
}
case 'LEADER': {
const pts = d.points ?? e.points;
if (pts?.length >= 2) {
this._polylineSegs(pts, false, 0, color, pushSeg);
pts.forEach(p => expand(p.x, p.y, p.z || 0));
if (d.arrowheadOn ?? e.arrowheadOn ?? true) {
const p0 = pts[0], p1 = pts[1];
const rot = Math.atan2(p1.y - p0.y, p1.x - p0.x);
const arrowSz = d.arrowSize ?? e.arrowSize ?? 2.5;
this._arrowMesh(p0, rot, arrowSz, color);
}
meta.bounds = { type:'point', cx:pts[0].x, cy:pts[0].y };
}
break;
}
default: break;
}
} catch { /* skip malformed entity */ }
meta.colEnd = lineColors.length;
}
// ── Render deferred text with bbox-proportional minimum size ──────────
if (pendingTexts.length) {
const sz = box.isEmpty() ? new THREE.Vector3(1,1,0) : box.getSize(new THREE.Vector3());
const diagSize = Math.hypot(Math.max(sz.x, 1), Math.max(sz.y, 1));
const minTextH = diagSize * 0.0002; // 0.02% of diagonal → readable at fit-view
// Cap sprite count to avoid GPU/heap OOM on large drawings. With the
// per-(text,color) texture cache in _textSprite, memory scales with
// UNIQUE strings, so the cap is a sprite-object guard, not a raster one
// (3000 previously culled half the texts on table sheets, e.g. BLOCK4).
const MAX_TEXTS = 20000;
const renderTexts = pendingTexts.length > MAX_TEXTS
? (console.warn(`텍스트 ${pendingTexts.length}개 → ${MAX_TEXTS}개로 제한`),
pendingTexts.sort((a, b) => b.height - a.height).slice(0, MAX_TEXTS))
: pendingTexts;
void this._drawTexts(renderTexts, minTextH);
}
this._lineColorAttr = null; this._origColors = null;
if (lineVerts.length) {
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.Float32BufferAttribute(lineVerts, 3));
const colorAttr = new THREE.Float32BufferAttribute(lineColors, 3);
geom.setAttribute('color', colorAttr);
this._group.add(new THREE.LineSegments(geom, new THREE.LineBasicMaterial({ vertexColors: true })));
this._lineColorAttr = colorAttr;
this._origColors = Float32Array.from(colorAttr.array);
}
// Dashed linetypes (HIDDEN / CENTER / …) — one LineSegments per dash|gap
// bucket with a LineDashedMaterial. computeLineDistances() is REQUIRED for
// the dash pattern to appear; on LineSegments each 2-vertex pair dashes
// independently from its own start (correct for CAD segments).
for (const bk of dashBuckets.values()) {
if (!bk.verts.length) continue;
const dgeom = new THREE.BufferGeometry();
dgeom.setAttribute('position', new THREE.Float32BufferAttribute(bk.verts, 3));
dgeom.setAttribute('color', new THREE.Float32BufferAttribute(bk.colors, 3));
const dline = new THREE.LineSegments(dgeom, new THREE.LineDashedMaterial({
vertexColors: true, dashSize: bk.dash, gapSize: bk.gap,
}));
dline.computeLineDistances();
this._group.add(dline);
}
if (!opts.keepView && !box.isEmpty()) this._fit(box);
}
// ── UI integration ─────────────────────────────────────────────────────────
/** Swap canvas background for theme. dark=true → near-black, false → light. */
setTheme(dark) {
this._scene.background = new THREE.Color(dark ? 0x0a0b0d : 0xf7f6f3);
if (this._gridVisible) this._rebuildGrid();
}
setGrid(visible) {
this._gridVisible = visible;
if (!visible) {
if (this._gridMesh) { this._scene.remove(this._gridMesh); this._gridMesh.geometry.dispose(); this._gridMesh.material.dispose(); this._gridMesh = null; }
} else {
this._rebuildGrid();
}
}
_rebuildGrid() {
if (this._gridMesh) { this._scene.remove(this._gridMesh); this._gridMesh.geometry.dispose(); this._gridMesh.material.dispose(); this._gridMesh = null; }
const zoom = this._camera.zoom || 1;
const cx = this._camera.position.x;
const cy = this._camera.position.y;
const hw = (this._camera.right - this._camera.left) / zoom / 2;
const hh = (this._camera.top - this._camera.bottom) / zoom / 2;
const ext = Math.max(hw, hh) * 3;
const spacing = this._niceSpacing(Math.max(hw, hh) * 2 / 20);
const [minX, maxX, minY, maxY] = [cx - ext, cx + ext, cy - ext, cy + ext];
const isDark = this._scene.background.r < 0.5;
const color = isDark ? 0x2b2b2b : 0xbbbbbb;
const verts = [];
for (let x = Math.ceil(minX / spacing) * spacing; x <= maxX; x += spacing) verts.push(x, minY, -0.5, x, maxY, -0.5);
for (let y = Math.ceil(minY / spacing) * spacing; y <= maxY; y += spacing) verts.push(minX, y, -0.5, maxX, y, -0.5);
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
this._gridMesh = new THREE.LineSegments(geo, new THREE.LineBasicMaterial({ color }));
this._scene.add(this._gridMesh);
}
_niceSpacing(raw) {
if (raw <= 0) return 1;
const exp = Math.pow(10, Math.floor(Math.log10(raw)));
const norm = raw / exp;
return (norm < 2 ? 1 : norm < 5 ? 2 : 5) * exp;
}
/** Zoom % relative to fit (ortho camera zoom). */
getZoomPercent() { return Math.round((this._camera?.zoom ?? 1) * 100); }
/** Subscribe to pan/zoom changes (for live zoom readout). */
onViewChange(cb) { this._controls?.addEventListener('change', cb); }
/** Hide entities on the given layer names (Set<string>); re-renders keeping the view. */
setHiddenLayers(nameSet) {
this._hiddenLayers = nameSet || new Set();
if (this._lastResult) this.load(this._lastResult, { keepView: true });
}
/** Entity count, block definition count — for recent-files metadata. */
getStats() {
return { entities: this._entityMeta.length, blocks: this._blockCount ?? 0 };
}
/** Capture current view as a WebP Blob. Requires preserveDrawingBuffer:true (already set). */
snapshotWebPBlob() {
return new Promise((resolve) => {
this._renderer.render(this._scene, this._camera);
this._renderer.domElement.toBlob((b) => resolve(b), 'image/webp', 0.75);
});
}
/** Layer list for the Layers panel: [{ name, colorHex, count, visible }]. */
getLayerInfo() {
const result = this._lastResult;
if (!result) return [];
const counts = new Map();
for (const e of (result.entities || [])) {
const lh = e.layerHandle?.value ?? e.layerHandle;
const name = (lh != null && this._layerNameByHandle?.get(String(lh)))
?? e.layer ?? e.layerName ?? '0';
counts.set(name, (counts.get(name) || 0) + 1);
}
const hidden = this._hiddenLayers || new Set();
return (result.tables?.layers || []).map(l => {
const name = l.name ?? l.layerName ?? '0';
const aci = Math.abs(l.colorIndex ?? l.color ?? l.colorNumber ?? 7);
return {
name,
colorHex: '#' + ((aciToHex(aci) ?? DEFAULT_COLOR).toString(16).padStart(6, '0')),
count: counts.get(name) || 0,
visible: !hidden.has(name),
};
}).filter(l => l.count > 0 || !l.name.startsWith('*'));
}
// ── Private helpers ────────────────────────────────────────────────────────
_buildLayerMap(layers) {
this._layerByHandle.clear();
this._layerByName.clear();
this._layerNameByHandle = new Map();
this._layer0Handle = null;
if (!layers) return;
for (const l of layers) {
const handle = l.handle?.value ?? l.handle;
const name = l.name ?? l.layerName;
const aci = Math.abs(l.colorIndex ?? l.color ?? l.colorNumber ?? 7);
const hex = aciToHex(aci) ?? DEFAULT_COLOR;
if (handle != null) this._layerByHandle.set(String(handle), hex);
if (name) this._layerByName.set(name, hex);
if (handle != null && name) this._layerNameByHandle.set(String(handle), name);
if (name === '0' && handle != null) this._layer0Handle = String(handle);
}
}
// Active-viewport VIEWTWIST (radians). The sheet is un-twisted by rotating the
// camera up by -viewTwist (verified against samples/11.dwg: title border upright).
_readViewTwist(result) {
const vports = result?.tables?.vports;
if (!Array.isArray(vports) || !vports.length) return 0;
const vp = vports.find(v => /active/i.test(v.name || '')) || vports[0];
const tw = vp?.viewTwist;
return (typeof tw === 'number' && isFinite(tw)) ? tw : 0;
}
// Build linetype lookup: pattern-by-name (signed element lengths, drawing
// units) + layer→linetype-name maps + the global LTSCALE. Consumed by
// _resolveDash to turn an entity's linetype into a dash/gap size.
_buildLinetypeMap(result) {
this._ltPatterns = new Map();
this._layerLtByName = new Map();
this._layerLtByHandle = new Map();
this._globalLtscale = (result?.vars?.ltscale > 0 ? result.vars.ltscale : 1);
for (const lt of (result?.tables?.lineTypes ?? [])) {
if (lt?.name) this._ltPatterns.set(lt.name, Array.isArray(lt.pattern) ? lt.pattern : []);
}
for (const l of (result?.tables?.layers ?? [])) {
const name = l.name ?? l.layerName;
const handle = l.handle?.value ?? l.handle;
const ltn = l.lineType ?? l.linetype;
if (name && ltn) this._layerLtByName.set(name, ltn);
if (handle != null && ltn) this._layerLtByHandle.set(String(handle), ltn);
}
}
// Resolve an entity's dash pattern → { key, dash, gap } world-unit sizes, or
// null for a solid line. BYLAYER / BYBLOCK / empty fall back to the layer's
// linetype; dash/gap = Σ|element| × LTSCALE × entity linetypeScale (CELTSCALE).
_resolveDash(e) {
if (!this._ltPatterns || this._ltPatterns.size === 0) return null;
let ltName = e.lineType ?? e.linetype;
if (!ltName || ltName === 'ByLayer' || ltName === 'BYLAYER' ||
ltName === 'ByBlock' || ltName === 'BYBLOCK') {
const lh = e.layerHandle?.value ?? e.layerHandle;
ltName = (lh != null && this._layerLtByHandle.get(String(lh)))
|| this._layerLtByName.get(e.layer ?? e.layerName) || null;
}
if (!ltName || ltName === 'Continuous' || ltName === 'ByLayer' || ltName === 'ByBlock') return null;
const pattern = this._ltPatterns.get(ltName);
if (!pattern || pattern.length === 0) return null;
const entScale = (e.entityHeader?.linetypeScale > 0 ? e.entityHeader.linetypeScale : 1);
const scale = this._globalLtscale * entScale;
let dash = 0, gap = 0;
for (const d of pattern) {
const len = Math.abs(d) * scale;
if (d > 1e-6) dash += len;
else if (d < -1e-6) gap += len;
else dash += Math.max(len, 0.01 * scale); // dot → tiny dash
}
if (dash < 1e-4 || gap < 1e-4) return null;
return { key: `${dash.toFixed(3)}|${gap.toFixed(3)}`, dash, gap };
}
// Resolve an entity's color inside a block, given the color inherited from the
// INSERT context. BYBLOCK and layer-0 BYLAYER entities inherit the INSERT color
// (AutoCAD's layer-0 rule); everything else uses its own color/layer.
_resolveBlockColor(entity, inherited) {
const aci = entity.entityHeader?.colorIndex
?? entity.color ?? entity.colorIndex ?? entity.colorNumber ?? 256;
if (aci === 0) return inherited; // BYBLOCK
const lh = String(entity.layerHandle?.value ?? entity.layerHandle ?? '');
if (aci === 256 && this._layer0Handle && lh === this._layer0Handle) return inherited;
return this._entityColor(entity);
}
_entityColor(entity) {
const aci = entity.entityHeader?.colorIndex
?? entity.color ?? entity.colorIndex ?? entity.colorNumber ?? 256;
if (aci != null) {
const hex = aciToHex(aci);
if (hex !== null) return hex;
}
const lh = entity.layerHandle?.value ?? entity.layerHandle;
if (lh != null) {
const c = this._layerByHandle.get(String(lh));
if (c !== undefined) return c;
}
const ln = entity.layer ?? entity.layerName;
if (ln) {
const c = this._layerByName.get(ln);
if (c !== undefined) return c;
}
return DEFAULT_COLOR;
}
// Filled triangle arrow mesh (replaces V-line arrowhead)
_arrowMesh(pt, rotation, size, color) {
if (!pt || size < 1e-4) return;
const w = size * 0.166;
const r = ((color >> 16) & 0xFF) / 255;
const g = ((color >> 8) & 0xFF) / 255;
const b = (color & 0xFF) / 255;
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.Float32BufferAttribute([0,0,0, -size,w,0, -size,-w,0], 3));
geom.setAttribute('color', new THREE.Float32BufferAttribute([r,g,b, r,g,b, r,g,b], 3));
const mesh = new THREE.Mesh(geom, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.DoubleSide }));
mesh.position.set(pt.x, pt.y, (pt.z || 0) + 0.5);
mesh.rotation.z = rotation;
this._group.add(mesh);
}
// Default geometry for AutoCAD standard arrowhead blocks that ship no geometry
// in the file (system blocks). `name` is the lowercased block name.
_defaultArrow(name, pt, rotation, size, color, pushSeg) {
if (!pt || size < 1e-4) return;
const n = (name || '').replace(/^[_*]+/, '');
const cos = Math.cos(rotation), sin = Math.sin(rotation);
// local→world: arrow points toward +X at the insertion point
const P = (lx, ly) => ({ x: pt.x + lx * cos - ly * sin, y: pt.y + lx * sin + ly * cos });
if (n === 'none' || n === 'small' || n === 'integral') return;
if (n === 'dot' || n === 'dotsmall' || n === 'dotblank' || n === 'dotsmallblank' || n === 'origin' || n === 'origin2') {
this._dotMesh(pt, size * (n === 'dotsmall' ? 0.12 : 0.5), color);
return;
}
if (n === 'oblique' || n === 'archtick') {
const a = P(size * 0.5, size * 0.5), b = P(-size * 0.5, -size * 0.5);
pushSeg(a.x, a.y, b.x, b.y, (pt.z || 0) + 0.5, color);
return;
}
if (n === 'open' || n === 'open30' || n === 'open90') {
const w = n === 'open90' ? size : size * 0.42;
const t = P(0, 0), u = P(-size, w), v = P(-size, -w);
pushSeg(u.x, u.y, t.x, t.y, (pt.z || 0) + 0.5, color);
pushSeg(v.x, v.y, t.x, t.y, (pt.z || 0) + 0.5, color);
return;
}
// default (_ClosedFilled, "", closed, boxfilled, datumfilled, …) → filled triangle
this._arrowMesh(pt, rotation, size, color);
}
// Filled disc (dot arrowhead).
_dotMesh(center, radius, color) {
if (!center || !(radius > 1e-5)) return;
const r = ((color >> 16) & 0xFF) / 255, g = ((color >> 8) & 0xFF) / 255, b = (color & 0xFF) / 255;
const segs = 20, verts = [], cols = [], z = (center.z || 0) + 0.5;
for (let i = 0; i < segs; i++) {
const a0 = i / segs * Math.PI * 2, a1 = (i + 1) / segs * Math.PI * 2;
verts.push(center.x, center.y, z,
center.x + radius * Math.cos(a0), center.y + radius * Math.sin(a0), z,
center.x + radius * Math.cos(a1), center.y + radius * Math.sin(a1), z);
cols.push(r, g, b, r, g, b, r, g, b);
}
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
geom.setAttribute('color', new THREE.Float32BufferAttribute(cols, 3));
this._group.add(new THREE.Mesh(geom, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.DoubleSide })));
}
// Dot radius (block-local) from an arrow block's geometry, measured from its centroid.
_arrowDotRadius(ents) {
let r = 0;
for (const ae of (ents || [])) {
const ad = ae.data || ae;
if ((ae.type || '').toUpperCase() === 'CIRCLE' && ad.radius) r = Math.max(r, ad.radius);
const pts = ad.points ?? ad.vertices;
if (pts?.length) {
let cx = 0, cy = 0; pts.forEach(p => { cx += p.x; cy += p.y; }); cx /= pts.length; cy /= pts.length;
pts.forEach(p => { r = Math.max(r, Math.hypot(p.x - cx, p.y - cy)); });
}
}
return r;
}
_arcSegs(center, r, a0, a1, z, color, pushSeg) {
let span = a1 - a0;
if (span <= 0) span += Math.PI * 2;
const steps = Math.max(8, Math.ceil((span / (Math.PI * 2)) * ARC_SEGS));
let prev = null;
for (let i = 0; i <= steps; i++) {
const a = a0 + (span * i) / steps;
const x = center.x + r * Math.cos(a);
const y = center.y + r * Math.sin(a);
if (prev) pushSeg(prev.x, prev.y, x, y, z, color);
prev = { x, y };
}
}
// Centripetal CatmullRom interpolation for SPLINE
_catmullRom(pts, t) {
const n = pts.length;
const f = t * (n - 1);
const i = Math.min(Math.floor(f), n - 2);
const tt = f - i;
const p0 = pts[Math.max(i - 1, 0)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(i + 2, n - 1)];
const t2 = tt * tt, t3 = t2 * tt;
return {
x: 0.5 * ((2*p1.x) + (-p0.x+p2.x)*tt + (2*p0.x-5*p1.x+4*p2.x-p3.x)*t2 + (-p0.x+3*p1.x-3*p2.x+p3.x)*t3),
y: 0.5 * ((2*p1.y) + (-p0.y+p2.y)*tt + (2*p0.y-5*p1.y+4*p2.y-p3.y)*t2 + (-p0.y+3*p1.y-3*p2.y+p3.y)*t3),
z: 0.5 * ((2*(p1.z||0)) + (-(p0.z||0)+(p2.z||0))*tt + (2*(p0.z||0)-5*(p1.z||0)+4*(p2.z||0)-(p3.z||0))*t2 + (-(p0.z||0)+3*(p1.z||0)-3*(p2.z||0)+(p3.z||0))*t3),
};
}
_ellipseSegs(d, color, pushSeg) {
// field names: majorAxis or smAxis; ratio or axisRatio
const ma = d.majorAxis ?? d.smAxis ?? { x: 1, y: 0, z: 0 };
const center = d.center;
const ratio = d.ratio ?? d.axisRatio ?? 1;
const startParam = d.startParam ?? 0;
const endParam = d.endParam ?? Math.PI * 2;
const mx = ma?.x ?? 1, my = ma?.y ?? 0;
const a = Math.sqrt(mx*mx + my*my);
const b = a * ratio;
const rot = Math.atan2(my, mx);
let span = endParam - startParam;
if (span <= 0) span += Math.PI * 2;
let prev = null;
for (let i = 0; i <= ELLIPSE_SEGS; i++) {
const t = startParam + (span * i) / ELLIPSE_SEGS;
const ex = a * Math.cos(t), ey = b * Math.sin(t);
const x = center.x + ex * Math.cos(rot) - ey * Math.sin(rot);
const y = center.y + ex * Math.sin(rot) + ey * Math.cos(rot);
if (prev) pushSeg(prev.x, prev.y, x, y, center.z || 0, color);
prev = { x, y };
}
}
_polylineSegs(points, closed, z, color, pushSeg) {
if (!points || points.length < 2) return;
for (let i = 0; i < points.length - 1; i++) {
pushSeg(points[i].x, points[i].y, points[i+1].x, points[i+1].y, z, color);
}
if (closed) {
const a = points[points.length-1], b = points[0];
pushSeg(a.x, a.y, b.x, b.y, z, color);
}
}
// Resolve start/end width for segment i→i+1 (DXF 40/41, fallback 43 constant).
_segWidths(ent, i, scale = 1) {
const d = ent?.data || ent || {};
const cw = (d.constantWidth ?? d.constWidth ?? 0) * scale;
const dsw = (d.defaultStartWidth ?? 0) * scale;
const dew = (d.defaultEndWidth ?? 0) * scale;
const sw0 = (d.startWidths?.[i] ?? 0) * scale;
const ew0 = (d.endWidths?.[i] ?? 0) * scale;
const sw = Math.abs(sw0) > 1e-12 ? sw0 : (Math.abs(cw) > 1e-12 ? cw : dsw);
const ew = Math.abs(ew0) > 1e-12 ? ew0 : (Math.abs(cw) > 1e-12 ? cw : dew);
return { sw: Math.abs(sw), ew: Math.abs(ew) };
}
_polyHasWidth(ent, nSeg, scale = 1) {
for (let i = 0; i < nSeg; i++) {
const { sw, ew } = this._segWidths(ent, i, scale);
if (sw > 1e-9 || ew > 1e-9) return true;
}
return false;
}
// Draw one trapezoid segment (variable start/end half-width) as two triangles.
_pushWideSeg(verts, p1, p2, w0, w1) {
const dx = p2.x - p1.x, dy = p2.y - p1.y;
const len = Math.hypot(dx, dy);
if (len < 1e-12) return;
const nx = -dy / len, ny = dx / len;
const h0 = w0 * 0.5, h1 = w1 * 0.5;
const a = { x: p1.x + nx * h0, y: p1.y + ny * h0 };
const b = { x: p1.x - nx * h0, y: p1.y - ny * h0 };
const c = { x: p2.x - nx * h1, y: p2.y - ny * h1 };
const d = { x: p2.x + nx * h1, y: p2.y + ny * h1 };
// two triangles a-b-c, a-c-d
verts.push(a.x, a.y, 0, b.x, b.y, 0, c.x, c.y, 0);
verts.push(a.x, a.y, 0, c.x, c.y, 0, d.x, d.y, 0);
}
// Variable-width polyline → solid mesh (MeshBasicMaterial). Used when any
// segment has start/end/constant width > 0 (도곽 outer frame etc.).
_widePolyMesh(points, bulges, closed, ent, color, scale = 1, expand = null) {
if (!points || points.length < 2) return;
const n = points.length;
const segs = closed ? n : n - 1;
const verts = [];
for (let i = 0; i < segs; i++) {
const p1 = points[i];
const p2 = points[(i + 1) % n];
const { sw, ew } = this._segWidths(ent, i, scale);
if (sw < 1e-9 && ew < 1e-9) {
// Zero-width segment: still emit a tiny hair so it isn't dropped entirely.
this._pushWideSeg(verts, p1, p2, 0.01 * scale, 0.01 * scale);
if (expand) { expand(p1.x, p1.y); expand(p2.x, p2.y); }
continue;
}
const bulge = bulges?.[i] || 0;
if (Math.abs(bulge) < 1e-6) {
this._pushWideSeg(verts, p1, p2, sw, ew);
if (expand) { expand(p1.x, p1.y); expand(p2.x, p2.y); }
} else {
// Tessellate bulge arc; interpolate width along the arc.
const dx = p2.x - p1.x, dy = p2.y - p1.y;
const dist = Math.hypot(dx, dy);
if (dist < 1e-9) continue;
const inc = 4 * Math.atan(bulge);
const half = Math.abs(inc) / 2;
const radius = (dist / 2) / Math.sin(half);
const apothem = radius * Math.cos(half);
const mx2 = (p1.x + p2.x) / 2, my2 = (p1.y + p2.y) / 2;
const nx2 = -dy / dist, ny2 = dx / dist;
const sign = bulge > 0 ? 1 : -1;
const cx = mx2 + sign * nx2 * apothem;
const cy = my2 + sign * ny2 * apothem;
const sa = Math.atan2(p1.y - cy, p1.x - cx);
const steps = Math.max(2, Math.ceil(Math.abs(inc) / (Math.PI / 16)));
let prev = { x: p1.x, y: p1.y };
let prevW = sw;
for (let k = 1; k <= steps; k++) {
const t = k / steps;
const a = sa + (inc * k) / steps;
const cur = { x: cx + radius * Math.cos(a), y: cy + radius * Math.sin(a) };
const curW = sw + (ew - sw) * t;
this._pushWideSeg(verts, prev, cur, prevW, curW);
prev = cur;
prevW = curW;
}
if (expand) { expand(p1.x, p1.y); expand(p2.x, p2.y); }
}
}
if (verts.length < 9) return;
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
geo.computeVertexNormals();
const mat = new THREE.MeshBasicMaterial({
color: new THREE.Color(color),
side: THREE.DoubleSide,
depthWrite: false,
});
this._group.add(new THREE.Mesh(geo, mat));
// Pick as edge segments only — do NOT register the poly as a fill loop.
// A closed outer frame (도곽 cw=2) used as _pickFills would mark the whole
// interior as "covered" and (with a weak size check) hide every inner line.
if (this._pickCurIdx >= 0 && this._pickSegs) {
for (let i = 0; i < segs; i++) {
const a = points[i], b = points[(i + 1) % n];
this._pickSegs.push(a.x, a.y, b.x, b.y);
this._pickSegMeta.push(this._pickCurIdx);
}
}
}
// Polyline with bulge arcs (LWPOLYLINE, HATCH boundaries)
_bulgePolySegs(points, bulges, closed, color, pushSeg, expand) {
const n = points.length;
const segs = closed ? n : n - 1;
for (let i = 0; i < segs; i++) {
const p1 = points[i];
const p2 = points[(i + 1) % n];
const bulge = bulges?.[i] || 0;
if (Math.abs(bulge) < 1e-6) {
pushSeg(p1.x, p1.y, p2.x, p2.y, 0, color);
if (expand) { expand(p1.x, p1.y); expand(p2.x, p2.y); }
} else {
const dx = p2.x - p1.x, dy = p2.y - p1.y;
const dist = Math.sqrt(dx*dx + dy*dy);
if (dist < 1e-9) continue;
// Sweep by the signed included angle Δ = 4·atan(bulge). Sweeping the angle
// directly (rather than computing both endpoint angles and resolving the
// 2π wrap) avoids drawing the major arc when |bulge| is tiny.
const inc = 4 * Math.atan(bulge); // signed; >0 = CCW
const half = Math.abs(inc) / 2;
const radius = (dist / 2) / Math.sin(half);
const apothem = radius * Math.cos(half);
const mx2 = (p1.x + p2.x) / 2, my2 = (p1.y + p2.y) / 2;
const nx2 = -dy / dist, ny2 = dx / dist; // left normal of p1→p2
const sign = bulge > 0 ? 1 : -1;
const cx = mx2 + sign * nx2 * apothem;
const cy = my2 + sign * ny2 * apothem;
const sa = Math.atan2(p1.y - cy, p1.x - cx);
const steps = Math.max(2, Math.ceil(Math.abs(inc) / (Math.PI / 16)));
let prev = p1;
for (let k = 1; k <= steps; k++) {
const a = sa + (inc * k) / steps;
const x = cx + radius * Math.cos(a), y = cy + radius * Math.sin(a);
pushSeg(prev.x, prev.y, x, y, 0, color);
prev = { x, y };
}
if (expand) { expand(p1.x, p1.y); expand(p2.x, p2.y); }
}
}
}
_solidMesh(corners, color) {
const r = ((color >> 16) & 0xFF) / 255;
const g = ((color >> 8) & 0xFF) / 255;
const b = (color & 0xFF) / 255;
const verts = [], cols = [];
const addTri = (...pts) => {
for (const p of pts) { verts.push(p.x, p.y, p.z || 0); cols.push(r, g, b); }
};
if (corners.length >= 4) {
addTri(corners[0], corners[1], corners[3]);
addTri(corners[0], corners[3], corners[2]);
} else {
addTri(corners[0], corners[1], corners[2]);
}
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
geom.setAttribute('color', new THREE.Float32BufferAttribute(cols, 3));
this._group.add(new THREE.Mesh(geom, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.DoubleSide })));
// Filled quad/tri → pickable region (DWG SOLID vertex order is 0,1,3,2).
if (this._pickCurIdx >= 0 && corners.length >= 3) {
const loop = corners.length >= 4 ? [corners[0], corners[1], corners[3], corners[2]] : corners.slice(0, 3);
this._pickFills.push({ metaIdx: this._pickCurIdx, loops: [loop] });
}
}
// Text has no line geometry — register its box as a pickable fill so a click
// anywhere on the text selects it. Box follows the sprite's anchor rules
// (alignH 0/3/5=left,2=right,1/4=center; alignV 3=top,0/1=bottom,2=middle).
_addTextPick(text, pos, height, alignH = 0, alignV = 0) {
if (this._pickCurIdx < 0 || !pos) return;
const rows = String(text).split('\n');
const cols = rows.reduce((m, s) => Math.max(m, s.length), 1);
const w = cols * height * 0.62; // ~ per-glyph advance
const h = rows.length * height * 1.2;
let x0 = pos.x;
if (alignH === 2) x0 = pos.x - w; // right anchor
else if (alignH === 1 || alignH === 4) x0 = pos.x - w / 2; // center
let y0 = pos.y; // bottom anchor
if (alignV === 3) y0 = pos.y - h; // top anchor
else if (alignV === 2) y0 = pos.y - h / 2; // middle
this._pickFills.push({ metaIdx: this._pickCurIdx, loops: [[
{ x: x0, y: y0 }, { x: x0 + w, y: y0 }, { x: x0 + w, y: y0 + h }, { x: x0, y: y0 + h },
]] });
}
// Even-odd point-in-region across ALL loops of a fill (holes exclude correctly).
_fillHit(loops, x, y) {
let inside = false;
for (const poly of loops) {
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const xi = poly[i].x, yi = poly[i].y, xj = poly[j].x, yj = poly[j].y;
if (((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) inside = !inside;
}
}
return inside;
}
// True if (x,y) lies inside ANY single loop (NOT even-odd across loops).
// Used to detect "this point belongs to hatch geometry" including hole
// interiors — even-odd would report holes as uncovered and let a later
// LWPOLYLINE re-solid them (CXGLOGO ㅇ/ㅎ counters).
_pointInAnyLoop(loops, x, y) {
for (const poly of loops) {
if (!poly?.length) continue;
let c = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const a = poly[i], b = poly[j];
if (((a.y > y) !== (b.y > y)) && (x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x)) c = !c;
}
if (c) return true;
}
return false;
}
// Signed area of a loop (for topmost = smallest-area tie-break on overlapping fills).
_loopArea(poly) {
let s = 0;
for (let i = 0; i < poly.length; i++) { const a = poly[i], b = poly[(i + 1) % poly.length]; s += a.x * b.y - b.x * a.y; }
return Math.abs(s) / 2;
}
// Solid-fill a polyline that is geometrically closed but not covered by any
// existing hatch fill. Text-as-hatch logos sometimes ship glyph strokes as
// LWPOLYLINE outlines only (e.g. ㅅ of 사 in CXGLOGO) while sibling glyphs
// have solid HATCH — without this they render as empty outlines.
//
// Coverage test is ANY-loop containment (not even-odd). Logo blocks list
// HATCH entities first, then the same glyph outlines as LWPOLYLINE. Even-odd
// treats hole interiors as "uncovered", so the post-hatch polyline pass used
// to re-solid ㅇ/ㅎ counters and wipe the punches. Any-loop still lets true
// orphan outlines (ㅅ with no hatch) fill, while skipping anything that
// already belongs to a hatch loop — fill or hole.
_fillClosedPolyIfUncovered(points, bulges, color) {
if (!points || points.length < 3) return;
const a = points[0], b = points[points.length - 1];
const gap = Math.hypot(a.x - b.x, a.y - b.y);
// Allow tiny gaps relative to polyline size (floating-point closed loops).
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const p of points) {
if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y;
if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y;
}
const diag = Math.hypot(maxX - minX, maxY - minY) || 1;
if (gap > diag * 1e-4 && gap > 1e-6) return;
// Area gate: logo glyph strokes are small (area ~1100 in block units).
// Refuse huge loops even inside blocks (defensive).
let sa = 0;
for (let i = 0; i < points.length; i++) {
const p0 = points[i], p1 = points[(i + 1) % points.length];
sa += p0.x * p1.y - p1.x * p0.y;
}
const absA = Math.abs(sa) / 2;
if (absA < 1e-6 || absA > 5000) return;
let cx = 0, cy = 0;
for (const p of points) { cx += p.x; cy += p.y; }
cx /= points.length; cy /= points.length;
for (const f of this._pickFills) {
if (this._pointInAnyLoop(f.loops, cx, cy)) return;
}
this._hatchFill([{ points, bulges: bulges || null, closed: true }], color);
}
// Solid fill HATCH using THREE.ShapeGeometry
// Tessellate one (closed) hatch boundary loop → flat {x,y}[] (resolves bulge arcs,
// including the closing edge — a 2-point/2-bulge loop is a full circle).
_pathPoints(path) {
const pts = path.points;
if (!pts?.length) return null;
const bulges = path.bulges;
const n = pts.length;
if (!bulges?.some(b => Math.abs(b) >= 1e-6)) return pts.map(p => ({ x: p.x, y: p.y }));
const out = [];
for (let i = 0; i < n; i++) {
const p1 = pts[i], p2 = pts[(i + 1) % n], bulge = bulges[i] || 0;
out.push({ x: p1.x, y: p1.y });
if (Math.abs(bulge) < 1e-6) continue;
const dx = p2.x - p1.x, dy = p2.y - p1.y, dist = Math.hypot(dx, dy);
if (dist < 1e-9) continue;
const inc = 4 * Math.atan(bulge), half = Math.abs(inc) / 2;
const radius = (dist / 2) / Math.sin(half), apo = radius * Math.cos(half);
const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2;
const nx = -dy / dist, ny = dx / dist, sign = bulge > 0 ? 1 : -1;
const cx = mx + sign * nx * apo, cy = my + sign * ny * apo;
const sa = Math.atan2(p1.y - cy, p1.x - cx);
const segs = Math.max(6, Math.ceil(Math.abs(inc) / (Math.PI / 16)));
for (let j = 1; j < segs; j++) { // intermediate arc points; p2 added next iter
const a = sa + inc * j / segs;
out.push({ x: cx + radius * Math.cos(a), y: cy + radius * Math.sin(a) });
}
}
return out;
}
// Solid fill with true even-odd parity across all boundary loops.
//
// DWG solid HATCH (style Normal / odd parity) is even-odd, not "outer +
// nested holes". Text-as-hatch logos (CXGLOGO 한국도로공사) ship
// self-intersecting multi-stroke loops where ShapeGeometry/earcut + geometric
// nesting mis-classifies ㅇ/ㅎ counters. Verified against samples/11.dwg:
// SVG fill-rule=evenodd over all non-outlier loops matches the CAD logo.
_hatchFill(paths, color) {
const totalPts = paths.reduce((s, p) => s + (p.points?.length ?? 0), 0);
if (totalPts > 8000) return; // safety for pathological boundaries
let polys = [];
for (const p of paths) { const pp = this._pathPoints(p); if (pp && pp.length >= 3) polys.push(pp); }
if (!polys.length) return;
const area = (poly) => { let s = 0; for (let i = 0; i < poly.length; i++) { const a = poly[i], b = poly[(i + 1) % poly.length]; s += a.x * b.y - b.x * a.y; } return Math.abs(s) / 2; };
// Drop spurious giant boundaries (parse-corrupt arcs expanded to huge loops).
// Many-loop hatches (≥5) or empty-loop hatches get median×10 outlier filter.
if ((paths.some(p => !(p.points?.length)) && polys.length >= 3) || polys.length >= 5) {
const sorted = polys.map(area).sort((a, b) => a - b);
const med = sorted[Math.floor(sorted.length / 2)] || 0;
if (med > 0) polys = polys.filter(p => area(p) <= med * 10);
if (!polys.length) return;
}
// Picking uses even-odd across all loops (holes exclude correctly).
if (this._pickCurIdx >= 0 && polys.length) this._pickFills.push({ metaIdx: this._pickCurIdx, loops: polys });
// When does this hatch need true even-odd (vs independent union of islands)?
// - Self-intersecting loops (text-as-hatch glyph strokes)
// - Many loops (≥5): logo words like 한국도로공사
// - Geometric nesting: a smaller loop sits inside a larger one (island/hole)
// Separate solid islands that only touch/overlap (EX logo 4 paths) must be
// UNION — even-odd would punch diamond holes at the overlaps.
const needsEvenOdd =
polys.length >= 5 ||
polys.some((p) => this._polySelfIntersects(p)) ||
this._hatchHasNestedLoop(polys);
if (needsEvenOdd) {
this._hatchFillEvenOddCanvas(polys, color);
return;
}
// Union of simple islands: one ShapeGeometry per loop (no hole punching).
const mat = new THREE.MeshBasicMaterial({ color: new THREE.Color(color), side: THREE.DoubleSide });
for (const poly of polys) {
try {
const shape = new THREE.Shape();
shape.moveTo(poly[0].x, poly[0].y);
for (let k = 1; k < poly.length; k++) shape.lineTo(poly[k].x, poly[k].y);
shape.closePath();
this._group.add(new THREE.Mesh(new THREE.ShapeGeometry(shape), mat));
} catch { /* degenerate */ }
}
}
// True if any loop is (mostly) inside a larger loop — island/hole topology.
_hatchHasNestedLoop(polys) {
if (polys.length < 2) return false;
const area = (poly) => {
let s = 0;
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
s += a.x * b.y - b.x * a.y;
}
return Math.abs(s) / 2;
};
const bboxOf = (poly) => {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const p of poly) {
if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y;
if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y;
}
return { minX, minY, maxX, maxY };
};
const inside = (poly, pt) => {
let c = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const a = poly[i], b = poly[j];
if (((a.y > pt.y) !== (b.y > pt.y)) && (pt.x < (b.x - a.x) * (pt.y - a.y) / (b.y - a.y) + a.x)) c = !c;
}
return c;
};
const ar = polys.map(area);
const bbs = polys.map(bboxOf);
for (let i = 0; i < polys.length; i++) {
let cx = 0, cy = 0;
for (const p of polys[i]) { cx += p.x; cy += p.y; }
cx /= polys[i].length; cy /= polys[i].length;
for (let j = 0; j < polys.length; j++) {
if (i === j || ar[j] <= ar[i]) continue;
const bb = bbs[j], bi = bbs[i];
const bboxHit = bb.minX <= bi.minX && bb.minY <= bi.minY && bb.maxX >= bi.maxX && bb.maxY >= bi.maxY;
if (bboxHit || inside(polys[j], { x: cx, y: cy })) return true;
}
}
return false;
}
// Cheap self-intersection probe (adjacent edges ignored). Used only to choose
// ShapeGeometry vs even-odd canvas; false negatives still go through canvas
// when there are multiple loops.
_polySelfIntersects(poly) {
const n = poly.length;
if (n < 4) return false;
const cross = (u, v, w) => (v.x - u.x) * (w.y - u.y) - (v.y - u.y) * (w.x - u.x);
const hits = (a, b, c, d) => {
const d1 = cross(a, b, c), d2 = cross(a, b, d), d3 = cross(c, d, a), d4 = cross(c, d, b);
return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0));
};
// Cap checks for large loops (logo glyphs are small).
const lim = Math.min(n, 80);
for (let i = 0; i < lim; i++) {
const a = poly[i], b = poly[(i + 1) % n];
for (let j = i + 2; j < lim; j++) {
if (i === 0 && j === n - 1) continue;
if (j === (i + n - 1) % n) continue;
if (hits(a, b, poly[j], poly[(j + 1) % n])) return true;
}
}
return false;
}
// Diameter-like edge through bbox center: corrupt hatch chords (CXGLOGO ㅎ)
// that turn even-odd into a Mercedes/pie pattern. True when shoelace area is
// also inflated above the convex hull (self-intersecting junk).
_isDiameterInflatedPath(pts) {
if (!pts || pts.length < 8) return false;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const p of pts) {
if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y;
if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y;
}
const w = maxX - minX, h = maxY - minY;
const minDim = Math.min(w, h);
if (!(minDim > 1e-9)) return false;
const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
const thr = 0.55 * minDim;
const centerR = 0.22 * minDim;
let diamN = 0;
for (let i = 0; i < pts.length; i++) {
const a = pts[i], b = pts[(i + 1) % pts.length];
const len = Math.hypot(b.x - a.x, b.y - a.y);
if (len < thr) continue;
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
if (Math.hypot(mx - cx, my - cy) <= centerR) diamN++;
}
if (!diamN) return false;
// shoelace abs area vs convex hull — inflated ⇒ self-intersecting
let sa = 0;
for (let i = 0; i < pts.length; i++) {
const a = pts[i], b = pts[(i + 1) % pts.length];
sa += a.x * b.y - b.x * a.y;
}
const absA = Math.abs(sa) / 2;
const hull = this._convexHull(pts);
if (hull.length < 3) return false;
let ha = 0;
for (let i = 0; i < hull.length; i++) {
const a = hull[i], b = hull[(i + 1) % hull.length];
ha += a.x * b.y - b.x * a.y;
}
ha = Math.abs(ha) / 2;
return ha > 1e-9 && absA > ha * 1.15;
}
_convexHull(pts) {
const p = pts.map((q) => ({ x: q.x, y: q.y })).sort((a, b) => a.x - b.x || a.y - b.y);
if (p.length <= 2) return p;
const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
const lower = [];
for (const pt of p) {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], pt) <= 0) lower.pop();
lower.push(pt);
}
const upper = [];
for (let i = p.length - 1; i >= 0; i--) {
const pt = p[i];
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], pt) <= 0) upper.pop();
upper.push(pt);
}
lower.pop();
upper.pop();
return lower.concat(upper);
}
_polyCentroid(poly) {
let x = 0, y = 0;
for (const p of poly) { x += p.x; y += p.y; }
return { x: x / poly.length, y: y / poly.length };
}
_pointInPoly(poly, pt) {
let c = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const a = poly[i], b = poly[j];
if (((a.y > pt.y) !== (b.y > pt.y)) && (pt.x < (b.x - a.x) * (pt.y - a.y) / (b.y - a.y) + a.x)) c = !c;
}
return c;
}
// Raster even-odd fill into a CanvasTexture plane. Sharp enough for logo-scale
// glyphs (tens of units); large site hatches still use ShapeGeometry when they
// are a single simple loop. Texture is disposed with the mesh on reload.
//
// Diameter-inflated loops (ㅎ of 한 in CXGLOGO): pure even-odd yields a pie/
// Mercedes pattern from spurious center chords. Those loops are filled with
// nonzero (solid glyph + cross), then nested sibling loops punch the counters
// (ㅇ hole). Remaining loops use normal even-odd.
_hatchFillEvenOddCanvas(polys, color) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const poly of polys) {
for (const p of poly) {
if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y;
if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y;
}
}
const worldW = maxX - minX;
const worldH = maxY - minY;
if (!(worldW > 1e-12) || !(worldH > 1e-12)) return;
// Pad 1px equivalent so AA edges aren't clipped.
const MAX = 2048;
const pxPerUnit = Math.min(MAX / worldW, MAX / worldH, 64);
const tw = Math.max(2, Math.ceil(worldW * pxPerUnit) + 2);
const th = Math.max(2, Math.ceil(worldH * pxPerUnit) + 2);
const sx = (tw - 2) / worldW;
const sy = (th - 2) / worldH;
const canvas = document.createElement('canvas');
canvas.width = tw;
canvas.height = th;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, tw, th);
const toCanvas = (p) => ({
x: 1 + (p.x - minX) * sx,
y: 1 + (maxY - p.y) * sy,
});
const addPoly = (poly) => {
if (poly.length < 3) return;
const p0 = toCanvas(poly[0]);
ctx.moveTo(p0.x, p0.y);
for (let i = 1; i < poly.length; i++) {
const p = toCanvas(poly[i]);
ctx.lineTo(p.x, p.y);
}
ctx.closePath();
};
const solidPolys = [];
const evenPolys = [];
for (const poly of polys) {
if (this._isDiameterInflatedPath(poly)) solidPolys.push(poly);
else evenPolys.push(poly);
}
// Self-intersecting solid outlines fail ordinary PIP for hole nesting — use
// their convex hull as the containment oracle (ㅎ outer vs ㅇ counter).
const solidHulls = solidPolys.map((p) => this._convexHull(p));
const isHoleOfSolid = (poly) => {
if (poly.length < 3 || !solidHulls.length) return false;
const c = this._polyCentroid(poly);
for (const hull of solidHulls) {
if (hull.length >= 3 && this._pointInPoly(hull, c)) return true;
}
return false;
};
// 1) Nonzero fill for diameter-inflated glyphs (ㅎ solid + cross, no pie).
if (solidPolys.length) {
ctx.fillStyle = '#ffffff';
ctx.beginPath();
for (const poly of solidPolys) addPoly(poly);
ctx.fill('nonzero');
// 2) Punch counters nested inside a solid glyph.
const holes = evenPolys.filter(isHoleOfSolid);
if (holes.length) {
ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath();
for (const poly of holes) addPoly(poly);
ctx.fill('nonzero');
ctx.globalCompositeOperation = 'source-over';
}
}
// 3) Remaining loops (not used as hole punches) → even-odd.
const rest = evenPolys.filter((p) => !isHoleOfSolid(p));
if (rest.length) {
ctx.fillStyle = '#ffffff';
ctx.beginPath();
for (const poly of rest) addPoly(poly);
ctx.fill('evenodd');
}
const tex = new THREE.CanvasTexture(canvas);
tex.colorSpace = THREE.NoColorSpace;
tex.magFilter = THREE.LinearFilter;
tex.minFilter = THREE.LinearMipmapLinearFilter;
tex.generateMipmaps = true;
tex.needsUpdate = true;
const mat = new THREE.MeshBasicMaterial({
map: tex,
color: new THREE.Color(color),
transparent: true,
alphaTest: 0.4,
side: THREE.DoubleSide,
depthWrite: false,
});
const geo = new THREE.PlaneGeometry(worldW, worldH);
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set((minX + maxX) / 2, (minY + maxY) / 2, 0);
// PlaneGeometry is XY; our 2D world is already XY. No rotation needed.
this._group.add(mesh);
}
// Multi-line MTEXT: one canvas with stacked lines, aligned as a block.
// alignH 0=left 1/4=center 2=right (per line); alignV 3=top 2=middle 0/1=bottom anchor.
_multilineSprite(lines, pos, height, rotation, color, alignH, alignV) {
const fontSize = 96;
const font = `${fontSize}px 'Malgun Gothic', 'Apple SD Gothic Neo', monospace`;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.font = font;
let tw = 1, asc = fontSize * 0.70, desc = fontSize * 0.20;
for (const ln of lines) {
const m = ctx.measureText(ln || ' ');
tw = Math.max(tw, Math.ceil(m.width));
asc = Math.max(asc, m.actualBoundingBoxAscent || fontSize * 0.70);
desc = Math.max(desc, m.actualBoundingBoxDescent || fontSize * 0.20);
}
const pad = Math.ceil(fontSize * 0.15);
const step = Math.ceil(asc + desc + fontSize * 0.35); // baseline-to-baseline
canvas.width = tw + pad * 2;
canvas.height = step * lines.length + pad * 2;
ctx.font = font; // resizing the canvas clears state
const ri = (color >> 16) & 0xFF, gi = (color >> 8) & 0xFF, bi = color & 0xFF;
ctx.fillStyle = `rgb(${ri},${gi},${bi})`;
ctx.textBaseline = 'alphabetic';
let ax = pad;
if (alignH === 1 || alignH === 4) { ctx.textAlign = 'center'; ax = canvas.width / 2; }
else if (alignH === 2) { ctx.textAlign = 'right'; ax = canvas.width - pad; }
else { ctx.textAlign = 'left'; ax = pad; }
for (let i = 0; i < lines.length; i++) ctx.fillText(lines[i], ax, pad + asc + i * step);
const texture = new THREE.CanvasTexture(canvas);
const pxToWorld = height / asc; // one line's cap height == DWG text height
const vw = canvas.width * pxToWorld;
const vh = canvas.height * pxToWorld;
// Anchor the text block so the requested datum lands on pos (block edges).
let ox = 0, oy = 0;
if (alignH === 0 || alignH === 3 || alignH === 5) ox = vw / 2; // left edge at pos.x
else if (alignH === 2) ox = -vw / 2; // right edge at pos.x
if (alignV === 3) oy = -vh / 2; // top edge at pos.y
else if (alignV === 0 || alignV === 1) oy = vh / 2; // bottom edge at pos.y
const mat = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false });
if (rotation) mat.rotation = rotation;
const sprite = new THREE.Sprite(mat);
sprite.scale.set(vw, vh, 1);
sprite.position.set(pos.x + ox, pos.y + oy, 1);
this._group.add(sprite);
}
// MTEXT background mask (opaque fill behind text). Flags (DXF 90):
// 0x01 = use background fill color (63)
// 0x02 = use drawing window color
// 0x10 = text frame only (R2018+)
// bgScale (DXF 45, default 1.5) = border offset factor × text height.
_drawMTextBackground(t, engine, minTextH) {
const flags = t.bgFillFlags | 0;
if (!flags) return;
const useFill = !!(flags & 0x01) || !!(flags & 0x02);
const useFrame = !!(flags & 0x10);
if (!useFill && !useFrame) return;
const height = Math.max(t.height || 2.5, minTextH || 0);
const lines = String(t.text || '').split('\n');
const ls = t.linespacingFactor > 0 ? t.linespacingFactor : 1;
// Match slugText line step (CAD MTEXT default ~ 5/3 of height × factor)
const lineStep = height * (5 / 3) * ls;
let maxEm = 0;
if (engine?.measureEm) {
for (const line of lines) maxEm = Math.max(maxEm, engine.measureEm(line || ' '));
} else {
// ~0.9 em per Hangul/Latin fallback when engine unavailable
for (const line of lines) maxEm = Math.max(maxEm, (line || ' ').length * 0.9);
}
let textW = Math.max(maxEm * height, height * 0.5);
let textH = lines.length <= 1
? height
: height + (lines.length - 1) * lineStep;
if (t.rectWidth > 0) textW = Math.max(textW, t.rectWidth);
if (t.rectHeight > 0) textH = Math.max(textH, t.rectHeight);
const scale = t.bgScale > 0 ? t.bgScale : 1.5;
// Scale factor expands the text bbox (1.5 → 50% larger total).
const boxW = textW * scale;
const boxH = textH * scale;
// Text-center offset from insertion/attachment point (same rules as slug/sprite).
const alignH = t.alignH ?? 1;
const alignV = t.alignV ?? 2;
let lx = 0, ly = 0;
if (alignH === 0 || alignH === 3 || alignH === 5) lx = textW / 2;
else if (alignH === 2) lx = -textW / 2;
if (alignV === 3) ly = -textH / 2;
else if (alignV === 0 || alignV === 1) ly = textH / 2;
const rot = t.rotation || 0;
const cos = Math.cos(rot), sin = Math.sin(rot);
const cx = t.pos.x + lx * cos - ly * sin;
const cy = t.pos.y + lx * sin + ly * cos;
let bgColor = 0xFFFF00; // default yellow if color missing
if (flags & 0x02) {
// Drawing window color — follow viewer theme
const dark = this._scene?.background?.r < 0.5;
bgColor = dark ? 0x0a0b0d : 0xf7f6f3;
} else if (t.bgColorRgb && (t.bgColorRgb.r != null)) {
bgColor = ((t.bgColorRgb.r & 255) << 16) | ((t.bgColorRgb.g & 255) << 8) | (t.bgColorRgb.b & 255);
} else if (t.bgColorIndex != null) {
const hex = aciToHex(t.bgColorIndex);
if (hex != null) bgColor = hex;
}
if (useFill) {
const geo = new THREE.PlaneGeometry(boxW, boxH);
const mat = new THREE.MeshBasicMaterial({
color: new THREE.Color(bgColor),
side: THREE.DoubleSide,
depthWrite: false,
transparent: false,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(cx, cy, 0.4); // behind text (z≈1)
mesh.rotation.z = rot;
this._group.add(mesh);
}
if (useFrame && !useFill) {
// Outline only — four edges as hairline segs (rare path)
const hw = boxW / 2, hh = boxH / 2;
const corners = [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh], [-hw, -hh]];
const world = corners.map(([x, y]) => ({
x: cx + x * cos - y * sin,
y: cy + x * sin + y * cos,
}));
// Use a thin mesh strip ring via Line if we had a helper; simple solid edges:
for (let i = 0; i < 4; i++) {
const a = world[i], b = world[i + 1];
this._widePolyMesh(
[a, b], null, false,
{ constantWidth: Math.max(height * 0.05, 0.01) },
bgColor, 1, null,
);
}
}
}
// Slug vector text (GPU winding-number glyphs, see slugText.ts): one merged
// mesh per load for ALL texts. Async because the TTF loads once on demand;
// falls back to the legacy canvas sprites if the font can't be fetched.
async _drawTexts(texts, minTextH) {
const gen = this._textGen;
try {
const engine = await SlugTextEngine.shared();
if (gen !== this._textGen) return; // a clear()/load() superseded this flush
// Background masks first (under glyphs).
for (const t of texts) {
if ((t.bgFillFlags | 0) !== 0) this._drawMTextBackground(t, engine, minTextH);
}
const batch = new SlugTextBatch(engine);
for (const t of texts) {
batch.add(t.text, t.pos, Math.max(t.height, minTextH), t.rotation || 0, t.color, t.alignH ?? 0, t.alignV ?? 0);
}
const mesh = batch.build();
if (mesh) this._group.add(mesh);
} catch (e) {
console.warn('[Viewer2D] Slug text unavailable, falling back to canvas sprites:', e?.message ?? e);
if (gen !== this._textGen) return;
for (const t of texts) {
if ((t.bgFillFlags | 0) !== 0) this._drawMTextBackground(t, null, minTextH);
this._textSprite(t.text, t.pos, Math.max(t.height, minTextH), t.rotation, t.color, t.alignH ?? 0, t.alignV ?? 0);
}
}
}
// alignH: 0=left, 1=center, 2=right, 4=middle-center. alignV: 0=baseline,1=bottom,2=middle,3=top.
_textSprite(text, pos, height, rotation, color, alignH = 0, alignV = 0) {
// MTEXT paragraph breaks (\P → \n) produce multi-line text: render stacked.
const lines = String(text).split('\n');
if (lines.length > 1) {
this._multilineSprite(lines, pos, height, rotation, color, alignH, alignV);
return;
}
// Texture cache: table drawings repeat the same strings thousands of times
// ("D25", sizes, counts). Rasterizing each occurrence separately OOMs on
// dense sheets (e.g. 6k+ texts) — share one CanvasTexture per (text, color).
// Alignment/rotation are per-sprite (scale/offset/material), not per-raster.
const cacheKey = text + '' + color;
let tex = (this._texCache ??= new Map()).get(cacheKey);
if (!tex) {
const fontSize = 96;
const font = `${fontSize}px 'Malgun Gothic', 'Apple SD Gothic Neo', monospace`;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.font = font;
const m = ctx.measureText(text);
const tw = Math.max(1, Math.ceil(m.width));
// Actual ink extents above/below the baseline (fallback to typical font ratios).
const asc = m.actualBoundingBoxAscent || fontSize * 0.70; // ≈ cap/glyph height
const desc = m.actualBoundingBoxDescent || fontSize * 0.20;
const pad = Math.ceil(fontSize * 0.15);
// Symmetric padding → the ink bounding box stays centered in the canvas.
canvas.width = tw + pad * 2;
canvas.height = Math.ceil(asc + desc) + pad * 2;
ctx.font = font; // reset (resizing the canvas clears state)
const ri = (color >> 16) & 0xFF, gi = (color >> 8) & 0xFF, bi = color & 0xFF;
ctx.fillStyle = `rgb(${ri},${gi},${bi})`;
ctx.textBaseline = 'alphabetic';
ctx.fillText(text, pad, pad + asc);
tex = { texture: new THREE.CanvasTexture(canvas), w: canvas.width, h: canvas.height, asc, pad };
this._texCache.set(cacheKey, tex);
}
const { texture, asc, pad } = tex;
// Size so the visible ascent (≈ DWG cap height) equals the DWG text `height`.
const vh = height * tex.h / asc;
const vw = vh * (tex.w / tex.h);
const pxToWorld = vh / tex.h;
const centerPx = tex.h / 2;
// X: place sprite so the requested horizontal datum lands on pos.x.
let ox = 0;
if (alignH === 0 || alignH === 3 || alignH === 5) ox = vw / 2; // left edge at pos.x
else if (alignH === 1 || alignH === 4) ox = 0; // center at pos.x
else if (alignH === 2) ox = -vw / 2; // right edge at pos.x
// Y: map the alignment datum (in canvas px) to pos.y. Canvas y grows downward,
// world y upward → world offset of a canvas row = (centerPx - rowPx) * pxToWorld.
// ah=4 (DWG "middle" justification) forces both-axis centering regardless of av.
let oy = 0;
if (alignH !== 4) {
if (alignV === 0 || alignV === 1) oy = (pad + asc - centerPx) * pxToWorld; // baseline/bottom datum
else if (alignV === 3) oy = (pad - centerPx) * pxToWorld; // top datum
// alignV === 2 (middle): ink already centered → oy = 0
}
const mat = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false });
if (rotation) mat.rotation = rotation;
const sprite = new THREE.Sprite(mat);
sprite.scale.set(vw, vh, 1);
sprite.position.set(pos.x + ox, pos.y + oy, 1);
this._group.add(sprite);
}
// Render block definition entities transformed by INSERT params
_insertEntities(entities, d, color, pushSeg, pendingTexts, ctx) {
const entsByOwner = ctx?.entsByOwner;
const blockBase = ctx?.blockBase;
const parentXf = ctx?.parentXf ?? null;
const parentScale = ctx?.scale ?? 1;
const parentRot = ctx?.rot ?? 0;
const depth = ctx?.depth ?? 0;
const ip = d.insertionPoint ?? { x: 0, y: 0 };
const base = d.basePoint;
const sx = d.xScale ?? 1, sy = d.yScale ?? 1;
const rot = d.rotation ?? 0;
const cos = Math.cos(rot), sin = Math.sin(rot);
// Local transform: block-local coords → parent-local (or world if top level).
const localXf = (px, py) => {
const lx = base ? px - base.x : px;
const ly = base ? py - base.y : py;
return [
ip.x + (lx * sx) * cos - (ly * sy) * sin,
ip.y + (lx * sx) * sin + (ly * sy) * cos,
];
};
// Compose with parent transform for nested INSERTs.
const xf = parentXf ? (px, py) => parentXf(...localXf(px, py)) : localXf;
// Accumulated uniform scale (for radii) and rotation (for arc/ellipse angles).
const scaleAcc = parentScale * Math.max(Math.abs(sx), Math.abs(sy));
const rotAcc = parentRot + rot;
for (const be of entities) {
const bd = be.data || be;
const bt = (be.type || be.typeName || '').toUpperCase();
if (bt === 'ATTDEF') continue; // skip attribute definitions
const ecol = this._resolveBlockColor(be, color);
try {
if (bt === 'LINE' && bd.start && bd.end) {
const [ax, ay] = xf(bd.start.x, bd.start.y);
const [bx, by] = xf(bd.end.x, bd.end.y);
pushSeg(ax, ay, bx, by, 0, ecol);
} else if ((bt === 'CIRCLE' || bt === 'ARC') && bd.center && bd.radius != null) {
const [cx, cy] = xf(bd.center.x, bd.center.y);
const r = bd.radius * scaleAcc;
const a0 = bt === 'ARC' ? (bd.startAngle ?? 0) + rotAcc : 0;
const a1 = bt === 'ARC' ? (bd.endAngle ?? Math.PI*2) + rotAcc : Math.PI*2;
this._arcSegs({ x:cx, y:cy, z:0 }, r, a0, a1, 0, ecol, pushSeg);
} else if (bt === 'ELLIPSE' && bd.center) {
const [cx, cy] = xf(bd.center.x, bd.center.y);
const ma = bd.majorAxis ?? bd.smAxis ?? { x:1, y:0 };
const mlen = Math.hypot(ma.x ?? 0, ma.y ?? 0) * scaleAcc;
const mang = Math.atan2(ma.y ?? 0, ma.x ?? 1) + rotAcc;
this._ellipseSegs({
center: { x:cx, y:cy, z:0 },
majorAxis: { x: mlen * Math.cos(mang), y: mlen * Math.sin(mang) },
ratio: bd.ratio ?? bd.axisRatio ?? 1,
startParam: bd.startParam ?? 0,
endParam: bd.endParam ?? Math.PI*2,
}, ecol, pushSeg);
} else if ((bt === 'LWPOLYLINE' || bt === 'POLYLINE') && (bd.points || bd.vertices)) {
const raw = bd.points ?? bd.vertices;
const pts = raw.map(p => { const [nx, ny] = xf(p.x, p.y); return { x:nx, y:ny }; });
// Preserve per-vertex bulges through the transform (scalar, not position).
const bulges = bd.bulges ?? null;
const closed = !!(bd.closed || (bd.flags & 1));
// Logo blocks list solid HATCH first, then the same glyph outlines as
// LWPOLYLINE. Re-stroking those glyph outlines draws chords across ㅇ
// holes — skip ONLY true re-outlines: same scale as the covering loop.
// (Not "poly smaller than loop" — that also killed 도곽 안쪽 가는 선
// whose centroid sat inside the outer frame / a large hatch.)
let skipGlyphReoutline = false;
if (pts.length >= 2 && this._pickFills.length) {
let cx = 0, cy = 0, pMinX = Infinity, pMinY = Infinity, pMaxX = -Infinity, pMaxY = -Infinity;
for (const p of pts) {
cx += p.x; cy += p.y;
if (p.x < pMinX) pMinX = p.x; if (p.y < pMinY) pMinY = p.y;
if (p.x > pMaxX) pMaxX = p.x; if (p.y > pMaxY) pMaxY = p.y;
}
cx /= pts.length; cy /= pts.length;
const pw = Math.max(pMaxX - pMinX, 1e-9);
const ph = Math.max(pMaxY - pMinY, 1e-9);
for (const f of this._pickFills) {
if (!this._pointInAnyLoop(f.loops, cx, cy)) continue;
for (const loop of f.loops) {
if (!loop?.length) continue;
let lMinX = Infinity, lMinY = Infinity, lMaxX = -Infinity, lMaxY = -Infinity;
for (const q of loop) {
if (q.x < lMinX) lMinX = q.x; if (q.y < lMinY) lMinY = q.y;
if (q.x > lMaxX) lMaxX = q.x; if (q.y > lMaxY) lMaxY = q.y;
}
const lw = Math.max(lMaxX - lMinX, 1e-9);
const lh = Math.max(lMaxY - lMinY, 1e-9);
// Similar size both ways (re-outline of same glyph/hatch).
const similar =
pw <= lw * 1.5 && ph <= lh * 1.5 &&
lw <= pw * 1.5 && lh <= ph * 1.5;
if (similar) { skipGlyphReoutline = true; break; }
}
if (skipGlyphReoutline) break;
}
}
if (!skipGlyphReoutline) {
const nSeg = closed ? pts.length : pts.length - 1;
// start/end/constant width (도곽 frame etc.) — mesh strip, scaled by INSERT
if (this._polyHasWidth(be, nSeg, scaleAcc)) {
this._widePolyMesh(pts, bulges, closed, be, ecol, scaleAcc, null);
} else {
if (bulges?.some(b => Math.abs(b) >= 1e-6)) this._bulgePolySegs(pts, bulges, closed, ecol, pushSeg);
else this._polylineSegs(pts, closed, 0, ecol, pushSeg);
this._fillClosedPolyIfUncovered(pts, bulges, ecol);
}
}
} else if (bt === 'POLYLINE_2D' && entsByOwner) {
// Old-style 2D polyline inside a block: vertices are separate VERTEX_2D
// entities owned by the polyline (point/bulge at top level).
const kids = (entsByOwner.get(be.handle?.value?.toString(16)) ?? [])
.filter(v => (v.type||'').toUpperCase().startsWith('VERTEX') && v.point);
if (kids.length >= 2) {
const pts = kids.map(v => { const [nx, ny] = xf(v.point.x, v.point.y); return { x:nx, y:ny }; });
const bulges = kids.map(v => v.bulge || 0);
const closed = (bd.flags & 1) === 1;
if (bulges.some(b => Math.abs(b) >= 1e-6)) this._bulgePolySegs(pts, bulges, closed, ecol, pushSeg);
else this._polylineSegs(pts, closed, 0, ecol, pushSeg);
}
} else if (bt === 'HATCH') {
const paths = bd.paths ?? be.paths;
if (paths?.length) {
const solidFill = bd.solidFill ?? be.solidFill ?? false;
const tpaths = paths.map(p => ({
...p,
points: (p.points ?? []).map(pt => { const [nx, ny] = xf(pt.x, pt.y); return { x:nx, y:ny }; }),
}));
// Solid fill: draw fill only (CAD does not render hatch boundaries).
// Pattern hatch: draw boundary outline as a proxy (no pattern support).
if (solidFill) {
this._hatchFill(tpaths, ecol);
} else {
for (const tp of tpaths) {
if ((tp.points?.length ?? 0) < 2) continue;
if (tp.bulges?.some(b => Math.abs(b) >= 1e-6)) this._bulgePolySegs(tp.points, tp.bulges, true, ecol, pushSeg);
else this._polylineSegs(tp.points, true, 0, ecol, pushSeg);
}
}
}
} else if (bt === 'SOLID') {
const crs = [bd.corner1||bd.pt1, bd.corner2||bd.pt2,
bd.corner3||bd.pt3, bd.corner4||bd.pt4].filter(Boolean);
if (crs.length >= 3) {
const mapped = crs.map(p => { const [nx,ny] = xf(p.x, p.y); return {x:nx, y:ny, z:p.z||0}; });
this._solidMesh(mapped, ecol);
}
} else if (bt === 'INSERT' && entsByOwner && depth < 8) {
// Nested block reference: recurse with composed transform.
const bhVal = be.blockHeaderHandle?.value ?? bd.blockHeaderHandle?.value;
if (bhVal != null) {
const hex = bhVal.toString(16);
const childEnts = entsByOwner.get(hex) ?? [];
if (childEnts.length) {
const nip = be.insertionPt ?? bd.insertionPt ?? bd.insertionPoint ?? { x:0, y:0 };
const nsx = bd.scaleX ?? bd.scale?.x ?? be.scale?.x ?? 1;
const nsy = bd.scaleY ?? bd.scale?.y ?? be.scale?.y ?? 1;
const nrot = bd.rotation ?? be.rotation ?? 0;
this._insertEntities(
childEnts,
{ insertionPoint: nip, xScale: nsx, yScale: nsy, rotation: nrot, basePoint: blockBase?.get(hex) },
ecol, pushSeg, pendingTexts,
{ entsByOwner, blockBase, parentXf: xf, scale: scaleAcc, rot: rotAcc, depth: depth + 1 }
);
}
}
} else if ((bt === 'TEXT' || bt === 'MTEXT' || bt === 'ATTRIB' || bt === 'ATTDEF') && pendingTexts) {
if ((bt === 'ATTRIB' || bt === 'ATTDEF') && ((bd.flags ?? 0) & 1)) continue;
const raw = be.text ?? bd.text ?? bd.textValue ?? bd.defaultValue ?? '';
const text = stripMText(raw);
let ah, av, rot = (bd.rotationAngle ?? 0) + rotAcc;
if (bt === 'MTEXT') {
// MTEXT anchors via attachment point 1-9 (1-3 top / 4-6 mid / 7-9
// bottom, columns L/C/R), not horiz/vertAlignment — the fallback
// 0/0 (left-baseline) pushed table cells to the top-right.
const ap = be.attachment ?? bd.attachment ?? be.attachmentPoint ?? bd.attachmentPoint ?? 5;
ah = ([1,4,7].includes(ap) ? 0 : [3,6,9].includes(ap) ? 2 : 1);
av = ([1,2,3].includes(ap) ? 3 : [7,8,9].includes(ap) ? 1 : 2);
const xd = be.xAxisDir ?? bd.xAxisDir;
if (xd) rot = Math.atan2(xd.y, xd.x) + rotAcc;
} else {
ah = bd.horizAlignment ?? be.horizAlignment ?? 0;
av = bd.vertAlignment ?? be.vertAlignment ?? 0;
}
const useAp = bt !== 'MTEXT' && (ah !== 0 || av !== 0);
const alignPt = be.alignmentPt ?? bd.alignmentPt;
const insertPt = be.insertionPt ?? bd.insertionPt ?? bd.insertionPoint;
const tpos = useAp
? (alignPt ?? insertPt ?? {x:0,y:0})
: (insertPt ?? {x:0,y:0});
const [tx, ty] = xf(tpos.x, tpos.y);
const ht = (bd.textHeight ?? bd.height ?? be.textHeight ?? 2.5) * scaleAcc;
if (text) {
const entry = { text, pos:{x:tx, y:ty}, height:ht, rotation:rot, color: ecol, alignH:ah, alignV:av };
if (bt === 'MTEXT') {
entry.linespacingFactor = be.linespacingFactor ?? bd.linespacingFactor ?? 1;
entry.rectWidth = (be.rectWidth ?? bd.rectWidth ?? 0) * scaleAcc;
entry.rectHeight = (be.rectHeight ?? bd.rectHeight ?? 0) * scaleAcc;
entry.bgFillFlags = be.bgFillFlags ?? bd.bgFillFlags ?? 0;
entry.bgScale = be.bgScale ?? bd.bgScale ?? 1.5;
entry.bgColorIndex = be.bgColorIndex ?? bd.bgColorIndex;
entry.bgColorRgb = be.bgColorRgb ?? bd.bgColorRgb;
}
pendingTexts.push(entry);
}
}
} catch { /* skip */ }
}
}
// Fallback dimension rendering from entity properties when no block geometry available
_renderDimFallback(e, d, type, color, pushSeg, pendingTexts, expand) {
const pt10 = e.pt10 ?? d.pt10;
const pt13 = e.pt13 ?? d.pt13;
const pt14 = e.pt14 ?? d.pt14;
const pt15 = e.pt15 ?? d.pt15;
const textMidPt = e.textMidPt ?? d.textMidPt;
const actualMeasurement = e.actualMeasurement ?? d.actualMeasurement ?? 0;
const textRot = e.textRot ?? d.textRot ?? 0;
const dimRot = e.dimRot ?? d.dimRot ?? 0;
const userText = e.userText ?? d.userText ?? '';
const fmtLen = (v) => Math.abs(v) < 1 ? v.toFixed(3) : v.toFixed(2);
const drawLine = (a, b) => {
if (!a || !b) return;
pushSeg(a.x, a.y, b.x, b.y, 0, color);
expand(a.x, a.y); expand(b.x, b.y);
};
const drawArc = (cx, cy, r, sa, ea) => {
this._arcSegs({ x:cx, y:cy }, r, sa, ea, 0, color, pushSeg);
};
const arrow = (pt, rot, sz) => this._arrowMesh(pt, rot, sz, color);
const drawText = (pos, text, height, rot) => {
if (text && pos) pendingTexts.push({ text, pos, height, rotation: rot ?? 0, color, alignH: 1, alignV: 2 });
};
switch (type) {
case 'DIMENSION':
case 'DIMENSION_LINEAR':
case 'DIMENSION_ALIGNED': {
if (!pt13 || !pt14 || !pt10) return;
let d1x, d1y, d2x, d2y;
if (type === 'DIMENSION_ALIGNED') {
const ddx = pt14.x-pt13.x, ddy = pt14.y-pt13.y;
const len = Math.hypot(ddx, ddy);
if (len < 1e-9) return;
const ux = ddx/len, uy = ddy/len;
const half = actualMeasurement / 2;
d1x = pt10.x - ux*half; d1y = pt10.y - uy*half;
d2x = pt10.x + ux*half; d2y = pt10.y + uy*half;
} else {
const isV = Math.abs(Math.sin(dimRot)) > 0.5;
const half = actualMeasurement / 2;
d1x = pt10.x - (isV ? 0 : half); d1y = pt10.y - (isV ? half : 0);
d2x = pt10.x + (isV ? 0 : half); d2y = pt10.y + (isV ? half : 0);
}
const arrowSz = Math.max(actualMeasurement * 0.04, 1);
drawLine({x:d1x,y:d1y}, {x:d2x,y:d2y});
drawLine(pt13, {x:d1x,y:d1y});
drawLine(pt14, {x:d2x,y:d2y});
arrow({x:d1x,y:d1y,z:0}, Math.atan2(d1y-d2y, d1x-d2x), arrowSz);
arrow({x:d2x,y:d2y,z:0}, Math.atan2(d2y-d1y, d2x-d1x), arrowSz);
drawText(textMidPt ?? {x:(d1x+d2x)/2, y:(d1y+d2y)/2}, userText || fmtLen(actualMeasurement), Math.max(arrowSz*1.5,1), textRot);
break;
}
case 'DIMENSION_RADIUS': {
if (!pt10 || !pt15) return;
const arrowSzR = Math.max(actualMeasurement * 0.05, 0.5);
drawLine(pt10, pt15);
arrow({x:pt15.x,y:pt15.y,z:0}, Math.atan2(pt15.y-pt10.y, pt15.x-pt10.x), arrowSzR);
drawText(textMidPt ?? {x:(pt10.x+pt15.x)/2,y:(pt10.y+pt15.y)/2}, userText || `R${fmtLen(actualMeasurement)}`, Math.max(arrowSzR*2,1));
break;
}
case 'DIMENSION_DIAMETER': {
if (!pt10 || !pt15) return;
const opp = {x:2*pt10.x-pt15.x, y:2*pt10.y-pt15.y};
const arrowSzD = Math.max((actualMeasurement/2)*0.05, 0.5);
drawLine(pt15, opp);
arrow({x:pt15.x,y:pt15.y,z:0}, Math.atan2(pt15.y-pt10.y, pt15.x-pt10.x), arrowSzD);
arrow({x:opp.x,y:opp.y,z:0}, Math.atan2(opp.y-pt10.y, opp.x-pt10.x), arrowSzD);
drawText(textMidPt ?? pt10, userText || ${fmtLen(actualMeasurement)}`, Math.max(arrowSzD*2,1));
break;
}
case 'DIMENSION_ANG_3PT':
case 'DIMENSION_ANG_2LN': {
if (!pt10 || !pt13 || !pt14) return;
const vx = pt10.x, vy = pt10.y;
const rr = Math.hypot(pt13.x-vx, pt13.y-vy);
if (rr < 1e-9) return;
let sa = Math.atan2(pt13.y-vy, pt13.x-vx);
let ea = Math.atan2(pt14.y-vy, pt14.x-vx);
let sweep = ea - sa;
while (sweep < 0) sweep += Math.PI*2;
if (sweep > Math.PI) { [sa, ea] = [ea, sa]; sweep = Math.PI*2 - sweep; }
drawArc(vx, vy, rr, sa, sa+sweep);
const arrowSzA = Math.max(rr * 0.05, 0.5);
arrow({x:vx+Math.cos(sa)*rr, y:vy+Math.sin(sa)*rr, z:0}, sa-Math.PI/2, arrowSzA);
arrow({x:vx+Math.cos(sa+sweep)*rr, y:vy+Math.sin(sa+sweep)*rr, z:0}, (sa+sweep)+Math.PI/2, arrowSzA);
const midA = sa + sweep/2;
drawText(textMidPt ?? {x:vx+Math.cos(midA)*rr*1.2, y:vy+Math.sin(midA)*rr*1.2},
userText || `${(actualMeasurement*180/Math.PI).toFixed(1)}°`, Math.max(rr*0.1,1));
break;
}
case 'DIMENSION_ORDINATE': {
if (!pt10 || !pt13) return;
drawLine(pt10, pt13);
drawText(textMidPt ?? pt13, userText || (actualMeasurement?.toFixed(2) ?? ''), 2.5);
break;
}
}
}
_onClick(e) {
// ignore clicks that were really a pan/zoom drag (>5px movement)
if (this._downXY) {
const dx = e.clientX - this._downXY[0], dy = e.clientY - this._downXY[1];
if (Math.hypot(dx, dy) > 5) return;
}
const rect = this._renderer.domElement.getBoundingClientRect();
const ndcX = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const ndcY = -((e.clientY - rect.top) / rect.height) * 2 + 1;
const cam = this._camera;
// unproject through the camera so pan (position) and zoom are accounted for
const v = new THREE.Vector3(ndcX, ndcY, 0).unproject(cam);
const wx = v.x, wy = v.y;
if (this._measureActive) { this._doMeasure2D(wx, wy); return; }
const thresh = CLICK_THRESHOLD_PX * (cam.right - cam.left) / (rect.width * (cam.zoom || 1));
// Geometric pick — raycast the actual rendered geometry, not a bounds proxy.
// 1) Nearest real line segment within the pixel threshold (lines, arcs,
// circles, polylines, block wires, dimension leaders/arrows).
let bestIdx = -1, bestDist = thresh;
const S = this._pickSegs, SM = this._pickSegMeta;
for (let i = 0, j = 0; i < S.length; i += 4, j++) {
const d = this._segDist(wx, wy, S[i], S[i + 1], S[i + 2], S[i + 3]);
if (d < bestDist) { bestDist = d; bestIdx = SM[j]; }
}
// 2) No line under the cursor → a filled region (hatch / SOLID / text box).
// Pick the smallest-area fill so text on top of a hatch wins.
if (bestIdx < 0) {
let bestArea = Infinity;
for (const f of this._pickFills) {
if (this._fillHit(f.loops, wx, wy)) {
const a = this._loopArea(f.loops[0] || []);
if (a < bestArea) { bestArea = a; bestIdx = f.metaIdx; }
}
}
}
const bestMeta = bestIdx >= 0 ? this._entityMeta[bestIdx] : null;
this._highlight(bestMeta);
this._onSelectCb?.(bestMeta ? bestMeta.entity : null);
}
// Distance from point (px,py) to segment (ax,ay)-(bx,by).
_segDist(px, py, ax, ay, bx, by) {
const dx = bx - ax, dy = by - ay;
const len2 = dx * dx + dy * dy;
if (!len2) return Math.hypot(px - ax, py - ay);
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2));
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
}
startMeasure(cb) {
this.stopMeasure();
this._measureCb = cb;
this._measureActive = true;
}
stopMeasure() {
this._measureActive = false;
this._measureCb = null;
this._measurePtA = null;
for (const m of this._measureMarkers) {
this._group.remove(m);
m.geometry?.dispose();
m.material?.dispose();
}
this._measureMarkers = [];
}
_doMeasure2D(wx, wy) {
if (!this._measurePtA) {
this._measurePtA = { x: wx, y: wy };
const mk = this._mkMeasureMarker(wx, wy);
this._measureMarkers.push(mk);
this._group.add(mk);
this._measureCb?.({ phase: 'a', ax: wx, ay: wy, az: 0, bx: wx, by: wy, bz: 0, d: 0 });
} else {
const a = this._measurePtA;
const d = Math.hypot(wx - a.x, wy - a.y);
const mkB = this._mkMeasureMarker(wx, wy);
const ln = this._mkMeasureLine(a.x, a.y, wx, wy);
this._measureMarkers.push(mkB, ln);
this._group.add(mkB, ln);
this._measureCb?.({ phase: 'done', ax: a.x, ay: a.y, az: 0, bx: wx, by: wy, bz: 0, d });
this._measurePtA = null;
}
}
_mkMeasureMarker(x, y) {
const cam = this._camera;
const rect = this._renderer.domElement.getBoundingClientRect();
const s = ((cam.right - cam.left) / (rect.width * (cam.zoom || 1))) * 8;
const pts = [
new THREE.Vector3(x - s, y, 0), new THREE.Vector3(x + s, y, 0),
new THREE.Vector3(x, y - s, 0), new THREE.Vector3(x, y + s, 0),
];
const geo = new THREE.BufferGeometry().setFromPoints(pts);
geo.setIndex([0, 1, 2, 3]);
const mat = new THREE.LineBasicMaterial({ color: 0xe63030, depthTest: false });
const m = new THREE.LineSegments(geo, mat);
m.renderOrder = 100;
return m;
}
_mkMeasureLine(x1, y1, x2, y2) {
const geo = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(x1, y1, 0), new THREE.Vector3(x2, y2, 0),
]);
const mat = new THREE.LineBasicMaterial({ color: 0xe63030, depthTest: false });
const m = new THREE.Line(geo, mat);
m.renderOrder = 100;
return m;
}
/** Recolor the selected entity's line geometry to the accent color (deselect previous). */
_highlight(meta) {
const attr = this._lineColorAttr, orig = this._origColors;
if (this._selMeta && attr && orig) {
const a = this._selMeta;
for (let i = a.colStart; i < a.colEnd && i < orig.length; i++) attr.array[i] = orig[i];
}
this._selMeta = meta || null;
if (meta && attr && meta.colEnd > meta.colStart) {
const c = this._selColor;
for (let i = meta.colStart; i < meta.colEnd; i += 3) {
attr.array[i] = c.r; attr.array[i + 1] = c.g; attr.array[i + 2] = c.b;
}
}
if (attr) attr.needsUpdate = true;
}
/** Selection highlight color (keeps in sync with the UI accent). */
setAccent(hex) {
this._selColor = {
r: parseInt(hex.slice(1, 3), 16) / 255,
g: parseInt(hex.slice(3, 5), 16) / 255,
b: parseInt(hex.slice(5, 7), 16) / 255,
};
if (this._selMeta) { const m = this._selMeta; this._selMeta = null; this._highlight(m); }
}
_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);
if (b.type === 'line') {
const dx = b.x2-b.x1, dy = b.y2-b.y1;
const len2 = dx*dx + dy*dy;
if (!len2) return Math.hypot(wx - b.x1, wy - b.y1);
const t = Math.max(0, Math.min(1, ((wx-b.x1)*dx + (wy-b.y1)*dy) / len2));
return Math.hypot(wx - (b.x1+t*dx), wy - (b.y1+t*dy));
}
return Infinity;
}
_fit(box) {
const c = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const w = this._container.clientWidth || 800;
const h = this._container.clientHeight || 600;
const aspect = w / h;
let vw = Math.max(size.x, 1) * 1.1;
let vh = Math.max(size.y, 1) * 1.1;
if (vw / vh > aspect) vh = vw / aspect; else vw = vh * aspect;
this._camera.zoom = 1;
this._camera.left = -vw/2; this._camera.right = vw/2;
this._camera.top = vh/2; this._camera.bottom = -vh/2;
this._camera.position.set(c.x, c.y, 100);
// Un-twist the sheet: rotate the view (camera up) by -viewTwist.
const a = -(this._viewTwist || 0);
this._camera.up.set(-Math.sin(a), Math.cos(a), 0);
this._camera.updateProjectionMatrix();
this._controls.target.set(c.x, c.y, 0);
this._controls.update();
}
_clear() {
for (let i = this._group.children.length - 1; i >= 0; i--) {
const o = this._group.children[i];
o.geometry?.dispose();
if (o.material) { o.material.map?.dispose(); o.material.dispose(); }
this._group.remove(o);
}
// Shared text textures were disposed via the sprites above (re-dispose is a
// no-op); drop the cache so the next load rasterizes fresh.
this._texCache?.clear();
this._textGen++; // invalidate any in-flight async text flush
}
_onResize() {
const w = this._container.clientWidth, h = this._container.clientHeight;
if (!w || !h) return;
this._renderer.setSize(w, h);
// Frustum planes are CAMERA-LOCAL (relative to camera.position), not world coords.
// Just preserve half-widths and re-adjust aspect — camera position tracks panning via OrbitControls.
const halfW = (this._camera.right - this._camera.left) / 2;
const halfH = halfW / (w / h);
this._camera.left = -halfW; this._camera.right = halfW;
this._camera.top = halfH; this._camera.bottom = -halfH;
this._camera.updateProjectionMatrix();
}
_animate() {
requestAnimationFrame(() => this._animate());
this._controls.update();
this._renderer.render(this._scene, this._camera);
}
}