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.
@@ -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
+ };