perf(viewer2d): toggle layers by visibility instead of reloading the drawing

setHiddenLayers() called load(result, {keepView:true}), so every layer
on/off re-parsed and re-tessellated the whole drawing — arc sampling,
hatch triangulation, complex linetypes and the Slug text batch all rebuilt
from scratch. On an 18 MB DWG a single eye-icon click took 5 s+.

Build every layer once, then switch geometry on and off:

- Merged LineSegments buffers get an index buffer plus a per-segment layer
  id (_registerIndexed). A toggle rewrites the index and setDrawRange;
  positions, colors and lineDistances are untouched, so draw order, dash
  phase and the selection-highlight offsets in meta.colStart stay valid.
- Per-entity meshes (hatch fills, SOLID quads, arrowheads, OLE images,
  sprites) are tagged by layer via _addObj and toggled with .visible.
- Slug text builds one merged mesh per CAD layer instead of one per
  draw order, so text hides with a visible flag. The shader source is
  identical across batches, so three still shares one WebGLProgram.
- Hidden geometry stays in the pick buffers, so _onClick now skips it via
  the _metaHidden mask.
- Fit keeps framing only what is drawn: per-layer Box3 + fit samples are
  recorded at build and unioned over the visible layers.
- getLayerInfo() caches the O(entities) name/color/count pass per load;
  only the visible flag is recomputed.

Layer panel: one delegated click listener instead of re-binding a handler
per row, and a toggle patches the affected row's opacity/icon in place
rather than regenerating the whole list's innerHTML.

Trade-off: hidden layers are now built and kept in memory, so loading a
file with layers already off costs what a full load costs.

Rebuilds dist2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
minsung
2026-08-04 14:08:59 +09:00
co-authored by Claude Opus 5
parent fb8f7ca4eb
commit 3480945b5a
4 changed files with 5900 additions and 88 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -159,7 +159,7 @@
}
.layer-sub-btn:hover { color: var(--ink); border-color: var(--accent); background: rgba(63,185,80,.1); }
</style>
<script type="module" crossorigin src="/assets/index-CmUgC68r.js"></script>
<script type="module" crossorigin src="/assets/index-KG0HO38y.js"></script>
</head>
<body>
<div id="app"></div>
+45 -26
View File
@@ -49,6 +49,15 @@ export class LayerPanelManager {
if (options?.hideAllBtnEl) {
options.hideAllBtnEl.addEventListener('click', () => this.hideAllLayers());
}
// Delegated click handling: bound once, so render() never has to re-attach
// a listener per row (that was O(layers) DOM work on every single toggle).
this.listEl.addEventListener('click', (e) => {
const row = (e.target as HTMLElement)?.closest?.('.layer-row') as HTMLElement | null;
if (!row) return;
const layerName = row.dataset.name;
if (layerName) this.toggleLayer(layerName);
});
}
/** Refresh and re-render layer list from Viewer2D state */
@@ -66,7 +75,7 @@ export class LayerPanelManager {
public showAllLayers(): void {
this.hiddenLayers.clear();
this.viewer.setHiddenLayers(this.hiddenLayers);
this.render();
this.syncRows();
}
public hideAllLayers(): void {
@@ -75,7 +84,7 @@ export class LayerPanelManager {
this.hiddenLayers.add(l.name);
}
this.viewer.setHiddenLayers(this.hiddenLayers);
this.render();
this.syncRows();
}
public toggleLayer(name: string): void {
@@ -85,7 +94,34 @@ export class LayerPanelManager {
this.hiddenLayers.add(name);
}
this.viewer.setHiddenLayers(this.hiddenLayers);
this.render();
this.syncRow(name);
}
/** Patch one row in place — a full render() would rebuild the whole list. */
private syncRow(name: string): void {
const row = this.listEl?.querySelector<HTMLElement>(
`.layer-row[data-name="${cssEscape(name)}"]`
);
if (!row) return;
this.paintRow(row, !this.hiddenLayers.has(name));
}
/** Patch every row (show-all / hide-all) without touching the DOM structure. */
private syncRows(): void {
if (!this.listEl) return;
this.listEl.querySelectorAll<HTMLElement>('.layer-row').forEach((row) => {
const name = row.dataset.name;
if (name != null) this.paintRow(row, !this.hiddenLayers.has(name));
});
}
private paintRow(row: HTMLElement, visible: boolean): void {
row.style.opacity = visible ? '1' : '0.4';
const btn = row.querySelector<HTMLElement>('.eye-btn');
if (btn) {
btn.textContent = visible ? '👁️' : '🕶️';
btn.title = visible ? '숨기기' : '표시하기';
}
}
public render(): void {
@@ -141,32 +177,15 @@ export class LayerPanelManager {
</div>`;
})
.join('');
// Bind eye button click events
this.listEl.querySelectorAll('.eye-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const layerName = (btn as HTMLElement).dataset.toggle;
if (layerName) {
this.toggleLayer(layerName);
}
});
});
// Also toggle when clicking row
this.listEl.querySelectorAll('.layer-row').forEach((row) => {
row.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (target.closest('.eye-btn')) return;
const layerName = (row as HTMLElement).dataset.name;
if (layerName) {
this.toggleLayer(layerName);
}
});
});
// Clicks are handled by the delegated listener bound in the constructor.
}
}
/** Escape a layer name for use inside a double-quoted CSS attribute selector. */
function cssEscape(str: string): string {
return str.replace(/["\\]/g, '\\$&');
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
+271 -61
View File
@@ -18,6 +18,18 @@ const ARC_SEGS = 64;
const ELLIPSE_SEGS = 72;
const CLICK_THRESHOLD_PX = 14;
/** Bucket pending texts by their owning CAD layer (insertion order preserved). */
function groupByLayer(list) {
const m = new Map();
for (const t of list) {
const k = t.layer ?? '0';
let arr = m.get(k);
if (!arr) m.set(k, arr = []);
arr.push(t);
}
return m;
}
function stripMText(s) {
if (!s) return '';
return s
@@ -46,6 +58,23 @@ export class Viewer2D {
this._measurePtA = null;
this._measureMarkers = [];
this._textGen = 0; // invalidation token for the async Slug text flush
// ── Layer visibility index (see _applyLayerVisibility) ───────────────────
// Layer on/off used to re-run load() = full re-tessellation (5 s+ on an
// 18 MB DWG). Instead we build every layer once and toggle visibility:
// · _layerObjs layer name → Object3D[] (hatch/solid/text/sprite meshes)
// · _indexedBufs merged LineSegments whose index buffer is rebuilt from a
// per-segment layer id — keeps the original draw order.
// · _layerBoxes / _layerSamples per-layer AABB + fit samples so Fit still
// frames only what is visible.
this._curLayer = null;
this._curLayerId = -1;
this._layerObjs = new Map();
this._layerBoxes = new Map();
this._layerSamples = new Map();
this._indexedBufs = [];
this._metaHidden = null;
this._fullContentBox = null;
this._hiddenLayers = new Set();
SlugTextEngine.shared().catch(() => {}); // warm the font load; sprite fallback covers failure
this._scene = new THREE.Scene();
@@ -228,7 +257,7 @@ export class Viewer2D {
const box = new THREE.Box3();
const samples = [];
this._group.traverse((obj) => {
if (!obj.geometry) return;
if (!obj.geometry || obj.visible === false) return;
box.expandByObject(obj);
const pos = obj.geometry.attributes?.position;
if (!pos?.array) return;
@@ -240,7 +269,9 @@ export class Viewer2D {
});
if (box.isEmpty()) return;
const use = this._trimmedContentBox(samples, box) || box;
this._contentBox = use.clone();
// Don't cache while layers are off — the merged line buffer still holds the
// hidden segments, so this box is wider than what's on screen.
if (!this._hiddenLayers.size) this._contentBox = use.clone();
this._fit(use);
}
@@ -289,6 +320,16 @@ export class Viewer2D {
const entities = result?.entities || [];
const lineVerts = [];
const lineColors = [];
// Layer id per emitted segment (2 verts). Drives the index rebuild that
// hides/shows layers without touching positions or colors.
const lineSegLayer = [];
const layerIds = new Map();
const layerNames = [];
const layerIdOf = (name) => {
let id = layerIds.get(name);
if (id === undefined) { id = layerNames.length; layerNames.push(name); layerIds.set(name, id); }
return id;
};
// 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.
@@ -354,11 +395,29 @@ export class Viewer2D {
// Samples feed percentile trim so Fit matches CAD extents (ignore 12 junk verts).
const fitSamples = [];
let fitSampleN = 0;
// Per-layer AABB + fit samples: Fit must frame only the VISIBLE layers, and
// toggling no longer re-runs load(), so the split has to happen at build.
let curLayerBox = null;
let curLayerSamples = null;
const setCurLayer = (name) => {
const key = name || '0';
if (key === this._curLayer) return;
this._curLayer = key;
this._curLayerId = layerIdOf(key);
curLayerBox = this._layerBoxes.get(key);
if (!curLayerBox) this._layerBoxes.set(key, curLayerBox = new THREE.Box3());
curLayerSamples = this._layerSamples.get(key);
if (!curLayerSamples) this._layerSamples.set(key, curLayerSamples = []);
};
const expand = (x, y, z = 0) => {
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
box.expandByPoint(_tmp.set(x, y, z));
if (curLayerBox) curLayerBox.expandByPoint(_tmp);
fitSampleN++;
if ((fitSampleN & 7) === 0 && fitSamples.length < 100000) fitSamples.push(x, y);
if ((fitSampleN & 7) === 0 && fitSamples.length < 100000) {
fitSamples.push(x, y);
if (curLayerSamples) curLayerSamples.push(x, y);
}
};
const pushSeg = (ax, ay, bx, by, z, color) => {
const r = ((color >> 16) & 0xFF) / 255;
@@ -366,12 +425,14 @@ export class Viewer2D {
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); }
if (!bk) { bk = { dash: curDash.dash, gap: curDash.gap, verts: [], colors: [], segLayer: [] }; dashBuckets.set(curDash.key, bk); }
bk.verts.push(ax, ay, z, bx, by, z);
bk.colors.push(r, g, b, r, g, b);
bk.segLayer.push(this._curLayerId);
} else {
lineVerts.push(ax, ay, z, bx, by, z);
lineColors.push(r, g, b, r, g, b);
lineSegLayer.push(this._curLayerId);
}
if (this._pickCurIdx >= 0) { this._pickSegs.push(ax, ay, bx, by); this._pickSegMeta.push(this._pickCurIdx); }
expand(ax, ay, z); expand(bx, by, z);
@@ -413,6 +474,7 @@ export class Viewer2D {
pushSeg: vpPush,
expand: vpExpand,
pendingTexts,
setCurLayer,
box,
_tmp,
underlay: true, // tag pending texts drawn under paper labels
@@ -430,20 +492,20 @@ export class Viewer2D {
// Model/Paper filter: only entities belonging to the active space (and
// their INSERT children such as ATTRIB). Other space geometry is hidden.
if (allowedOwners && ownerHex && !allowedOwners.has(ownerHex)) continue;
if (this._hiddenLayers?.size) {
const lh = e.layerHandle?.value ?? e.layerHandle;
const lname = (lh != null && this._layerNameByHandle.get(String(lh))) ?? e.layer ?? e.layerName;
if (lname && this._hiddenLayers.has(lname)) continue;
}
// Hidden layers are NOT skipped here: geometry for every layer is built
// once and switched on/off by _applyLayerVisibility, so a toggle costs an
// index rebuild instead of a full reload.
setCurLayer(this._layerNameOf(e));
const d = e.data || e;
const type = (e.type || e.typeName || '').toUpperCase();
const color = this._entityColor(e);
const complexLt = this._resolveComplexLinetype(e);
if (complexLt) curDash = null;
else curDash = this._resolveDash(e);
const meta = { entity: e, type, bounds: null, colStart: lineColors.length };
const meta = { entity: e, type, bounds: null, layer: this._curLayer, colStart: lineColors.length };
this._entityMeta.push(meta);
this._pickCurIdx = this._entityMeta.length - 1; // owner for pick geometry emitted below
const textStart = pendingTexts.length;
try {
switch (type) {
@@ -943,7 +1005,7 @@ export class Viewer2D {
});
const mesh = new THREE.Mesh(planeGeom, planeMat);
mesh.position.set(minX + width / 2, minY + Math.abs(height) / 2, z + 10);
this._group.add(mesh);
this._addObj(mesh);
this._renderer.render(this._scene, this._camera);
} catch (err) {
console.error('Failed rendering CanvasTexture for OLE image:', err);
@@ -983,6 +1045,11 @@ export class Viewer2D {
}
} catch { /* skip malformed entity */ }
meta.colEnd = lineColors.length;
// Deferred texts are flushed after the loop (async) — stamp the owning
// layer now so _drawTexts can batch per layer.
for (let ti = textStart; ti < pendingTexts.length; ti++) {
if (pendingTexts[ti].layer === undefined) pendingTexts[ti].layer = this._curLayer;
}
}
// ── Render deferred text (underlay / paper stack) ─────────────────────
@@ -1020,13 +1087,16 @@ export class Viewer2D {
void this._drawTexts(renderTexts, minTextH);
}
this._layerNames = layerNames;
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 })));
const lmesh = new THREE.LineSegments(geom, new THREE.LineBasicMaterial({ vertexColors: true }));
this._registerIndexed(lmesh, lineSegLayer);
this._group.add(lmesh);
this._lineColorAttr = colorAttr;
this._origColors = Float32Array.from(colorAttr.array);
}
@@ -1043,7 +1113,8 @@ export class Viewer2D {
const dline = new THREE.LineSegments(dgeom, new THREE.LineDashedMaterial({
vertexColors: true, dashSize: bk.dash, gapSize: bk.gap,
}));
dline.computeLineDistances();
dline.computeLineDistances(); // must run BEFORE setIndex (three skips indexed geometry)
this._registerIndexed(dline, bk.segLayer);
this._group.add(dline);
}
@@ -1052,8 +1123,13 @@ export class Viewer2D {
// Visible-content AABB for Fit — trim extreme outliers (matches CAD zoom extents better).
const fitBox = box.isEmpty() ? null : (this._trimmedContentBox(fitSamples, box) || box);
this._fullContentBox = fitBox ? fitBox.clone() : null;
this._contentBox = fitBox ? fitBox.clone() : null;
if (!opts.keepView && fitBox && !fitBox.isEmpty()) this._fit(fitBox);
// Geometry for every layer is in the scene now — switch off the hidden ones
// (also refreshes _contentBox to the visible subset).
this._applyLayerVisibility();
const useBox = this._contentBox;
if (!opts.keepView && useBox && !useBox.isEmpty()) this._fit(useBox);
} finally {
this._isLoading = false;
}
@@ -1117,12 +1193,118 @@ export class Viewer2D {
/** Subscribe to pan/zoom changes (for live zoom readout). */
onViewChange(cb) { this._controls?.addEventListener('change', cb); }
/** Hide entities on the given layer names (Set<string>); re-renders keeping the view. */
/**
* Hide entities on the given layer names (Set<string>).
* O(scene objects + segments) — no re-parse, no re-tessellation, no reload.
* Falls back to a full load() only if the scene predates the layer index.
*/
setHiddenLayers(nameSet) {
this._hiddenLayers = nameSet || new Set();
// Copy: the Layers panel keeps mutating the Set it handed us.
this._hiddenLayers = new Set(nameSet || []);
if (this._applyLayerVisibility()) return;
if (this._lastResult) this.load(this._lastResult, { keepView: true });
}
/** Layer name of an entity ('0' when the file gives none). */
_layerNameOf(e) {
const lh = e.layerHandle?.value ?? e.layerHandle;
return (lh != null && this._layerNameByHandle?.get(String(lh))) ?? e.layer ?? e.layerName ?? '0';
}
/**
* Give a merged LineSegments an index buffer so layers can be masked by
* rewriting indices (positions/colors/lineDistances stay put, which keeps
* draw order and the selection-highlight offsets in meta.colStart valid).
*/
_registerIndexed(mesh, segLayerArr) {
const vcount = mesh.geometry.attributes.position.count;
if (!vcount) return;
const IdxArr = vcount > 65535 ? Uint32Array : Uint16Array;
const index = new IdxArr(vcount);
for (let i = 0; i < vcount; i++) index[i] = i;
const attr = new THREE.BufferAttribute(index, 1);
attr.setUsage(THREE.DynamicDrawUsage);
mesh.geometry.setIndex(attr);
this._indexedBufs.push({ mesh, segLayer: Int32Array.from(segLayerArr), index, attr });
}
/** Track an entity-owned object so layer toggles can flip its visibility. */
_addObj(obj, layer) {
const key = layer ?? this._curLayer;
if (key != null) {
obj.userData.cadLayer = key;
let arr = this._layerObjs.get(key);
if (!arr) this._layerObjs.set(key, arr = []);
arr.push(obj);
if (this._hiddenLayers.has(key)) obj.visible = false;
}
this._group.add(obj);
}
/**
* Apply _hiddenLayers to the already-built scene.
* @returns {boolean} false when there is nothing built yet (caller reloads).
*/
_applyLayerVisibility() {
if (!this._layerNames && !this._layerObjs.size) return false;
const hidden = this._hiddenLayers;
// 1) Whole objects (hatch fills, SOLID quads, arrows, images, text meshes).
for (const [name, objs] of this._layerObjs) {
const vis = !hidden.has(name);
for (const o of objs) o.visible = vis;
}
// 2) Merged line buffers → rewrite the index to skip hidden segments.
const names = this._layerNames || [];
const hiddenId = new Uint8Array(names.length);
for (let i = 0; i < names.length; i++) if (hidden.has(names[i])) hiddenId[i] = 1;
for (const buf of this._indexedBufs) {
const seg = buf.segLayer, idx = buf.index;
let w = 0;
for (let s = 0; s < seg.length; s++) {
const id = seg[s];
if (id >= 0 && hiddenId[id]) continue;
const v = s * 2;
idx[w++] = v; idx[w++] = v + 1;
}
buf.attr.needsUpdate = true;
buf.mesh.geometry.setDrawRange(0, w);
}
// 3) Picking mask — _onClick skips segments/fills owned by hidden layers.
const metas = this._entityMeta;
const mh = new Uint8Array(metas.length);
if (hidden.size) {
for (let i = 0; i < metas.length; i++) if (hidden.has(metas[i].layer)) mh[i] = 1;
}
this._metaHidden = mh;
if (this._selMeta && hidden.has(this._selMeta.layer)) {
this._highlight(null);
this._onSelectCb?.(null);
}
// 4) Fit box for the visible subset.
this._contentBox = this._visibleContentBox();
return true;
}
/** AABB (outlier-trimmed) of the layers currently switched on. */
_visibleContentBox() {
const hidden = this._hiddenLayers;
if (!hidden.size) return this._fullContentBox ? this._fullContentBox.clone() : null;
const box = new THREE.Box3();
const samples = [];
for (const [name, b] of this._layerBoxes) {
if (hidden.has(name) || b.isEmpty()) continue;
box.union(b);
const s = this._layerSamples.get(name);
if (s) for (let i = 0; i < s.length; i++) samples.push(s[i]);
}
if (box.isEmpty()) return null;
return (this._trimmedContentBox(samples, box) || box).clone();
}
/**
* Model / Paper (Layout) spaces present in the last loaded result.
* @returns {import('./cadSpaces').CadSpace[]}
@@ -1271,7 +1453,7 @@ export class Viewer2D {
_emitModelThroughViewport(vp, ctx) {
const {
entities, modelAllowed, blockDefHandles, entsByOwner, blockBaseByHex,
pushSeg: basePush, expand: baseExpand, pendingTexts,
pushSeg: basePush, expand: baseExpand, pendingTexts, setCurLayer,
underlay = false,
} = ctx;
const xf = this._viewportModelToPaper(vp);
@@ -1300,11 +1482,8 @@ export class Viewer2D {
const ownerHex = e.ownerHandle?.value?.toString(16);
if (!ownerHex || !modelAllowed.has(ownerHex)) continue;
if (blockDefHandles.has(ownerHex)) continue;
if (this._hiddenLayers?.size) {
const lh = e.layerHandle?.value ?? e.layerHandle;
const lname = (lh != null && this._layerNameByHandle.get(String(lh))) ?? e.layer ?? e.layerName;
if (lname && this._hiddenLayers.has(lname)) continue;
}
// Layer visibility is applied post-build (see _applyLayerVisibility).
setCurLayer?.(this._layerNameOf(e));
if (frozen.size) {
const lh = e.layerHandle?.value;
if (lh != null && frozen.has(String(lh))) continue;
@@ -1516,6 +1695,9 @@ export class Viewer2D {
}
}
} catch { /* skip malformed */ }
for (let ti = vpTextStart; ti < pendingTexts.length; ti++) {
if (pendingTexts[ti].layer === undefined) pendingTexts[ti].layer = this._curLayer;
}
}
// Transform any pending texts that are still in model space (from INSERT helpers).
@@ -1540,33 +1722,39 @@ export class Viewer2D {
});
}
/** Layer list for the Layers panel: [{ name, colorHex, count, visible }]. */
/**
* Layer list for the Layers panel: [{ name, colorHex, count, visible }].
* The name/color/count part is O(entities) — cached per load so the panel can
* call this on every toggle without re-walking a 500k-entity drawing.
*/
getLayerInfo() {
const result = this._lastResult;
if (!result) return [];
const counts = new Map();
const allowed = this._activeSpaceHex
? buildAllowedOwnerHexes(result.entities || [], this._activeSpaceHex)
: null;
for (const e of (result.entities || [])) {
const ownerHex = e.ownerHandle?.value?.toString(16);
if (allowed && ownerHex && !allowed.has(ownerHex)) continue;
const lh = e.layerHandle?.value ?? e.layerHandle;
const name = (lh != null && this._layerNameByHandle?.get(String(lh)))
?? e.layer ?? e.layerName ?? '0';
counts.set(name, (counts.get(name) || 0) + 1);
let base = this._layerInfoCache;
if (!base) {
const counts = new Map();
const allowed = this._activeSpaceHex
? buildAllowedOwnerHexes(result.entities || [], this._activeSpaceHex)
: null;
for (const e of (result.entities || [])) {
const ownerHex = e.ownerHandle?.value?.toString(16);
if (allowed && ownerHex && !allowed.has(ownerHex)) continue;
const name = this._layerNameOf(e);
counts.set(name, (counts.get(name) || 0) + 1);
}
base = (result.tables?.layers || []).map(l => {
const name = l.name ?? l.layerName ?? '0';
const aci = Math.abs(l.colorIndex ?? l.color ?? l.colorNumber ?? 7);
return {
name,
colorHex: '#' + ((aciToHex(aci, this._isDark) ?? DEFAULT_COLOR).toString(16).padStart(6, '0')),
count: counts.get(name) || 0,
};
}).filter(l => l.count > 0 || !l.name.startsWith('*'));
this._layerInfoCache = base;
}
const hidden = this._hiddenLayers || new Set();
return (result.tables?.layers || []).map(l => {
const name = l.name ?? l.layerName ?? '0';
const aci = Math.abs(l.colorIndex ?? l.color ?? l.colorNumber ?? 7);
return {
name,
colorHex: '#' + ((aciToHex(aci, this._isDark) ?? DEFAULT_COLOR).toString(16).padStart(6, '0')),
count: counts.get(name) || 0,
visible: !hidden.has(name),
};
}).filter(l => l.count > 0 || !l.name.startsWith('*'));
return base.map(l => ({ ...l, visible: !hidden.has(l.name) }));
}
// ── Private helpers ────────────────────────────────────────────────────────
@@ -2050,7 +2238,7 @@ export class Viewer2D {
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);
this._addObj(mesh);
}
// Default geometry for AutoCAD standard arrowhead blocks that ship no geometry
@@ -2097,7 +2285,7 @@ export class Viewer2D {
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 })));
this._addObj(new THREE.Mesh(geom, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.DoubleSide })));
}
// Dot radius (block-local) from an arrow block's geometry, measured from its centroid.
@@ -2279,7 +2467,7 @@ export class Viewer2D {
side: THREE.DoubleSide,
depthWrite: false,
});
this._group.add(new THREE.Mesh(geo, mat));
this._addObj(new THREE.Mesh(geo, mat));
// Pick as edge segments only — do NOT register the poly as a fill loop.
// A closed outer frame (도곽 cw=2) used as _pickFills would mark the whole
// interior as "covered" and (with a weak size check) hide every inner line.
@@ -2350,7 +2538,7 @@ export class Viewer2D {
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 })));
this._addObj(new THREE.Mesh(geom, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.DoubleSide })));
// Filled quad/tri → pickable region (DWG SOLID vertex order is 0,1,3,2).
if (this._pickCurIdx >= 0 && corners.length >= 3) {
const loop = corners.length >= 4 ? [corners[0], corners[1], corners[3], corners[2]] : corners.slice(0, 3);
@@ -2538,7 +2726,7 @@ export class Viewer2D {
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));
this._addObj(new THREE.Mesh(new THREE.ShapeGeometry(shape), mat));
} catch { /* degenerate */ }
}
}
@@ -2803,7 +2991,7 @@ export class Viewer2D {
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);
this._addObj(mesh);
}
// Multi-line MTEXT: one canvas with stacked lines, aligned as a block.
@@ -2854,7 +3042,7 @@ export class Viewer2D {
sprite.scale.set(vw, vh, 1);
sprite.position.set(pos.x + ox, pos.y + oy, 1);
sprite.renderOrder = renderOrder;
this._group.add(sprite);
this._addObj(sprite);
}
// MTEXT background mask (opaque fill behind text). Flags (DXF 90):
@@ -2930,7 +3118,7 @@ export class Viewer2D {
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);
this._addObj(mesh);
}
if (useFrame && !useFill) {
// Outline only — four edges as hairline segs (rare path)
@@ -2971,21 +3159,27 @@ export class Viewer2D {
for (const { list, order } of layers) {
if (!list.length) continue;
for (const t of list) {
if ((t.bgFillFlags | 0) !== 0) this._drawMTextBackground(t, engine, minTextH);
if ((t.bgFillFlags | 0) !== 0) {
this._curLayer = t.layer ?? '0'; // _drawMTextBackground emits via _addObj
this._drawMTextBackground(t, engine, minTextH);
}
}
const batch = new SlugTextBatch(engine);
for (const t of list) {
batch.add(t.text, t.pos, Math.max(t.height, minTextH), t.rotation || 0, t.color, t.alignH ?? 0, t.alignV ?? 0);
}
const mesh = batch.build();
if (mesh) {
// One merged mesh per CAD layer (not one per order): a layer toggle then
// just flips mesh.visible instead of rebuilding the whole text batch.
for (const [lname, items] of groupByLayer(list)) {
const batch = new SlugTextBatch(engine);
for (const t of items) {
batch.add(t.text, t.pos, Math.max(t.height, minTextH), t.rotation || 0, t.color, t.alignH ?? 0, t.alignV ?? 0);
}
const mesh = batch.build();
if (!mesh) continue;
mesh.renderOrder = order;
// Transparent text: draw later order on top regardless of z
if (mesh.material) {
mesh.material.depthWrite = false;
mesh.material.transparent = true;
}
this._group.add(mesh);
this._addObj(mesh, lname);
}
}
} catch (e) {
@@ -2993,6 +3187,7 @@ export class Viewer2D {
if (gen !== this._textGen) return;
for (const { list, order } of layers) {
for (const t of list) {
this._curLayer = t.layer ?? '0';
if ((t.bgFillFlags | 0) !== 0) this._drawMTextBackground(t, null, minTextH);
this._textSprite(t.text, t.pos, Math.max(t.height, minTextH), t.rotation, t.color, t.alignH ?? 0, t.alignV ?? 0, order);
}
@@ -3067,7 +3262,7 @@ export class Viewer2D {
sprite.scale.set(vw, vh, 1);
sprite.position.set(pos.x + ox, pos.y + oy, 1);
sprite.renderOrder = renderOrder;
this._group.add(sprite);
this._addObj(sprite);
}
// Render block definition entities transformed by INSERT params
@@ -3416,7 +3611,11 @@ export class Viewer2D {
// circles, polylines, block wires, dimension leaders/arrows).
let bestIdx = -1, bestDist = thresh;
const S = this._pickSegs, SM = this._pickSegMeta;
// Hidden layers stay in the buffers (only masked out of the draw index), so
// picking has to skip them explicitly.
const MH = this._hiddenLayers.size ? this._metaHidden : null;
for (let i = 0, j = 0; i < S.length; i += 4, j++) {
if (MH && MH[SM[j]]) continue;
const d = this._segDist(wx, wy, S[i], S[i + 1], S[i + 2], S[i + 3]);
if (d < bestDist) { bestDist = d; bestIdx = SM[j]; }
}
@@ -3425,6 +3624,7 @@ export class Viewer2D {
if (bestIdx < 0) {
let bestArea = Infinity;
for (const f of this._pickFills) {
if (MH && MH[f.metaIdx]) continue;
if (this._fillHit(f.loops, wx, wy)) {
const a = this._loopArea(f.loops[0] || []);
if (a < bestArea) { bestArea = a; bestIdx = f.metaIdx; }
@@ -3705,6 +3905,16 @@ export class Viewer2D {
this._texCache?.clear();
this._textGen++; // invalidate any in-flight async text flush
this._contentBox = null;
this._fullContentBox = null;
this._layerObjs.clear();
this._layerBoxes.clear();
this._layerSamples.clear();
this._indexedBufs = [];
this._layerNames = null;
this._metaHidden = null;
this._curLayer = null;
this._curLayerId = -1;
this._layerInfoCache = null;
}
_onResize() {