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/CHANGELOG.md +167 -0
- package/LICENCE.md +21 -0
- package/README.md +372 -0
- package/THIRD-PARTY-LICENSES.md +261 -0
- package/dist/esm/gds-lens.js +4731 -0
- package/dist/inline-wasm/gds-lens-engine.js +0 -0
- package/dist/inline-wasm/gds-lens-host.js +82 -0
- package/dist/inline-wasm/gds-lens-worker.js +118 -0
- package/dist/inline-wasm/gds-lens.html +29 -0
- package/dist/inline-wasm/gds-lens.js +4705 -0
- package/dist/web/gds-lens-engine.js +2 -0
- package/dist/web/gds-lens-engine.wasm +0 -0
- package/dist/web/gds-lens-host.js +82 -0
- package/dist/web/gds-lens-worker.js +118 -0
- package/dist/web/gds-lens.html +29 -0
- package/dist/web/gds-lens.js +4705 -0
- package/package.json +113 -0
- package/src/cell-search.js +88 -0
- package/src/coord-parse.js +50 -0
- package/src/engine-source.esm.js +49 -0
- package/src/engine-source.js +20 -0
- package/src/gds-lens.js +175 -0
- package/src/hosts/browser.js +156 -0
- package/src/layout-bytes.js +143 -0
- package/src/load-errors.js +64 -0
- package/src/marker-parsers.js +672 -0
- package/src/mount-target.js +23 -0
- package/src/viewer-shell.html +105 -0
- package/src/viewer.css +469 -0
- package/src/viewer.html +29 -0
- package/src/viewer.js +2688 -0
- package/src/wasm-worker.js +157 -0
- package/types/cell-search.d.ts +33 -0
- package/types/coord-parse.d.ts +11 -0
- package/types/gds-lens.d.ts +135 -0
- package/types/hosts-browser.d.ts +13 -0
- package/types/layout-bytes.d.ts +49 -0
- package/types/load-errors.d.ts +27 -0
- package/types/parsers.d.ts +84 -0
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
|
+
}
|