/**
* 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 1–2 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
)
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
// ~200–300 drawing units while native TEXT height is ~0.3–3.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); 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,
};
}
/** Cohen–Sutherland 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 ~1–100 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 1–2 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 50–200: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);
}
}