gds-lens 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/viewer.js ADDED
@@ -0,0 +1,2688 @@
1
+ import { rankCellMatches, cellPathToTarget } from "./cell-search.js";
2
+ import { parseMarkerFile, flattenMarkerModel } from "./marker-parsers.js";
3
+ import { describeLoadFailure, describeDecodeFailure } from "./load-errors.js";
4
+ import { decodeLayoutBytes, looksGzipped } from "./layout-bytes.js";
5
+ // Resolved by the build to engine-source.js (the served payloads) or
6
+ // engine-source.esm.js (the bundled module). A bare specifier because
7
+ // esbuild's alias only rewrites those, not relative paths.
8
+ import { loadGdstkFactory, workerBundle } from "gds-lens:engine";
9
+ // Inlined at build time (esbuild's text loader), because a component has to
10
+ // carry its own markup and styles: there is no separate document for a host
11
+ // page to load them from.
12
+ import { takeMountTarget } from "./mount-target.js";
13
+ import shellHtml from "./viewer-shell.html";
14
+ import viewerCss from "./viewer.css";
15
+ // lil-gui as a real dependency rather than a vendored UMD file: it publishes
16
+ // an ES module and, importantly here, its stylesheet as a plain .css file. The
17
+ // panel lives in our shadow root, which cannot see the stylesheet lil-gui
18
+ // would otherwise append to document.head, so we inject it ourselves.
19
+ //
20
+ // "lil-gui-css" is an alias the build resolves to that file (see
21
+ // build-webview.mjs). It needs one because lil-gui's exports map declares only
22
+ // import/require, so the stylesheet ships in the package but has no subpath
23
+ // that can reach it.
24
+ import GUI from "lil-gui";
25
+ import guiCss from "lil-gui-css";
26
+
27
+ // Thin bootstrap: instantiate the wasm module and relay what the host hands in
28
+ // into it. JS never touches GDS/GL data -- that all lives in
29
+ // wasm/renderer.cpp (gdstk GDSII/OASIS parsing, GL context, shaders, camera
30
+ // and input), which attaches to #glCanvas and the shadow root itself. The
31
+ // control surface (load .lyp button + per-layer visibility toggles) is built
32
+ // with lil-gui, a real dependency (see the import above).
33
+ //
34
+ // Loading a layout file is split across a Worker (see wasm-worker.js, shipped
35
+ // as gds-lens-worker.js) and this
36
+ // main-thread module: the Worker instantiates its own copy of the same wasm
37
+ // module and runs parseGdsToLayers() (parse + flatten + triangulate, no
38
+ // GL/DOM) so the canvas/lil-gui panel stay responsive on very large files,
39
+ // reporting progress via 'gdsProgress' messages along the way. Once it posts
40
+ // back the flattened geometry, this thread's Module.uploadLayers() does the
41
+ // (fast, GPU-bound) VBO upload -- the only part that needs the GL context.
42
+
43
+ // ---- Mounting ----
44
+ // The viewer lives in a shadow root so it can be dropped into a page that has
45
+ // its own styles: nothing here escapes, and nothing outside reaches in.
46
+ //
47
+ // The element is handed over by <gds-lens>'s connectedCallback (gds-lens.js),
48
+ // which is what defers everything in this module until one connects. The
49
+ // fallbacks keep the payload usable when it is loaded as a plain page.
50
+ let hostElement = takeMountTarget() || document.querySelector("gds-lens") ||
51
+ document.body.appendChild(document.createElement("gds-lens"));
52
+ let shadow = hostElement.shadowRoot || hostElement.attachShadow({ mode: "open" });
53
+ shadow.innerHTML = `<style>${guiCss}</style><style>${viewerCss}</style>${shellHtml}`;
54
+
55
+ // Every lookup is scoped to the shadow root. ShadowRoot is a DocumentFragment,
56
+ // which implements NonElementParentNode, so getElementById works on it exactly
57
+ // as on a document.
58
+ let viewerRoot = shadow;
59
+
60
+ // The element carrying the viewer's state classes (theme-light, debug,
61
+ // hierarchy-open, ...), which viewer.css selects on. The host element rather
62
+ // than the shadow root, because a DocumentFragment is not an element and
63
+ // cannot carry classes -- and because :host() then lets a page theme the
64
+ // viewer from outside.
65
+ let rootEl = hostElement;
66
+
67
+ // Resolved in one pass at startup rather than one lookup per element. All of
68
+ // these are static in viewer-shell.html (nothing below creates an element with
69
+ // an id), and the shell is injected into the shadow root just above, so the
70
+ // tree is fully built by the time this runs. A missing element yields
71
+ // undefined rather than null, which the falsy guards throughout this file
72
+ // already handle: a host still holding stale HTML from before a panel existed
73
+ // must not throw and abort the rest of setup.
74
+ const els = Object.fromEntries(
75
+ Array.from(viewerRoot.querySelectorAll("[id]"), (el) => [el.id, el])
76
+ );
77
+
78
+ // On-screen debug log (see #debugPanel in viewer-shell.html): the viewer's own
79
+ // trace output, plus 'gdsLog' messages relayed from the Worker (which has no
80
+ // DOM of its own to render into), so debugging doesn't depend on getting the
81
+ // right DevTools window attached to the right frame -- the log is just
82
+ // selectable text in the page itself.
83
+ //
84
+ // This used to be done by assigning window.console.log/error. It cannot be:
85
+ // this is a component in someone else's page, and replacing a global the host
86
+ // owns means the host's own logging appends to our panel for the life of the
87
+ // page (growing a detached <div> forever once the element is removed), with no
88
+ // way to opt out. Everything here routes through trace()/fail() instead, and
89
+ // the host's console is left exactly as we found it.
90
+ const debugLogEl = els.debugLog;
91
+
92
+ // A long-lived page can log a lot. The panel is a debugging aid, not a
93
+ // transcript, so old lines are dropped rather than retained forever.
94
+ const MAX_DEBUG_LINES = 500;
95
+
96
+ function safeStringify(arg) {
97
+ if (typeof arg === "string") return arg;
98
+ if (arg instanceof Error) return arg.stack || arg.message;
99
+ try {
100
+ return JSON.stringify(arg);
101
+ } catch {
102
+ return String(arg);
103
+ }
104
+ }
105
+ function appendDebugLine(text, isError) {
106
+ if (!debugLogEl) return;
107
+ const line = document.createElement("div");
108
+ if (isError) line.className = "err";
109
+ line.textContent = `[${new Date().toISOString().slice(11, 23)}] ${text}`;
110
+ debugLogEl.appendChild(line);
111
+ while (debugLogEl.childElementCount > MAX_DEBUG_LINES) {
112
+ debugLogEl.removeChild(debugLogEl.firstElementChild);
113
+ }
114
+ debugLogEl.scrollTop = debugLogEl.scrollHeight;
115
+ }
116
+
117
+ // Opt-in, because the breadcrumbs below are useful when a load misbehaves and
118
+ // noise on every successful one. `debug` on the element covers an embedder
119
+ // driving the component; ?gdsDebug=1 covers a plain page where the markup
120
+ // isn't the reader's to edit.
121
+ const debugRequested = () => {
122
+ if (rootEl && rootEl.hasAttribute && rootEl.hasAttribute("debug")) return true;
123
+ try {
124
+ return new URLSearchParams(location.search).get("gdsDebug") === "1";
125
+ } catch {
126
+ return false;
127
+ }
128
+ };
129
+ const traceToConsole = debugRequested();
130
+
131
+ // Breadcrumbs: the panel always, the host's console only when asked.
132
+ function trace(...args) {
133
+ appendDebugLine(args.map(safeStringify).join(" "), false);
134
+ if (traceToConsole) console.log(...args);
135
+ }
136
+
137
+ // Failures: the panel and the console, always. These are the lines that
138
+ // explain a blank viewer, so they are never gated behind a flag.
139
+ function fail(...args) {
140
+ appendDebugLine(args.map(safeStringify).join(" "), true);
141
+ console.error(...args);
142
+ }
143
+ // Null-guarded: a missing element here (e.g. a webview still holding stale
144
+ // HTML from before this panel existed) must not throw and abort the rest of
145
+ // this script -- everything below, including the window "message" listener
146
+ // that shows the loading bar at all, depends on this file finishing setup.
147
+ const debugPanelEl = els.debugPanel;
148
+ const debugToggleBtn = els.debugToggleBtn;
149
+ if (debugToggleBtn && debugPanelEl) {
150
+ debugToggleBtn.addEventListener("click", () => {
151
+ const open = debugPanelEl.classList.toggle("hidden") === false;
152
+ debugToggleBtn.setAttribute("aria-expanded", String(open));
153
+ });
154
+ }
155
+ const debugCopyBtn = els.debugCopyBtn;
156
+ if (debugCopyBtn) {
157
+ debugCopyBtn.addEventListener("click", () => {
158
+ const text = debugLogEl ? debugLogEl.innerText : "";
159
+ navigator.clipboard.writeText(text).then(
160
+ () => trace("[GDS] debug log copied to clipboard"),
161
+ (err) => {
162
+ // Clipboard API can be blocked in a sandboxed webview -- fall back
163
+ // to selecting the text so the user can Cmd/Ctrl+C manually.
164
+ fail("[GDS] clipboard write failed, select-all instead:", err);
165
+ if (!debugLogEl) return;
166
+ const range = document.createRange();
167
+ range.selectNodeContents(debugLogEl);
168
+ const sel = window.getSelection();
169
+ sel.removeAllRanges();
170
+ sel.addRange(range);
171
+ }
172
+ );
173
+ });
174
+ }
175
+
176
+ trace("[GDS] viewer.js starting to execute");
177
+
178
+ window.onerror = (msg, url, line, col, err) => {
179
+ fail("[GDS] window.onerror:", msg, "at", url + ":" + line + ":" + col, err && err.stack);
180
+ };
181
+ window.addEventListener("unhandledrejection", (event) => {
182
+ fail("[GDS] unhandled promise rejection on main thread:", event.reason);
183
+ });
184
+
185
+ // Everything this file needs from whatever is embedding it. hosts/browser.js
186
+ // installs a default implementation for a plain page; an embedder replaces it
187
+ // by setting window.gdsLensHost before this script runs. Nothing below knows
188
+ // which host it has -- see hosts/browser.js for the interface.
189
+ const host = (typeof window !== "undefined" && window.gdsLensHost) || {};
190
+ // A host that never arrived is the one failure that looks like nothing at all:
191
+ // with no connect() there is nobody to hand a layout in, so the page sits on
192
+ // its loading bar forever with an empty log. Nearly always a gds-lens-host.js
193
+ // that did
194
+ // not load (a 404, or a CSP that blocked it), so say that plainly rather than
195
+ // leaving the bar to be interpreted.
196
+ if (!window.gdsLensHost) {
197
+ fail(
198
+ "[GDS] no window.gdsLensHost -- gds-lens-host.js did not load or did not " +
199
+ "run. " +
200
+ "Nothing can drive the viewer, so no layout will ever appear."
201
+ );
202
+ }
203
+ // Every method is optional, so calls go through these rather than being
204
+ // guarded one by one at each site. A missing service is not an error: it means
205
+ // the embedder does not offer it, and the control for it is hidden.
206
+ const hostCan = (name) => typeof host[name] === "function";
207
+ const hostCall = (name, ...args) => (hostCan(name) ? host[name](...args) : undefined);
208
+ trace("[GDS] host ready; Worker:", typeof Worker, "Blob:", typeof Blob, "bundled worker:", !!workerBundle);
209
+
210
+ // container: the panel belongs inside the shadow root, not at document level.
211
+ // injectStyles: false: lil-gui otherwise appends its stylesheet to
212
+ // document.head, which a shadow root does not see, so the panel would render
213
+ // unstyled. Its CSS is carried in viewer.css instead (see the lil-gui block
214
+ // there), alongside everything else the shadow root owns.
215
+ const gui = new GUI({ width: 260, container: viewerRoot.getElementById("guiHost"), injectStyles: false });
216
+ const actions = {
217
+ // Clicking the row always opens the file dialog (load, or replace the
218
+ // current file); the injected ✕ (see setFileChip) handles unloading.
219
+ loadLypFile: () => Promise.resolve(hostCall("pickLyp")).then((picked) => {
220
+ if (picked) applyLyp(picked.name, picked.text);
221
+ }),
222
+ loadMarkerFile: () => Promise.resolve(hostCall("pickMarkers")).then((picked) => {
223
+ if (picked) applyMarkers(picked.name, picked.text);
224
+ }),
225
+ resetView: () => modulePromise.then((Module) => Module.resetView()),
226
+ showInfill: false,
227
+ showText: false,
228
+ mergeOverlaps: false,
229
+ // On by default -- matches g_show_grid in renderer.cpp, which is the
230
+ // renderer's own initial state (nothing pushes this value down at startup).
231
+ showGrid: true
232
+ };
233
+ // ---- Display folder ----
234
+ // Everything here is either set once and forgotten (the render toggles, the
235
+ // .lyp) or reached for occasionally (a marker database, refitting the view) --
236
+ // so it's one closed folder rather than eight rows above the layer list, which
237
+ // is what the panel is actually for. Closed by default: nothing in here has to
238
+ // be visible to read a layout.
239
+ const displayFolder = gui.addFolder("Display");
240
+ displayFolder.close();
241
+
242
+ displayFolder.add(actions, "showInfill").name("Infill")
243
+ .onChange((show) => modulePromise.then((Module) => Module.setShowInfill(show)));
244
+ // Draw the layout's own labels (GDSII/OASIS TEXT elements) at a fixed
245
+ // on-screen size, in each label's layer color -- off by default because a
246
+ // full chip's worth of text buries the geometry it sits on.
247
+ const textController = displayFolder.add(actions, "showText").name("Text")
248
+ .onChange((show) => modulePromise.then((Module) => Module.setShowText(show)));
249
+ textController.domElement.title = "Show layout text labels, drawn in their layer's color";
250
+ // Draw each layer as the union of its polygons (boundary + fill only, no
251
+ // internal edges) -- a pure render-mode toggle, no re-parse involved.
252
+ displayFolder.add(actions, "mergeOverlaps").name("Merge Overlaps")
253
+ .onChange((on) => modulePromise.then((Module) => Module.setMergeMode(on)));
254
+ // Background reference grid, pitched at a power-of-ten nm/µm/mm step that
255
+ // follows the zoom (see draw_grid).
256
+ const gridController = displayFolder.add(actions, "showGrid").name("Grid")
257
+ .onChange((show) => modulePromise.then((Module) => Module.setShowGrid(show)));
258
+ gridController.domElement.title = "Show the background grid, spaced at a round step that follows the zoom";
259
+
260
+ // The two file loaders live under the toggles because that's the order they're
261
+ // used in over a session: the render toggles are a preference, and a .lyp or a
262
+ // marker database is loaded once (and then remembered across reopens by the
263
+ // extension host, so most sessions never touch these rows at all).
264
+ const lypController = displayFolder.add(actions, "loadLypFile").name("Load .lyp File");
265
+ const markerController = displayFolder.add(actions, "loadMarkerFile").name("Load Marker File (.lyrdb / DRC)");
266
+ displayFolder.add(actions, "resetView").name("Reset View");
267
+
268
+ // ---- Interaction mode (Pan / Measure) ----
269
+ // The canvas can only do one thing with a click, so the two are exclusive
270
+ // modes rather than an "on top of panning" toggle: in Pan mode a drag moves
271
+ // the view, in Measure mode clicks place the ruler's two ends (see
272
+ // on_mousedown in renderer.cpp) and dragging does nothing. Rendered as a
273
+ // segmented pair of buttons -- a checkbox would say "measure is an extra",
274
+ // which is exactly the wrong mental model. Wasm only needs the boolean; the
275
+ // row below is the whole difference.
276
+ const MODES = [
277
+ { id: "pan", label: "Pan", title: "Drag to pan the view, wheel to zoom" },
278
+ {
279
+ id: "measure",
280
+ label: "Measure",
281
+ title: "Click two points to measure between them. Snaps to nearby vertices and edges " +
282
+ "(Alt to place freely), Shift constrains to horizontal/vertical, Esc cancels."
283
+ }
284
+ ];
285
+ let currentMode = "pan";
286
+ const modeButtons = new Map();
287
+
288
+ // Built by hand instead of via gui.add(): lil-gui has no segmented-control
289
+ // type. Reusing its own .lil-controller/.lil-name/.lil-widget classes means
290
+ // the row picks up the panel's row metrics and theme colors for free (the
291
+ // button styling itself lives in viewer.css).
292
+ const modeRow = document.createElement("div");
293
+ modeRow.className = "lil-controller mode-row";
294
+ const modeName = document.createElement("div");
295
+ modeName.className = "lil-name";
296
+ modeName.textContent = "Mode";
297
+ const modeWidget = document.createElement("div");
298
+ modeWidget.className = "lil-widget mode-widget";
299
+ for (const mode of MODES) {
300
+ const btn = document.createElement("button");
301
+ btn.type = "button";
302
+ btn.textContent = mode.label;
303
+ btn.title = mode.title;
304
+ btn.addEventListener("click", () => setMode(mode.id));
305
+ modeWidget.appendChild(btn);
306
+ modeButtons.set(mode.id, btn);
307
+ }
308
+ modeRow.appendChild(modeName);
309
+ modeRow.appendChild(modeWidget);
310
+ // First row in the panel, above the Display folder and the layer list: it's the
311
+ // one control here that changes what a click on the canvas does, so it's the one
312
+ // that has to be found without opening anything. prepend rather than append
313
+ // because the folders and the layer list are added to $children by the load
314
+ // path, which runs long after this.
315
+ gui.$children.prepend(modeRow);
316
+
317
+ function setMode(id) {
318
+ if (currentMode === id) return;
319
+ currentMode = id;
320
+ for (const [modeId, btn] of modeButtons) {
321
+ btn.classList.toggle("mode-active", modeId === id);
322
+ }
323
+ // Leaving measure mode drops a half-placed ruler but keeps the finished
324
+ // ones (see setMeasureMode in renderer.cpp) -- so the row below has to be
325
+ // re-read either way.
326
+ modulePromise.then((Module) => {
327
+ Module.setMeasureMode(id === "measure");
328
+ refreshRulerRow(Module);
329
+ });
330
+ }
331
+ modeButtons.get(currentMode).classList.add("mode-active");
332
+
333
+ // ---- Rulers ----
334
+ // Measurements persist once placed and stack up, so there has to be a way to
335
+ // take them down that doesn't involve re-entering the mode that made them. The
336
+ // row only exists while there is something to clear.
337
+ const rulerRow = document.createElement("div");
338
+ rulerRow.className = "lil-controller mode-row ruler-row";
339
+ rulerRow.style.display = "none";
340
+ const rulerName = document.createElement("div");
341
+ rulerName.className = "lil-name";
342
+ rulerName.textContent = "Rulers";
343
+ const rulerWidget = document.createElement("div");
344
+ rulerWidget.className = "lil-widget mode-widget";
345
+ const rulerClearBtn = document.createElement("button");
346
+ rulerClearBtn.type = "button";
347
+ rulerClearBtn.title = "Remove every measurement on the canvas (also Esc, once nothing is being placed)";
348
+ rulerClearBtn.addEventListener("click", () => {
349
+ modulePromise.then((Module) => {
350
+ Module.clearMeasurements();
351
+ refreshRulerRow(Module);
352
+ });
353
+ });
354
+ rulerWidget.appendChild(rulerClearBtn);
355
+ rulerRow.append(rulerName, rulerWidget);
356
+ modeRow.after(rulerRow);
357
+
358
+ function refreshRulerRow(Module) {
359
+ const count = Module.measurementCount();
360
+ rulerRow.style.display = count > 0 ? "" : "none";
361
+ rulerClearBtn.textContent = `Clear ${count}`;
362
+ }
363
+
364
+ // Rulers are placed by clicking the canvas, and renderer.cpp owns that mouse
365
+ // handling entirely -- this side never sees the result. So the count is re-read
366
+ // after any click that could have finished one. setTimeout(0) rather than
367
+ // handling it inline because it has to run after the whole event dispatch,
368
+ // whichever order the renderer's listener and this one were registered in.
369
+ const glCanvas = els.glCanvas;
370
+ if (glCanvas) {
371
+ glCanvas.addEventListener("mousedown", () => {
372
+ if (currentMode !== "measure") return;
373
+ setTimeout(() => modulePromise.then(refreshRulerRow), 0);
374
+ });
375
+ }
376
+
377
+ // Reflects a loaded-file state in a lil-gui button row (used by both the
378
+ // .lyp and marker-file rows). With no file it's a plain load button. Once a
379
+ // file is loaded it shows the filename with an ✕ on the right that unloads
380
+ // it via onUnload. Clicking the filename itself re-opens the dialog to swap
381
+ // in a different file. (The .lyp-* CSS classes are shared by both rows.)
382
+ function setFileChip(controller, name, { idleLabel, idleTitle, unloadTitle, onUnload }) {
383
+ // Remove any ✕ from a previous loaded state before re-deciding.
384
+ const existingX = controller.domElement.querySelector(".lyp-unload");
385
+ if (existingX) existingX.remove();
386
+ controller.domElement.classList.toggle("lyp-loaded", !!name);
387
+
388
+ if (!name) {
389
+ controller.name(idleLabel);
390
+ controller.domElement.title = idleTitle;
391
+ return;
392
+ }
393
+
394
+ controller.name(name);
395
+ controller.domElement.title = `${name} — click to replace, ✕ to unload`;
396
+ const x = document.createElement("span");
397
+ x.className = "lyp-unload";
398
+ x.textContent = "✕";
399
+ x.title = unloadTitle;
400
+ x.addEventListener("click", (event) => {
401
+ // The ✕ overlays the row's full-width <button> but isn't inside it,
402
+ // so a click here never reaches the load-dialog handler; stopping
403
+ // propagation just makes that explicit.
404
+ event.stopPropagation();
405
+ onUnload();
406
+ });
407
+ controller.domElement.appendChild(x);
408
+ }
409
+
410
+ function setLypChip(name) {
411
+ setFileChip(lypController, name, {
412
+ idleLabel: "Load .lyp File",
413
+ idleTitle: "Load a .lyp layer-properties file",
414
+ unloadTitle: "Unload .lyp",
415
+ onUnload: () => {
416
+ modulePromise.then((Module) => {
417
+ // Empty text clears g_lyp_info and reverts layers to hash colors.
418
+ Module.loadLypText("");
419
+ renderLayerList(Module.getLayers());
420
+ });
421
+ hostCall("unloadLyp");
422
+ setLypChip(null);
423
+ }
424
+ });
425
+ }
426
+
427
+ function setMarkerChip(name) {
428
+ setFileChip(markerController, name, {
429
+ idleLabel: "Load Marker File (.lyrdb / DRC)",
430
+ idleTitle: "Load a .lyrdb report database or ASCII DRC results database",
431
+ unloadTitle: "Unload marker file",
432
+ onUnload: () => {
433
+ modulePromise.then((Module) => Module.clearMarkers());
434
+ hostCall("unloadMarkers");
435
+ removeMarkerBrowser();
436
+ currentMarkers = null;
437
+ setMarkerChip(null);
438
+ }
439
+ });
440
+ }
441
+ setLypChip(null);
442
+ setMarkerChip(null);
443
+
444
+ let layersFolder = null;
445
+ // Every checkbox row and category folder currently in the panel. Kept because
446
+ // a flat checkbox list stops working somewhere around 20 layers and a real PDK
447
+ // has well over 100, so the panel needs to filter, solo and bulk-toggle -- and
448
+ // all four of those act on rows that already exist. Filtering in particular
449
+ // has to show and hide rows in place: destroying and re-adding a hundred
450
+ // lil-gui controllers on every keystroke is far too slow to type against.
451
+ let layerRows = [];
452
+ let layerCategories = [];
453
+ // Whether a filter query is currently narrowing the list, and each category's
454
+ // open/closed state from before it was -- typing opens every folder with a hit
455
+ // (the point of typing is to see the matches, not to then click nine folders
456
+ // open), and clearing the box has to put them back rather than leaving the
457
+ // whole list expanded.
458
+ let layerFilterActive = false;
459
+
460
+ // Which layer is soloed, plus the visibility of every layer at the moment it
461
+ // was. Solo is only reversible because of that snapshot: "show everything
462
+ // again" is a different and usually wrong answer, since most of a PDK's layer
463
+ // list is layers you had already turned off on purpose.
464
+ let soloTag = null;
465
+ let soloRestore = null;
466
+
467
+ function layerTag(item) {
468
+ return `${item.layer}/${item.datatype}`;
469
+ }
470
+
471
+ // Compact count for a row ("1.2k", "3M") -- the exact number goes in the
472
+ // tooltip. A 260px panel has no room for seven digits per row.
473
+ function fmtCount(n) {
474
+ if (n < 1000) return String(n);
475
+ if (n < 1e6) return `${(n / 1e3).toFixed(n < 1e4 ? 1 : 0)}k`;
476
+ return `${(n / 1e6).toFixed(n < 1e7 ? 1 : 0)}M`;
477
+ }
478
+
479
+ // Tints a lil-gui row/folder's 4px left border with a layer's frame color --
480
+ // lil-gui has no built-in color swatch for booleans, so the border is the cue.
481
+ function tintBorder(el, color) {
482
+ if (el) el.style.borderLeft = `4px solid ${color}`;
483
+ }
484
+
485
+ // Writes a row's visibility everywhere it's held: the checkbox's own state
486
+ // object, the checkbox on screen, and wasm. Deliberately not via the
487
+ // controller's setValue, which fires onChange -- that path is reserved for the
488
+ // user actually clicking the checkbox, which is what drops the solo snapshot.
489
+ function setRowVisible(Module, row, visible) {
490
+ if (row.state.visible === visible) return;
491
+ row.state.visible = visible;
492
+ row.controller.updateDisplay();
493
+ Module.setLayerVisible(row.item.layer, row.item.datatype, visible);
494
+ }
495
+
496
+ // Re-reads every layer row's checkbox from wasm. Needed by the paths that set
497
+ // visibility in bulk without going through the rows themselves -- restoring a
498
+ // saved view (see restoreNamedView) is one -- where the panel would otherwise
499
+ // keep showing the checkboxes of the state it replaced. The reverse of
500
+ // setRowVisible: wasm already holds the value, so this only catches the display
501
+ // up to it.
502
+ function syncLayerRowsFromModule(Module) {
503
+ const visibleByTag = new Map();
504
+ for (const layer of Module.getLayers()) {
505
+ visibleByTag.set(`${layer.layer}/${layer.datatype}`, layer.visible);
506
+ }
507
+ for (const row of layerRows) {
508
+ const visible = visibleByTag.get(layerTag(row.item));
509
+ if (visible === undefined || row.state.visible === visible) continue;
510
+ row.state.visible = visible;
511
+ row.controller.updateDisplay();
512
+ }
513
+ syncCategoryChecks();
514
+ }
515
+
516
+ // Re-derives every category's "all" checkbox from the rows under it.
517
+ function syncCategoryChecks() {
518
+ for (const category of layerCategories) {
519
+ const all = category.rows.every((row) => row.state.visible);
520
+ if (category.allState.visible !== all) {
521
+ category.allState.visible = all;
522
+ category.allController.updateDisplay();
523
+ }
524
+ }
525
+ }
526
+
527
+ // Drops the solo snapshot: any hand-set visibility makes it describe a state
528
+ // that no longer exists, and restoring to it would undo the change just made.
529
+ function forgetSolo() {
530
+ if (soloTag === null) return;
531
+ soloTag = null;
532
+ soloRestore = null;
533
+ markSoloRow();
534
+ }
535
+
536
+ function markSoloRow() {
537
+ for (const row of layerRows) {
538
+ row.controller.domElement.classList.toggle("layer-soloed", layerTag(row.item) === soloTag);
539
+ }
540
+ }
541
+
542
+ // Show only this layer; clicking the same layer's S again puts back the
543
+ // visibility set the first click captured.
544
+ function toggleSolo(item) {
545
+ const tag = layerTag(item);
546
+ const restore = soloTag === tag ? soloRestore : null;
547
+ if (restore) {
548
+ soloTag = null;
549
+ soloRestore = null;
550
+ } else {
551
+ soloRestore = new Map(layerRows.map((row) => [layerTag(row.item), row.state.visible]));
552
+ soloTag = tag;
553
+ }
554
+ markSoloRow();
555
+ modulePromise.then((Module) => {
556
+ for (const row of layerRows) {
557
+ const rowTag = layerTag(row.item);
558
+ setRowVisible(Module, row, restore ? restore.get(rowTag) !== false : rowTag === tag);
559
+ }
560
+ syncCategoryChecks();
561
+ });
562
+ }
563
+
564
+ // The All / None / Invert row. Scoped to whatever the filter is currently
565
+ // showing, which is what makes the pair worth having: filter to "metal", click
566
+ // None, and you've hidden one family without touching the other ninety layers.
567
+ function applyBulkVisibility(kind) {
568
+ forgetSolo();
569
+ const rows = layerRows.filter((row) => row.matches);
570
+ modulePromise.then((Module) => {
571
+ for (const row of rows) {
572
+ setRowVisible(Module, row, kind === "invert" ? !row.state.visible : kind === "all");
573
+ }
574
+ syncCategoryChecks();
575
+ });
576
+ }
577
+
578
+ // Narrows the list to rows whose number, datatype, name or category contains
579
+ // the query. Rows are hidden, not removed, so the visibility state behind them
580
+ // is untouched -- a filter is a view of the list, not an edit to it.
581
+ function applyLayerFilter(text) {
582
+ const query = text.trim().toLowerCase();
583
+ if (query && !layerFilterActive) {
584
+ for (const category of layerCategories) {
585
+ category.wasOpen = !category.folder.domElement.classList.contains("lil-closed");
586
+ }
587
+ }
588
+ layerFilterActive = !!query;
589
+
590
+ for (const row of layerRows) {
591
+ row.matches = !query || row.haystack.includes(query);
592
+ row.controller.domElement.style.display = row.matches ? "" : "none";
593
+ }
594
+ for (const category of layerCategories) {
595
+ const matched = category.rows.reduce((n, row) => n + (row.matches ? 1 : 0), 0);
596
+ category.folder.domElement.style.display = matched > 0 ? "" : "none";
597
+ category.folder.title(query
598
+ ? `${category.name} (${matched} of ${category.rows.length})`
599
+ : `${category.name} (${category.rows.length})`);
600
+ if (query) category.folder.open();
601
+ else if (!category.wasOpen) category.folder.close();
602
+ }
603
+ }
604
+
605
+ // Adds one visibility checkbox for a single (layer, datatype) item to `parent`,
606
+ // with its shape count and a solo button on the right. onSync (optional)
607
+ // refreshes the enclosing category's "all" checkbox after a toggle. Returns the
608
+ // row record the filter/solo/bulk paths above operate on.
609
+ function addLayerRow(parent, item, onSync) {
610
+ const label = item.name
611
+ ? `${item.layer}/${item.datatype} – ${item.name}`
612
+ : `${item.layer}/${item.datatype}`;
613
+ const shapes = item.polygonCount || 0;
614
+ const labels = item.labelCount || 0;
615
+ const state = { visible: item.visible };
616
+ const controller = parent.add(state, "visible")
617
+ .name(label)
618
+ .onChange((visible) => {
619
+ forgetSolo();
620
+ modulePromise.then((Module) => Module.setLayerVisible(item.layer, item.datatype, visible));
621
+ if (onSync) onSync();
622
+ });
623
+ tintBorder(controller.domElement, item.frameColor);
624
+ controller.domElement.classList.add("layer-row");
625
+ controller.domElement.title = [
626
+ label,
627
+ `${shapes.toLocaleString()} shape${shapes === 1 ? "" : "s"}, ` +
628
+ `${labels.toLocaleString()} label${labels === 1 ? "" : "s"}`
629
+ ].join("\n");
630
+
631
+ const count = document.createElement("span");
632
+ count.className = "layer-count";
633
+ // A layer with no polygons at all but labels on it -- which real decks do
634
+ // have -- would read as empty behind a bare "0", so those count their text
635
+ // instead and say so with the same T the panel's text toggle uses.
636
+ count.textContent = shapes > 0 ? fmtCount(shapes) : (labels > 0 ? `T${fmtCount(labels)}` : "0");
637
+
638
+ const solo = document.createElement("span");
639
+ solo.className = "layer-solo";
640
+ solo.textContent = "S";
641
+ solo.title = "Solo — hide every other layer (click again to restore them)";
642
+ solo.addEventListener("click", (event) => {
643
+ // lil-gui builds a boolean row as a <label> wrapping its checkbox, so
644
+ // without this a click anywhere inside it -- here included -- would also
645
+ // toggle the layer it's meant to solo.
646
+ event.preventDefault();
647
+ event.stopPropagation();
648
+ toggleSolo(item);
649
+ });
650
+ controller.domElement.append(count, solo);
651
+
652
+ return { controller, state, item, matches: true, haystack: `${label} ${item.group || ""}`.toLowerCase() };
653
+ }
654
+
655
+ // The two hand-built rows at the top of the Layers folder: a live filter box
656
+ // and All | None | Invert. Both are built by hand for the same reason the
657
+ // Pan | Measure row is -- lil-gui has neither a live-updating text field (its
658
+ // string controller only reports on Enter/blur, which is useless for a filter)
659
+ // nor a segmented control -- and both reuse its row classes so they pick up the
660
+ // panel's metrics and theme for free.
661
+ function addLayerListControls(folder) {
662
+ const filterRow = document.createElement("div");
663
+ filterRow.className = "lil-controller layer-filter-row";
664
+ const filterName = document.createElement("div");
665
+ filterName.className = "lil-name";
666
+ filterName.textContent = "Filter";
667
+ const filterWidget = document.createElement("div");
668
+ filterWidget.className = "lil-widget";
669
+ const filterInput = document.createElement("input");
670
+ filterInput.type = "text";
671
+ filterInput.placeholder = "number, name or group";
672
+ filterInput.addEventListener("input", () => applyLayerFilter(filterInput.value));
673
+ filterWidget.appendChild(filterInput);
674
+ filterRow.append(filterName, filterWidget);
675
+ filterRow.title = "Show only layers whose number, datatype, name or group contains this";
676
+
677
+ const bulkRow = document.createElement("div");
678
+ bulkRow.className = "lil-controller mode-row layer-bulk-row";
679
+ const bulkName = document.createElement("div");
680
+ bulkName.className = "lil-name";
681
+ bulkName.textContent = "Show";
682
+ const bulkWidget = document.createElement("div");
683
+ bulkWidget.className = "lil-widget mode-widget";
684
+ const BULK = [
685
+ { id: "all", label: "All", title: "Show every layer the filter is showing" },
686
+ { id: "none", label: "None", title: "Hide every layer the filter is showing" },
687
+ { id: "invert", label: "Invert", title: "Flip every filtered layer's visibility" }
688
+ ];
689
+ for (const action of BULK) {
690
+ const btn = document.createElement("button");
691
+ btn.type = "button";
692
+ btn.textContent = action.label;
693
+ btn.title = action.title;
694
+ btn.addEventListener("click", () => applyBulkVisibility(action.id));
695
+ bulkWidget.appendChild(btn);
696
+ }
697
+ bulkRow.append(bulkName, bulkWidget);
698
+
699
+ folder.$children.append(filterRow, bulkRow);
700
+ }
701
+
702
+ // Rebuilds the layer folder from Module.getLayers() -- {layer, datatype, name,
703
+ // group, fillColor, frameColor, visible}[], all plain scalars/strings (no
704
+ // per-polygon geometry crosses into JS). Layers are keyed on the (layer,
705
+ // datatype) pair and organized into collapsible categories from the .lyp's
706
+ // top-level groups (`group`, e.g. "Metals"): each category folder has an "all"
707
+ // checkbox that toggles every layer under it, plus one checkbox per
708
+ // layer/datatype. Layers with no category (ungrouped, or present in the GDS but
709
+ // absent from the .lyp) go under "Other layers". Called after every
710
+ // load/loadLypText() since either can change the layer set, colors, or
711
+ // visibility.
712
+ function renderLayerList(layers) {
713
+ if (layersFolder) {
714
+ layersFolder.destroy();
715
+ }
716
+ layerRows = [];
717
+ layerCategories = [];
718
+ layerFilterActive = false;
719
+ // The snapshot describes the layer set that's being thrown away, so it
720
+ // can't survive into the next one.
721
+ soloTag = null;
722
+ soloRestore = null;
723
+
724
+ // lil-gui folders open by default (dat.gui's were closed) -- keep the
725
+ // panel compact until the user asks for the layer list.
726
+ layersFolder = gui.addFolder("Layers");
727
+ layersFolder.close();
728
+ addLayerListControls(layersFolder);
729
+
730
+ // Group by category, preserving getLayers()'s ordering (lyp order first).
731
+ // Ungrouped layers collect under a single trailing "Other layers" bucket.
732
+ const OTHER = "Other layers";
733
+ const categories = new Map();
734
+ for (const layer of layers) {
735
+ const key = layer.group || OTHER;
736
+ if (!categories.has(key)) categories.set(key, []);
737
+ categories.get(key).push(layer);
738
+ }
739
+
740
+ for (const [category, items] of categories) {
741
+ const folder = layersFolder.addFolder(`${category} (${items.length})`);
742
+ folder.close();
743
+ // The folder's own <div.lil-gui> (title + children) carries a 4px
744
+ // border too.
745
+ tintBorder(folder.domElement, items[0].frameColor);
746
+
747
+ const children = [];
748
+ const syncCategory = () => {
749
+ const all = children.every((c) => c.state.visible);
750
+ if (allState.visible !== all) {
751
+ allState.visible = all;
752
+ allController.updateDisplay();
753
+ }
754
+ };
755
+ const allState = { visible: items.every((it) => it.visible) };
756
+ const allController = folder.add(allState, "visible")
757
+ .name("◼ all")
758
+ .onChange((visible) => {
759
+ forgetSolo();
760
+ modulePromise.then((Module) => {
761
+ for (const c of children) setRowVisible(Module, c, visible);
762
+ });
763
+ });
764
+ allController.domElement.title = `Toggle all ${items.length} layers in ${category}`;
765
+
766
+ const shapeTotal = items.reduce((n, it) => n + (it.polygonCount || 0), 0);
767
+ folder.$title.title = `${category}: ${items.length} layer${items.length === 1 ? "" : "s"}, ` +
768
+ `${shapeTotal.toLocaleString()} shape${shapeTotal === 1 ? "" : "s"}`;
769
+
770
+ for (const item of items) {
771
+ children.push(addLayerRow(folder, item, syncCategory));
772
+ }
773
+ layerRows.push(...children);
774
+ layerCategories.push({ name: category, folder, rows: children, allState, allController, wasOpen: false });
775
+ }
776
+ }
777
+
778
+ // ---- Hierarchy tree (left panel) ----
779
+ // The design's cell tree, as parseGdsToLayers hands it back (see
780
+ // build_hierarchy in renderer.cpp): a flat cells[] array of
781
+ // {name, polygons, labels, bbox, refs} plus the indices of the top-level
782
+ // cells, where each ref is {cell, count, bbox, xform} -- one entry per
783
+ // distinct cell a parent places, however many times it places it.
784
+ //
785
+ // Rows are built lazily, only when a branch is opened: cells[] describes each
786
+ // cell once, but the tree it spans is the expansion of a DAG, so a mid-sized
787
+ // chip's fully materialized tree is far larger than its library -- and nobody
788
+ // reads more than the few branches they opened.
789
+ const hierarchyPanel = els.hierarchyPanel;
790
+ const hierarchyTree = els.hierarchyTree;
791
+ const hierarchyCount = els.hierarchyCount;
792
+ const hierarchyHide = els.hierarchyHide;
793
+ const hierarchyShowBtn = els.hierarchyShowBtn;
794
+
795
+ let hierarchyModel = null;
796
+ // Open branches and the selected row are keyed by their path of cell names
797
+ // ("TOP/PIXEL/TAP"), not by DOM node: a reload throws every row away, and a
798
+ // path still identifies the same branch in the re-read file, so an edit-and-
799
+ // reload lands back where you were rather than collapsed to the roots.
800
+ const hierarchyExpanded = new Set();
801
+ let hierarchySelectedPath = null;
802
+ let hierarchySelectedRow = null;
803
+ // The selected row's world-space boxes, one per placement it stands for (empty
804
+ // for a cell with no geometry), kept so the canvas outlines can be re-pushed
805
+ // whenever the panel opens or closes -- see syncCellHighlight.
806
+ let hierarchySelectedBoxes = [];
807
+ // Which design the state above belongs to, so opening a different file starts
808
+ // from a clean tree instead of inheriting another design's open branches.
809
+ let hierarchyRootKey = null;
810
+ // Set once the user hides or shows the panel by hand; from then on that
811
+ // decision wins over the default below on every subsequent load.
812
+ let hierarchyUserChoice = null;
813
+ // Mirrors kMaxHierarchyDepth in renderer.cpp. References form a DAG in any
814
+ // valid file, so this only bites a malformed one that closes a loop -- where a
815
+ // branch could otherwise be opened without end.
816
+ const HIERARCHY_MAX_DEPTH = 256;
817
+
818
+ // [a, b, c, d, tx, ty] laid out as renderer.cpp's Affine2D: x' = a*x + b*y + tx,
819
+ // y' = c*x + d*y + ty.
820
+ const HIERARCHY_IDENTITY = [1, 0, 0, 1, 0, 0];
821
+
822
+ // compose(outer, inner) applied to a point == outer applied to inner applied
823
+ // to it (the JS twin of compose_affine in renderer.cpp).
824
+ function composeXform(outer, inner) {
825
+ return [
826
+ outer[0] * inner[0] + outer[1] * inner[2],
827
+ outer[0] * inner[1] + outer[1] * inner[3],
828
+ outer[2] * inner[0] + outer[3] * inner[2],
829
+ outer[2] * inner[1] + outer[3] * inner[3],
830
+ outer[0] * inner[4] + outer[1] * inner[5] + outer[4],
831
+ outer[2] * inner[4] + outer[3] * inner[5] + outer[5]
832
+ ];
833
+ }
834
+
835
+ // A box mapped through a transform, as the box of its four mapped corners --
836
+ // an over-estimate under a non-90° rotation, same as the wasm side's
837
+ // placed_box, and for framing the camera that's immaterial.
838
+ function transformBox(m, box) {
839
+ const corners = [[box.minX, box.minY], [box.maxX, box.minY], [box.minX, box.maxY], [box.maxX, box.maxY]];
840
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
841
+ for (const [x, y] of corners) {
842
+ const wx = m[0] * x + m[1] * y + m[4];
843
+ const wy = m[2] * x + m[3] * y + m[5];
844
+ minX = Math.min(minX, wx);
845
+ maxX = Math.max(maxX, wx);
846
+ minY = Math.min(minY, wy);
847
+ maxY = Math.max(maxY, wy);
848
+ }
849
+ return { minX, maxX, minY, maxY };
850
+ }
851
+
852
+ // The boxes to outline for one tree node: one per placement the row stands for.
853
+ // A row is a cell *as one parent places it*, so a cell placed 40 times is 40
854
+ // separate rectangles where the copies actually sit -- the box spanning all 40
855
+ // (which is what the row frames the camera on) mostly encloses other cells'
856
+ // geometry, and drawing that instead says the selected cell is everything in it.
857
+ //
858
+ // Each placement transform maps the child's own frame into the parent's, so its
859
+ // world box is the child cell's own box carried through the placement and then
860
+ // through the parent's transform. Rows over the tree's placement cap (see
861
+ // kMaxRowPlacements in renderer.cpp) carry no transforms; those fall back to the
862
+ // spanning box, since a partial set of copies is worse than an honest envelope.
863
+ function hierarchyBoxes(node, cell, parentXform, spanningBox) {
864
+ const placements = node.placements;
865
+ if (!placements || placements.length < 6 || !cell.bbox) {
866
+ return spanningBox ? [spanningBox] : [];
867
+ }
868
+ const boxes = [];
869
+ for (let i = 0; i + 5 < placements.length; i += 6) {
870
+ const xform = composeXform(parentXform, [
871
+ placements[i], placements[i + 1], placements[i + 2],
872
+ placements[i + 3], placements[i + 4], placements[i + 5]
873
+ ]);
874
+ boxes.push(transformBox(xform, cell.bbox));
875
+ }
876
+ return boxes;
877
+ }
878
+
879
+ // Shows/hides the panel. byUser marks the entry points the user drives (the
880
+ // header's ✕, the reopen button, the H key), which is what makes the choice
881
+ // stick across loads.
882
+ function setHierarchyOpen(open, byUser) {
883
+ if (byUser) hierarchyUserChoice = open;
884
+ if (hierarchyPanel) hierarchyPanel.classList.toggle("hidden", !open);
885
+ rootEl.classList.toggle("hierarchy-open", open);
886
+ // Both controls point at the same panel, so both carry its state: the
887
+ // class alone says nothing to a screen reader.
888
+ if (hierarchyShowBtn) hierarchyShowBtn.setAttribute("aria-expanded", String(open));
889
+ if (hierarchyHide) hierarchyHide.setAttribute("aria-expanded", String(open));
890
+ // The outline belongs to the panel: it's the tree pointing at the layout,
891
+ // so with the tree away it would be a dashed rectangle with nothing on
892
+ // screen to explain it. Putting the panel away takes it down, and bringing
893
+ // the panel back puts it up again for whatever row is still selected.
894
+ syncCellHighlight();
895
+ }
896
+
897
+ // Pushes the canvas outlines the current state calls for: the selected row's
898
+ // boxes while the panel is open, nothing otherwise. Every change to either half
899
+ // of that -- the selection, the panel's visibility -- goes through here rather
900
+ // than calling into wasm directly, so the two can't disagree.
901
+ function syncCellHighlight() {
902
+ const open = hierarchyPanel && !hierarchyPanel.classList.contains("hidden");
903
+ const boxes = open ? hierarchySelectedBoxes : null;
904
+ modulePromise.then((Module) => {
905
+ if (!boxes || boxes.length === 0) {
906
+ Module.clearCellHighlight();
907
+ return;
908
+ }
909
+ // Flat [minX, minY, maxX, maxY, ...] -- one bulk conversion in wasm
910
+ // rather than a call (and a JS object read) per placement.
911
+ const flat = [];
912
+ for (const box of boxes) flat.push(box.minX, box.minY, box.maxX, box.maxY);
913
+ Module.setCellHighlight(flat);
914
+ });
915
+ }
916
+
917
+ // Marks a row selected: the row itself in the panel, and -- via `boxes`, the
918
+ // placements the row stands for (see hierarchyBoxes) -- an outline around each
919
+ // copy of that cell on the canvas. The outlines are the half that survives
920
+ // navigating away: framing a cell says which shapes it is only until the view
921
+ // moves, whereas the boxes stay glued to the geometry, so zooming out to see
922
+ // where the cell sits in the design keeps the answer instead of losing it. Cells
923
+ // with no geometry pass no boxes and draw nothing -- there's no region to point
924
+ // at.
925
+ function hierarchySelect(row, path, boxes) {
926
+ if (hierarchySelectedRow) hierarchySelectedRow.classList.remove("hier-selected");
927
+ hierarchySelectedRow = row;
928
+ hierarchySelectedPath = path;
929
+ hierarchySelectedBoxes = boxes || [];
930
+ row.classList.add("hier-selected");
931
+ syncCellHighlight();
932
+ }
933
+
934
+ // Drops the selection entirely (Escape, and every path that replaces the tree).
935
+ // hierarchySelectedPath is cleared too, so a rebuild doesn't re-select it.
936
+ function hierarchyDeselect() {
937
+ if (hierarchySelectedRow) hierarchySelectedRow.classList.remove("hier-selected");
938
+ hierarchySelectedRow = null;
939
+ hierarchySelectedPath = null;
940
+ hierarchySelectedBoxes = [];
941
+ syncCellHighlight();
942
+ }
943
+
944
+ function hierarchyTooltip(cell, node, box, boxes) {
945
+ const lines = [cell.name];
946
+ if (node.count > 1) {
947
+ // Only the first copy is walked into, so say so on the row rather than
948
+ // leaving "expand" and "×64" to look like a contradiction. Whether the
949
+ // copies are outlined one by one or covered by a single box is a visible
950
+ // difference on screen, so the row says which it is.
951
+ const each = boxes.length > 1 ? "each outlined" : "outlined as one box, too many to mark separately";
952
+ lines.push(`${node.count} placements here, ${each} (expanding follows the first)`);
953
+ }
954
+ lines.push(`${cell.polygons} own shape${cell.polygons === 1 ? "" : "s"}, ` +
955
+ `${cell.labels} label${cell.labels === 1 ? "" : "s"}, ` +
956
+ `${cell.refs.length} child cell${cell.refs.length === 1 ? "" : "s"}`);
957
+ if (box) {
958
+ lines.push(`${fmtCoord(box.maxX - box.minX)} × ${fmtCoord(box.maxY - box.minY)} µm ` +
959
+ `at (${fmtCoord((box.minX + box.maxX) / 2)}, ${fmtCoord((box.minY + box.maxY) / 2)}) — ` +
960
+ `click to zoom to it and outline it (Esc clears)`);
961
+ } else {
962
+ lines.push("empty — no geometry to zoom to");
963
+ }
964
+ return lines.join("\n");
965
+ }
966
+
967
+ // Appends a row per entry of `nodes` (ref entries, or the synthetic root
968
+ // entries built in renderHierarchy) to `container`.
969
+ //
970
+ // parentXform maps the parent cell's own coordinates into world space, so a
971
+ // node's world box is its bbox -- which is in the parent's frame, spanning
972
+ // every placement the entry stands for -- mapped through it. Descending
973
+ // instead composes the entry's own xform, the *first* placement's: a cell
974
+ // placed 64 times is one row that frames all 64, and opening it walks into one
975
+ // of them, because there is no single deeper coordinate frame to offer. The
976
+ // per-placement boxes (what selecting the row outlines) are the other side of
977
+ // that same collapse -- see hierarchyBoxes.
978
+ function addHierarchyRows(container, nodes, depth, parentPath, parentXform) {
979
+ for (const node of nodes) {
980
+ const cell = hierarchyModel.cells[node.cell];
981
+ if (!cell) continue;
982
+
983
+ const path = parentPath ? `${parentPath}/${cell.name}` : cell.name;
984
+ const box = node.bbox ? transformBox(parentXform, node.bbox) : null;
985
+ const boxes = hierarchyBoxes(node, cell, parentXform, box);
986
+ const childXform = composeXform(parentXform, node.xform);
987
+ const expandable = cell.refs.length > 0 && depth + 1 < HIERARCHY_MAX_DEPTH;
988
+
989
+ const row = document.createElement("div");
990
+ row.className = "hier-row";
991
+ row.style.paddingLeft = `${6 + depth * 12}px`;
992
+ if (!box) row.classList.add("hier-boxless");
993
+ // The panel is the main way around a design, so the tree is a real
994
+ // tree to anything reading it: depth is what the indent conveys
995
+ // visually, and aria-expanded is set by setExpanded below.
996
+ row.setAttribute("role", "treeitem");
997
+ row.setAttribute("aria-level", String(depth + 1));
998
+ if (expandable) row.setAttribute("aria-expanded", "false");
999
+
1000
+ const twisty = document.createElement("span");
1001
+ twisty.className = "hier-twisty";
1002
+ twisty.textContent = expandable ? "▸" : "";
1003
+ const name = document.createElement("span");
1004
+ name.className = "hier-name";
1005
+ name.textContent = cell.name;
1006
+ const count = document.createElement("span");
1007
+ count.className = "hier-count";
1008
+ if (node.count > 1) count.textContent = `×${node.count}`;
1009
+ row.append(twisty, name, count);
1010
+ row.title = hierarchyTooltip(cell, node, box, boxes);
1011
+
1012
+ const children = document.createElement("div");
1013
+ children.className = "hier-children hidden";
1014
+ // A treeitem's children have to sit in a group for the nesting to be
1015
+ // reported, rather than reading as one flat list.
1016
+ children.setAttribute("role", "group");
1017
+ container.append(row, children);
1018
+
1019
+ let built = false;
1020
+ function setExpanded(open) {
1021
+ if (!expandable) return;
1022
+ if (open && !built) {
1023
+ built = true;
1024
+ addHierarchyRows(children, cell.refs, depth + 1, path, childXform);
1025
+ }
1026
+ children.classList.toggle("hidden", !open);
1027
+ twisty.textContent = open ? "▾" : "▸";
1028
+ row.setAttribute("aria-expanded", String(open));
1029
+ if (open) hierarchyExpanded.add(path);
1030
+ else hierarchyExpanded.delete(path);
1031
+ }
1032
+
1033
+ twisty.addEventListener("click", (event) => {
1034
+ // The twisty sits inside the row, whose own click moves the
1035
+ // camera -- opening a branch shouldn't also fly the view there.
1036
+ event.stopPropagation();
1037
+ setExpanded(children.classList.contains("hidden"));
1038
+ });
1039
+ row.addEventListener("click", () => {
1040
+ // Selection outlines every placement; the camera still frames all of
1041
+ // them at once, since that's the one view that shows the row's whole
1042
+ // meaning.
1043
+ hierarchySelect(row, path, boxes);
1044
+ if (!box) return;
1045
+ modulePromise.then((Module) => Module.zoomToBox(box.minX, box.minY, box.maxX, box.maxY));
1046
+ });
1047
+
1048
+ // Re-selecting after a rebuild (a reload, or reopening a branch) puts
1049
+ // the outlines back without moving the camera -- only a click moves it.
1050
+ if (path === hierarchySelectedPath) hierarchySelect(row, path, boxes);
1051
+ // ...except a rebuild the search asked for, which is standing in for the
1052
+ // click the user would have made if the row had been on screen: that one
1053
+ // frames the cell and scrolls the row it made to it (see revealCell).
1054
+ if (path === hierarchyRevealPath) {
1055
+ row.scrollIntoView({ block: "center" });
1056
+ if (box) modulePromise.then((Module) => Module.zoomToBox(box.minX, box.minY, box.maxX, box.maxY));
1057
+ }
1058
+ if (hierarchyExpanded.has(path)) setExpanded(true);
1059
+ }
1060
+ }
1061
+
1062
+ // Path the search asked to reveal, consumed by addHierarchyRows as it builds
1063
+ // that row (see revealCell). Null at every other moment, so a rebuild for any
1064
+ // other reason moves nothing.
1065
+ let hierarchyRevealPath = null;
1066
+
1067
+ // Throws the rows away and builds them again from the current model, keeping
1068
+ // the open branches and the selection (both are held by path, not by DOM node
1069
+ // -- see hierarchyExpanded). Rebuilding rather than reaching into the rows is
1070
+ // what makes revealing a cell possible at all: a row for a branch nobody has
1071
+ // opened doesn't exist yet, and adding the branch's path to hierarchyExpanded
1072
+ // and building again is how it comes to.
1073
+ function rebuildHierarchyRows() {
1074
+ if (!hierarchyTree || !hierarchyModel) return;
1075
+ hierarchySelectedRow = null;
1076
+ hierarchySelectedBoxes = [];
1077
+ hierarchyTree.textContent = "";
1078
+
1079
+ const cells = hierarchyModel.cells || [];
1080
+ const roots = (hierarchyModel.roots || []).filter((index) => cells[index]);
1081
+ // Top-level cells are drawn as if referenced once by an invisible parent
1082
+ // at the identity transform -- their own coordinates *are* world
1083
+ // coordinates, which is exactly what that entry says.
1084
+ const rootNodes = roots.map((index) => ({
1085
+ cell: index,
1086
+ count: 1,
1087
+ bbox: cells[index].bbox,
1088
+ xform: HIERARCHY_IDENTITY
1089
+ }));
1090
+ addHierarchyRows(hierarchyTree, rootNodes, 0, "", HIERARCHY_IDENTITY);
1091
+ }
1092
+
1093
+ // The tree's half of a cell search: cellPathToTarget (cell-search.js, loaded
1094
+ // via its own <script> tag) finds the path, and revealCell below opens it.
1095
+ function hierarchyPathToCell(target) {
1096
+ if (!hierarchyModel) return null;
1097
+ return cellPathToTarget(hierarchyModel.cells || [], hierarchyModel.roots || [],
1098
+ target, HIERARCHY_MAX_DEPTH);
1099
+ }
1100
+
1101
+ // Opens the tree down to a cell, selects the row and frames it -- what clicking
1102
+ // that row would have done, for a row that wasn't on screen to click. False
1103
+ // means no top cell places this one (a reference cycle, or a cell the tree's
1104
+ // own caps left out), so there is no branch to open.
1105
+ function revealCell(target) {
1106
+ const path = hierarchyPathToCell(target);
1107
+ if (!path) return false;
1108
+
1109
+ const names = path.map((index) => hierarchyModel.cells[index].name);
1110
+ // Every ancestor of the target row has to be open for it to exist, and
1111
+ // rows are keyed by the path of names leading to them.
1112
+ for (let i = 1; i < names.length; i++) {
1113
+ hierarchyExpanded.add(names.slice(0, i).join("/"));
1114
+ }
1115
+ hierarchySelectedPath = names.join("/");
1116
+ hierarchyRevealPath = hierarchySelectedPath;
1117
+ try {
1118
+ rebuildHierarchyRows();
1119
+ } finally {
1120
+ // Cleared even if a row throws mid-build, so a later rebuild for some
1121
+ // unrelated reason can't jump the camera at a stale path.
1122
+ hierarchyRevealPath = null;
1123
+ }
1124
+ return true;
1125
+ }
1126
+
1127
+ // Rebuilds the tree from a freshly loaded design (or clears it, for model
1128
+ // null -- a load that failed has no hierarchy to browse).
1129
+ function renderHierarchy(model) {
1130
+ if (!hierarchyTree) return;
1131
+ hierarchyModel = model;
1132
+ // Every row is about to be thrown away. The selected *path* is kept -- the
1133
+ // rows rebuilt below re-select it, which puts the canvas outlines back at
1134
+ // the reloaded file's coordinates -- but the boxes themselves are dropped
1135
+ // first: if this design has no cell on that path, nothing else would.
1136
+ hierarchySelectedRow = null;
1137
+ hierarchySelectedBoxes = [];
1138
+ syncCellHighlight();
1139
+ hierarchyTree.textContent = "";
1140
+
1141
+ const cells = (model && model.cells) || [];
1142
+ // Filtered once here so everything below can index cells[] freely -- the
1143
+ // omitted case ships no cells at all, and a root that names no cell would
1144
+ // otherwise throw somewhere less obvious.
1145
+ const roots = ((model && model.roots) || []).filter((index) => cells[index]);
1146
+ const cellCount = model ? model.cellCount : 0;
1147
+
1148
+ // .hierarchy-available says there's a tree to show, open or not, which is
1149
+ // what the stale banner's left edge keys off (the reopen button sits in
1150
+ // the same corner it starts in).
1151
+ rootEl.classList.toggle("hierarchy-available", cellCount > 0);
1152
+
1153
+ if (!model || cellCount === 0) {
1154
+ if (hierarchyCount) hierarchyCount.textContent = "";
1155
+ if (hierarchyShowBtn) hierarchyShowBtn.classList.add("hidden");
1156
+ refreshFind(true);
1157
+ setHierarchyOpen(false);
1158
+ return;
1159
+ }
1160
+ if (hierarchyShowBtn) hierarchyShowBtn.classList.remove("hidden");
1161
+ if (hierarchyCount) hierarchyCount.textContent = `${cellCount} cell${cellCount === 1 ? "" : "s"}`;
1162
+
1163
+ // A different design: drop the previous one's open branches and selection
1164
+ // rather than matching them against unrelated cell names.
1165
+ const rootKey = roots.map((i) => cells[i].name).join(" ");
1166
+ const sameDesign = rootKey === hierarchyRootKey;
1167
+ if (!sameDesign) {
1168
+ hierarchyRootKey = rootKey;
1169
+ hierarchyExpanded.clear();
1170
+ hierarchySelectedPath = null;
1171
+ }
1172
+ // The find box follows the same rule: a reload of the same design keeps the
1173
+ // query and re-runs it over what was just read -- a search in progress is
1174
+ // part of the working context a reload preserves, alongside the camera and
1175
+ // the open branches -- while a different design starts from an empty box.
1176
+ refreshFind(!sameDesign);
1177
+
1178
+ if (model.omitted) {
1179
+ const note = document.createElement("div");
1180
+ note.className = "hier-note";
1181
+ note.textContent = `This design has ${cellCount} cells — too many to browse as a tree, so it isn't built.`;
1182
+ hierarchyTree.append(note);
1183
+ setHierarchyOpen(hierarchyUserChoice === true);
1184
+ return;
1185
+ }
1186
+
1187
+ // First look at a design: open the top cell, so the panel shows what it's
1188
+ // made of instead of a single row you have to click to learn anything.
1189
+ if (hierarchyExpanded.size === 0 && roots.length > 0) {
1190
+ hierarchyExpanded.add(cells[roots[0]].name);
1191
+ }
1192
+
1193
+ rebuildHierarchyRows();
1194
+
1195
+ // Closed by default: the viewport belongs to the layout, and a panel that
1196
+ // takes 260px of it should be something you ask for. The rows above are
1197
+ // built either way -- they're what makes reopening instant -- and once the
1198
+ // panel has been opened by hand it stays open for the rest of the session,
1199
+ // including across reloads and other files.
1200
+ setHierarchyOpen(hierarchyUserChoice === true);
1201
+ }
1202
+
1203
+ // Both the ✕ and the reopen button are the user speaking, as is the H key
1204
+ // below -- all three go through here so the choice sticks.
1205
+ function toggleHierarchy() {
1206
+ // Nothing loaded (or nothing to show): don't open an empty panel.
1207
+ if (!hierarchyPanel || !hierarchyModel || !hierarchyModel.cellCount) return;
1208
+ setHierarchyOpen(hierarchyPanel.classList.contains("hidden"), true);
1209
+ }
1210
+
1211
+ if (hierarchyHide) {
1212
+ hierarchyHide.addEventListener("click", () => setHierarchyOpen(false, true));
1213
+ }
1214
+ if (hierarchyShowBtn) {
1215
+ hierarchyShowBtn.addEventListener("click", () => setHierarchyOpen(true, true));
1216
+ }
1217
+
1218
+ // ---- Find: cells and labels ----
1219
+ // One box over the two things in a design that have names: its cells, and the
1220
+ // layout's own TEXT labels. Both answer the same question -- "where is the
1221
+ // thing called X" -- so they share a box, a result list and a keystroke, with
1222
+ // the scope pair saying which name is being matched.
1223
+ //
1224
+ // Results take the tree's place while a query is up, rather than filtering the
1225
+ // rows the way the Layers panel filters its list: the tree is built lazily, so
1226
+ // a cell in a branch nobody has opened has no row to show or hide. That is
1227
+ // also why choosing a cell *reveals* it -- opens the branches down to it and
1228
+ // selects the row (see revealCell) -- instead of just moving the camera and
1229
+ // leaving the tree pointing somewhere else entirely.
1230
+ const hierarchyFindToggle = els.hierarchyFindToggle;
1231
+ const hierarchyFindTwisty = els.hierarchyFindTwisty;
1232
+ const hierarchySearchBox = els.hierarchySearch;
1233
+ const hierarchySearchInput = els.hierarchySearchInput;
1234
+ const hierarchySearchCount = els.hierarchySearchCount;
1235
+ const hierarchyResults = els.hierarchyResults;
1236
+ const hierarchyScopeCells = els.hierarchyScopeCells;
1237
+ const hierarchyScopeLabels = els.hierarchyScopeLabels;
1238
+
1239
+ // Rows past this aren't built. A 260px list is read, not scrolled through by
1240
+ // the thousand, and the count line says how many matches were left out -- the
1241
+ // same bargain the marker browser's per-category cap makes.
1242
+ const MAX_FIND_ROWS = 200;
1243
+
1244
+ // Half-width of the box a chosen label is marked with, in pixels at the zoom it
1245
+ // was chosen at. A label has no extent of its own -- its glyphs are drawn at a
1246
+ // fixed pixel size, so there is no world-space box to frame -- which is also
1247
+ // why choosing one pans without zooming: how much around it you want to see
1248
+ // isn't something the label says (the same reasoning as Go to Coordinate).
1249
+ const LABEL_MARK_PX = 14;
1250
+
1251
+ // "cells" or "labels".
1252
+ let findScope = "cells";
1253
+ // The query the list on screen belongs to, so an answer for a query already
1254
+ // typed past can't overwrite a newer one.
1255
+ let findQuery = "";
1256
+ // One record per built row: {element, activate}. Also what the arrow keys walk.
1257
+ let findRows = [];
1258
+ let findActiveIndex = -1;
1259
+
1260
+ // Opens and closes the fold the box lives in (closed on arrival -- see the
1261
+ // markup in viewer-shell.html). Closing clears the query rather than just hiding the
1262
+ // box: a result list is the answer to a question the panel would no longer be
1263
+ // showing, and leaving one up with nothing on screen to explain it is the same
1264
+ // mistake as leaving cell outlines up with the panel away.
1265
+ function setFindOpen(open) {
1266
+ if (!hierarchySearchBox) return;
1267
+ const changed = open !== findIsOpen();
1268
+ hierarchySearchBox.classList.toggle("hidden", !open);
1269
+ if (hierarchyFindTwisty) hierarchyFindTwisty.textContent = open ? "▾" : "▸";
1270
+ // The twisty is decoration; this is what actually announces the state.
1271
+ if (hierarchyFindToggle) hierarchyFindToggle.setAttribute("aria-expanded", String(open));
1272
+
1273
+ if (open) {
1274
+ // Opening it is asking to type in it -- and so is asking for it again
1275
+ // when it's already out (which is what "/" does, see focusFindBox).
1276
+ if (hierarchySearchInput) hierarchySearchInput.focus();
1277
+ return;
1278
+ }
1279
+ // Only on the way down, so closing an already-closed box can't wipe a
1280
+ // query. Nothing does that today, but runSearch below is not free on a
1281
+ // design with a million labels.
1282
+ if (!changed) return;
1283
+ if (hierarchySearchInput) {
1284
+ hierarchySearchInput.value = "";
1285
+ hierarchySearchInput.blur();
1286
+ }
1287
+ runSearch();
1288
+ }
1289
+
1290
+ function findIsOpen() {
1291
+ return !!(hierarchySearchBox && !hierarchySearchBox.classList.contains("hidden"));
1292
+ }
1293
+
1294
+ // The list and the tree are the same slot in the panel.
1295
+ function setFindResultsOpen(open) {
1296
+ if (!hierarchyResults || !hierarchyTree) return;
1297
+ hierarchyResults.classList.toggle("hidden", !open);
1298
+ hierarchyTree.classList.toggle("hidden", open);
1299
+ }
1300
+
1301
+ function setFindCount(text) {
1302
+ if (hierarchySearchCount) hierarchySearchCount.textContent = text;
1303
+ }
1304
+
1305
+ function clearFindRows() {
1306
+ if (hierarchyResults) hierarchyResults.textContent = "";
1307
+ findRows = [];
1308
+ findActiveIndex = -1;
1309
+ }
1310
+
1311
+ // A line of prose in the list: no matches, or how many were left out.
1312
+ function findNote(text) {
1313
+ if (!hierarchyResults) return;
1314
+ const note = document.createElement("div");
1315
+ note.className = "find-note";
1316
+ note.textContent = text;
1317
+ hierarchyResults.append(note);
1318
+ }
1319
+
1320
+ // One result row: `name` on the left, `meta` on the right, `activate(row)` on
1321
+ // click or Enter. Rows are appended in the order they're built, which is the
1322
+ // order the arrow keys walk them in.
1323
+ function addFindRow(name, meta, title, activate) {
1324
+ if (!hierarchyResults) return;
1325
+ const element = document.createElement("div");
1326
+ element.className = "find-row";
1327
+ const nameEl = document.createElement("span");
1328
+ nameEl.className = "find-name";
1329
+ nameEl.textContent = name;
1330
+ const metaEl = document.createElement("span");
1331
+ metaEl.className = "find-meta";
1332
+ metaEl.textContent = meta;
1333
+ element.append(nameEl, metaEl);
1334
+ element.title = title;
1335
+ const index = findRows.length;
1336
+ element.addEventListener("click", () => activateFindRow(index));
1337
+ hierarchyResults.append(element);
1338
+ findRows.push({ element, activate });
1339
+ }
1340
+
1341
+ function setFindActive(index) {
1342
+ const previous = findRows[findActiveIndex];
1343
+ if (previous) previous.element.classList.remove("find-active");
1344
+ findActiveIndex = index;
1345
+ const row = findRows[index];
1346
+ if (!row) return;
1347
+ row.element.classList.add("find-active");
1348
+ row.element.scrollIntoView({ block: "nearest" });
1349
+ }
1350
+
1351
+ function activateFindRow(index) {
1352
+ const row = findRows[index];
1353
+ if (!row || !row.activate) return;
1354
+ setFindActive(index);
1355
+ row.activate(row);
1356
+ }
1357
+
1358
+ // Arrow keys walk the list and Enter takes the row they're on (the first, if
1359
+ // they haven't been used): a list you can only reach with the mouse makes you
1360
+ // let go of the keyboard you just typed the query with.
1361
+ function stepFindRow(direction) {
1362
+ if (findRows.length === 0) return;
1363
+ const from = findActiveIndex < 0 ? (direction > 0 ? -1 : 0) : findActiveIndex;
1364
+ setFindActive((from + direction + findRows.length) % findRows.length);
1365
+ }
1366
+
1367
+ // Re-runs the search from whatever is in the box: every keystroke, a scope
1368
+ // switch, and each load.
1369
+ function runSearch() {
1370
+ if (!hierarchySearchInput) return;
1371
+ const query = hierarchySearchInput.value.trim();
1372
+ findQuery = query;
1373
+ if (!query) {
1374
+ clearFindRows();
1375
+ setFindResultsOpen(false);
1376
+ setFindCount("");
1377
+ return;
1378
+ }
1379
+ setFindResultsOpen(true);
1380
+ if (findScope === "labels") runLabelSearch(query);
1381
+ else renderCellResults(query);
1382
+ }
1383
+
1384
+ function renderCellResults(query) {
1385
+ clearFindRows();
1386
+ // A design too large for a tree ships no cell names at all (see the
1387
+ // omitted case in build_hierarchy), so there is nothing here to match --
1388
+ // say which of the two it is rather than answering "no such cell".
1389
+ if (hierarchyModel && hierarchyModel.omitted) {
1390
+ setFindCount("");
1391
+ findNote(`This design's ${hierarchyModel.cellCount} cells are too many for the viewer to hold as a tree, ` +
1392
+ `so it has no cell names to search. Labels still work.`);
1393
+ return;
1394
+ }
1395
+
1396
+ const cells = (hierarchyModel && hierarchyModel.cells) || [];
1397
+ // Best match first -- see rankCellMatches in cell-search.js.
1398
+ const matches = rankCellMatches(cells, query);
1399
+
1400
+ setFindCount(matches.length === 0
1401
+ ? "no match"
1402
+ : `${Math.min(matches.length, MAX_FIND_ROWS)} of ${matches.length} cell${matches.length === 1 ? "" : "s"}`);
1403
+
1404
+ for (const index of matches.slice(0, MAX_FIND_ROWS)) {
1405
+ const cell = cells[index];
1406
+ // Same shorthand the layer rows use: a cell whose own content is text
1407
+ // rather than geometry would otherwise read as empty behind a bare 0.
1408
+ const meta = cell.polygons > 0
1409
+ ? fmtCount(cell.polygons)
1410
+ : (cell.labels > 0 ? `T${fmtCount(cell.labels)}` : "0");
1411
+ const title = [
1412
+ cell.name,
1413
+ `${cell.polygons.toLocaleString()} own shape${cell.polygons === 1 ? "" : "s"}, ` +
1414
+ `${cell.labels.toLocaleString()} label${cell.labels === 1 ? "" : "s"}, ` +
1415
+ `${cell.refs.length} child cell${cell.refs.length === 1 ? "" : "s"}`,
1416
+ "Click to open the tree down to it, frame it and outline every placement"
1417
+ ].join("\n");
1418
+ addFindRow(cell.name, meta, title, (row) => chooseCell(index, row));
1419
+ }
1420
+ if (matches.length > MAX_FIND_ROWS) {
1421
+ findNote(`… ${matches.length - MAX_FIND_ROWS} more — narrow the query`);
1422
+ }
1423
+ if (matches.length === 0) findNote(`No cell name contains “${query}”.`);
1424
+ }
1425
+
1426
+ // The answer to "where is this cell" is a row in the hierarchy, in context and
1427
+ // with its parents opened -- so the tree is what's on screen afterwards, and
1428
+ // the list steps aside. The query stays in the box: clicking back into it
1429
+ // brings the same list back without retyping.
1430
+ function chooseCell(index, row) {
1431
+ // The tree goes back on screen *before* the row is built, not after:
1432
+ // revealCell scrolls the row it makes into view, and an element inside a
1433
+ // display:none container has nowhere to be scrolled to.
1434
+ setFindResultsOpen(false);
1435
+ if (revealCell(index)) return;
1436
+
1437
+ // No top cell places this one, so there's no branch to open down to it.
1438
+ // Said on the row that was clicked rather than as a message elsewhere --
1439
+ // which means bringing the list back to say it -- and the row goes inert so
1440
+ // it doesn't invite a second try.
1441
+ setFindResultsOpen(true);
1442
+ row.activate = null;
1443
+ row.element.classList.add("find-unreachable");
1444
+ row.element.title = `${hierarchyModel.cells[index].name}\n` +
1445
+ `No top cell places this one, so the tree has no branch that reaches it.`;
1446
+ }
1447
+
1448
+ // Labels live in wasm and never cross back (a full chip's worth of them is far
1449
+ // too much to hold twice), so the match itself happens there -- see findLabels
1450
+ // in renderer.cpp, which also reports how many matched beyond the ones it
1451
+ // returned.
1452
+ function runLabelSearch(query) {
1453
+ modulePromise.then((Module) => {
1454
+ const result = Module.findLabels(query, MAX_FIND_ROWS);
1455
+ // The box may have moved on while this was in flight.
1456
+ if (query !== findQuery || findScope !== "labels") return;
1457
+ renderLabelResults(query, result);
1458
+ });
1459
+ }
1460
+
1461
+ function renderLabelResults(query, result) {
1462
+ clearFindRows();
1463
+ const hits = result.hits || [];
1464
+ const total = result.total || 0;
1465
+ setFindCount(total === 0
1466
+ ? "no match"
1467
+ : `${hits.length} of ${total.toLocaleString()} label${total === 1 ? "" : "s"}`);
1468
+
1469
+ for (const hit of hits) {
1470
+ const tag = hit.name ? `${hit.layer}/${hit.datatype} ${hit.name}` : `${hit.layer}/${hit.datatype}`;
1471
+ // Hidden layers are searched too -- the label you're looking for is
1472
+ // often on one you turned off -- so the row says when that's the case
1473
+ // rather than sending you to a spot with nothing on it.
1474
+ const meta = hit.visible ? tag : `${tag} · hidden`;
1475
+ const title = [
1476
+ hit.text,
1477
+ `on layer ${tag}${hit.visible ? "" : " — currently hidden, but the label is still marked"}`,
1478
+ `at (${fmtCoord(hit.x)}, ${fmtCoord(hit.y)}) µm — click to pan there and mark it`
1479
+ ].join("\n");
1480
+ addFindRow(hit.text, meta, title, () => goToLabel(hit));
1481
+ }
1482
+ if (total > hits.length) {
1483
+ findNote(`… ${(total - hits.length).toLocaleString()} more — narrow the query`);
1484
+ }
1485
+ if (total === 0) findNote(`No label text contains “${query}”.`);
1486
+ }
1487
+
1488
+ // Pans to a label and marks it. The list stays up, unlike the cell case: each
1489
+ // row here is a candidate to look at, and stepping through them is a series of
1490
+ // camera moves, not a change of what the panel is showing.
1491
+ function goToLabel(hit) {
1492
+ modulePromise.then((Module) => {
1493
+ Module.goToPoint(hit.x, hit.y);
1494
+ // The layout's own labels are only drawn with the Text toggle on, and
1495
+ // it's off by default -- landing on a label and showing nothing is the
1496
+ // wrong end to a search, so finding one turns text on rather than
1497
+ // explaining why it isn't there. setValue (not the bare flag) so the
1498
+ // checkbox and wasm both follow.
1499
+ if (!actions.showText) textController.setValue(true);
1500
+ markLabelHit(Module, hit);
1501
+ });
1502
+ }
1503
+
1504
+ // The mark goes in the panel's highlight channel -- the same one a selected
1505
+ // cell's outlines use -- so Esc takes it down, hiding the panel takes it with
1506
+ // it, and pointing at one thing stops pointing at the other. The panel points
1507
+ // at one place at a time, so the tree's selection is dropped here rather than
1508
+ // left highlighted somewhere off screen.
1509
+ function markLabelHit(Module, hit) {
1510
+ const zoom = Module.getCamera().zoom;
1511
+ const half = LABEL_MARK_PX / (zoom > 0 ? zoom : 1);
1512
+ if (hierarchySelectedRow) hierarchySelectedRow.classList.remove("hier-selected");
1513
+ hierarchySelectedRow = null;
1514
+ hierarchySelectedPath = null;
1515
+ hierarchySelectedBoxes = [{
1516
+ minX: hit.x - half,
1517
+ minY: hit.y - half,
1518
+ maxX: hit.x + half,
1519
+ maxY: hit.y + half
1520
+ }];
1521
+ syncCellHighlight();
1522
+ }
1523
+
1524
+ function setFindScope(scope) {
1525
+ if (findScope === scope) return;
1526
+ findScope = scope;
1527
+ updateFindScope();
1528
+ runSearch();
1529
+ }
1530
+
1531
+ // Reflects the scope pair and the box's placeholder, including the case where
1532
+ // one side has nothing to search (see renderCellResults).
1533
+ function updateFindScope() {
1534
+ const cellsAvailable = !!(hierarchyModel && !hierarchyModel.omitted &&
1535
+ hierarchyModel.cells && hierarchyModel.cells.length > 0);
1536
+ if (hierarchyScopeCells) {
1537
+ hierarchyScopeCells.classList.toggle("scope-active", findScope === "cells");
1538
+ hierarchyScopeCells.setAttribute("aria-pressed", String(findScope === "cells"));
1539
+ hierarchyScopeCells.disabled = !cellsAvailable;
1540
+ hierarchyScopeCells.title = cellsAvailable
1541
+ ? "Search the design's cell names"
1542
+ : "This design's cell names aren't held in the viewer — see the note in the panel";
1543
+ }
1544
+ if (hierarchyScopeLabels) {
1545
+ hierarchyScopeLabels.classList.toggle("scope-active", findScope === "labels");
1546
+ hierarchyScopeLabels.setAttribute("aria-pressed", String(findScope === "labels"));
1547
+ }
1548
+ if (hierarchySearchInput) {
1549
+ hierarchySearchInput.placeholder = findScope === "labels" ? "label text" : "cell name";
1550
+ }
1551
+ }
1552
+
1553
+ // Called for every load (see renderHierarchy). `reset` empties the box; either
1554
+ // way whatever query is left is re-run, since both haystacks have just been
1555
+ // replaced by the file that was read. A design with no cell tree has nothing to
1556
+ // search by cell name, so the scope moves to the side that can still answer
1557
+ // rather than leaving a box that returns nothing whatever you type.
1558
+ function refreshFind(reset) {
1559
+ if (reset && hierarchySearchInput) hierarchySearchInput.value = "";
1560
+ if (hierarchyModel && hierarchyModel.omitted) findScope = "labels";
1561
+ updateFindScope();
1562
+ runSearch();
1563
+ }
1564
+
1565
+ if (hierarchySearchInput) {
1566
+ hierarchySearchInput.addEventListener("input", runSearch);
1567
+ // Choosing a cell puts the list away; clicking back into the box is how it
1568
+ // comes back, without retyping the query it still holds.
1569
+ hierarchySearchInput.addEventListener("focus", () => {
1570
+ if (findQuery) setFindResultsOpen(true);
1571
+ });
1572
+ hierarchySearchInput.addEventListener("keydown", (event) => {
1573
+ if (event.key === "Escape") {
1574
+ // Escape in a search box undoes the search, and only leaves the box
1575
+ // once there's no query left to undo. Stopped here either way, so it
1576
+ // never reaches the window handler's Escape -- which takes rulers
1577
+ // and outlines off the canvas, and has nothing to do with typing.
1578
+ if (hierarchySearchInput.value) {
1579
+ hierarchySearchInput.value = "";
1580
+ runSearch();
1581
+ } else {
1582
+ // Nothing left to undo: fold the box away, which is the state
1583
+ // it was found in.
1584
+ setFindOpen(false);
1585
+ }
1586
+ event.stopPropagation();
1587
+ } else if (event.key === "Enter") {
1588
+ activateFindRow(findActiveIndex < 0 ? 0 : findActiveIndex);
1589
+ } else if (event.key === "ArrowDown") {
1590
+ event.preventDefault();
1591
+ stepFindRow(1);
1592
+ } else if (event.key === "ArrowUp") {
1593
+ event.preventDefault();
1594
+ stepFindRow(-1);
1595
+ }
1596
+ });
1597
+ }
1598
+ if (hierarchyScopeCells) hierarchyScopeCells.addEventListener("click", () => setFindScope("cells"));
1599
+ if (hierarchyScopeLabels) hierarchyScopeLabels.addEventListener("click", () => setFindScope("labels"));
1600
+ if (hierarchyFindToggle) hierarchyFindToggle.addEventListener("click", () => setFindOpen(!findIsOpen()));
1601
+ // Once up front, so the pair reads correctly before the first load rather than
1602
+ // waiting for the refreshFind that comes with one.
1603
+ updateFindScope();
1604
+
1605
+ // The "/" key: unfolds the box and puts the cursor in it, opening the panel too
1606
+ // if that was away. Searching is the one thing in this panel reached for
1607
+ // mid-look with a name already in mind, so a fold that has to be found with the
1608
+ // mouse first would be a fold in the way -- this is what keeps it out of the
1609
+ // way instead.
1610
+ function focusFindBox() {
1611
+ if (!hierarchySearchInput || !hierarchyModel || !hierarchyModel.cellCount) return;
1612
+ if (hierarchyPanel && hierarchyPanel.classList.contains("hidden")) setHierarchyOpen(true, true);
1613
+ setFindOpen(true);
1614
+ hierarchySearchInput.select();
1615
+ }
1616
+
1617
+ // ---- Marker browser (DRC/LVS violation databases) ----
1618
+ // The parsed normalized model (see marker-parsers.js) is the JS-side source
1619
+ // of truth for the browser UI; wasm only holds the flattened geometry it
1620
+ // draws. Rebuilt from scratch on every marker load.
1621
+ let currentMarkers = null;
1622
+ let markersFolder = null;
1623
+ let selectedMarkerId = -1;
1624
+ let selectedMarkerRow = null; // the selected item's lil-gui row <div>, if it has one
1625
+ const markerItemRows = new Map(); // item id -> row <div> (only the uncapped rows)
1626
+
1627
+ // Browser-wide controls, kept outside the model so they survive re-renders
1628
+ // and marker-file swaps within a session. opacity scales the whole overlay's
1629
+ // alpha in wasm; hideEmpty filters clean categories (0 violations) out of
1630
+ // the panel (they draw nothing anyway).
1631
+ const markerUiState = { opacity: 1.0, hideEmpty: false };
1632
+
1633
+ // The GUI's DOM does not survive 100k rows -- cap the rows per category and
1634
+ // close with a disabled "… N more" row. Category visibility still covers
1635
+ // capped-off items (it lives in wasm per-category), and [ / ] key stepping
1636
+ // reaches them too.
1637
+ const MAX_MARKER_ROWS_PER_CATEGORY = 200;
1638
+
1639
+ function removeMarkerBrowser() {
1640
+ if (markersFolder) {
1641
+ markersFolder.destroy();
1642
+ markersFolder = null;
1643
+ }
1644
+ markerItemRows.clear();
1645
+ selectedMarkerRow = null;
1646
+ selectedMarkerId = -1;
1647
+ }
1648
+
1649
+ // Marks `item` selected (white emphasis in wasm + row highlight) and zooms
1650
+ // the view to its bbox. Geometry-less items (bbox null) just select.
1651
+ function selectMarker(Module, item) {
1652
+ if (selectedMarkerRow) selectedMarkerRow.classList.remove("marker-selected");
1653
+ selectedMarkerRow = markerItemRows.get(item.id) || null;
1654
+ if (selectedMarkerRow) selectedMarkerRow.classList.add("marker-selected");
1655
+ selectedMarkerId = item.id;
1656
+ Module.setSelectedMarker(item.id);
1657
+ if (item.bbox) {
1658
+ Module.zoomToBox(item.bbox.minX, item.bbox.minY, item.bbox.maxX, item.bbox.maxY);
1659
+ }
1660
+ }
1661
+
1662
+ // %.4g-ish coordinate for the item rows -- full precision belongs in the
1663
+ // tooltip, not a 260px panel.
1664
+ function fmtCoord(v) {
1665
+ return Number(v.toPrecision(4)).toString();
1666
+ }
1667
+
1668
+ function renderMarkerBrowser(model) {
1669
+ // Re-renders (e.g. the hide-empty toggle) keep the current selection;
1670
+ // fresh loads reset selectedMarkerId first (see the markersLoaded handler).
1671
+ const keepSelectedId = selectedMarkerId;
1672
+ removeMarkerBrowser();
1673
+ selectedMarkerId = keepSelectedId;
1674
+
1675
+ const totalItems = model.categories.reduce((n, c) => n + c.items.length, 0);
1676
+ markersFolder = gui.addFolder(`Markers (${totalItems})`);
1677
+ markersFolder.open();
1678
+
1679
+ if (model.warnings.length > 0) {
1680
+ fail("[GDS] marker warnings:", model.warnings.join(" | "));
1681
+ const row = markersFolder.add({ w: () => {} }, "w")
1682
+ .name(`⚠ ${model.warnings.length} warning${model.warnings.length === 1 ? "" : "s"}`);
1683
+ row.domElement.title = model.warnings.join("\n");
1684
+ }
1685
+
1686
+ const opacityController = markersFolder.add(markerUiState, "opacity", 0, 1, 0.05).name("Opacity")
1687
+ .onChange((value) => modulePromise.then((Module) => Module.setMarkerOpacity(value)));
1688
+ opacityController.domElement.title = "Opacity of the whole marker overlay";
1689
+
1690
+ const emptyCount = model.categories.filter((c) => c.items.length === 0).length;
1691
+ const hideEmptyController = markersFolder.add(markerUiState, "hideEmpty").name("Hide empty categories")
1692
+ .onChange(() => renderMarkerBrowser(model));
1693
+ hideEmptyController.domElement.title =
1694
+ `Hide categories with 0 violations (currently ${emptyCount} of ${model.categories.length})`;
1695
+
1696
+ model.categories.forEach((cat, categoryIndex) => {
1697
+ if (markerUiState.hideEmpty && cat.items.length === 0) return;
1698
+ const folder = markersFolder.addFolder(`${cat.name} (${cat.items.length})`);
1699
+ folder.close();
1700
+ if (cat.description) folder.domElement.title = cat.description;
1701
+
1702
+ // uiVisible (consulted by stepMarker so [ / ] skips hidden categories)
1703
+ // survives re-renders -- wasm keeps the real per-category visibility,
1704
+ // so the checkbox must not silently reset out of sync with it.
1705
+ // Categories start hidden (matching wasm's MarkerCategoryGL default):
1706
+ // the user opts in to the rulechecks they want drawn.
1707
+ if (cat.uiVisible === undefined) cat.uiVisible = false;
1708
+ const visState = { visible: cat.uiVisible };
1709
+ const visController = folder.add(visState, "visible").name("◼ visible")
1710
+ .onChange((visible) => {
1711
+ cat.uiVisible = visible;
1712
+ modulePromise.then((Module) => Module.setMarkerCategoryVisible(categoryIndex, visible));
1713
+ });
1714
+ visController.domElement.title = `Show/hide all ${cat.items.length} markers in ${cat.name}`;
1715
+
1716
+ for (const item of cat.items.slice(0, MAX_MARKER_ROWS_PER_CATEGORY)) {
1717
+ const label = item.bbox
1718
+ ? `#${item.label} (${fmtCoord((item.bbox.minX + item.bbox.maxX) / 2)}, ${fmtCoord((item.bbox.minY + item.bbox.maxY) / 2)})`
1719
+ : `#${item.label}`;
1720
+ const controller = folder.add({ go: () => modulePromise.then((Module) => selectMarker(Module, item)) }, "go")
1721
+ .name(label);
1722
+ controller.domElement.title = [item.note, cat.description].filter(Boolean).join("\n") || label;
1723
+ markerItemRows.set(item.id, controller.domElement);
1724
+ }
1725
+ if (cat.items.length > MAX_MARKER_ROWS_PER_CATEGORY) {
1726
+ const more = folder.add({ m: () => {} }, "m")
1727
+ .name(`… ${cat.items.length - MAX_MARKER_ROWS_PER_CATEGORY} more (press [ or ] to step)`);
1728
+ more.domElement.classList.add("marker-more-row");
1729
+ }
1730
+ });
1731
+
1732
+ // Restore the selected item's row highlight after a re-render.
1733
+ selectedMarkerRow = markerItemRows.get(selectedMarkerId) || null;
1734
+ if (selectedMarkerRow) selectedMarkerRow.classList.add("marker-selected");
1735
+ }
1736
+
1737
+ // The [ and ] keys step the selection backward/forward through every item
1738
+ // in checked categories (wrapping), including items past the per-category
1739
+ // row cap. With no category checked (the default state right after a load),
1740
+ // step through everything instead -- the selected marker draws regardless of
1741
+ // category visibility, so stepping is never a dead key.
1742
+ function stepMarker(direction) {
1743
+ if (!currentMarkers) return;
1744
+ let items = [];
1745
+ for (const cat of currentMarkers.categories) {
1746
+ if (cat.uiVisible === false) continue;
1747
+ items.push(...cat.items);
1748
+ }
1749
+ if (items.length === 0) {
1750
+ items = currentMarkers.categories.flatMap((cat) => cat.items);
1751
+ }
1752
+ if (items.length === 0) return;
1753
+ let idx = items.findIndex((it) => it.id === selectedMarkerId);
1754
+ idx = idx < 0 ? (direction > 0 ? 0 : items.length - 1) : (idx + direction + items.length) % items.length;
1755
+ modulePromise.then((Module) => selectMarker(Module, items[idx]));
1756
+ }
1757
+
1758
+ // ---- Right-click menu on the canvas ----
1759
+ // Right-clicking the layout offers the coordinate of the pixel that was
1760
+ // clicked. Right-click is where "what is this, exactly" lives in every other
1761
+ // tool, and it needs nothing to have been read beforehand -- unlike a shortcut,
1762
+ // which only helps someone who already knows it's there.
1763
+ //
1764
+ // VS Code shows its own menu over a webview, but its preload skips that when
1765
+ // the page has already called preventDefault ("Extension code has already
1766
+ // handled this event"), which is what makes a menu of our own possible at all.
1767
+ // It only covers the canvas: right-clicking the panels still gets VS Code's
1768
+ // menu, since a coordinate means nothing there.
1769
+ const copyToastEl = els.copyToast;
1770
+ let copyToastTimer = 0;
1771
+ function showCopyToast(text) {
1772
+ if (!copyToastEl) return;
1773
+ copyToastEl.textContent = text;
1774
+ copyToastEl.classList.remove("hidden");
1775
+ clearTimeout(copyToastTimer);
1776
+ copyToastTimer = setTimeout(() => copyToastEl.classList.add("hidden"), 1800);
1777
+ }
1778
+
1779
+ const canvasMenuEl = els.canvasMenu;
1780
+ const canvasMenuCopyEl = els.canvasMenuCopy;
1781
+ const canvasMenuValueEl = canvasMenuEl && canvasMenuEl.querySelector(".menu-value");
1782
+ // The text the open menu is offering, captured when it opened. Held here rather
1783
+ // than re-read on click because the click happens after the pointer has moved
1784
+ // off the spot -- onto the menu item -- and the coordinate has to stay the one
1785
+ // that was right-clicked.
1786
+ let canvasMenuText = "";
1787
+
1788
+ function hideCanvasMenu() {
1789
+ if (canvasMenuEl) canvasMenuEl.classList.add("hidden");
1790
+ }
1791
+
1792
+ function showCanvasMenu(clientX, clientY, text) {
1793
+ if (!canvasMenuEl) return;
1794
+ canvasMenuText = text;
1795
+ if (canvasMenuValueEl) canvasMenuValueEl.textContent = text;
1796
+ // Unhide first: the menu has no size to measure while it's display:none.
1797
+ canvasMenuEl.classList.remove("hidden");
1798
+ // Keep it on screen -- a right-click near the bottom or right edge would
1799
+ // otherwise open a menu that runs off it.
1800
+ const margin = 4;
1801
+ const maxLeft = window.innerWidth - canvasMenuEl.offsetWidth - margin;
1802
+ const maxTop = window.innerHeight - canvasMenuEl.offsetHeight - margin;
1803
+ canvasMenuEl.style.left = Math.max(margin, Math.min(clientX, maxLeft)) + "px";
1804
+ canvasMenuEl.style.top = Math.max(margin, Math.min(clientY, maxTop)) + "px";
1805
+ // Focus makes Enter and Escape work without a second reach for the mouse,
1806
+ // and lights the row the way hovering it does.
1807
+ if (canvasMenuCopyEl) canvasMenuCopyEl.focus();
1808
+ }
1809
+
1810
+ if (glCanvas && canvasMenuEl && canvasMenuCopyEl) {
1811
+ glCanvas.addEventListener("contextmenu", (event) => {
1812
+ // Nothing to offer until the renderer is up (there's no camera yet, so
1813
+ // no coordinate) -- leave the event alone and let VS Code's menu open,
1814
+ // rather than swallowing the click for a menu that can't answer.
1815
+ if (!resolvedModule) return;
1816
+ event.preventDefault();
1817
+ showCanvasMenu(event.clientX, event.clientY,
1818
+ resolvedModule.getCoordinateTextAt(event.clientX, event.clientY));
1819
+ });
1820
+
1821
+ canvasMenuCopyEl.addEventListener("click", () => {
1822
+ const text = canvasMenuText;
1823
+ hideCanvasMenu();
1824
+ navigator.clipboard.writeText(text).then(
1825
+ () => showCopyToast("Copied \u2014 " + text),
1826
+ (err) => {
1827
+ // Same failure the debug log's Copy button guards against -- the
1828
+ // Clipboard API can be blocked in a sandboxed webview. There's no
1829
+ // select-and-Ctrl-C fallback for a number that isn't on the page
1830
+ // as selectable text, so the toast has to carry it: it stays up
1831
+ // long enough to read and to retype.
1832
+ fail("[GDS] clipboard write failed for coordinate:", err);
1833
+ showCopyToast("Couldn't copy \u2014 " + text);
1834
+ }
1835
+ );
1836
+ });
1837
+
1838
+ // Everything that means "not that, then": a click anywhere else (capture
1839
+ // phase, so a click on the canvas closes the menu before the renderer acts
1840
+ // on it), zooming or panning the layout out from under it, and leaving the
1841
+ // webview. Escape is handled with the other keys below.
1842
+ window.addEventListener("pointerdown", (event) => {
1843
+ if (!canvasMenuEl.contains(event.target)) hideCanvasMenu();
1844
+ }, true);
1845
+ window.addEventListener("wheel", hideCanvasMenu, { passive: true });
1846
+ window.addEventListener("resize", hideCanvasMenu);
1847
+ window.addEventListener("blur", hideCanvasMenu);
1848
+ }
1849
+
1850
+ // Capture phase, because every lil-gui controller stopPropagation()s keydown
1851
+ // in the bubble phase -- a plain window listener would never hear [ / ]
1852
+ // while focus sits anywhere inside the panel, which is the normal state
1853
+ // after clicking any row (boolean rows are <label>s that focus their
1854
+ // checkbox; marker rows are <button>s that keep focus).
1855
+ window.addEventListener("keydown", (event) => {
1856
+ // Don't hijack typing in lil-gui's text/number inputs -- but focused
1857
+ // checkboxes and buttons must not block marker stepping.
1858
+ const t = event.target;
1859
+ const tag = t && t.tagName;
1860
+ if (tag === "TEXTAREA" || (tag === "INPUT" && t.type !== "checkbox")) return;
1861
+ if (event.key === "[") stepMarker(-1);
1862
+ else if (event.key === "]") stepMarker(1);
1863
+ // Mode switching from the keyboard, since measuring is a two-hand job
1864
+ // (click, click, then back to panning): M enters measure mode, Escape
1865
+ // always lands back in pan mode and drops the ruler.
1866
+ else if (event.key === "m" || event.key === "M") setMode(currentMode === "measure" ? "pan" : "measure");
1867
+ // Escape is the "put the canvas back" key: it takes down the things the
1868
+ // viewer draws on top of the layout at the user's request. For rulers that
1869
+ // is two steps rather than one (see escapeMeasure in renderer.cpp) -- the
1870
+ // first press abandons a measurement being placed, and only a press with
1871
+ // nothing left to abandon clears the finished ones and leaves the mode.
1872
+ // A finished measurement is an annotation, so it shouldn't disappear on the
1873
+ // same keystroke that backs out of a half-drawn one.
1874
+ else if (event.key === "Escape") {
1875
+ // The canvas menu first and alone: it's the most recent thing put on
1876
+ // screen, and Escape shouldn't also throw away a measurement behind it.
1877
+ if (canvasMenuEl && !canvasMenuEl.classList.contains("hidden")) {
1878
+ hideCanvasMenu();
1879
+ return;
1880
+ }
1881
+ modulePromise.then((Module) => {
1882
+ if (!Module.escapeMeasure()) setMode("pan");
1883
+ refreshRulerRow(Module);
1884
+ });
1885
+ hierarchyDeselect();
1886
+ }
1887
+ // H shows/hides the hierarchy tree -- it's the one panel that takes a
1888
+ // slice of the viewport, so getting it out of the way is worth a key.
1889
+ else if (event.key === "h" || event.key === "H") toggleHierarchy();
1890
+ // "/" jumps to the find box (opening the panel if it's away), the way it
1891
+ // does in a file tree. preventDefault so the slash itself doesn't land in
1892
+ // the box that just took focus.
1893
+ else if (event.key === "/") {
1894
+ event.preventDefault();
1895
+ focusFindBox();
1896
+ }
1897
+ }, true);
1898
+
1899
+ // ---- "Newer version on disk" banner ----
1900
+ // A host that watches the layout file calls viewer.showStale() when it changes
1901
+ // underneath us; this is only the UI for that. Clicking Reload calls the host's
1902
+ // requestReload(), asking it to re-read and re-send the file -- the viewer
1903
+ // never touches disk itself, and hides this banner entirely for a host that
1904
+ // implements neither (see hostCan in the host block above).
1905
+ const staleBanner = els.staleBanner;
1906
+ const staleText = els.staleText;
1907
+ const staleReloadBtn = els.staleReloadBtn;
1908
+ const staleAlwaysBtn = els.staleAlwaysBtn;
1909
+ const staleDismiss = els.staleDismiss;
1910
+
1911
+ function showStaleBanner(show, text) {
1912
+ if (!staleBanner) return;
1913
+ if (text) staleText.textContent = text;
1914
+ staleBanner.classList.toggle("hidden", !show);
1915
+ }
1916
+
1917
+ if (staleReloadBtn) {
1918
+ staleReloadBtn.addEventListener("click", () => {
1919
+ showStaleBanner(false);
1920
+ hostCall("requestReload");
1921
+ });
1922
+ }
1923
+ if (staleAlwaysBtn) {
1924
+ // "Reload, and stop asking": writes through to the GDS-Lens.autoReload
1925
+ // setting so the choice sticks across viewers and restarts. One-way by
1926
+ // design -- the banner only exists while auto-reload is off, so there's
1927
+ // nothing here to turn it back off with; that's the "GDSLens: Toggle
1928
+ // Auto-Reload on Change" command (and the Settings UI).
1929
+ staleAlwaysBtn.addEventListener("click", () => {
1930
+ showStaleBanner(false);
1931
+ hostCall("setAutoReload", true);
1932
+ hostCall("requestReload");
1933
+ });
1934
+ }
1935
+ if (staleDismiss) {
1936
+ staleDismiss.addEventListener("click", () => showStaleBanner(false));
1937
+ }
1938
+
1939
+ // State carried across a reload so re-reading the file doesn't reset the
1940
+ // user's working context. Captured just before the new geometry is uploaded
1941
+ // (uploadLayers re-frames the camera and rebuilds the layer table from the
1942
+ // new file), re-applied just after.
1943
+ function captureViewState(Module) {
1944
+ const layers = Module.getLayers();
1945
+ // Nothing drawn yet (reload triggered while the first load was still in
1946
+ // flight) -- there's no camera worth keeping, and restoring the default
1947
+ // zoom-1-at-origin would override the framing uploadLayers is about to do.
1948
+ if (layers.length === 0) return null;
1949
+ const visibility = {};
1950
+ for (const layer of layers) {
1951
+ visibility[`${layer.layer}/${layer.datatype}`] = layer.visible;
1952
+ }
1953
+ return { camera: Module.getCamera(), visibility: visibility };
1954
+ }
1955
+
1956
+ function restoreViewState(Module, saved) {
1957
+ if (!saved) return;
1958
+ // Only layers that existed before are restored -- ones the edit newly
1959
+ // introduced keep the fresh load's default so they aren't invisible for
1960
+ // no discoverable reason.
1961
+ for (const layer of Module.getLayers()) {
1962
+ const wasVisible = saved.visibility[`${layer.layer}/${layer.datatype}`];
1963
+ if (wasVisible !== undefined && wasVisible !== layer.visible) {
1964
+ Module.setLayerVisible(layer.layer, layer.datatype, wasVisible);
1965
+ }
1966
+ }
1967
+ Module.setCamera(saved.camera.zoom, saved.camera.panX, saved.camera.panY);
1968
+ }
1969
+
1970
+ // ---- Named views ----
1971
+ // A view is a camera plus which layers were on -- the two halves of "how I was
1972
+ // looking at this design" -- saved under a name and persisted by the host
1973
+ // (loadViews/saveViews on the ViewerHost; the default browser host puts them in
1974
+ // localStorage), so they're still there when the file is reopened days later.
1975
+ //
1976
+ // Deliberately not part of one: the render toggles (Infill / Text / Merge /
1977
+ // Grid). Those are a preference set once for how you like layouts drawn, not a
1978
+ // place in a design -- which is exactly the split the Display folder is built
1979
+ // around -- and a saved view that quietly flipped them back would undo a
1980
+ // setting the user didn't think they were saving.
1981
+ //
1982
+ // Restoring is the reload path's restore, reused as-is: keeping the camera and
1983
+ // the layer set across a re-read of the file is the same problem as putting
1984
+ // them back from a name, and captureViewState/restoreViewState already are it.
1985
+ const viewsFolder = gui.addFolder("Views");
1986
+ viewsFolder.close();
1987
+ // Nothing loaded yet has no view to save, so the folder isn't there to be
1988
+ // opened until the first layout is drawn (see the gdsResult handler).
1989
+ viewsFolder.hide();
1990
+
1991
+ // [{name, camera: {zoom, panX, panY}, visibility: {"1/0": true, ...}}], in the
1992
+ // order they were saved. The host holds the copy that outlives the session; this
1993
+ // is the working one.
1994
+ let namedViews = [];
1995
+ // The rows built for them, kept so a re-render can take exactly those down and
1996
+ // leave the Save row (which isn't one of them) alone.
1997
+ let viewControllers = [];
1998
+ // The state captured when the name was asked for. A view should be the view you
1999
+ // were looking at when you hit save, not wherever the camera ended up while the
2000
+ // input box was open.
2001
+ let pendingViewCapture = null;
2002
+
2003
+ const saveViewController = viewsFolder.add({ save: () => requestSaveView() }, "save")
2004
+ .name("Save Current View");
2005
+ saveViewController.domElement.title =
2006
+ "Name the current camera and layer visibility, and keep it with this layout";
2007
+
2008
+ function persistNamedViews() {
2009
+ hostCall("saveViews", namedViews);
2010
+ }
2011
+
2012
+ // The name is asked for by the extension host rather than in the page: a
2013
+ // webview has no prompt() to call, and the host's input box validates as you
2014
+ // type and looks like the rest of the editor.
2015
+ function requestSaveView() {
2016
+ modulePromise.then((Module) => {
2017
+ const captured = captureViewState(Module);
2018
+ // Null means nothing is drawn yet -- there's no camera worth naming.
2019
+ if (!captured) return;
2020
+ pendingViewCapture = captured;
2021
+ Promise.resolve(hostCall("promptViewName", namedViews.map((view) => view.name)))
2022
+ .then((name) => {
2023
+ if (name) saveNamedView(name);
2024
+ else pendingViewCapture = null;
2025
+ });
2026
+ });
2027
+ }
2028
+
2029
+ // Saves under the name the host came back with. A name already in the list
2030
+ // replaces that view in place rather than adding a second one under it: "save
2031
+ // as Overview again" means the overview moved.
2032
+ function saveNamedView(name) {
2033
+ const captured = pendingViewCapture;
2034
+ pendingViewCapture = null;
2035
+ if (!captured || !name) return;
2036
+ // The camera is copied field by field rather than passed along: what
2037
+ // getCamera() handed back crosses to the host and into stored state from
2038
+ // here, and this is the shape that has to keep working when it's read back
2039
+ // by a later version (see setNamedViews).
2040
+ const view = {
2041
+ name: name,
2042
+ camera: {
2043
+ zoom: captured.camera.zoom,
2044
+ panX: captured.camera.panX,
2045
+ panY: captured.camera.panY
2046
+ },
2047
+ visibility: captured.visibility
2048
+ };
2049
+ const at = namedViews.findIndex((existing) => existing.name.toLowerCase() === name.toLowerCase());
2050
+ if (at >= 0) namedViews[at] = view;
2051
+ else namedViews.push(view);
2052
+ renderNamedViews();
2053
+ persistNamedViews();
2054
+ }
2055
+
2056
+ function deleteNamedView(view) {
2057
+ namedViews = namedViews.filter((existing) => existing !== view);
2058
+ renderNamedViews();
2059
+ persistNamedViews();
2060
+ }
2061
+
2062
+ function restoreNamedView(view) {
2063
+ modulePromise.then((Module) => {
2064
+ // The solo snapshot describes the visibility set this is replacing, so
2065
+ // it would restore to a state that no longer exists.
2066
+ forgetSolo();
2067
+ restoreViewState(Module, view);
2068
+ // restoreViewState writes straight to wasm (it normally runs just
2069
+ // before the panel is rebuilt from scratch), so the checkboxes have to
2070
+ // be caught up by hand here -- rebuilding the whole layer list instead
2071
+ // would throw away the filter text and open folders with it.
2072
+ syncLayerRowsFromModule(Module);
2073
+ });
2074
+ }
2075
+
2076
+ // Rebuilds just the view rows under the Save row. Each is a full-width button
2077
+ // that restores the view, with an ✕ that deletes it -- the same shape as the
2078
+ // loaded-file chips in the Display folder.
2079
+ function renderNamedViews() {
2080
+ for (const controller of viewControllers) controller.destroy();
2081
+ viewControllers = [];
2082
+ viewsFolder.title(namedViews.length > 0 ? `Views (${namedViews.length})` : "Views");
2083
+
2084
+ for (const view of namedViews) {
2085
+ const controller = viewsFolder.add({ go: () => restoreNamedView(view) }, "go").name(view.name);
2086
+ controller.domElement.classList.add("view-row");
2087
+ controller.domElement.title =
2088
+ `${view.name} — click to put the camera and layer visibility back, ✕ to delete`;
2089
+ const remove = document.createElement("span");
2090
+ remove.className = "view-delete";
2091
+ remove.textContent = "✕";
2092
+ remove.title = `Delete "${view.name}"`;
2093
+ remove.addEventListener("click", (event) => {
2094
+ // The ✕ overlays the row's own <button> without being inside it, so
2095
+ // deleting a view can't also restore it on the way out.
2096
+ event.stopPropagation();
2097
+ deleteNamedView(view);
2098
+ });
2099
+ controller.domElement.appendChild(remove);
2100
+ viewControllers.push(controller);
2101
+ }
2102
+ }
2103
+
2104
+ // What the host sends back from storage on open. Filtered rather than trusted:
2105
+ // this is persisted state that a future version's shape could differ from, and
2106
+ // a malformed entry would otherwise build a row that throws on click.
2107
+ function setNamedViews(views) {
2108
+ namedViews = (Array.isArray(views) ? views : []).filter((view) =>
2109
+ view && typeof view.name === "string" && view.name.length > 0 &&
2110
+ view.camera && typeof view.camera.zoom === "number" &&
2111
+ typeof view.camera.panX === "number" && typeof view.camera.panY === "number");
2112
+ renderNamedViews();
2113
+ }
2114
+
2115
+ const loadingOverlay = els.loadingOverlay;
2116
+ const loadingBarFill = els.loadingBarFill;
2117
+ const loadingPhase = els.loadingPhase;
2118
+ const loadingPercent = els.loadingPercent;
2119
+ const reloadProgress = els.reloadProgress;
2120
+ const reloadBarFill = els.reloadBarFill;
2121
+ const reloadLabel = els.reloadLabel;
2122
+ const loadError = els.loadError;
2123
+
2124
+ // Every load-failure path ends here. Writing the DOM directly rather than
2125
+ // going through Module.showLoadError matters: the module itself may be the
2126
+ // thing that failed (instantiation rejected, or aborted on OOM mid-parse), in
2127
+ // which case `modulePromise.then(...)` never runs and the user would be left
2128
+ // staring at a stalled progress bar with no explanation. The wasm side is
2129
+ // then told separately, best-effort, so it can clear any half-loaded layers.
2130
+ function showFatalError(message) {
2131
+ fail("[GDS] load failed:", message);
2132
+ loadError.textContent = "Could not open this layout\n\n" + message;
2133
+ endProgress();
2134
+ // Nothing loaded, so there's no cell tree to browse -- and leaving the
2135
+ // previous file's one up beside the error would invite clicking rows that
2136
+ // frame geometry no longer on screen.
2137
+ renderHierarchy(null);
2138
+ modulePromise.then((Module) => {
2139
+ Module.showLoadError(message);
2140
+ renderLayerList(Module.getLayers());
2141
+ }).catch(() => {
2142
+ // Module is gone -- the DOM message above is all we can offer.
2143
+ });
2144
+ }
2145
+
2146
+ function clearFatalError() {
2147
+ loadError.textContent = "";
2148
+ }
2149
+
2150
+ // describeLoadFailure comes from load-errors.js (its own <script> tag).
2151
+
2152
+ const phaseLabels = {
2153
+ decompressing: "Decompressing layout...",
2154
+ parsing: "Parsing layout file...",
2155
+ flattening: "Flattening hierarchy...",
2156
+ triangulating: "Triangulating geometry..."
2157
+ };
2158
+
2159
+ // Which of the two progress UIs the current load is driving (see
2160
+ // beginProgress). Only one is on screen at a time, so updateProgress writes
2161
+ // whichever that is.
2162
+ let progressInline = false;
2163
+
2164
+ // inline: keep the viewport as it is and report progress in the top strip.
2165
+ // Otherwise take the screen with the full overlay. Reloads pass true only
2166
+ // when there's geometry already drawn to keep showing -- blanking the
2167
+ // viewport for a reload throws away the very view the reload restores.
2168
+ function beginProgress(inline) {
2169
+ progressInline = inline;
2170
+ loadingOverlay.classList.toggle("hidden", inline);
2171
+ reloadProgress.classList.toggle("hidden", !inline);
2172
+ updateProgress("parsing", 0, 1);
2173
+ }
2174
+
2175
+ function endProgress() {
2176
+ loadingOverlay.classList.add("hidden");
2177
+ reloadProgress.classList.add("hidden");
2178
+ }
2179
+
2180
+ function updateProgress(phase, current, total) {
2181
+ const label = phaseLabels[phase] || phase;
2182
+ const fraction = total > 0 ? current / total : 0;
2183
+ const percent = Math.round(fraction * 100);
2184
+ // Triangulation reports layers rather than a fraction of the file.
2185
+ const detail = phase === "triangulating" ? `Layer ${current}/${total}` : `${percent}%`;
2186
+ if (progressInline) {
2187
+ reloadBarFill.style.width = `${percent}%`;
2188
+ reloadLabel.textContent = `Reloading — ${label} ${detail}`;
2189
+ return;
2190
+ }
2191
+ loadingPhase.textContent = label;
2192
+ loadingBarFill.style.width = `${percent}%`;
2193
+ loadingPercent.textContent = detail;
2194
+ }
2195
+
2196
+ // Registered synchronously (not inside the .then() below) so an 'init'
2197
+ // message that arrives before wasm instantiation finishes isn't dropped --
2198
+ // window message events aren't queued for late listeners.
2199
+ trace("[GDS] resolving the wasm factory on the main thread...");
2200
+ // Synchronous handle on the same module modulePromise resolves to. The
2201
+ // 'init' handler needs to read the *current* camera/layer state before the
2202
+ // incoming parse replaces it, and a .then() would run too late for that.
2203
+ let resolvedModule = null;
2204
+ // The load currently in flight, so a reload can cancel it (see 'init').
2205
+ let activeWorker = null;
2206
+ // View state captured for the in-flight reload, re-applied once its geometry
2207
+ // is uploaded. Null on a first open.
2208
+ let pendingViewState = null;
2209
+ const modulePromise = loadGdstkFactory().then((createGdstkModule) => {
2210
+ if (typeof createGdstkModule !== "function") {
2211
+ throw new Error(
2212
+ "gds-lens-engine.js did not load: createGdstkModule is not defined. "
2213
+ + "In the served payload it is a classic <script> that must come "
2214
+ + "before gds-lens.js (see gds-lens.html).");
2215
+ }
2216
+ return createGdstkModule({
2217
+ // Read by dom_root() in renderer.cpp for its own element lookups.
2218
+ // Passed in the instantiation object so it is in place before main()
2219
+ // runs -- and re-pointed by adopt() if the viewer later moves.
2220
+ gdsLensRoot: viewerRoot,
2221
+ preRun: [(Module) => {
2222
+ // Emscripten resolves an event/context target by consulting
2223
+ // specialHTMLTargets before falling back to
2224
+ // document.querySelector, which cannot see into a shadow root.
2225
+ // Registering the canvas under the name renderer.cpp asks for is
2226
+ // what lets the GL context and every mouse handler bind to an
2227
+ // element the document cannot find. preRun is the last point
2228
+ // before main() creates that context.
2229
+ Module.specialHTMLTargets["!gdsLensCanvas"] =
2230
+ viewerRoot.getElementById("glCanvas");
2231
+ }]
2232
+ });
2233
+ });
2234
+ modulePromise.then(
2235
+ (Module) => {
2236
+ resolvedModule = Module;
2237
+ trace("[GDS] main-thread createGdstkModule() resolved OK");
2238
+ },
2239
+ (err) => {
2240
+ fail("[GDS] main-thread createGdstkModule() REJECTED:", err);
2241
+ // Nothing else will ever run if this fails, so this is the one place
2242
+ // the message has to come from -- showFatalError's own best-effort
2243
+ // call into the module simply no-ops on the same rejection.
2244
+ showFatalError(`WebAssembly module failed to load: ${describeLoadFailure(err)}`);
2245
+ }
2246
+ );
2247
+
2248
+ // ---- Theme ----
2249
+ // The chrome themes itself off a `theme-light` class on the host element in
2250
+ // CSS alone (see the token block in viewer.css). What's left for JS is the half
2251
+ // CSS can't reach: the canvas is drawn by renderer.cpp, which owns the
2252
+ // background it clears to, the ruler/selection ink, and the fallback layer
2253
+ // palette, all three of which assume a near-black background otherwise.
2254
+ //
2255
+ // Which theme is current is the embedder's to say, since an embedder usually
2256
+ // has a better answer than the OS does: a host implementing isLightTheme()
2257
+ // decides, and calls the viewer's applyTheme() when its answer changes.
2258
+ // Without one, the OS preference is the only signal there is.
2259
+ const lightMediaQuery = window.matchMedia("(prefers-color-scheme: light)");
2260
+
2261
+ function detectLightTheme() {
2262
+ if (hostCan("isLightTheme")) return !!host.isLightTheme();
2263
+ // A host may instead just set the class itself, which is cheaper than
2264
+ // implementing a method when it is already rewriting <body> anyway.
2265
+ if (rootEl.classList.contains("theme-light")) return true;
2266
+ return lightMediaQuery.matches;
2267
+ }
2268
+
2269
+ // Null until the first applyTheme(), so it can't match either decision and
2270
+ // the initial push to wasm always happens.
2271
+ let lightTheme = null;
2272
+
2273
+ function applyTheme() {
2274
+ const light = detectLightTheme();
2275
+ if (light === lightTheme) return;
2276
+ lightTheme = light;
2277
+ // Toggling this re-triggers the observer below, which then no-ops on the
2278
+ // early return above.
2279
+ rootEl.classList.toggle("theme-light", light);
2280
+ modulePromise.then((Module) => {
2281
+ Module.setTheme(light);
2282
+ // setTheme recolors the layers in place (the fallback palette is
2283
+ // theme-dependent), so the panel's per-row color chips are stale.
2284
+ // Only worth rebuilding once there's a layer list to rebuild -- before
2285
+ // the first load renderLayerList would add an empty "Layers" folder.
2286
+ const layers = Module.getLayers();
2287
+ if (layers.length > 0) renderLayerList(layers);
2288
+ });
2289
+ }
2290
+
2291
+ // A host that signals a theme switch by rewriting <body>'s class list rather
2292
+ // than calling applyTheme() gets picked up here. (This also fires for the
2293
+ // debug command's own class toggle, which applyTheme ignores.)
2294
+ new MutationObserver(applyTheme).observe(rootEl, { attributes: true, attributeFilter: ["class"] });
2295
+ lightMediaQuery.addEventListener("change", applyTheme);
2296
+ applyTheme();
2297
+
2298
+ // The parse Worker runs its own copy of the wasm module (see wasm-worker.js).
2299
+ // How its script is assembled is the host's business, because it is exactly
2300
+ // where embedders differ: an ordinary page can fetch the scripts by URL, while
2301
+ // a sandboxed webview often cannot reach its own asset URLs from inside a
2302
+ // Worker and has to inline them instead.
2303
+ function createParseWorker() {
2304
+ // A host that cannot serve URLs to a Worker supplies the script itself.
2305
+ if (hostCan("createWorker")) return host.createWorker();
2306
+
2307
+ // The bundled ESM build carries the Worker's whole script as text, because
2308
+ // a bundled module has no siblings to fetch. See engine-source.esm.js.
2309
+ if (workerBundle) {
2310
+ trace("[GDS] building worker from the bundled script");
2311
+ const blob = new Blob([workerBundle.text()], { type: "text/javascript" });
2312
+ return new Worker(URL.createObjectURL(blob), { type: workerBundle.type });
2313
+ }
2314
+
2315
+ // Ordinary page: pull the two scripts in by URL. They have to be absolute
2316
+ // -- importScripts() inside a blob Worker resolves relative URLs against
2317
+ // the blob: URL rather than against the document, so bare filenames here
2318
+ // would silently fail to load.
2319
+ const url = (name) => JSON.stringify(new URL(name, document.baseURI).href);
2320
+ // Same reason, one level deeper: in the default build the .wasm is a
2321
+ // separate file, and Emscripten locates it relative to its own script
2322
+ // URL -- which inside a blob Worker is the blob:, not the directory the
2323
+ // scripts actually came from. Handing the worker the real base is what
2324
+ // lets it build a locateFile(); see wasm-worker.js. Harmless for the
2325
+ // inline-wasm build, which never looks a binary up.
2326
+ const bootstrap =
2327
+ `self.gdsLensScriptBase = ${url(".")};\n` +
2328
+ `importScripts(${url("gds-lens-engine.js")}, ${url("gds-lens-worker.js")});`;
2329
+ trace("[GDS] building worker from document-relative script URLs");
2330
+ return new Worker(URL.createObjectURL(new Blob([bootstrap], { type: "application/javascript" })));
2331
+ }
2332
+
2333
+ // ---- Moving the viewer to a new host element ----
2334
+ // This module's body runs once and cannot be re-run: it holds the GL context,
2335
+ // the wasm module and lil-gui's bindings. So when the element is removed and a
2336
+ // different one appears -- a framework re-render, an SPA route change -- the
2337
+ // answer is to move the viewer into the new element rather than to stand up a
2338
+ // second engine, which is impossible anyway.
2339
+ //
2340
+ // Three things make the move cheap. A <canvas> keeps its WebGL context when it
2341
+ // moves in the DOM. Listeners live on nodes, so everything bound inside the
2342
+ // shadow tree comes along. And renderer.cpp reads its DOM root from
2343
+ // Module.gdsLensRoot on every lookup rather than caching it (see dom_root), so
2344
+ // re-pointing that property is all the C++ side needs.
2345
+ const adoptCallbacks = new Set();
2346
+
2347
+ function adopt(element) {
2348
+ if (!element || element === hostElement) return;
2349
+ const next = element.shadowRoot || element.attachShadow({ mode: "open" });
2350
+
2351
+ // The state classes belong to the viewer, not to whichever element is
2352
+ // hosting it: viewer.css selects on them (theme-light, debug,
2353
+ // hierarchy-open) and :host() lets a page theme through them.
2354
+ for (const name of hostElement.classList) element.classList.add(name);
2355
+
2356
+ // Moved as nodes. Re-serializing through innerHTML would build fresh
2357
+ // elements and lose the GL context, every listener and lil-gui's bindings
2358
+ // -- the whole point of moving rather than rebuilding.
2359
+ while (shadow.firstChild) next.appendChild(shadow.firstChild);
2360
+
2361
+ hostElement = element;
2362
+ rootEl = element;
2363
+ shadow = next;
2364
+ viewerRoot = next;
2365
+
2366
+ if (resolvedModule) resolvedModule.gdsLensRoot = next;
2367
+ else modulePromise.then((Module) => { Module.gdsLensRoot = next; }).catch(() => {});
2368
+
2369
+ // Hosts bind to the element they were given (the default one puts
2370
+ // drag-and-drop there), and that element is now detached. Tell them.
2371
+ for (const callback of adoptCallbacks) {
2372
+ try {
2373
+ callback(element);
2374
+ } catch (err) {
2375
+ fail("[GDS] a host's onAdopt callback threw:", err);
2376
+ }
2377
+ }
2378
+ trace("[GDS] moved the viewer into a new <gds-lens>");
2379
+ }
2380
+
2381
+ // ---- The surface a host drives the viewer through ----
2382
+ // Each of these was a branch of a postMessage handler. They are plain
2383
+ // functions now, so the transport (VS Code's RPC, a page calling them
2384
+ // directly, a test) is the host's business rather than this file's.
2385
+
2386
+ // What a compressed layout is allowed to expand to. The parse has to fit the
2387
+ // expanded file *and* the geometry built from it into one 32-bit address space
2388
+ // (see "Limits" in README.md), so a cap well inside 4 GB fails a hopeless load
2389
+ // at the cheap step instead of after a minute of parsing.
2390
+ const MAX_LAYOUT_BYTES = 2 * 1024 * 1024 * 1024;
2391
+
2392
+ // Bytes arrive in whichever shape the caller happened to have. Normalized here
2393
+ // rather than at each entry point, because gzip is sniffed by looking at the
2394
+ // first two bytes and an ArrayBuffer has no [0] to look at -- a compressed
2395
+ // file handed over as one would sail past undetected and reach the parser
2396
+ // still gzipped.
2397
+ function asBytes(source) {
2398
+ if (source instanceof Uint8Array) return source;
2399
+ if (source instanceof ArrayBuffer) return new Uint8Array(source);
2400
+ if (source && source.buffer instanceof ArrayBuffer) {
2401
+ return new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
2402
+ }
2403
+ return source;
2404
+ }
2405
+
2406
+ async function loadLayout(source, { reload = false } = {}) {
2407
+ const bytes = asBytes(source);
2408
+ trace("[GDS] init payload: fileData byteLength =", bytes && bytes.byteLength,
2409
+ "reload:", !!reload);
2410
+ // A reload supersedes any load still running (the file can change
2411
+ // again while a slow one is in flight) -- drop the old worker rather
2412
+ // than letting two of them race to upload geometry.
2413
+ if (activeWorker) {
2414
+ trace("[GDS] superseding an in-flight load");
2415
+ activeWorker.terminate();
2416
+ activeWorker = null;
2417
+ }
2418
+ showStaleBanner(false);
2419
+
2420
+ // Only meaningful on a reload: on first open there's nothing to keep,
2421
+ // and framing the view on the design is exactly what we want.
2422
+ // Captured synchronously off resolvedModule rather than through
2423
+ // modulePromise: the geometry has to be read *before* the new parse
2424
+ // lands, and a .then() would run after this handler returns.
2425
+ pendingViewState = null;
2426
+ if (reload && resolvedModule) {
2427
+ try {
2428
+ pendingViewState = captureViewState(resolvedModule);
2429
+ } catch (err) {
2430
+ // Nothing loaded yet, or the module is wedged -- reload as if
2431
+ // it were a first open (framed on the design) rather than
2432
+ // failing the reload outright.
2433
+ fail("[GDS] could not capture view state, reloading framed:", err);
2434
+ }
2435
+ }
2436
+
2437
+ // Captured state doubles as the test for "is there a view worth
2438
+ // keeping on screen": it's null exactly when nothing is drawn yet, and
2439
+ // an empty viewport behind a hairline bar reads as a hung viewer.
2440
+ beginProgress(pendingViewState !== null);
2441
+
2442
+ // Gzip comes off here rather than inside the wasm module. Detection is by
2443
+ // magic number, not by filename, so a ".gds" that is secretly gzipped
2444
+ // opens too -- which is how these arrive out of some flows. Doing it on
2445
+ // this side also keeps a second full copy of the file out of the 32-bit
2446
+ // heap, which is the one address space that can least afford it.
2447
+ let parseBytes = bytes;
2448
+ if (looksGzipped(bytes)) {
2449
+ updateProgress("decompressing", 0, 0);
2450
+ const decoded = await decodeLayoutBytes(bytes, MAX_LAYOUT_BYTES);
2451
+ if (!decoded.ok) {
2452
+ fail(`[GDS] gzip expansion failed (${decoded.reason}):`, decoded.detail);
2453
+ showFatalError(describeDecodeFailure(decoded));
2454
+ return;
2455
+ }
2456
+ trace("[GDS] expanded gzip:", bytes.byteLength, "->", decoded.bytes.byteLength, "bytes");
2457
+ parseBytes = decoded.bytes;
2458
+ }
2459
+
2460
+ let worker;
2461
+ try {
2462
+ worker = createParseWorker();
2463
+ trace("[GDS] new Worker() constructor returned OK");
2464
+ } catch (err) {
2465
+ fail("[GDS] failed to build/start worker:", err);
2466
+ showFatalError(`Failed to create worker: ${err.message || err}`);
2467
+ return;
2468
+ }
2469
+ startWorker(worker, parseBytes);
2470
+ }
2471
+
2472
+ function applyLyp(name, text) {
2473
+ modulePromise.then((Module) => {
2474
+ Module.loadLypText(text);
2475
+ renderLayerList(Module.getLayers());
2476
+ });
2477
+ setLypChip(name || null);
2478
+ }
2479
+
2480
+ function applyMarkers(name, text) {
2481
+ modulePromise.then((Module) => {
2482
+ let model;
2483
+ try {
2484
+ // Format sniffed by content (lyrdb XML vs ASCII DRC) --
2485
+ // see marker-parsers.js, loaded via its own <script> tag.
2486
+ model = parseMarkerFile(text, DOMParser);
2487
+ } catch (err) {
2488
+ fail("[GDS] marker parse failed:", err);
2489
+ removeMarkerBrowser();
2490
+ currentMarkers = null;
2491
+ Module.clearMarkers();
2492
+ setMarkerChip(name || null);
2493
+ markerController.domElement.title = `Failed to parse ${name}: ${err.message || err}`;
2494
+ return;
2495
+ }
2496
+ currentMarkers = model;
2497
+ Module.setMarkers(flattenMarkerModel(model));
2498
+ // The slider state outlives marker swaps; wasm resets selection
2499
+ // on setMarkers but keeps opacity, so re-assert both explicitly.
2500
+ Module.setMarkerOpacity(markerUiState.opacity);
2501
+ selectedMarkerId = -1;
2502
+ renderMarkerBrowser(model);
2503
+ setMarkerChip(name || null);
2504
+ });
2505
+ }
2506
+
2507
+ function goToPointFromHost(x, y) {
2508
+ // "GDSLens: Go to Coordinate". The host has already read the typed text
2509
+ // into a µm pair (see coord-parse.js), so all that's left here is the
2510
+ // pan -- the zoom is deliberately untouched, since a pasted coordinate
2511
+ // doesn't say how much around it you want to see (see goToPoint in
2512
+ // renderer.cpp). Only this side knows whether the point is inside the
2513
+ // layout, so the answer goes back for the host to report -- and is also
2514
+ // returned, so a caller driving the viewer directly does not have to
2515
+ // implement onGotoResult just to learn whether it landed.
2516
+ return modulePromise.then((Module) => {
2517
+ const onScreen = Module.goToPoint(x, y);
2518
+ // A crosshair on the coordinate itself, which fades out after a
2519
+ // couple of seconds (see draw_goto_flash in renderer.cpp). Panning
2520
+ // alone leaves you looking at a screen of layout with nothing
2521
+ // saying which part of it is the coordinate you pasted -- and when
2522
+ // clamp_pan has to hold the camera inside the design, it isn't even
2523
+ // the middle of the screen.
2524
+ Module.flashPoint(x, y);
2525
+ hostCall("onGotoResult", { ok: !!onScreen, x, y });
2526
+ return !!onScreen;
2527
+ });
2528
+ }
2529
+
2530
+ function toggleDebug() {
2531
+ // "Toggle Debug Tools" -- show/hide the debug entry point (the button
2532
+ // that opens #debugPanel, which holds both the engine readout and the
2533
+ // log), hidden by default, see viewer-shell.html.
2534
+ rootEl.classList.toggle("debug");
2535
+ }
2536
+
2537
+ const viewer = {
2538
+ // The element the viewer is mounted in. A host needs it to scope anything
2539
+ // it binds to the viewer's own surface -- drag-and-drop above all, which
2540
+ // on `window` would preventDefault every drag in the embedding page and
2541
+ // quietly break the host's own drop targets.
2542
+ get element() {
2543
+ return hostElement;
2544
+ },
2545
+ // Called with the new element when the viewer moves (see adopt). Returns a
2546
+ // function that unregisters. A host that binds anything to `element` needs
2547
+ // this, or its listeners stay on an element that is no longer in the page.
2548
+ onAdopt(callback) {
2549
+ adoptCallbacks.add(callback);
2550
+ return () => adoptCallbacks.delete(callback);
2551
+ },
2552
+ load: loadLayout,
2553
+ showError: showFatalError,
2554
+ setLyp: applyLyp,
2555
+ setMarkers: applyMarkers,
2556
+ showStale: (text) => showStaleBanner(true, text),
2557
+ goToPoint: goToPointFromHost,
2558
+ toggleDebug,
2559
+ // For a host whose theme can change after load, to re-ask isLightTheme().
2560
+ applyTheme,
2561
+ // For a host whose stored views can change after open (another editor on
2562
+ // the same layout saving one, say) rather than only being read once.
2563
+ setNamedViews
2564
+ };
2565
+
2566
+ // Controls whose host service is missing have nothing behind them, so they
2567
+ // are removed rather than left to do nothing when clicked. lil-gui prefixes
2568
+ // its class names (.lil-controller, not .controller), and an optional-chained
2569
+ // remove() on a selector that matches nothing fails silently, so getting this
2570
+ // wrong leaves dead controls rather than an error.
2571
+ const controllerRow = (controller) => controller.domElement.closest(".lil-controller");
2572
+ if (!hostCan("pickLyp")) controllerRow(lypController)?.remove();
2573
+ if (!hostCan("pickMarkers")) controllerRow(markerController)?.remove();
2574
+ if (!hostCan("saveViews") && !hostCan("promptViewName")) {
2575
+ controllerRow(saveViewController)?.remove();
2576
+ }
2577
+
2578
+ Promise.resolve(hostCall("loadViews")).then((views) => {
2579
+ if (views) setNamedViews(views);
2580
+ });
2581
+
2582
+ hostCall("connect", viewer);
2583
+
2584
+ export { viewer, adopt };
2585
+
2586
+ function startWorker(worker, fileData) {
2587
+ activeWorker = worker;
2588
+ // Only fires for the Worker failing to start at all (e.g. its script
2589
+ // URL rejected by CSP) -- failures inside the worker's own async code
2590
+ // are reported via a 'gdsResult' message instead (see wasm-worker.js),
2591
+ // since a Worker's unhandled promise rejections don't reach this
2592
+ // handler.
2593
+ worker.onerror = (err) => {
2594
+ fail("[GDS] worker.onerror fired:", err.message, "at", err.filename + ":" + err.lineno + ":" + err.colno, err.error);
2595
+ showFatalError(`Worker failed to start: ${err.message || err}`);
2596
+ };
2597
+ worker.onmessageerror = (err) => {
2598
+ fail("[GDS] worker.onmessageerror fired (structured-clone failure):", err);
2599
+ showFatalError("Worker message failed to deserialize -- see devtools console");
2600
+ };
2601
+ worker.onmessage = (workerEvent) => {
2602
+ const workerMessage = workerEvent.data;
2603
+ if (workerMessage.type === "gdsLog") {
2604
+ // Relayed from wasm-worker.js's console.log/error patch --
2605
+ // the worker has no DOM to render its own debug panel into.
2606
+ appendDebugLine("[worker] " + workerMessage.text, workerMessage.level === "error");
2607
+ return;
2608
+ }
2609
+ // Deliberately logging the type and not the whole workerMessage: the
2610
+ // 'gdsResult' message carries the entire parsed geometry (every
2611
+ // layer's outline/fill vertex arrays), and trace() JSON.stringifies
2612
+ // whatever it is given into #debugLog -- serializing and
2613
+ // DOM-inserting the whole design on every load was the dominant cost
2614
+ // of moving parsing into a Worker at all, swamping whatever the
2615
+ // off-main-thread parse saved.
2616
+ trace("[GDS] main thread received worker message:", workerMessage.type);
2617
+ if (workerMessage.type === "gdsProgress") {
2618
+ updateProgress(workerMessage.phase, workerMessage.current, workerMessage.total);
2619
+ } else if (workerMessage.type === "gdsResult") {
2620
+ // Free the worker's copy of the geometry before uploading ours:
2621
+ // on a big design both threads holding it at once is what tips a
2622
+ // borderline load over the edge.
2623
+ worker.terminate();
2624
+ if (activeWorker === worker) activeWorker = null;
2625
+ if (!workerMessage.ok) {
2626
+ showFatalError(workerMessage.error);
2627
+ return;
2628
+ }
2629
+ trace("[GDS] load succeeded, layer count:", workerMessage.layers.length);
2630
+ modulePromise.then((Module) => {
2631
+ // uploadLayers is the other place a big layout can run out of
2632
+ // memory -- the parse fit in the worker, but this thread's
2633
+ // module now has to hold the same geometry plus its VBOs. An
2634
+ // unhandled throw here would leave the progress bar spinning
2635
+ // forever, so surface it like any other load failure.
2636
+ try {
2637
+ Module.uploadLayers(workerMessage.layers, workerMessage.instanceGroups, workerMessage.bbox);
2638
+ } catch (err) {
2639
+ showFatalError(describeLoadFailure(err));
2640
+ return;
2641
+ }
2642
+ clearFatalError();
2643
+ // Put the camera and per-layer visibility back before the
2644
+ // panel is rebuilt, so renderLayerList reflects the restored
2645
+ // checkboxes rather than the fresh load's defaults.
2646
+ if (pendingViewState) {
2647
+ try {
2648
+ restoreViewState(Module, pendingViewState);
2649
+ } catch (err) {
2650
+ fail("[GDS] could not restore view state:", err);
2651
+ }
2652
+ pendingViewState = null;
2653
+ }
2654
+ renderLayerList(Module.getLayers());
2655
+ renderHierarchy(workerMessage.hierarchy);
2656
+ // There's a view to save from now on (see viewsFolder.hide()).
2657
+ viewsFolder.show();
2658
+ // uploadLayers drops the rulers -- they were anchored to the
2659
+ // geometry this load just replaced.
2660
+ refreshRulerRow(Module);
2661
+ endProgress();
2662
+ trace("[GDS] done, progress hidden");
2663
+ }, (err) => {
2664
+ showFatalError(`WebAssembly module failed to load: ${err && err.message ? err.message : err}`);
2665
+ });
2666
+ }
2667
+ };
2668
+ trace("[GDS] posting 'parse' message to worker...");
2669
+ // A host may hand in either an ArrayBuffer or a typed-array view over
2670
+ // one, and the transfer list accepts only the buffer itself. Normalize
2671
+ // here rather than making every host care, but do not transfer a buffer
2672
+ // we only partially own: a view with an offset, or shorter than its
2673
+ // buffer, would hand the worker neighbouring bytes as though they were
2674
+ // part of the layout, so that case is copied out first.
2675
+ let transfer;
2676
+ if (fileData instanceof ArrayBuffer) {
2677
+ transfer = fileData;
2678
+ } else if (fileData.byteOffset === 0 && fileData.byteLength === fileData.buffer.byteLength) {
2679
+ transfer = fileData.buffer;
2680
+ } else {
2681
+ transfer = fileData.slice().buffer;
2682
+ }
2683
+ worker.postMessage(
2684
+ { type: "parse", fileData: transfer },
2685
+ [transfer]
2686
+ );
2687
+ trace("[GDS] worker.postMessage('parse') call returned");
2688
+ }