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
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
// Parsers for DRC/LVS marker databases: .lyrdb report databases (.lyrdb,
|
|
2
|
+
// XML) and ASCII DRC results databases (line-oriented text).
|
|
3
|
+
//
|
|
4
|
+
// No imports, no DOM and no wasm, so this runs wherever it is put: the
|
|
5
|
+
// viewer, a Worker, or a Node test. parseLyrdb takes the DOMParser
|
|
6
|
+
// *constructor* as an argument rather than importing one, which is what keeps
|
|
7
|
+
// it environment-free -- the viewer passes the browser global, tests pass
|
|
8
|
+
// @xmldom/xmldom's. It is also published on its own as `gds-lens/parsers`.
|
|
9
|
+
//
|
|
10
|
+
// Both parsers emit the same normalized model. All coordinates are in µm,
|
|
11
|
+
// y-up world space -- the same space renderer.cpp draws in (integer
|
|
12
|
+
// coordinates are divided by the header's precision here, at parse time):
|
|
13
|
+
//
|
|
14
|
+
// {
|
|
15
|
+
// topCell: "TOP", // "" if unknown
|
|
16
|
+
// warnings: ["...", ...],
|
|
17
|
+
// categories: [{
|
|
18
|
+
// name, // full path, '.'-joined for lyrdb nesting
|
|
19
|
+
// description,
|
|
20
|
+
// items: [{
|
|
21
|
+
// id, // global, unique, == index in category-major order
|
|
22
|
+
// label, // short label for the list row
|
|
23
|
+
// note, // non-geometry values, multiplicity, cell ref
|
|
24
|
+
// polygons: [Float64Array(x,y,...), ...], // one array per ring
|
|
25
|
+
// edges: Float64Array(x0,y0,x1,y1,...), // packed segments
|
|
26
|
+
// bbox: {minX,minY,maxX,maxY} | null, // null = no geometry
|
|
27
|
+
// waived: false, // true = WE<n> waiver
|
|
28
|
+
// }],
|
|
29
|
+
// }],
|
|
30
|
+
// }
|
|
31
|
+
//
|
|
32
|
+
// Items with no geometry (float/text values) keep the raw value in `note`,
|
|
33
|
+
// have bbox === null, and draw nothing.
|
|
34
|
+
|
|
35
|
+
"use strict";
|
|
36
|
+
|
|
37
|
+
// Decides the format by content, not extension (marker files are named all
|
|
38
|
+
// sorts of things): a .lyrdb is XML with a <report-database> root; an ASCII DRC
|
|
39
|
+
// ASCII results database starts with a "<top-cell-name> <resolution>" header
|
|
40
|
+
// line, optionally preceded by '//' comment lines. Returns 'lyrdb' | 'drc'
|
|
41
|
+
// | null (unrecognized).
|
|
42
|
+
function sniffMarkerFormat(text) {
|
|
43
|
+
if (typeof text !== "string" || text.length === 0) return null;
|
|
44
|
+
let t = text;
|
|
45
|
+
if (t.charCodeAt(0) === 0xfeff) t = t.slice(1); // UTF-8 BOM survives decoding as U+FEFF
|
|
46
|
+
t = t.replace(/^\s+/, "");
|
|
47
|
+
if (t.startsWith("<")) {
|
|
48
|
+
return t.slice(0, 2048).includes("<report-database") ? "lyrdb" : null;
|
|
49
|
+
}
|
|
50
|
+
// "<top cell> <resolution>", then a check name, then that check's counts
|
|
51
|
+
// line -- three lines is what it takes to tell a results database from a
|
|
52
|
+
// text file whose first line happens to be a word and a number.
|
|
53
|
+
const head = [];
|
|
54
|
+
for (const raw of t.slice(0, 8192).split("\n")) {
|
|
55
|
+
const line = raw.replace(/\r$/, "").trim();
|
|
56
|
+
if (line === "" || line.startsWith("//")) continue;
|
|
57
|
+
head.push(line);
|
|
58
|
+
if (head.length === 3) break;
|
|
59
|
+
}
|
|
60
|
+
const header = /^(\S+)\s+(\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)$/.exec(head[0] || "");
|
|
61
|
+
if (!header) return null;
|
|
62
|
+
// Resolution is database units per µm; the format's usual sanity range.
|
|
63
|
+
const resolution = parseFloat(header[2]);
|
|
64
|
+
if (!(resolution >= 0.001 && resolution <= 1e6)) return null;
|
|
65
|
+
// A file that stops after the header is an empty (clean) database.
|
|
66
|
+
if (head.length < 3) return "drc";
|
|
67
|
+
return /^\d+\s+\d+\s+\d+(\s|$)/.test(head[2]) ? "drc" : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// "(0,0;1.5,0;1.5,0.2)" (parens optional, whitespace/newlines tolerated) ->
|
|
71
|
+
// Float64Array [x,y,x,y,...]. Throws on malformed points.
|
|
72
|
+
function parsePointList(text) {
|
|
73
|
+
const cleaned = text.trim().replace(/^\(/, "").replace(/\)$/, "").trim();
|
|
74
|
+
if (cleaned === "") return new Float64Array(0);
|
|
75
|
+
const parts = cleaned.split(";");
|
|
76
|
+
const pts = new Float64Array(parts.length * 2);
|
|
77
|
+
for (let i = 0; i < parts.length; i++) {
|
|
78
|
+
const xy = parts[i].split(",");
|
|
79
|
+
if (xy.length !== 2) throw new Error(`bad point "${parts[i].trim()}"`);
|
|
80
|
+
const x = parseFloat(xy[0]);
|
|
81
|
+
const y = parseFloat(xy[1]);
|
|
82
|
+
if (!isFinite(x) || !isFinite(y)) throw new Error(`bad point "${parts[i].trim()}"`);
|
|
83
|
+
pts[i * 2] = x;
|
|
84
|
+
pts[i * 2 + 1] = y;
|
|
85
|
+
}
|
|
86
|
+
return pts;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// One .lyrdb <value> payload: "<type>: <geometry>". Appends geometry to
|
|
90
|
+
// item.polygons / item.edges (a plain array while building). Returns a
|
|
91
|
+
// display note ("" when the value was pure geometry): unknown types,
|
|
92
|
+
// malformed geometry, and bare strings all fall back to showing the raw
|
|
93
|
+
// text. unknownTypes accumulates unsupported type names for a single
|
|
94
|
+
// summary warning.
|
|
95
|
+
function parseLyrdbValue(raw, item, unknownTypes) {
|
|
96
|
+
const text = raw.replace(/\s+/g, " ").trim();
|
|
97
|
+
if (text === "") return "";
|
|
98
|
+
const m = /^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*([\s\S]*)$/.exec(text);
|
|
99
|
+
if (!m) return text; // bare string -> note only
|
|
100
|
+
const type = m[1].toLowerCase();
|
|
101
|
+
const body = m[2].trim();
|
|
102
|
+
try {
|
|
103
|
+
if (type === "box") {
|
|
104
|
+
const p = parsePointList(body);
|
|
105
|
+
if (p.length !== 4) throw new Error("box needs 2 points");
|
|
106
|
+
const l = Math.min(p[0], p[2]);
|
|
107
|
+
const r = Math.max(p[0], p[2]);
|
|
108
|
+
const b = Math.min(p[1], p[3]);
|
|
109
|
+
const t = Math.max(p[1], p[3]);
|
|
110
|
+
item.polygons.push(Float64Array.from([l, b, r, b, r, t, l, t]));
|
|
111
|
+
return "";
|
|
112
|
+
}
|
|
113
|
+
if (type === "polygon") {
|
|
114
|
+
// The .lyrdb hole notation puts '/'-separated rings inside one paren
|
|
115
|
+
// group: (hull/hole1/...). v1 renders every ring as its own
|
|
116
|
+
// outline+fill (holes fill too -- acceptable, outline correct).
|
|
117
|
+
const inner = body.replace(/^\(/, "").replace(/\)$/, "");
|
|
118
|
+
for (const ringText of inner.split("/")) {
|
|
119
|
+
const ring = parsePointList(ringText);
|
|
120
|
+
if (ring.length >= 6) item.polygons.push(ring);
|
|
121
|
+
}
|
|
122
|
+
return "";
|
|
123
|
+
}
|
|
124
|
+
if (type === "edge") {
|
|
125
|
+
const p = parsePointList(body);
|
|
126
|
+
if (p.length !== 4) throw new Error("edge needs 2 points");
|
|
127
|
+
item.edges.push(p[0], p[1], p[2], p[3]);
|
|
128
|
+
return "";
|
|
129
|
+
}
|
|
130
|
+
if (type === "edge-pair") {
|
|
131
|
+
const mm = /\(([^)]*)\)\s*[/|]\s*\(([^)]*)\)/.exec(body);
|
|
132
|
+
if (!mm) throw new Error("malformed edge-pair");
|
|
133
|
+
for (const part of [mm[1], mm[2]]) {
|
|
134
|
+
const p = parsePointList(part);
|
|
135
|
+
if (p.length !== 4) throw new Error("edge-pair edge needs 2 points");
|
|
136
|
+
item.edges.push(p[0], p[1], p[2], p[3]);
|
|
137
|
+
}
|
|
138
|
+
return "";
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
return text; // malformed geometry: keep the raw string as the note
|
|
142
|
+
}
|
|
143
|
+
unknownTypes.add(type);
|
|
144
|
+
return text;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function computeItemBBox(item) {
|
|
148
|
+
let minX = Infinity;
|
|
149
|
+
let minY = Infinity;
|
|
150
|
+
let maxX = -Infinity;
|
|
151
|
+
let maxY = -Infinity;
|
|
152
|
+
const eat = (arr) => {
|
|
153
|
+
for (let i = 0; i + 1 < arr.length; i += 2) {
|
|
154
|
+
if (arr[i] < minX) minX = arr[i];
|
|
155
|
+
if (arr[i] > maxX) maxX = arr[i];
|
|
156
|
+
if (arr[i + 1] < minY) minY = arr[i + 1];
|
|
157
|
+
if (arr[i + 1] > maxY) maxY = arr[i + 1];
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
for (const ring of item.polygons) eat(ring);
|
|
161
|
+
eat(item.edges);
|
|
162
|
+
if (minX > maxX) return null;
|
|
163
|
+
return { minX, minY, maxX, maxY };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Final pass shared by both parsers: assign global ids in category-major
|
|
167
|
+
// emission order (flattenMarkerModel indexes its per-item arrays by id, so
|
|
168
|
+
// this ordering is load-bearing) and freeze each item's edge array.
|
|
169
|
+
function finalizeModel(model) {
|
|
170
|
+
let id = 0;
|
|
171
|
+
for (const cat of model.categories) {
|
|
172
|
+
for (const item of cat.items) {
|
|
173
|
+
item.id = id++;
|
|
174
|
+
if (!(item.edges instanceof Float64Array)) item.edges = Float64Array.from(item.edges);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return model;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// .lyrdb report-database XML, written by DRC/LVS report(...).
|
|
181
|
+
// Units are µm floats already in layout space -- no scaling. domParserCtor
|
|
182
|
+
// is the DOMParser constructor to instantiate (see file header).
|
|
183
|
+
function parseLyrdb(text, domParserCtor) {
|
|
184
|
+
const doc = new domParserCtor().parseFromString(text, "text/xml");
|
|
185
|
+
const root = doc && doc.documentElement;
|
|
186
|
+
if (!root || root.nodeName !== "report-database") {
|
|
187
|
+
throw new Error("not a .lyrdb report database (no <report-database> root)");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const childElements = (node, name) => {
|
|
191
|
+
const out = [];
|
|
192
|
+
for (let c = node.firstChild; c; c = c.nextSibling) {
|
|
193
|
+
if (c.nodeType === 1 && c.nodeName === name) out.push(c);
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
};
|
|
197
|
+
const childText = (node, name) => {
|
|
198
|
+
const els = childElements(node, name);
|
|
199
|
+
return els.length ? els[0].textContent || "" : null;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const topCell = (childText(root, "top-cell") || "").trim();
|
|
203
|
+
const warnings = [];
|
|
204
|
+
const model = { topCell, warnings, categories: [] };
|
|
205
|
+
|
|
206
|
+
// Category defs can be nested (path components '.'-joined) and are
|
|
207
|
+
// emitted lazily by writers -- an item may reference a category with no
|
|
208
|
+
// def at all, so ensureCategory also derives categories from item refs.
|
|
209
|
+
const catByPath = new Map();
|
|
210
|
+
const ensureCategory = (path, description) => {
|
|
211
|
+
let cat = catByPath.get(path);
|
|
212
|
+
if (!cat) {
|
|
213
|
+
cat = { name: path, description: description || "", items: [] };
|
|
214
|
+
catByPath.set(path, cat);
|
|
215
|
+
model.categories.push(cat);
|
|
216
|
+
} else if (description && !cat.description) {
|
|
217
|
+
cat.description = description;
|
|
218
|
+
}
|
|
219
|
+
return cat;
|
|
220
|
+
};
|
|
221
|
+
const walkCategories = (categoriesEl, prefix) => {
|
|
222
|
+
for (const catEl of childElements(categoriesEl, "category")) {
|
|
223
|
+
const name = (childText(catEl, "name") || "").trim();
|
|
224
|
+
if (!name) continue;
|
|
225
|
+
const path = prefix ? prefix + "." + name : name;
|
|
226
|
+
ensureCategory(path, (childText(catEl, "description") || "").trim());
|
|
227
|
+
for (const sub of childElements(catEl, "categories")) walkCategories(sub, path);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
for (const catsEl of childElements(root, "categories")) walkCategories(catsEl, "");
|
|
231
|
+
|
|
232
|
+
let nonTopCount = 0;
|
|
233
|
+
const unknownTypes = new Set();
|
|
234
|
+
|
|
235
|
+
for (const itemsEl of childElements(root, "items")) {
|
|
236
|
+
for (const itemEl of childElements(itemsEl, "item")) {
|
|
237
|
+
// Item refs quote the path: <category>'cat.subcat'</category>.
|
|
238
|
+
let catRef = (childText(itemEl, "category") || "").trim().replace(/^'+|'+$/g, "");
|
|
239
|
+
if (!catRef) catRef = "(uncategorized)";
|
|
240
|
+
const cat = ensureCategory(catRef, "");
|
|
241
|
+
|
|
242
|
+
const item = { id: -1, label: String(cat.items.length + 1), note: "", polygons: [], edges: [], bbox: null };
|
|
243
|
+
const notes = [];
|
|
244
|
+
|
|
245
|
+
// Coordinates are interpreted as top-cell space; items bound to
|
|
246
|
+
// another cell (or a "CELL:variant" of any cell) may be placed
|
|
247
|
+
// wrong -- rendered anyway, counted for one summary warning.
|
|
248
|
+
const cellRef = (childText(itemEl, "cell") || "").trim();
|
|
249
|
+
if (cellRef) {
|
|
250
|
+
const baseCell = cellRef.split(":")[0];
|
|
251
|
+
if ((topCell && baseCell !== topCell) || cellRef.includes(":")) {
|
|
252
|
+
nonTopCount++;
|
|
253
|
+
notes.push("cell " + cellRef);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const mult = parseInt((childText(itemEl, "multiplicity") || "").trim(), 10);
|
|
258
|
+
if (mult > 1) notes.push("×" + mult);
|
|
259
|
+
|
|
260
|
+
for (const valuesEl of childElements(itemEl, "values")) {
|
|
261
|
+
for (const valueEl of childElements(valuesEl, "value")) {
|
|
262
|
+
const note = parseLyrdbValue(valueEl.textContent || "", item, unknownTypes);
|
|
263
|
+
if (note) notes.push(note);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
item.note = notes.join(" · ");
|
|
268
|
+
item.bbox = computeItemBBox(item);
|
|
269
|
+
cat.items.push(item);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (nonTopCount > 0) {
|
|
274
|
+
warnings.push(nonTopCount + " marker(s) reference non-top cells; positions may be wrong");
|
|
275
|
+
}
|
|
276
|
+
if (unknownTypes.size > 0) {
|
|
277
|
+
warnings.push("values of unsupported type shown as text only: " + Array.from(unknownTypes).join(", "));
|
|
278
|
+
}
|
|
279
|
+
return finalizeModel(model);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ASCII DRC results database, a.k.a. an RVE database (rule-file's "DRC
|
|
283
|
+
// RESULTS DATABASE <file> ASCII"). Line-oriented, and rigidly counted -- the
|
|
284
|
+
// counts line says how many description lines and how many results follow, and
|
|
285
|
+
// those counts, not the shape of later lines, are what delimit a block:
|
|
286
|
+
//
|
|
287
|
+
// <top-cell> <resolution> resolution = database units per µm
|
|
288
|
+
// <check name> one block per rulecheck, repeating:
|
|
289
|
+
// <results> <original> <desc lines> <timestamp>
|
|
290
|
+
// <desc line> ... exactly <desc lines> of them: rule text,
|
|
291
|
+
// prose, or WE<n> waiver records
|
|
292
|
+
// <p|e> <ordinal> <count> exactly <results> of these records:
|
|
293
|
+
// [CN <cell> [c] <m11 m21 m12 m22 x y>] cell + placement (hierarchical)
|
|
294
|
+
// [<PropertyName> <number>] per-result value (density, area, ...)
|
|
295
|
+
// <x> <y> ... 'p': <count> vertex lines -> one polygon
|
|
296
|
+
// <x1> <y1> <x2> <y2> ... 'e': <count> edge lines (2 = an edge pair)
|
|
297
|
+
//
|
|
298
|
+
// Note the asymmetry in the record count: for 'p' it counts vertices, for 'e'
|
|
299
|
+
// it counts *edges*, each edge being four numbers on one line. '//' comment
|
|
300
|
+
// lines may appear anywhere.
|
|
301
|
+
//
|
|
302
|
+
// Grammar and semantics were worked out from the open-source reader in
|
|
303
|
+
// KLayout (src/rdb/rdb/rdbRVEReader.cc), not from any vendor documentation,
|
|
304
|
+
// including the trailing-'.' strip on check
|
|
305
|
+
// names and the CN 'c' flag. Never throws on malformed interior lines -- skips
|
|
306
|
+
// and records a warning instead. Not handled: the sibling "<file>.waived"
|
|
307
|
+
// database some tools look for alongside the results file (the webview only ever
|
|
308
|
+
// receives one file's text).
|
|
309
|
+
function parseDrcAscii(text) {
|
|
310
|
+
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
|
|
311
|
+
const lines = text.split(/\r?\n/);
|
|
312
|
+
const warnings = [];
|
|
313
|
+
const model = { topCell: "", warnings, categories: [] };
|
|
314
|
+
|
|
315
|
+
// A line cursor that hides '//' comments and blank lines: at every point in
|
|
316
|
+
// this grammar where a line is expected, both are noise. peek() returns the
|
|
317
|
+
// raw line (indentation intact, for rule text) or null at end of input.
|
|
318
|
+
let i = 0;
|
|
319
|
+
const peek = () => {
|
|
320
|
+
while (i < lines.length) {
|
|
321
|
+
const t = lines[i].trim();
|
|
322
|
+
if (t !== "" && !t.startsWith("//")) return lines[i];
|
|
323
|
+
i++;
|
|
324
|
+
}
|
|
325
|
+
return null;
|
|
326
|
+
};
|
|
327
|
+
const take = () => {
|
|
328
|
+
const line = peek();
|
|
329
|
+
if (line !== null) i++;
|
|
330
|
+
return line;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const headerLine = peek();
|
|
334
|
+
const header = /^(\S+)\s+(\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)$/.exec((headerLine || "").trim());
|
|
335
|
+
if (!header) throw new Error("not a ASCII DRC results database (bad header line)");
|
|
336
|
+
model.topCell = header[1];
|
|
337
|
+
// Resolution is database units per µm, and is a float in the grammar even
|
|
338
|
+
// though every real file writes an integer. the format's usual sanity range.
|
|
339
|
+
const resolution = parseFloat(header[2]);
|
|
340
|
+
if (!(resolution >= 0.001 && resolution <= 1e6)) throw new Error("bad precision in ASCII DRC header");
|
|
341
|
+
i++;
|
|
342
|
+
|
|
343
|
+
// "<results> <original> <desc lines> <timestamp>". The third count is
|
|
344
|
+
// optional only to tolerate hand-made files; the timestamp tail is ignored.
|
|
345
|
+
const countsRe = /^(\d+)\s+(\d+)(?:\s+(\d+))?(?:\s+\S.*)?$/;
|
|
346
|
+
const recordRe = /^([pePE])\s+(\d+)\s+(\d+)\s*(\S.*)?$/;
|
|
347
|
+
const numberRe = /^-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?$/;
|
|
348
|
+
const waiverRe = /^WE(\d+)\s*(.*)$/;
|
|
349
|
+
// CN <cell> [c|C] [m11 m21 m12 m22 x y] -- cell names may contain _.$-
|
|
350
|
+
const cnRe = /^CN\s+(\S+)((?:\s+[cC])?)((?:\s+-?\d+){6})?\s*$/;
|
|
351
|
+
const propRe = /^([A-Za-z_]\w*)\s+(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)\s*$/;
|
|
352
|
+
|
|
353
|
+
// Numbers on a pure-numeric line, or null if the line is something else
|
|
354
|
+
// (the next record, a property, the next check's name).
|
|
355
|
+
const lineNumbers = (line) => {
|
|
356
|
+
const parts = line.trim().split(/\s+/);
|
|
357
|
+
for (const p of parts) if (!numberRe.test(p)) return null;
|
|
358
|
+
return parts;
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
let unsupportedProps = 0;
|
|
362
|
+
let cellRefCount = 0;
|
|
363
|
+
let strayCoords = 0;
|
|
364
|
+
let pendingName = null; // check name already read while ending a block
|
|
365
|
+
|
|
366
|
+
while (true) {
|
|
367
|
+
let name = pendingName;
|
|
368
|
+
pendingName = null;
|
|
369
|
+
if (name === null) {
|
|
370
|
+
const line = take();
|
|
371
|
+
if (line === null) break;
|
|
372
|
+
name = line;
|
|
373
|
+
}
|
|
374
|
+
// Leftover coordinates from a record that wrote more points than it
|
|
375
|
+
// declared land here too, once its count has already been satisfied.
|
|
376
|
+
if (lineNumbers(name.trim())) {
|
|
377
|
+
strayCoords++;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
// Some writers write some check names with a trailing period; one is
|
|
381
|
+
// stripped so the name matches the rule as written in the deck.
|
|
382
|
+
name = name.trim().replace(/\.$/, "");
|
|
383
|
+
const cat = { name, description: "", items: [] };
|
|
384
|
+
model.categories.push(cat);
|
|
385
|
+
|
|
386
|
+
// Counts line. If what follows is already a result record, this file
|
|
387
|
+
// omits the counts line: fall back to reading records until something
|
|
388
|
+
// that isn't one (resultCount === null means "unbounded").
|
|
389
|
+
let resultCount = null;
|
|
390
|
+
let descCount = 0;
|
|
391
|
+
const next = peek();
|
|
392
|
+
const counts = next !== null && !recordRe.test(next.trim()) ? countsRe.exec(next.trim()) : null;
|
|
393
|
+
if (counts) {
|
|
394
|
+
i++;
|
|
395
|
+
resultCount = parseInt(counts[1], 10);
|
|
396
|
+
descCount = counts[3] === undefined ? 0 : parseInt(counts[3], 10);
|
|
397
|
+
} else if (next !== null && !recordRe.test(next.trim())) {
|
|
398
|
+
warnings.push(`${name}: no counts line after the check name`);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Description block: exactly descCount lines. WE<n> lines are waiver
|
|
402
|
+
// records for result n rather than description text -- the first line
|
|
403
|
+
// of each is the waiver's author/timestamp and is dropped, the rest
|
|
404
|
+
// become that result's comment (see rdbRVEReader.cc).
|
|
405
|
+
const waivers = new Map();
|
|
406
|
+
const descParts = [];
|
|
407
|
+
for (let d = 0; d < descCount; d++) {
|
|
408
|
+
const line = take();
|
|
409
|
+
if (line === null) {
|
|
410
|
+
warnings.push(`${name}: file ended inside the description block`);
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
const trimmed = line.trim();
|
|
414
|
+
// A result record here means the count is too high; don't eat
|
|
415
|
+
// geometry with it.
|
|
416
|
+
if (recordRe.test(trimmed)) {
|
|
417
|
+
i--;
|
|
418
|
+
warnings.push(`${name}: description count ${descCount} overruns the results`);
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
const we = waiverRe.exec(trimmed);
|
|
422
|
+
if (we) {
|
|
423
|
+
const n = parseInt(we[1], 10);
|
|
424
|
+
if (!waivers.has(n)) waivers.set(n, []);
|
|
425
|
+
else waivers.get(n).push(we[2]);
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
descParts.push(trimmed.startsWith('"') ? trimmed.replace(/^"/, "").replace(/"$/, "") : line.replace(/\s+$/, ""));
|
|
429
|
+
}
|
|
430
|
+
cat.description = descParts.join("\n");
|
|
431
|
+
|
|
432
|
+
// Results. Coordinates are top-cell space unless a CN record says
|
|
433
|
+
// otherwise; the state persists across the records of a check (ASCII DRC
|
|
434
|
+
// writes one CN per cell, not per result) and resets at the next check.
|
|
435
|
+
let cellName = "";
|
|
436
|
+
let xf = null; // {m11,m21,m12,m22,tx,ty} in DB units, or null = identity
|
|
437
|
+
let shape = 0;
|
|
438
|
+
// resultCount is the sentinel here, not the counter: null means "no
|
|
439
|
+
// declared count, read until the next record wins". `shape` is what
|
|
440
|
+
// advances, further down the loop body.
|
|
441
|
+
// eslint-disable-next-line no-unmodified-loop-condition
|
|
442
|
+
while (resultCount === null || shape < resultCount) {
|
|
443
|
+
const line = take();
|
|
444
|
+
if (line === null) {
|
|
445
|
+
if (resultCount !== null && shape < resultCount) {
|
|
446
|
+
warnings.push(`${name}: file ended after ${shape} of ${resultCount} result(s)`);
|
|
447
|
+
}
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
const rec = recordRe.exec(line.trim());
|
|
451
|
+
if (!rec) {
|
|
452
|
+
// A bare coordinate line here is a record that declared fewer
|
|
453
|
+
// points than it wrote. Dropping it keeps the block on the
|
|
454
|
+
// rails; treating it as a name would invent a check called
|
|
455
|
+
// "123 456" and then read the strays after it as its counts.
|
|
456
|
+
if (lineNumbers(line.trim())) {
|
|
457
|
+
strayCoords++;
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
// Anything else is the next check's name. ASCII DRC's counts are
|
|
461
|
+
// trustworthy, so reaching this early means the file is
|
|
462
|
+
// truncated or the count is wrong.
|
|
463
|
+
if (resultCount !== null && shape < resultCount) {
|
|
464
|
+
warnings.push(`${name}: results ended after ${shape} of ${resultCount}; continuing with the next check`);
|
|
465
|
+
}
|
|
466
|
+
pendingName = line;
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const kind = rec[1].toLowerCase();
|
|
471
|
+
const ordinal = rec[2];
|
|
472
|
+
const count = parseInt(rec[3], 10);
|
|
473
|
+
const item = {
|
|
474
|
+
id: -1,
|
|
475
|
+
label: `${kind} ${ordinal}`,
|
|
476
|
+
note: "",
|
|
477
|
+
polygons: [],
|
|
478
|
+
edges: [],
|
|
479
|
+
bbox: null,
|
|
480
|
+
waived: false,
|
|
481
|
+
};
|
|
482
|
+
const notes = [];
|
|
483
|
+
if (rec[4]) notes.push(rec[4].trim());
|
|
484
|
+
|
|
485
|
+
// Optional property records, between the record line and its
|
|
486
|
+
// coordinates.
|
|
487
|
+
while (true) {
|
|
488
|
+
const propLine = peek();
|
|
489
|
+
if (propLine === null) break;
|
|
490
|
+
const trimmed = propLine.trim();
|
|
491
|
+
if (lineNumbers(trimmed) || recordRe.test(trimmed)) break;
|
|
492
|
+
const cn = cnRe.exec(trimmed);
|
|
493
|
+
if (cn) {
|
|
494
|
+
i++;
|
|
495
|
+
cellName = cn[1];
|
|
496
|
+
const m = cn[3] ? cn[3].trim().split(/\s+/).map(Number) : [1, 0, 0, 1, 0, 0];
|
|
497
|
+
// Without the 'c' flag the coordinates are already in
|
|
498
|
+
// top-cell space and the matrix only says where the cell
|
|
499
|
+
// sits; with it they are cell-local and the matrix places
|
|
500
|
+
// them (some tools use shape_trans, others its inverse).
|
|
501
|
+
xf = cn[2].trim() === "" ? null
|
|
502
|
+
: { m11: m[0], m21: m[1], m12: m[2], m22: m[3], tx: m[4], ty: m[5] };
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
const prop = propRe.exec(trimmed);
|
|
506
|
+
if (prop) {
|
|
507
|
+
i++;
|
|
508
|
+
notes.push(`${prop[1]}=${prop[2]}`);
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
// Any other word-leading line is a property record in a format
|
|
512
|
+
// we don't read (several variants exist); consumed so it can't be
|
|
513
|
+
// mistaken for the next check name, counted for one warning.
|
|
514
|
+
if (/^[A-Za-z_]/.test(trimmed)) {
|
|
515
|
+
i++;
|
|
516
|
+
unsupportedProps++;
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
if (cellName && cellName !== model.topCell) {
|
|
522
|
+
cellRefCount++;
|
|
523
|
+
notes.push("cell " + cellName);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Coordinates: 2 numbers per vertex for 'p', 4 per edge for 'e'.
|
|
527
|
+
// Read them by number rather than by line so a writer that wraps
|
|
528
|
+
// differently still parses.
|
|
529
|
+
const wanted = kind === "p" ? count * 2 : count * 4;
|
|
530
|
+
const nums = [];
|
|
531
|
+
while (nums.length < wanted) {
|
|
532
|
+
const vline = peek();
|
|
533
|
+
if (vline === null) break;
|
|
534
|
+
const parts = lineNumbers(vline.trim());
|
|
535
|
+
if (!parts) break;
|
|
536
|
+
i++;
|
|
537
|
+
for (const p of parts) nums.push(parseFloat(p));
|
|
538
|
+
}
|
|
539
|
+
if (nums.length < wanted) {
|
|
540
|
+
warnings.push(
|
|
541
|
+
`${name} ${kind} ${ordinal}: ${nums.length / 2} of ${kind === "p" ? count : count * 2} point(s) present`
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Divide rather than multiply by a reciprocal: 700/2000 is exactly
|
|
546
|
+
// 0.35, 700*(1/2000) is not.
|
|
547
|
+
const mapX = xf
|
|
548
|
+
? (x, y) => (xf.m11 * x + xf.m12 * y + xf.tx) / resolution
|
|
549
|
+
: (x) => x / resolution;
|
|
550
|
+
const mapY = xf
|
|
551
|
+
? (x, y) => (xf.m21 * x + xf.m22 * y + xf.ty) / resolution
|
|
552
|
+
: (x, y) => y / resolution;
|
|
553
|
+
if (kind === "p") {
|
|
554
|
+
const usable = Math.floor(nums.length / 2) * 2;
|
|
555
|
+
if (usable >= 6) {
|
|
556
|
+
const ring = new Float64Array(usable);
|
|
557
|
+
for (let k = 0; k < usable; k += 2) {
|
|
558
|
+
ring[k] = mapX(nums[k], nums[k + 1]);
|
|
559
|
+
ring[k + 1] = mapY(nums[k], nums[k + 1]);
|
|
560
|
+
}
|
|
561
|
+
item.polygons.push(ring);
|
|
562
|
+
}
|
|
563
|
+
} else {
|
|
564
|
+
const usable = Math.floor(nums.length / 4) * 4;
|
|
565
|
+
for (let k = 0; k < usable; k += 2) {
|
|
566
|
+
item.edges.push(mapX(nums[k], nums[k + 1]), mapY(nums[k], nums[k + 1]));
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const waiver = waivers.get(shape);
|
|
571
|
+
if (waiver) {
|
|
572
|
+
item.waived = true;
|
|
573
|
+
notes.push(waiver.length ? "waived: " + waiver.join(" ") : "waived");
|
|
574
|
+
}
|
|
575
|
+
item.note = notes.join(" · ");
|
|
576
|
+
item.bbox = computeItemBBox(item);
|
|
577
|
+
cat.items.push(item);
|
|
578
|
+
shape++;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (cellRefCount > 0) {
|
|
583
|
+
warnings.push(cellRefCount + " marker(s) placed in cells other than " + model.topCell);
|
|
584
|
+
}
|
|
585
|
+
if (unsupportedProps > 0) {
|
|
586
|
+
warnings.push(unsupportedProps + " unsupported per-result property record(s) ignored");
|
|
587
|
+
}
|
|
588
|
+
if (strayCoords > 0) {
|
|
589
|
+
warnings.push(strayCoords + " coordinate line(s) past the declared point counts ignored");
|
|
590
|
+
}
|
|
591
|
+
return finalizeModel(model);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Sniff + dispatch. Throws on unrecognized input (callers surface the
|
|
595
|
+
// message in the debug log / marker chip).
|
|
596
|
+
function parseMarkerFile(text, domParserCtor) {
|
|
597
|
+
const format = sniffMarkerFormat(text);
|
|
598
|
+
if (format === "lyrdb") return parseLyrdb(text, domParserCtor);
|
|
599
|
+
if (format === "drc") return parseDrcAscii(text);
|
|
600
|
+
throw new Error("Unrecognized marker file format (expected .lyrdb XML or ASCII DRC results)");
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Concatenates a normalized model's geometry into the flat typed-array
|
|
604
|
+
// payload renderer.cpp's setMarkers() consumes (one bulk copy per array
|
|
605
|
+
// across the wasm boundary -- no chatty per-item objects):
|
|
606
|
+
// categories: [{itemStart, itemCount}] (index into the item arrays)
|
|
607
|
+
// itemCategory: Int32Array, category index per item id
|
|
608
|
+
// itemBBoxes: Float32Array, 4 per item ([0,0,-1,-1] = no geometry)
|
|
609
|
+
// polyVerts: Float32Array x,y pairs, rings back-to-back
|
|
610
|
+
// polyVertCounts: Uint32Array vertices per ring
|
|
611
|
+
// polyItemIds: Uint32Array owning item per ring
|
|
612
|
+
// edgeVerts: Float32Array x0,y0,x1,y1 per segment
|
|
613
|
+
// edgeItemIds: Uint32Array owning item per segment
|
|
614
|
+
function flattenMarkerModel(model) {
|
|
615
|
+
const categories = [];
|
|
616
|
+
let itemCount = 0;
|
|
617
|
+
let ringCount = 0;
|
|
618
|
+
let ringVertCount = 0;
|
|
619
|
+
let edgeSegCount = 0;
|
|
620
|
+
for (const cat of model.categories) {
|
|
621
|
+
categories.push({ itemStart: itemCount, itemCount: cat.items.length });
|
|
622
|
+
for (const item of cat.items) {
|
|
623
|
+
itemCount++;
|
|
624
|
+
for (const ring of item.polygons) {
|
|
625
|
+
ringCount++;
|
|
626
|
+
ringVertCount += ring.length / 2;
|
|
627
|
+
}
|
|
628
|
+
edgeSegCount += Math.floor(item.edges.length / 4);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const itemCategory = new Int32Array(itemCount);
|
|
633
|
+
const itemBBoxes = new Float32Array(itemCount * 4);
|
|
634
|
+
const polyVerts = new Float32Array(ringVertCount * 2);
|
|
635
|
+
const polyVertCounts = new Uint32Array(ringCount);
|
|
636
|
+
const polyItemIds = new Uint32Array(ringCount);
|
|
637
|
+
const edgeVerts = new Float32Array(edgeSegCount * 4);
|
|
638
|
+
const edgeItemIds = new Uint32Array(edgeSegCount);
|
|
639
|
+
|
|
640
|
+
let ring = 0;
|
|
641
|
+
let vert = 0;
|
|
642
|
+
let seg = 0;
|
|
643
|
+
model.categories.forEach((cat, ci) => {
|
|
644
|
+
for (const item of cat.items) {
|
|
645
|
+
itemCategory[item.id] = ci;
|
|
646
|
+
const bb = item.bbox;
|
|
647
|
+
itemBBoxes.set(bb ? [bb.minX, bb.minY, bb.maxX, bb.maxY] : [0, 0, -1, -1], item.id * 4);
|
|
648
|
+
for (const r of item.polygons) {
|
|
649
|
+
polyVerts.set(r, vert * 2);
|
|
650
|
+
polyVertCounts[ring] = r.length / 2;
|
|
651
|
+
polyItemIds[ring] = item.id;
|
|
652
|
+
vert += r.length / 2;
|
|
653
|
+
ring++;
|
|
654
|
+
}
|
|
655
|
+
const segs = Math.floor(item.edges.length / 4);
|
|
656
|
+
edgeVerts.set(item.edges.subarray(0, segs * 4), seg * 4);
|
|
657
|
+
for (let k = 0; k < segs; k++) edgeItemIds[seg + k] = item.id;
|
|
658
|
+
seg += segs;
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
return { categories, itemCategory, itemBBoxes, polyVerts, polyVertCounts, polyItemIds, edgeVerts, edgeItemIds };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
export {
|
|
666
|
+
sniffMarkerFormat,
|
|
667
|
+
parsePointList,
|
|
668
|
+
parseLyrdb,
|
|
669
|
+
parseDrcAscii,
|
|
670
|
+
parseMarkerFile,
|
|
671
|
+
flattenMarkerModel,
|
|
672
|
+
};
|