Files
dwg-dxf-viewer-sample/src/viewer2d/Viewer2D.js
T
minsungandClaude Opus 5 c67a803e4d feat(viewer2d): render DWG lineweight in screen pixels
Every line was drawn 1px regardless of its CAD lineweight. Two causes:
the dwg-wasm parseResult never carried the field (acadrust reads
EntityCommon::line_weight but it was dropped in serialization), and the
renderer merged all segments into one LineSegments whose
LineBasicMaterial.linewidth WebGL ignores.

Resolve ByLayer/ByBlock/Default per entity and bucket segments by
dash|lineweight. Weighted buckets get their own LineSegments2 +
LineMaterial with worldUnits:false, so width stays constant in pixels
while zooming - AutoCAD LWDISPLAY semantics - at 8 px/mm (0.25mm = 2px).
Anything at or below the 0.25mm default stays in the hairline batch so
drawings that never assigned a weight render exactly as before.

Fat batches are instanced, so layer masking compacts the instance buffer
and lowers instanceCount instead of rewriting an index; slotOf tracks
where each segment moved so the selection highlight still finds its
vertices. Per-entity color spans (meta.spans) let the highlight repaint
across the hairline batch and every bucket it touched. Buckets past
400k segments fall back to hairline to bound GPU/heap cost.

Display follows the drawing's LWDISPLAY and can be forced from the
toolbar; the property panel now shows the entity lineweight.

Verified headless at a fixed camera on the 65k-entity road drawing:
cyan road edge 2px -> 5px, layer hide/restore returns the exact baseline
pixel counts, and a 0.35mm LWPOLYLINE selects and highlights.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:25:11 +09:00

4247 lines
189 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Viewer2D — three.js renderer for DWG/DXF parseResult (orthographic 2D drawing).
* Ported from webviewer/src/viewer/Viewer3D.js (class renamed 3D→2D) for the
* 2D+3D merge. Renders CAD entities as LineSegments / Mesh / CanvasTexture sprites.
* Supported: LINE, CIRCLE, ARC, LWPOLYLINE, POLYLINE, POINT, ELLIPSE, SOLID,
* TEXT, MTEXT, INSERT (ownerHandle block expansion), HATCH (solid+outline+bulge),
* 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 12 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 <img>)
const decodeBmpDataUrl = (dataUrl) => {
const b64 = dataUrl.split(',')[1];
const bin = atob(b64);
const buf = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
const dv = new DataView(buf.buffer);
// BITMAPFILEHEADER (14 bytes)
const sig = String.fromCharCode(buf[0], buf[1]);
if (sig !== 'BM') throw new Error('Not a BMP file');
const pixelOffset = dv.getUint32(10, true);
// BITMAPINFOHEADER (40 bytes, starts at offset 14)
const bmpWidth = dv.getInt32(18, true);
const bmpHeight = dv.getInt32(22, true); // positive = bottom-up
const bitCount = dv.getUint16(28, true);
const absH = Math.abs(bmpHeight);
const flipY = bmpHeight > 0; // bottom-up storage
const cvs = document.createElement('canvas');
cvs.width = bmpWidth;
cvs.height = absH;
const ctx2 = cvs.getContext('2d');
const imgData = ctx2.createImageData(bmpWidth, absH);
const px = imgData.data;
if (bitCount === 32) {
const bpr = bmpWidth * 4;
for (let y = 0; y < absH; y++) {
const srcRow = flipY ? (absH - 1 - y) : y;
const srcOff = pixelOffset + srcRow * bpr;
const dstOff = y * bmpWidth * 4;
for (let x = 0; x < bmpWidth; x++) {
const s = srcOff + x * 4;
const d = dstOff + x * 4;
px[d] = buf[s + 2]; // R (BMP stores BGR)
px[d + 1] = buf[s + 1]; // G
px[d + 2] = buf[s]; // B
px[d + 3] = 255; // A — GDI 32bpp has zeroed alpha padding
}
}
} else if (bitCount === 24) {
const stride = Math.ceil(bmpWidth * 3 / 4) * 4; // row padded to 4 bytes
for (let y = 0; y < absH; y++) {
const srcRow = flipY ? (absH - 1 - y) : y;
const srcOff = pixelOffset + srcRow * stride;
const dstOff = y * bmpWidth * 4;
for (let x = 0; x < bmpWidth; x++) {
const s = srcOff + x * 3;
const d = dstOff + x * 4;
px[d] = buf[s + 2];
px[d + 1] = buf[s + 1];
px[d + 2] = buf[s];
px[d + 3] = 255;
}
}
}
ctx2.putImageData(imgData, 0, 0);
return cvs;
};
const bmpCanvas = decodeBmpDataUrl(imageDataUrl);
const texture = new THREE.CanvasTexture(bmpCanvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.needsUpdate = true;
const planeGeom = new THREE.PlaneGeometry(width, Math.abs(height));
const planeMat = new THREE.MeshBasicMaterial({
map: texture,
side: THREE.DoubleSide,
transparent: false,
});
const mesh = new THREE.Mesh(planeGeom, planeMat);
mesh.position.set(minX + width / 2, minY + Math.abs(height) / 2, z + 10);
this._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
// ~200300 drawing units while native TEXT height is ~0.33.5 → every
// label becomes a giant billboard over the contours (MOT/FLOW etc.).
// Cap the floor at the median native height so we never upscale past CAD.
const sz = box.isEmpty() ? new THREE.Vector3(1, 1, 0) : box.getSize(new THREE.Vector3());
const diagSize = Math.hypot(Math.max(sz.x, 1), Math.max(sz.y, 1));
const heights = [];
for (const t of pendingTexts) {
const h = t.height;
if (h > 0 && Number.isFinite(h)) heights.push(h);
}
heights.sort((a, b) => a - b);
const medianH = heights.length ? heights[heights.length >> 1] : 2.5;
const minTextH = Math.min(diagSize * 0.0002, medianH);
// Cap sprite count to avoid GPU/heap OOM on large drawings. With the
// per-(text,color) texture cache in _textSprite, memory scales with
// UNIQUE strings, so the cap is a sprite-object guard, not a raster one
// (3000 previously culled half the texts on table sheets, e.g. BLOCK4).
const MAX_TEXTS = 20000;
const underlay = pendingTexts.filter((t) => t.underlay);
const overlay = pendingTexts.filter((t) => !t.underlay);
const ordered = underlay.concat(overlay);
const renderTexts = ordered.length > MAX_TEXTS
? (console.warn(`텍스트 ${ordered.length}개 → ${MAX_TEXTS}개로 제한`),
// Prefer keeping paper (overlay) labels when capping
overlay.concat(underlay).sort((a, b) => b.height - a.height).slice(0, MAX_TEXTS))
: ordered;
void this._drawTexts(renderTexts, minTextH);
}
this._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<string>).
* 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,
};
}
/** CohenSutherland clip; calls out(x1,y1,x2,y2) for the visible segment (if any). */
_clipSegToRect(x1, y1, x2, y2, rect, out) {
const INSIDE = 0, LEFT = 1, RIGHT = 2, BOTTOM = 4, TOP = 8;
const code = (x, y) => {
let c = INSIDE;
if (x < rect.minX) c |= LEFT;
else if (x > rect.maxX) c |= RIGHT;
if (y < rect.minY) c |= BOTTOM;
else if (y > rect.maxY) c |= TOP;
return c;
};
let c1 = code(x1, y1), c2 = code(x2, y2);
for (;;) {
if (!(c1 | c2)) { out(x1, y1, x2, y2); return; }
if (c1 & c2) return;
const c = c1 || c2;
let x = 0, y = 0;
if (c & TOP) {
x = x1 + (x2 - x1) * (rect.maxY - y1) / (y2 - y1 || 1e-30);
y = rect.maxY;
} else if (c & BOTTOM) {
x = x1 + (x2 - x1) * (rect.minY - y1) / (y2 - y1 || 1e-30);
y = rect.minY;
} else if (c & RIGHT) {
y = y1 + (y2 - y1) * (rect.maxX - x1) / (x2 - x1 || 1e-30);
x = rect.maxX;
} else {
y = y1 + (y2 - y1) * (rect.minX - x1) / (x2 - x1 || 1e-30);
x = rect.minX;
}
if (c === c1) { x1 = x; y1 = y; c1 = code(x1, y1); }
else { x2 = x; y2 = y; c2 = code(x2, y2); }
}
}
/**
* Emit model-space geometry into a paper VIEWPORT (transformed + clipped).
* Covers the entity types that dominate civil plan/profile sheets.
*/
_emitModelThroughViewport(vp, ctx) {
const {
entities, modelAllowed, blockDefHandles, entsByOwner, blockBaseByHex,
pushSeg: basePush, expand: baseExpand, pendingTexts, 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 ~1100 in block units).
// Refuse huge loops even inside blocks (defensive).
let sa = 0;
for (let i = 0; i < points.length; i++) {
const p0 = points[i], p1 = points[(i + 1) % points.length];
sa += p0.x * p1.y - p1.x * p0.y;
}
const absA = Math.abs(sa) / 2;
if (absA < 1e-6 || absA > 5000) return;
let cx = 0, cy = 0;
for (const p of points) { cx += p.x; cy += p.y; }
cx /= points.length; cy /= points.length;
for (const f of this._pickFills) {
if (this._pointInAnyLoop(f.loops, cx, cy)) return;
}
this._hatchFill([{ points, bulges: bulges || null, closed: true }], color);
}
// Solid fill HATCH using THREE.ShapeGeometry
// Tessellate one (closed) hatch boundary loop → flat {x,y}[] (resolves bulge arcs,
// including the closing edge — a 2-point/2-bulge loop is a full circle).
_pathPoints(path) {
const pts = path.points;
if (!pts?.length) return null;
const bulges = path.bulges;
const n = pts.length;
if (!bulges?.some(b => Math.abs(b) >= 1e-6)) return pts.map(p => ({ x: p.x, y: p.y }));
const out = [];
for (let i = 0; i < n; i++) {
const p1 = pts[i], p2 = pts[(i + 1) % n], bulge = bulges[i] || 0;
out.push({ x: p1.x, y: p1.y });
if (Math.abs(bulge) < 1e-6) continue;
const dx = p2.x - p1.x, dy = p2.y - p1.y, dist = Math.hypot(dx, dy);
if (dist < 1e-9) continue;
const inc = 4 * Math.atan(bulge), half = Math.abs(inc) / 2;
const radius = (dist / 2) / Math.sin(half), apo = radius * Math.cos(half);
const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2;
const nx = -dy / dist, ny = dx / dist, sign = bulge > 0 ? 1 : -1;
const cx = mx + sign * nx * apo, cy = my + sign * ny * apo;
const sa = Math.atan2(p1.y - cy, p1.x - cx);
const segs = Math.max(6, Math.ceil(Math.abs(inc) / (Math.PI / 16)));
for (let j = 1; j < segs; j++) { // intermediate arc points; p2 added next iter
const a = sa + inc * j / segs;
out.push({ x: cx + radius * Math.cos(a), y: cy + radius * Math.sin(a) });
}
}
return out;
}
// Solid fill with true even-odd parity across all boundary loops.
//
// DWG solid HATCH (style Normal / odd parity) is even-odd, not "outer +
// nested holes". Text-as-hatch logos (CXGLOGO 한국도로공사) ship
// self-intersecting multi-stroke loops where ShapeGeometry/earcut + geometric
// nesting mis-classifies ㅇ/ㅎ counters. Verified against samples/11.dwg:
// SVG fill-rule=evenodd over all non-outlier loops matches the CAD logo.
_hatchFill(paths, color) {
const totalPts = paths.reduce((s, p) => s + (p.points?.length ?? 0), 0);
if (totalPts > 8000) return; // safety for pathological boundaries
let polys = [];
for (const p of paths) { const pp = this._pathPoints(p); if (pp && pp.length >= 3) polys.push(pp); }
if (!polys.length) return;
const area = (poly) => { let s = 0; for (let i = 0; i < poly.length; i++) { const a = poly[i], b = poly[(i + 1) % poly.length]; s += a.x * b.y - b.x * a.y; } return Math.abs(s) / 2; };
// Drop spurious giant boundaries (parse-corrupt arcs expanded to huge loops).
// Many-loop hatches (≥5) or empty-loop hatches get median×10 outlier filter.
if ((paths.some(p => !(p.points?.length)) && polys.length >= 3) || polys.length >= 5) {
const sorted = polys.map(area).sort((a, b) => a - b);
const med = sorted[Math.floor(sorted.length / 2)] || 0;
if (med > 0) polys = polys.filter(p => area(p) <= med * 10);
if (!polys.length) return;
}
// Picking uses even-odd across all loops (holes exclude correctly).
if (this._pickCurIdx >= 0 && polys.length) this._pickFills.push({ metaIdx: this._pickCurIdx, loops: polys });
// When does this hatch need true even-odd (vs independent union of islands)?
// - Self-intersecting loops (text-as-hatch glyph strokes)
// - Many loops (≥5): logo words like 한국도로공사
// - Geometric nesting: a smaller loop sits inside a larger one (island/hole)
// Separate solid islands that only touch/overlap (EX logo 4 paths) must be
// UNION — even-odd would punch diamond holes at the overlaps.
const needsEvenOdd =
polys.length >= 5 ||
polys.some((p) => this._polySelfIntersects(p)) ||
this._hatchHasNestedLoop(polys);
if (needsEvenOdd) {
this._hatchFillEvenOddCanvas(polys, color);
return;
}
// Union of simple islands: one ShapeGeometry per loop (no hole punching).
const mat = new THREE.MeshBasicMaterial({ color: new THREE.Color(color), side: THREE.DoubleSide });
for (const poly of polys) {
try {
const shape = new THREE.Shape();
shape.moveTo(poly[0].x, poly[0].y);
for (let k = 1; k < poly.length; k++) shape.lineTo(poly[k].x, poly[k].y);
shape.closePath();
this._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 12 far junk vertices (or a
* degenerate polyline) do not dominate Zoom Fit. Falls back to fullBox.
* @param {number[]} samples flat [x,y,x,y,…]
* @param {THREE.Box3} fullBox
*/
_trimmedContentBox(samples, fullBox) {
if (!fullBox || fullBox.isEmpty()) return null;
const n = samples ? (samples.length >> 1) : 0;
if (n < 32) return fullBox.clone();
const xs = new Float64Array(n);
const ys = new Float64Array(n);
let k = 0;
for (let i = 0; i + 1 < samples.length; i += 2) {
const x = samples[i], y = samples[i + 1];
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
xs[k] = x; ys[k] = y; k++;
}
if (k < 32) return fullBox.clone();
const xsl = xs.subarray(0, k);
const ysl = ys.subarray(0, k);
xsl.sort();
ysl.sort();
// 0.5% … 99.5% — drops a handful of extreme verts on ~100k+ samples
const lo = Math.max(0, Math.floor(k * 0.005));
const hi = Math.min(k - 1, Math.floor(k * 0.995));
let minX = xsl[lo], maxX = xsl[hi], minY = ysl[lo], maxY = ysl[hi];
if (!(maxX > minX) || !(maxY > minY)) return fullBox.clone();
// Pad slightly so content is not flush to the view edge
const padX = Math.max((maxX - minX) * 0.02, 1);
const padY = Math.max((maxY - minY) * 0.02, 1);
const box = new THREE.Box3(
new THREE.Vector3(minX - padX, minY - padY, 0),
new THREE.Vector3(maxX + padX, maxY + padY, 0),
);
// Never expand beyond the true full box
box.min.max(fullBox.min);
box.max.min(fullBox.max);
return box;
}
/**
* Zoom camera to a world AABB of visible content (camera-local frustum + pan).
* `box` must already reflect only currently drawn entities (space/layer filter).
*
* Civil model drawings are often extreme horizontal strips (aspect 50200:1).
* Classic "contain" fit then letterboxes so the strip is only a few pixels tall
* (looks like a single line at the top/center). For such strips, fit so the
* **short axis fills the screen** (Y fills for wide strips); pan to see the rest.
*/
_fit(box) {
if (!box || box.isEmpty()) return;
const c = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const w = this._container.clientWidth || 800;
const h = this._container.clientHeight || 600;
const aspect = w / Math.max(h, 1);
const pad = 1.12;
const bw = Math.max(size.x, 1e-9);
const bh = Math.max(size.y, 1e-9);
const contentAspect = bw / bh;
let vw, vh;
// Extreme strip: prefer filling the short side so geometry is readable.
// Threshold ~8× screen aspect (e.g. content 16:1 on a 2:1 window).
if (contentAspect > aspect * 8) {
// Very wide (plan corridor / bb-style double box): fill view height
vh = bh * pad;
vw = vh * aspect;
} else if (contentAspect < aspect / 8) {
// Very tall: fill view width
vw = bw * pad;
vh = vw / aspect;
} else {
// Normal sheet / roughly viewport-shaped content: classic contain
vw = bw * pad;
vh = bh * pad;
if (vw / vh > aspect) vh = vw / aspect;
else vw = vh * aspect;
}
this._camera.zoom = 1;
this._camera.left = -vw / 2; this._camera.right = vw / 2;
this._camera.top = vh / 2; this._camera.bottom = -vh / 2;
const span = Math.max(vw, vh, 1000);
this._camera.near = -span;
this._camera.far = span;
this._camera.position.set(c.x, c.y, 100);
const a = -(this._viewTwist || 0);
this._camera.up.set(-Math.sin(a), Math.cos(a), 0);
this._controls.target.set(c.x, c.y, 0);
this._camera.lookAt(this._controls.target);
this._camera.updateProjectionMatrix();
this._camera.updateMatrixWorld(true);
this._controls.update();
}
_clear() {
for (let i = this._group.children.length - 1; i >= 0; i--) {
const o = this._group.children[i];
o.geometry?.dispose();
if (o.material) { o.material.map?.dispose(); o.material.dispose(); }
this._group.remove(o);
}
// Shared text textures were disposed via the sprites above (re-dispose is a
// no-op); drop the cache so the next load rasterizes fresh.
this._texCache?.clear();
this._textGen++; // invalidate any in-flight async text flush
this._contentBox = null;
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);
}
}