/**
* 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),
* MESH (AcDbSubDMesh, base-mesh wireframe),
* 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 { LineSegments2 } from 'three/examples/jsm/lines/LineSegments2.js';
import { LineSegmentsGeometry } from 'three/examples/jsm/lines/LineSegmentsGeometry.js';
import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial.js';
import { aciToHex } from './aciColors.js';
import { buildAllowedOwnerHexes, listCadSpaces } from './cadSpaces';
import { SlugTextEngine, SlugTextBatch } from './slugText';
import { getLinPattern, mergeDwgLinetypePattern } from './linParser';
const DEFAULT_COLOR = 0xc9d1d9;
const ARC_SEGS = 64;
const ELLIPSE_SEGS = 72;
const CLICK_THRESHOLD_PX = 14;
// ── Lineweight (AutoCAD LWDISPLAY) ─────────────────────────────────────────
// DWG stores lineweight as an i16 in 1/100 mm (-1 ByLayer, -2 ByBlock,
// -3 Default). AutoCAD shows it in *screen* pixels — the width does not grow
// when you zoom in — at roughly 8 px per mm on the default display-scale
// slider (0.25 mm ≈ 2 px, 2.11 mm ≈ 17 px), so that is the mapping used here.
const LW_PX_PER_MM = 8;
// LWDEFAULT: what -3 (and a layer that never set one) resolves to.
const LW_DEFAULT_MM = 0.25;
// At or below the CAD default the line stays in the 1-px hairline batch: a
// drawing that never assigned weights must look exactly as it did before.
const LW_HAIRLINE_MM = 0.25;
const LW_MAX_PX = 24;
// Fat lines cost 12 floats + 8 verts per segment (instanced quads). Past this
// many segments in one weight bucket, fall back to hairline instead of risking
// a GPU/heap stall on huge survey drawings.
const LW_MAX_FAT_SEGS = 400000;
/** Bucket pending texts by their owning CAD layer (insertion order preserved). */
function groupByLayer(list) {
const m = new Map();
for (const t of list) {
const k = t.layer ?? '0';
let arr = m.get(k);
if (!arr) m.set(k, arr = []);
arr.push(t);
}
return m;
}
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
// ── Layer visibility index (see _applyLayerVisibility) ───────────────────
// Layer on/off used to re-run load() = full re-tessellation (5 s+ on an
// 18 MB DWG). Instead we build every layer once and toggle visibility:
// · _layerObjs layer name → Object3D[] (hatch/solid/text/sprite meshes)
// · _indexedBufs merged LineSegments whose index buffer is rebuilt from a
// per-segment layer id — keeps the original draw order.
// · _layerBoxes / _layerSamples per-layer AABB + fit samples so Fit still
// frames only what is visible.
this._curLayer = null;
this._curLayerId = -1;
this._layerObjs = new Map();
this._layerBoxes = new Map();
this._layerSamples = new Map();
this._indexedBufs = [];
// · _fatBufs LineSegments2 batches (one per lineweight/dash bucket).
// Instanced, so layer masking compacts the instance buffer
// instead of rewriting an index (see _applyLayerVisibility).
this._fatBufs = [];
this._metaHidden = null;
this._fullContentBox = null;
this._hiddenLayers = new Set();
// Lineweight display: null = follow the drawing's LWDISPLAY header var,
// true/false = forced by the host UI (setLineweightEnabled).
this._lwForced = null;
this._lwDisplay = false;
this._curLwPx = 0;
SlugTextEngine.shared().catch(() => {}); // warm the font load; sprite fallback covers failure
this._scene = new THREE.Scene();
this._scene.background = new THREE.Color(0x0d1117);
this._isDark = true;
this._isLoading = false;
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 || obj.visible === false) 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;
// Don't cache while layers are off — the merged line buffer still holds the
// hidden segments, so this box is wider than what's on screen.
if (!this._hiddenLayers.size) this._contentBox = use.clone();
this._fit(use);
}
load(result, opts = {}) {
this._isLoading = true;
try {
this._lastResult = result;
this._selMeta = null;
this._clear();
this._entityMeta = [];
// 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();
}
if (!opts.keepTheme) {
const spacesList = listCadSpaces(result);
const activeSp = spacesList.find((s) => s.handleHex === this._activeSpaceHex);
const isPaper = activeSp?.kind === 'paper';
this.setTheme(!isPaper);
}
this._buildLayerMap(result?.tables?.layers);
this._buildStyleMap(result?.tables?.styles || result?.tables?.textStyles);
this._buildLinetypeMap(result);
// LWDISPLAY decides whether weights show at all — same switch as AutoCAD's
// status-bar "Show/Hide Lineweight". The host UI can override it.
// DXF spells the header var $LWDISPLAY; dwg-wasm emits vars.lwdisplay.
this._lwDisplay = this._lwForced
?? !!(result?.vars?.lwdisplay ?? result?.vars?.$LWDISPLAY ?? result?.vars?.LWDISPLAY);
// Sheet orientation: the active model-space viewport's VIEWTWIST rotates the
// view so a drawing stored tilted in WCS (rotated survey sheets — header UCS
// stays identity) displays with its title border upright. Applied as a
// camera-up rotation in _fit (geometry untouched → picking stays exact).
this._viewTwist = this._readViewTwist(result);
// Geometric pick index — the ACTUAL rendered geometry, not a bounds proxy.
// _pickSegs: flat [ax,ay,bx,by,…] of every line segment (lines, arcs, circles,
// polylines, block wires, dimension leaders/arrows) with a parallel owner-meta
// index. _pickFills: filled regions (hatch loops, SOLID quads, text quads)
// tested by even-odd point-in-polygon. _onClick raycasts these directly.
this._pickSegs = [];
this._pickSegMeta = [];
this._pickFills = [];
this._pickCurIdx = -1;
const entities = result?.entities || [];
const lineVerts = [];
const lineColors = [];
// Layer id per emitted segment (2 verts). Drives the index rebuild that
// hides/shows layers without touching positions or colors.
const lineSegLayer = [];
const layerIds = new Map();
const layerNames = [];
const layerIdOf = (name) => {
let id = layerIds.get(name);
if (id === undefined) { id = layerNames.length; layerNames.push(name); layerIds.set(name, id); }
return id;
};
// Segments that can't join the one merged hairline batch go into buckets
// keyed by dash|gap size and lineweight; each becomes its own mesh at
// assembly (LineDashedMaterial for thin dashes, LineSegments2 for anything
// with a real lineweight). Solid hairlines stay in lineVerts/lineColors.
const segBuckets = new Map();
let curDash = null; // {key,dash,gap} for the entity currently being emitted
let entIdx = -1; // _entityMeta index of that entity (-1 before the loop)
let curSpans = []; // buckets it has written to so far
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;
// Per-layer AABB + fit samples: Fit must frame only the VISIBLE layers, and
// toggling no longer re-runs load(), so the split has to happen at build.
let curLayerBox = null;
let curLayerSamples = null;
const setCurLayer = (name) => {
const key = name || '0';
if (key === this._curLayer) return;
this._curLayer = key;
this._curLayerId = layerIdOf(key);
curLayerBox = this._layerBoxes.get(key);
if (!curLayerBox) this._layerBoxes.set(key, curLayerBox = new THREE.Box3());
curLayerSamples = this._layerSamples.get(key);
if (!curLayerSamples) this._layerSamples.set(key, curLayerSamples = []);
};
const expand = (x, y, z = 0) => {
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
box.expandByPoint(_tmp.set(x, y, z));
if (curLayerBox) curLayerBox.expandByPoint(_tmp);
fitSampleN++;
if ((fitSampleN & 7) === 0 && fitSamples.length < 100000) {
fitSamples.push(x, y);
if (curLayerSamples) curLayerSamples.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;
const lwPx = this._curLwPx;
if (curDash || lwPx > 0) {
const key = `${curDash ? curDash.key : ''}#${lwPx}`;
let bk = segBuckets.get(key);
if (!bk) {
bk = { dash: curDash?.dash ?? 0, gap: curDash?.gap ?? 0, lwPx, verts: [], colors: [], segLayer: [], entIdx: -2 };
segBuckets.set(key, bk);
}
// Selection highlight recolors an entity's own vertices. Its colors no
// longer live in one array, so remember where this entity's run starts
// in every bucket it touches (see meta.spans).
if (bk.entIdx !== entIdx) { bk.entIdx = entIdx; bk.entStart = bk.colors.length; curSpans.push(bk); }
bk.verts.push(ax, ay, z, bx, by, z);
bk.colors.push(r, g, b, r, g, b);
bk.segLayer.push(this._curLayerId);
} else {
lineVerts.push(ax, ay, z, bx, by, z);
lineColors.push(r, g, b, r, g, b);
lineSegLayer.push(this._curLayerId);
}
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,
setCurLayer,
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;
// Hidden layers are NOT skipped here: geometry for every layer is built
// once and switched on/off by _applyLayerVisibility, so a toggle costs an
// index rebuild instead of a full reload.
setCurLayer(this._layerNameOf(e));
const d = e.data || e;
const type = (e.type || e.typeName || '').toUpperCase();
const color = this._entityColor(e);
const complexLt = this._resolveComplexLinetype(e);
if (complexLt) curDash = null;
else curDash = this._resolveDash(e);
this._curLwPx = this._lwPx(e);
const meta = { entity: e, type, bounds: null, layer: this._curLayer, colStart: lineColors.length };
entIdx = this._entityMeta.length;
curSpans = [];
this._entityMeta.push(meta);
this._pickCurIdx = this._entityMeta.length - 1; // owner for pick geometry emitted below
const textStart = pendingTexts.length;
try {
switch (type) {
// ── Basic geometry ────────────────────────────────────────────────
case 'LINE':
if (d.start && d.end) {
if (complexLt) {
this._drawComplexPath([d.start, d.end], false, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts);
} else {
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) {
if (complexLt) {
const pts = this._sampleArcPoints(d.center, d.radius, 0, Math.PI * 2, d.center.z || 0);
this._drawComplexPath(pts, true, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts);
} else {
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) {
if (complexLt) {
const pts = this._sampleArcPoints(d.center, d.radius, d.startAngle ?? 0, d.endAngle ?? Math.PI * 2, d.center.z || 0);
this._drawComplexPath(pts, false, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts);
} else {
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 (complexLt) {
const pts = this._samplePolylinePoints(d.points, d.bulges, closed, d.elevation || 0);
this._drawComplexPath(pts, closed, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts);
pts.forEach(p => expand(p.x, p.y, p.z));
} 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':
if (complexLt && (d.vertices || d.points)) {
const closed = !!(d.closed || (d.flags & 1));
const pts = d.vertices || d.points;
this._drawComplexPath(pts, closed, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts);
pts.forEach(p => expand(p.x, p.y, p.z || 0));
} else {
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 (complexLt) {
const sampled = this._samplePolylinePoints(pts, bulges, closed, elev);
this._drawComplexPath(sampled, closed, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts);
sampled.forEach(p => expand(p.x, p.y, p.z));
} else 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) {
if (complexLt) {
const pts = this._sampleEllipsePoints(d);
this._drawComplexPath(pts, false, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts);
pts.forEach(p => expand(p.x, p.y, p.z || 0));
} else {
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;
if (complexLt) {
this._drawComplexPath(pts, !!d.closed, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts);
pts.forEach(p => expand(p.x, p.y, p.z || 0));
} else {
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;
}
// ── MESH (AcDbSubDMesh) ───────────────────────────────────────────
// Drawn as its face wireframe, matching AutoCAD's 2D wireframe view.
case 'MESH': {
if (d.vertices?.length >= 3) {
this._meshSegs(d, color, pushSeg);
meta.bounds = { type:'point', cx:d.vertices[0], cy:d.vertices[1] };
}
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._addObj(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;
if (curSpans.length) {
meta.spans = curSpans.map((bk) => ({ bk, start: bk.entStart, end: bk.colors.length }));
curSpans = [];
}
// Deferred texts are flushed after the loop (async) — stamp the owning
// layer now so _drawTexts can batch per layer.
for (let ti = textStart; ti < pendingTexts.length; ti++) {
if (pendingTexts[ti].layer === undefined) pendingTexts[ti].layer = this._curLayer;
}
}
// ── 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._layerNames = layerNames;
this._lineColorAttr = null; this._origColors = null; this._hairRt = null;
// A weighted bucket only earns its own fat-line mesh while it stays inside
// the segment cap; past that it is folded back into the hairline batch
// (solid) or drawn as a thin dash bucket, which is what the viewer did
// before lineweight existed.
for (const bk of segBuckets.values()) {
bk.fat = bk.lwPx > 0 && bk.verts.length / 6 <= LW_MAX_FAT_SEGS;
if (bk.lwPx > 0 && !bk.fat) {
console.warn(`선가중치 ${bk.lwPx}px 세그먼트 ${bk.verts.length / 6}개 → 헤어라인으로 대체`);
}
if (!bk.fat && bk.dash <= 0) {
bk.mergeOffset = lineColors.length;
for (let i = 0; i < bk.verts.length; i++) lineVerts.push(bk.verts[i]);
for (let i = 0; i < bk.colors.length; i++) lineColors.push(bk.colors[i]);
for (let i = 0; i < bk.segLayer.length; i++) lineSegLayer.push(bk.segLayer[i]);
bk.merged = true;
}
}
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);
const lmesh = new THREE.LineSegments(geom, new THREE.LineBasicMaterial({ vertexColors: true }));
this._registerIndexed(lmesh, lineSegLayer);
this._group.add(lmesh);
this._lineColorAttr = colorAttr;
this._origColors = Float32Array.from(colorAttr.array);
this._hairRt = { attr: colorAttr, orig: this._origColors };
}
// One mesh per remaining bucket.
// · lineweight > 0 → LineSegments2: screen-space quads, so the width stays
// constant in pixels while zooming (AutoCAD LWDISPLAY semantics).
// · otherwise → LineSegments + LineDashedMaterial for the dash
// linetypes (HIDDEN / CENTER / …). computeLineDistances() is REQUIRED
// for the pattern to appear; on LineSegments each 2-vertex pair dashes
// independently from its own start (correct for CAD segments).
for (const bk of segBuckets.values()) {
if (!bk.verts.length || bk.merged) continue;
if (bk.fat) {
const fgeom = new LineSegmentsGeometry();
fgeom.setPositions(bk.verts);
fgeom.setColors(bk.colors);
const fmat = new LineMaterial({
vertexColors: true, linewidth: bk.lwPx, worldUnits: false,
dashed: bk.dash > 0, dashSize: bk.dash, gapSize: bk.gap,
});
this._sizeLineMaterial(fmat);
const fline = new LineSegments2(fgeom, fmat);
if (bk.dash > 0) fline.computeLineDistances();
this._registerFat(fline, bk.segLayer);
bk.rt = this._fatBufs[this._fatBufs.length - 1];
this._group.add(fline);
} else {
const dgeom = new THREE.BufferGeometry();
dgeom.setAttribute('position', new THREE.Float32BufferAttribute(bk.verts, 3));
const dcol = new THREE.Float32BufferAttribute(bk.colors, 3);
dgeom.setAttribute('color', dcol);
const dline = new THREE.LineSegments(dgeom, new THREE.LineDashedMaterial({
vertexColors: true, dashSize: bk.dash, gapSize: bk.gap,
}));
dline.computeLineDistances(); // must run BEFORE setIndex (three skips indexed geometry)
this._registerIndexed(dline, bk.segLayer);
bk.rt = { attr: dcol, orig: Float32Array.from(dcol.array) };
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._fullContentBox = fitBox ? fitBox.clone() : null;
this._contentBox = fitBox ? fitBox.clone() : null;
// Geometry for every layer is in the scene now — switch off the hidden ones
// (also refreshes _contentBox to the visible subset).
this._applyLayerVisibility();
const useBox = this._contentBox;
if (!opts.keepView && useBox && !useBox.isEmpty()) this._fit(useBox);
} finally {
this._isLoading = false;
}
}
// ── UI integration ─────────────────────────────────────────────────────────
/** Swap canvas background for theme. dark=true → near-black (Model), false → white (Layout). */
setTheme(dark) {
const isDarkBool = !!dark;
const changed = (this._isDark !== isDarkBool);
this._isDark = isDarkBool;
this._scene.background = new THREE.Color(this._isDark ? 0x0a0b0d : 0xffffff);
if (this._gridVisible) this._rebuildGrid();
if (changed && this._lastResult && !this._isLoading) {
this.load(this._lastResult, { keepView: true, keepTheme: true, spaceHandle: this._activeSpaceHex });
}
}
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).
* O(scene objects + segments) — no re-parse, no re-tessellation, no reload.
* Falls back to a full load() only if the scene predates the layer index.
*/
setHiddenLayers(nameSet) {
// Copy: the Layers panel keeps mutating the Set it handed us.
this._hiddenLayers = new Set(nameSet || []);
if (this._applyLayerVisibility()) return;
if (this._lastResult) this.load(this._lastResult, { keepView: true });
}
/** Layer name of an entity ('0' when the file gives none). */
_layerNameOf(e) {
const lh = e.layerHandle?.value ?? e.layerHandle;
return (lh != null && this._layerNameByHandle?.get(String(lh))) ?? e.layer ?? e.layerName ?? '0';
}
/**
* Give a merged LineSegments an index buffer so layers can be masked by
* rewriting indices (positions/colors/lineDistances stay put, which keeps
* draw order and the selection-highlight offsets in meta.colStart valid).
*/
_registerIndexed(mesh, segLayerArr) {
const vcount = mesh.geometry.attributes.position.count;
if (!vcount) return;
const IdxArr = vcount > 65535 ? Uint32Array : Uint16Array;
const index = new IdxArr(vcount);
for (let i = 0; i < vcount; i++) index[i] = i;
const attr = new THREE.BufferAttribute(index, 1);
attr.setUsage(THREE.DynamicDrawUsage);
mesh.geometry.setIndex(attr);
this._indexedBufs.push({ mesh, segLayer: Int32Array.from(segLayerArr), index, attr });
}
/**
* Track a LineSegments2 batch for layer masking. Its geometry is instanced
* (one instance per segment, positions/colors interleaved 6 floats each), so
* there is no index to rewrite: hiding a layer compacts the visible segments
* into the front of the instance buffers and drops `instanceCount`.
* The untouched originals are kept so any later mask starts from clean data.
*/
_registerFat(mesh, segLayerArr) {
const geo = mesh.geometry;
const posBuf = geo.attributes.instanceStart?.data;
const colBuf = geo.attributes.instanceColorStart?.data;
if (!posBuf) return;
posBuf.setUsage(THREE.DynamicDrawUsage);
colBuf?.setUsage(THREE.DynamicDrawUsage);
this._fatBufs.push({
mesh,
segLayer: Int32Array.from(segLayerArr),
posBuf, colBuf,
posSrc: Float32Array.from(posBuf.array),
colSrc: colBuf ? Float32Array.from(colBuf.array) : null,
});
}
/** Fat-line materials need the canvas size in px to convert linewidth. */
_sizeLineMaterial(mat) {
const w = this._container?.clientWidth || this._renderer?.domElement?.clientWidth || 1;
const h = this._container?.clientHeight || this._renderer?.domElement?.clientHeight || 1;
mat.resolution.set(w, h);
}
/**
* Lineweight display switch (AutoCAD LWDISPLAY).
* @param {boolean|null} on true/false forces it, null follows the drawing.
*/
setLineweightEnabled(on) {
this._lwForced = (on == null) ? null : !!on;
if (this._lastResult && !this._isLoading) {
this.load(this._lastResult, { keepView: true, keepTheme: true, spaceHandle: this._activeSpaceHex });
}
}
/** Whether lineweights are currently being drawn. */
getLineweightEnabled() { return this._lwDisplay; }
/** Track an entity-owned object so layer toggles can flip its visibility. */
_addObj(obj, layer) {
const key = layer ?? this._curLayer;
if (key != null) {
obj.userData.cadLayer = key;
let arr = this._layerObjs.get(key);
if (!arr) this._layerObjs.set(key, arr = []);
arr.push(obj);
if (this._hiddenLayers.has(key)) obj.visible = false;
}
this._group.add(obj);
}
/**
* Apply _hiddenLayers to the already-built scene.
* @returns {boolean} false when there is nothing built yet (caller reloads).
*/
_applyLayerVisibility() {
if (!this._layerNames && !this._layerObjs.size) return false;
const hidden = this._hiddenLayers;
// 1) Whole objects (hatch fills, SOLID quads, arrows, images, text meshes).
for (const [name, objs] of this._layerObjs) {
const vis = !hidden.has(name);
for (const o of objs) o.visible = vis;
}
// 2) Merged line buffers → rewrite the index to skip hidden segments.
const names = this._layerNames || [];
const hiddenId = new Uint8Array(names.length);
for (let i = 0; i < names.length; i++) if (hidden.has(names[i])) hiddenId[i] = 1;
for (const buf of this._indexedBufs) {
const seg = buf.segLayer, idx = buf.index;
let w = 0;
for (let s = 0; s < seg.length; s++) {
const id = seg[s];
if (id >= 0 && hiddenId[id]) continue;
const v = s * 2;
idx[w++] = v; idx[w++] = v + 1;
}
buf.attr.needsUpdate = true;
buf.mesh.geometry.setDrawRange(0, w);
}
// 2b) Fat-line batches → compact the instance buffers, then cap instanceCount.
// slotOf records where each source segment ended up so the selection
// highlight can still find its vertices (-1 = this segment is hidden).
for (const buf of this._fatBufs) {
const seg = buf.segLayer;
if (!buf.slotOf) buf.slotOf = new Int32Array(seg.length);
const slotOf = buf.slotOf;
let w = 0;
for (let s = 0; s < seg.length; s++) {
const id = seg[s];
if (id >= 0 && hiddenId[id]) { slotOf[s] = -1; continue; }
buf.posBuf.array.set(buf.posSrc.subarray(s * 6, s * 6 + 6), w * 6);
if (buf.colBuf) buf.colBuf.array.set(buf.colSrc.subarray(s * 6, s * 6 + 6), w * 6);
slotOf[s] = w;
w++;
}
buf.posBuf.needsUpdate = true;
if (buf.colBuf) buf.colBuf.needsUpdate = true;
buf.mesh.geometry.instanceCount = w;
buf.mesh.visible = w > 0;
}
// 3) Picking mask — _onClick skips segments/fills owned by hidden layers.
const metas = this._entityMeta;
const mh = new Uint8Array(metas.length);
if (hidden.size) {
for (let i = 0; i < metas.length; i++) if (hidden.has(metas[i].layer)) mh[i] = 1;
}
this._metaHidden = mh;
if (this._selMeta && hidden.has(this._selMeta.layer)) {
this._highlight(null);
this._onSelectCb?.(null);
} else if (this._selMeta && this._fatBufs.length) {
// Compaction above rewrote the fat instance colors from the pristine
// source — repaint the selection on its new slots.
this._paintMeta(this._selMeta, this._selColor);
}
// 4) Fit box for the visible subset.
this._contentBox = this._visibleContentBox();
return true;
}
/** AABB (outlier-trimmed) of the layers currently switched on. */
_visibleContentBox() {
const hidden = this._hiddenLayers;
if (!hidden.size) return this._fullContentBox ? this._fullContentBox.clone() : null;
const box = new THREE.Box3();
const samples = [];
for (const [name, b] of this._layerBoxes) {
if (hidden.has(name) || b.isEmpty()) continue;
box.union(b);
const s = this._layerSamples.get(name);
if (s) for (let i = 0; i < s.length; i++) samples.push(s[i]);
}
if (box.isEmpty()) return null;
return (this._trimmedContentBox(samples, box) || box).clone();
}
/**
* 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, setCurLayer,
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;
// Layer visibility is applied post-build (see _applyLayerVisibility).
setCurLayer?.(this._layerNameOf(e));
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);
const complexLt = this._resolveComplexLinetype(e);
const vpTextStart = pendingTexts.length;
try {
switch (type) {
case 'LINE':
if (d.start && d.end) {
if (complexLt) {
this._drawComplexPath([d.start, d.end], false, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts);
} else {
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) {
if (complexLt) {
const pts = this._sampleArcPoints(d.center, d.radius, 0, Math.PI * 2, d.center.z || 0);
this._drawComplexPath(pts, true, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts);
} else {
// 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) {
if (complexLt) {
const pts = this._sampleArcPoints(d.center, d.radius, d.startAngle ?? 0, d.endAngle ?? Math.PI * 2, d.center.z || 0);
this._drawComplexPath(pts, false, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts);
} else {
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 (complexLt) {
const pts = this._samplePolylinePoints(d.points, d.bulges, closed, d.elevation || 0);
this._drawComplexPath(pts, closed, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts);
pts.forEach((p) => expand(p.x, p.y, p.z));
} 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));
}
}
break;
case 'POLYLINE':
if (complexLt && (d.vertices || d.points)) {
const closed = !!(d.closed || (d.flags & 1));
const pts = d.vertices || d.points;
this._drawComplexPath(pts, closed, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts);
pts.forEach((p) => expand(p.x, p.y, p.z || 0));
} else {
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 'MESH':
// pushSeg already maps model → paper and clips, so no xf here.
if (d.vertices?.length >= 3) this._meshSegs(d, color, pushSeg);
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;
}
if (complexLt && pendingTexts.length > vpTextStart) {
for (let ti = vpTextStart; 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;
}
}
} catch { /* skip malformed */ }
for (let ti = vpTextStart; ti < pendingTexts.length; ti++) {
if (pendingTexts[ti].layer === undefined) pendingTexts[ti].layer = this._curLayer;
}
}
// 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 }].
* The name/color/count part is O(entities) — cached per load so the panel can
* call this on every toggle without re-walking a 500k-entity drawing.
*/
getLayerInfo() {
const result = this._lastResult;
if (!result) return [];
let base = this._layerInfoCache;
if (!base) {
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 name = this._layerNameOf(e);
counts.set(name, (counts.get(name) || 0) + 1);
}
base = (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, this._isDark) ?? DEFAULT_COLOR).toString(16).padStart(6, '0')),
count: counts.get(name) || 0,
};
}).filter(l => l.count > 0 || !l.name.startsWith('*'));
this._layerInfoCache = base;
}
const hidden = this._hiddenLayers || new Set();
return base.map(l => ({ ...l, visible: !hidden.has(l.name) }));
}
// ── Private helpers ────────────────────────────────────────────────────────
_resolveComplexLinetype(e) {
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 upperKey = ltName.toUpperCase().trim();
const pattern = this._dwgLinPatterns?.get(upperKey) || getLinPattern(ltName);
if (!pattern) return null;
const entScale = (e.entityHeader?.linetypeScale > 0 ? e.entityHeader.linetypeScale : 1);
const ltscale = (this._globalLtscale || 1) * entScale;
return { pattern, ltscale };
}
_sampleArcPoints(center, radius, startAngle, endAngle, elevation = 0) {
let sweep = endAngle - startAngle;
if (sweep <= 0) sweep += Math.PI * 2;
const steps = Math.max(16, Math.ceil((sweep / (Math.PI * 2)) * ARC_SEGS));
const pts = [];
const z = center.z || elevation || 0;
for (let i = 0; i <= steps; i++) {
const a = startAngle + (sweep * i) / steps;
pts.push({
x: center.x + radius * Math.cos(a),
y: center.y + radius * Math.sin(a),
z,
});
}
return pts;
}
_samplePolylinePoints(points, bulges, closed, elevation = 0) {
if (!points || points.length < 2) return points || [];
const res = [];
const n = closed ? points.length : points.length - 1;
for (let i = 0; i < n; i++) {
const u = points[i];
const v = points[(i + 1) % points.length];
const b = bulges?.[i] || 0;
const z = u.z || elevation || 0;
if (Math.abs(b) < 1e-6) {
if (res.length === 0) res.push({ x: u.x, y: u.y, z });
res.push({ x: v.x, y: v.y, z });
} else {
const dx = v.x - u.x;
const dy = v.y - u.y;
const dist = Math.hypot(dx, dy);
if (dist > 1e-9) {
const theta = 4 * Math.atan(b);
const halfTheta = Math.abs(theta) / 2;
const R = dist / 2 / Math.sin(halfTheta);
const dCenter = R * Math.cos(halfTheta);
const mx = (u.x + v.x) / 2;
const my = (u.y + v.y) / 2;
const nx = -dy / dist;
const ny = dx / dist;
const dir = b > 0 ? 1 : -1;
const cx = mx + dir * nx * dCenter;
const cy = my + dir * ny * dCenter;
const a1 = Math.atan2(u.y - cy, u.x - cx);
const steps = Math.max(4, Math.ceil(Math.abs(theta) / (Math.PI / 16)));
for (let k = (res.length === 0 ? 0 : 1); k <= steps; k++) {
const a = a1 + (theta * k) / steps;
res.push({
x: cx + R * Math.cos(a),
y: cy + R * Math.sin(a),
z,
});
}
}
}
}
return res;
}
_sampleEllipsePoints(d) {
const major = d.majorAxis ?? d.smAxis ?? { x: d.radius || 1, y: 0 };
const center = d.center || { x: 0, y: 0 };
const ratio = d.ratio ?? d.axisRatio ?? d.minorAxisRatio ?? 0.5;
const startParam = d.startParam ?? d.startAngle ?? 0;
const endParam = d.endParam ?? d.endAngle ?? Math.PI * 2;
const aLen = Math.hypot(major.x ?? 1, major.y ?? 0);
const bLen = aLen * ratio;
const rot = Math.atan2(major.y ?? 0, major.x ?? 1);
let sweep = endParam - startParam;
if (sweep <= 0) sweep += Math.PI * 2;
const steps = ELLIPSE_SEGS;
const pts = [];
const z = center.z || 0;
for (let i = 0; i <= steps; i++) {
const t = startParam + (sweep * i) / steps;
const ex = aLen * Math.cos(t);
const ey = bLen * 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);
pts.push({ x, y, z });
}
return pts;
}
_drawComplexPath(points, closed, linPattern, ltscale, color, pushSeg, pendingTexts) {
if (!points || points.length < 2) return;
const pts = points.slice();
if (closed && (Math.hypot(pts[pts.length - 1].x - pts[0].x, pts[pts.length - 1].y - pts[0].y) > 1e-6)) {
pts.push(pts[0]);
}
const segLens = [];
let totalLen = 0;
for (let i = 0; i < pts.length - 1; i++) {
const dx = pts[i + 1].x - pts[i].x;
const dy = pts[i + 1].y - pts[i].y;
const len = Math.hypot(dx, dy);
segLens.push(len);
totalLen += len;
}
if (totalLen < 1e-6) return;
const getPathState = (dist) => {
let s = Math.max(0, Math.min(dist, totalLen));
for (let i = 0; i < segLens.length; i++) {
const len = segLens[i];
if (s <= len || i === segLens.length - 1) {
const t = len > 1e-9 ? s / len : 0;
const p0 = pts[i];
const p1 = pts[i + 1];
const x = p0.x + (p1.x - p0.x) * t;
const y = p0.y + (p1.y - p0.y) * t;
const z = (p0.z || 0) + ((p1.z || 0) - (p0.z || 0)) * t;
const angle = Math.atan2(p1.y - p0.y, p1.x - p0.x);
return { x, y, z, angle };
}
s -= len;
}
const last = pts[pts.length - 1];
const prev = pts[pts.length - 2];
return {
x: last.x,
y: last.y,
z: last.z || 0,
angle: Math.atan2(last.y - prev.y, last.x - prev.x),
};
};
const elements = linPattern.elements || [];
const patternLen = linPattern.patternLength * ltscale;
if (patternLen < 1e-6 || !elements.length) return;
// Calculate distance from pattern start to shape (arrowhead) element if present
let shapeOffset = -1;
let accum = 0;
for (const e of elements) {
if (e.type === 'shape') {
shapeOffset = accum;
break;
}
if (e.type === 'dash') accum += Math.max((e.val || 0.05) * ltscale, 0.01 * ltscale);
else if (e.type === 'gap') accum += Math.abs(e.val || 0.1) * ltscale;
}
let s = 0;
let elemIdx = 0;
let lastShapePos = -1;
while (s < totalLen) {
const elem = elements[elemIdx % elements.length];
elemIdx++;
if (elem.type === 'dash') {
const dashLen = Math.max((elem.val || 0.05) * ltscale, 0.01 * ltscale);
const startDist = s;
const endDist = Math.min(s + dashLen, totalLen);
if (endDist > startDist) {
const pStart = getPathState(startDist);
const pEnd = getPathState(endDist);
pushSeg(pStart.x, pStart.y, pEnd.x, pEnd.y, pStart.z, color);
}
s += dashLen;
} else if (elem.type === 'gap') {
const gapLen = Math.abs(elem.val || 0.1) * ltscale;
s += gapLen;
} else if (elem.type === 'shape') {
lastShapePos = s;
const pState = getPathState(s);
this._renderShapeSymbol(elem, pState, ltscale, color, pushSeg);
} else if (elem.type === 'text') {
const pState = getPathState(s);
if (elem.text && pendingTexts) {
// 1. Data-driven Text Height: (fixedH * elem.scale) or (elem.scale * 0.5) to match arrowhead scale ratio
const sName = (elem.style || 'STANDARD').toUpperCase();
const fixedH = this._textStyleHeightMap?.get(sName) || 0;
const sFactor = (elem.scale > 0 ? elem.scale * 0.5 : 0.1);
const baseH = fixedH > 0 ? (fixedH * sFactor) : sFactor;
const h = baseH * ltscale;
let angle = pState.angle + ((elem.rotation || 0) * Math.PI) / 180;
if (elem.isAbsoluteAngle) {
angle = ((elem.rotation || 0) * Math.PI) / 180;
}
// CAD Standard Upright Rule: text must always read from bottom or right (-90deg to +90deg)
let normA = Math.atan2(Math.sin(angle), Math.cos(angle));
if (normA > Math.PI / 2) normA -= Math.PI;
else if (normA < -Math.PI / 2) normA += Math.PI;
angle = normA;
const cosT = Math.cos(angle);
const sinT = Math.sin(angle);
const xo = (elem.xOffset || 0) * ltscale;
// Vertical mid alignment: set yo = 0 so text middle lies directly on line center axis
const yo = 0;
// 2. Exact Visual Midpoint: determine primary text gap (previous vs next gap)
const prevElem = elements[(elemIdx - 2 + elements.length) % elements.length];
const nextElem = elements[elemIdx % elements.length];
const prevGapLen = (prevElem && prevElem.type === 'gap') ? Math.abs(prevElem.val || 0) * ltscale : 0;
const nextGapLen = (nextElem && nextElem.type === 'gap') ? Math.abs(nextElem.val || 0) * ltscale : 0;
let midState = pState;
if (prevGapLen > nextGapLen && prevGapLen > 1e-6) {
// Text belongs inside previous gap (e.g. H-DICHL06R reverse mode S2 = -0.99)
midState = getPathState(s - prevGapLen / 2);
} else if (nextGapLen > 1e-6) {
// Text belongs inside next gap (e.g. H-DICHL06 forward mode S3 = -0.74)
midState = getPathState(s + nextGapLen / 2);
}
const tx = midState.x + (xo * cosT - yo * sinT);
const ty = midState.y + (xo * sinT + yo * cosT);
pendingTexts.push({
text: elem.text,
pos: { x: tx, y: ty, z: midState.z },
height: Math.max(h, 0.01),
rotation: angle,
color,
alignH: 1, // center horizontally in gap
alignV: 2, // center vertically on line axis (vertical mid)
});
}
}
}
}
_renderShapeSymbol(elem, pState, ltscale, color, pushSeg) {
const name = (elem.shapeName || '').toUpperCase();
const sc = (elem.scale || 0.1) * ltscale;
const angle = elem.isAbsoluteAngle
? ((elem.rotation || 0) * Math.PI) / 180
: pState.angle + ((elem.rotation || 0) * Math.PI) / 180;
const xo = (elem.xOffset || 0) * ltscale;
const yo = (elem.yOffset || 0) * ltscale;
const cosA = Math.cos(pState.angle);
const sinA = Math.sin(pState.angle);
const cx = pState.x + (xo * cosA - yo * sinA);
const cy = pState.y + (xo * sinA + yo * cosA);
const cz = pState.z || 0;
const cosR = Math.cos(angle);
const sinR = Math.sin(angle);
const transform = (lx, ly) => ({
x: cx + (lx * cosR - ly * sinR) * sc,
y: cy + (lx * sinR + ly * cosR) * sc,
});
if (name.includes('KSC35') || name.includes('KSC36') || name.includes('KSC19') || name.includes('ARROW')) {
// Solid hatched arrowhead (matching AutoCAD KSC35 standard symbol)
const steps = 12;
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const lx = -0.5 + t;
const hy = 0.25 * (1 - t);
const top = transform(lx, hy);
const bot = transform(lx, -hy);
pushSeg(top.x, top.y, bot.x, bot.y, cz, color);
}
const p1 = transform(0.5, 0);
const p2 = transform(-0.5, 0.25);
const p3 = transform(-0.5, -0.25);
pushSeg(p1.x, p1.y, p2.x, p2.y, cz, color);
pushSeg(p2.x, p2.y, p3.x, p3.y, cz, color);
pushSeg(p3.x, p3.y, p1.x, p1.y, cz, color);
} else if (name.includes('CIRC') || name.includes('KSC11') || name.includes('KSC01')) {
const segs = 12;
let prev = transform(0.3, 0);
for (let i = 1; i <= segs; i++) {
const a = (i / segs) * Math.PI * 2;
const curr = transform(0.3 * Math.cos(a), 0.3 * Math.sin(a));
pushSeg(prev.x, prev.y, curr.x, curr.y, cz, color);
prev = curr;
}
} else if (name.includes('BOX') || name.includes('KSC02') || name.includes('KSC05')) {
const p1 = transform(-0.3, -0.3);
const p2 = transform(0.3, -0.3);
const p3 = transform(0.3, 0.3);
const p4 = transform(-0.3, 0.3);
pushSeg(p1.x, p1.y, p2.x, p2.y, cz, color);
pushSeg(p2.x, p2.y, p3.x, p3.y, cz, color);
pushSeg(p3.x, p3.y, p4.x, p4.y, cz, color);
pushSeg(p4.x, p4.y, p1.x, p1.y, cz, color);
} else if (name.includes('TRACK1')) {
const p1 = transform(0, -0.4);
const p2 = transform(0, 0.4);
pushSeg(p1.x, p1.y, p2.x, p2.y, cz, color);
} else if (name.includes('BAT') || name.includes('DIAMOND')) {
const p1 = transform(0, 0.4);
const p2 = transform(0.4, 0);
const p3 = transform(0, -0.4);
const p4 = transform(-0.4, 0);
pushSeg(p1.x, p1.y, p2.x, p2.y, cz, color);
pushSeg(p2.x, p2.y, p3.x, p3.y, cz, color);
pushSeg(p3.x, p3.y, p4.x, p4.y, cz, color);
pushSeg(p4.x, p4.y, p1.x, p1.y, cz, color);
} else {
const p1 = transform(0.3, 0);
const p2 = transform(-0.3, 0.2);
const p3 = transform(-0.3, -0.2);
pushSeg(p1.x, p1.y, p2.x, p2.y, cz, color);
pushSeg(p2.x, p2.y, p3.x, p3.y, cz, color);
pushSeg(p3.x, p3.y, p1.x, p1.y, cz, color);
}
}
_buildStyleMap(styles) {
if (!this._textStyleHeightMap) this._textStyleHeightMap = new Map();
this._textStyleHeightMap.clear();
if (!styles || !Array.isArray(styles)) return;
for (const st of styles) {
const sName = (st.name || st.styleName || '').toUpperCase();
const h = st.height ?? st.fixedTextHeight ?? 0;
if (sName && h > 0) {
this._textStyleHeightMap.set(sName, h);
}
}
}
_buildLayerMap(layers) {
this._layerByHandle.clear();
this._layerByName.clear();
this._layerNameByHandle = new Map();
this._layerLwByHandle = new Map();
this._layerLwByName = 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, this._isDark) ?? 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);
// Raw i16 lineweight (1/100 mm, or -1/-2/-3). DXF spells it 370.
const lw = l.lineWeight ?? l.lineweight ?? l.lineWeightRaw;
if (typeof lw === 'number') {
if (handle != null) this._layerLwByHandle.set(String(handle), lw);
if (name) this._layerLwByName.set(name, lw); // DXF layers carry no handle
}
}
}
/**
* Effective lineweight of an entity, in screen pixels (0 = hairline).
*
* ByLayer (-1) resolves against the LAYER table, ByBlock (-2) against the
* weight inherited from the owning INSERT, Default (-3) against LWDEFAULT.
* Anything at or below the CAD default stays hairline so drawings that never
* assigned a weight render exactly as before.
*/
_lwPx(entity, inheritedPx = 0) {
if (!this._lwDisplay) return 0;
let raw = entity?.entityHeader?.lineWeight ?? entity?.lineWeight ?? entity?.lineweight;
if (typeof raw !== 'number') return 0;
if (raw === -2) return inheritedPx; // ByBlock
if (raw === -1) { // ByLayer
const lh = entity.layerHandle?.value ?? entity.layerHandle;
const byHandle = lh != null ? this._layerLwByHandle.get(String(lh)) : undefined;
const ln = entity.layer ?? entity.layerName;
raw = byHandle ?? (ln != null ? this._layerLwByName.get(ln) : undefined) ?? -3;
if (raw === -1 || raw === -2) raw = -3; // layer can't be ByLayer/ByBlock
}
const mm = raw === -3 ? LW_DEFAULT_MM : raw / 100;
if (!(mm > LW_HAIRLINE_MM)) return 0;
return Math.min(LW_MAX_PX, Math.round(mm * LW_PX_PER_MM * 10) / 10);
}
// 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._dwgLinPatterns = 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) {
const patArr = Array.isArray(lt.pattern) ? lt.pattern : [];
this._ltPatterns.set(lt.name, patArr);
if (patArr.length > 0) {
const merged = mergeDwgLinetypePattern(lt.name, patArr, lt.description);
if (merged) {
this._dwgLinPatterns.set(lt.name.toUpperCase().trim(), merged);
}
}
}
}
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, this._isDark);
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._addObj(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._addObj(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);
}
}
/**
* MESH (AcDbSubDMesh) → wireframe segments.
*
* dwg-wasm emits the mesh as flat number arrays (see its `EntityType::Mesh`
* arm) because a civil road-surface mesh runs to ~10^5 vertices:
* vertices [x, y, z, …]
* edges deduplicated vertex-index pairs [a, b, …]
* faceList DXF group-93 layout — [n, i0 … i(n-1)] repeated, n-gons included
*
* The edge list is preferred: it is already deduplicated, so an edge shared by
* two triangles is drawn once instead of twice (~2× fewer segments on a TIN).
* faceList is the fallback for a mesh whose edge list is empty, and it is what
* a future filled pass would triangulate.
*
* Only the base (control) mesh is drawn. `subdivisionLevel > 0` means AutoCAD
* displays a Catmull-Clark refinement of these vertices; refining is not
* implemented, so such a mesh renders slightly more angular than in AutoCAD.
*
* `xf(x, y) -> [x, y]` optionally maps the coordinates (block-local INSERT,
* or model space seen through a paper-space viewport).
*/
_meshSegs(d, color, pushSeg, xf = null) {
const v = d.vertices;
if (!v?.length) return;
const n = (v.length / 3) | 0;
const seg = (a, b) => {
if (a === b || !(a >= 0 && a < n) || !(b >= 0 && b < n)) return;
let ax = v[a*3], ay = v[a*3+1];
let bx = v[b*3], by = v[b*3+1];
// pushSeg carries one z per segment; a sloped edge uses its midpoint
// elevation, which is what a top view needs for depth ordering.
const z = ((v[a*3+2] || 0) + (v[b*3+2] || 0)) / 2;
if (xf) { [ax, ay] = xf(ax, ay); [bx, by] = xf(bx, by); }
pushSeg(ax, ay, bx, by, z, color);
};
const edges = d.edges;
if (edges?.length >= 2) {
for (let i = 0; i + 1 < edges.length; i += 2) seg(edges[i], edges[i+1]);
return;
}
const fl = d.faceList;
if (!fl?.length) return;
for (let i = 0; i < fl.length; ) {
const cnt = fl[i++];
// A corrupt count would run the cursor off the end — stop instead.
if (!(cnt >= 3) || i + cnt > fl.length) break;
for (let k = 0; k < cnt; k++) seg(fl[i + k], fl[i + ((k + 1) % cnt)]);
i += cnt;
}
}
// 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._addObj(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._addObj(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._addObj(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._addObj(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._addObj(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, this._isDark);
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._addObj(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._curLayer = t.layer ?? '0'; // _drawMTextBackground emits via _addObj
this._drawMTextBackground(t, engine, minTextH);
}
}
// One merged mesh per CAD layer (not one per order): a layer toggle then
// just flips mesh.visible instead of rebuilding the whole text batch.
for (const [lname, items] of groupByLayer(list)) {
const batch = new SlugTextBatch(engine);
for (const t of items) {
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) continue;
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._addObj(mesh, lname);
}
}
} 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) {
this._curLayer = t.layer ?? '0';
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._addObj(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;
// Weight the INSERT itself carries — what a ByBlock child resolves to.
const insertLwPx = this._curLwPx;
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);
this._curLwPx = this._lwPx(be, insertLwPx);
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 === 'MESH' && bd.vertices?.length >= 3) {
this._meshSegs(bd, ecol, pushSeg, xf);
} 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 */ }
}
this._curLwPx = insertLwPx; // hand the INSERT's own weight back to the caller
}
// 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;
// Hidden layers stay in the buffers (only masked out of the draw index), so
// picking has to skip them explicitly.
const MH = this._hiddenLayers.size ? this._metaHidden : null;
for (let i = 0, j = 0; i < S.length; i += 4, j++) {
if (MH && MH[SM[j]]) continue;
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 (MH && MH[f.metaIdx]) continue;
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) {
if (this._selMeta) this._paintMeta(this._selMeta, null);
this._selMeta = meta || null;
if (meta) this._paintMeta(meta, this._selColor);
}
/**
* Paint every vertex an entity owns. Its segments are spread over the
* hairline batch plus one run per dash/lineweight bucket it touched
* (meta.spans), so all of them get recolored — or restored when rgb is null.
*/
_paintMeta(meta, rgb) {
const hair = this._hairRt;
if (hair && meta.colEnd > meta.colStart) this._paintSpan(hair, meta.colStart, meta.colEnd, rgb);
if (!meta.spans) return;
for (const s of meta.spans) {
const bk = s.bk;
if (bk.merged) {
if (hair) this._paintSpan(hair, s.start + bk.mergeOffset, s.end + bk.mergeOffset, rgb);
} else if (bk.rt) {
this._paintSpan(bk.rt, s.start, s.end, rgb);
}
}
}
/** Write one color run: `rgb` to select, null to restore the original color. */
_paintSpan(rt, start, end, rgb) {
if (rt.attr) { // plain vertex-color batch (hairline / thin dashes)
const arr = rt.attr.array, orig = rt.orig;
for (let i = start; i < end && i < arr.length; i += 3) {
if (rgb) { arr[i] = rgb.r; arr[i + 1] = rgb.g; arr[i + 2] = rgb.b; }
else { arr[i] = orig[i]; arr[i + 1] = orig[i + 1]; arr[i + 2] = orig[i + 2]; }
}
rt.attr.needsUpdate = true;
return;
}
// Fat batch: hiding a layer compacts the instance buffer, so segment s of
// the source colors may now live at a different slot (slotOf, -1 = hidden).
const arr = rt.colBuf?.array, orig = rt.colSrc, slotOf = rt.slotOf;
if (!arr || !orig) return;
for (let i = start; i < end && i < orig.length; i += 3) {
const slot = slotOf ? slotOf[(i / 6) | 0] : (i / 6) | 0;
if (slot < 0) continue;
const t = slot * 6 + (i % 6);
if (rgb) { arr[t] = rgb.r; arr[t + 1] = rgb.g; arr[t + 2] = rgb.b; }
else { arr[t] = orig[i]; arr[t + 1] = orig[i + 1]; arr[t + 2] = orig[i + 2]; }
}
rt.colBuf.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); }
}
/** Get currently selected CAD entity (or null). */
getSelectedEntity() {
return this._selMeta ? this._selMeta.entity : null;
}
/** Zoom extents to the currently selected CAD entity. */
zoomToSelection() {
if (!this._selMeta) return;
const idx = this._entityMeta.indexOf(this._selMeta);
if (idx < 0) return;
const box = new THREE.Box3();
const S = this._pickSegs, SM = this._pickSegMeta;
if (S && SM) {
for (let i = 0, j = 0; i < S.length; i += 4, j++) {
if (SM[j] === idx) {
box.expandByPoint(new THREE.Vector3(S[i], S[i + 1], 0));
box.expandByPoint(new THREE.Vector3(S[i + 2], S[i + 3], 0));
}
}
}
if (this._pickFills) {
for (const f of this._pickFills) {
if (f.metaIdx === idx) {
for (const loop of f.loops || []) {
for (const p of loop) {
box.expandByPoint(new THREE.Vector3(p.x, p.y, 0));
}
}
}
}
}
if (box.isEmpty() && this._selMeta.entity) {
const e = this._selMeta.entity;
const d = e.data || e;
const pt = d.start || d.center || d.pos || d.insertionPoint || d.insertionPt;
if (pt) {
const pad = 10;
box.expandByPoint(new THREE.Vector3(pt.x - pad, pt.y - pad, 0));
box.expandByPoint(new THREE.Vector3(pt.x + pad, pt.y + pad, 0));
}
}
if (!box.isEmpty()) {
this._fit(box);
}
}
_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;
this._fullContentBox = null;
this._layerObjs.clear();
this._layerBoxes.clear();
this._layerSamples.clear();
this._indexedBufs = [];
this._fatBufs = [];
this._layerNames = null;
this._metaHidden = null;
this._curLayer = null;
this._curLayerId = -1;
this._layerInfoCache = 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();
// Fat lines size themselves against the canvas resolution — restate it.
for (const buf of this._fatBufs) this._sizeLineMaterial(buf.mesh.material);
}
_animate() {
requestAnimationFrame(() => this._animate());
this._controls.update();
this._renderer.render(this._scene, this._camera);
}
}