Files
dwg-dxf-viewer-sample/src/viewer2d/Viewer2D.js
T

3220 lines
145 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 { buildAllowedOwnerHexes, listCadSpaces } from './cadSpaces';
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._renderer.domElement.addEventListener('pointermove', (e) => this._onPointerMove(e));
this._renderer.domElement.addEventListener('pointerleave', () => {
if (this._onPointerWorldCb) this._onPointerWorldCb(null);
});
this._selColor = { r: 0xd8 / 255, g: 0x3a / 255, b: 0x2f / 255 };
this._gridVisible = false;
this._gridMesh = null;
/** @type {string|null} active Model/Paper space handle hex; null = no space filter */
this._activeSpaceHex = null;
/** @type {null|((p:{x:number,y:number,z:number}|null)=>void)} */
this._onPointerWorldCb = null;
this._ptrWorldTmp = new THREE.Vector3();
// Display frame for coordinates (Model UCS vs WCS). Paper uses identity.
this._coordOrigin = { x: 0, y: 0, z: 0 };
this._coordXAxis = { x: 1, y: 0, z: 0 };
this._coordYAxis = { x: 0, y: 1, z: 0 };
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; }
/**
* Subscribe to mouse world-coordinate updates (CAD WCS / drawing units).
* Callback receives `{ x, y, z }` or `null` when the pointer leaves the canvas.
*/
onPointerWorld(cb) { this._onPointerWorldCb = cb || null; }
/**
* Convert browser client coords → **CAD display** coordinates (Z=0 plane).
* Model Space: model UCS (matches AutoCAD status bar when UCS = drawing UCS).
* Paper/Layout: paper drawing units (identity).
* Pick/measure still use raw WCS via `_screenToWcs`.
*/
screenToWorld(clientX, clientY) {
const wcs = this._screenToWcs(clientX, clientY);
if (!wcs) return null;
return this._wcsToCad(wcs.x, wcs.y);
}
/**
* Raw WCS (geometry storage frame) under the cursor.
* Ortho 2D: map NDC through camera right/up axes (handles view-twist up vector).
*/
_screenToWcs(clientX, clientY) {
const el = this._renderer?.domElement;
const cam = this._camera;
if (!el || !cam) return null;
const rect = el.getBoundingClientRect();
if (rect.width < 1 || rect.height < 1) return null;
cam.updateMatrixWorld(true);
const ndcX = ((clientX - rect.left) / rect.width) * 2 - 1;
const ndcY = -((clientY - rect.top) / rect.height) * 2 + 1;
// Orthographic frustum half-sizes in world units (respect zoom).
const zoom = cam.zoom || 1;
const halfW = ((cam.right - cam.left) * 0.5) / zoom;
const halfH = ((cam.top - cam.bottom) * 0.5) / zoom;
// Camera basis in world (columns of matrixWorld).
const te = cam.matrixWorld.elements;
const rx = te[0], ry = te[1]; // camera local +X (right)
const ux = te[4], uy = te[5]; // camera local +Y (up)
// Anchor on the look target (z=0 plane), not camera.position.z
const tx = this._controls?.target?.x ?? cam.position.x;
const ty = this._controls?.target?.y ?? cam.position.y;
return {
x: tx + ndcX * halfW * rx + ndcY * halfH * ux,
y: ty + ndcX * halfW * ry + ndcY * halfH * uy,
z: 0,
};
}
/** WCS → CAD display (model UCS when axes are available). */
_wcsToCad(wx, wy) {
const o = this._coordOrigin || { x: 0, y: 0 };
const xx = this._coordXAxis || { x: 1, y: 0 };
const yy = this._coordYAxis || { x: 0, y: 1 };
const dx = wx - (o.x || 0);
const dy = wy - (o.y || 0);
// Orthogonal axes: project onto X/Y directions (identity → subtract origin).
return {
x: dx * (xx.x || 0) + dy * (xx.y || 0),
y: dx * (yy.x || 0) + dy * (yy.y || 0),
z: 0,
};
}
/** Configure coordinate readout frame for the active space. */
_setCoordFrame(result) {
const spaces = listCadSpaces(result);
const active = spaces.find((s) => s.handleHex === this._activeSpaceHex);
const vars = result?.vars || {};
// Model: AutoCAD status bar uses model UCS origin/axes when set.
// Paper: sheet coordinates — identity.
if (active?.kind === 'model' && vars.ucsOrigin) {
this._coordOrigin = {
x: vars.ucsOrigin.x || 0,
y: vars.ucsOrigin.y || 0,
z: vars.ucsOrigin.z || 0,
};
this._coordXAxis = vars.ucsXAxis || { x: 1, y: 0, z: 0 };
this._coordYAxis = vars.ucsYAxis || { x: 0, y: 1, z: 0 };
} else {
this._coordOrigin = { x: 0, y: 0, z: 0 };
this._coordXAxis = { x: 1, y: 0, z: 0 };
this._coordYAxis = { x: 0, y: 1, z: 0 };
}
}
_onPointerMove(e) {
if (!this._onPointerWorldCb) return;
const p = this.screenToWorld(e.clientX, e.clientY);
this._onPointerWorldCb(p);
}
/**
* Zoom extents to visible entity AABB (space/layer filtered).
* Prefer the load-time content box (same filter as draw); recompute from
* scene if missing. Percentile-trim drops far junk verts.
*/
fit() {
// Prefer AABB recorded while drawing (matches visible entities exactly).
if (this._contentBox && !this._contentBox.isEmpty()) {
this._fit(this._contentBox);
return;
}
const box = new THREE.Box3();
const samples = [];
this._group.traverse((obj) => {
if (!obj.geometry) return;
box.expandByObject(obj);
const pos = obj.geometry.attributes?.position;
if (!pos?.array) return;
const arr = pos.array;
const step = Math.max(3, Math.floor(arr.length / 3 / 4000) * 3);
for (let i = 0; i + 1 < arr.length; i += step) {
if (Number.isFinite(arr[i]) && Number.isFinite(arr[i + 1])) samples.push(arr[i], arr[i + 1]);
}
});
if (box.isEmpty()) return;
const use = this._trimmedContentBox(samples, box) || box;
this._contentBox = use.clone();
this._fit(use);
}
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;
// Model vs Paper (Layout): never draw both spaces at once.
// - opts.spaceHandle provided (including null) → set active space
// - omitted → keep previous _activeSpaceHex if still valid for this file
if (Object.prototype.hasOwnProperty.call(opts, 'spaceHandle')) {
const sh = opts.spaceHandle;
if (sh == null || sh === '') this._activeSpaceHex = null;
else this._activeSpaceHex = typeof sh === 'number' ? sh.toString(16) : String(sh).toLowerCase();
}
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;
// If active space is set but no longer present (new file), clear it.
if (this._activeSpaceHex && spaceHandles.size && !spaceHandles.has(this._activeSpaceHex)) {
this._activeSpaceHex = null;
}
// Owner closure for ATTRIB under INSERT in the active space, etc.
const allowedOwners = this._activeSpaceHex
? buildAllowedOwnerHexes(entities, this._activeSpaceHex)
: null;
// 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);
}
}
// AABB of whatever is actually drawn (current Model/Layout + layers).
// Samples feed percentile trim so Fit matches CAD extents (ignore 12 junk verts).
const fitSamples = [];
let fitSampleN = 0;
const expand = (x, y, z = 0) => {
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
box.expandByPoint(_tmp.set(x, y, z));
fitSampleN++;
if ((fitSampleN & 7) === 0 && fitSamples.length < 100000) fitSamples.push(x, y);
};
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);
};
// ── Layout draw order ────────────────────────────────────────────────
// AutoCAD: model shown through VIEWPORT sits *under* paper-space entities
// (title block, notes, borders). We used to append viewport geometry after
// the paper pass → VP content painted on top. Fix: emit VP model first
// (z slightly behind) so paper entities and paper text overdraw it.
//
// DWG draw-order: acadrust has SORTENTSTABLE (ACAD_SORTENTS) + SORTENTS
// header flag, but dwg-wasm parseResult does not export them yet — so we
// apply the standard Layout stacking: viewport content below paper.
const VIEWPORT_Z = -1; // camera looks from +Z; lower Z is behind
const spacesList = listCadSpaces(result);
const activeSp = spacesList.find((s) => s.handleHex === this._activeSpaceHex);
if (activeSp?.kind === 'paper') {
const modelSp = spacesList.find((s) => s.kind === 'model');
if (modelSp) {
const modelAllowed = buildAllowedOwnerHexes(entities, modelSp.handleHex);
const viewports = entities.filter((e) => {
if ((e.type || e.typeName || '').toUpperCase() !== 'VIEWPORT') return false;
const oh = e.ownerHandle?.value?.toString(16);
return oh === this._activeSpaceHex && this._isModelViewport(e);
});
const vpPush = (ax, ay, bx, by, _z, color) => {
pushSeg(ax, ay, bx, by, VIEWPORT_Z, color);
};
const vpExpand = (x, y, _z = 0) => expand(x, y, VIEWPORT_Z);
for (const vp of viewports) {
this._emitModelThroughViewport(vp, {
entities,
modelAllowed,
blockDefHandles,
entsByOwner,
blockBaseByHex,
blockNameByHex,
pushSeg: vpPush,
expand: vpExpand,
pendingTexts,
box,
_tmp,
underlay: true, // tag pending texts drawn under paper labels
});
}
}
}
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;
// Model/Paper filter: only entities belonging to the active space (and
// their INSERT children such as ATTRIB). Other space geometry is hidden.
if (allowedOwners && ownerHex && !allowedOwners.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;
}
// ── OLE2FRAME (Embedded OLE Object) ──────────────────────────────
case 'OLE2FRAME': {
const ul = e.upperLeft ?? d.upperLeft ?? { x: 0, y: 0, z: 0 };
const lr = e.lowerRight ?? d.lowerRight ?? { x: 0, y: 0, z: 0 };
const appName = e.sourceApp ?? d.sourceApp ?? e.source_application ?? '';
const imageDataUrl = e.imageDataUrl ?? d.imageDataUrl;
// Use actual entity bounds from the WASM parser (upper_left / lower_right)
let minX = Math.min(ul.x, lr.x);
let maxX = Math.max(ul.x, lr.x);
let minY = Math.min(ul.y, lr.y);
let maxY = Math.max(ul.y, lr.y);
let width = maxX - minX;
let height = maxY - minY;
const z = ul.z || 0;
// Always draw border rectangle
pushSeg(minX, minY, maxX, minY, z, color);
pushSeg(maxX, minY, maxX, maxY, z, color);
pushSeg(maxX, maxY, minX, maxY, z, color);
pushSeg(minX, maxY, minX, minY, z, color);
// If embedded image texture (BMP/DIB) is present, render it as a 3D Plane Mesh!
if (imageDataUrl) {
try {
// Pure-JS BMP decoder — works in ALL browsers (Brave/Firefox don't support data:image/bmp in <img>)
const decodeBmpDataUrl = (dataUrl) => {
const b64 = dataUrl.split(',')[1];
const bin = atob(b64);
const buf = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
const dv = new DataView(buf.buffer);
// BITMAPFILEHEADER (14 bytes)
const sig = String.fromCharCode(buf[0], buf[1]);
if (sig !== 'BM') throw new Error('Not a BMP file');
const pixelOffset = dv.getUint32(10, true);
// BITMAPINFOHEADER (40 bytes, starts at offset 14)
const bmpWidth = dv.getInt32(18, true);
const bmpHeight = dv.getInt32(22, true); // positive = bottom-up
const bitCount = dv.getUint16(28, true);
const absH = Math.abs(bmpHeight);
const flipY = bmpHeight > 0; // bottom-up storage
const cvs = document.createElement('canvas');
cvs.width = bmpWidth;
cvs.height = absH;
const ctx2 = cvs.getContext('2d');
const imgData = ctx2.createImageData(bmpWidth, absH);
const px = imgData.data;
if (bitCount === 32) {
const bpr = bmpWidth * 4;
for (let y = 0; y < absH; y++) {
const srcRow = flipY ? (absH - 1 - y) : y;
const srcOff = pixelOffset + srcRow * bpr;
const dstOff = y * bmpWidth * 4;
for (let x = 0; x < bmpWidth; x++) {
const s = srcOff + x * 4;
const d = dstOff + x * 4;
px[d] = buf[s + 2]; // R (BMP stores BGR)
px[d + 1] = buf[s + 1]; // G
px[d + 2] = buf[s]; // B
px[d + 3] = 255; // A — GDI 32bpp has zeroed alpha padding
}
}
} else if (bitCount === 24) {
const stride = Math.ceil(bmpWidth * 3 / 4) * 4; // row padded to 4 bytes
for (let y = 0; y < absH; y++) {
const srcRow = flipY ? (absH - 1 - y) : y;
const srcOff = pixelOffset + srcRow * stride;
const dstOff = y * bmpWidth * 4;
for (let x = 0; x < bmpWidth; x++) {
const s = srcOff + x * 3;
const d = dstOff + x * 4;
px[d] = buf[s + 2];
px[d + 1] = buf[s + 1];
px[d + 2] = buf[s];
px[d + 3] = 255;
}
}
}
ctx2.putImageData(imgData, 0, 0);
return cvs;
};
const bmpCanvas = decodeBmpDataUrl(imageDataUrl);
const texture = new THREE.CanvasTexture(bmpCanvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.needsUpdate = true;
const planeGeom = new THREE.PlaneGeometry(width, Math.abs(height));
const planeMat = new THREE.MeshBasicMaterial({
map: texture,
side: THREE.DoubleSide,
transparent: false,
});
const mesh = new THREE.Mesh(planeGeom, planeMat);
mesh.position.set(minX + width / 2, minY + Math.abs(height) / 2, z + 10);
this._group.add(mesh);
this._renderer.render(this._scene, this._camera);
} catch (err) {
console.error('Failed rendering CanvasTexture for OLE image:', err);
}
} else {
// Draw diagonal cross + overlay label fallback
pushSeg(minX, minY, maxX, maxY, z, color);
pushSeg(minX, maxY, maxX, minY, z, color);
const hSpan = Math.abs(height);
const lblHeight = Math.max(Math.min(hSpan * 0.05, 300), 40);
const labelText = appName ? `[OLE: ${appName}]` : '[OLE2Frame]';
const labelPos = { x: (minX + maxX) / 2, y: (minY + maxY) / 2, z };
pendingTexts.push({
text: labelText,
pos: labelPos,
height: lblHeight,
rotation: 0,
color,
alignH: 1,
alignV: 2,
});
}
// Expand scene bounds & set meta bounds for picking/fitting
expand(minX, minY, z);
expand(maxX, maxY, z);
meta.bounds = { type: 'point', cx: (minX + maxX) / 2, cy: (minY + maxY) / 2 };
break;
}
// Paper-space VIEWPORT: drawn later as a model-space projection window.
case 'VIEWPORT':
break;
default: break;
}
} catch { /* skip malformed entity */ }
meta.colEnd = lineColors.length;
}
// ── Render deferred text (underlay / paper stack) ─────────────────────
// Viewport-sourced labels were pushed first (underlay:true); paper labels
// follow. Draw underlay first so paper text meshes land later = on top.
if (pendingTexts.length) {
// Historical floor: diag×0.0002 made paper-sheet labels readable at
// zoom-extents. On survey Model Space (diag ~1e6 m) that floor is
// ~200300 drawing units while native TEXT height is ~0.33.5 → every
// label becomes a giant billboard over the contours (MOT/FLOW etc.).
// Cap the floor at the median native height so we never upscale past CAD.
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 heights = [];
for (const t of pendingTexts) {
const h = t.height;
if (h > 0 && Number.isFinite(h)) heights.push(h);
}
heights.sort((a, b) => a - b);
const medianH = heights.length ? heights[heights.length >> 1] : 2.5;
const minTextH = Math.min(diagSize * 0.0002, medianH);
// 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 underlay = pendingTexts.filter((t) => t.underlay);
const overlay = pendingTexts.filter((t) => !t.underlay);
const ordered = underlay.concat(overlay);
const renderTexts = ordered.length > MAX_TEXTS
? (console.warn(`텍스트 ${ordered.length}개 → ${MAX_TEXTS}개로 제한`),
// Prefer keeping paper (overlay) labels when capping
overlay.concat(underlay).sort((a, b) => b.height - a.height).slice(0, MAX_TEXTS))
: ordered;
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);
}
// Coordinate readout frame (Model UCS vs paper identity).
this._setCoordFrame(result);
// Visible-content AABB for Fit — trim extreme outliers (matches CAD zoom extents better).
const fitBox = box.isEmpty() ? null : (this._trimmedContentBox(fitSamples, box) || box);
this._contentBox = fitBox ? fitBox.clone() : null;
if (!opts.keepView && fitBox && !fitBox.isEmpty()) this._fit(fitBox);
}
// ── 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 });
}
/**
* Model / Paper (Layout) spaces present in the last loaded result.
* @returns {import('./cadSpaces').CadSpace[]}
*/
getSpaces() {
return listCadSpaces(this._lastResult);
}
/** Currently active space handle hex, or null if unfiltered. */
getActiveSpace() {
return this._activeSpaceHex;
}
/**
* Switch Model ↔ Layout (paper) space and re-render.
* @param {string|number|null} spaceHandle hex string or numeric handle; null clears filter
* @param {{ keepView?: boolean }} [opts]
*/
setSpace(spaceHandle, opts = {}) {
if (spaceHandle == null || spaceHandle === '') {
this._activeSpaceHex = null;
} else if (typeof spaceHandle === 'number') {
this._activeSpaceHex = spaceHandle.toString(16);
} else {
this._activeSpaceHex = String(spaceHandle).toLowerCase();
}
if (this._lastResult) {
this.load(this._lastResult, {
keepView: !!opts.keepView,
spaceHandle: this._activeSpaceHex,
});
// load() refreshes coord frame for Model UCS vs paper identity
}
}
/**
* True if this VIEWPORT is a window into model space (not the overall paper VP).
* R2000 often stores id=0 for all VPs, so we detect the overall paper viewport by
* 1:1 scale + viewTarget≈0 + viewCenter≈paper center.
*/
_isModelViewport(vp) {
if (!vp) return false;
if (vp.status && vp.status.isOn === false) return false;
const id = vp.id ?? 0;
// Classic DXF: id 1 = overall paper-space viewport (not a model window).
if (id === 1) return false;
const H = Number(vp.height) || 0;
const vH = Number(vp.viewHeight) || 0;
if (H <= 0 || vH <= 0) return false;
const scale = H / vH;
const c = vp.center || {};
const vc = vp.viewCenter || {};
const vt = vp.viewTarget || {};
const targetNear0 = Math.hypot(vt.x || 0, vt.y || 0) < 1e-3;
const viewCtrNearPaper = Math.hypot((vc.x || 0) - (c.x || 0), (vc.y || 0) - (c.y || 0)) < Math.max(H, 1) * 0.05;
// Overall paper VP: identity mapping of the sheet onto itself.
if (Math.abs(scale - 1) < 0.03 && targetNear0 && viewCtrNearPaper) return false;
return true;
}
/**
* Model WCS (x,y) → paper-space point for a VIEWPORT (orthographic / top).
* DCS = R(+twist) * (model - viewTarget) [matches acadrust/ODA view-center convention]
* paper = center + scale * (DCS - viewCenter)
* scale = paperHeight / viewHeight
*/
_viewportModelToPaper(vp) {
const c = vp.center || { x: 0, y: 0 };
const vc = vp.viewCenter || { x: 0, y: 0 };
const vt = vp.viewTarget || { x: 0, y: 0 };
const H = Number(vp.height) || 1;
const vH = (Number(vp.viewHeight) > 1e-12) ? Number(vp.viewHeight) : H;
const scale = H / vH;
const twist = Number(vp.twistAngle) || 0;
const cos = Math.cos(twist);
const sin = Math.sin(twist);
const fn = (mx, my) => {
const dx = mx - (vt.x || 0);
const dy = my - (vt.y || 0);
// R(+twist) · [dx, dy]
const rx = cos * dx - sin * dy;
const ry = sin * dx + cos * dy;
return {
x: (c.x || 0) + (rx - (vc.x || 0)) * scale,
y: (c.y || 0) + (ry - (vc.y || 0)) * scale,
};
};
fn.scale = scale;
fn.twist = twist;
return fn;
}
_viewportPaperClip(vp) {
const c = vp.center || { x: 0, y: 0 };
const hw = (Number(vp.width) || 0) / 2;
const hh = (Number(vp.height) || 0) / 2;
// Slight expand to avoid hairline gaps at edges
const pad = Math.max(hw, hh) * 1e-6;
return {
minX: (c.x || 0) - hw - pad,
maxX: (c.x || 0) + hw + pad,
minY: (c.y || 0) - hh - pad,
maxY: (c.y || 0) + hh + pad,
};
}
/** CohenSutherland clip; calls out(x1,y1,x2,y2) for the visible segment (if any). */
_clipSegToRect(x1, y1, x2, y2, rect, out) {
const INSIDE = 0, LEFT = 1, RIGHT = 2, BOTTOM = 4, TOP = 8;
const code = (x, y) => {
let c = INSIDE;
if (x < rect.minX) c |= LEFT;
else if (x > rect.maxX) c |= RIGHT;
if (y < rect.minY) c |= BOTTOM;
else if (y > rect.maxY) c |= TOP;
return c;
};
let c1 = code(x1, y1), c2 = code(x2, y2);
for (;;) {
if (!(c1 | c2)) { out(x1, y1, x2, y2); return; }
if (c1 & c2) return;
const c = c1 || c2;
let x = 0, y = 0;
if (c & TOP) {
x = x1 + (x2 - x1) * (rect.maxY - y1) / (y2 - y1 || 1e-30);
y = rect.maxY;
} else if (c & BOTTOM) {
x = x1 + (x2 - x1) * (rect.minY - y1) / (y2 - y1 || 1e-30);
y = rect.minY;
} else if (c & RIGHT) {
y = y1 + (y2 - y1) * (rect.maxX - x1) / (x2 - x1 || 1e-30);
x = rect.maxX;
} else {
y = y1 + (y2 - y1) * (rect.minX - x1) / (x2 - x1 || 1e-30);
x = rect.minX;
}
if (c === c1) { x1 = x; y1 = y; c1 = code(x1, y1); }
else { x2 = x; y2 = y; c2 = code(x2, y2); }
}
}
/**
* Emit model-space geometry into a paper VIEWPORT (transformed + clipped).
* Covers the entity types that dominate civil plan/profile sheets.
*/
_emitModelThroughViewport(vp, ctx) {
const {
entities, modelAllowed, blockDefHandles, entsByOwner, blockBaseByHex,
pushSeg: basePush, expand: baseExpand, pendingTexts,
underlay = false,
} = ctx;
const xf = this._viewportModelToPaper(vp);
const clip = this._viewportPaperClip(vp);
const scale = xf.scale || 1;
const frozen = new Set((vp.frozenLayers || []).map((h) => String(h)));
const pushSeg = (ax, ay, bx, by, z, color) => {
const a = xf(ax, ay);
const b = xf(bx, by);
this._clipSegToRect(a.x, a.y, b.x, b.y, clip, (x1, y1, x2, y2) => {
basePush(x1, y1, x2, y2, z || 0, color);
});
};
const expand = (x, y, z = 0) => {
const p = xf(x, y);
if (p.x >= clip.minX && p.x <= clip.maxX && p.y >= clip.minY && p.y <= clip.maxY) {
baseExpand(p.x, p.y, z);
}
};
const inClip = (x, y) => x >= clip.minX && x <= clip.maxX && y >= clip.minY && y <= clip.maxY;
// Quick reject: model bbox far from view focus (optional optimisation skip for correctness)
for (const e of entities) {
const ownerHex = e.ownerHandle?.value?.toString(16);
if (!ownerHex || !modelAllowed.has(ownerHex)) continue;
if (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;
}
if (frozen.size) {
const lh = e.layerHandle?.value;
if (lh != null && frozen.has(String(lh))) continue;
}
const d = e.data || e;
const type = (e.type || e.typeName || '').toUpperCase();
const color = this._entityColor(e);
try {
switch (type) {
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);
}
break;
case 'CIRCLE':
if (d.center && d.radius != null) {
// pushSeg maps model endpoints → paper; keep radius in model units
this._arcSegs(d.center, d.radius, 0, Math.PI * 2, d.center.z || 0, color, pushSeg);
}
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);
}
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));
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));
}
}
break;
case 'POLYLINE':
this._polylineSegs(d.vertices || d.points, d.closed || (d.flags & 1), 0, color, pushSeg);
break;
case 'ELLIPSE':
if (d.center) {
// Reuse main-path ellipse helper if present; else approximate via parametric segs
if (typeof this._ellipseSegs === 'function') {
this._ellipseSegs(d, color, pushSeg);
} else {
const cx = d.center.x, cy = d.center.y;
const maj = d.smAxis || d.majorAxis || { x: d.radius || 1, y: 0 };
const ratio = d.axisRatio ?? d.minorAxisRatio ?? 0.5;
const a0 = d.startAngle ?? d.startParameter ?? 0;
const a1 = d.endAngle ?? d.endParameter ?? Math.PI * 2;
const n = 64;
let px, py;
for (let i = 0; i <= n; i++) {
const t = a0 + (a1 - a0) * (i / n);
const cos = Math.cos(t), sin = Math.sin(t);
const x = cx + maj.x * cos - maj.y * ratio * sin;
const y = cy + maj.y * cos + maj.x * ratio * sin;
if (i > 0) pushSeg(px, py, x, y, 0, color);
px = x; py = y;
}
}
}
break;
case 'SOLID': {
const corners = [d.corner1 || d.pt1, d.corner2 || d.pt2, d.corner3 || d.pt3, d.corner4 || d.pt4].filter(Boolean);
if (corners.length >= 2) {
for (let i = 0; i < corners.length; i++) {
const a = corners[i], b = corners[(i + 1) % corners.length];
if (a && b) pushSeg(a.x, a.y, b.x, b.y, 0, color);
}
}
break;
}
case 'HATCH': {
const paths = d.paths || e.paths || [];
for (const path of paths) {
const pts = path.points || path.vertices;
if (!pts?.length) continue;
const closed = path.closed !== false;
const bulges = path.bulges;
if (bulges?.some((b) => Math.abs(b) >= 1e-6)) {
this._bulgePolySegs(pts, bulges, closed, color, pushSeg, expand);
} else {
this._polylineSegs(pts, closed, 0, color, pushSeg);
pts.forEach((p) => expand(p.x, p.y));
}
}
break;
}
case 'SPLINE': {
const pts = d.fitPoints?.length ? d.fitPoints : d.controlPoints;
if (pts?.length >= 2) this._polylineSegs(pts, !!d.closed, 0, color, pushSeg);
break;
}
case 'TEXT':
case 'MTEXT': {
const raw = e.text ?? d.text ?? '';
const text = stripMText(raw);
if (!text) break;
const height = (e.textHeight ?? e.height ?? d.textHeight ?? d.height ?? 2.5) * scale;
// Geometry uses R(+twist); text angle in paper is modelAngle + twist.
let rotation = (e.rotationAngle ?? d.rotationAngle ?? 0) + (xf.twist || 0);
let alignH = 0, alignV = 0, pos;
if (type === 'TEXT') {
alignH = e.horizAlignment ?? d.horizAlignment ?? 0;
alignV = e.vertAlignment ?? d.vertAlignment ?? 0;
if (alignH === 4) alignV = 2;
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) + (xf.twist || 0);
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 };
}
const pp = xf(pos.x, pos.y);
if (!inClip(pp.x, pp.y)) break;
pendingTexts.push({
text, pos: pp, height, rotation, color, alignH, alignV,
underlay: !!underlay,
});
expand(pos.x, pos.y);
break;
}
case 'INSERT': {
const ip = e.insertionPt ?? d.insertionPt ?? d.insertionPoint;
if (!ip) break;
const sx = (d.scaleX ?? d.scale?.x ?? e.scale?.x ?? 1);
const sy = (d.scaleY ?? d.scale?.y ?? e.scale?.y ?? 1);
const bhVal = e.blockHeaderHandle?.value ?? d.blockHeaderHandle?.value;
const bHex = bhVal != null ? bhVal.toString(16) : null;
const bEnts = bHex ? (entsByOwner.get(bHex) ?? []) : [];
// Transform block children by composing INSERT xf with viewport xf via pushSeg
if (bEnts.length) {
const textStart = pendingTexts.length;
this._insertEntities(
bEnts,
{
insertionPoint: ip,
xScale: sx,
yScale: sy,
rotation: d.rotation ?? e.rotation ?? 0,
basePoint: blockBaseByHex.get(bHex),
},
color,
pushSeg,
pendingTexts,
{ entsByOwner, blockBase: blockBaseByHex },
);
// INSERT may push model-space text; re-map into paper + underlay flag
for (let ti = textStart; ti < pendingTexts.length; ti++) {
const t = pendingTexts[ti];
if (!t?.pos) continue;
const q = xf(t.pos.x, t.pos.y);
t.pos = q;
t.height = (t.height || 2.5) * scale;
t.rotation = (t.rotation || 0) + (xf.twist || 0);
t.underlay = !!underlay;
}
}
expand(ip.x, ip.y);
break;
}
default:
break;
}
} catch { /* skip malformed */ }
}
// Transform any pending texts that are still in model space (from INSERT helpers).
// Entries created above already have paper pos. Detect model-space leftovers by
// checking if pos lies far outside the paper clip *and* inside a model-ish range —
// safer: tag viewport texts. For insert-pushed texts, re-map those outside paper
// sheet bounds of the whole drawing is hard. Instead, convert texts whose height
// wasn't scaled (model heights are often large): skip — INSERT text in viewports
// is rare on these sheets.
}
/** 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();
const allowed = this._activeSpaceHex
? buildAllowedOwnerHexes(result.entities || [], this._activeSpaceHex)
: null;
for (const e of (result.entities || [])) {
const ownerHex = e.ownerHandle?.value?.toString(16);
if (allowed && ownerHex && !allowed.has(ownerHex)) continue;
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, renderOrder = 0) {
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);
sprite.renderOrder = renderOrder;
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.
// Underlay (viewport model labels) and overlay (paper labels) are two meshes
// so paper text always composites above viewport content (Layout draw order).
async _drawTexts(texts, minTextH) {
const gen = this._textGen;
const under = texts.filter((t) => t.underlay);
const over = texts.filter((t) => !t.underlay);
const layers = [
{ list: under, order: 0 },
{ list: over, order: 1 },
];
try {
const engine = await SlugTextEngine.shared();
if (gen !== this._textGen) return;
for (const { list, order } of layers) {
if (!list.length) continue;
for (const t of list) {
if ((t.bgFillFlags | 0) !== 0) this._drawMTextBackground(t, engine, minTextH);
}
const batch = new SlugTextBatch(engine);
for (const t of list) {
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) {
mesh.renderOrder = order;
// Transparent text: draw later order on top regardless of z
if (mesh.material) {
mesh.material.depthWrite = false;
mesh.material.transparent = true;
}
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 { list, order } of layers) {
for (const t of list) {
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, order);
}
}
}
}
// 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, renderOrder = 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, renderOrder);
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);
sprite.renderOrder = renderOrder;
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 cam = this._camera;
const rect = this._renderer.domElement.getBoundingClientRect();
// WCS for pick/measure (not UCS display frame)
const wcs = this._screenToWcs(e.clientX, e.clientY);
if (!wcs) return;
const wx = wcs.x, wy = wcs.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;
}
/**
* Percentile-trim an AABB from samples so 12 far junk vertices (or a
* degenerate polyline) do not dominate Zoom Fit. Falls back to fullBox.
* @param {number[]} samples flat [x,y,x,y,…]
* @param {THREE.Box3} fullBox
*/
_trimmedContentBox(samples, fullBox) {
if (!fullBox || fullBox.isEmpty()) return null;
const n = samples ? (samples.length >> 1) : 0;
if (n < 32) return fullBox.clone();
const xs = new Float64Array(n);
const ys = new Float64Array(n);
let k = 0;
for (let i = 0; i + 1 < samples.length; i += 2) {
const x = samples[i], y = samples[i + 1];
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
xs[k] = x; ys[k] = y; k++;
}
if (k < 32) return fullBox.clone();
const xsl = xs.subarray(0, k);
const ysl = ys.subarray(0, k);
xsl.sort();
ysl.sort();
// 0.5% … 99.5% — drops a handful of extreme verts on ~100k+ samples
const lo = Math.max(0, Math.floor(k * 0.005));
const hi = Math.min(k - 1, Math.floor(k * 0.995));
let minX = xsl[lo], maxX = xsl[hi], minY = ysl[lo], maxY = ysl[hi];
if (!(maxX > minX) || !(maxY > minY)) return fullBox.clone();
// Pad slightly so content is not flush to the view edge
const padX = Math.max((maxX - minX) * 0.02, 1);
const padY = Math.max((maxY - minY) * 0.02, 1);
const box = new THREE.Box3(
new THREE.Vector3(minX - padX, minY - padY, 0),
new THREE.Vector3(maxX + padX, maxY + padY, 0),
);
// Never expand beyond the true full box
box.min.max(fullBox.min);
box.max.min(fullBox.max);
return box;
}
/**
* Zoom camera to a world AABB of visible content (camera-local frustum + pan).
* `box` must already reflect only currently drawn entities (space/layer filter).
*
* Civil model drawings are often extreme horizontal strips (aspect 50200:1).
* Classic "contain" fit then letterboxes so the strip is only a few pixels tall
* (looks like a single line at the top/center). For such strips, fit so the
* **short axis fills the screen** (Y fills for wide strips); pan to see the rest.
*/
_fit(box) {
if (!box || box.isEmpty()) return;
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 / Math.max(h, 1);
const pad = 1.12;
const bw = Math.max(size.x, 1e-9);
const bh = Math.max(size.y, 1e-9);
const contentAspect = bw / bh;
let vw, vh;
// Extreme strip: prefer filling the short side so geometry is readable.
// Threshold ~8× screen aspect (e.g. content 16:1 on a 2:1 window).
if (contentAspect > aspect * 8) {
// Very wide (plan corridor / bb-style double box): fill view height
vh = bh * pad;
vw = vh * aspect;
} else if (contentAspect < aspect / 8) {
// Very tall: fill view width
vw = bw * pad;
vh = vw / aspect;
} else {
// Normal sheet / roughly viewport-shaped content: classic contain
vw = bw * pad;
vh = bh * pad;
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;
const span = Math.max(vw, vh, 1000);
this._camera.near = -span;
this._camera.far = span;
this._camera.position.set(c.x, c.y, 100);
const a = -(this._viewTwist || 0);
this._camera.up.set(-Math.sin(a), Math.cos(a), 0);
this._controls.target.set(c.x, c.y, 0);
this._camera.lookAt(this._controls.target);
this._camera.updateProjectionMatrix();
this._camera.updateMatrixWorld(true);
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
this._contentBox = null;
}
_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);
}
}