/** * 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); const w = container.clientWidth || 800; const h = container.clientHeight || 600; this._camera = new THREE.OrthographicCamera(-w/2, w/2, h/2, -h/2, -10000, 10000); this._camera.position.set(0, 0, 100); this._renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true }); this._renderer.setPixelRatio(window.devicePixelRatio); this._renderer.setSize(w, h); container.appendChild(this._renderer.domElement); this._controls = new OrbitControls(this._camera, this._renderer.domElement); this._controls.enableRotate = false; this._controls.screenSpacePanning = true; this._controls.zoomToCursor = true; this._controls.mouseButtons = { LEFT: THREE.MOUSE.PAN, MIDDLE: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.PAN }; this._controls.touches = { ONE: THREE.TOUCH.PAN, TWO: THREE.TOUCH.DOLLY_PAN }; const savedSpeed = localStorage.getItem('hmw:zoom-speed'); if (savedSpeed) { const speed = parseFloat(savedSpeed); if (!isNaN(speed)) { this._controls.zoomSpeed = speed; } } window.addEventListener('keydown', (e) => { if (e.key === 'f' || e.key === 'F') this.fit(); }); this._group = new THREE.Group(); this._scene.add(this._group); window.addEventListener('resize', () => this._onResize()); // Prevent browser auto-scroll popup when middle-button is pressed on the WebGL canvas this._renderer.domElement.addEventListener('pointerdown', (e) => { if (e.button === 1) e.preventDefault(); }); this._renderer.domElement.addEventListener('mousedown', (e) => { this._downXY = [e.clientX, e.clientY]; if (e.button === 1) e.preventDefault(); }); this._renderer.domElement.addEventListener('click', (e) => this._onClick(e)); this._renderer.domElement.addEventListener('pointermove', (e) => this._onPointerMove(e)); this._renderer.domElement.addEventListener('pointerleave', () => { if (this._onPointerWorldCb) this._onPointerWorldCb(null); }); this._selColor = { r: 0xd8 / 255, g: 0x3a / 255, b: 0x2f / 255 }; this._gridVisible = false; this._gridMesh = null; /** @type {string|null} active Model/Paper space handle hex; null = no space filter */ this._activeSpaceHex = null; /** @type {null|((p:{x:number,y:number,z:number}|null)=>void)} */ this._onPointerWorldCb = null; this._ptrWorldTmp = new THREE.Vector3(); // Display frame for coordinates (Model UCS vs WCS). Paper uses identity. this._coordOrigin = { x: 0, y: 0, z: 0 }; this._coordXAxis = { x: 1, y: 0, z: 0 }; this._coordYAxis = { x: 0, y: 1, z: 0 }; this._controls.addEventListener('change', () => { if (this._gridVisible) this._rebuildGrid(); }); this._animate(); } onSelect(cb) { this._onSelectCb = cb; } resize() { this._onResize(); } setZoomSpeed(speed) { if (this._controls) this._controls.zoomSpeed = speed; } getZoomSpeed() { return this._controls ? this._controls.zoomSpeed : 1.0; } /** * Subscribe to mouse world-coordinate updates (CAD WCS / drawing units). * Callback receives `{ x, y, z }` or `null` when the pointer leaves the canvas. */ onPointerWorld(cb) { this._onPointerWorldCb = cb || null; } /** * Convert browser client coords → **CAD display** coordinates (Z=0 plane). * Model Space: model UCS (matches AutoCAD status bar when UCS = drawing UCS). * Paper/Layout: paper drawing units (identity). * Pick/measure still use raw WCS via `_screenToWcs`. */ screenToWorld(clientX, clientY) { const wcs = this._screenToWcs(clientX, clientY); if (!wcs) return null; return this._wcsToCad(wcs.x, wcs.y); } /** * Raw WCS (geometry storage frame) under the cursor. * Ortho 2D: map NDC through camera right/up axes (handles view-twist up vector). */ _screenToWcs(clientX, clientY) { const el = this._renderer?.domElement; const cam = this._camera; if (!el || !cam) return null; const rect = el.getBoundingClientRect(); if (rect.width < 1 || rect.height < 1) return null; cam.updateMatrixWorld(true); const ndcX = ((clientX - rect.left) / rect.width) * 2 - 1; const ndcY = -((clientY - rect.top) / rect.height) * 2 + 1; // Orthographic frustum half-sizes in world units (respect zoom). const zoom = cam.zoom || 1; const halfW = ((cam.right - cam.left) * 0.5) / zoom; const halfH = ((cam.top - cam.bottom) * 0.5) / zoom; // Camera basis in world (columns of matrixWorld). const te = cam.matrixWorld.elements; const rx = te[0], ry = te[1]; // camera local +X (right) const ux = te[4], uy = te[5]; // camera local +Y (up) // Anchor on the look target (z=0 plane), not camera.position.z const tx = this._controls?.target?.x ?? cam.position.x; const ty = this._controls?.target?.y ?? cam.position.y; return { x: tx + ndcX * halfW * rx + ndcY * halfH * ux, y: ty + ndcX * halfW * ry + ndcY * halfH * uy, z: 0, }; } /** WCS → CAD display (model UCS when axes are available). */ _wcsToCad(wx, wy) { const o = this._coordOrigin || { x: 0, y: 0 }; const xx = this._coordXAxis || { x: 1, y: 0 }; const yy = this._coordYAxis || { x: 0, y: 1 }; const dx = wx - (o.x || 0); const dy = wy - (o.y || 0); // Orthogonal axes: project onto X/Y directions (identity → subtract origin). return { x: dx * (xx.x || 0) + dy * (xx.y || 0), y: dx * (yy.x || 0) + dy * (yy.y || 0), z: 0, }; } /** Configure coordinate readout frame for the active space. */ _setCoordFrame(result) { const spaces = listCadSpaces(result); const active = spaces.find((s) => s.handleHex === this._activeSpaceHex); const vars = result?.vars || {}; // Model: AutoCAD status bar uses model UCS origin/axes when set. // Paper: sheet coordinates — identity. if (active?.kind === 'model' && vars.ucsOrigin) { this._coordOrigin = { x: vars.ucsOrigin.x || 0, y: vars.ucsOrigin.y || 0, z: vars.ucsOrigin.z || 0, }; this._coordXAxis = vars.ucsXAxis || { x: 1, y: 0, z: 0 }; this._coordYAxis = vars.ucsYAxis || { x: 0, y: 1, z: 0 }; } else { this._coordOrigin = { x: 0, y: 0, z: 0 }; this._coordXAxis = { x: 1, y: 0, z: 0 }; this._coordYAxis = { x: 0, y: 1, z: 0 }; } } _onPointerMove(e) { if (!this._onPointerWorldCb) return; const p = this.screenToWorld(e.clientX, e.clientY); this._onPointerWorldCb(p); } /** * Zoom extents to visible entity AABB (space/layer filtered). * Prefer the load-time content box (same filter as draw); recompute from * scene if missing. Percentile-trim drops far junk verts. */ fit() { // Prefer AABB recorded while drawing (matches visible entities exactly). if (this._contentBox && !this._contentBox.isEmpty()) { this._fit(this._contentBox); return; } const box = new THREE.Box3(); const samples = []; this._group.traverse((obj) => { if (!obj.geometry) return; box.expandByObject(obj); const pos = obj.geometry.attributes?.position; if (!pos?.array) return; const arr = pos.array; const step = Math.max(3, Math.floor(arr.length / 3 / 4000) * 3); for (let i = 0; i + 1 < arr.length; i += step) { if (Number.isFinite(arr[i]) && Number.isFinite(arr[i + 1])) samples.push(arr[i], arr[i + 1]); } }); if (box.isEmpty()) return; const use = this._trimmedContentBox(samples, box) || box; this._contentBox = use.clone(); this._fit(use); } load(result, opts = {}) { this._lastResult = result; this._selMeta = null; this._clear(); this._entityMeta = []; this._buildLayerMap(result?.tables?.layers); this._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; // 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); } 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 ) const decodeBmpDataUrl = (dataUrl) => { const b64 = dataUrl.split(',')[1]; const bin = atob(b64); const buf = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i); const dv = new DataView(buf.buffer); // BITMAPFILEHEADER (14 bytes) const sig = String.fromCharCode(buf[0], buf[1]); if (sig !== 'BM') throw new Error('Not a BMP file'); const pixelOffset = dv.getUint32(10, true); // BITMAPINFOHEADER (40 bytes, starts at offset 14) const bmpWidth = dv.getInt32(18, true); const bmpHeight = dv.getInt32(22, true); // positive = bottom-up const bitCount = dv.getUint16(28, true); const absH = Math.abs(bmpHeight); const flipY = bmpHeight > 0; // bottom-up storage const cvs = document.createElement('canvas'); cvs.width = bmpWidth; cvs.height = absH; const ctx2 = cvs.getContext('2d'); const imgData = ctx2.createImageData(bmpWidth, absH); const px = imgData.data; if (bitCount === 32) { const bpr = bmpWidth * 4; for (let y = 0; y < absH; y++) { const srcRow = flipY ? (absH - 1 - y) : y; const srcOff = pixelOffset + srcRow * bpr; const dstOff = y * bmpWidth * 4; for (let x = 0; x < bmpWidth; x++) { const s = srcOff + x * 4; const d = dstOff + x * 4; px[d] = buf[s + 2]; // R (BMP stores BGR) px[d + 1] = buf[s + 1]; // G px[d + 2] = buf[s]; // B px[d + 3] = 255; // A — GDI 32bpp has zeroed alpha padding } } } else if (bitCount === 24) { const stride = Math.ceil(bmpWidth * 3 / 4) * 4; // row padded to 4 bytes for (let y = 0; y < absH; y++) { const srcRow = flipY ? (absH - 1 - y) : y; const srcOff = pixelOffset + srcRow * stride; const dstOff = y * bmpWidth * 4; for (let x = 0; x < bmpWidth; x++) { const s = srcOff + x * 3; const d = dstOff + x * 4; px[d] = buf[s + 2]; px[d + 1] = buf[s + 1]; px[d + 2] = buf[s]; px[d + 3] = 255; } } } ctx2.putImageData(imgData, 0, 0); return cvs; }; const bmpCanvas = decodeBmpDataUrl(imageDataUrl); const texture = new THREE.CanvasTexture(bmpCanvas); texture.colorSpace = THREE.SRGBColorSpace; texture.needsUpdate = true; const planeGeom = new THREE.PlaneGeometry(width, Math.abs(height)); const planeMat = new THREE.MeshBasicMaterial({ map: texture, side: THREE.DoubleSide, transparent: false, }); const mesh = new THREE.Mesh(planeGeom, planeMat); mesh.position.set(minX + width / 2, minY + Math.abs(height) / 2, z + 10); this._group.add(mesh); this._renderer.render(this._scene, this._camera); } catch (err) { console.error('Failed rendering CanvasTexture for OLE image:', err); } } else { // Draw diagonal cross + overlay label fallback pushSeg(minX, minY, maxX, maxY, z, color); pushSeg(minX, maxY, maxX, minY, z, color); const hSpan = Math.abs(height); const lblHeight = Math.max(Math.min(hSpan * 0.05, 300), 40); const labelText = appName ? `[OLE: ${appName}]` : '[OLE2Frame]'; const labelPos = { x: (minX + maxX) / 2, y: (minY + maxY) / 2, z }; pendingTexts.push({ text: labelText, pos: labelPos, height: lblHeight, rotation: 0, color, alignH: 1, alignV: 2, }); } // Expand scene bounds & set meta bounds for picking/fitting expand(minX, minY, z); expand(maxX, maxY, z); meta.bounds = { type: 'point', cx: (minX + maxX) / 2, cy: (minY + maxY) / 2 }; break; } // Paper-space VIEWPORT: drawn later as a model-space projection window. case 'VIEWPORT': break; default: break; } } catch { /* skip malformed entity */ } meta.colEnd = lineColors.length; } // ── Render deferred text (underlay / paper stack) ───────────────────── // Viewport-sourced labels were pushed first (underlay:true); paper labels // follow. Draw underlay first so paper text meshes land later = on top. if (pendingTexts.length) { // Historical floor: diag×0.0002 made paper-sheet labels readable at // zoom-extents. On survey Model Space (diag ~1e6 m) that floor is // ~200–300 drawing units while native TEXT height is ~0.3–3.5 → every // label becomes a giant billboard over the contours (MOT/FLOW etc.). // Cap the floor at the median native height so we never upscale past CAD. const sz = box.isEmpty() ? new THREE.Vector3(1, 1, 0) : box.getSize(new THREE.Vector3()); const diagSize = Math.hypot(Math.max(sz.x, 1), Math.max(sz.y, 1)); const heights = []; for (const t of pendingTexts) { const h = t.height; if (h > 0 && Number.isFinite(h)) heights.push(h); } heights.sort((a, b) => a - b); const medianH = heights.length ? heights[heights.length >> 1] : 2.5; const minTextH = Math.min(diagSize * 0.0002, medianH); // Cap sprite count to avoid GPU/heap OOM on large drawings. With the // per-(text,color) texture cache in _textSprite, memory scales with // UNIQUE strings, so the cap is a sprite-object guard, not a raster one // (3000 previously culled half the texts on table sheets, e.g. BLOCK4). const MAX_TEXTS = 20000; const underlay = pendingTexts.filter((t) => t.underlay); const overlay = pendingTexts.filter((t) => !t.underlay); const ordered = underlay.concat(overlay); const renderTexts = ordered.length > MAX_TEXTS ? (console.warn(`텍스트 ${ordered.length}개 → ${MAX_TEXTS}개로 제한`), // Prefer keeping paper (overlay) labels when capping overlay.concat(underlay).sort((a, b) => b.height - a.height).slice(0, MAX_TEXTS)) : ordered; void this._drawTexts(renderTexts, minTextH); } this._lineColorAttr = null; this._origColors = null; if (lineVerts.length) { const geom = new THREE.BufferGeometry(); geom.setAttribute('position', new THREE.Float32BufferAttribute(lineVerts, 3)); const colorAttr = new THREE.Float32BufferAttribute(lineColors, 3); geom.setAttribute('color', colorAttr); this._group.add(new THREE.LineSegments(geom, new THREE.LineBasicMaterial({ vertexColors: true }))); this._lineColorAttr = colorAttr; this._origColors = Float32Array.from(colorAttr.array); } // Dashed linetypes (HIDDEN / CENTER / …) — one LineSegments per dash|gap // bucket with a LineDashedMaterial. computeLineDistances() is REQUIRED for // the dash pattern to appear; on LineSegments each 2-vertex pair dashes // independently from its own start (correct for CAD segments). for (const bk of dashBuckets.values()) { if (!bk.verts.length) continue; const dgeom = new THREE.BufferGeometry(); dgeom.setAttribute('position', new THREE.Float32BufferAttribute(bk.verts, 3)); dgeom.setAttribute('color', new THREE.Float32BufferAttribute(bk.colors, 3)); const dline = new THREE.LineSegments(dgeom, new THREE.LineDashedMaterial({ vertexColors: true, dashSize: bk.dash, gapSize: bk.gap, })); dline.computeLineDistances(); this._group.add(dline); } // Coordinate readout frame (Model UCS vs paper identity). this._setCoordFrame(result); // Visible-content AABB for Fit — trim extreme outliers (matches CAD zoom extents better). const fitBox = box.isEmpty() ? null : (this._trimmedContentBox(fitSamples, box) || box); this._contentBox = fitBox ? fitBox.clone() : null; if (!opts.keepView && fitBox && !fitBox.isEmpty()) this._fit(fitBox); } // ── UI integration ───────────────────────────────────────────────────────── /** Swap canvas background for theme. dark=true → near-black (Model), false → white (Layout). */ setTheme(dark) { this._scene.background = new THREE.Color(dark ? 0x0a0b0d : 0xffffff); if (this._gridVisible) this._rebuildGrid(); } setGrid(visible) { this._gridVisible = visible; if (!visible) { if (this._gridMesh) { this._scene.remove(this._gridMesh); this._gridMesh.geometry.dispose(); this._gridMesh.material.dispose(); this._gridMesh = null; } } else { this._rebuildGrid(); } } _rebuildGrid() { if (this._gridMesh) { this._scene.remove(this._gridMesh); this._gridMesh.geometry.dispose(); this._gridMesh.material.dispose(); this._gridMesh = null; } const zoom = this._camera.zoom || 1; const cx = this._camera.position.x; const cy = this._camera.position.y; const hw = (this._camera.right - this._camera.left) / zoom / 2; const hh = (this._camera.top - this._camera.bottom) / zoom / 2; const ext = Math.max(hw, hh) * 3; const spacing = this._niceSpacing(Math.max(hw, hh) * 2 / 20); const [minX, maxX, minY, maxY] = [cx - ext, cx + ext, cy - ext, cy + ext]; const isDark = this._scene.background.r < 0.5; const color = isDark ? 0x2b2b2b : 0xbbbbbb; const verts = []; for (let x = Math.ceil(minX / spacing) * spacing; x <= maxX; x += spacing) verts.push(x, minY, -0.5, x, maxY, -0.5); for (let y = Math.ceil(minY / spacing) * spacing; y <= maxY; y += spacing) verts.push(minX, y, -0.5, maxX, y, -0.5); const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3)); this._gridMesh = new THREE.LineSegments(geo, new THREE.LineBasicMaterial({ color })); this._scene.add(this._gridMesh); } _niceSpacing(raw) { if (raw <= 0) return 1; const exp = Math.pow(10, Math.floor(Math.log10(raw))); const norm = raw / exp; return (norm < 2 ? 1 : norm < 5 ? 2 : 5) * exp; } /** Zoom % relative to fit (ortho camera zoom). */ getZoomPercent() { return Math.round((this._camera?.zoom ?? 1) * 100); } /** Subscribe to pan/zoom changes (for live zoom readout). */ onViewChange(cb) { this._controls?.addEventListener('change', cb); } /** Hide entities on the given layer names (Set); re-renders keeping the view. */ setHiddenLayers(nameSet) { this._hiddenLayers = nameSet || new Set(); if (this._lastResult) this.load(this._lastResult, { keepView: true }); } /** * Model / Paper (Layout) spaces present in the last loaded result. * @returns {import('./cadSpaces').CadSpace[]} */ getSpaces() { return listCadSpaces(this._lastResult); } /** Currently active space handle hex, or null if unfiltered. */ getActiveSpace() { return this._activeSpaceHex; } /** * Switch Model ↔ Layout (paper) space and re-render. * @param {string|number|null} spaceHandle hex string or numeric handle; null clears filter * @param {{ keepView?: boolean }} [opts] */ setSpace(spaceHandle, opts = {}) { if (spaceHandle == null || spaceHandle === '') { this._activeSpaceHex = null; } else if (typeof spaceHandle === 'number') { this._activeSpaceHex = spaceHandle.toString(16); } else { this._activeSpaceHex = String(spaceHandle).toLowerCase(); } if (this._lastResult) { this.load(this._lastResult, { keepView: !!opts.keepView, spaceHandle: this._activeSpaceHex, }); // load() refreshes coord frame for Model UCS vs paper identity } } /** * True if this VIEWPORT is a window into model space (not the overall paper VP). * R2000 often stores id=0 for all VPs, so we detect the overall paper viewport by * 1:1 scale + viewTarget≈0 + viewCenter≈paper center. */ _isModelViewport(vp) { if (!vp) return false; if (vp.status && vp.status.isOn === false) return false; const id = vp.id ?? 0; // Classic DXF: id 1 = overall paper-space viewport (not a model window). if (id === 1) return false; const H = Number(vp.height) || 0; const vH = Number(vp.viewHeight) || 0; if (H <= 0 || vH <= 0) return false; const scale = H / vH; const c = vp.center || {}; const vc = vp.viewCenter || {}; const vt = vp.viewTarget || {}; const targetNear0 = Math.hypot(vt.x || 0, vt.y || 0) < 1e-3; const viewCtrNearPaper = Math.hypot((vc.x || 0) - (c.x || 0), (vc.y || 0) - (c.y || 0)) < Math.max(H, 1) * 0.05; // Overall paper VP: identity mapping of the sheet onto itself. if (Math.abs(scale - 1) < 0.03 && targetNear0 && viewCtrNearPaper) return false; return true; } /** * Model WCS (x,y) → paper-space point for a VIEWPORT (orthographic / top). * DCS = R(+twist) * (model - viewTarget) [matches acadrust/ODA view-center convention] * paper = center + scale * (DCS - viewCenter) * scale = paperHeight / viewHeight */ _viewportModelToPaper(vp) { const c = vp.center || { x: 0, y: 0 }; const vc = vp.viewCenter || { x: 0, y: 0 }; const vt = vp.viewTarget || { x: 0, y: 0 }; const H = Number(vp.height) || 1; const vH = (Number(vp.viewHeight) > 1e-12) ? Number(vp.viewHeight) : H; const scale = H / vH; const twist = Number(vp.twistAngle) || 0; const cos = Math.cos(twist); const sin = Math.sin(twist); const fn = (mx, my) => { const dx = mx - (vt.x || 0); const dy = my - (vt.y || 0); // R(+twist) · [dx, dy] const rx = cos * dx - sin * dy; const ry = sin * dx + cos * dy; return { x: (c.x || 0) + (rx - (vc.x || 0)) * scale, y: (c.y || 0) + (ry - (vc.y || 0)) * scale, }; }; fn.scale = scale; fn.twist = twist; return fn; } _viewportPaperClip(vp) { const c = vp.center || { x: 0, y: 0 }; const hw = (Number(vp.width) || 0) / 2; const hh = (Number(vp.height) || 0) / 2; // Slight expand to avoid hairline gaps at edges const pad = Math.max(hw, hh) * 1e-6; return { minX: (c.x || 0) - hw - pad, maxX: (c.x || 0) + hw + pad, minY: (c.y || 0) - hh - pad, maxY: (c.y || 0) + hh + pad, }; } /** Cohen–Sutherland clip; calls out(x1,y1,x2,y2) for the visible segment (if any). */ _clipSegToRect(x1, y1, x2, y2, rect, out) { const INSIDE = 0, LEFT = 1, RIGHT = 2, BOTTOM = 4, TOP = 8; const code = (x, y) => { let c = INSIDE; if (x < rect.minX) c |= LEFT; else if (x > rect.maxX) c |= RIGHT; if (y < rect.minY) c |= BOTTOM; else if (y > rect.maxY) c |= TOP; return c; }; let c1 = code(x1, y1), c2 = code(x2, y2); for (;;) { if (!(c1 | c2)) { out(x1, y1, x2, y2); return; } if (c1 & c2) return; const c = c1 || c2; let x = 0, y = 0; if (c & TOP) { x = x1 + (x2 - x1) * (rect.maxY - y1) / (y2 - y1 || 1e-30); y = rect.maxY; } else if (c & BOTTOM) { x = x1 + (x2 - x1) * (rect.minY - y1) / (y2 - y1 || 1e-30); y = rect.minY; } else if (c & RIGHT) { y = y1 + (y2 - y1) * (rect.maxX - x1) / (x2 - x1 || 1e-30); x = rect.maxX; } else { y = y1 + (y2 - y1) * (rect.minX - x1) / (x2 - x1 || 1e-30); x = rect.minX; } if (c === c1) { x1 = x; y1 = y; c1 = code(x1, y1); } else { x2 = x; y2 = y; c2 = code(x2, y2); } } } /** * Emit model-space geometry into a paper VIEWPORT (transformed + clipped). * Covers the entity types that dominate civil plan/profile sheets. */ _emitModelThroughViewport(vp, ctx) { const { entities, modelAllowed, blockDefHandles, entsByOwner, blockBaseByHex, pushSeg: basePush, expand: baseExpand, pendingTexts, underlay = false, } = ctx; const xf = this._viewportModelToPaper(vp); const clip = this._viewportPaperClip(vp); const scale = xf.scale || 1; const frozen = new Set((vp.frozenLayers || []).map((h) => String(h))); const pushSeg = (ax, ay, bx, by, z, color) => { const a = xf(ax, ay); const b = xf(bx, by); this._clipSegToRect(a.x, a.y, b.x, b.y, clip, (x1, y1, x2, y2) => { basePush(x1, y1, x2, y2, z || 0, color); }); }; const expand = (x, y, z = 0) => { const p = xf(x, y); if (p.x >= clip.minX && p.x <= clip.maxX && p.y >= clip.minY && p.y <= clip.maxY) { baseExpand(p.x, p.y, z); } }; const inClip = (x, y) => x >= clip.minX && x <= clip.maxX && y >= clip.minY && y <= clip.maxY; // Quick reject: model bbox far from view focus (optional optimisation skip for correctness) for (const e of entities) { const ownerHex = e.ownerHandle?.value?.toString(16); if (!ownerHex || !modelAllowed.has(ownerHex)) continue; if (blockDefHandles.has(ownerHex)) continue; if (this._hiddenLayers?.size) { const lh = e.layerHandle?.value ?? e.layerHandle; const lname = (lh != null && this._layerNameByHandle.get(String(lh))) ?? e.layer ?? e.layerName; if (lname && this._hiddenLayers.has(lname)) continue; } if (frozen.size) { const lh = e.layerHandle?.value; if (lh != null && frozen.has(String(lh))) continue; } const d = e.data || e; const type = (e.type || e.typeName || '').toUpperCase(); const color = this._entityColor(e); 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) ?? 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) ?? 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); if (hex !== null) return hex; } const lh = entity.layerHandle?.value ?? entity.layerHandle; if (lh != null) { const c = this._layerByHandle.get(String(lh)); if (c !== undefined) return c; } const ln = entity.layer ?? entity.layerName; if (ln) { const c = this._layerByName.get(ln); if (c !== undefined) return c; } return DEFAULT_COLOR; } // Filled triangle arrow mesh (replaces V-line arrowhead) _arrowMesh(pt, rotation, size, color) { if (!pt || size < 1e-4) return; const w = size * 0.166; const r = ((color >> 16) & 0xFF) / 255; const g = ((color >> 8) & 0xFF) / 255; const b = (color & 0xFF) / 255; const geom = new THREE.BufferGeometry(); geom.setAttribute('position', new THREE.Float32BufferAttribute([0,0,0, -size,w,0, -size,-w,0], 3)); geom.setAttribute('color', new THREE.Float32BufferAttribute([r,g,b, r,g,b, r,g,b], 3)); const mesh = new THREE.Mesh(geom, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.DoubleSide })); mesh.position.set(pt.x, pt.y, (pt.z || 0) + 0.5); mesh.rotation.z = rotation; this._group.add(mesh); } // Default geometry for AutoCAD standard arrowhead blocks that ship no geometry // in the file (system blocks). `name` is the lowercased block name. _defaultArrow(name, pt, rotation, size, color, pushSeg) { if (!pt || size < 1e-4) return; const n = (name || '').replace(/^[_*]+/, ''); const cos = Math.cos(rotation), sin = Math.sin(rotation); // local→world: arrow points toward +X at the insertion point const P = (lx, ly) => ({ x: pt.x + lx * cos - ly * sin, y: pt.y + lx * sin + ly * cos }); if (n === 'none' || n === 'small' || n === 'integral') return; if (n === 'dot' || n === 'dotsmall' || n === 'dotblank' || n === 'dotsmallblank' || n === 'origin' || n === 'origin2') { this._dotMesh(pt, size * (n === 'dotsmall' ? 0.12 : 0.5), color); return; } if (n === 'oblique' || n === 'archtick') { const a = P(size * 0.5, size * 0.5), b = P(-size * 0.5, -size * 0.5); pushSeg(a.x, a.y, b.x, b.y, (pt.z || 0) + 0.5, color); return; } if (n === 'open' || n === 'open30' || n === 'open90') { const w = n === 'open90' ? size : size * 0.42; const t = P(0, 0), u = P(-size, w), v = P(-size, -w); pushSeg(u.x, u.y, t.x, t.y, (pt.z || 0) + 0.5, color); pushSeg(v.x, v.y, t.x, t.y, (pt.z || 0) + 0.5, color); return; } // default (_ClosedFilled, "", closed, boxfilled, datumfilled, …) → filled triangle this._arrowMesh(pt, rotation, size, color); } // Filled disc (dot arrowhead). _dotMesh(center, radius, color) { if (!center || !(radius > 1e-5)) return; const r = ((color >> 16) & 0xFF) / 255, g = ((color >> 8) & 0xFF) / 255, b = (color & 0xFF) / 255; const segs = 20, verts = [], cols = [], z = (center.z || 0) + 0.5; for (let i = 0; i < segs; i++) { const a0 = i / segs * Math.PI * 2, a1 = (i + 1) / segs * Math.PI * 2; verts.push(center.x, center.y, z, center.x + radius * Math.cos(a0), center.y + radius * Math.sin(a0), z, center.x + radius * Math.cos(a1), center.y + radius * Math.sin(a1), z); cols.push(r, g, b, r, g, b, r, g, b); } const geom = new THREE.BufferGeometry(); geom.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3)); geom.setAttribute('color', new THREE.Float32BufferAttribute(cols, 3)); this._group.add(new THREE.Mesh(geom, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.DoubleSide }))); } // Dot radius (block-local) from an arrow block's geometry, measured from its centroid. _arrowDotRadius(ents) { let r = 0; for (const ae of (ents || [])) { const ad = ae.data || ae; if ((ae.type || '').toUpperCase() === 'CIRCLE' && ad.radius) r = Math.max(r, ad.radius); const pts = ad.points ?? ad.vertices; if (pts?.length) { let cx = 0, cy = 0; pts.forEach(p => { cx += p.x; cy += p.y; }); cx /= pts.length; cy /= pts.length; pts.forEach(p => { r = Math.max(r, Math.hypot(p.x - cx, p.y - cy)); }); } } return r; } _arcSegs(center, r, a0, a1, z, color, pushSeg) { let span = a1 - a0; if (span <= 0) span += Math.PI * 2; const steps = Math.max(8, Math.ceil((span / (Math.PI * 2)) * ARC_SEGS)); let prev = null; for (let i = 0; i <= steps; i++) { const a = a0 + (span * i) / steps; const x = center.x + r * Math.cos(a); const y = center.y + r * Math.sin(a); if (prev) pushSeg(prev.x, prev.y, x, y, z, color); prev = { x, y }; } } // Centripetal CatmullRom interpolation for SPLINE _catmullRom(pts, t) { const n = pts.length; const f = t * (n - 1); const i = Math.min(Math.floor(f), n - 2); const tt = f - i; const p0 = pts[Math.max(i - 1, 0)]; const p1 = pts[i]; const p2 = pts[i + 1]; const p3 = pts[Math.min(i + 2, n - 1)]; const t2 = tt * tt, t3 = t2 * tt; return { x: 0.5 * ((2*p1.x) + (-p0.x+p2.x)*tt + (2*p0.x-5*p1.x+4*p2.x-p3.x)*t2 + (-p0.x+3*p1.x-3*p2.x+p3.x)*t3), y: 0.5 * ((2*p1.y) + (-p0.y+p2.y)*tt + (2*p0.y-5*p1.y+4*p2.y-p3.y)*t2 + (-p0.y+3*p1.y-3*p2.y+p3.y)*t3), z: 0.5 * ((2*(p1.z||0)) + (-(p0.z||0)+(p2.z||0))*tt + (2*(p0.z||0)-5*(p1.z||0)+4*(p2.z||0)-(p3.z||0))*t2 + (-(p0.z||0)+3*(p1.z||0)-3*(p2.z||0)+(p3.z||0))*t3), }; } _ellipseSegs(d, color, pushSeg) { // field names: majorAxis or smAxis; ratio or axisRatio const ma = d.majorAxis ?? d.smAxis ?? { x: 1, y: 0, z: 0 }; const center = d.center; const ratio = d.ratio ?? d.axisRatio ?? 1; const startParam = d.startParam ?? 0; const endParam = d.endParam ?? Math.PI * 2; const mx = ma?.x ?? 1, my = ma?.y ?? 0; const a = Math.sqrt(mx*mx + my*my); const b = a * ratio; const rot = Math.atan2(my, mx); let span = endParam - startParam; if (span <= 0) span += Math.PI * 2; let prev = null; for (let i = 0; i <= ELLIPSE_SEGS; i++) { const t = startParam + (span * i) / ELLIPSE_SEGS; const ex = a * Math.cos(t), ey = b * Math.sin(t); const x = center.x + ex * Math.cos(rot) - ey * Math.sin(rot); const y = center.y + ex * Math.sin(rot) + ey * Math.cos(rot); if (prev) pushSeg(prev.x, prev.y, x, y, center.z || 0, color); prev = { x, y }; } } _polylineSegs(points, closed, z, color, pushSeg) { if (!points || points.length < 2) return; for (let i = 0; i < points.length - 1; i++) { pushSeg(points[i].x, points[i].y, points[i+1].x, points[i+1].y, z, color); } if (closed) { const a = points[points.length-1], b = points[0]; pushSeg(a.x, a.y, b.x, b.y, z, color); } } // Resolve start/end width for segment i→i+1 (DXF 40/41, fallback 43 constant). _segWidths(ent, i, scale = 1) { const d = ent?.data || ent || {}; const cw = (d.constantWidth ?? d.constWidth ?? 0) * scale; const dsw = (d.defaultStartWidth ?? 0) * scale; const dew = (d.defaultEndWidth ?? 0) * scale; const sw0 = (d.startWidths?.[i] ?? 0) * scale; const ew0 = (d.endWidths?.[i] ?? 0) * scale; const sw = Math.abs(sw0) > 1e-12 ? sw0 : (Math.abs(cw) > 1e-12 ? cw : dsw); const ew = Math.abs(ew0) > 1e-12 ? ew0 : (Math.abs(cw) > 1e-12 ? cw : dew); return { sw: Math.abs(sw), ew: Math.abs(ew) }; } _polyHasWidth(ent, nSeg, scale = 1) { for (let i = 0; i < nSeg; i++) { const { sw, ew } = this._segWidths(ent, i, scale); if (sw > 1e-9 || ew > 1e-9) return true; } return false; } // Draw one trapezoid segment (variable start/end half-width) as two triangles. _pushWideSeg(verts, p1, p2, w0, w1) { const dx = p2.x - p1.x, dy = p2.y - p1.y; const len = Math.hypot(dx, dy); if (len < 1e-12) return; const nx = -dy / len, ny = dx / len; const h0 = w0 * 0.5, h1 = w1 * 0.5; const a = { x: p1.x + nx * h0, y: p1.y + ny * h0 }; const b = { x: p1.x - nx * h0, y: p1.y - ny * h0 }; const c = { x: p2.x - nx * h1, y: p2.y - ny * h1 }; const d = { x: p2.x + nx * h1, y: p2.y + ny * h1 }; // two triangles a-b-c, a-c-d verts.push(a.x, a.y, 0, b.x, b.y, 0, c.x, c.y, 0); verts.push(a.x, a.y, 0, c.x, c.y, 0, d.x, d.y, 0); } // Variable-width polyline → solid mesh (MeshBasicMaterial). Used when any // segment has start/end/constant width > 0 (도곽 outer frame etc.). _widePolyMesh(points, bulges, closed, ent, color, scale = 1, expand = null) { if (!points || points.length < 2) return; const n = points.length; const segs = closed ? n : n - 1; const verts = []; for (let i = 0; i < segs; i++) { const p1 = points[i]; const p2 = points[(i + 1) % n]; const { sw, ew } = this._segWidths(ent, i, scale); if (sw < 1e-9 && ew < 1e-9) { // Zero-width segment: still emit a tiny hair so it isn't dropped entirely. this._pushWideSeg(verts, p1, p2, 0.01 * scale, 0.01 * scale); if (expand) { expand(p1.x, p1.y); expand(p2.x, p2.y); } continue; } const bulge = bulges?.[i] || 0; if (Math.abs(bulge) < 1e-6) { this._pushWideSeg(verts, p1, p2, sw, ew); if (expand) { expand(p1.x, p1.y); expand(p2.x, p2.y); } } else { // Tessellate bulge arc; interpolate width along the arc. const dx = p2.x - p1.x, dy = p2.y - p1.y; const dist = Math.hypot(dx, dy); if (dist < 1e-9) continue; const inc = 4 * Math.atan(bulge); const half = Math.abs(inc) / 2; const radius = (dist / 2) / Math.sin(half); const apothem = radius * Math.cos(half); const mx2 = (p1.x + p2.x) / 2, my2 = (p1.y + p2.y) / 2; const nx2 = -dy / dist, ny2 = dx / dist; const sign = bulge > 0 ? 1 : -1; const cx = mx2 + sign * nx2 * apothem; const cy = my2 + sign * ny2 * apothem; const sa = Math.atan2(p1.y - cy, p1.x - cx); const steps = Math.max(2, Math.ceil(Math.abs(inc) / (Math.PI / 16))); let prev = { x: p1.x, y: p1.y }; let prevW = sw; for (let k = 1; k <= steps; k++) { const t = k / steps; const a = sa + (inc * k) / steps; const cur = { x: cx + radius * Math.cos(a), y: cy + radius * Math.sin(a) }; const curW = sw + (ew - sw) * t; this._pushWideSeg(verts, prev, cur, prevW, curW); prev = cur; prevW = curW; } if (expand) { expand(p1.x, p1.y); expand(p2.x, p2.y); } } } if (verts.length < 9) return; const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3)); geo.computeVertexNormals(); const mat = new THREE.MeshBasicMaterial({ color: new THREE.Color(color), side: THREE.DoubleSide, depthWrite: false, }); this._group.add(new THREE.Mesh(geo, mat)); // Pick as edge segments only — do NOT register the poly as a fill loop. // A closed outer frame (도곽 cw=2) used as _pickFills would mark the whole // interior as "covered" and (with a weak size check) hide every inner line. if (this._pickCurIdx >= 0 && this._pickSegs) { for (let i = 0; i < segs; i++) { const a = points[i], b = points[(i + 1) % n]; this._pickSegs.push(a.x, a.y, b.x, b.y); this._pickSegMeta.push(this._pickCurIdx); } } } // Polyline with bulge arcs (LWPOLYLINE, HATCH boundaries) _bulgePolySegs(points, bulges, closed, color, pushSeg, expand) { const n = points.length; const segs = closed ? n : n - 1; for (let i = 0; i < segs; i++) { const p1 = points[i]; const p2 = points[(i + 1) % n]; const bulge = bulges?.[i] || 0; if (Math.abs(bulge) < 1e-6) { pushSeg(p1.x, p1.y, p2.x, p2.y, 0, color); if (expand) { expand(p1.x, p1.y); expand(p2.x, p2.y); } } else { const dx = p2.x - p1.x, dy = p2.y - p1.y; const dist = Math.sqrt(dx*dx + dy*dy); if (dist < 1e-9) continue; // Sweep by the signed included angle Δ = 4·atan(bulge). Sweeping the angle // directly (rather than computing both endpoint angles and resolving the // 2π wrap) avoids drawing the major arc when |bulge| is tiny. const inc = 4 * Math.atan(bulge); // signed; >0 = CCW const half = Math.abs(inc) / 2; const radius = (dist / 2) / Math.sin(half); const apothem = radius * Math.cos(half); const mx2 = (p1.x + p2.x) / 2, my2 = (p1.y + p2.y) / 2; const nx2 = -dy / dist, ny2 = dx / dist; // left normal of p1→p2 const sign = bulge > 0 ? 1 : -1; const cx = mx2 + sign * nx2 * apothem; const cy = my2 + sign * ny2 * apothem; const sa = Math.atan2(p1.y - cy, p1.x - cx); const steps = Math.max(2, Math.ceil(Math.abs(inc) / (Math.PI / 16))); let prev = p1; for (let k = 1; k <= steps; k++) { const a = sa + (inc * k) / steps; const x = cx + radius * Math.cos(a), y = cy + radius * Math.sin(a); pushSeg(prev.x, prev.y, x, y, 0, color); prev = { x, y }; } if (expand) { expand(p1.x, p1.y); expand(p2.x, p2.y); } } } } _solidMesh(corners, color) { const r = ((color >> 16) & 0xFF) / 255; const g = ((color >> 8) & 0xFF) / 255; const b = (color & 0xFF) / 255; const verts = [], cols = []; const addTri = (...pts) => { for (const p of pts) { verts.push(p.x, p.y, p.z || 0); cols.push(r, g, b); } }; if (corners.length >= 4) { addTri(corners[0], corners[1], corners[3]); addTri(corners[0], corners[3], corners[2]); } else { addTri(corners[0], corners[1], corners[2]); } const geom = new THREE.BufferGeometry(); geom.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3)); geom.setAttribute('color', new THREE.Float32BufferAttribute(cols, 3)); this._group.add(new THREE.Mesh(geom, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.DoubleSide }))); // Filled quad/tri → pickable region (DWG SOLID vertex order is 0,1,3,2). if (this._pickCurIdx >= 0 && corners.length >= 3) { const loop = corners.length >= 4 ? [corners[0], corners[1], corners[3], corners[2]] : corners.slice(0, 3); this._pickFills.push({ metaIdx: this._pickCurIdx, loops: [loop] }); } } // Text has no line geometry — register its box as a pickable fill so a click // anywhere on the text selects it. Box follows the sprite's anchor rules // (alignH 0/3/5=left,2=right,1/4=center; alignV 3=top,0/1=bottom,2=middle). _addTextPick(text, pos, height, alignH = 0, alignV = 0) { if (this._pickCurIdx < 0 || !pos) return; const rows = String(text).split('\n'); const cols = rows.reduce((m, s) => Math.max(m, s.length), 1); const w = cols * height * 0.62; // ~ per-glyph advance const h = rows.length * height * 1.2; let x0 = pos.x; if (alignH === 2) x0 = pos.x - w; // right anchor else if (alignH === 1 || alignH === 4) x0 = pos.x - w / 2; // center let y0 = pos.y; // bottom anchor if (alignV === 3) y0 = pos.y - h; // top anchor else if (alignV === 2) y0 = pos.y - h / 2; // middle this._pickFills.push({ metaIdx: this._pickCurIdx, loops: [[ { x: x0, y: y0 }, { x: x0 + w, y: y0 }, { x: x0 + w, y: y0 + h }, { x: x0, y: y0 + h }, ]] }); } // Even-odd point-in-region across ALL loops of a fill (holes exclude correctly). _fillHit(loops, x, y) { let inside = false; for (const poly of loops) { for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { const xi = poly[i].x, yi = poly[i].y, xj = poly[j].x, yj = poly[j].y; if (((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) inside = !inside; } } return inside; } // True if (x,y) lies inside ANY single loop (NOT even-odd across loops). // Used to detect "this point belongs to hatch geometry" including hole // interiors — even-odd would report holes as uncovered and let a later // LWPOLYLINE re-solid them (CXGLOGO ㅇ/ㅎ counters). _pointInAnyLoop(loops, x, y) { for (const poly of loops) { if (!poly?.length) continue; let c = false; for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { const a = poly[i], b = poly[j]; if (((a.y > y) !== (b.y > y)) && (x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x)) c = !c; } if (c) return true; } return false; } // Signed area of a loop (for topmost = smallest-area tie-break on overlapping fills). _loopArea(poly) { let s = 0; for (let i = 0; i < poly.length; i++) { const a = poly[i], b = poly[(i + 1) % poly.length]; s += a.x * b.y - b.x * a.y; } return Math.abs(s) / 2; } // Solid-fill a polyline that is geometrically closed but not covered by any // existing hatch fill. Text-as-hatch logos sometimes ship glyph strokes as // LWPOLYLINE outlines only (e.g. ㅅ of 사 in CXGLOGO) while sibling glyphs // have solid HATCH — without this they render as empty outlines. // // Coverage test is ANY-loop containment (not even-odd). Logo blocks list // HATCH entities first, then the same glyph outlines as LWPOLYLINE. Even-odd // treats hole interiors as "uncovered", so the post-hatch polyline pass used // to re-solid ㅇ/ㅎ counters and wipe the punches. Any-loop still lets true // orphan outlines (ㅅ with no hatch) fill, while skipping anything that // already belongs to a hatch loop — fill or hole. _fillClosedPolyIfUncovered(points, bulges, color) { if (!points || points.length < 3) return; const a = points[0], b = points[points.length - 1]; const gap = Math.hypot(a.x - b.x, a.y - b.y); // Allow tiny gaps relative to polyline size (floating-point closed loops). let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const p of points) { if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y; if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y; } const diag = Math.hypot(maxX - minX, maxY - minY) || 1; if (gap > diag * 1e-4 && gap > 1e-6) return; // Area gate: logo glyph strokes are small (area ~1–100 in block units). // Refuse huge loops even inside blocks (defensive). let sa = 0; for (let i = 0; i < points.length; i++) { const p0 = points[i], p1 = points[(i + 1) % points.length]; sa += p0.x * p1.y - p1.x * p0.y; } const absA = Math.abs(sa) / 2; if (absA < 1e-6 || absA > 5000) return; let cx = 0, cy = 0; for (const p of points) { cx += p.x; cy += p.y; } cx /= points.length; cy /= points.length; for (const f of this._pickFills) { if (this._pointInAnyLoop(f.loops, cx, cy)) return; } this._hatchFill([{ points, bulges: bulges || null, closed: true }], color); } // Solid fill HATCH using THREE.ShapeGeometry // Tessellate one (closed) hatch boundary loop → flat {x,y}[] (resolves bulge arcs, // including the closing edge — a 2-point/2-bulge loop is a full circle). _pathPoints(path) { const pts = path.points; if (!pts?.length) return null; const bulges = path.bulges; const n = pts.length; if (!bulges?.some(b => Math.abs(b) >= 1e-6)) return pts.map(p => ({ x: p.x, y: p.y })); const out = []; for (let i = 0; i < n; i++) { const p1 = pts[i], p2 = pts[(i + 1) % n], bulge = bulges[i] || 0; out.push({ x: p1.x, y: p1.y }); if (Math.abs(bulge) < 1e-6) continue; const dx = p2.x - p1.x, dy = p2.y - p1.y, dist = Math.hypot(dx, dy); if (dist < 1e-9) continue; const inc = 4 * Math.atan(bulge), half = Math.abs(inc) / 2; const radius = (dist / 2) / Math.sin(half), apo = radius * Math.cos(half); const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2; const nx = -dy / dist, ny = dx / dist, sign = bulge > 0 ? 1 : -1; const cx = mx + sign * nx * apo, cy = my + sign * ny * apo; const sa = Math.atan2(p1.y - cy, p1.x - cx); const segs = Math.max(6, Math.ceil(Math.abs(inc) / (Math.PI / 16))); for (let j = 1; j < segs; j++) { // intermediate arc points; p2 added next iter const a = sa + inc * j / segs; out.push({ x: cx + radius * Math.cos(a), y: cy + radius * Math.sin(a) }); } } return out; } // Solid fill with true even-odd parity across all boundary loops. // // DWG solid HATCH (style Normal / odd parity) is even-odd, not "outer + // nested holes". Text-as-hatch logos (CXGLOGO 한국도로공사) ship // self-intersecting multi-stroke loops where ShapeGeometry/earcut + geometric // nesting mis-classifies ㅇ/ㅎ counters. Verified against samples/11.dwg: // SVG fill-rule=evenodd over all non-outlier loops matches the CAD logo. _hatchFill(paths, color) { const totalPts = paths.reduce((s, p) => s + (p.points?.length ?? 0), 0); if (totalPts > 8000) return; // safety for pathological boundaries let polys = []; for (const p of paths) { const pp = this._pathPoints(p); if (pp && pp.length >= 3) polys.push(pp); } if (!polys.length) return; const area = (poly) => { let s = 0; for (let i = 0; i < poly.length; i++) { const a = poly[i], b = poly[(i + 1) % poly.length]; s += a.x * b.y - b.x * a.y; } return Math.abs(s) / 2; }; // Drop spurious giant boundaries (parse-corrupt arcs expanded to huge loops). // Many-loop hatches (≥5) or empty-loop hatches get median×10 outlier filter. if ((paths.some(p => !(p.points?.length)) && polys.length >= 3) || polys.length >= 5) { const sorted = polys.map(area).sort((a, b) => a - b); const med = sorted[Math.floor(sorted.length / 2)] || 0; if (med > 0) polys = polys.filter(p => area(p) <= med * 10); if (!polys.length) return; } // Picking uses even-odd across all loops (holes exclude correctly). if (this._pickCurIdx >= 0 && polys.length) this._pickFills.push({ metaIdx: this._pickCurIdx, loops: polys }); // When does this hatch need true even-odd (vs independent union of islands)? // - Self-intersecting loops (text-as-hatch glyph strokes) // - Many loops (≥5): logo words like 한국도로공사 // - Geometric nesting: a smaller loop sits inside a larger one (island/hole) // Separate solid islands that only touch/overlap (EX logo 4 paths) must be // UNION — even-odd would punch diamond holes at the overlaps. const needsEvenOdd = polys.length >= 5 || polys.some((p) => this._polySelfIntersects(p)) || this._hatchHasNestedLoop(polys); if (needsEvenOdd) { this._hatchFillEvenOddCanvas(polys, color); return; } // Union of simple islands: one ShapeGeometry per loop (no hole punching). const mat = new THREE.MeshBasicMaterial({ color: new THREE.Color(color), side: THREE.DoubleSide }); for (const poly of polys) { try { const shape = new THREE.Shape(); shape.moveTo(poly[0].x, poly[0].y); for (let k = 1; k < poly.length; k++) shape.lineTo(poly[k].x, poly[k].y); shape.closePath(); this._group.add(new THREE.Mesh(new THREE.ShapeGeometry(shape), mat)); } catch { /* degenerate */ } } } // True if any loop is (mostly) inside a larger loop — island/hole topology. _hatchHasNestedLoop(polys) { if (polys.length < 2) return false; const area = (poly) => { let s = 0; for (let i = 0; i < poly.length; i++) { const a = poly[i], b = poly[(i + 1) % poly.length]; s += a.x * b.y - b.x * a.y; } return Math.abs(s) / 2; }; const bboxOf = (poly) => { let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const p of poly) { if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y; if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y; } return { minX, minY, maxX, maxY }; }; const inside = (poly, pt) => { let c = false; for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { const a = poly[i], b = poly[j]; if (((a.y > pt.y) !== (b.y > pt.y)) && (pt.x < (b.x - a.x) * (pt.y - a.y) / (b.y - a.y) + a.x)) c = !c; } return c; }; const ar = polys.map(area); const bbs = polys.map(bboxOf); for (let i = 0; i < polys.length; i++) { let cx = 0, cy = 0; for (const p of polys[i]) { cx += p.x; cy += p.y; } cx /= polys[i].length; cy /= polys[i].length; for (let j = 0; j < polys.length; j++) { if (i === j || ar[j] <= ar[i]) continue; const bb = bbs[j], bi = bbs[i]; const bboxHit = bb.minX <= bi.minX && bb.minY <= bi.minY && bb.maxX >= bi.maxX && bb.maxY >= bi.maxY; if (bboxHit || inside(polys[j], { x: cx, y: cy })) return true; } } return false; } // Cheap self-intersection probe (adjacent edges ignored). Used only to choose // ShapeGeometry vs even-odd canvas; false negatives still go through canvas // when there are multiple loops. _polySelfIntersects(poly) { const n = poly.length; if (n < 4) return false; const cross = (u, v, w) => (v.x - u.x) * (w.y - u.y) - (v.y - u.y) * (w.x - u.x); const hits = (a, b, c, d) => { const d1 = cross(a, b, c), d2 = cross(a, b, d), d3 = cross(c, d, a), d4 = cross(c, d, b); return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0)); }; // Cap checks for large loops (logo glyphs are small). const lim = Math.min(n, 80); for (let i = 0; i < lim; i++) { const a = poly[i], b = poly[(i + 1) % n]; for (let j = i + 2; j < lim; j++) { if (i === 0 && j === n - 1) continue; if (j === (i + n - 1) % n) continue; if (hits(a, b, poly[j], poly[(j + 1) % n])) return true; } } return false; } // Diameter-like edge through bbox center: corrupt hatch chords (CXGLOGO ㅎ) // that turn even-odd into a Mercedes/pie pattern. True when shoelace area is // also inflated above the convex hull (self-intersecting junk). _isDiameterInflatedPath(pts) { if (!pts || pts.length < 8) return false; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const p of pts) { if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y; if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y; } const w = maxX - minX, h = maxY - minY; const minDim = Math.min(w, h); if (!(minDim > 1e-9)) return false; const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2; const thr = 0.55 * minDim; const centerR = 0.22 * minDim; let diamN = 0; for (let i = 0; i < pts.length; i++) { const a = pts[i], b = pts[(i + 1) % pts.length]; const len = Math.hypot(b.x - a.x, b.y - a.y); if (len < thr) continue; const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2; if (Math.hypot(mx - cx, my - cy) <= centerR) diamN++; } if (!diamN) return false; // shoelace abs area vs convex hull — inflated ⇒ self-intersecting let sa = 0; for (let i = 0; i < pts.length; i++) { const a = pts[i], b = pts[(i + 1) % pts.length]; sa += a.x * b.y - b.x * a.y; } const absA = Math.abs(sa) / 2; const hull = this._convexHull(pts); if (hull.length < 3) return false; let ha = 0; for (let i = 0; i < hull.length; i++) { const a = hull[i], b = hull[(i + 1) % hull.length]; ha += a.x * b.y - b.x * a.y; } ha = Math.abs(ha) / 2; return ha > 1e-9 && absA > ha * 1.15; } _convexHull(pts) { const p = pts.map((q) => ({ x: q.x, y: q.y })).sort((a, b) => a.x - b.x || a.y - b.y); if (p.length <= 2) return p; const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x); const lower = []; for (const pt of p) { while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], pt) <= 0) lower.pop(); lower.push(pt); } const upper = []; for (let i = p.length - 1; i >= 0; i--) { const pt = p[i]; while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], pt) <= 0) upper.pop(); upper.push(pt); } lower.pop(); upper.pop(); return lower.concat(upper); } _polyCentroid(poly) { let x = 0, y = 0; for (const p of poly) { x += p.x; y += p.y; } return { x: x / poly.length, y: y / poly.length }; } _pointInPoly(poly, pt) { let c = false; for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { const a = poly[i], b = poly[j]; if (((a.y > pt.y) !== (b.y > pt.y)) && (pt.x < (b.x - a.x) * (pt.y - a.y) / (b.y - a.y) + a.x)) c = !c; } return c; } // Raster even-odd fill into a CanvasTexture plane. Sharp enough for logo-scale // glyphs (tens of units); large site hatches still use ShapeGeometry when they // are a single simple loop. Texture is disposed with the mesh on reload. // // Diameter-inflated loops (ㅎ of 한 in CXGLOGO): pure even-odd yields a pie/ // Mercedes pattern from spurious center chords. Those loops are filled with // nonzero (solid glyph + cross), then nested sibling loops punch the counters // (ㅇ hole). Remaining loops use normal even-odd. _hatchFillEvenOddCanvas(polys, color) { let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const poly of polys) { for (const p of poly) { if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y; if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y; } } const worldW = maxX - minX; const worldH = maxY - minY; if (!(worldW > 1e-12) || !(worldH > 1e-12)) return; // Pad 1px equivalent so AA edges aren't clipped. const MAX = 2048; const pxPerUnit = Math.min(MAX / worldW, MAX / worldH, 64); const tw = Math.max(2, Math.ceil(worldW * pxPerUnit) + 2); const th = Math.max(2, Math.ceil(worldH * pxPerUnit) + 2); const sx = (tw - 2) / worldW; const sy = (th - 2) / worldH; const canvas = document.createElement('canvas'); canvas.width = tw; canvas.height = th; const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.clearRect(0, 0, tw, th); const toCanvas = (p) => ({ x: 1 + (p.x - minX) * sx, y: 1 + (maxY - p.y) * sy, }); const addPoly = (poly) => { if (poly.length < 3) return; const p0 = toCanvas(poly[0]); ctx.moveTo(p0.x, p0.y); for (let i = 1; i < poly.length; i++) { const p = toCanvas(poly[i]); ctx.lineTo(p.x, p.y); } ctx.closePath(); }; const solidPolys = []; const evenPolys = []; for (const poly of polys) { if (this._isDiameterInflatedPath(poly)) solidPolys.push(poly); else evenPolys.push(poly); } // Self-intersecting solid outlines fail ordinary PIP for hole nesting — use // their convex hull as the containment oracle (ㅎ outer vs ㅇ counter). const solidHulls = solidPolys.map((p) => this._convexHull(p)); const isHoleOfSolid = (poly) => { if (poly.length < 3 || !solidHulls.length) return false; const c = this._polyCentroid(poly); for (const hull of solidHulls) { if (hull.length >= 3 && this._pointInPoly(hull, c)) return true; } return false; }; // 1) Nonzero fill for diameter-inflated glyphs (ㅎ solid + cross, no pie). if (solidPolys.length) { ctx.fillStyle = '#ffffff'; ctx.beginPath(); for (const poly of solidPolys) addPoly(poly); ctx.fill('nonzero'); // 2) Punch counters nested inside a solid glyph. const holes = evenPolys.filter(isHoleOfSolid); if (holes.length) { ctx.globalCompositeOperation = 'destination-out'; ctx.beginPath(); for (const poly of holes) addPoly(poly); ctx.fill('nonzero'); ctx.globalCompositeOperation = 'source-over'; } } // 3) Remaining loops (not used as hole punches) → even-odd. const rest = evenPolys.filter((p) => !isHoleOfSolid(p)); if (rest.length) { ctx.fillStyle = '#ffffff'; ctx.beginPath(); for (const poly of rest) addPoly(poly); ctx.fill('evenodd'); } const tex = new THREE.CanvasTexture(canvas); tex.colorSpace = THREE.NoColorSpace; tex.magFilter = THREE.LinearFilter; tex.minFilter = THREE.LinearMipmapLinearFilter; tex.generateMipmaps = true; tex.needsUpdate = true; const mat = new THREE.MeshBasicMaterial({ map: tex, color: new THREE.Color(color), transparent: true, alphaTest: 0.4, side: THREE.DoubleSide, depthWrite: false, }); const geo = new THREE.PlaneGeometry(worldW, worldH); const mesh = new THREE.Mesh(geo, mat); mesh.position.set((minX + maxX) / 2, (minY + maxY) / 2, 0); // PlaneGeometry is XY; our 2D world is already XY. No rotation needed. this._group.add(mesh); } // Multi-line MTEXT: one canvas with stacked lines, aligned as a block. // alignH 0=left 1/4=center 2=right (per line); alignV 3=top 2=middle 0/1=bottom anchor. _multilineSprite(lines, pos, height, rotation, color, alignH, alignV, renderOrder = 0) { const fontSize = 96; const font = `${fontSize}px 'Malgun Gothic', 'Apple SD Gothic Neo', monospace`; const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); ctx.font = font; let tw = 1, asc = fontSize * 0.70, desc = fontSize * 0.20; for (const ln of lines) { const m = ctx.measureText(ln || ' '); tw = Math.max(tw, Math.ceil(m.width)); asc = Math.max(asc, m.actualBoundingBoxAscent || fontSize * 0.70); desc = Math.max(desc, m.actualBoundingBoxDescent || fontSize * 0.20); } const pad = Math.ceil(fontSize * 0.15); const step = Math.ceil(asc + desc + fontSize * 0.35); // baseline-to-baseline canvas.width = tw + pad * 2; canvas.height = step * lines.length + pad * 2; ctx.font = font; // resizing the canvas clears state const ri = (color >> 16) & 0xFF, gi = (color >> 8) & 0xFF, bi = color & 0xFF; ctx.fillStyle = `rgb(${ri},${gi},${bi})`; ctx.textBaseline = 'alphabetic'; let ax = pad; if (alignH === 1 || alignH === 4) { ctx.textAlign = 'center'; ax = canvas.width / 2; } else if (alignH === 2) { ctx.textAlign = 'right'; ax = canvas.width - pad; } else { ctx.textAlign = 'left'; ax = pad; } for (let i = 0; i < lines.length; i++) ctx.fillText(lines[i], ax, pad + asc + i * step); const texture = new THREE.CanvasTexture(canvas); const pxToWorld = height / asc; // one line's cap height == DWG text height const vw = canvas.width * pxToWorld; const vh = canvas.height * pxToWorld; // Anchor the text block so the requested datum lands on pos (block edges). let ox = 0, oy = 0; if (alignH === 0 || alignH === 3 || alignH === 5) ox = vw / 2; // left edge at pos.x else if (alignH === 2) ox = -vw / 2; // right edge at pos.x if (alignV === 3) oy = -vh / 2; // top edge at pos.y else if (alignV === 0 || alignV === 1) oy = vh / 2; // bottom edge at pos.y const mat = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false }); if (rotation) mat.rotation = rotation; const sprite = new THREE.Sprite(mat); sprite.scale.set(vw, vh, 1); sprite.position.set(pos.x + ox, pos.y + oy, 1); sprite.renderOrder = renderOrder; this._group.add(sprite); } // MTEXT background mask (opaque fill behind text). Flags (DXF 90): // 0x01 = use background fill color (63) // 0x02 = use drawing window color // 0x10 = text frame only (R2018+) // bgScale (DXF 45, default 1.5) = border offset factor × text height. _drawMTextBackground(t, engine, minTextH) { const flags = t.bgFillFlags | 0; if (!flags) return; const useFill = !!(flags & 0x01) || !!(flags & 0x02); const useFrame = !!(flags & 0x10); if (!useFill && !useFrame) return; const height = Math.max(t.height || 2.5, minTextH || 0); const lines = String(t.text || '').split('\n'); const ls = t.linespacingFactor > 0 ? t.linespacingFactor : 1; // Match slugText line step (CAD MTEXT default ~ 5/3 of height × factor) const lineStep = height * (5 / 3) * ls; let maxEm = 0; if (engine?.measureEm) { for (const line of lines) maxEm = Math.max(maxEm, engine.measureEm(line || ' ')); } else { // ~0.9 em per Hangul/Latin fallback when engine unavailable for (const line of lines) maxEm = Math.max(maxEm, (line || ' ').length * 0.9); } let textW = Math.max(maxEm * height, height * 0.5); let textH = lines.length <= 1 ? height : height + (lines.length - 1) * lineStep; if (t.rectWidth > 0) textW = Math.max(textW, t.rectWidth); if (t.rectHeight > 0) textH = Math.max(textH, t.rectHeight); const scale = t.bgScale > 0 ? t.bgScale : 1.5; // Scale factor expands the text bbox (1.5 → 50% larger total). const boxW = textW * scale; const boxH = textH * scale; // Text-center offset from insertion/attachment point (same rules as slug/sprite). const alignH = t.alignH ?? 1; const alignV = t.alignV ?? 2; let lx = 0, ly = 0; if (alignH === 0 || alignH === 3 || alignH === 5) lx = textW / 2; else if (alignH === 2) lx = -textW / 2; if (alignV === 3) ly = -textH / 2; else if (alignV === 0 || alignV === 1) ly = textH / 2; const rot = t.rotation || 0; const cos = Math.cos(rot), sin = Math.sin(rot); const cx = t.pos.x + lx * cos - ly * sin; const cy = t.pos.y + lx * sin + ly * cos; let bgColor = 0xFFFF00; // default yellow if color missing if (flags & 0x02) { // Drawing window color — follow viewer theme const dark = this._scene?.background?.r < 0.5; bgColor = dark ? 0x0a0b0d : 0xf7f6f3; } else if (t.bgColorRgb && (t.bgColorRgb.r != null)) { bgColor = ((t.bgColorRgb.r & 255) << 16) | ((t.bgColorRgb.g & 255) << 8) | (t.bgColorRgb.b & 255); } else if (t.bgColorIndex != null) { const hex = aciToHex(t.bgColorIndex); if (hex != null) bgColor = hex; } if (useFill) { const geo = new THREE.PlaneGeometry(boxW, boxH); const mat = new THREE.MeshBasicMaterial({ color: new THREE.Color(bgColor), side: THREE.DoubleSide, depthWrite: false, transparent: false, }); const mesh = new THREE.Mesh(geo, mat); mesh.position.set(cx, cy, 0.4); // behind text (z≈1) mesh.rotation.z = rot; this._group.add(mesh); } if (useFrame && !useFill) { // Outline only — four edges as hairline segs (rare path) const hw = boxW / 2, hh = boxH / 2; const corners = [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh], [-hw, -hh]]; const world = corners.map(([x, y]) => ({ x: cx + x * cos - y * sin, y: cy + x * sin + y * cos, })); // Use a thin mesh strip ring via Line if we had a helper; simple solid edges: for (let i = 0; i < 4; i++) { const a = world[i], b = world[i + 1]; this._widePolyMesh( [a, b], null, false, { constantWidth: Math.max(height * 0.05, 0.01) }, bgColor, 1, null, ); } } } // Slug vector text (GPU winding-number glyphs, see slugText.ts): one merged // mesh per load for ALL texts. Async because the TTF loads once on demand; // falls back to the legacy canvas sprites if the font can't be fetched. // Underlay (viewport model labels) and overlay (paper labels) are two meshes // so paper text always composites above viewport content (Layout draw order). async _drawTexts(texts, minTextH) { const gen = this._textGen; const under = texts.filter((t) => t.underlay); const over = texts.filter((t) => !t.underlay); const layers = [ { list: under, order: 0 }, { list: over, order: 1 }, ]; try { const engine = await SlugTextEngine.shared(); if (gen !== this._textGen) return; for (const { list, order } of layers) { if (!list.length) continue; for (const t of list) { if ((t.bgFillFlags | 0) !== 0) this._drawMTextBackground(t, engine, minTextH); } const batch = new SlugTextBatch(engine); for (const t of list) { batch.add(t.text, t.pos, Math.max(t.height, minTextH), t.rotation || 0, t.color, t.alignH ?? 0, t.alignV ?? 0); } const mesh = batch.build(); if (mesh) { mesh.renderOrder = order; // Transparent text: draw later order on top regardless of z if (mesh.material) { mesh.material.depthWrite = false; mesh.material.transparent = true; } this._group.add(mesh); } } } catch (e) { console.warn('[Viewer2D] Slug text unavailable, falling back to canvas sprites:', e?.message ?? e); if (gen !== this._textGen) return; for (const { list, order } of layers) { for (const t of list) { if ((t.bgFillFlags | 0) !== 0) this._drawMTextBackground(t, null, minTextH); this._textSprite(t.text, t.pos, Math.max(t.height, minTextH), t.rotation, t.color, t.alignH ?? 0, t.alignV ?? 0, order); } } } } // alignH: 0=left, 1=center, 2=right, 4=middle-center. alignV: 0=baseline,1=bottom,2=middle,3=top. _textSprite(text, pos, height, rotation, color, alignH = 0, alignV = 0, renderOrder = 0) { // MTEXT paragraph breaks (\P → \n) produce multi-line text: render stacked. const lines = String(text).split('\n'); if (lines.length > 1) { this._multilineSprite(lines, pos, height, rotation, color, alignH, alignV, renderOrder); return; } // Texture cache: table drawings repeat the same strings thousands of times // ("D25", sizes, counts). Rasterizing each occurrence separately OOMs on // dense sheets (e.g. 6k+ texts) — share one CanvasTexture per (text, color). // Alignment/rotation are per-sprite (scale/offset/material), not per-raster. const cacheKey = text + '' + color; let tex = (this._texCache ??= new Map()).get(cacheKey); if (!tex) { const fontSize = 96; const font = `${fontSize}px 'Malgun Gothic', 'Apple SD Gothic Neo', monospace`; const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); ctx.font = font; const m = ctx.measureText(text); const tw = Math.max(1, Math.ceil(m.width)); // Actual ink extents above/below the baseline (fallback to typical font ratios). const asc = m.actualBoundingBoxAscent || fontSize * 0.70; // ≈ cap/glyph height const desc = m.actualBoundingBoxDescent || fontSize * 0.20; const pad = Math.ceil(fontSize * 0.15); // Symmetric padding → the ink bounding box stays centered in the canvas. canvas.width = tw + pad * 2; canvas.height = Math.ceil(asc + desc) + pad * 2; ctx.font = font; // reset (resizing the canvas clears state) const ri = (color >> 16) & 0xFF, gi = (color >> 8) & 0xFF, bi = color & 0xFF; ctx.fillStyle = `rgb(${ri},${gi},${bi})`; ctx.textBaseline = 'alphabetic'; ctx.fillText(text, pad, pad + asc); tex = { texture: new THREE.CanvasTexture(canvas), w: canvas.width, h: canvas.height, asc, pad }; this._texCache.set(cacheKey, tex); } const { texture, asc, pad } = tex; // Size so the visible ascent (≈ DWG cap height) equals the DWG text `height`. const vh = height * tex.h / asc; const vw = vh * (tex.w / tex.h); const pxToWorld = vh / tex.h; const centerPx = tex.h / 2; // X: place sprite so the requested horizontal datum lands on pos.x. let ox = 0; if (alignH === 0 || alignH === 3 || alignH === 5) ox = vw / 2; // left edge at pos.x else if (alignH === 1 || alignH === 4) ox = 0; // center at pos.x else if (alignH === 2) ox = -vw / 2; // right edge at pos.x // Y: map the alignment datum (in canvas px) to pos.y. Canvas y grows downward, // world y upward → world offset of a canvas row = (centerPx - rowPx) * pxToWorld. // ah=4 (DWG "middle" justification) forces both-axis centering regardless of av. let oy = 0; if (alignH !== 4) { if (alignV === 0 || alignV === 1) oy = (pad + asc - centerPx) * pxToWorld; // baseline/bottom datum else if (alignV === 3) oy = (pad - centerPx) * pxToWorld; // top datum // alignV === 2 (middle): ink already centered → oy = 0 } const mat = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false }); if (rotation) mat.rotation = rotation; const sprite = new THREE.Sprite(mat); sprite.scale.set(vw, vh, 1); sprite.position.set(pos.x + ox, pos.y + oy, 1); sprite.renderOrder = renderOrder; this._group.add(sprite); } // Render block definition entities transformed by INSERT params _insertEntities(entities, d, color, pushSeg, pendingTexts, ctx) { const entsByOwner = ctx?.entsByOwner; const blockBase = ctx?.blockBase; const parentXf = ctx?.parentXf ?? null; const parentScale = ctx?.scale ?? 1; const parentRot = ctx?.rot ?? 0; const depth = ctx?.depth ?? 0; const ip = d.insertionPoint ?? { x: 0, y: 0 }; const base = d.basePoint; const sx = d.xScale ?? 1, sy = d.yScale ?? 1; const rot = d.rotation ?? 0; const cos = Math.cos(rot), sin = Math.sin(rot); // Local transform: block-local coords → parent-local (or world if top level). const localXf = (px, py) => { const lx = base ? px - base.x : px; const ly = base ? py - base.y : py; return [ ip.x + (lx * sx) * cos - (ly * sy) * sin, ip.y + (lx * sx) * sin + (ly * sy) * cos, ]; }; // Compose with parent transform for nested INSERTs. const xf = parentXf ? (px, py) => parentXf(...localXf(px, py)) : localXf; // Accumulated uniform scale (for radii) and rotation (for arc/ellipse angles). const scaleAcc = parentScale * Math.max(Math.abs(sx), Math.abs(sy)); const rotAcc = parentRot + rot; for (const be of entities) { const bd = be.data || be; const bt = (be.type || be.typeName || '').toUpperCase(); if (bt === 'ATTDEF') continue; // skip attribute definitions const ecol = this._resolveBlockColor(be, color); try { if (bt === 'LINE' && bd.start && bd.end) { const [ax, ay] = xf(bd.start.x, bd.start.y); const [bx, by] = xf(bd.end.x, bd.end.y); pushSeg(ax, ay, bx, by, 0, ecol); } else if ((bt === 'CIRCLE' || bt === 'ARC') && bd.center && bd.radius != null) { const [cx, cy] = xf(bd.center.x, bd.center.y); const r = bd.radius * scaleAcc; const a0 = bt === 'ARC' ? (bd.startAngle ?? 0) + rotAcc : 0; const a1 = bt === 'ARC' ? (bd.endAngle ?? Math.PI*2) + rotAcc : Math.PI*2; this._arcSegs({ x:cx, y:cy, z:0 }, r, a0, a1, 0, ecol, pushSeg); } else if (bt === 'ELLIPSE' && bd.center) { const [cx, cy] = xf(bd.center.x, bd.center.y); const ma = bd.majorAxis ?? bd.smAxis ?? { x:1, y:0 }; const mlen = Math.hypot(ma.x ?? 0, ma.y ?? 0) * scaleAcc; const mang = Math.atan2(ma.y ?? 0, ma.x ?? 1) + rotAcc; this._ellipseSegs({ center: { x:cx, y:cy, z:0 }, majorAxis: { x: mlen * Math.cos(mang), y: mlen * Math.sin(mang) }, ratio: bd.ratio ?? bd.axisRatio ?? 1, startParam: bd.startParam ?? 0, endParam: bd.endParam ?? Math.PI*2, }, ecol, pushSeg); } else if ((bt === 'LWPOLYLINE' || bt === 'POLYLINE') && (bd.points || bd.vertices)) { const raw = bd.points ?? bd.vertices; const pts = raw.map(p => { const [nx, ny] = xf(p.x, p.y); return { x:nx, y:ny }; }); // Preserve per-vertex bulges through the transform (scalar, not position). const bulges = bd.bulges ?? null; const closed = !!(bd.closed || (bd.flags & 1)); // Logo blocks list solid HATCH first, then the same glyph outlines as // LWPOLYLINE. Re-stroking those glyph outlines draws chords across ㅇ // holes — skip ONLY true re-outlines: same scale as the covering loop. // (Not "poly smaller than loop" — that also killed 도곽 안쪽 가는 선 // whose centroid sat inside the outer frame / a large hatch.) let skipGlyphReoutline = false; if (pts.length >= 2 && this._pickFills.length) { let cx = 0, cy = 0, pMinX = Infinity, pMinY = Infinity, pMaxX = -Infinity, pMaxY = -Infinity; for (const p of pts) { cx += p.x; cy += p.y; if (p.x < pMinX) pMinX = p.x; if (p.y < pMinY) pMinY = p.y; if (p.x > pMaxX) pMaxX = p.x; if (p.y > pMaxY) pMaxY = p.y; } cx /= pts.length; cy /= pts.length; const pw = Math.max(pMaxX - pMinX, 1e-9); const ph = Math.max(pMaxY - pMinY, 1e-9); for (const f of this._pickFills) { if (!this._pointInAnyLoop(f.loops, cx, cy)) continue; for (const loop of f.loops) { if (!loop?.length) continue; let lMinX = Infinity, lMinY = Infinity, lMaxX = -Infinity, lMaxY = -Infinity; for (const q of loop) { if (q.x < lMinX) lMinX = q.x; if (q.y < lMinY) lMinY = q.y; if (q.x > lMaxX) lMaxX = q.x; if (q.y > lMaxY) lMaxY = q.y; } const lw = Math.max(lMaxX - lMinX, 1e-9); const lh = Math.max(lMaxY - lMinY, 1e-9); // Similar size both ways (re-outline of same glyph/hatch). const similar = pw <= lw * 1.5 && ph <= lh * 1.5 && lw <= pw * 1.5 && lh <= ph * 1.5; if (similar) { skipGlyphReoutline = true; break; } } if (skipGlyphReoutline) break; } } if (!skipGlyphReoutline) { const nSeg = closed ? pts.length : pts.length - 1; // start/end/constant width (도곽 frame etc.) — mesh strip, scaled by INSERT if (this._polyHasWidth(be, nSeg, scaleAcc)) { this._widePolyMesh(pts, bulges, closed, be, ecol, scaleAcc, null); } else { if (bulges?.some(b => Math.abs(b) >= 1e-6)) this._bulgePolySegs(pts, bulges, closed, ecol, pushSeg); else this._polylineSegs(pts, closed, 0, ecol, pushSeg); this._fillClosedPolyIfUncovered(pts, bulges, ecol); } } } else if (bt === 'POLYLINE_2D' && entsByOwner) { // Old-style 2D polyline inside a block: vertices are separate VERTEX_2D // entities owned by the polyline (point/bulge at top level). const kids = (entsByOwner.get(be.handle?.value?.toString(16)) ?? []) .filter(v => (v.type||'').toUpperCase().startsWith('VERTEX') && v.point); if (kids.length >= 2) { const pts = kids.map(v => { const [nx, ny] = xf(v.point.x, v.point.y); return { x:nx, y:ny }; }); const bulges = kids.map(v => v.bulge || 0); const closed = (bd.flags & 1) === 1; if (bulges.some(b => Math.abs(b) >= 1e-6)) this._bulgePolySegs(pts, bulges, closed, ecol, pushSeg); else this._polylineSegs(pts, closed, 0, ecol, pushSeg); } } else if (bt === 'HATCH') { const paths = bd.paths ?? be.paths; if (paths?.length) { const solidFill = bd.solidFill ?? be.solidFill ?? false; const tpaths = paths.map(p => ({ ...p, points: (p.points ?? []).map(pt => { const [nx, ny] = xf(pt.x, pt.y); return { x:nx, y:ny }; }), })); // Solid fill: draw fill only (CAD does not render hatch boundaries). // Pattern hatch: draw boundary outline as a proxy (no pattern support). if (solidFill) { this._hatchFill(tpaths, ecol); } else { for (const tp of tpaths) { if ((tp.points?.length ?? 0) < 2) continue; if (tp.bulges?.some(b => Math.abs(b) >= 1e-6)) this._bulgePolySegs(tp.points, tp.bulges, true, ecol, pushSeg); else this._polylineSegs(tp.points, true, 0, ecol, pushSeg); } } } } else if (bt === 'SOLID') { const crs = [bd.corner1||bd.pt1, bd.corner2||bd.pt2, bd.corner3||bd.pt3, bd.corner4||bd.pt4].filter(Boolean); if (crs.length >= 3) { const mapped = crs.map(p => { const [nx,ny] = xf(p.x, p.y); return {x:nx, y:ny, z:p.z||0}; }); this._solidMesh(mapped, ecol); } } else if (bt === 'INSERT' && entsByOwner && depth < 8) { // Nested block reference: recurse with composed transform. const bhVal = be.blockHeaderHandle?.value ?? bd.blockHeaderHandle?.value; if (bhVal != null) { const hex = bhVal.toString(16); const childEnts = entsByOwner.get(hex) ?? []; if (childEnts.length) { const nip = be.insertionPt ?? bd.insertionPt ?? bd.insertionPoint ?? { x:0, y:0 }; const nsx = bd.scaleX ?? bd.scale?.x ?? be.scale?.x ?? 1; const nsy = bd.scaleY ?? bd.scale?.y ?? be.scale?.y ?? 1; const nrot = bd.rotation ?? be.rotation ?? 0; this._insertEntities( childEnts, { insertionPoint: nip, xScale: nsx, yScale: nsy, rotation: nrot, basePoint: blockBase?.get(hex) }, ecol, pushSeg, pendingTexts, { entsByOwner, blockBase, parentXf: xf, scale: scaleAcc, rot: rotAcc, depth: depth + 1 } ); } } } else if ((bt === 'TEXT' || bt === 'MTEXT' || bt === 'ATTRIB' || bt === 'ATTDEF') && pendingTexts) { if ((bt === 'ATTRIB' || bt === 'ATTDEF') && ((bd.flags ?? 0) & 1)) continue; const raw = be.text ?? bd.text ?? bd.textValue ?? bd.defaultValue ?? ''; const text = stripMText(raw); let ah, av, rot = (bd.rotationAngle ?? 0) + rotAcc; if (bt === 'MTEXT') { // MTEXT anchors via attachment point 1-9 (1-3 top / 4-6 mid / 7-9 // bottom, columns L/C/R), not horiz/vertAlignment — the fallback // 0/0 (left-baseline) pushed table cells to the top-right. const ap = be.attachment ?? bd.attachment ?? be.attachmentPoint ?? bd.attachmentPoint ?? 5; ah = ([1,4,7].includes(ap) ? 0 : [3,6,9].includes(ap) ? 2 : 1); av = ([1,2,3].includes(ap) ? 3 : [7,8,9].includes(ap) ? 1 : 2); const xd = be.xAxisDir ?? bd.xAxisDir; if (xd) rot = Math.atan2(xd.y, xd.x) + rotAcc; } else { ah = bd.horizAlignment ?? be.horizAlignment ?? 0; av = bd.vertAlignment ?? be.vertAlignment ?? 0; } const useAp = bt !== 'MTEXT' && (ah !== 0 || av !== 0); const alignPt = be.alignmentPt ?? bd.alignmentPt; const insertPt = be.insertionPt ?? bd.insertionPt ?? bd.insertionPoint; const tpos = useAp ? (alignPt ?? insertPt ?? {x:0,y:0}) : (insertPt ?? {x:0,y:0}); const [tx, ty] = xf(tpos.x, tpos.y); const ht = (bd.textHeight ?? bd.height ?? be.textHeight ?? 2.5) * scaleAcc; if (text) { const entry = { text, pos:{x:tx, y:ty}, height:ht, rotation:rot, color: ecol, alignH:ah, alignV:av }; if (bt === 'MTEXT') { entry.linespacingFactor = be.linespacingFactor ?? bd.linespacingFactor ?? 1; entry.rectWidth = (be.rectWidth ?? bd.rectWidth ?? 0) * scaleAcc; entry.rectHeight = (be.rectHeight ?? bd.rectHeight ?? 0) * scaleAcc; entry.bgFillFlags = be.bgFillFlags ?? bd.bgFillFlags ?? 0; entry.bgScale = be.bgScale ?? bd.bgScale ?? 1.5; entry.bgColorIndex = be.bgColorIndex ?? bd.bgColorIndex; entry.bgColorRgb = be.bgColorRgb ?? bd.bgColorRgb; } pendingTexts.push(entry); } } } catch { /* skip */ } } } // Fallback dimension rendering from entity properties when no block geometry available _renderDimFallback(e, d, type, color, pushSeg, pendingTexts, expand) { const pt10 = e.pt10 ?? d.pt10; const pt13 = e.pt13 ?? d.pt13; const pt14 = e.pt14 ?? d.pt14; const pt15 = e.pt15 ?? d.pt15; const textMidPt = e.textMidPt ?? d.textMidPt; const actualMeasurement = e.actualMeasurement ?? d.actualMeasurement ?? 0; const textRot = e.textRot ?? d.textRot ?? 0; const dimRot = e.dimRot ?? d.dimRot ?? 0; const userText = e.userText ?? d.userText ?? ''; const fmtLen = (v) => Math.abs(v) < 1 ? v.toFixed(3) : v.toFixed(2); const drawLine = (a, b) => { if (!a || !b) return; pushSeg(a.x, a.y, b.x, b.y, 0, color); expand(a.x, a.y); expand(b.x, b.y); }; const drawArc = (cx, cy, r, sa, ea) => { this._arcSegs({ x:cx, y:cy }, r, sa, ea, 0, color, pushSeg); }; const arrow = (pt, rot, sz) => this._arrowMesh(pt, rot, sz, color); const drawText = (pos, text, height, rot) => { if (text && pos) pendingTexts.push({ text, pos, height, rotation: rot ?? 0, color, alignH: 1, alignV: 2 }); }; switch (type) { case 'DIMENSION': case 'DIMENSION_LINEAR': case 'DIMENSION_ALIGNED': { if (!pt13 || !pt14 || !pt10) return; let d1x, d1y, d2x, d2y; if (type === 'DIMENSION_ALIGNED') { const ddx = pt14.x-pt13.x, ddy = pt14.y-pt13.y; const len = Math.hypot(ddx, ddy); if (len < 1e-9) return; const ux = ddx/len, uy = ddy/len; const half = actualMeasurement / 2; d1x = pt10.x - ux*half; d1y = pt10.y - uy*half; d2x = pt10.x + ux*half; d2y = pt10.y + uy*half; } else { const isV = Math.abs(Math.sin(dimRot)) > 0.5; const half = actualMeasurement / 2; d1x = pt10.x - (isV ? 0 : half); d1y = pt10.y - (isV ? half : 0); d2x = pt10.x + (isV ? 0 : half); d2y = pt10.y + (isV ? half : 0); } const arrowSz = Math.max(actualMeasurement * 0.04, 1); drawLine({x:d1x,y:d1y}, {x:d2x,y:d2y}); drawLine(pt13, {x:d1x,y:d1y}); drawLine(pt14, {x:d2x,y:d2y}); arrow({x:d1x,y:d1y,z:0}, Math.atan2(d1y-d2y, d1x-d2x), arrowSz); arrow({x:d2x,y:d2y,z:0}, Math.atan2(d2y-d1y, d2x-d1x), arrowSz); drawText(textMidPt ?? {x:(d1x+d2x)/2, y:(d1y+d2y)/2}, userText || fmtLen(actualMeasurement), Math.max(arrowSz*1.5,1), textRot); break; } case 'DIMENSION_RADIUS': { if (!pt10 || !pt15) return; const arrowSzR = Math.max(actualMeasurement * 0.05, 0.5); drawLine(pt10, pt15); arrow({x:pt15.x,y:pt15.y,z:0}, Math.atan2(pt15.y-pt10.y, pt15.x-pt10.x), arrowSzR); drawText(textMidPt ?? {x:(pt10.x+pt15.x)/2,y:(pt10.y+pt15.y)/2}, userText || `R${fmtLen(actualMeasurement)}`, Math.max(arrowSzR*2,1)); break; } case 'DIMENSION_DIAMETER': { if (!pt10 || !pt15) return; const opp = {x:2*pt10.x-pt15.x, y:2*pt10.y-pt15.y}; const arrowSzD = Math.max((actualMeasurement/2)*0.05, 0.5); drawLine(pt15, opp); arrow({x:pt15.x,y:pt15.y,z:0}, Math.atan2(pt15.y-pt10.y, pt15.x-pt10.x), arrowSzD); arrow({x:opp.x,y:opp.y,z:0}, Math.atan2(opp.y-pt10.y, opp.x-pt10.x), arrowSzD); drawText(textMidPt ?? pt10, userText || `Ø${fmtLen(actualMeasurement)}`, Math.max(arrowSzD*2,1)); break; } case 'DIMENSION_ANG_3PT': case 'DIMENSION_ANG_2LN': { if (!pt10 || !pt13 || !pt14) return; const vx = pt10.x, vy = pt10.y; const rr = Math.hypot(pt13.x-vx, pt13.y-vy); if (rr < 1e-9) return; let sa = Math.atan2(pt13.y-vy, pt13.x-vx); let ea = Math.atan2(pt14.y-vy, pt14.x-vx); let sweep = ea - sa; while (sweep < 0) sweep += Math.PI*2; if (sweep > Math.PI) { [sa, ea] = [ea, sa]; sweep = Math.PI*2 - sweep; } drawArc(vx, vy, rr, sa, sa+sweep); const arrowSzA = Math.max(rr * 0.05, 0.5); arrow({x:vx+Math.cos(sa)*rr, y:vy+Math.sin(sa)*rr, z:0}, sa-Math.PI/2, arrowSzA); arrow({x:vx+Math.cos(sa+sweep)*rr, y:vy+Math.sin(sa+sweep)*rr, z:0}, (sa+sweep)+Math.PI/2, arrowSzA); const midA = sa + sweep/2; drawText(textMidPt ?? {x:vx+Math.cos(midA)*rr*1.2, y:vy+Math.sin(midA)*rr*1.2}, userText || `${(actualMeasurement*180/Math.PI).toFixed(1)}°`, Math.max(rr*0.1,1)); break; } case 'DIMENSION_ORDINATE': { if (!pt10 || !pt13) return; drawLine(pt10, pt13); drawText(textMidPt ?? pt13, userText || (actualMeasurement?.toFixed(2) ?? ''), 2.5); break; } } } _onClick(e) { // ignore clicks that were really a pan/zoom drag (>5px movement) if (this._downXY) { const dx = e.clientX - this._downXY[0], dy = e.clientY - this._downXY[1]; if (Math.hypot(dx, dy) > 5) return; } const cam = this._camera; const rect = this._renderer.domElement.getBoundingClientRect(); // WCS for pick/measure (not UCS display frame) const wcs = this._screenToWcs(e.clientX, e.clientY); if (!wcs) return; const wx = wcs.x, wy = wcs.y; if (this._measureActive) { this._doMeasure2D(wx, wy); return; } const thresh = CLICK_THRESHOLD_PX * (cam.right - cam.left) / (rect.width * (cam.zoom || 1)); // Geometric pick — raycast the actual rendered geometry, not a bounds proxy. // 1) Nearest real line segment within the pixel threshold (lines, arcs, // circles, polylines, block wires, dimension leaders/arrows). let bestIdx = -1, bestDist = thresh; const S = this._pickSegs, SM = this._pickSegMeta; for (let i = 0, j = 0; i < S.length; i += 4, j++) { const d = this._segDist(wx, wy, S[i], S[i + 1], S[i + 2], S[i + 3]); if (d < bestDist) { bestDist = d; bestIdx = SM[j]; } } // 2) No line under the cursor → a filled region (hatch / SOLID / text box). // Pick the smallest-area fill so text on top of a hatch wins. if (bestIdx < 0) { let bestArea = Infinity; for (const f of this._pickFills) { if (this._fillHit(f.loops, wx, wy)) { const a = this._loopArea(f.loops[0] || []); if (a < bestArea) { bestArea = a; bestIdx = f.metaIdx; } } } } const bestMeta = bestIdx >= 0 ? this._entityMeta[bestIdx] : null; this._highlight(bestMeta); this._onSelectCb?.(bestMeta ? bestMeta.entity : null); } // Distance from point (px,py) to segment (ax,ay)-(bx,by). _segDist(px, py, ax, ay, bx, by) { const dx = bx - ax, dy = by - ay; const len2 = dx * dx + dy * dy; if (!len2) return Math.hypot(px - ax, py - ay); const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2)); return Math.hypot(px - (ax + t * dx), py - (ay + t * dy)); } startMeasure(cb) { this.stopMeasure(); this._measureCb = cb; this._measureActive = true; } stopMeasure() { this._measureActive = false; this._measureCb = null; this._measurePtA = null; for (const m of this._measureMarkers) { this._group.remove(m); m.geometry?.dispose(); m.material?.dispose(); } this._measureMarkers = []; } _doMeasure2D(wx, wy) { if (!this._measurePtA) { this._measurePtA = { x: wx, y: wy }; const mk = this._mkMeasureMarker(wx, wy); this._measureMarkers.push(mk); this._group.add(mk); this._measureCb?.({ phase: 'a', ax: wx, ay: wy, az: 0, bx: wx, by: wy, bz: 0, d: 0 }); } else { const a = this._measurePtA; const d = Math.hypot(wx - a.x, wy - a.y); const mkB = this._mkMeasureMarker(wx, wy); const ln = this._mkMeasureLine(a.x, a.y, wx, wy); this._measureMarkers.push(mkB, ln); this._group.add(mkB, ln); this._measureCb?.({ phase: 'done', ax: a.x, ay: a.y, az: 0, bx: wx, by: wy, bz: 0, d }); this._measurePtA = null; } } _mkMeasureMarker(x, y) { const cam = this._camera; const rect = this._renderer.domElement.getBoundingClientRect(); const s = ((cam.right - cam.left) / (rect.width * (cam.zoom || 1))) * 8; const pts = [ new THREE.Vector3(x - s, y, 0), new THREE.Vector3(x + s, y, 0), new THREE.Vector3(x, y - s, 0), new THREE.Vector3(x, y + s, 0), ]; const geo = new THREE.BufferGeometry().setFromPoints(pts); geo.setIndex([0, 1, 2, 3]); const mat = new THREE.LineBasicMaterial({ color: 0xe63030, depthTest: false }); const m = new THREE.LineSegments(geo, mat); m.renderOrder = 100; return m; } _mkMeasureLine(x1, y1, x2, y2) { const geo = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(x1, y1, 0), new THREE.Vector3(x2, y2, 0), ]); const mat = new THREE.LineBasicMaterial({ color: 0xe63030, depthTest: false }); const m = new THREE.Line(geo, mat); m.renderOrder = 100; return m; } /** Recolor the selected entity's line geometry to the accent color (deselect previous). */ _highlight(meta) { const attr = this._lineColorAttr, orig = this._origColors; if (this._selMeta && attr && orig) { const a = this._selMeta; for (let i = a.colStart; i < a.colEnd && i < orig.length; i++) attr.array[i] = orig[i]; } this._selMeta = meta || null; if (meta && attr && meta.colEnd > meta.colStart) { const c = this._selColor; for (let i = meta.colStart; i < meta.colEnd; i += 3) { attr.array[i] = c.r; attr.array[i + 1] = c.g; attr.array[i + 2] = c.b; } } if (attr) attr.needsUpdate = true; } /** Selection highlight color (keeps in sync with the UI accent). */ setAccent(hex) { this._selColor = { r: parseInt(hex.slice(1, 3), 16) / 255, g: parseInt(hex.slice(3, 5), 16) / 255, b: parseInt(hex.slice(5, 7), 16) / 255, }; if (this._selMeta) { const m = this._selMeta; this._selMeta = null; this._highlight(m); } } _distToBounds(wx, wy, b) { if (b.type === 'point') return Math.hypot(wx - b.cx, wy - b.cy); if (b.type === 'circle') return Math.abs(Math.hypot(wx - b.cx, wy - b.cy) - b.r); if (b.type === 'line') { const dx = b.x2-b.x1, dy = b.y2-b.y1; const len2 = dx*dx + dy*dy; if (!len2) return Math.hypot(wx - b.x1, wy - b.y1); const t = Math.max(0, Math.min(1, ((wx-b.x1)*dx + (wy-b.y1)*dy) / len2)); return Math.hypot(wx - (b.x1+t*dx), wy - (b.y1+t*dy)); } return Infinity; } /** * Percentile-trim an AABB from samples so 1–2 far junk vertices (or a * degenerate polyline) do not dominate Zoom Fit. Falls back to fullBox. * @param {number[]} samples flat [x,y,x,y,…] * @param {THREE.Box3} fullBox */ _trimmedContentBox(samples, fullBox) { if (!fullBox || fullBox.isEmpty()) return null; const n = samples ? (samples.length >> 1) : 0; if (n < 32) return fullBox.clone(); const xs = new Float64Array(n); const ys = new Float64Array(n); let k = 0; for (let i = 0; i + 1 < samples.length; i += 2) { const x = samples[i], y = samples[i + 1]; if (!Number.isFinite(x) || !Number.isFinite(y)) continue; xs[k] = x; ys[k] = y; k++; } if (k < 32) return fullBox.clone(); const xsl = xs.subarray(0, k); const ysl = ys.subarray(0, k); xsl.sort(); ysl.sort(); // 0.5% … 99.5% — drops a handful of extreme verts on ~100k+ samples const lo = Math.max(0, Math.floor(k * 0.005)); const hi = Math.min(k - 1, Math.floor(k * 0.995)); let minX = xsl[lo], maxX = xsl[hi], minY = ysl[lo], maxY = ysl[hi]; if (!(maxX > minX) || !(maxY > minY)) return fullBox.clone(); // Pad slightly so content is not flush to the view edge const padX = Math.max((maxX - minX) * 0.02, 1); const padY = Math.max((maxY - minY) * 0.02, 1); const box = new THREE.Box3( new THREE.Vector3(minX - padX, minY - padY, 0), new THREE.Vector3(maxX + padX, maxY + padY, 0), ); // Never expand beyond the true full box box.min.max(fullBox.min); box.max.min(fullBox.max); return box; } /** * Zoom camera to a world AABB of visible content (camera-local frustum + pan). * `box` must already reflect only currently drawn entities (space/layer filter). * * Civil model drawings are often extreme horizontal strips (aspect 50–200:1). * Classic "contain" fit then letterboxes so the strip is only a few pixels tall * (looks like a single line at the top/center). For such strips, fit so the * **short axis fills the screen** (Y fills for wide strips); pan to see the rest. */ _fit(box) { if (!box || box.isEmpty()) return; const c = box.getCenter(new THREE.Vector3()); const size = box.getSize(new THREE.Vector3()); const w = this._container.clientWidth || 800; const h = this._container.clientHeight || 600; const aspect = w / Math.max(h, 1); const pad = 1.12; const bw = Math.max(size.x, 1e-9); const bh = Math.max(size.y, 1e-9); const contentAspect = bw / bh; let vw, vh; // Extreme strip: prefer filling the short side so geometry is readable. // Threshold ~8× screen aspect (e.g. content 16:1 on a 2:1 window). if (contentAspect > aspect * 8) { // Very wide (plan corridor / bb-style double box): fill view height vh = bh * pad; vw = vh * aspect; } else if (contentAspect < aspect / 8) { // Very tall: fill view width vw = bw * pad; vh = vw / aspect; } else { // Normal sheet / roughly viewport-shaped content: classic contain vw = bw * pad; vh = bh * pad; if (vw / vh > aspect) vh = vw / aspect; else vw = vh * aspect; } this._camera.zoom = 1; this._camera.left = -vw / 2; this._camera.right = vw / 2; this._camera.top = vh / 2; this._camera.bottom = -vh / 2; const span = Math.max(vw, vh, 1000); this._camera.near = -span; this._camera.far = span; this._camera.position.set(c.x, c.y, 100); const a = -(this._viewTwist || 0); this._camera.up.set(-Math.sin(a), Math.cos(a), 0); this._controls.target.set(c.x, c.y, 0); this._camera.lookAt(this._controls.target); this._camera.updateProjectionMatrix(); this._camera.updateMatrixWorld(true); this._controls.update(); } _clear() { for (let i = this._group.children.length - 1; i >= 0; i--) { const o = this._group.children[i]; o.geometry?.dispose(); if (o.material) { o.material.map?.dispose(); o.material.dispose(); } this._group.remove(o); } // Shared text textures were disposed via the sprites above (re-dispose is a // no-op); drop the cache so the next load rasterizes fresh. this._texCache?.clear(); this._textGen++; // invalidate any in-flight async text flush this._contentBox = null; } _onResize() { const w = this._container.clientWidth, h = this._container.clientHeight; if (!w || !h) return; this._renderer.setSize(w, h); // Frustum planes are CAMERA-LOCAL (relative to camera.position), not world coords. // Just preserve half-widths and re-adjust aspect — camera position tracks panning via OrbitControls. const halfW = (this._camera.right - this._camera.left) / 2; const halfH = halfW / (w / h); this._camera.left = -halfW; this._camera.right = halfW; this._camera.top = halfH; this._camera.bottom = -halfH; this._camera.updateProjectionMatrix(); } _animate() { requestAnimationFrame(() => this._animate()); this._controls.update(); this._renderer.render(this._scene, this._camera); } }