3729 lines
166 KiB
JavaScript
3729 lines
166 KiB
JavaScript
/**
|
||
* Viewer2D — three.js renderer for DWG/DXF parseResult (orthographic 2D drawing).
|
||
* Ported from webviewer/src/viewer/Viewer3D.js (class renamed 3D→2D) for the
|
||
* 2D+3D merge. Renders CAD entities as LineSegments / Mesh / CanvasTexture sprites.
|
||
* Supported: LINE, CIRCLE, ARC, LWPOLYLINE, POLYLINE, POINT, ELLIPSE, SOLID,
|
||
* TEXT, MTEXT, INSERT (ownerHandle block expansion), HATCH (solid+outline+bulge),
|
||
* DIMENSION_LINEAR/ALIGNED/RADIUS/DIAMETER/ANG_3PT/ANG_2LN/ORDINATE
|
||
*/
|
||
import * as THREE from 'three';
|
||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||
import { aciToHex } from './aciColors.js';
|
||
import { buildAllowedOwnerHexes, listCadSpaces } from './cadSpaces';
|
||
import { SlugTextEngine, SlugTextBatch } from './slugText';
|
||
import { getLinPattern, mergeDwgLinetypePattern } from './linParser';
|
||
|
||
const DEFAULT_COLOR = 0xc9d1d9;
|
||
const ARC_SEGS = 64;
|
||
const ELLIPSE_SEGS = 72;
|
||
const CLICK_THRESHOLD_PX = 14;
|
||
|
||
function stripMText(s) {
|
||
if (!s) return '';
|
||
return s
|
||
.replace(/\\A\d+;/g, '') // \A1; vertical alignment
|
||
.replace(/\\p[^;]*;/g, '') // \p...; paragraph properties (lowercase, has ;)
|
||
.replace(/\\P/g, '\n') // \P paragraph break → newline (capital, no ;)
|
||
.replace(/\\[a-zA-Z][^;]*;/g, '') // remaining inline codes \f \H \C \W \Q \T...
|
||
.replace(/\{[^{}]*\}/g, m => stripMText(m.slice(1, -1)))
|
||
.replace(/%%d/gi, '°').replace(/%%p/gi, '±').replace(/%%c/gi, 'Ø')
|
||
.replace(/[{}]/g, '')
|
||
.replace(/[ \t]{2,}/g, ' ') // collapse runs of spaces
|
||
.replace(/^[ \t]+|[ \t]+$/gm, '') // trim each line's edges (keeps interior \n)
|
||
.replace(/\n{3,}/g, '\n\n')
|
||
.replace(/^\n+|\n+$/g, ''); // drop leading/trailing blank lines
|
||
}
|
||
|
||
export class Viewer2D {
|
||
constructor(container) {
|
||
this._container = container;
|
||
this._entityMeta = [];
|
||
this._layerByHandle = new Map();
|
||
this._layerByName = new Map();
|
||
this._onSelectCb = null;
|
||
this._measureActive = false;
|
||
this._measureCb = null;
|
||
this._measurePtA = null;
|
||
this._measureMarkers = [];
|
||
this._textGen = 0; // invalidation token for the async Slug text flush
|
||
SlugTextEngine.shared().catch(() => {}); // warm the font load; sprite fallback covers failure
|
||
|
||
this._scene = new THREE.Scene();
|
||
this._scene.background = new THREE.Color(0x0d1117);
|
||
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) return;
|
||
box.expandByObject(obj);
|
||
const pos = obj.geometry.attributes?.position;
|
||
if (!pos?.array) return;
|
||
const arr = pos.array;
|
||
const step = Math.max(3, Math.floor(arr.length / 3 / 4000) * 3);
|
||
for (let i = 0; i + 1 < arr.length; i += step) {
|
||
if (Number.isFinite(arr[i]) && Number.isFinite(arr[i + 1])) samples.push(arr[i], arr[i + 1]);
|
||
}
|
||
});
|
||
if (box.isEmpty()) return;
|
||
const use = this._trimmedContentBox(samples, box) || box;
|
||
this._contentBox = use.clone();
|
||
this._fit(use);
|
||
}
|
||
|
||
load(result, opts = {}) {
|
||
this._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);
|
||
// Sheet orientation: the active model-space viewport's VIEWTWIST rotates the
|
||
// view so a drawing stored tilted in WCS (rotated survey sheets — header UCS
|
||
// stays identity) displays with its title border upright. Applied as a
|
||
// camera-up rotation in _fit (geometry untouched → picking stays exact).
|
||
this._viewTwist = this._readViewTwist(result);
|
||
// Geometric pick index — the ACTUAL rendered geometry, not a bounds proxy.
|
||
// _pickSegs: flat [ax,ay,bx,by,…] of every line segment (lines, arcs, circles,
|
||
// polylines, block wires, dimension leaders/arrows) with a parallel owner-meta
|
||
// index. _pickFills: filled regions (hatch loops, SOLID quads, text quads)
|
||
// tested by even-odd point-in-polygon. _onClick raycasts these directly.
|
||
this._pickSegs = [];
|
||
this._pickSegMeta = [];
|
||
this._pickFills = [];
|
||
this._pickCurIdx = -1;
|
||
|
||
const entities = result?.entities || [];
|
||
const lineVerts = [];
|
||
const lineColors = [];
|
||
// Dashed-linetype segments go into separate buckets keyed by dash|gap size;
|
||
// each becomes its own LineDashedMaterial LineSegments at assembly. Solid
|
||
// (Continuous / ByLayer→Continuous) segments stay in lineVerts/lineColors.
|
||
const dashBuckets = new Map();
|
||
let curDash = null; // {key,dash,gap} for the entity currently being emitted
|
||
const box = new THREE.Box3();
|
||
const _tmp = new THREE.Vector3();
|
||
const pendingTexts = [];
|
||
|
||
// Build set of user block definition handles.
|
||
// Block-def entities have ownerHandle = their block's handle. They're rendered
|
||
// via INSERT (_insertEntities) and must be SKIPPED in the main loop to avoid
|
||
// expanding the bounding box with block-local coordinates (near 0,0), which
|
||
// would make the real drawing appear as a tiny speck on zoom-extents.
|
||
const blockDefHandles = new Set();
|
||
// Also track model/paper space handles to verify correctness
|
||
const spaceHandles = new Set();
|
||
// Block header handle (hex) → basePoint, for nested INSERT transform composition.
|
||
const blockBaseByHex = new Map();
|
||
const blockNameByHex = new Map();
|
||
for (const b of (result?.tables?.blocks ?? [])) {
|
||
const name = (b.name ?? '').toLowerCase().replace(/\*/g, '').trim();
|
||
const h = typeof b.handle === 'object' ? b.handle?.value?.toString(16) : b.handle?.toString(16);
|
||
if (!h) continue;
|
||
if (b.basePoint) blockBaseByHex.set(h, b.basePoint);
|
||
blockNameByHex.set(h, (b.name ?? '').toLowerCase());
|
||
// System space blocks: any of these names → entity owner, render directly
|
||
if (name === 'model_space' || name === 'paper_space' ||
|
||
name === 'ms' || name === 'ps' ||
|
||
name.startsWith('model') || name.startsWith('paper') ||
|
||
name === '') {
|
||
spaceHandles.add(h);
|
||
} else {
|
||
blockDefHandles.add(h);
|
||
}
|
||
}
|
||
// Safety: if spaceHandles is empty we couldn't identify model space →
|
||
// disable the filter entirely to avoid hiding everything.
|
||
const useBlockFilter = spaceHandles.size > 0;
|
||
this._blockCount = blockDefHandles.size;
|
||
|
||
// If active space is set but no longer present (new file), clear it.
|
||
if (this._activeSpaceHex && spaceHandles.size && !spaceHandles.has(this._activeSpaceHex)) {
|
||
this._activeSpaceHex = null;
|
||
}
|
||
// Owner closure for ATTRIB under INSERT in the active space, etc.
|
||
const allowedOwners = this._activeSpaceHex
|
||
? buildAllowedOwnerHexes(entities, this._activeSpaceHex)
|
||
: null;
|
||
|
||
// Pre-group all entities by ownerHandle hex string for O(1) lookup.
|
||
// Without this, every INSERT/DIMENSION does entities.filter() = O(n) per call = O(n²) total.
|
||
const entsByOwner = new Map();
|
||
for (const ent of entities) {
|
||
const key = ent.ownerHandle?.value?.toString(16);
|
||
if (key != null) {
|
||
if (!entsByOwner.has(key)) entsByOwner.set(key, []);
|
||
entsByOwner.get(key).push(ent);
|
||
}
|
||
}
|
||
|
||
// AABB of whatever is actually drawn (current Model/Layout + layers).
|
||
// Samples feed percentile trim so Fit matches CAD extents (ignore 1–2 junk verts).
|
||
const fitSamples = [];
|
||
let fitSampleN = 0;
|
||
const expand = (x, y, z = 0) => {
|
||
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
||
box.expandByPoint(_tmp.set(x, y, z));
|
||
fitSampleN++;
|
||
if ((fitSampleN & 7) === 0 && fitSamples.length < 100000) fitSamples.push(x, y);
|
||
};
|
||
const pushSeg = (ax, ay, bx, by, z, color) => {
|
||
const r = ((color >> 16) & 0xFF) / 255;
|
||
const g = ((color >> 8) & 0xFF) / 255;
|
||
const b = (color & 0xFF) / 255;
|
||
if (curDash) {
|
||
let bk = dashBuckets.get(curDash.key);
|
||
if (!bk) { bk = { dash: curDash.dash, gap: curDash.gap, verts: [], colors: [] }; dashBuckets.set(curDash.key, bk); }
|
||
bk.verts.push(ax, ay, z, bx, by, z);
|
||
bk.colors.push(r, g, b, r, g, b);
|
||
} else {
|
||
lineVerts.push(ax, ay, z, bx, by, z);
|
||
lineColors.push(r, g, b, r, g, b);
|
||
}
|
||
if (this._pickCurIdx >= 0) { this._pickSegs.push(ax, ay, bx, by); this._pickSegMeta.push(this._pickCurIdx); }
|
||
expand(ax, ay, z); expand(bx, by, z);
|
||
};
|
||
|
||
// ── Layout draw order ────────────────────────────────────────────────
|
||
// AutoCAD: model shown through VIEWPORT sits *under* paper-space entities
|
||
// (title block, notes, borders). We used to append viewport geometry after
|
||
// the paper pass → VP content painted on top. Fix: emit VP model first
|
||
// (z slightly behind) so paper entities and paper text overdraw it.
|
||
//
|
||
// DWG draw-order: acadrust has SORTENTSTABLE (ACAD_SORTENTS) + SORTENTS
|
||
// header flag, but dwg-wasm parseResult does not export them yet — so we
|
||
// apply the standard Layout stacking: viewport content below paper.
|
||
const VIEWPORT_Z = -1; // camera looks from +Z; lower Z is behind
|
||
const spacesList = listCadSpaces(result);
|
||
const activeSp = spacesList.find((s) => s.handleHex === this._activeSpaceHex);
|
||
if (activeSp?.kind === 'paper') {
|
||
const modelSp = spacesList.find((s) => s.kind === 'model');
|
||
if (modelSp) {
|
||
const modelAllowed = buildAllowedOwnerHexes(entities, modelSp.handleHex);
|
||
const viewports = entities.filter((e) => {
|
||
if ((e.type || e.typeName || '').toUpperCase() !== 'VIEWPORT') return false;
|
||
const oh = e.ownerHandle?.value?.toString(16);
|
||
return oh === this._activeSpaceHex && this._isModelViewport(e);
|
||
});
|
||
const vpPush = (ax, ay, bx, by, _z, color) => {
|
||
pushSeg(ax, ay, bx, by, VIEWPORT_Z, color);
|
||
};
|
||
const vpExpand = (x, y, _z = 0) => expand(x, y, VIEWPORT_Z);
|
||
for (const vp of viewports) {
|
||
this._emitModelThroughViewport(vp, {
|
||
entities,
|
||
modelAllowed,
|
||
blockDefHandles,
|
||
entsByOwner,
|
||
blockBaseByHex,
|
||
blockNameByHex,
|
||
pushSeg: vpPush,
|
||
expand: vpExpand,
|
||
pendingTexts,
|
||
box,
|
||
_tmp,
|
||
underlay: true, // tag pending texts drawn under paper labels
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const e of entities) {
|
||
// Skip block-definition entities — they live at block-local coords and are
|
||
// rendered at correct world positions via INSERT / _insertEntities.
|
||
// Only apply filter when we've successfully identified model/paper space handles.
|
||
const ownerHex = e.ownerHandle?.value?.toString(16);
|
||
if (useBlockFilter && ownerHex && blockDefHandles.has(ownerHex)) continue;
|
||
// Model/Paper filter: only entities belonging to the active space (and
|
||
// their INSERT children such as ATTRIB). Other space geometry is hidden.
|
||
if (allowedOwners && ownerHex && !allowedOwners.has(ownerHex)) continue;
|
||
if (this._hiddenLayers?.size) {
|
||
const lh = e.layerHandle?.value ?? e.layerHandle;
|
||
const lname = (lh != null && this._layerNameByHandle.get(String(lh))) ?? e.layer ?? e.layerName;
|
||
if (lname && this._hiddenLayers.has(lname)) continue;
|
||
}
|
||
const d = e.data || e;
|
||
const type = (e.type || e.typeName || '').toUpperCase();
|
||
const color = this._entityColor(e);
|
||
const complexLt = this._resolveComplexLinetype(e);
|
||
if (complexLt) curDash = null;
|
||
else curDash = this._resolveDash(e);
|
||
const meta = { entity: e, type, bounds: null, colStart: lineColors.length };
|
||
this._entityMeta.push(meta);
|
||
this._pickCurIdx = this._entityMeta.length - 1; // owner for pick geometry emitted below
|
||
|
||
try {
|
||
switch (type) {
|
||
|
||
// ── Basic geometry ────────────────────────────────────────────────
|
||
case 'LINE':
|
||
if (d.start && d.end) {
|
||
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;
|
||
}
|
||
|
||
// ── Text ──────────────────────────────────────────────────────────
|
||
case 'TEXT':
|
||
case 'MTEXT': {
|
||
const raw = e.text ?? d.text ?? '';
|
||
const text = stripMText(raw);
|
||
const height = e.textHeight ?? e.height ?? d.textHeight ?? d.height ?? 2.5;
|
||
let rotation = 0, alignH = 0, alignV = 0, pos;
|
||
if (type === 'TEXT') {
|
||
rotation = e.rotationAngle ?? d.rotationAngle ?? 0;
|
||
alignH = e.horizAlignment ?? d.horizAlignment ?? 0;
|
||
alignV = e.vertAlignment ?? d.vertAlignment ?? 0;
|
||
// DXF group 72 = 4 ("Middle") centers the text both horizontally AND
|
||
// vertically on the alignment point — group 73 (vertical) is ignored.
|
||
// Without this, vA=0 (baseline) placed such text half a line too high
|
||
// (titleblock labels 노선이정/설계사/… floated up).
|
||
if (alignH === 4) alignV = 2;
|
||
// DWG: when alignment is non-default, alignmentPt is the actual anchor
|
||
// (insertionPt is the "first point" which may differ from anchor)
|
||
const useAlignPt = alignH !== 0 || alignV !== 0;
|
||
pos = useAlignPt
|
||
? (e.alignmentPt ?? d.alignmentPt ?? e.insertionPt ?? d.insertionPt ?? { x:0, y:0 })
|
||
: (e.insertionPt ?? d.insertionPt ?? d.insertionPoint ?? { x:0, y:0 });
|
||
} else {
|
||
const xd = e.xAxisDir ?? d.xAxisDir;
|
||
if (xd) rotation = Math.atan2(xd.y, xd.x);
|
||
// MTEXT: attachmentPoint 1-3=top, 4-6=mid, 7-9=bottom
|
||
// (parsers emit the field as `attachment`; keep both names)
|
||
const ap = e.attachment ?? d.attachment ?? e.attachmentPoint ?? d.attachmentPoint ?? 5;
|
||
alignH = ([1,4,7].includes(ap) ? 0 : [3,6,9].includes(ap) ? 2 : 1);
|
||
alignV = ([1,2,3].includes(ap) ? 3 : [7,8,9].includes(ap) ? 1 : 2);
|
||
pos = e.insertionPt ?? d.insertionPt ?? d.insertionPoint ?? { x:0, y:0 };
|
||
}
|
||
if (text) {
|
||
const entry = { text, pos, height, rotation, color, alignH, alignV };
|
||
if (type === 'MTEXT') {
|
||
entry.linespacingFactor = e.linespacingFactor ?? d.linespacingFactor ?? 1;
|
||
entry.rectWidth = e.rectWidth ?? d.rectWidth ?? 0;
|
||
entry.rectHeight = e.rectHeight ?? d.rectHeight ?? 0;
|
||
// Background mask (DXF 90/45/63): bit0=use fill color, bit1=window color, bit4=frame
|
||
entry.bgFillFlags = e.bgFillFlags ?? d.bgFillFlags ?? 0;
|
||
entry.bgScale = e.bgScale ?? d.bgScale ?? 1.5;
|
||
entry.bgColorIndex = e.bgColorIndex ?? d.bgColorIndex;
|
||
entry.bgColorRgb = e.bgColorRgb ?? d.bgColorRgb;
|
||
}
|
||
pendingTexts.push(entry);
|
||
meta.bounds = { type:'point', cx:pos.x, cy:pos.y };
|
||
this._addTextPick(text, pos, height, alignH, alignV);
|
||
expand(pos.x, pos.y, 0);
|
||
}
|
||
break;
|
||
}
|
||
|
||
// ── INSERT (block reference) ──────────────────────────────────────
|
||
case 'INSERT': {
|
||
const ip = e.insertionPt ?? d.insertionPt ?? d.insertionPoint;
|
||
const sx = d.scaleX ?? d.scale?.x ?? e.scale?.x ?? 1;
|
||
const sy = d.scaleY ?? d.scale?.y ?? e.scale?.y ?? 1;
|
||
const rot = d.rotation ?? e.rotation ?? 0;
|
||
const bhVal = e.blockHeaderHandle?.value ?? d.blockHeaderHandle?.value;
|
||
|
||
if (bhVal != null) {
|
||
// Block definition entities sit in the flat entities array,
|
||
// identified by ownerHandle.value === block header handle
|
||
const bHandleHex = bhVal.toString(16);
|
||
const bEnts = entsByOwner.get(bHandleHex) ?? [];
|
||
if (bEnts.length > 0) {
|
||
const bh = result?.tables?.blocks?.find(b => {
|
||
const bh2 = typeof b.handle === 'object' ? b.handle?.value : b.handle;
|
||
return bh2 === bhVal;
|
||
});
|
||
this._insertEntities(
|
||
bEnts,
|
||
{ insertionPoint: ip ?? {x:0,y:0,z:0}, xScale:sx, yScale:sy, rotation:rot, basePoint: bh?.basePoint },
|
||
color, pushSeg, pendingTexts,
|
||
{ entsByOwner, blockBase: blockBaseByHex }
|
||
);
|
||
}
|
||
}
|
||
if (ip) { meta.bounds = { type:'point', cx:ip.x, cy:ip.y }; expand(ip.x, ip.y, 0); }
|
||
break;
|
||
}
|
||
|
||
// ── HATCH ─────────────────────────────────────────────────────────
|
||
case 'HATCH': {
|
||
const paths = d.paths ?? e.paths;
|
||
const solidFill = d.solidFill ?? e.solidFill ?? false;
|
||
if (!paths?.length) break;
|
||
|
||
let firstPt = null;
|
||
for (const path of paths) {
|
||
if (!path.points?.length || path.points.length < 2) continue;
|
||
if (!firstPt) firstPt = path.points[0];
|
||
const hasBulge = path.bulges?.some(b => Math.abs(b) >= 1e-6);
|
||
if (hasBulge) {
|
||
this._bulgePolySegs(path.points, path.bulges, true, color, pushSeg, expand);
|
||
} else {
|
||
this._polylineSegs(path.points, true, 0, color, pushSeg);
|
||
path.points.forEach(p => expand(p.x, p.y, 0));
|
||
}
|
||
}
|
||
if (solidFill) this._hatchFill(paths, color);
|
||
if (firstPt) meta.bounds = { type:'point', cx:firstPt.x, cy:firstPt.y };
|
||
break;
|
||
}
|
||
|
||
// ── DIMENSION (all subtypes) ───────────────────────────────────────
|
||
case 'DIMENSION':
|
||
case 'DIMENSION_LINEAR':
|
||
case 'DIMENSION_ALIGNED':
|
||
case 'DIMENSION_RADIUS':
|
||
case 'DIMENSION_DIAMETER':
|
||
case 'DIMENSION_ANG_3PT':
|
||
case 'DIMENSION_ANG_2LN':
|
||
case 'DIMENSION_ORDINATE': {
|
||
const bhVal = e.blockHeaderHandle?.value ?? d.blockHeaderHandle?.value;
|
||
let dimEnts = [];
|
||
if (bhVal != null) {
|
||
const hex = bhVal.toString(16);
|
||
dimEnts = entsByOwner.get(hex) ?? [];
|
||
}
|
||
|
||
if (dimEnts.length > 0) {
|
||
// Render pre-built dimension block geometry
|
||
for (const child of dimEnts) {
|
||
const ct = (child.type || child.typeName || '').toUpperCase();
|
||
const cd = child.data || child;
|
||
const cc = this._entityColor(child) || color;
|
||
if (ct === 'LINE' && cd.start && cd.end) {
|
||
pushSeg(cd.start.x, cd.start.y, cd.end.x, cd.end.y, 0, cc);
|
||
expand(cd.start.x, cd.start.y); expand(cd.end.x, cd.end.y);
|
||
} else if (ct === 'SOLID') {
|
||
const crs = [cd.corner1||cd.pt1, cd.corner2||cd.pt2,
|
||
cd.corner3||cd.pt3, cd.corner4||cd.pt4].filter(Boolean);
|
||
if (crs.length >= 3) { this._solidMesh(crs, cc); crs.forEach(c => expand(c.x, c.y)); }
|
||
} else if (ct === 'ARC' && cd.center && cd.radius != null) {
|
||
this._arcSegs(cd.center, cd.radius, cd.startAngle??0, cd.endAngle??Math.PI*2, 0, cc, pushSeg);
|
||
} else if ((ct === 'TEXT' || ct === 'MTEXT')) {
|
||
const raw = child.text ?? cd.text ?? cd.textValue ?? '';
|
||
const text = stripMText(raw);
|
||
const pos = child.insertionPt ?? cd.insertionPt ?? cd.insertionPoint ?? {x:0,y:0};
|
||
const ht = cd.textHeight ?? cd.height ?? child.textHeight ?? 2.5;
|
||
if (text) { pendingTexts.push({ text, pos, height:ht, rotation: cd.rotationAngle??0, color:cc, alignH:1, alignV:2 }); expand(pos.x, pos.y); }
|
||
} else if (ct === 'INSERT') {
|
||
// Arrowhead block INSERT within the dimension block: render the real
|
||
// arrow geometry; fall back to a named default for geometry-less
|
||
// system blocks (_ClosedFilled / _Dot / _Oblique / _Open / …).
|
||
const cip = child.insertionPt ?? cd.insertionPt ?? cd.insertionPoint ?? {x:0,y:0};
|
||
const crot = cd.rotation ?? child.rotation ?? 0;
|
||
const csx = cd.scaleX ?? cd.scale?.x ?? child.scale?.x ?? 1;
|
||
const csy = cd.scaleY ?? cd.scale?.y ?? child.scale?.y ?? csx;
|
||
const abh = child.blockHeaderHandle?.value ?? cd.blockHeaderHandle?.value;
|
||
const aHex = abh?.toString(16);
|
||
const aname = blockNameByHex.get(aHex) ?? '';
|
||
const arrowEnts = aHex ? (entsByOwner.get(aHex) ?? []) : [];
|
||
if (aname.includes('dot')) {
|
||
// Dot arrowhead → filled disc (radius from the block's geometry × scale)
|
||
this._dotMesh(cip, this._arrowDotRadius(arrowEnts) * Math.abs(csx) || Math.abs(csx) * 0.5, cc);
|
||
} else if (arrowEnts.length) {
|
||
this._insertEntities(
|
||
arrowEnts,
|
||
{ insertionPoint: cip, xScale: csx, yScale: csy, rotation: crot, basePoint: blockBaseByHex.get(aHex) },
|
||
cc, pushSeg, pendingTexts,
|
||
{ entsByOwner, blockBase: blockBaseByHex }
|
||
);
|
||
} else {
|
||
this._defaultArrow(aname, cip, crot, Math.abs(csx) || 1, cc, pushSeg);
|
||
}
|
||
expand(cip.x, cip.y);
|
||
}
|
||
}
|
||
const fl = dimEnts.find(c => (c.type||c.typeName||'').toUpperCase() === 'LINE');
|
||
const fd = fl?.data || fl;
|
||
if (fd?.start) meta.bounds = { type:'point', cx:fd.start.x, cy:fd.start.y };
|
||
} else {
|
||
// Fallback: reconstruct from entity properties
|
||
this._renderDimFallback(e, d, type, color, pushSeg, pendingTexts, expand);
|
||
const pt10 = e.pt10 ?? d.pt10;
|
||
if (pt10) { meta.bounds = { type:'point', cx:pt10.x, cy:pt10.y }; expand(pt10.x, pt10.y); }
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'LEADER': {
|
||
const pts = d.points ?? e.points;
|
||
if (pts?.length >= 2) {
|
||
this._polylineSegs(pts, false, 0, color, pushSeg);
|
||
pts.forEach(p => expand(p.x, p.y, p.z || 0));
|
||
if (d.arrowheadOn ?? e.arrowheadOn ?? true) {
|
||
const p0 = pts[0], p1 = pts[1];
|
||
const rot = Math.atan2(p1.y - p0.y, p1.x - p0.x);
|
||
const arrowSz = d.arrowSize ?? e.arrowSize ?? 2.5;
|
||
this._arrowMesh(p0, rot, arrowSz, color);
|
||
}
|
||
meta.bounds = { type:'point', cx:pts[0].x, cy:pts[0].y };
|
||
}
|
||
break;
|
||
}
|
||
|
||
// ── OLE2FRAME (Embedded OLE Object) ──────────────────────────────
|
||
case 'OLE2FRAME': {
|
||
const ul = e.upperLeft ?? d.upperLeft ?? { x: 0, y: 0, z: 0 };
|
||
const lr = e.lowerRight ?? d.lowerRight ?? { x: 0, y: 0, z: 0 };
|
||
const appName = e.sourceApp ?? d.sourceApp ?? e.source_application ?? '';
|
||
const imageDataUrl = e.imageDataUrl ?? d.imageDataUrl;
|
||
|
||
// Use actual entity bounds from the WASM parser (upper_left / lower_right)
|
||
let minX = Math.min(ul.x, lr.x);
|
||
let maxX = Math.max(ul.x, lr.x);
|
||
let minY = Math.min(ul.y, lr.y);
|
||
let maxY = Math.max(ul.y, lr.y);
|
||
let width = maxX - minX;
|
||
let height = maxY - minY;
|
||
const z = ul.z || 0;
|
||
|
||
// Always draw border rectangle
|
||
pushSeg(minX, minY, maxX, minY, z, color);
|
||
pushSeg(maxX, minY, maxX, maxY, z, color);
|
||
pushSeg(maxX, maxY, minX, maxY, z, color);
|
||
pushSeg(minX, maxY, minX, minY, z, color);
|
||
|
||
// If embedded image texture (BMP/DIB) is present, render it as a 3D Plane Mesh!
|
||
if (imageDataUrl) {
|
||
try {
|
||
// Pure-JS BMP decoder — works in ALL browsers (Brave/Firefox don't support data:image/bmp in <img>)
|
||
const decodeBmpDataUrl = (dataUrl) => {
|
||
const b64 = dataUrl.split(',')[1];
|
||
const bin = atob(b64);
|
||
const buf = new Uint8Array(bin.length);
|
||
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
|
||
|
||
const dv = new DataView(buf.buffer);
|
||
// BITMAPFILEHEADER (14 bytes)
|
||
const sig = String.fromCharCode(buf[0], buf[1]);
|
||
if (sig !== 'BM') throw new Error('Not a BMP file');
|
||
const pixelOffset = dv.getUint32(10, true);
|
||
// BITMAPINFOHEADER (40 bytes, starts at offset 14)
|
||
const bmpWidth = dv.getInt32(18, true);
|
||
const bmpHeight = dv.getInt32(22, true); // positive = bottom-up
|
||
const bitCount = dv.getUint16(28, true);
|
||
const absH = Math.abs(bmpHeight);
|
||
const flipY = bmpHeight > 0; // bottom-up storage
|
||
|
||
const cvs = document.createElement('canvas');
|
||
cvs.width = bmpWidth;
|
||
cvs.height = absH;
|
||
const ctx2 = cvs.getContext('2d');
|
||
const imgData = ctx2.createImageData(bmpWidth, absH);
|
||
const px = imgData.data;
|
||
|
||
if (bitCount === 32) {
|
||
const bpr = bmpWidth * 4;
|
||
for (let y = 0; y < absH; y++) {
|
||
const srcRow = flipY ? (absH - 1 - y) : y;
|
||
const srcOff = pixelOffset + srcRow * bpr;
|
||
const dstOff = y * bmpWidth * 4;
|
||
for (let x = 0; x < bmpWidth; x++) {
|
||
const s = srcOff + x * 4;
|
||
const d = dstOff + x * 4;
|
||
px[d] = buf[s + 2]; // R (BMP stores BGR)
|
||
px[d + 1] = buf[s + 1]; // G
|
||
px[d + 2] = buf[s]; // B
|
||
px[d + 3] = 255; // A — GDI 32bpp has zeroed alpha padding
|
||
}
|
||
}
|
||
} else if (bitCount === 24) {
|
||
const stride = Math.ceil(bmpWidth * 3 / 4) * 4; // row padded to 4 bytes
|
||
for (let y = 0; y < absH; y++) {
|
||
const srcRow = flipY ? (absH - 1 - y) : y;
|
||
const srcOff = pixelOffset + srcRow * stride;
|
||
const dstOff = y * bmpWidth * 4;
|
||
for (let x = 0; x < bmpWidth; x++) {
|
||
const s = srcOff + x * 3;
|
||
const d = dstOff + x * 4;
|
||
px[d] = buf[s + 2];
|
||
px[d + 1] = buf[s + 1];
|
||
px[d + 2] = buf[s];
|
||
px[d + 3] = 255;
|
||
}
|
||
}
|
||
}
|
||
ctx2.putImageData(imgData, 0, 0);
|
||
return cvs;
|
||
};
|
||
|
||
const bmpCanvas = decodeBmpDataUrl(imageDataUrl);
|
||
const texture = new THREE.CanvasTexture(bmpCanvas);
|
||
texture.colorSpace = THREE.SRGBColorSpace;
|
||
texture.needsUpdate = true;
|
||
const planeGeom = new THREE.PlaneGeometry(width, Math.abs(height));
|
||
const planeMat = new THREE.MeshBasicMaterial({
|
||
map: texture,
|
||
side: THREE.DoubleSide,
|
||
transparent: false,
|
||
});
|
||
const mesh = new THREE.Mesh(planeGeom, planeMat);
|
||
mesh.position.set(minX + width / 2, minY + Math.abs(height) / 2, z + 10);
|
||
this._group.add(mesh);
|
||
this._renderer.render(this._scene, this._camera);
|
||
} catch (err) {
|
||
console.error('Failed rendering CanvasTexture for OLE image:', err);
|
||
}
|
||
} else {
|
||
// Draw diagonal cross + overlay label fallback
|
||
pushSeg(minX, minY, maxX, maxY, z, color);
|
||
pushSeg(minX, maxY, maxX, minY, z, color);
|
||
|
||
const hSpan = Math.abs(height);
|
||
const lblHeight = Math.max(Math.min(hSpan * 0.05, 300), 40);
|
||
const labelText = appName ? `[OLE: ${appName}]` : '[OLE2Frame]';
|
||
const labelPos = { x: (minX + maxX) / 2, y: (minY + maxY) / 2, z };
|
||
pendingTexts.push({
|
||
text: labelText,
|
||
pos: labelPos,
|
||
height: lblHeight,
|
||
rotation: 0,
|
||
color,
|
||
alignH: 1,
|
||
alignV: 2,
|
||
});
|
||
}
|
||
|
||
// Expand scene bounds & set meta bounds for picking/fitting
|
||
expand(minX, minY, z);
|
||
expand(maxX, maxY, z);
|
||
meta.bounds = { type: 'point', cx: (minX + maxX) / 2, cy: (minY + maxY) / 2 };
|
||
break;
|
||
}
|
||
|
||
// Paper-space VIEWPORT: drawn later as a model-space projection window.
|
||
case 'VIEWPORT':
|
||
break;
|
||
|
||
default: break;
|
||
}
|
||
} catch { /* skip malformed entity */ }
|
||
meta.colEnd = lineColors.length;
|
||
}
|
||
|
||
// ── Render deferred text (underlay / paper stack) ─────────────────────
|
||
// Viewport-sourced labels were pushed first (underlay:true); paper labels
|
||
// follow. Draw underlay first so paper text meshes land later = on top.
|
||
if (pendingTexts.length) {
|
||
// Historical floor: diag×0.0002 made paper-sheet labels readable at
|
||
// zoom-extents. On survey Model Space (diag ~1e6 m) that floor is
|
||
// ~200–300 drawing units while native TEXT height is ~0.3–3.5 → every
|
||
// label becomes a giant billboard over the contours (MOT/FLOW etc.).
|
||
// Cap the floor at the median native height so we never upscale past CAD.
|
||
const sz = box.isEmpty() ? new THREE.Vector3(1, 1, 0) : box.getSize(new THREE.Vector3());
|
||
const diagSize = Math.hypot(Math.max(sz.x, 1), Math.max(sz.y, 1));
|
||
const heights = [];
|
||
for (const t of pendingTexts) {
|
||
const h = t.height;
|
||
if (h > 0 && Number.isFinite(h)) heights.push(h);
|
||
}
|
||
heights.sort((a, b) => a - b);
|
||
const medianH = heights.length ? heights[heights.length >> 1] : 2.5;
|
||
const minTextH = Math.min(diagSize * 0.0002, medianH);
|
||
// Cap sprite count to avoid GPU/heap OOM on large drawings. With the
|
||
// per-(text,color) texture cache in _textSprite, memory scales with
|
||
// UNIQUE strings, so the cap is a sprite-object guard, not a raster one
|
||
// (3000 previously culled half the texts on table sheets, e.g. BLOCK4).
|
||
const MAX_TEXTS = 20000;
|
||
const underlay = pendingTexts.filter((t) => t.underlay);
|
||
const overlay = pendingTexts.filter((t) => !t.underlay);
|
||
const ordered = underlay.concat(overlay);
|
||
const renderTexts = ordered.length > MAX_TEXTS
|
||
? (console.warn(`텍스트 ${ordered.length}개 → ${MAX_TEXTS}개로 제한`),
|
||
// Prefer keeping paper (overlay) labels when capping
|
||
overlay.concat(underlay).sort((a, b) => b.height - a.height).slice(0, MAX_TEXTS))
|
||
: ordered;
|
||
void this._drawTexts(renderTexts, minTextH);
|
||
}
|
||
|
||
this._lineColorAttr = null; this._origColors = null;
|
||
if (lineVerts.length) {
|
||
const geom = new THREE.BufferGeometry();
|
||
geom.setAttribute('position', new THREE.Float32BufferAttribute(lineVerts, 3));
|
||
const colorAttr = new THREE.Float32BufferAttribute(lineColors, 3);
|
||
geom.setAttribute('color', colorAttr);
|
||
this._group.add(new THREE.LineSegments(geom, new THREE.LineBasicMaterial({ vertexColors: true })));
|
||
this._lineColorAttr = colorAttr;
|
||
this._origColors = Float32Array.from(colorAttr.array);
|
||
}
|
||
|
||
// Dashed linetypes (HIDDEN / CENTER / …) — one LineSegments per dash|gap
|
||
// bucket with a LineDashedMaterial. computeLineDistances() is REQUIRED for
|
||
// the dash pattern to appear; on LineSegments each 2-vertex pair dashes
|
||
// independently from its own start (correct for CAD segments).
|
||
for (const bk of dashBuckets.values()) {
|
||
if (!bk.verts.length) continue;
|
||
const dgeom = new THREE.BufferGeometry();
|
||
dgeom.setAttribute('position', new THREE.Float32BufferAttribute(bk.verts, 3));
|
||
dgeom.setAttribute('color', new THREE.Float32BufferAttribute(bk.colors, 3));
|
||
const dline = new THREE.LineSegments(dgeom, new THREE.LineDashedMaterial({
|
||
vertexColors: true, dashSize: bk.dash, gapSize: bk.gap,
|
||
}));
|
||
dline.computeLineDistances();
|
||
this._group.add(dline);
|
||
}
|
||
|
||
// Coordinate readout frame (Model UCS vs paper identity).
|
||
this._setCoordFrame(result);
|
||
|
||
// Visible-content AABB for Fit — trim extreme outliers (matches CAD zoom extents better).
|
||
const fitBox = box.isEmpty() ? null : (this._trimmedContentBox(fitSamples, box) || box);
|
||
this._contentBox = fitBox ? fitBox.clone() : null;
|
||
if (!opts.keepView && fitBox && !fitBox.isEmpty()) this._fit(fitBox);
|
||
} 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>); re-renders keeping the view. */
|
||
setHiddenLayers(nameSet) {
|
||
this._hiddenLayers = nameSet || new Set();
|
||
if (this._lastResult) this.load(this._lastResult, { keepView: true });
|
||
}
|
||
|
||
/**
|
||
* Model / Paper (Layout) spaces present in the last loaded result.
|
||
* @returns {import('./cadSpaces').CadSpace[]}
|
||
*/
|
||
getSpaces() {
|
||
return listCadSpaces(this._lastResult);
|
||
}
|
||
|
||
/** Currently active space handle hex, or null if unfiltered. */
|
||
getActiveSpace() {
|
||
return this._activeSpaceHex;
|
||
}
|
||
|
||
/**
|
||
* Switch Model ↔ Layout (paper) space and re-render.
|
||
* @param {string|number|null} spaceHandle hex string or numeric handle; null clears filter
|
||
* @param {{ keepView?: boolean }} [opts]
|
||
*/
|
||
setSpace(spaceHandle, opts = {}) {
|
||
if (spaceHandle == null || spaceHandle === '') {
|
||
this._activeSpaceHex = null;
|
||
} else if (typeof spaceHandle === 'number') {
|
||
this._activeSpaceHex = spaceHandle.toString(16);
|
||
} else {
|
||
this._activeSpaceHex = String(spaceHandle).toLowerCase();
|
||
}
|
||
if (this._lastResult) {
|
||
this.load(this._lastResult, {
|
||
keepView: !!opts.keepView,
|
||
spaceHandle: this._activeSpaceHex,
|
||
});
|
||
// load() refreshes coord frame for Model UCS vs paper identity
|
||
}
|
||
}
|
||
|
||
/**
|
||
* True if this VIEWPORT is a window into model space (not the overall paper VP).
|
||
* R2000 often stores id=0 for all VPs, so we detect the overall paper viewport by
|
||
* 1:1 scale + viewTarget≈0 + viewCenter≈paper center.
|
||
*/
|
||
_isModelViewport(vp) {
|
||
if (!vp) return false;
|
||
if (vp.status && vp.status.isOn === false) return false;
|
||
const id = vp.id ?? 0;
|
||
// Classic DXF: id 1 = overall paper-space viewport (not a model window).
|
||
if (id === 1) return false;
|
||
const H = Number(vp.height) || 0;
|
||
const vH = Number(vp.viewHeight) || 0;
|
||
if (H <= 0 || vH <= 0) return false;
|
||
const scale = H / vH;
|
||
const c = vp.center || {};
|
||
const vc = vp.viewCenter || {};
|
||
const vt = vp.viewTarget || {};
|
||
const targetNear0 = Math.hypot(vt.x || 0, vt.y || 0) < 1e-3;
|
||
const viewCtrNearPaper = Math.hypot((vc.x || 0) - (c.x || 0), (vc.y || 0) - (c.y || 0)) < Math.max(H, 1) * 0.05;
|
||
// Overall paper VP: identity mapping of the sheet onto itself.
|
||
if (Math.abs(scale - 1) < 0.03 && targetNear0 && viewCtrNearPaper) return false;
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Model WCS (x,y) → paper-space point for a VIEWPORT (orthographic / top).
|
||
* DCS = R(+twist) * (model - viewTarget) [matches acadrust/ODA view-center convention]
|
||
* paper = center + scale * (DCS - viewCenter)
|
||
* scale = paperHeight / viewHeight
|
||
*/
|
||
_viewportModelToPaper(vp) {
|
||
const c = vp.center || { x: 0, y: 0 };
|
||
const vc = vp.viewCenter || { x: 0, y: 0 };
|
||
const vt = vp.viewTarget || { x: 0, y: 0 };
|
||
const H = Number(vp.height) || 1;
|
||
const vH = (Number(vp.viewHeight) > 1e-12) ? Number(vp.viewHeight) : H;
|
||
const scale = H / vH;
|
||
const twist = Number(vp.twistAngle) || 0;
|
||
const cos = Math.cos(twist);
|
||
const sin = Math.sin(twist);
|
||
const fn = (mx, my) => {
|
||
const dx = mx - (vt.x || 0);
|
||
const dy = my - (vt.y || 0);
|
||
// R(+twist) · [dx, dy]
|
||
const rx = cos * dx - sin * dy;
|
||
const ry = sin * dx + cos * dy;
|
||
return {
|
||
x: (c.x || 0) + (rx - (vc.x || 0)) * scale,
|
||
y: (c.y || 0) + (ry - (vc.y || 0)) * scale,
|
||
};
|
||
};
|
||
fn.scale = scale;
|
||
fn.twist = twist;
|
||
return fn;
|
||
}
|
||
|
||
_viewportPaperClip(vp) {
|
||
const c = vp.center || { x: 0, y: 0 };
|
||
const hw = (Number(vp.width) || 0) / 2;
|
||
const hh = (Number(vp.height) || 0) / 2;
|
||
// Slight expand to avoid hairline gaps at edges
|
||
const pad = Math.max(hw, hh) * 1e-6;
|
||
return {
|
||
minX: (c.x || 0) - hw - pad,
|
||
maxX: (c.x || 0) + hw + pad,
|
||
minY: (c.y || 0) - hh - pad,
|
||
maxY: (c.y || 0) + hh + pad,
|
||
};
|
||
}
|
||
|
||
/** Cohen–Sutherland clip; calls out(x1,y1,x2,y2) for the visible segment (if any). */
|
||
_clipSegToRect(x1, y1, x2, y2, rect, out) {
|
||
const INSIDE = 0, LEFT = 1, RIGHT = 2, BOTTOM = 4, TOP = 8;
|
||
const code = (x, y) => {
|
||
let c = INSIDE;
|
||
if (x < rect.minX) c |= LEFT;
|
||
else if (x > rect.maxX) c |= RIGHT;
|
||
if (y < rect.minY) c |= BOTTOM;
|
||
else if (y > rect.maxY) c |= TOP;
|
||
return c;
|
||
};
|
||
let c1 = code(x1, y1), c2 = code(x2, y2);
|
||
for (;;) {
|
||
if (!(c1 | c2)) { out(x1, y1, x2, y2); return; }
|
||
if (c1 & c2) return;
|
||
const c = c1 || c2;
|
||
let x = 0, y = 0;
|
||
if (c & TOP) {
|
||
x = x1 + (x2 - x1) * (rect.maxY - y1) / (y2 - y1 || 1e-30);
|
||
y = rect.maxY;
|
||
} else if (c & BOTTOM) {
|
||
x = x1 + (x2 - x1) * (rect.minY - y1) / (y2 - y1 || 1e-30);
|
||
y = rect.minY;
|
||
} else if (c & RIGHT) {
|
||
y = y1 + (y2 - y1) * (rect.maxX - x1) / (x2 - x1 || 1e-30);
|
||
x = rect.maxX;
|
||
} else {
|
||
y = y1 + (y2 - y1) * (rect.minX - x1) / (x2 - x1 || 1e-30);
|
||
x = rect.minX;
|
||
}
|
||
if (c === c1) { x1 = x; y1 = y; c1 = code(x1, y1); }
|
||
else { x2 = x; y2 = y; c2 = code(x2, y2); }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Emit model-space geometry into a paper VIEWPORT (transformed + clipped).
|
||
* Covers the entity types that dominate civil plan/profile sheets.
|
||
*/
|
||
_emitModelThroughViewport(vp, ctx) {
|
||
const {
|
||
entities, modelAllowed, blockDefHandles, entsByOwner, blockBaseByHex,
|
||
pushSeg: basePush, expand: baseExpand, pendingTexts,
|
||
underlay = false,
|
||
} = ctx;
|
||
const xf = this._viewportModelToPaper(vp);
|
||
const clip = this._viewportPaperClip(vp);
|
||
const scale = xf.scale || 1;
|
||
const frozen = new Set((vp.frozenLayers || []).map((h) => String(h)));
|
||
|
||
const pushSeg = (ax, ay, bx, by, z, color) => {
|
||
const a = xf(ax, ay);
|
||
const b = xf(bx, by);
|
||
this._clipSegToRect(a.x, a.y, b.x, b.y, clip, (x1, y1, x2, y2) => {
|
||
basePush(x1, y1, x2, y2, z || 0, color);
|
||
});
|
||
};
|
||
const expand = (x, y, z = 0) => {
|
||
const p = xf(x, y);
|
||
if (p.x >= clip.minX && p.x <= clip.maxX && p.y >= clip.minY && p.y <= clip.maxY) {
|
||
baseExpand(p.x, p.y, z);
|
||
}
|
||
};
|
||
const inClip = (x, y) => x >= clip.minX && x <= clip.maxX && y >= clip.minY && y <= clip.maxY;
|
||
|
||
// Quick reject: model bbox far from view focus (optional optimisation skip for correctness)
|
||
|
||
for (const e of entities) {
|
||
const ownerHex = e.ownerHandle?.value?.toString(16);
|
||
if (!ownerHex || !modelAllowed.has(ownerHex)) continue;
|
||
if (blockDefHandles.has(ownerHex)) continue;
|
||
if (this._hiddenLayers?.size) {
|
||
const lh = e.layerHandle?.value ?? e.layerHandle;
|
||
const lname = (lh != null && this._layerNameByHandle.get(String(lh))) ?? e.layer ?? e.layerName;
|
||
if (lname && this._hiddenLayers.has(lname)) continue;
|
||
}
|
||
if (frozen.size) {
|
||
const lh = e.layerHandle?.value;
|
||
if (lh != null && frozen.has(String(lh))) continue;
|
||
}
|
||
|
||
const d = e.data || e;
|
||
const type = (e.type || e.typeName || '').toUpperCase();
|
||
const color = this._entityColor(e);
|
||
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 '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 */ }
|
||
}
|
||
|
||
// Transform any pending texts that are still in model space (from INSERT helpers).
|
||
// Entries created above already have paper pos. Detect model-space leftovers by
|
||
// checking if pos lies far outside the paper clip *and* inside a model-ish range —
|
||
// safer: tag viewport texts. For insert-pushed texts, re-map those outside paper
|
||
// sheet bounds of the whole drawing is hard. Instead, convert texts whose height
|
||
// wasn't scaled (model heights are often large): skip — INSERT text in viewports
|
||
// is rare on these sheets.
|
||
}
|
||
|
||
/** Entity count, block definition count — for recent-files metadata. */
|
||
getStats() {
|
||
return { entities: this._entityMeta.length, blocks: this._blockCount ?? 0 };
|
||
}
|
||
|
||
/** Capture current view as a WebP Blob. Requires preserveDrawingBuffer:true (already set). */
|
||
snapshotWebPBlob() {
|
||
return new Promise((resolve) => {
|
||
this._renderer.render(this._scene, this._camera);
|
||
this._renderer.domElement.toBlob((b) => resolve(b), 'image/webp', 0.75);
|
||
});
|
||
}
|
||
|
||
/** Layer list for the Layers panel: [{ name, colorHex, count, visible }]. */
|
||
getLayerInfo() {
|
||
const result = this._lastResult;
|
||
if (!result) return [];
|
||
const counts = new Map();
|
||
const allowed = this._activeSpaceHex
|
||
? buildAllowedOwnerHexes(result.entities || [], this._activeSpaceHex)
|
||
: null;
|
||
for (const e of (result.entities || [])) {
|
||
const ownerHex = e.ownerHandle?.value?.toString(16);
|
||
if (allowed && ownerHex && !allowed.has(ownerHex)) continue;
|
||
const lh = e.layerHandle?.value ?? e.layerHandle;
|
||
const name = (lh != null && this._layerNameByHandle?.get(String(lh)))
|
||
?? e.layer ?? e.layerName ?? '0';
|
||
counts.set(name, (counts.get(name) || 0) + 1);
|
||
}
|
||
const hidden = this._hiddenLayers || new Set();
|
||
return (result.tables?.layers || []).map(l => {
|
||
const name = l.name ?? l.layerName ?? '0';
|
||
const aci = Math.abs(l.colorIndex ?? l.color ?? l.colorNumber ?? 7);
|
||
return {
|
||
name,
|
||
colorHex: '#' + ((aciToHex(aci, this._isDark) ?? DEFAULT_COLOR).toString(16).padStart(6, '0')),
|
||
count: counts.get(name) || 0,
|
||
visible: !hidden.has(name),
|
||
};
|
||
}).filter(l => l.count > 0 || !l.name.startsWith('*'));
|
||
}
|
||
|
||
// ── Private helpers ────────────────────────────────────────────────────────
|
||
|
||
_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._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);
|
||
}
|
||
}
|
||
|
||
// 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._group.add(mesh);
|
||
}
|
||
|
||
// Default geometry for AutoCAD standard arrowhead blocks that ship no geometry
|
||
// in the file (system blocks). `name` is the lowercased block name.
|
||
_defaultArrow(name, pt, rotation, size, color, pushSeg) {
|
||
if (!pt || size < 1e-4) return;
|
||
const n = (name || '').replace(/^[_*]+/, '');
|
||
const cos = Math.cos(rotation), sin = Math.sin(rotation);
|
||
// local→world: arrow points toward +X at the insertion point
|
||
const P = (lx, ly) => ({ x: pt.x + lx * cos - ly * sin, y: pt.y + lx * sin + ly * cos });
|
||
if (n === 'none' || n === 'small' || n === 'integral') return;
|
||
if (n === 'dot' || n === 'dotsmall' || n === 'dotblank' || n === 'dotsmallblank' || n === 'origin' || n === 'origin2') {
|
||
this._dotMesh(pt, size * (n === 'dotsmall' ? 0.12 : 0.5), color);
|
||
return;
|
||
}
|
||
if (n === 'oblique' || n === 'archtick') {
|
||
const a = P(size * 0.5, size * 0.5), b = P(-size * 0.5, -size * 0.5);
|
||
pushSeg(a.x, a.y, b.x, b.y, (pt.z || 0) + 0.5, color);
|
||
return;
|
||
}
|
||
if (n === 'open' || n === 'open30' || n === 'open90') {
|
||
const w = n === 'open90' ? size : size * 0.42;
|
||
const t = P(0, 0), u = P(-size, w), v = P(-size, -w);
|
||
pushSeg(u.x, u.y, t.x, t.y, (pt.z || 0) + 0.5, color);
|
||
pushSeg(v.x, v.y, t.x, t.y, (pt.z || 0) + 0.5, color);
|
||
return;
|
||
}
|
||
// default (_ClosedFilled, "", closed, boxfilled, datumfilled, …) → filled triangle
|
||
this._arrowMesh(pt, rotation, size, color);
|
||
}
|
||
|
||
// Filled disc (dot arrowhead).
|
||
_dotMesh(center, radius, color) {
|
||
if (!center || !(radius > 1e-5)) return;
|
||
const r = ((color >> 16) & 0xFF) / 255, g = ((color >> 8) & 0xFF) / 255, b = (color & 0xFF) / 255;
|
||
const segs = 20, verts = [], cols = [], z = (center.z || 0) + 0.5;
|
||
for (let i = 0; i < segs; i++) {
|
||
const a0 = i / segs * Math.PI * 2, a1 = (i + 1) / segs * Math.PI * 2;
|
||
verts.push(center.x, center.y, z,
|
||
center.x + radius * Math.cos(a0), center.y + radius * Math.sin(a0), z,
|
||
center.x + radius * Math.cos(a1), center.y + radius * Math.sin(a1), z);
|
||
cols.push(r, g, b, r, g, b, r, g, b);
|
||
}
|
||
const geom = new THREE.BufferGeometry();
|
||
geom.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
|
||
geom.setAttribute('color', new THREE.Float32BufferAttribute(cols, 3));
|
||
this._group.add(new THREE.Mesh(geom, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.DoubleSide })));
|
||
}
|
||
|
||
// Dot radius (block-local) from an arrow block's geometry, measured from its centroid.
|
||
_arrowDotRadius(ents) {
|
||
let r = 0;
|
||
for (const ae of (ents || [])) {
|
||
const ad = ae.data || ae;
|
||
if ((ae.type || '').toUpperCase() === 'CIRCLE' && ad.radius) r = Math.max(r, ad.radius);
|
||
const pts = ad.points ?? ad.vertices;
|
||
if (pts?.length) {
|
||
let cx = 0, cy = 0; pts.forEach(p => { cx += p.x; cy += p.y; }); cx /= pts.length; cy /= pts.length;
|
||
pts.forEach(p => { r = Math.max(r, Math.hypot(p.x - cx, p.y - cy)); });
|
||
}
|
||
}
|
||
return r;
|
||
}
|
||
|
||
_arcSegs(center, r, a0, a1, z, color, pushSeg) {
|
||
let span = a1 - a0;
|
||
if (span <= 0) span += Math.PI * 2;
|
||
const steps = Math.max(8, Math.ceil((span / (Math.PI * 2)) * ARC_SEGS));
|
||
let prev = null;
|
||
for (let i = 0; i <= steps; i++) {
|
||
const a = a0 + (span * i) / steps;
|
||
const x = center.x + r * Math.cos(a);
|
||
const y = center.y + r * Math.sin(a);
|
||
if (prev) pushSeg(prev.x, prev.y, x, y, z, color);
|
||
prev = { x, y };
|
||
}
|
||
}
|
||
|
||
// Centripetal CatmullRom interpolation for SPLINE
|
||
_catmullRom(pts, t) {
|
||
const n = pts.length;
|
||
const f = t * (n - 1);
|
||
const i = Math.min(Math.floor(f), n - 2);
|
||
const tt = f - i;
|
||
const p0 = pts[Math.max(i - 1, 0)];
|
||
const p1 = pts[i];
|
||
const p2 = pts[i + 1];
|
||
const p3 = pts[Math.min(i + 2, n - 1)];
|
||
const t2 = tt * tt, t3 = t2 * tt;
|
||
return {
|
||
x: 0.5 * ((2*p1.x) + (-p0.x+p2.x)*tt + (2*p0.x-5*p1.x+4*p2.x-p3.x)*t2 + (-p0.x+3*p1.x-3*p2.x+p3.x)*t3),
|
||
y: 0.5 * ((2*p1.y) + (-p0.y+p2.y)*tt + (2*p0.y-5*p1.y+4*p2.y-p3.y)*t2 + (-p0.y+3*p1.y-3*p2.y+p3.y)*t3),
|
||
z: 0.5 * ((2*(p1.z||0)) + (-(p0.z||0)+(p2.z||0))*tt + (2*(p0.z||0)-5*(p1.z||0)+4*(p2.z||0)-(p3.z||0))*t2 + (-(p0.z||0)+3*(p1.z||0)-3*(p2.z||0)+(p3.z||0))*t3),
|
||
};
|
||
}
|
||
|
||
_ellipseSegs(d, color, pushSeg) {
|
||
// field names: majorAxis or smAxis; ratio or axisRatio
|
||
const ma = d.majorAxis ?? d.smAxis ?? { x: 1, y: 0, z: 0 };
|
||
const center = d.center;
|
||
const ratio = d.ratio ?? d.axisRatio ?? 1;
|
||
const startParam = d.startParam ?? 0;
|
||
const endParam = d.endParam ?? Math.PI * 2;
|
||
const mx = ma?.x ?? 1, my = ma?.y ?? 0;
|
||
const a = Math.sqrt(mx*mx + my*my);
|
||
const b = a * ratio;
|
||
const rot = Math.atan2(my, mx);
|
||
let span = endParam - startParam;
|
||
if (span <= 0) span += Math.PI * 2;
|
||
let prev = null;
|
||
for (let i = 0; i <= ELLIPSE_SEGS; i++) {
|
||
const t = startParam + (span * i) / ELLIPSE_SEGS;
|
||
const ex = a * Math.cos(t), ey = b * Math.sin(t);
|
||
const x = center.x + ex * Math.cos(rot) - ey * Math.sin(rot);
|
||
const y = center.y + ex * Math.sin(rot) + ey * Math.cos(rot);
|
||
if (prev) pushSeg(prev.x, prev.y, x, y, center.z || 0, color);
|
||
prev = { x, y };
|
||
}
|
||
}
|
||
|
||
_polylineSegs(points, closed, z, color, pushSeg) {
|
||
if (!points || points.length < 2) return;
|
||
for (let i = 0; i < points.length - 1; i++) {
|
||
pushSeg(points[i].x, points[i].y, points[i+1].x, points[i+1].y, z, color);
|
||
}
|
||
if (closed) {
|
||
const a = points[points.length-1], b = points[0];
|
||
pushSeg(a.x, a.y, b.x, b.y, z, color);
|
||
}
|
||
}
|
||
|
||
// Resolve start/end width for segment i→i+1 (DXF 40/41, fallback 43 constant).
|
||
_segWidths(ent, i, scale = 1) {
|
||
const d = ent?.data || ent || {};
|
||
const cw = (d.constantWidth ?? d.constWidth ?? 0) * scale;
|
||
const dsw = (d.defaultStartWidth ?? 0) * scale;
|
||
const dew = (d.defaultEndWidth ?? 0) * scale;
|
||
const sw0 = (d.startWidths?.[i] ?? 0) * scale;
|
||
const ew0 = (d.endWidths?.[i] ?? 0) * scale;
|
||
const sw = Math.abs(sw0) > 1e-12 ? sw0 : (Math.abs(cw) > 1e-12 ? cw : dsw);
|
||
const ew = Math.abs(ew0) > 1e-12 ? ew0 : (Math.abs(cw) > 1e-12 ? cw : dew);
|
||
return { sw: Math.abs(sw), ew: Math.abs(ew) };
|
||
}
|
||
|
||
_polyHasWidth(ent, nSeg, scale = 1) {
|
||
for (let i = 0; i < nSeg; i++) {
|
||
const { sw, ew } = this._segWidths(ent, i, scale);
|
||
if (sw > 1e-9 || ew > 1e-9) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Draw one trapezoid segment (variable start/end half-width) as two triangles.
|
||
_pushWideSeg(verts, p1, p2, w0, w1) {
|
||
const dx = p2.x - p1.x, dy = p2.y - p1.y;
|
||
const len = Math.hypot(dx, dy);
|
||
if (len < 1e-12) return;
|
||
const nx = -dy / len, ny = dx / len;
|
||
const h0 = w0 * 0.5, h1 = w1 * 0.5;
|
||
const a = { x: p1.x + nx * h0, y: p1.y + ny * h0 };
|
||
const b = { x: p1.x - nx * h0, y: p1.y - ny * h0 };
|
||
const c = { x: p2.x - nx * h1, y: p2.y - ny * h1 };
|
||
const d = { x: p2.x + nx * h1, y: p2.y + ny * h1 };
|
||
// two triangles a-b-c, a-c-d
|
||
verts.push(a.x, a.y, 0, b.x, b.y, 0, c.x, c.y, 0);
|
||
verts.push(a.x, a.y, 0, c.x, c.y, 0, d.x, d.y, 0);
|
||
}
|
||
|
||
// Variable-width polyline → solid mesh (MeshBasicMaterial). Used when any
|
||
// segment has start/end/constant width > 0 (도곽 outer frame etc.).
|
||
_widePolyMesh(points, bulges, closed, ent, color, scale = 1, expand = null) {
|
||
if (!points || points.length < 2) return;
|
||
const n = points.length;
|
||
const segs = closed ? n : n - 1;
|
||
const verts = [];
|
||
for (let i = 0; i < segs; i++) {
|
||
const p1 = points[i];
|
||
const p2 = points[(i + 1) % n];
|
||
const { sw, ew } = this._segWidths(ent, i, scale);
|
||
if (sw < 1e-9 && ew < 1e-9) {
|
||
// Zero-width segment: still emit a tiny hair so it isn't dropped entirely.
|
||
this._pushWideSeg(verts, p1, p2, 0.01 * scale, 0.01 * scale);
|
||
if (expand) { expand(p1.x, p1.y); expand(p2.x, p2.y); }
|
||
continue;
|
||
}
|
||
const bulge = bulges?.[i] || 0;
|
||
if (Math.abs(bulge) < 1e-6) {
|
||
this._pushWideSeg(verts, p1, p2, sw, ew);
|
||
if (expand) { expand(p1.x, p1.y); expand(p2.x, p2.y); }
|
||
} else {
|
||
// Tessellate bulge arc; interpolate width along the arc.
|
||
const dx = p2.x - p1.x, dy = p2.y - p1.y;
|
||
const dist = Math.hypot(dx, dy);
|
||
if (dist < 1e-9) continue;
|
||
const inc = 4 * Math.atan(bulge);
|
||
const half = Math.abs(inc) / 2;
|
||
const radius = (dist / 2) / Math.sin(half);
|
||
const apothem = radius * Math.cos(half);
|
||
const mx2 = (p1.x + p2.x) / 2, my2 = (p1.y + p2.y) / 2;
|
||
const nx2 = -dy / dist, ny2 = dx / dist;
|
||
const sign = bulge > 0 ? 1 : -1;
|
||
const cx = mx2 + sign * nx2 * apothem;
|
||
const cy = my2 + sign * ny2 * apothem;
|
||
const sa = Math.atan2(p1.y - cy, p1.x - cx);
|
||
const steps = Math.max(2, Math.ceil(Math.abs(inc) / (Math.PI / 16)));
|
||
let prev = { x: p1.x, y: p1.y };
|
||
let prevW = sw;
|
||
for (let k = 1; k <= steps; k++) {
|
||
const t = k / steps;
|
||
const a = sa + (inc * k) / steps;
|
||
const cur = { x: cx + radius * Math.cos(a), y: cy + radius * Math.sin(a) };
|
||
const curW = sw + (ew - sw) * t;
|
||
this._pushWideSeg(verts, prev, cur, prevW, curW);
|
||
prev = cur;
|
||
prevW = curW;
|
||
}
|
||
if (expand) { expand(p1.x, p1.y); expand(p2.x, p2.y); }
|
||
}
|
||
}
|
||
if (verts.length < 9) return;
|
||
const geo = new THREE.BufferGeometry();
|
||
geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
|
||
geo.computeVertexNormals();
|
||
const mat = new THREE.MeshBasicMaterial({
|
||
color: new THREE.Color(color),
|
||
side: THREE.DoubleSide,
|
||
depthWrite: false,
|
||
});
|
||
this._group.add(new THREE.Mesh(geo, mat));
|
||
// Pick as edge segments only — do NOT register the poly as a fill loop.
|
||
// A closed outer frame (도곽 cw=2) used as _pickFills would mark the whole
|
||
// interior as "covered" and (with a weak size check) hide every inner line.
|
||
if (this._pickCurIdx >= 0 && this._pickSegs) {
|
||
for (let i = 0; i < segs; i++) {
|
||
const a = points[i], b = points[(i + 1) % n];
|
||
this._pickSegs.push(a.x, a.y, b.x, b.y);
|
||
this._pickSegMeta.push(this._pickCurIdx);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Polyline with bulge arcs (LWPOLYLINE, HATCH boundaries)
|
||
_bulgePolySegs(points, bulges, closed, color, pushSeg, expand) {
|
||
const n = points.length;
|
||
const segs = closed ? n : n - 1;
|
||
for (let i = 0; i < segs; i++) {
|
||
const p1 = points[i];
|
||
const p2 = points[(i + 1) % n];
|
||
const bulge = bulges?.[i] || 0;
|
||
if (Math.abs(bulge) < 1e-6) {
|
||
pushSeg(p1.x, p1.y, p2.x, p2.y, 0, color);
|
||
if (expand) { expand(p1.x, p1.y); expand(p2.x, p2.y); }
|
||
} else {
|
||
const dx = p2.x - p1.x, dy = p2.y - p1.y;
|
||
const dist = Math.sqrt(dx*dx + dy*dy);
|
||
if (dist < 1e-9) continue;
|
||
// Sweep by the signed included angle Δ = 4·atan(bulge). Sweeping the angle
|
||
// directly (rather than computing both endpoint angles and resolving the
|
||
// 2π wrap) avoids drawing the major arc when |bulge| is tiny.
|
||
const inc = 4 * Math.atan(bulge); // signed; >0 = CCW
|
||
const half = Math.abs(inc) / 2;
|
||
const radius = (dist / 2) / Math.sin(half);
|
||
const apothem = radius * Math.cos(half);
|
||
const mx2 = (p1.x + p2.x) / 2, my2 = (p1.y + p2.y) / 2;
|
||
const nx2 = -dy / dist, ny2 = dx / dist; // left normal of p1→p2
|
||
const sign = bulge > 0 ? 1 : -1;
|
||
const cx = mx2 + sign * nx2 * apothem;
|
||
const cy = my2 + sign * ny2 * apothem;
|
||
const sa = Math.atan2(p1.y - cy, p1.x - cx);
|
||
const steps = Math.max(2, Math.ceil(Math.abs(inc) / (Math.PI / 16)));
|
||
let prev = p1;
|
||
for (let k = 1; k <= steps; k++) {
|
||
const a = sa + (inc * k) / steps;
|
||
const x = cx + radius * Math.cos(a), y = cy + radius * Math.sin(a);
|
||
pushSeg(prev.x, prev.y, x, y, 0, color);
|
||
prev = { x, y };
|
||
}
|
||
if (expand) { expand(p1.x, p1.y); expand(p2.x, p2.y); }
|
||
}
|
||
}
|
||
}
|
||
|
||
_solidMesh(corners, color) {
|
||
const r = ((color >> 16) & 0xFF) / 255;
|
||
const g = ((color >> 8) & 0xFF) / 255;
|
||
const b = (color & 0xFF) / 255;
|
||
const verts = [], cols = [];
|
||
const addTri = (...pts) => {
|
||
for (const p of pts) { verts.push(p.x, p.y, p.z || 0); cols.push(r, g, b); }
|
||
};
|
||
if (corners.length >= 4) {
|
||
addTri(corners[0], corners[1], corners[3]);
|
||
addTri(corners[0], corners[3], corners[2]);
|
||
} else {
|
||
addTri(corners[0], corners[1], corners[2]);
|
||
}
|
||
const geom = new THREE.BufferGeometry();
|
||
geom.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
|
||
geom.setAttribute('color', new THREE.Float32BufferAttribute(cols, 3));
|
||
this._group.add(new THREE.Mesh(geom, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.DoubleSide })));
|
||
// Filled quad/tri → pickable region (DWG SOLID vertex order is 0,1,3,2).
|
||
if (this._pickCurIdx >= 0 && corners.length >= 3) {
|
||
const loop = corners.length >= 4 ? [corners[0], corners[1], corners[3], corners[2]] : corners.slice(0, 3);
|
||
this._pickFills.push({ metaIdx: this._pickCurIdx, loops: [loop] });
|
||
}
|
||
}
|
||
|
||
// Text has no line geometry — register its box as a pickable fill so a click
|
||
// anywhere on the text selects it. Box follows the sprite's anchor rules
|
||
// (alignH 0/3/5=left,2=right,1/4=center; alignV 3=top,0/1=bottom,2=middle).
|
||
_addTextPick(text, pos, height, alignH = 0, alignV = 0) {
|
||
if (this._pickCurIdx < 0 || !pos) return;
|
||
const rows = String(text).split('\n');
|
||
const cols = rows.reduce((m, s) => Math.max(m, s.length), 1);
|
||
const w = cols * height * 0.62; // ~ per-glyph advance
|
||
const h = rows.length * height * 1.2;
|
||
let x0 = pos.x;
|
||
if (alignH === 2) x0 = pos.x - w; // right anchor
|
||
else if (alignH === 1 || alignH === 4) x0 = pos.x - w / 2; // center
|
||
let y0 = pos.y; // bottom anchor
|
||
if (alignV === 3) y0 = pos.y - h; // top anchor
|
||
else if (alignV === 2) y0 = pos.y - h / 2; // middle
|
||
this._pickFills.push({ metaIdx: this._pickCurIdx, loops: [[
|
||
{ x: x0, y: y0 }, { x: x0 + w, y: y0 }, { x: x0 + w, y: y0 + h }, { x: x0, y: y0 + h },
|
||
]] });
|
||
}
|
||
|
||
// Even-odd point-in-region across ALL loops of a fill (holes exclude correctly).
|
||
_fillHit(loops, x, y) {
|
||
let inside = false;
|
||
for (const poly of loops) {
|
||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||
const xi = poly[i].x, yi = poly[i].y, xj = poly[j].x, yj = poly[j].y;
|
||
if (((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) inside = !inside;
|
||
}
|
||
}
|
||
return inside;
|
||
}
|
||
|
||
// True if (x,y) lies inside ANY single loop (NOT even-odd across loops).
|
||
// Used to detect "this point belongs to hatch geometry" including hole
|
||
// interiors — even-odd would report holes as uncovered and let a later
|
||
// LWPOLYLINE re-solid them (CXGLOGO ㅇ/ㅎ counters).
|
||
_pointInAnyLoop(loops, x, y) {
|
||
for (const poly of loops) {
|
||
if (!poly?.length) continue;
|
||
let c = false;
|
||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||
const a = poly[i], b = poly[j];
|
||
if (((a.y > y) !== (b.y > y)) && (x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x)) c = !c;
|
||
}
|
||
if (c) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Signed area of a loop (for topmost = smallest-area tie-break on overlapping fills).
|
||
_loopArea(poly) {
|
||
let s = 0;
|
||
for (let i = 0; i < poly.length; i++) { const a = poly[i], b = poly[(i + 1) % poly.length]; s += a.x * b.y - b.x * a.y; }
|
||
return Math.abs(s) / 2;
|
||
}
|
||
|
||
// Solid-fill a polyline that is geometrically closed but not covered by any
|
||
// existing hatch fill. Text-as-hatch logos sometimes ship glyph strokes as
|
||
// LWPOLYLINE outlines only (e.g. ㅅ of 사 in CXGLOGO) while sibling glyphs
|
||
// have solid HATCH — without this they render as empty outlines.
|
||
//
|
||
// Coverage test is ANY-loop containment (not even-odd). Logo blocks list
|
||
// HATCH entities first, then the same glyph outlines as LWPOLYLINE. Even-odd
|
||
// treats hole interiors as "uncovered", so the post-hatch polyline pass used
|
||
// to re-solid ㅇ/ㅎ counters and wipe the punches. Any-loop still lets true
|
||
// orphan outlines (ㅅ with no hatch) fill, while skipping anything that
|
||
// already belongs to a hatch loop — fill or hole.
|
||
_fillClosedPolyIfUncovered(points, bulges, color) {
|
||
if (!points || points.length < 3) return;
|
||
const a = points[0], b = points[points.length - 1];
|
||
const gap = Math.hypot(a.x - b.x, a.y - b.y);
|
||
// Allow tiny gaps relative to polyline size (floating-point closed loops).
|
||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||
for (const p of points) {
|
||
if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y;
|
||
if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y;
|
||
}
|
||
const diag = Math.hypot(maxX - minX, maxY - minY) || 1;
|
||
if (gap > diag * 1e-4 && gap > 1e-6) return;
|
||
// Area gate: logo glyph strokes are small (area ~1–100 in block units).
|
||
// Refuse huge loops even inside blocks (defensive).
|
||
let sa = 0;
|
||
for (let i = 0; i < points.length; i++) {
|
||
const p0 = points[i], p1 = points[(i + 1) % points.length];
|
||
sa += p0.x * p1.y - p1.x * p0.y;
|
||
}
|
||
const absA = Math.abs(sa) / 2;
|
||
if (absA < 1e-6 || absA > 5000) return;
|
||
let cx = 0, cy = 0;
|
||
for (const p of points) { cx += p.x; cy += p.y; }
|
||
cx /= points.length; cy /= points.length;
|
||
for (const f of this._pickFills) {
|
||
if (this._pointInAnyLoop(f.loops, cx, cy)) return;
|
||
}
|
||
this._hatchFill([{ points, bulges: bulges || null, closed: true }], color);
|
||
}
|
||
|
||
// Solid fill HATCH using THREE.ShapeGeometry
|
||
// Tessellate one (closed) hatch boundary loop → flat {x,y}[] (resolves bulge arcs,
|
||
// including the closing edge — a 2-point/2-bulge loop is a full circle).
|
||
_pathPoints(path) {
|
||
const pts = path.points;
|
||
if (!pts?.length) return null;
|
||
const bulges = path.bulges;
|
||
const n = pts.length;
|
||
if (!bulges?.some(b => Math.abs(b) >= 1e-6)) return pts.map(p => ({ x: p.x, y: p.y }));
|
||
const out = [];
|
||
for (let i = 0; i < n; i++) {
|
||
const p1 = pts[i], p2 = pts[(i + 1) % n], bulge = bulges[i] || 0;
|
||
out.push({ x: p1.x, y: p1.y });
|
||
if (Math.abs(bulge) < 1e-6) continue;
|
||
const dx = p2.x - p1.x, dy = p2.y - p1.y, dist = Math.hypot(dx, dy);
|
||
if (dist < 1e-9) continue;
|
||
const inc = 4 * Math.atan(bulge), half = Math.abs(inc) / 2;
|
||
const radius = (dist / 2) / Math.sin(half), apo = radius * Math.cos(half);
|
||
const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2;
|
||
const nx = -dy / dist, ny = dx / dist, sign = bulge > 0 ? 1 : -1;
|
||
const cx = mx + sign * nx * apo, cy = my + sign * ny * apo;
|
||
const sa = Math.atan2(p1.y - cy, p1.x - cx);
|
||
const segs = Math.max(6, Math.ceil(Math.abs(inc) / (Math.PI / 16)));
|
||
for (let j = 1; j < segs; j++) { // intermediate arc points; p2 added next iter
|
||
const a = sa + inc * j / segs;
|
||
out.push({ x: cx + radius * Math.cos(a), y: cy + radius * Math.sin(a) });
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Solid fill with true even-odd parity across all boundary loops.
|
||
//
|
||
// DWG solid HATCH (style Normal / odd parity) is even-odd, not "outer +
|
||
// nested holes". Text-as-hatch logos (CXGLOGO 한국도로공사) ship
|
||
// self-intersecting multi-stroke loops where ShapeGeometry/earcut + geometric
|
||
// nesting mis-classifies ㅇ/ㅎ counters. Verified against samples/11.dwg:
|
||
// SVG fill-rule=evenodd over all non-outlier loops matches the CAD logo.
|
||
_hatchFill(paths, color) {
|
||
const totalPts = paths.reduce((s, p) => s + (p.points?.length ?? 0), 0);
|
||
if (totalPts > 8000) return; // safety for pathological boundaries
|
||
let polys = [];
|
||
for (const p of paths) { const pp = this._pathPoints(p); if (pp && pp.length >= 3) polys.push(pp); }
|
||
if (!polys.length) return;
|
||
|
||
const area = (poly) => { let s = 0; for (let i = 0; i < poly.length; i++) { const a = poly[i], b = poly[(i + 1) % poly.length]; s += a.x * b.y - b.x * a.y; } return Math.abs(s) / 2; };
|
||
|
||
// Drop spurious giant boundaries (parse-corrupt arcs expanded to huge loops).
|
||
// Many-loop hatches (≥5) or empty-loop hatches get median×10 outlier filter.
|
||
if ((paths.some(p => !(p.points?.length)) && polys.length >= 3) || polys.length >= 5) {
|
||
const sorted = polys.map(area).sort((a, b) => a - b);
|
||
const med = sorted[Math.floor(sorted.length / 2)] || 0;
|
||
if (med > 0) polys = polys.filter(p => area(p) <= med * 10);
|
||
if (!polys.length) return;
|
||
}
|
||
|
||
// Picking uses even-odd across all loops (holes exclude correctly).
|
||
if (this._pickCurIdx >= 0 && polys.length) this._pickFills.push({ metaIdx: this._pickCurIdx, loops: polys });
|
||
|
||
// When does this hatch need true even-odd (vs independent union of islands)?
|
||
// - Self-intersecting loops (text-as-hatch glyph strokes)
|
||
// - Many loops (≥5): logo words like 한국도로공사
|
||
// - Geometric nesting: a smaller loop sits inside a larger one (island/hole)
|
||
// Separate solid islands that only touch/overlap (EX logo 4 paths) must be
|
||
// UNION — even-odd would punch diamond holes at the overlaps.
|
||
const needsEvenOdd =
|
||
polys.length >= 5 ||
|
||
polys.some((p) => this._polySelfIntersects(p)) ||
|
||
this._hatchHasNestedLoop(polys);
|
||
|
||
if (needsEvenOdd) {
|
||
this._hatchFillEvenOddCanvas(polys, color);
|
||
return;
|
||
}
|
||
|
||
// Union of simple islands: one ShapeGeometry per loop (no hole punching).
|
||
const mat = new THREE.MeshBasicMaterial({ color: new THREE.Color(color), side: THREE.DoubleSide });
|
||
for (const poly of polys) {
|
||
try {
|
||
const shape = new THREE.Shape();
|
||
shape.moveTo(poly[0].x, poly[0].y);
|
||
for (let k = 1; k < poly.length; k++) shape.lineTo(poly[k].x, poly[k].y);
|
||
shape.closePath();
|
||
this._group.add(new THREE.Mesh(new THREE.ShapeGeometry(shape), mat));
|
||
} catch { /* degenerate */ }
|
||
}
|
||
}
|
||
|
||
// True if any loop is (mostly) inside a larger loop — island/hole topology.
|
||
_hatchHasNestedLoop(polys) {
|
||
if (polys.length < 2) return false;
|
||
const area = (poly) => {
|
||
let s = 0;
|
||
for (let i = 0; i < poly.length; i++) {
|
||
const a = poly[i], b = poly[(i + 1) % poly.length];
|
||
s += a.x * b.y - b.x * a.y;
|
||
}
|
||
return Math.abs(s) / 2;
|
||
};
|
||
const bboxOf = (poly) => {
|
||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||
for (const p of poly) {
|
||
if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y;
|
||
if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y;
|
||
}
|
||
return { minX, minY, maxX, maxY };
|
||
};
|
||
const inside = (poly, pt) => {
|
||
let c = false;
|
||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||
const a = poly[i], b = poly[j];
|
||
if (((a.y > pt.y) !== (b.y > pt.y)) && (pt.x < (b.x - a.x) * (pt.y - a.y) / (b.y - a.y) + a.x)) c = !c;
|
||
}
|
||
return c;
|
||
};
|
||
const ar = polys.map(area);
|
||
const bbs = polys.map(bboxOf);
|
||
for (let i = 0; i < polys.length; i++) {
|
||
let cx = 0, cy = 0;
|
||
for (const p of polys[i]) { cx += p.x; cy += p.y; }
|
||
cx /= polys[i].length; cy /= polys[i].length;
|
||
for (let j = 0; j < polys.length; j++) {
|
||
if (i === j || ar[j] <= ar[i]) continue;
|
||
const bb = bbs[j], bi = bbs[i];
|
||
const bboxHit = bb.minX <= bi.minX && bb.minY <= bi.minY && bb.maxX >= bi.maxX && bb.maxY >= bi.maxY;
|
||
if (bboxHit || inside(polys[j], { x: cx, y: cy })) return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Cheap self-intersection probe (adjacent edges ignored). Used only to choose
|
||
// ShapeGeometry vs even-odd canvas; false negatives still go through canvas
|
||
// when there are multiple loops.
|
||
_polySelfIntersects(poly) {
|
||
const n = poly.length;
|
||
if (n < 4) return false;
|
||
const cross = (u, v, w) => (v.x - u.x) * (w.y - u.y) - (v.y - u.y) * (w.x - u.x);
|
||
const hits = (a, b, c, d) => {
|
||
const d1 = cross(a, b, c), d2 = cross(a, b, d), d3 = cross(c, d, a), d4 = cross(c, d, b);
|
||
return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0));
|
||
};
|
||
// Cap checks for large loops (logo glyphs are small).
|
||
const lim = Math.min(n, 80);
|
||
for (let i = 0; i < lim; i++) {
|
||
const a = poly[i], b = poly[(i + 1) % n];
|
||
for (let j = i + 2; j < lim; j++) {
|
||
if (i === 0 && j === n - 1) continue;
|
||
if (j === (i + n - 1) % n) continue;
|
||
if (hits(a, b, poly[j], poly[(j + 1) % n])) return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Diameter-like edge through bbox center: corrupt hatch chords (CXGLOGO ㅎ)
|
||
// that turn even-odd into a Mercedes/pie pattern. True when shoelace area is
|
||
// also inflated above the convex hull (self-intersecting junk).
|
||
_isDiameterInflatedPath(pts) {
|
||
if (!pts || pts.length < 8) return false;
|
||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||
for (const p of pts) {
|
||
if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y;
|
||
if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y;
|
||
}
|
||
const w = maxX - minX, h = maxY - minY;
|
||
const minDim = Math.min(w, h);
|
||
if (!(minDim > 1e-9)) return false;
|
||
const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
|
||
const thr = 0.55 * minDim;
|
||
const centerR = 0.22 * minDim;
|
||
let diamN = 0;
|
||
for (let i = 0; i < pts.length; i++) {
|
||
const a = pts[i], b = pts[(i + 1) % pts.length];
|
||
const len = Math.hypot(b.x - a.x, b.y - a.y);
|
||
if (len < thr) continue;
|
||
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
|
||
if (Math.hypot(mx - cx, my - cy) <= centerR) diamN++;
|
||
}
|
||
if (!diamN) return false;
|
||
// shoelace abs area vs convex hull — inflated ⇒ self-intersecting
|
||
let sa = 0;
|
||
for (let i = 0; i < pts.length; i++) {
|
||
const a = pts[i], b = pts[(i + 1) % pts.length];
|
||
sa += a.x * b.y - b.x * a.y;
|
||
}
|
||
const absA = Math.abs(sa) / 2;
|
||
const hull = this._convexHull(pts);
|
||
if (hull.length < 3) return false;
|
||
let ha = 0;
|
||
for (let i = 0; i < hull.length; i++) {
|
||
const a = hull[i], b = hull[(i + 1) % hull.length];
|
||
ha += a.x * b.y - b.x * a.y;
|
||
}
|
||
ha = Math.abs(ha) / 2;
|
||
return ha > 1e-9 && absA > ha * 1.15;
|
||
}
|
||
|
||
_convexHull(pts) {
|
||
const p = pts.map((q) => ({ x: q.x, y: q.y })).sort((a, b) => a.x - b.x || a.y - b.y);
|
||
if (p.length <= 2) return p;
|
||
const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
|
||
const lower = [];
|
||
for (const pt of p) {
|
||
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], pt) <= 0) lower.pop();
|
||
lower.push(pt);
|
||
}
|
||
const upper = [];
|
||
for (let i = p.length - 1; i >= 0; i--) {
|
||
const pt = p[i];
|
||
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], pt) <= 0) upper.pop();
|
||
upper.push(pt);
|
||
}
|
||
lower.pop();
|
||
upper.pop();
|
||
return lower.concat(upper);
|
||
}
|
||
|
||
_polyCentroid(poly) {
|
||
let x = 0, y = 0;
|
||
for (const p of poly) { x += p.x; y += p.y; }
|
||
return { x: x / poly.length, y: y / poly.length };
|
||
}
|
||
|
||
_pointInPoly(poly, pt) {
|
||
let c = false;
|
||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||
const a = poly[i], b = poly[j];
|
||
if (((a.y > pt.y) !== (b.y > pt.y)) && (pt.x < (b.x - a.x) * (pt.y - a.y) / (b.y - a.y) + a.x)) c = !c;
|
||
}
|
||
return c;
|
||
}
|
||
|
||
// Raster even-odd fill into a CanvasTexture plane. Sharp enough for logo-scale
|
||
// glyphs (tens of units); large site hatches still use ShapeGeometry when they
|
||
// are a single simple loop. Texture is disposed with the mesh on reload.
|
||
//
|
||
// Diameter-inflated loops (ㅎ of 한 in CXGLOGO): pure even-odd yields a pie/
|
||
// Mercedes pattern from spurious center chords. Those loops are filled with
|
||
// nonzero (solid glyph + cross), then nested sibling loops punch the counters
|
||
// (ㅇ hole). Remaining loops use normal even-odd.
|
||
_hatchFillEvenOddCanvas(polys, color) {
|
||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||
for (const poly of polys) {
|
||
for (const p of poly) {
|
||
if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y;
|
||
if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y;
|
||
}
|
||
}
|
||
const worldW = maxX - minX;
|
||
const worldH = maxY - minY;
|
||
if (!(worldW > 1e-12) || !(worldH > 1e-12)) return;
|
||
|
||
// Pad 1px equivalent so AA edges aren't clipped.
|
||
const MAX = 2048;
|
||
const pxPerUnit = Math.min(MAX / worldW, MAX / worldH, 64);
|
||
const tw = Math.max(2, Math.ceil(worldW * pxPerUnit) + 2);
|
||
const th = Math.max(2, Math.ceil(worldH * pxPerUnit) + 2);
|
||
const sx = (tw - 2) / worldW;
|
||
const sy = (th - 2) / worldH;
|
||
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = tw;
|
||
canvas.height = th;
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) return;
|
||
ctx.clearRect(0, 0, tw, th);
|
||
|
||
const toCanvas = (p) => ({
|
||
x: 1 + (p.x - minX) * sx,
|
||
y: 1 + (maxY - p.y) * sy,
|
||
});
|
||
const addPoly = (poly) => {
|
||
if (poly.length < 3) return;
|
||
const p0 = toCanvas(poly[0]);
|
||
ctx.moveTo(p0.x, p0.y);
|
||
for (let i = 1; i < poly.length; i++) {
|
||
const p = toCanvas(poly[i]);
|
||
ctx.lineTo(p.x, p.y);
|
||
}
|
||
ctx.closePath();
|
||
};
|
||
|
||
const solidPolys = [];
|
||
const evenPolys = [];
|
||
for (const poly of polys) {
|
||
if (this._isDiameterInflatedPath(poly)) solidPolys.push(poly);
|
||
else evenPolys.push(poly);
|
||
}
|
||
// Self-intersecting solid outlines fail ordinary PIP for hole nesting — use
|
||
// their convex hull as the containment oracle (ㅎ outer vs ㅇ counter).
|
||
const solidHulls = solidPolys.map((p) => this._convexHull(p));
|
||
|
||
const isHoleOfSolid = (poly) => {
|
||
if (poly.length < 3 || !solidHulls.length) return false;
|
||
const c = this._polyCentroid(poly);
|
||
for (const hull of solidHulls) {
|
||
if (hull.length >= 3 && this._pointInPoly(hull, c)) return true;
|
||
}
|
||
return false;
|
||
};
|
||
|
||
// 1) Nonzero fill for diameter-inflated glyphs (ㅎ solid + cross, no pie).
|
||
if (solidPolys.length) {
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.beginPath();
|
||
for (const poly of solidPolys) addPoly(poly);
|
||
ctx.fill('nonzero');
|
||
|
||
// 2) Punch counters nested inside a solid glyph.
|
||
const holes = evenPolys.filter(isHoleOfSolid);
|
||
if (holes.length) {
|
||
ctx.globalCompositeOperation = 'destination-out';
|
||
ctx.beginPath();
|
||
for (const poly of holes) addPoly(poly);
|
||
ctx.fill('nonzero');
|
||
ctx.globalCompositeOperation = 'source-over';
|
||
}
|
||
}
|
||
|
||
// 3) Remaining loops (not used as hole punches) → even-odd.
|
||
const rest = evenPolys.filter((p) => !isHoleOfSolid(p));
|
||
if (rest.length) {
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.beginPath();
|
||
for (const poly of rest) addPoly(poly);
|
||
ctx.fill('evenodd');
|
||
}
|
||
|
||
const tex = new THREE.CanvasTexture(canvas);
|
||
tex.colorSpace = THREE.NoColorSpace;
|
||
tex.magFilter = THREE.LinearFilter;
|
||
tex.minFilter = THREE.LinearMipmapLinearFilter;
|
||
tex.generateMipmaps = true;
|
||
tex.needsUpdate = true;
|
||
|
||
const mat = new THREE.MeshBasicMaterial({
|
||
map: tex,
|
||
color: new THREE.Color(color),
|
||
transparent: true,
|
||
alphaTest: 0.4,
|
||
side: THREE.DoubleSide,
|
||
depthWrite: false,
|
||
});
|
||
const geo = new THREE.PlaneGeometry(worldW, worldH);
|
||
const mesh = new THREE.Mesh(geo, mat);
|
||
mesh.position.set((minX + maxX) / 2, (minY + maxY) / 2, 0);
|
||
// PlaneGeometry is XY; our 2D world is already XY. No rotation needed.
|
||
this._group.add(mesh);
|
||
}
|
||
|
||
// Multi-line MTEXT: one canvas with stacked lines, aligned as a block.
|
||
// alignH 0=left 1/4=center 2=right (per line); alignV 3=top 2=middle 0/1=bottom anchor.
|
||
_multilineSprite(lines, pos, height, rotation, color, alignH, alignV, renderOrder = 0) {
|
||
const fontSize = 96;
|
||
const font = `${fontSize}px 'Malgun Gothic', 'Apple SD Gothic Neo', monospace`;
|
||
const canvas = document.createElement('canvas');
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.font = font;
|
||
let tw = 1, asc = fontSize * 0.70, desc = fontSize * 0.20;
|
||
for (const ln of lines) {
|
||
const m = ctx.measureText(ln || ' ');
|
||
tw = Math.max(tw, Math.ceil(m.width));
|
||
asc = Math.max(asc, m.actualBoundingBoxAscent || fontSize * 0.70);
|
||
desc = Math.max(desc, m.actualBoundingBoxDescent || fontSize * 0.20);
|
||
}
|
||
const pad = Math.ceil(fontSize * 0.15);
|
||
const step = Math.ceil(asc + desc + fontSize * 0.35); // baseline-to-baseline
|
||
canvas.width = tw + pad * 2;
|
||
canvas.height = step * lines.length + pad * 2;
|
||
ctx.font = font; // resizing the canvas clears state
|
||
|
||
const ri = (color >> 16) & 0xFF, gi = (color >> 8) & 0xFF, bi = color & 0xFF;
|
||
ctx.fillStyle = `rgb(${ri},${gi},${bi})`;
|
||
ctx.textBaseline = 'alphabetic';
|
||
let ax = pad;
|
||
if (alignH === 1 || alignH === 4) { ctx.textAlign = 'center'; ax = canvas.width / 2; }
|
||
else if (alignH === 2) { ctx.textAlign = 'right'; ax = canvas.width - pad; }
|
||
else { ctx.textAlign = 'left'; ax = pad; }
|
||
for (let i = 0; i < lines.length; i++) ctx.fillText(lines[i], ax, pad + asc + i * step);
|
||
|
||
const texture = new THREE.CanvasTexture(canvas);
|
||
const pxToWorld = height / asc; // one line's cap height == DWG text height
|
||
const vw = canvas.width * pxToWorld;
|
||
const vh = canvas.height * pxToWorld;
|
||
|
||
// Anchor the text block so the requested datum lands on pos (block edges).
|
||
let ox = 0, oy = 0;
|
||
if (alignH === 0 || alignH === 3 || alignH === 5) ox = vw / 2; // left edge at pos.x
|
||
else if (alignH === 2) ox = -vw / 2; // right edge at pos.x
|
||
if (alignV === 3) oy = -vh / 2; // top edge at pos.y
|
||
else if (alignV === 0 || alignV === 1) oy = vh / 2; // bottom edge at pos.y
|
||
|
||
const mat = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false });
|
||
if (rotation) mat.rotation = rotation;
|
||
const sprite = new THREE.Sprite(mat);
|
||
sprite.scale.set(vw, vh, 1);
|
||
sprite.position.set(pos.x + ox, pos.y + oy, 1);
|
||
sprite.renderOrder = renderOrder;
|
||
this._group.add(sprite);
|
||
}
|
||
|
||
// MTEXT background mask (opaque fill behind text). Flags (DXF 90):
|
||
// 0x01 = use background fill color (63)
|
||
// 0x02 = use drawing window color
|
||
// 0x10 = text frame only (R2018+)
|
||
// bgScale (DXF 45, default 1.5) = border offset factor × text height.
|
||
_drawMTextBackground(t, engine, minTextH) {
|
||
const flags = t.bgFillFlags | 0;
|
||
if (!flags) return;
|
||
const useFill = !!(flags & 0x01) || !!(flags & 0x02);
|
||
const useFrame = !!(flags & 0x10);
|
||
if (!useFill && !useFrame) return;
|
||
|
||
const height = Math.max(t.height || 2.5, minTextH || 0);
|
||
const lines = String(t.text || '').split('\n');
|
||
const ls = t.linespacingFactor > 0 ? t.linespacingFactor : 1;
|
||
// Match slugText line step (CAD MTEXT default ~ 5/3 of height × factor)
|
||
const lineStep = height * (5 / 3) * ls;
|
||
let maxEm = 0;
|
||
if (engine?.measureEm) {
|
||
for (const line of lines) maxEm = Math.max(maxEm, engine.measureEm(line || ' '));
|
||
} else {
|
||
// ~0.9 em per Hangul/Latin fallback when engine unavailable
|
||
for (const line of lines) maxEm = Math.max(maxEm, (line || ' ').length * 0.9);
|
||
}
|
||
let textW = Math.max(maxEm * height, height * 0.5);
|
||
let textH = lines.length <= 1
|
||
? height
|
||
: height + (lines.length - 1) * lineStep;
|
||
if (t.rectWidth > 0) textW = Math.max(textW, t.rectWidth);
|
||
if (t.rectHeight > 0) textH = Math.max(textH, t.rectHeight);
|
||
|
||
const scale = t.bgScale > 0 ? t.bgScale : 1.5;
|
||
// Scale factor expands the text bbox (1.5 → 50% larger total).
|
||
const boxW = textW * scale;
|
||
const boxH = textH * scale;
|
||
|
||
// Text-center offset from insertion/attachment point (same rules as slug/sprite).
|
||
const alignH = t.alignH ?? 1;
|
||
const alignV = t.alignV ?? 2;
|
||
let lx = 0, ly = 0;
|
||
if (alignH === 0 || alignH === 3 || alignH === 5) lx = textW / 2;
|
||
else if (alignH === 2) lx = -textW / 2;
|
||
if (alignV === 3) ly = -textH / 2;
|
||
else if (alignV === 0 || alignV === 1) ly = textH / 2;
|
||
|
||
const rot = t.rotation || 0;
|
||
const cos = Math.cos(rot), sin = Math.sin(rot);
|
||
const cx = t.pos.x + lx * cos - ly * sin;
|
||
const cy = t.pos.y + lx * sin + ly * cos;
|
||
|
||
let bgColor = 0xFFFF00; // default yellow if color missing
|
||
if (flags & 0x02) {
|
||
// Drawing window color — follow viewer theme
|
||
const dark = this._scene?.background?.r < 0.5;
|
||
bgColor = dark ? 0x0a0b0d : 0xf7f6f3;
|
||
} else if (t.bgColorRgb && (t.bgColorRgb.r != null)) {
|
||
bgColor = ((t.bgColorRgb.r & 255) << 16) | ((t.bgColorRgb.g & 255) << 8) | (t.bgColorRgb.b & 255);
|
||
} else if (t.bgColorIndex != null) {
|
||
const hex = aciToHex(t.bgColorIndex, 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._group.add(mesh);
|
||
}
|
||
if (useFrame && !useFill) {
|
||
// Outline only — four edges as hairline segs (rare path)
|
||
const hw = boxW / 2, hh = boxH / 2;
|
||
const corners = [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh], [-hw, -hh]];
|
||
const world = corners.map(([x, y]) => ({
|
||
x: cx + x * cos - y * sin,
|
||
y: cy + x * sin + y * cos,
|
||
}));
|
||
// Use a thin mesh strip ring via Line if we had a helper; simple solid edges:
|
||
for (let i = 0; i < 4; i++) {
|
||
const a = world[i], b = world[i + 1];
|
||
this._widePolyMesh(
|
||
[a, b], null, false,
|
||
{ constantWidth: Math.max(height * 0.05, 0.01) },
|
||
bgColor, 1, null,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Slug vector text (GPU winding-number glyphs, see slugText.ts): one merged
|
||
// mesh per load for ALL texts. Async because the TTF loads once on demand;
|
||
// falls back to the legacy canvas sprites if the font can't be fetched.
|
||
// Underlay (viewport model labels) and overlay (paper labels) are two meshes
|
||
// so paper text always composites above viewport content (Layout draw order).
|
||
async _drawTexts(texts, minTextH) {
|
||
const gen = this._textGen;
|
||
const under = texts.filter((t) => t.underlay);
|
||
const over = texts.filter((t) => !t.underlay);
|
||
const layers = [
|
||
{ list: under, order: 0 },
|
||
{ list: over, order: 1 },
|
||
];
|
||
try {
|
||
const engine = await SlugTextEngine.shared();
|
||
if (gen !== this._textGen) return;
|
||
for (const { list, order } of layers) {
|
||
if (!list.length) continue;
|
||
for (const t of list) {
|
||
if ((t.bgFillFlags | 0) !== 0) this._drawMTextBackground(t, engine, minTextH);
|
||
}
|
||
const batch = new SlugTextBatch(engine);
|
||
for (const t of list) {
|
||
batch.add(t.text, t.pos, Math.max(t.height, minTextH), t.rotation || 0, t.color, t.alignH ?? 0, t.alignV ?? 0);
|
||
}
|
||
const mesh = batch.build();
|
||
if (mesh) {
|
||
mesh.renderOrder = order;
|
||
// Transparent text: draw later order on top regardless of z
|
||
if (mesh.material) {
|
||
mesh.material.depthWrite = false;
|
||
mesh.material.transparent = true;
|
||
}
|
||
this._group.add(mesh);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('[Viewer2D] Slug text unavailable, falling back to canvas sprites:', e?.message ?? e);
|
||
if (gen !== this._textGen) return;
|
||
for (const { list, order } of layers) {
|
||
for (const t of list) {
|
||
if ((t.bgFillFlags | 0) !== 0) this._drawMTextBackground(t, null, minTextH);
|
||
this._textSprite(t.text, t.pos, Math.max(t.height, minTextH), t.rotation, t.color, t.alignH ?? 0, t.alignV ?? 0, order);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// alignH: 0=left, 1=center, 2=right, 4=middle-center. alignV: 0=baseline,1=bottom,2=middle,3=top.
|
||
_textSprite(text, pos, height, rotation, color, alignH = 0, alignV = 0, renderOrder = 0) {
|
||
// MTEXT paragraph breaks (\P → \n) produce multi-line text: render stacked.
|
||
const lines = String(text).split('\n');
|
||
if (lines.length > 1) {
|
||
this._multilineSprite(lines, pos, height, rotation, color, alignH, alignV, renderOrder);
|
||
return;
|
||
}
|
||
// Texture cache: table drawings repeat the same strings thousands of times
|
||
// ("D25", sizes, counts). Rasterizing each occurrence separately OOMs on
|
||
// dense sheets (e.g. 6k+ texts) — share one CanvasTexture per (text, color).
|
||
// Alignment/rotation are per-sprite (scale/offset/material), not per-raster.
|
||
const cacheKey = text + ' |