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>
196 lines
6.4 KiB
TypeScript
196 lines
6.4 KiB
TypeScript
/**
|
|
* Layer Panel manager for dwg-dxf-viewer-sample.
|
|
* Renders layer items, color swatches, entity counts, search filters, and visibility toggles.
|
|
*/
|
|
|
|
import type { Viewer2D } from './viewer2d/Viewer2D';
|
|
|
|
export interface LayerItem {
|
|
name: string;
|
|
colorHex: string;
|
|
count: number;
|
|
visible: boolean;
|
|
}
|
|
|
|
export class LayerPanelManager {
|
|
private viewer: Viewer2D;
|
|
private listEl: HTMLElement;
|
|
private countEl: HTMLElement | null;
|
|
private searchInputEl: HTMLInputElement | null;
|
|
private hiddenLayers: Set<string> = new Set();
|
|
private searchQuery: string = '';
|
|
|
|
constructor(
|
|
viewer: Viewer2D,
|
|
listEl: HTMLElement,
|
|
options?: {
|
|
countEl?: HTMLElement | null;
|
|
searchInputEl?: HTMLInputElement | null;
|
|
showAllBtnEl?: HTMLElement | null;
|
|
hideAllBtnEl?: HTMLElement | null;
|
|
}
|
|
) {
|
|
this.viewer = viewer;
|
|
this.listEl = listEl;
|
|
this.countEl = options?.countEl || null;
|
|
this.searchInputEl = options?.searchInputEl || null;
|
|
|
|
if (this.searchInputEl) {
|
|
this.searchInputEl.addEventListener('input', (e) => {
|
|
this.searchQuery = (e.target as HTMLInputElement).value.trim().toLowerCase();
|
|
this.render();
|
|
});
|
|
}
|
|
|
|
if (options?.showAllBtnEl) {
|
|
options.showAllBtnEl.addEventListener('click', () => this.showAllLayers());
|
|
}
|
|
|
|
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 */
|
|
public update(): void {
|
|
this.hiddenLayers.clear();
|
|
const layers = this.viewer.getLayerInfo() || [];
|
|
for (const l of layers) {
|
|
if (!l.visible) {
|
|
this.hiddenLayers.add(l.name);
|
|
}
|
|
}
|
|
this.render();
|
|
}
|
|
|
|
public showAllLayers(): void {
|
|
this.hiddenLayers.clear();
|
|
this.viewer.setHiddenLayers(this.hiddenLayers);
|
|
this.syncRows();
|
|
}
|
|
|
|
public hideAllLayers(): void {
|
|
const layers = this.viewer.getLayerInfo() || [];
|
|
for (const l of layers) {
|
|
this.hiddenLayers.add(l.name);
|
|
}
|
|
this.viewer.setHiddenLayers(this.hiddenLayers);
|
|
this.syncRows();
|
|
}
|
|
|
|
public toggleLayer(name: string): void {
|
|
if (this.hiddenLayers.has(name)) {
|
|
this.hiddenLayers.delete(name);
|
|
} else {
|
|
this.hiddenLayers.add(name);
|
|
}
|
|
this.viewer.setHiddenLayers(this.hiddenLayers);
|
|
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 {
|
|
if (!this.listEl) return;
|
|
|
|
const layers: LayerItem[] = this.viewer.getLayerInfo() || [];
|
|
|
|
if (this.countEl) {
|
|
this.countEl.textContent = `${layers.length}개`;
|
|
}
|
|
|
|
if (!layers.length) {
|
|
this.listEl.innerHTML = `
|
|
<div style="padding:30px 16px;text-align:center;color:var(--muted);font-size:12px">
|
|
도면에 레이어 정보가 없습니다
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
const filtered = layers.filter((l) =>
|
|
l.name.toLowerCase().includes(this.searchQuery)
|
|
);
|
|
|
|
if (!filtered.length) {
|
|
this.listEl.innerHTML = `
|
|
<div style="padding:24px 16px;text-align:center;color:var(--muted);font-size:12px">
|
|
검색 결과가 없습니다 ("${escapeHtml(this.searchQuery)}")
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
this.listEl.innerHTML = filtered
|
|
.map((l) => {
|
|
const isVisible = !this.hiddenLayers.has(l.name);
|
|
const opacity = isVisible ? '1' : '0.4';
|
|
const eyeIcon = isVisible ? '👁️' : '🕶️';
|
|
const eyeTitle = isVisible ? '숨기기' : '표시하기';
|
|
|
|
return `
|
|
<div class="layer-row" data-name="${escapeHtml(l.name)}"
|
|
style="display:flex;align-items:center;gap:10px;padding:7px 10px;border-bottom:1px solid var(--line);opacity:${opacity};transition:opacity .15s;user-select:none">
|
|
<span style="width:10px;height:10px;border-radius:3px;background:${l.colorHex || '#888'};flex-shrink:0;box-shadow:0 0 0 1px rgba(255,255,255,.15)"></span>
|
|
<span class="mono" style="flex:1;font-size:12px;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${escapeHtml(l.name)}">
|
|
${escapeHtml(l.name)}
|
|
</span>
|
|
<span class="mono" style="font-size:11px;color:var(--muted);background:rgba(255,255,255,.06);padding:2px 6px;border-radius:4px;flex-shrink:0">
|
|
${l.count}
|
|
</span>
|
|
<button type="button" class="eye-btn" data-toggle="${escapeHtml(l.name)}" title="${eyeTitle}"
|
|
style="background:none;border:none;cursor:pointer;font-size:13px;padding:2px 4px;border-radius:4px;color:var(--ink);transition:transform .1s">
|
|
${eyeIcon}
|
|
</button>
|
|
</div>`;
|
|
})
|
|
.join('');
|
|
// 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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|