/** * 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 { SlugTextEngine, SlugTextBatch } from './slugText'; const DEFAULT_COLOR = 0xc9d1d9; const ARC_SEGS = 64; const ELLIPSE_SEGS = 72; const CLICK_THRESHOLD_PX = 14; function stripMText(s) { if (!s) return ''; return s .replace(/\\A\d+;/g, '') // \A1; vertical alignment .replace(/\\p[^;]*;/g, '') // \p...; paragraph properties (lowercase, has ;) .replace(/\\P/g, '\n') // \P paragraph break → newline (capital, no ;) .replace(/\\[a-zA-Z][^;]*;/g, '') // remaining inline codes \f \H \C \W \Q \T... .replace(/\{[^{}]*\}/g, m => stripMText(m.slice(1, -1))) .replace(/%%d/gi, '°').replace(/%%p/gi, '±').replace(/%%c/gi, 'Ø') .replace(/[{}]/g, '') .replace(/[ \t]{2,}/g, ' ') // collapse runs of spaces .replace(/^[ \t]+|[ \t]+$/gm, '') // trim each line's edges (keeps interior \n) .replace(/\n{3,}/g, '\n\n') .replace(/^\n+|\n+$/g, ''); // drop leading/trailing blank lines } export class Viewer2D { constructor(container) { this._container = container; this._entityMeta = []; this._layerByHandle = new Map(); this._layerByName = new Map(); this._onSelectCb = null; this._measureActive = false; this._measureCb = null; this._measurePtA = null; this._measureMarkers = []; this._textGen = 0; // invalidation token for the async Slug text flush SlugTextEngine.shared().catch(() => {}); // warm the font load; sprite fallback covers failure this._scene = new THREE.Scene(); this._scene.background = new THREE.Color(0x0d1117); const w = container.clientWidth || 800; const h = container.clientHeight || 600; this._camera = new THREE.OrthographicCamera(-w/2, w/2, h/2, -h/2, -10000, 10000); this._camera.position.set(0, 0, 100); this._renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true }); this._renderer.setPixelRatio(window.devicePixelRatio); this._renderer.setSize(w, h); container.appendChild(this._renderer.domElement); this._controls = new OrbitControls(this._camera, this._renderer.domElement); this._controls.enableRotate = false; this._controls.screenSpacePanning = true; this._controls.zoomToCursor = true; this._controls.mouseButtons = { LEFT: THREE.MOUSE.PAN, MIDDLE: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.PAN }; this._controls.touches = { ONE: THREE.TOUCH.PAN, TWO: THREE.TOUCH.DOLLY_PAN }; const savedSpeed = localStorage.getItem('hmw:zoom-speed'); if (savedSpeed) { const speed = parseFloat(savedSpeed); if (!isNaN(speed)) { this._controls.zoomSpeed = speed; } } window.addEventListener('keydown', (e) => { if (e.key === 'f' || e.key === 'F') this.fit(); }); this._group = new THREE.Group(); this._scene.add(this._group); window.addEventListener('resize', () => this._onResize()); // Prevent browser auto-scroll popup when middle-button is pressed on the WebGL canvas this._renderer.domElement.addEventListener('pointerdown', (e) => { if (e.button === 1) e.preventDefault(); }); this._renderer.domElement.addEventListener('mousedown', (e) => { this._downXY = [e.clientX, e.clientY]; if (e.button === 1) e.preventDefault(); }); this._renderer.domElement.addEventListener('click', (e) => this._onClick(e)); this._selColor = { r: 0xd8 / 255, g: 0x3a / 255, b: 0x2f / 255 }; this._gridVisible = false; this._gridMesh = null; 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; } fit() { const box = new THREE.Box3(); this._group.traverse(obj => { if (obj.geometry) box.expandByObject(obj); }); if (!box.isEmpty()) this._fit(box); } load(result, opts = {}) { this._lastResult = result; this._selMeta = null; this._clear(); this._entityMeta = []; this._buildLayerMap(result?.tables?.layers); this._buildLinetypeMap(result); // Sheet orientation: the active model-space viewport's VIEWTWIST rotates the // view so a drawing stored tilted in WCS (rotated survey sheets — header UCS // stays identity) displays with its title border upright. Applied as a // camera-up rotation in _fit (geometry untouched → picking stays exact). this._viewTwist = this._readViewTwist(result); // Geometric pick index — the ACTUAL rendered geometry, not a bounds proxy. // _pickSegs: flat [ax,ay,bx,by,…] of every line segment (lines, arcs, circles, // polylines, block wires, dimension leaders/arrows) with a parallel owner-meta // index. _pickFills: filled regions (hatch loops, SOLID quads, text quads) // tested by even-odd point-in-polygon. _onClick raycasts these directly. this._pickSegs = []; this._pickSegMeta = []; this._pickFills = []; this._pickCurIdx = -1; 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; // 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); } } const expand = (x, y, z = 0) => box.expandByPoint(_tmp.set(x, y, z)); 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); }; 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; if (this._hiddenLayers?.size) { const lh = e.layerHandle?.value ?? e.layerHandle; const lname = (lh != null && this._layerNameByHandle.get(String(lh))) ?? e.layer ?? e.layerName; if (lname && this._hiddenLayers.has(lname)) continue; } const d = e.data || e; const type = (e.type || e.typeName || '').toUpperCase(); const color = this._entityColor(e); curDash = this._resolveDash(e); const meta = { entity: e, type, bounds: null, colStart: lineColors.length }; this._entityMeta.push(meta); this._pickCurIdx = this._entityMeta.length - 1; // owner for pick geometry emitted below try { switch (type) { // ── Basic geometry ──────────────────────────────────────────────── case 'LINE': if (d.start && d.end) { pushSeg(d.start.x, d.start.y, d.end.x, d.end.y, d.start.z || 0, color); meta.bounds = { type:'line', x1:d.start.x, y1:d.start.y, x2:d.end.x, y2:d.end.y }; } break; case 'CIRCLE': if (d.center && d.radius != null) { this._arcSegs(d.center, d.radius, 0, Math.PI*2, d.center.z||0, color, pushSeg); meta.bounds = { type:'circle', cx:d.center.x, cy:d.center.y, r:d.radius }; } break; case 'ARC': if (d.center && d.radius != null) { this._arcSegs(d.center, d.radius, d.startAngle??0, d.endAngle??Math.PI*2, d.center.z||0, color, pushSeg); meta.bounds = { type:'circle', cx:d.center.x, cy:d.center.y, r:d.radius }; } break; case 'LWPOLYLINE': if (d.points?.length) { const hasBulge = d.bulges?.some(b => Math.abs(b) >= 1e-6); const closed = !!(d.closed || (d.flags & 1)); const nSeg = closed ? d.points.length : d.points.length - 1; if (this._polyHasWidth(e, nSeg, 1)) { this._widePolyMesh(d.points, d.bulges, closed, e, color, 1, expand); } else if (hasBulge) { this._bulgePolySegs(d.points, d.bulges, closed, color, pushSeg, expand); } else { this._polylineSegs(d.points, closed, d.elevation||0, color, pushSeg); d.points.forEach(p => expand(p.x, p.y, d.elevation||0)); } // Note: uncovered-poly fill is only applied in _insertEntities (block // glyphs). Model-space polylines can be huge parcels — never fill those. meta.bounds = { type:'point', cx:d.points[0].x, cy:d.points[0].y }; } break; case 'POLYLINE': this._polylineSegs(d.vertices||d.points, d.closed||(d.flags&1), 0, color, pushSeg); break; case 'POLYLINE_2D': { // Old-style 2D polyline: vertices are separate VERTEX_2D entities // owned by this polyline (point/bulge at top level, not under .data). const kids = (entsByOwner.get(e.handle?.value?.toString(16)) ?? []) .filter(v => (v.type||'').toUpperCase().startsWith('VERTEX') && v.point); if (kids.length >= 2) { const pts = kids.map(v => v.point); const bulges = kids.map(v => v.bulge || 0); const closed = (d.flags & 1) === 1; const elev = d.elevation || 0; if (bulges.some(b => Math.abs(b) >= 1e-6)) { this._bulgePolySegs(pts, bulges, closed, color, pushSeg, expand); } else { this._polylineSegs(pts, closed, elev, color, pushSeg); pts.forEach(p => expand(p.x, p.y, elev)); } meta.bounds = { type:'point', cx: pts[0].x, cy: pts[0].y }; } break; } case 'POINT': { const p = d.position || d; if (typeof p.x === 'number') { const s = 1; pushSeg(p.x-s, p.y, p.x+s, p.y, p.z||0, color); pushSeg(p.x, p.y-s, p.x, p.y+s, p.z||0, color); meta.bounds = { type:'point', cx:p.x, cy:p.y }; } break; } case 'ELLIPSE': if (d.center) { this._ellipseSegs(d, color, pushSeg); meta.bounds = { type:'point', cx:d.center.x, cy:d.center.y }; } break; case 'SPLINE': { const cps = d.controlPoints ?? d.fitPoints; if (cps?.length >= 2) { const pts = cps.length >= 3 ? (() => { const segs = cps.length * 10; const result = []; for (let i = 0; i <= segs; i++) { const t = i / segs; result.push(this._catmullRom(cps, t)); } return result; })() : cps; this._polylineSegs(pts, false, 0, color, pushSeg); pts.forEach(p => expand(p.x, p.y, p.z || 0)); meta.bounds = { type:'point', cx:pts[0].x, cy:pts[0].y }; } break; } case 'ATTRIB': case 'ATTDEF': { if ((d.flags ?? 0) & 1) break; // invisible attribute const raw = e.text ?? d.text ?? d.textValue ?? d.defaultValue ?? ''; const text = stripMText(raw); const height = e.textHeight ?? e.height ?? d.textHeight ?? d.height ?? 2.5; const rotation = e.rotationAngle ?? d.rotationAngle ?? 0; const alignH = e.horizAlignment ?? d.horizAlignment ?? 0; const alignV = e.vertAlignment ?? d.vertAlignment ?? 0; // When aligned (non-default H/V), the alignmentPt is the true anchor; // insertionPt is the "first point" and differs for centered/right text. const useAlignPt = alignH !== 0 || alignV !== 0; const pos = useAlignPt ? (e.alignmentPt ?? d.alignmentPt ?? e.insertionPt ?? d.insertionPt ?? d.insertionPoint ?? { x:0, y:0 }) : (e.insertionPt ?? d.insertionPt ?? d.insertionPoint ?? { x:0, y:0 }); if (text) { // Force vertical center for ATTRIB — attribute values (e.g. node tags) appear centered pendingTexts.push({ text, pos, height, rotation, color, alignH, alignV: 2 }); meta.bounds = { type:'point', cx:pos.x, cy:pos.y }; this._addTextPick(text, pos, height, alignH, 2); expand(pos.x, pos.y, 0); } break; } case 'XLINE': case 'RAY': { // Infinite/semi-infinite lines — render as very long line segment const bp = d.basePoint ?? d.point ?? d.startPoint; const dir = d.direction ?? d.unitDir ?? d.vector; if (bp && dir) { const len = 1e6; const ex = bp.x + dir.x * len, ey = bp.y + dir.y * len; if (type === 'XLINE') { pushSeg(bp.x - dir.x * len, bp.y - dir.y * len, ex, ey, 0, color); } else { pushSeg(bp.x, bp.y, ex, ey, 0, color); } meta.bounds = { type:'point', cx:bp.x, cy:bp.y }; expand(bp.x, bp.y); } break; } case 'SOLID': { const corners = [d.corner1||d.pt1||d.point1, d.corner2||d.pt2||d.point2, d.corner3||d.pt3||d.point3, d.corner4||d.pt4||d.point4].filter(Boolean); if (corners.length >= 3) { this._solidMesh(corners, color); meta.bounds = { type:'point', cx:corners[0].x, cy:corners[0].y }; } break; } // ── Text ────────────────────────────────────────────────────────── case 'TEXT': case 'MTEXT': { const raw = e.text ?? d.text ?? ''; const text = stripMText(raw); const height = e.textHeight ?? e.height ?? d.textHeight ?? d.height ?? 2.5; let rotation = 0, alignH = 0, alignV = 0, pos; if (type === 'TEXT') { rotation = e.rotationAngle ?? d.rotationAngle ?? 0; alignH = e.horizAlignment ?? d.horizAlignment ?? 0; alignV = e.vertAlignment ?? d.vertAlignment ?? 0; // DXF group 72 = 4 ("Middle") centers the text both horizontally AND // vertically on the alignment point — group 73 (vertical) is ignored. // Without this, vA=0 (baseline) placed such text half a line too high // (titleblock labels 노선이정/설계사/… floated up). if (alignH === 4) alignV = 2; // DWG: when alignment is non-default, alignmentPt is the actual anchor // (insertionPt is the "first point" which may differ from anchor) const useAlignPt = alignH !== 0 || alignV !== 0; pos = useAlignPt ? (e.alignmentPt ?? d.alignmentPt ?? e.insertionPt ?? d.insertionPt ?? { x:0, y:0 }) : (e.insertionPt ?? d.insertionPt ?? d.insertionPoint ?? { x:0, y:0 }); } else { const xd = e.xAxisDir ?? d.xAxisDir; if (xd) rotation = Math.atan2(xd.y, xd.x); // MTEXT: attachmentPoint 1-3=top, 4-6=mid, 7-9=bottom // (parsers emit the field as `attachment`; keep both names) const ap = e.attachment ?? d.attachment ?? e.attachmentPoint ?? d.attachmentPoint ?? 5; alignH = ([1,4,7].includes(ap) ? 0 : [3,6,9].includes(ap) ? 2 : 1); alignV = ([1,2,3].includes(ap) ? 3 : [7,8,9].includes(ap) ? 1 : 2); pos = e.insertionPt ?? d.insertionPt ?? d.insertionPoint ?? { x:0, y:0 }; } if (text) { const entry = { text, pos, height, rotation, color, alignH, alignV }; if (type === 'MTEXT') { entry.linespacingFactor = e.linespacingFactor ?? d.linespacingFactor ?? 1; entry.rectWidth = e.rectWidth ?? d.rectWidth ?? 0; entry.rectHeight = e.rectHeight ?? d.rectHeight ?? 0; // Background mask (DXF 90/45/63): bit0=use fill color, bit1=window color, bit4=frame entry.bgFillFlags = e.bgFillFlags ?? d.bgFillFlags ?? 0; entry.bgScale = e.bgScale ?? d.bgScale ?? 1.5; entry.bgColorIndex = e.bgColorIndex ?? d.bgColorIndex; entry.bgColorRgb = e.bgColorRgb ?? d.bgColorRgb; } pendingTexts.push(entry); meta.bounds = { type:'point', cx:pos.x, cy:pos.y }; this._addTextPick(text, pos, height, alignH, alignV); expand(pos.x, pos.y, 0); } break; } // ── INSERT (block reference) ────────────────────────────────────── case 'INSERT': { const ip = e.insertionPt ?? d.insertionPt ?? d.insertionPoint; const sx = d.scaleX ?? d.scale?.x ?? e.scale?.x ?? 1; const sy = d.scaleY ?? d.scale?.y ?? e.scale?.y ?? 1; const rot = d.rotation ?? e.rotation ?? 0; const bhVal = e.blockHeaderHandle?.value ?? d.blockHeaderHandle?.value; if (bhVal != null) { // Block definition entities sit in the flat entities array, // identified by ownerHandle.value === block header handle const bHandleHex = bhVal.toString(16); const bEnts = entsByOwner.get(bHandleHex) ?? []; if (bEnts.length > 0) { const bh = result?.tables?.blocks?.find(b => { const bh2 = typeof b.handle === 'object' ? b.handle?.value : b.handle; return bh2 === bhVal; }); this._insertEntities( bEnts, { insertionPoint: ip ?? {x:0,y:0,z:0}, xScale:sx, yScale:sy, rotation:rot, basePoint: bh?.basePoint }, color, pushSeg, pendingTexts, { entsByOwner, blockBase: blockBaseByHex } ); } } if (ip) { meta.bounds = { type:'point', cx:ip.x, cy:ip.y }; expand(ip.x, ip.y, 0); } break; } // ── HATCH ───────────────────────────────────────────────────────── case 'HATCH': { const paths = d.paths ?? e.paths; const solidFill = d.solidFill ?? e.solidFill ?? false; if (!paths?.length) break; let firstPt = null; for (const path of paths) { if (!path.points?.length || path.points.length < 2) continue; if (!firstPt) firstPt = path.points[0]; const hasBulge = path.bulges?.some(b => Math.abs(b) >= 1e-6); if (hasBulge) { this._bulgePolySegs(path.points, path.bulges, true, color, pushSeg, expand); } else { this._polylineSegs(path.points, true, 0, color, pushSeg); path.points.forEach(p => expand(p.x, p.y, 0)); } } if (solidFill) this._hatchFill(paths, color); if (firstPt) meta.bounds = { type:'point', cx:firstPt.x, cy:firstPt.y }; break; } // ── DIMENSION (all subtypes) ─────────────────────────────────────── case 'DIMENSION': case 'DIMENSION_LINEAR': case 'DIMENSION_ALIGNED': case 'DIMENSION_RADIUS': case 'DIMENSION_DIAMETER': case 'DIMENSION_ANG_3PT': case 'DIMENSION_ANG_2LN': case 'DIMENSION_ORDINATE': { const bhVal = e.blockHeaderHandle?.value ?? d.blockHeaderHandle?.value; let dimEnts = []; if (bhVal != null) { const hex = bhVal.toString(16); dimEnts = entsByOwner.get(hex) ?? []; } if (dimEnts.length > 0) { // Render pre-built dimension block geometry for (const child of dimEnts) { const ct = (child.type || child.typeName || '').toUpperCase(); const cd = child.data || child; const cc = this._entityColor(child) || color; if (ct === 'LINE' && cd.start && cd.end) { pushSeg(cd.start.x, cd.start.y, cd.end.x, cd.end.y, 0, cc); expand(cd.start.x, cd.start.y); expand(cd.end.x, cd.end.y); } else if (ct === 'SOLID') { const crs = [cd.corner1||cd.pt1, cd.corner2||cd.pt2, cd.corner3||cd.pt3, cd.corner4||cd.pt4].filter(Boolean); if (crs.length >= 3) { this._solidMesh(crs, cc); crs.forEach(c => expand(c.x, c.y)); } } else if (ct === 'ARC' && cd.center && cd.radius != null) { this._arcSegs(cd.center, cd.radius, cd.startAngle??0, cd.endAngle??Math.PI*2, 0, cc, pushSeg); } else if ((ct === 'TEXT' || ct === 'MTEXT')) { const raw = child.text ?? cd.text ?? cd.textValue ?? ''; const text = stripMText(raw); const pos = child.insertionPt ?? cd.insertionPt ?? cd.insertionPoint ?? {x:0,y:0}; const ht = cd.textHeight ?? cd.height ?? child.textHeight ?? 2.5; if (text) { pendingTexts.push({ text, pos, height:ht, rotation: cd.rotationAngle??0, color:cc, alignH:1, alignV:2 }); expand(pos.x, pos.y); } } else if (ct === 'INSERT') { // Arrowhead block INSERT within the dimension block: render the real // arrow geometry; fall back to a named default for geometry-less // system blocks (_ClosedFilled / _Dot / _Oblique / _Open / …). const cip = child.insertionPt ?? cd.insertionPt ?? cd.insertionPoint ?? {x:0,y:0}; const crot = cd.rotation ?? child.rotation ?? 0; const csx = cd.scaleX ?? cd.scale?.x ?? child.scale?.x ?? 1; const csy = cd.scaleY ?? cd.scale?.y ?? child.scale?.y ?? csx; const abh = child.blockHeaderHandle?.value ?? cd.blockHeaderHandle?.value; const aHex = abh?.toString(16); const aname = blockNameByHex.get(aHex) ?? ''; const arrowEnts = aHex ? (entsByOwner.get(aHex) ?? []) : []; if (aname.includes('dot')) { // Dot arrowhead → filled disc (radius from the block's geometry × scale) this._dotMesh(cip, this._arrowDotRadius(arrowEnts) * Math.abs(csx) || Math.abs(csx) * 0.5, cc); } else if (arrowEnts.length) { this._insertEntities( arrowEnts, { insertionPoint: cip, xScale: csx, yScale: csy, rotation: crot, basePoint: blockBaseByHex.get(aHex) }, cc, pushSeg, pendingTexts, { entsByOwner, blockBase: blockBaseByHex } ); } else { this._defaultArrow(aname, cip, crot, Math.abs(csx) || 1, cc, pushSeg); } expand(cip.x, cip.y); } } const fl = dimEnts.find(c => (c.type||c.typeName||'').toUpperCase() === 'LINE'); const fd = fl?.data || fl; if (fd?.start) meta.bounds = { type:'point', cx:fd.start.x, cy:fd.start.y }; } else { // Fallback: reconstruct from entity properties this._renderDimFallback(e, d, type, color, pushSeg, pendingTexts, expand); const pt10 = e.pt10 ?? d.pt10; if (pt10) { meta.bounds = { type:'point', cx:pt10.x, cy:pt10.y }; expand(pt10.x, pt10.y); } } break; } case 'LEADER': { const pts = d.points ?? e.points; if (pts?.length >= 2) { this._polylineSegs(pts, false, 0, color, pushSeg); pts.forEach(p => expand(p.x, p.y, p.z || 0)); if (d.arrowheadOn ?? e.arrowheadOn ?? true) { const p0 = pts[0], p1 = pts[1]; const rot = Math.atan2(p1.y - p0.y, p1.x - p0.x); const arrowSz = d.arrowSize ?? e.arrowSize ?? 2.5; this._arrowMesh(p0, rot, arrowSz, color); } meta.bounds = { type:'point', cx:pts[0].x, cy:pts[0].y }; } break; } default: break; } } catch { /* skip malformed entity */ } meta.colEnd = lineColors.length; } // ── Render deferred text with bbox-proportional minimum size ────────── if (pendingTexts.length) { 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 minTextH = diagSize * 0.0002; // 0.02% of diagonal → readable at fit-view // 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 renderTexts = pendingTexts.length > MAX_TEXTS ? (console.warn(`텍스트 ${pendingTexts.length}개 → ${MAX_TEXTS}개로 제한`), pendingTexts.sort((a, b) => b.height - a.height).slice(0, MAX_TEXTS)) : pendingTexts; 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); } if (!opts.keepView && !box.isEmpty()) this._fit(box); } // ── UI integration ───────────────────────────────────────────────────────── /** Swap canvas background for theme. dark=true → near-black, false → light. */ setTheme(dark) { this._scene.background = new THREE.Color(dark ? 0x0a0b0d : 0xf7f6f3); if (this._gridVisible) this._rebuildGrid(); } setGrid(visible) { this._gridVisible = visible; if (!visible) { if (this._gridMesh) { this._scene.remove(this._gridMesh); this._gridMesh.geometry.dispose(); this._gridMesh.material.dispose(); this._gridMesh = null; } } else { this._rebuildGrid(); } } _rebuildGrid() { if (this._gridMesh) { this._scene.remove(this._gridMesh); this._gridMesh.geometry.dispose(); this._gridMesh.material.dispose(); this._gridMesh = null; } const zoom = this._camera.zoom || 1; const cx = this._camera.position.x; const cy = this._camera.position.y; const hw = (this._camera.right - this._camera.left) / zoom / 2; const hh = (this._camera.top - this._camera.bottom) / zoom / 2; const ext = Math.max(hw, hh) * 3; const spacing = this._niceSpacing(Math.max(hw, hh) * 2 / 20); const [minX, maxX, minY, maxY] = [cx - ext, cx + ext, cy - ext, cy + ext]; const isDark = this._scene.background.r < 0.5; const color = isDark ? 0x2b2b2b : 0xbbbbbb; const verts = []; for (let x = Math.ceil(minX / spacing) * spacing; x <= maxX; x += spacing) verts.push(x, minY, -0.5, x, maxY, -0.5); for (let y = Math.ceil(minY / spacing) * spacing; y <= maxY; y += spacing) verts.push(minX, y, -0.5, maxX, y, -0.5); const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3)); this._gridMesh = new THREE.LineSegments(geo, new THREE.LineBasicMaterial({ color })); this._scene.add(this._gridMesh); } _niceSpacing(raw) { if (raw <= 0) return 1; const exp = Math.pow(10, Math.floor(Math.log10(raw))); const norm = raw / exp; return (norm < 2 ? 1 : norm < 5 ? 2 : 5) * exp; } /** Zoom % relative to fit (ortho camera zoom). */ getZoomPercent() { return Math.round((this._camera?.zoom ?? 1) * 100); } /** Subscribe to pan/zoom changes (for live zoom readout). */ onViewChange(cb) { this._controls?.addEventListener('change', cb); } /** Hide entities on the given layer names (Set); re-renders keeping the view. */ setHiddenLayers(nameSet) { this._hiddenLayers = nameSet || new Set(); if (this._lastResult) this.load(this._lastResult, { keepView: true }); } /** 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(); for (const e of (result.entities || [])) { const lh = e.layerHandle?.value ?? e.layerHandle; const name = (lh != null && this._layerNameByHandle?.get(String(lh))) ?? e.layer ?? e.layerName ?? '0'; counts.set(name, (counts.get(name) || 0) + 1); } const hidden = this._hiddenLayers || new Set(); return (result.tables?.layers || []).map(l => { const name = l.name ?? l.layerName ?? '0'; const aci = Math.abs(l.colorIndex ?? l.color ?? l.colorNumber ?? 7); return { name, colorHex: '#' + ((aciToHex(aci) ?? DEFAULT_COLOR).toString(16).padStart(6, '0')), count: counts.get(name) || 0, visible: !hidden.has(name), }; }).filter(l => l.count > 0 || !l.name.startsWith('*')); } // ── Private helpers ──────────────────────────────────────────────────────── _buildLayerMap(layers) { this._layerByHandle.clear(); this._layerByName.clear(); this._layerNameByHandle = new Map(); this._layer0Handle = null; if (!layers) return; for (const l of layers) { const handle = l.handle?.value ?? l.handle; const name = l.name ?? l.layerName; const aci = Math.abs(l.colorIndex ?? l.color ?? l.colorNumber ?? 7); const hex = aciToHex(aci) ?? DEFAULT_COLOR; if (handle != null) this._layerByHandle.set(String(handle), hex); if (name) this._layerByName.set(name, hex); if (handle != null && name) this._layerNameByHandle.set(String(handle), name); if (name === '0' && handle != null) this._layer0Handle = String(handle); } } // Active-viewport VIEWTWIST (radians). The sheet is un-twisted by rotating the // camera up by -viewTwist (verified against samples/11.dwg: title border upright). _readViewTwist(result) { const vports = result?.tables?.vports; if (!Array.isArray(vports) || !vports.length) return 0; const vp = vports.find(v => /active/i.test(v.name || '')) || vports[0]; const tw = vp?.viewTwist; return (typeof tw === 'number' && isFinite(tw)) ? tw : 0; } // Build linetype lookup: pattern-by-name (signed element lengths, drawing // units) + layer→linetype-name maps + the global LTSCALE. Consumed by // _resolveDash to turn an entity's linetype into a dash/gap size. _buildLinetypeMap(result) { this._ltPatterns = new Map(); this._layerLtByName = new Map(); this._layerLtByHandle = new Map(); this._globalLtscale = (result?.vars?.ltscale > 0 ? result.vars.ltscale : 1); for (const lt of (result?.tables?.lineTypes ?? [])) { if (lt?.name) this._ltPatterns.set(lt.name, Array.isArray(lt.pattern) ? lt.pattern : []); } for (const l of (result?.tables?.layers ?? [])) { const name = l.name ?? l.layerName; const handle = l.handle?.value ?? l.handle; const ltn = l.lineType ?? l.linetype; if (name && ltn) this._layerLtByName.set(name, ltn); if (handle != null && ltn) this._layerLtByHandle.set(String(handle), ltn); } } // Resolve an entity's dash pattern → { key, dash, gap } world-unit sizes, or // null for a solid line. BYLAYER / BYBLOCK / empty fall back to the layer's // linetype; dash/gap = Σ|element| × LTSCALE × entity linetypeScale (CELTSCALE). _resolveDash(e) { if (!this._ltPatterns || this._ltPatterns.size === 0) return null; let ltName = e.lineType ?? e.linetype; if (!ltName || ltName === 'ByLayer' || ltName === 'BYLAYER' || ltName === 'ByBlock' || ltName === 'BYBLOCK') { const lh = e.layerHandle?.value ?? e.layerHandle; ltName = (lh != null && this._layerLtByHandle.get(String(lh))) || this._layerLtByName.get(e.layer ?? e.layerName) || null; } if (!ltName || ltName === 'Continuous' || ltName === 'ByLayer' || ltName === 'ByBlock') return null; const pattern = this._ltPatterns.get(ltName); if (!pattern || pattern.length === 0) return null; const entScale = (e.entityHeader?.linetypeScale > 0 ? e.entityHeader.linetypeScale : 1); const scale = this._globalLtscale * entScale; let dash = 0, gap = 0; for (const d of pattern) { const len = Math.abs(d) * scale; if (d > 1e-6) dash += len; else if (d < -1e-6) gap += len; else dash += Math.max(len, 0.01 * scale); // dot → tiny dash } if (dash < 1e-4 || gap < 1e-4) return null; return { key: `${dash.toFixed(3)}|${gap.toFixed(3)}`, dash, gap }; } // Resolve an entity's color inside a block, given the color inherited from the // INSERT context. BYBLOCK and layer-0 BYLAYER entities inherit the INSERT color // (AutoCAD's layer-0 rule); everything else uses its own color/layer. _resolveBlockColor(entity, inherited) { const aci = entity.entityHeader?.colorIndex ?? entity.color ?? entity.colorIndex ?? entity.colorNumber ?? 256; if (aci === 0) return inherited; // BYBLOCK const lh = String(entity.layerHandle?.value ?? entity.layerHandle ?? ''); if (aci === 256 && this._layer0Handle && lh === this._layer0Handle) return inherited; return this._entityColor(entity); } _entityColor(entity) { const aci = entity.entityHeader?.colorIndex ?? entity.color ?? entity.colorIndex ?? entity.colorNumber ?? 256; if (aci != null) { const hex = aciToHex(aci); if (hex !== null) return hex; } const lh = entity.layerHandle?.value ?? entity.layerHandle; if (lh != null) { const c = this._layerByHandle.get(String(lh)); if (c !== undefined) return c; } const ln = entity.layer ?? entity.layerName; if (ln) { const c = this._layerByName.get(ln); if (c !== undefined) return c; } return DEFAULT_COLOR; } // Filled triangle arrow mesh (replaces V-line arrowhead) _arrowMesh(pt, rotation, size, color) { if (!pt || size < 1e-4) return; const w = size * 0.166; const r = ((color >> 16) & 0xFF) / 255; const g = ((color >> 8) & 0xFF) / 255; const b = (color & 0xFF) / 255; const geom = new THREE.BufferGeometry(); geom.setAttribute('position', new THREE.Float32BufferAttribute([0,0,0, -size,w,0, -size,-w,0], 3)); geom.setAttribute('color', new THREE.Float32BufferAttribute([r,g,b, r,g,b, r,g,b], 3)); const mesh = new THREE.Mesh(geom, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.DoubleSide })); mesh.position.set(pt.x, pt.y, (pt.z || 0) + 0.5); mesh.rotation.z = rotation; this._group.add(mesh); } // Default geometry for AutoCAD standard arrowhead blocks that ship no geometry // in the file (system blocks). `name` is the lowercased block name. _defaultArrow(name, pt, rotation, size, color, pushSeg) { if (!pt || size < 1e-4) return; const n = (name || '').replace(/^[_*]+/, ''); const cos = Math.cos(rotation), sin = Math.sin(rotation); // local→world: arrow points toward +X at the insertion point const P = (lx, ly) => ({ x: pt.x + lx * cos - ly * sin, y: pt.y + lx * sin + ly * cos }); if (n === 'none' || n === 'small' || n === 'integral') return; if (n === 'dot' || n === 'dotsmall' || n === 'dotblank' || n === 'dotsmallblank' || n === 'origin' || n === 'origin2') { this._dotMesh(pt, size * (n === 'dotsmall' ? 0.12 : 0.5), color); return; } if (n === 'oblique' || n === 'archtick') { const a = P(size * 0.5, size * 0.5), b = P(-size * 0.5, -size * 0.5); pushSeg(a.x, a.y, b.x, b.y, (pt.z || 0) + 0.5, color); return; } if (n === 'open' || n === 'open30' || n === 'open90') { const w = n === 'open90' ? size : size * 0.42; const t = P(0, 0), u = P(-size, w), v = P(-size, -w); pushSeg(u.x, u.y, t.x, t.y, (pt.z || 0) + 0.5, color); pushSeg(v.x, v.y, t.x, t.y, (pt.z || 0) + 0.5, color); return; } // default (_ClosedFilled, "", closed, boxfilled, datumfilled, …) → filled triangle this._arrowMesh(pt, rotation, size, color); } // Filled disc (dot arrowhead). _dotMesh(center, radius, color) { if (!center || !(radius > 1e-5)) return; const r = ((color >> 16) & 0xFF) / 255, g = ((color >> 8) & 0xFF) / 255, b = (color & 0xFF) / 255; const segs = 20, verts = [], cols = [], z = (center.z || 0) + 0.5; for (let i = 0; i < segs; i++) { const a0 = i / segs * Math.PI * 2, a1 = (i + 1) / segs * Math.PI * 2; verts.push(center.x, center.y, z, center.x + radius * Math.cos(a0), center.y + radius * Math.sin(a0), z, center.x + radius * Math.cos(a1), center.y + radius * Math.sin(a1), z); cols.push(r, g, b, r, g, b, r, g, b); } const geom = new THREE.BufferGeometry(); geom.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3)); geom.setAttribute('color', new THREE.Float32BufferAttribute(cols, 3)); this._group.add(new THREE.Mesh(geom, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.DoubleSide }))); } // Dot radius (block-local) from an arrow block's geometry, measured from its centroid. _arrowDotRadius(ents) { let r = 0; for (const ae of (ents || [])) { const ad = ae.data || ae; if ((ae.type || '').toUpperCase() === 'CIRCLE' && ad.radius) r = Math.max(r, ad.radius); const pts = ad.points ?? ad.vertices; if (pts?.length) { let cx = 0, cy = 0; pts.forEach(p => { cx += p.x; cy += p.y; }); cx /= pts.length; cy /= pts.length; pts.forEach(p => { r = Math.max(r, Math.hypot(p.x - cx, p.y - cy)); }); } } return r; } _arcSegs(center, r, a0, a1, z, color, pushSeg) { let span = a1 - a0; if (span <= 0) span += Math.PI * 2; const steps = Math.max(8, Math.ceil((span / (Math.PI * 2)) * ARC_SEGS)); let prev = null; for (let i = 0; i <= steps; i++) { const a = a0 + (span * i) / steps; const x = center.x + r * Math.cos(a); const y = center.y + r * Math.sin(a); if (prev) pushSeg(prev.x, prev.y, x, y, z, color); prev = { x, y }; } } // Centripetal CatmullRom interpolation for SPLINE _catmullRom(pts, t) { const n = pts.length; const f = t * (n - 1); const i = Math.min(Math.floor(f), n - 2); const tt = f - i; const p0 = pts[Math.max(i - 1, 0)]; const p1 = pts[i]; const p2 = pts[i + 1]; const p3 = pts[Math.min(i + 2, n - 1)]; const t2 = tt * tt, t3 = t2 * tt; return { x: 0.5 * ((2*p1.x) + (-p0.x+p2.x)*tt + (2*p0.x-5*p1.x+4*p2.x-p3.x)*t2 + (-p0.x+3*p1.x-3*p2.x+p3.x)*t3), y: 0.5 * ((2*p1.y) + (-p0.y+p2.y)*tt + (2*p0.y-5*p1.y+4*p2.y-p3.y)*t2 + (-p0.y+3*p1.y-3*p2.y+p3.y)*t3), z: 0.5 * ((2*(p1.z||0)) + (-(p0.z||0)+(p2.z||0))*tt + (2*(p0.z||0)-5*(p1.z||0)+4*(p2.z||0)-(p3.z||0))*t2 + (-(p0.z||0)+3*(p1.z||0)-3*(p2.z||0)+(p3.z||0))*t3), }; } _ellipseSegs(d, color, pushSeg) { // field names: majorAxis or smAxis; ratio or axisRatio const ma = d.majorAxis ?? d.smAxis ?? { x: 1, y: 0, z: 0 }; const center = d.center; const ratio = d.ratio ?? d.axisRatio ?? 1; const startParam = d.startParam ?? 0; const endParam = d.endParam ?? Math.PI * 2; const mx = ma?.x ?? 1, my = ma?.y ?? 0; const a = Math.sqrt(mx*mx + my*my); const b = a * ratio; const rot = Math.atan2(my, mx); let span = endParam - startParam; if (span <= 0) span += Math.PI * 2; let prev = null; for (let i = 0; i <= ELLIPSE_SEGS; i++) { const t = startParam + (span * i) / ELLIPSE_SEGS; const ex = a * Math.cos(t), ey = b * Math.sin(t); const x = center.x + ex * Math.cos(rot) - ey * Math.sin(rot); const y = center.y + ex * Math.sin(rot) + ey * Math.cos(rot); if (prev) pushSeg(prev.x, prev.y, x, y, center.z || 0, color); prev = { x, y }; } } _polylineSegs(points, closed, z, color, pushSeg) { if (!points || points.length < 2) return; for (let i = 0; i < points.length - 1; i++) { pushSeg(points[i].x, points[i].y, points[i+1].x, points[i+1].y, z, color); } if (closed) { const a = points[points.length-1], b = points[0]; pushSeg(a.x, a.y, b.x, b.y, z, color); } } // Resolve start/end width for segment i→i+1 (DXF 40/41, fallback 43 constant). _segWidths(ent, i, scale = 1) { const d = ent?.data || ent || {}; const cw = (d.constantWidth ?? d.constWidth ?? 0) * scale; const dsw = (d.defaultStartWidth ?? 0) * scale; const dew = (d.defaultEndWidth ?? 0) * scale; const sw0 = (d.startWidths?.[i] ?? 0) * scale; const ew0 = (d.endWidths?.[i] ?? 0) * scale; const sw = Math.abs(sw0) > 1e-12 ? sw0 : (Math.abs(cw) > 1e-12 ? cw : dsw); const ew = Math.abs(ew0) > 1e-12 ? ew0 : (Math.abs(cw) > 1e-12 ? cw : dew); return { sw: Math.abs(sw), ew: Math.abs(ew) }; } _polyHasWidth(ent, nSeg, scale = 1) { for (let i = 0; i < nSeg; i++) { const { sw, ew } = this._segWidths(ent, i, scale); if (sw > 1e-9 || ew > 1e-9) return true; } return false; } // Draw one trapezoid segment (variable start/end half-width) as two triangles. _pushWideSeg(verts, p1, p2, w0, w1) { const dx = p2.x - p1.x, dy = p2.y - p1.y; const len = Math.hypot(dx, dy); if (len < 1e-12) return; const nx = -dy / len, ny = dx / len; const h0 = w0 * 0.5, h1 = w1 * 0.5; const a = { x: p1.x + nx * h0, y: p1.y + ny * h0 }; const b = { x: p1.x - nx * h0, y: p1.y - ny * h0 }; const c = { x: p2.x - nx * h1, y: p2.y - ny * h1 }; const d = { x: p2.x + nx * h1, y: p2.y + ny * h1 }; // two triangles a-b-c, a-c-d verts.push(a.x, a.y, 0, b.x, b.y, 0, c.x, c.y, 0); verts.push(a.x, a.y, 0, c.x, c.y, 0, d.x, d.y, 0); } // Variable-width polyline → solid mesh (MeshBasicMaterial). Used when any // segment has start/end/constant width > 0 (도곽 outer frame etc.). _widePolyMesh(points, bulges, closed, ent, color, scale = 1, expand = null) { if (!points || points.length < 2) return; const n = points.length; const segs = closed ? n : n - 1; const verts = []; for (let i = 0; i < segs; i++) { const p1 = points[i]; const p2 = points[(i + 1) % n]; const { sw, ew } = this._segWidths(ent, i, scale); if (sw < 1e-9 && ew < 1e-9) { // Zero-width segment: still emit a tiny hair so it isn't dropped entirely. this._pushWideSeg(verts, p1, p2, 0.01 * scale, 0.01 * scale); if (expand) { expand(p1.x, p1.y); expand(p2.x, p2.y); } continue; } const bulge = bulges?.[i] || 0; if (Math.abs(bulge) < 1e-6) { this._pushWideSeg(verts, p1, p2, sw, ew); if (expand) { expand(p1.x, p1.y); expand(p2.x, p2.y); } } else { // Tessellate bulge arc; interpolate width along the arc. const dx = p2.x - p1.x, dy = p2.y - p1.y; const dist = Math.hypot(dx, dy); if (dist < 1e-9) continue; const inc = 4 * Math.atan(bulge); const half = Math.abs(inc) / 2; const radius = (dist / 2) / Math.sin(half); const apothem = radius * Math.cos(half); const mx2 = (p1.x + p2.x) / 2, my2 = (p1.y + p2.y) / 2; const nx2 = -dy / dist, ny2 = dx / dist; const sign = bulge > 0 ? 1 : -1; const cx = mx2 + sign * nx2 * apothem; const cy = my2 + sign * ny2 * apothem; const sa = Math.atan2(p1.y - cy, p1.x - cx); const steps = Math.max(2, Math.ceil(Math.abs(inc) / (Math.PI / 16))); let prev = { x: p1.x, y: p1.y }; let prevW = sw; for (let k = 1; k <= steps; k++) { const t = k / steps; const a = sa + (inc * k) / steps; const cur = { x: cx + radius * Math.cos(a), y: cy + radius * Math.sin(a) }; const curW = sw + (ew - sw) * t; this._pushWideSeg(verts, prev, cur, prevW, curW); prev = cur; prevW = curW; } if (expand) { expand(p1.x, p1.y); expand(p2.x, p2.y); } } } if (verts.length < 9) return; const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3)); geo.computeVertexNormals(); const mat = new THREE.MeshBasicMaterial({ color: new THREE.Color(color), side: THREE.DoubleSide, depthWrite: false, }); this._group.add(new THREE.Mesh(geo, mat)); // Pick as edge segments only — do NOT register the poly as a fill loop. // A closed outer frame (도곽 cw=2) used as _pickFills would mark the whole // interior as "covered" and (with a weak size check) hide every inner line. if (this._pickCurIdx >= 0 && this._pickSegs) { for (let i = 0; i < segs; i++) { const a = points[i], b = points[(i + 1) % n]; this._pickSegs.push(a.x, a.y, b.x, b.y); this._pickSegMeta.push(this._pickCurIdx); } } } // Polyline with bulge arcs (LWPOLYLINE, HATCH boundaries) _bulgePolySegs(points, bulges, closed, color, pushSeg, expand) { const n = points.length; const segs = closed ? n : n - 1; for (let i = 0; i < segs; i++) { const p1 = points[i]; const p2 = points[(i + 1) % n]; const bulge = bulges?.[i] || 0; if (Math.abs(bulge) < 1e-6) { pushSeg(p1.x, p1.y, p2.x, p2.y, 0, color); if (expand) { expand(p1.x, p1.y); expand(p2.x, p2.y); } } else { const dx = p2.x - p1.x, dy = p2.y - p1.y; const dist = Math.sqrt(dx*dx + dy*dy); if (dist < 1e-9) continue; // Sweep by the signed included angle Δ = 4·atan(bulge). Sweeping the angle // directly (rather than computing both endpoint angles and resolving the // 2π wrap) avoids drawing the major arc when |bulge| is tiny. const inc = 4 * Math.atan(bulge); // signed; >0 = CCW const half = Math.abs(inc) / 2; const radius = (dist / 2) / Math.sin(half); const apothem = radius * Math.cos(half); const mx2 = (p1.x + p2.x) / 2, my2 = (p1.y + p2.y) / 2; const nx2 = -dy / dist, ny2 = dx / dist; // left normal of p1→p2 const sign = bulge > 0 ? 1 : -1; const cx = mx2 + sign * nx2 * apothem; const cy = my2 + sign * ny2 * apothem; const sa = Math.atan2(p1.y - cy, p1.x - cx); const steps = Math.max(2, Math.ceil(Math.abs(inc) / (Math.PI / 16))); let prev = p1; for (let k = 1; k <= steps; k++) { const a = sa + (inc * k) / steps; const x = cx + radius * Math.cos(a), y = cy + radius * Math.sin(a); pushSeg(prev.x, prev.y, x, y, 0, color); prev = { x, y }; } if (expand) { expand(p1.x, p1.y); expand(p2.x, p2.y); } } } } _solidMesh(corners, color) { const r = ((color >> 16) & 0xFF) / 255; const g = ((color >> 8) & 0xFF) / 255; const b = (color & 0xFF) / 255; const verts = [], cols = []; const addTri = (...pts) => { for (const p of pts) { verts.push(p.x, p.y, p.z || 0); cols.push(r, g, b); } }; if (corners.length >= 4) { addTri(corners[0], corners[1], corners[3]); addTri(corners[0], corners[3], corners[2]); } else { addTri(corners[0], corners[1], corners[2]); } const geom = new THREE.BufferGeometry(); geom.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3)); geom.setAttribute('color', new THREE.Float32BufferAttribute(cols, 3)); this._group.add(new THREE.Mesh(geom, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.DoubleSide }))); // Filled quad/tri → pickable region (DWG SOLID vertex order is 0,1,3,2). if (this._pickCurIdx >= 0 && corners.length >= 3) { const loop = corners.length >= 4 ? [corners[0], corners[1], corners[3], corners[2]] : corners.slice(0, 3); this._pickFills.push({ metaIdx: this._pickCurIdx, loops: [loop] }); } } // Text has no line geometry — register its box as a pickable fill so a click // anywhere on the text selects it. Box follows the sprite's anchor rules // (alignH 0/3/5=left,2=right,1/4=center; alignV 3=top,0/1=bottom,2=middle). _addTextPick(text, pos, height, alignH = 0, alignV = 0) { if (this._pickCurIdx < 0 || !pos) return; const rows = String(text).split('\n'); const cols = rows.reduce((m, s) => Math.max(m, s.length), 1); const w = cols * height * 0.62; // ~ per-glyph advance const h = rows.length * height * 1.2; let x0 = pos.x; if (alignH === 2) x0 = pos.x - w; // right anchor else if (alignH === 1 || alignH === 4) x0 = pos.x - w / 2; // center let y0 = pos.y; // bottom anchor if (alignV === 3) y0 = pos.y - h; // top anchor else if (alignV === 2) y0 = pos.y - h / 2; // middle this._pickFills.push({ metaIdx: this._pickCurIdx, loops: [[ { x: x0, y: y0 }, { x: x0 + w, y: y0 }, { x: x0 + w, y: y0 + h }, { x: x0, y: y0 + h }, ]] }); } // Even-odd point-in-region across ALL loops of a fill (holes exclude correctly). _fillHit(loops, x, y) { let inside = false; for (const poly of loops) { for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { const xi = poly[i].x, yi = poly[i].y, xj = poly[j].x, yj = poly[j].y; if (((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) inside = !inside; } } return inside; } // True if (x,y) lies inside ANY single loop (NOT even-odd across loops). // Used to detect "this point belongs to hatch geometry" including hole // interiors — even-odd would report holes as uncovered and let a later // LWPOLYLINE re-solid them (CXGLOGO ㅇ/ㅎ counters). _pointInAnyLoop(loops, x, y) { for (const poly of loops) { if (!poly?.length) continue; let c = false; for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { const a = poly[i], b = poly[j]; if (((a.y > y) !== (b.y > y)) && (x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x)) c = !c; } if (c) return true; } return false; } // Signed area of a loop (for topmost = smallest-area tie-break on overlapping fills). _loopArea(poly) { let s = 0; for (let i = 0; i < poly.length; i++) { const a = poly[i], b = poly[(i + 1) % poly.length]; s += a.x * b.y - b.x * a.y; } return Math.abs(s) / 2; } // Solid-fill a polyline that is geometrically closed but not covered by any // existing hatch fill. Text-as-hatch logos sometimes ship glyph strokes as // LWPOLYLINE outlines only (e.g. ㅅ of 사 in CXGLOGO) while sibling glyphs // have solid HATCH — without this they render as empty outlines. // // Coverage test is ANY-loop containment (not even-odd). Logo blocks list // HATCH entities first, then the same glyph outlines as LWPOLYLINE. Even-odd // treats hole interiors as "uncovered", so the post-hatch polyline pass used // to re-solid ㅇ/ㅎ counters and wipe the punches. Any-loop still lets true // orphan outlines (ㅅ with no hatch) fill, while skipping anything that // already belongs to a hatch loop — fill or hole. _fillClosedPolyIfUncovered(points, bulges, color) { if (!points || points.length < 3) return; const a = points[0], b = points[points.length - 1]; const gap = Math.hypot(a.x - b.x, a.y - b.y); // Allow tiny gaps relative to polyline size (floating-point closed loops). let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const p of points) { if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y; if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y; } const diag = Math.hypot(maxX - minX, maxY - minY) || 1; if (gap > diag * 1e-4 && gap > 1e-6) return; // Area gate: logo glyph strokes are small (area ~1–100 in block units). // Refuse huge loops even inside blocks (defensive). let sa = 0; for (let i = 0; i < points.length; i++) { const p0 = points[i], p1 = points[(i + 1) % points.length]; sa += p0.x * p1.y - p1.x * p0.y; } const absA = Math.abs(sa) / 2; if (absA < 1e-6 || absA > 5000) return; let cx = 0, cy = 0; for (const p of points) { cx += p.x; cy += p.y; } cx /= points.length; cy /= points.length; for (const f of this._pickFills) { if (this._pointInAnyLoop(f.loops, cx, cy)) return; } this._hatchFill([{ points, bulges: bulges || null, closed: true }], color); } // Solid fill HATCH using THREE.ShapeGeometry // Tessellate one (closed) hatch boundary loop → flat {x,y}[] (resolves bulge arcs, // including the closing edge — a 2-point/2-bulge loop is a full circle). _pathPoints(path) { const pts = path.points; if (!pts?.length) return null; const bulges = path.bulges; const n = pts.length; if (!bulges?.some(b => Math.abs(b) >= 1e-6)) return pts.map(p => ({ x: p.x, y: p.y })); const out = []; for (let i = 0; i < n; i++) { const p1 = pts[i], p2 = pts[(i + 1) % n], bulge = bulges[i] || 0; out.push({ x: p1.x, y: p1.y }); if (Math.abs(bulge) < 1e-6) continue; const dx = p2.x - p1.x, dy = p2.y - p1.y, dist = Math.hypot(dx, dy); if (dist < 1e-9) continue; const inc = 4 * Math.atan(bulge), half = Math.abs(inc) / 2; const radius = (dist / 2) / Math.sin(half), apo = radius * Math.cos(half); const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2; const nx = -dy / dist, ny = dx / dist, sign = bulge > 0 ? 1 : -1; const cx = mx + sign * nx * apo, cy = my + sign * ny * apo; const sa = Math.atan2(p1.y - cy, p1.x - cx); const segs = Math.max(6, Math.ceil(Math.abs(inc) / (Math.PI / 16))); for (let j = 1; j < segs; j++) { // intermediate arc points; p2 added next iter const a = sa + inc * j / segs; out.push({ x: cx + radius * Math.cos(a), y: cy + radius * Math.sin(a) }); } } return out; } // Solid fill with true even-odd parity across all boundary loops. // // DWG solid HATCH (style Normal / odd parity) is even-odd, not "outer + // nested holes". Text-as-hatch logos (CXGLOGO 한국도로공사) ship // self-intersecting multi-stroke loops where ShapeGeometry/earcut + geometric // nesting mis-classifies ㅇ/ㅎ counters. Verified against samples/11.dwg: // SVG fill-rule=evenodd over all non-outlier loops matches the CAD logo. _hatchFill(paths, color) { const totalPts = paths.reduce((s, p) => s + (p.points?.length ?? 0), 0); if (totalPts > 8000) return; // safety for pathological boundaries let polys = []; for (const p of paths) { const pp = this._pathPoints(p); if (pp && pp.length >= 3) polys.push(pp); } if (!polys.length) return; const area = (poly) => { let s = 0; for (let i = 0; i < poly.length; i++) { const a = poly[i], b = poly[(i + 1) % poly.length]; s += a.x * b.y - b.x * a.y; } return Math.abs(s) / 2; }; // Drop spurious giant boundaries (parse-corrupt arcs expanded to huge loops). // Many-loop hatches (≥5) or empty-loop hatches get median×10 outlier filter. if ((paths.some(p => !(p.points?.length)) && polys.length >= 3) || polys.length >= 5) { const sorted = polys.map(area).sort((a, b) => a - b); const med = sorted[Math.floor(sorted.length / 2)] || 0; if (med > 0) polys = polys.filter(p => area(p) <= med * 10); if (!polys.length) return; } // Picking uses even-odd across all loops (holes exclude correctly). if (this._pickCurIdx >= 0 && polys.length) this._pickFills.push({ metaIdx: this._pickCurIdx, loops: polys }); // When does this hatch need true even-odd (vs independent union of islands)? // - Self-intersecting loops (text-as-hatch glyph strokes) // - Many loops (≥5): logo words like 한국도로공사 // - Geometric nesting: a smaller loop sits inside a larger one (island/hole) // Separate solid islands that only touch/overlap (EX logo 4 paths) must be // UNION — even-odd would punch diamond holes at the overlaps. const needsEvenOdd = polys.length >= 5 || polys.some((p) => this._polySelfIntersects(p)) || this._hatchHasNestedLoop(polys); if (needsEvenOdd) { this._hatchFillEvenOddCanvas(polys, color); return; } // Union of simple islands: one ShapeGeometry per loop (no hole punching). const mat = new THREE.MeshBasicMaterial({ color: new THREE.Color(color), side: THREE.DoubleSide }); for (const poly of polys) { try { const shape = new THREE.Shape(); shape.moveTo(poly[0].x, poly[0].y); for (let k = 1; k < poly.length; k++) shape.lineTo(poly[k].x, poly[k].y); shape.closePath(); this._group.add(new THREE.Mesh(new THREE.ShapeGeometry(shape), mat)); } catch { /* degenerate */ } } } // True if any loop is (mostly) inside a larger loop — island/hole topology. _hatchHasNestedLoop(polys) { if (polys.length < 2) return false; const area = (poly) => { let s = 0; for (let i = 0; i < poly.length; i++) { const a = poly[i], b = poly[(i + 1) % poly.length]; s += a.x * b.y - b.x * a.y; } return Math.abs(s) / 2; }; const bboxOf = (poly) => { let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const p of poly) { if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y; if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y; } return { minX, minY, maxX, maxY }; }; const inside = (poly, pt) => { let c = false; for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { const a = poly[i], b = poly[j]; if (((a.y > pt.y) !== (b.y > pt.y)) && (pt.x < (b.x - a.x) * (pt.y - a.y) / (b.y - a.y) + a.x)) c = !c; } return c; }; const ar = polys.map(area); const bbs = polys.map(bboxOf); for (let i = 0; i < polys.length; i++) { let cx = 0, cy = 0; for (const p of polys[i]) { cx += p.x; cy += p.y; } cx /= polys[i].length; cy /= polys[i].length; for (let j = 0; j < polys.length; j++) { if (i === j || ar[j] <= ar[i]) continue; const bb = bbs[j], bi = bbs[i]; const bboxHit = bb.minX <= bi.minX && bb.minY <= bi.minY && bb.maxX >= bi.maxX && bb.maxY >= bi.maxY; if (bboxHit || inside(polys[j], { x: cx, y: cy })) return true; } } return false; } // Cheap self-intersection probe (adjacent edges ignored). Used only to choose // ShapeGeometry vs even-odd canvas; false negatives still go through canvas // when there are multiple loops. _polySelfIntersects(poly) { const n = poly.length; if (n < 4) return false; const cross = (u, v, w) => (v.x - u.x) * (w.y - u.y) - (v.y - u.y) * (w.x - u.x); const hits = (a, b, c, d) => { const d1 = cross(a, b, c), d2 = cross(a, b, d), d3 = cross(c, d, a), d4 = cross(c, d, b); return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0)); }; // Cap checks for large loops (logo glyphs are small). const lim = Math.min(n, 80); for (let i = 0; i < lim; i++) { const a = poly[i], b = poly[(i + 1) % n]; for (let j = i + 2; j < lim; j++) { if (i === 0 && j === n - 1) continue; if (j === (i + n - 1) % n) continue; if (hits(a, b, poly[j], poly[(j + 1) % n])) return true; } } return false; } // Diameter-like edge through bbox center: corrupt hatch chords (CXGLOGO ㅎ) // that turn even-odd into a Mercedes/pie pattern. True when shoelace area is // also inflated above the convex hull (self-intersecting junk). _isDiameterInflatedPath(pts) { if (!pts || pts.length < 8) return false; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const p of pts) { if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y; if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y; } const w = maxX - minX, h = maxY - minY; const minDim = Math.min(w, h); if (!(minDim > 1e-9)) return false; const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2; const thr = 0.55 * minDim; const centerR = 0.22 * minDim; let diamN = 0; for (let i = 0; i < pts.length; i++) { const a = pts[i], b = pts[(i + 1) % pts.length]; const len = Math.hypot(b.x - a.x, b.y - a.y); if (len < thr) continue; const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2; if (Math.hypot(mx - cx, my - cy) <= centerR) diamN++; } if (!diamN) return false; // shoelace abs area vs convex hull — inflated ⇒ self-intersecting let sa = 0; for (let i = 0; i < pts.length; i++) { const a = pts[i], b = pts[(i + 1) % pts.length]; sa += a.x * b.y - b.x * a.y; } const absA = Math.abs(sa) / 2; const hull = this._convexHull(pts); if (hull.length < 3) return false; let ha = 0; for (let i = 0; i < hull.length; i++) { const a = hull[i], b = hull[(i + 1) % hull.length]; ha += a.x * b.y - b.x * a.y; } ha = Math.abs(ha) / 2; return ha > 1e-9 && absA > ha * 1.15; } _convexHull(pts) { const p = pts.map((q) => ({ x: q.x, y: q.y })).sort((a, b) => a.x - b.x || a.y - b.y); if (p.length <= 2) return p; const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x); const lower = []; for (const pt of p) { while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], pt) <= 0) lower.pop(); lower.push(pt); } const upper = []; for (let i = p.length - 1; i >= 0; i--) { const pt = p[i]; while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], pt) <= 0) upper.pop(); upper.push(pt); } lower.pop(); upper.pop(); return lower.concat(upper); } _polyCentroid(poly) { let x = 0, y = 0; for (const p of poly) { x += p.x; y += p.y; } return { x: x / poly.length, y: y / poly.length }; } _pointInPoly(poly, pt) { let c = false; for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { const a = poly[i], b = poly[j]; if (((a.y > pt.y) !== (b.y > pt.y)) && (pt.x < (b.x - a.x) * (pt.y - a.y) / (b.y - a.y) + a.x)) c = !c; } return c; } // Raster even-odd fill into a CanvasTexture plane. Sharp enough for logo-scale // glyphs (tens of units); large site hatches still use ShapeGeometry when they // are a single simple loop. Texture is disposed with the mesh on reload. // // Diameter-inflated loops (ㅎ of 한 in CXGLOGO): pure even-odd yields a pie/ // Mercedes pattern from spurious center chords. Those loops are filled with // nonzero (solid glyph + cross), then nested sibling loops punch the counters // (ㅇ hole). Remaining loops use normal even-odd. _hatchFillEvenOddCanvas(polys, color) { let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const poly of polys) { for (const p of poly) { if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y; if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y; } } const worldW = maxX - minX; const worldH = maxY - minY; if (!(worldW > 1e-12) || !(worldH > 1e-12)) return; // Pad 1px equivalent so AA edges aren't clipped. const MAX = 2048; const pxPerUnit = Math.min(MAX / worldW, MAX / worldH, 64); const tw = Math.max(2, Math.ceil(worldW * pxPerUnit) + 2); const th = Math.max(2, Math.ceil(worldH * pxPerUnit) + 2); const sx = (tw - 2) / worldW; const sy = (th - 2) / worldH; const canvas = document.createElement('canvas'); canvas.width = tw; canvas.height = th; const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.clearRect(0, 0, tw, th); const toCanvas = (p) => ({ x: 1 + (p.x - minX) * sx, y: 1 + (maxY - p.y) * sy, }); const addPoly = (poly) => { if (poly.length < 3) return; const p0 = toCanvas(poly[0]); ctx.moveTo(p0.x, p0.y); for (let i = 1; i < poly.length; i++) { const p = toCanvas(poly[i]); ctx.lineTo(p.x, p.y); } ctx.closePath(); }; const solidPolys = []; const evenPolys = []; for (const poly of polys) { if (this._isDiameterInflatedPath(poly)) solidPolys.push(poly); else evenPolys.push(poly); } // Self-intersecting solid outlines fail ordinary PIP for hole nesting — use // their convex hull as the containment oracle (ㅎ outer vs ㅇ counter). const solidHulls = solidPolys.map((p) => this._convexHull(p)); const isHoleOfSolid = (poly) => { if (poly.length < 3 || !solidHulls.length) return false; const c = this._polyCentroid(poly); for (const hull of solidHulls) { if (hull.length >= 3 && this._pointInPoly(hull, c)) return true; } return false; }; // 1) Nonzero fill for diameter-inflated glyphs (ㅎ solid + cross, no pie). if (solidPolys.length) { ctx.fillStyle = '#ffffff'; ctx.beginPath(); for (const poly of solidPolys) addPoly(poly); ctx.fill('nonzero'); // 2) Punch counters nested inside a solid glyph. const holes = evenPolys.filter(isHoleOfSolid); if (holes.length) { ctx.globalCompositeOperation = 'destination-out'; ctx.beginPath(); for (const poly of holes) addPoly(poly); ctx.fill('nonzero'); ctx.globalCompositeOperation = 'source-over'; } } // 3) Remaining loops (not used as hole punches) → even-odd. const rest = evenPolys.filter((p) => !isHoleOfSolid(p)); if (rest.length) { ctx.fillStyle = '#ffffff'; ctx.beginPath(); for (const poly of rest) addPoly(poly); ctx.fill('evenodd'); } const tex = new THREE.CanvasTexture(canvas); tex.colorSpace = THREE.NoColorSpace; tex.magFilter = THREE.LinearFilter; tex.minFilter = THREE.LinearMipmapLinearFilter; tex.generateMipmaps = true; tex.needsUpdate = true; const mat = new THREE.MeshBasicMaterial({ map: tex, color: new THREE.Color(color), transparent: true, alphaTest: 0.4, side: THREE.DoubleSide, depthWrite: false, }); const geo = new THREE.PlaneGeometry(worldW, worldH); const mesh = new THREE.Mesh(geo, mat); mesh.position.set((minX + maxX) / 2, (minY + maxY) / 2, 0); // PlaneGeometry is XY; our 2D world is already XY. No rotation needed. this._group.add(mesh); } // Multi-line MTEXT: one canvas with stacked lines, aligned as a block. // alignH 0=left 1/4=center 2=right (per line); alignV 3=top 2=middle 0/1=bottom anchor. _multilineSprite(lines, pos, height, rotation, color, alignH, alignV) { 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); 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. async _drawTexts(texts, minTextH) { const gen = this._textGen; try { const engine = await SlugTextEngine.shared(); if (gen !== this._textGen) return; // a clear()/load() superseded this flush // Background masks first (under glyphs). for (const t of texts) { if ((t.bgFillFlags | 0) !== 0) this._drawMTextBackground(t, engine, minTextH); } const batch = new SlugTextBatch(engine); for (const t of texts) { 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) 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 t of texts) { 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); } } } // 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) { // 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); 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); 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 rect = this._renderer.domElement.getBoundingClientRect(); const ndcX = ((e.clientX - rect.left) / rect.width) * 2 - 1; const ndcY = -((e.clientY - rect.top) / rect.height) * 2 + 1; const cam = this._camera; // unproject through the camera so pan (position) and zoom are accounted for const v = new THREE.Vector3(ndcX, ndcY, 0).unproject(cam); const wx = v.x, wy = v.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; } _fit(box) { 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 / h; let vw = Math.max(size.x, 1) * 1.1; let vh = Math.max(size.y, 1) * 1.1; 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; this._camera.position.set(c.x, c.y, 100); // Un-twist the sheet: rotate the view (camera up) by -viewTwist. const a = -(this._viewTwist || 0); this._camera.up.set(-Math.sin(a), Math.cos(a), 0); this._camera.updateProjectionMatrix(); this._controls.target.set(c.x, c.y, 0); 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 } _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); } }