@chanmeng666/archlang 0.3.0 → 0.5.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.
@@ -100,19 +100,42 @@ function lex(src) {
100
100
  push("arrow", "->", startLine, startCol, startIdx);
101
101
  continue;
102
102
  }
103
- if (isDigit(c) || c === "-" && isDigit(peek(1)) || c === "." && isDigit(peek(1))) {
103
+ if (c === "+") {
104
+ advance();
105
+ push("plus", "+", startLine, startCol, startIdx);
106
+ continue;
107
+ }
108
+ if (c === "-") {
109
+ advance();
110
+ push("minus", "-", startLine, startCol, startIdx);
111
+ continue;
112
+ }
113
+ if (c === "*") {
114
+ advance();
115
+ push("star", "*", startLine, startCol, startIdx);
116
+ continue;
117
+ }
118
+ if (c === "/") {
119
+ advance();
120
+ push("slash", "/", startLine, startCol, startIdx);
121
+ continue;
122
+ }
123
+ if (c === "%") {
124
+ advance();
125
+ push("percent", "%", startLine, startCol, startIdx);
126
+ continue;
127
+ }
128
+ if (isDigit(c) || c === "." && isDigit(peek(1))) {
104
129
  let raw = "";
105
- if (c === "-") raw += advance();
106
130
  while (isDigit(peek())) raw += advance();
107
131
  if (peek() === ".") {
108
132
  raw += advance();
109
133
  while (isDigit(peek())) raw += advance();
110
134
  }
111
135
  const first = parseFloat(raw);
112
- if (peek() === "x" && (isDigit(peek(1)) || peek(1) === "-" && isDigit(peek(2)) || peek(1) === "." && isDigit(peek(2)))) {
136
+ if (peek() === "x" && (isDigit(peek(1)) || peek(1) === "." && isDigit(peek(2)))) {
113
137
  advance();
114
138
  let raw2 = "";
115
- if (peek() === "-") raw2 += advance();
116
139
  while (isDigit(peek())) raw2 += advance();
117
140
  if (peek() === ".") {
118
141
  raw2 += advance();
@@ -138,6 +161,198 @@ function lex(src) {
138
161
  return { tokens, errors };
139
162
  }
140
163
 
164
+ // src/expr.ts
165
+ var BIN_PREC = {
166
+ plus: 1,
167
+ minus: 1,
168
+ star: 2,
169
+ slash: 2,
170
+ percent: 2
171
+ };
172
+ var BIN_OP = {
173
+ plus: "+",
174
+ minus: "-",
175
+ star: "*",
176
+ slash: "/",
177
+ percent: "%"
178
+ };
179
+ function parseExpr(ts) {
180
+ return parseBin(ts, 1);
181
+ }
182
+ function parseBin(ts, minPrec) {
183
+ let left = parseUnary(ts);
184
+ for (; ; ) {
185
+ const t = ts.peek();
186
+ const prec = BIN_PREC[t.type];
187
+ if (prec === void 0 || prec < minPrec) break;
188
+ ts.next();
189
+ const right = parseBin(ts, prec + 1);
190
+ left = { t: "bin", op: BIN_OP[t.type], l: left, r: right };
191
+ }
192
+ return left;
193
+ }
194
+ function parseUnary(ts) {
195
+ const t = ts.peek();
196
+ if (t.type === "minus" || t.type === "plus") {
197
+ ts.next();
198
+ return { t: "unary", op: t.type === "minus" ? "-" : "+", e: parseUnary(ts) };
199
+ }
200
+ return parseAtom(ts);
201
+ }
202
+ function parseAtom(ts) {
203
+ const t = ts.peek();
204
+ if (t.type === "number") {
205
+ ts.next();
206
+ return { t: "num", value: t.num };
207
+ }
208
+ if (t.type === "ident") {
209
+ ts.next();
210
+ return { t: "ref", name: t.value, span: { start: t.start, end: t.end } };
211
+ }
212
+ if (t.type === "lparen") {
213
+ ts.next();
214
+ const e = parseExpr(ts);
215
+ const close = ts.peek();
216
+ if (close.type !== "rparen") ts.fail(`Expected ")" but found ${describe(close)}`);
217
+ ts.next();
218
+ return e;
219
+ }
220
+ return ts.fail(`Expected a number, name, or "(" but found ${describe(t)}`);
221
+ }
222
+ function evalExpr(e, env, onError) {
223
+ switch (e.t) {
224
+ case "num":
225
+ return e.value;
226
+ case "ref": {
227
+ const v = env.get(e.name);
228
+ if (v === void 0) {
229
+ const hint = closest(e.name, [...env.keys()]);
230
+ onError({
231
+ severity: "error",
232
+ message: `Unknown name "${e.name}"`,
233
+ code: "E_UNKNOWN_REF",
234
+ span: e.span,
235
+ hints: hint ? [`did you mean "${hint}"?`] : void 0
236
+ });
237
+ return 0;
238
+ }
239
+ return v;
240
+ }
241
+ case "unary": {
242
+ const v = evalExpr(e.e, env, onError);
243
+ return e.op === "-" ? -v : v;
244
+ }
245
+ case "bin": {
246
+ const l = evalExpr(e.l, env, onError);
247
+ const r = evalExpr(e.r, env, onError);
248
+ switch (e.op) {
249
+ case "+":
250
+ return l + r;
251
+ case "-":
252
+ return l - r;
253
+ case "*":
254
+ return l * r;
255
+ case "/":
256
+ case "%":
257
+ if (r === 0) {
258
+ onError({ severity: "error", message: `${e.op === "/" ? "Division" : "Modulo"} by zero`, code: "E_DIV_ZERO" });
259
+ return 0;
260
+ }
261
+ return e.op === "/" ? l / r : l % r;
262
+ }
263
+ }
264
+ }
265
+ }
266
+ function describe(t) {
267
+ if (t.type === "eof") return "end of input";
268
+ if (t.type === "string") return `string ${JSON.stringify(t.value)}`;
269
+ return `"${t.value}"`;
270
+ }
271
+ function closest(name, candidates) {
272
+ let best = null;
273
+ let bestDist = Infinity;
274
+ for (const c of candidates) {
275
+ const d = levenshtein(name, c);
276
+ if (d < bestDist) {
277
+ bestDist = d;
278
+ best = c;
279
+ }
280
+ }
281
+ const limit = Math.max(2, Math.floor(name.length / 3));
282
+ return best !== null && bestDist <= limit ? best : null;
283
+ }
284
+ function levenshtein(a, b) {
285
+ const m = a.length;
286
+ const n = b.length;
287
+ const dp = Array.from({ length: m + 1 }, (_, i) => i);
288
+ for (let j = 1; j <= n; j++) {
289
+ let prev = dp[0];
290
+ dp[0] = j;
291
+ for (let i = 1; i <= m; i++) {
292
+ const tmp = dp[i];
293
+ dp[i] = Math.min(
294
+ dp[i] + 1,
295
+ dp[i - 1] + 1,
296
+ prev + (a[i - 1] === b[j - 1] ? 0 : 1)
297
+ );
298
+ prev = tmp;
299
+ }
300
+ }
301
+ return dp[m];
302
+ }
303
+
304
+ // src/theme.ts
305
+ var DEFAULT_THEME = {
306
+ bg: "#ffffff",
307
+ pocheBase: "#e9e4db",
308
+ pocheHatch: "#b9b1a4",
309
+ wallStroke: "#1b1b1b",
310
+ roomFill: "#fbfaf7",
311
+ roomLabel: "#222222",
312
+ areaLabel: "#7a7a7a",
313
+ furnitureStroke: "#a8a29a",
314
+ furnitureFill: "#f4f2ee",
315
+ furnitureLabel: "#9a948c",
316
+ opening: "#ffffff",
317
+ doorLeaf: "#555555",
318
+ windowPane: "#3a6ea5",
319
+ dim: "#0E5484",
320
+ annotation: "#333333",
321
+ annotationMuted: "#888888",
322
+ column: "#4a4a4a",
323
+ lineWeight: 1,
324
+ font: "Helvetica, Arial, sans-serif"
325
+ };
326
+ var ALIASES = {
327
+ background: "bg",
328
+ wall: "wallStroke",
329
+ wallFill: "pocheBase",
330
+ wallHatch: "pocheHatch",
331
+ room: "roomFill",
332
+ furniture: "furnitureFill",
333
+ door: "doorLeaf",
334
+ window: "windowPane"
335
+ };
336
+ function resolveThemeKey(key) {
337
+ if (key in DEFAULT_THEME) return key;
338
+ if (key in ALIASES) return ALIASES[key];
339
+ return null;
340
+ }
341
+ function isNumericThemeKey(key) {
342
+ return key === "lineWeight";
343
+ }
344
+ function mergeTheme(...layers) {
345
+ const out = { ...DEFAULT_THEME };
346
+ for (const layer of layers) {
347
+ if (!layer) continue;
348
+ for (const k of Object.keys(layer)) {
349
+ const v = layer[k];
350
+ if (v !== void 0) out[k] = v;
351
+ }
352
+ }
353
+ return out;
354
+ }
355
+
141
356
  // src/geometry.ts
142
357
  var sub = (a, b) => ({ x: a.x - b.x, y: a.y - b.y });
143
358
  var add = (a, b) => ({ x: a.x + b.x, y: a.y + b.y });
@@ -229,6 +444,46 @@ function isOnSomeWall(walls, at, ref) {
229
444
  return false;
230
445
  }
231
446
 
447
+ // src/hatches.ts
448
+ var KNOWN_MATERIALS = ["poche", "concrete", "brick", "insulation", "tile", "none"];
449
+ var DEFAULT_MATERIAL = "poche";
450
+ function patternId(material) {
451
+ return material === "poche" ? "poche" : `hatch-${material}`;
452
+ }
453
+ var HATCHES = {
454
+ poche: (id, c) => `<pattern id="${id}" patternUnits="userSpaceOnUse" width="${c.fmt(c.gap)}" height="${c.fmt(c.gap)}" patternTransform="rotate(45)"><rect width="${c.fmt(c.gap)}" height="${c.fmt(c.gap)}" fill="${c.base}"/><line x1="0" y1="0" x2="0" y2="${c.fmt(c.gap)}" stroke="${c.line}" stroke-width="${c.fmt(c.thin * 0.7)}"/></pattern>`,
455
+ // Aggregate speckle.
456
+ concrete: (id, c) => {
457
+ const w = c.gap * 1.6;
458
+ return `<pattern id="${id}" patternUnits="userSpaceOnUse" width="${c.fmt(w)}" height="${c.fmt(w)}"><rect width="${c.fmt(w)}" height="${c.fmt(w)}" fill="${c.base}"/><circle cx="${c.fmt(w * 0.25)}" cy="${c.fmt(w * 0.3)}" r="${c.fmt(c.thin * 0.9)}" fill="${c.line}"/><circle cx="${c.fmt(w * 0.7)}" cy="${c.fmt(w * 0.62)}" r="${c.fmt(c.thin * 0.6)}" fill="${c.line}"/><circle cx="${c.fmt(w * 0.45)}" cy="${c.fmt(w * 0.85)}" r="${c.fmt(c.thin * 0.75)}" fill="${c.line}"/></pattern>`;
459
+ },
460
+ // Running-bond brick courses.
461
+ brick: (id, c) => {
462
+ const w = c.gap * 3;
463
+ const h = c.gap * 1.4;
464
+ const sw = c.fmt(c.thin * 0.6);
465
+ return `<pattern id="${id}" patternUnits="userSpaceOnUse" width="${c.fmt(w)}" height="${c.fmt(h)}"><rect width="${c.fmt(w)}" height="${c.fmt(h)}" fill="${c.base}"/><line x1="0" y1="${c.fmt(h)}" x2="${c.fmt(w)}" y2="${c.fmt(h)}" stroke="${c.line}" stroke-width="${sw}"/><line x1="0" y1="${c.fmt(h / 2)}" x2="${c.fmt(w)}" y2="${c.fmt(h / 2)}" stroke="${c.line}" stroke-width="${sw}"/><line x1="${c.fmt(w / 2)}" y1="0" x2="${c.fmt(w / 2)}" y2="${c.fmt(h / 2)}" stroke="${c.line}" stroke-width="${sw}"/><line x1="0" y1="${c.fmt(h / 2)}" x2="0" y2="${c.fmt(h)}" stroke="${c.line}" stroke-width="${sw}"/><line x1="${c.fmt(w)}" y1="${c.fmt(h / 2)}" x2="${c.fmt(w)}" y2="${c.fmt(h)}" stroke="${c.line}" stroke-width="${sw}"/></pattern>`;
466
+ },
467
+ // Cross-hatch batting.
468
+ insulation: (id, c) => {
469
+ const w = c.gap * 1.2;
470
+ return `<pattern id="${id}" patternUnits="userSpaceOnUse" width="${c.fmt(w)}" height="${c.fmt(w)}"><rect width="${c.fmt(w)}" height="${c.fmt(w)}" fill="${c.base}"/><path d="M0,0 L${c.fmt(w)},${c.fmt(w)} M${c.fmt(w)},0 L0,${c.fmt(w)}" stroke="${c.line}" stroke-width="${c.fmt(c.thin * 0.5)}" fill="none"/></pattern>`;
471
+ },
472
+ // Square tile grid.
473
+ tile: (id, c) => {
474
+ const w = c.gap * 1.8;
475
+ return `<pattern id="${id}" patternUnits="userSpaceOnUse" width="${c.fmt(w)}" height="${c.fmt(w)}"><rect width="${c.fmt(w)}" height="${c.fmt(w)}" fill="${c.base}"/><rect x="0" y="0" width="${c.fmt(w)}" height="${c.fmt(w)}" fill="none" stroke="${c.line}" stroke-width="${c.fmt(c.thin * 0.6)}"/></pattern>`;
476
+ },
477
+ // Solid fill, no hatch.
478
+ none: (id, c) => `<pattern id="${id}" patternUnits="userSpaceOnUse" width="${c.fmt(c.gap)}" height="${c.fmt(c.gap)}"><rect width="${c.fmt(c.gap)}" height="${c.fmt(c.gap)}" fill="${c.base}"/></pattern>`
479
+ };
480
+ function isKnownMaterial(name) {
481
+ return KNOWN_MATERIALS.includes(name);
482
+ }
483
+ function hatchPattern(material, c) {
484
+ return HATCHES[material](patternId(material), c);
485
+ }
486
+
232
487
  // src/elements/wall.ts
233
488
  var wall = {
234
489
  kind: "wall",
@@ -238,7 +493,12 @@ var wall = {
238
493
  const id = ctx.parseIdOpt();
239
494
  const category = ctx.eatIdent().value;
240
495
  ctx.eatKeyword("thickness");
241
- const thickness = ctx.eatNumber();
496
+ const thickness = ctx.parseExpr();
497
+ let material;
498
+ if (ctx.isKeyword("material")) {
499
+ ctx.next();
500
+ material = ctx.eatIdent().value;
501
+ }
242
502
  ctx.eat("lcurly");
243
503
  const points = [];
244
504
  let closed = false;
@@ -252,22 +512,34 @@ var wall = {
252
512
  points.push(ctx.parsePoint());
253
513
  continue;
254
514
  }
255
- ctx.fail(`Expected a point "(x,y)" or "close" in wall body but found ${describe(ctx)}`);
515
+ ctx.fail(`Expected a point "(x,y)" or "close" in wall body but found ${describe2(ctx)}`);
256
516
  }
257
517
  ctx.eat("rcurly");
258
518
  if (points.length < 2) ctx.fail("A wall needs at least two points", kw);
259
- return { kind: "wall", id, category, thickness, points, closed, line: kw.line };
519
+ return { kind: "wall", id, category, thickness, material, points, closed, line: kw.line };
260
520
  },
261
521
  idPrefix: (node) => node.category || "wall",
262
522
  resolve(node, ctx) {
263
523
  const n = node;
264
- const id = ctx.idOf(n);
265
- const points = n.points.map(ctx.snapPt);
266
- const thickness = ctx.snap(n.thickness) || n.thickness;
524
+ const id = ctx.id;
525
+ const points = n.points.map((p) => ctx.snapPt(ctx.evalPt(p)));
526
+ const tv = ctx.eval(n.thickness);
527
+ const thickness = ctx.snap(tv) || tv;
267
528
  if (thickness <= 0) {
268
529
  ctx.diag({ severity: "error", message: `Wall "${id}" must have a positive thickness`, code: "E_WALL_THICKNESS", span: n.span });
269
530
  }
270
- return { kind: "wall", id, category: n.category, thickness, points, closed: n.closed, span: n.span };
531
+ let material = DEFAULT_MATERIAL;
532
+ if (n.material !== void 0) {
533
+ if (isKnownMaterial(n.material)) material = n.material;
534
+ else
535
+ ctx.diag({
536
+ severity: "warning",
537
+ message: `Unknown wall material "${n.material}" (known: ${KNOWN_MATERIALS.join(", ")}); using the default hatch`,
538
+ code: "W_UNKNOWN_MATERIAL",
539
+ span: n.span
540
+ });
541
+ }
542
+ return { kind: "wall", id, category: n.category, thickness, material, points, closed: n.closed, span: n.span };
271
543
  },
272
544
  bounds(resolved) {
273
545
  const w = resolved;
@@ -302,7 +574,7 @@ var wall = {
302
574
  return ops;
303
575
  }
304
576
  };
305
- function describe(ctx) {
577
+ function describe2(ctx) {
306
578
  const t = ctx.peek();
307
579
  if (t.type === "eof") return "end of input";
308
580
  if (t.type === "string") return `string ${JSON.stringify(t.value)}`;
@@ -319,8 +591,8 @@ var room = {
319
591
  ctx.eatKeyword("at");
320
592
  const at = ctx.parsePoint();
321
593
  ctx.eatKeyword("size");
322
- const dim2 = ctx.eat("dimension");
323
- const node = { kind: "room", id, at, size: { w: dim2.num, h: dim2.num2 }, line: kw.line };
594
+ const size = ctx.parseDimensions();
595
+ const node = { kind: "room", id, at, size, line: kw.line };
324
596
  if (ctx.isKeyword("label")) {
325
597
  ctx.next();
326
598
  node.label = ctx.eatString();
@@ -330,9 +602,9 @@ var room = {
330
602
  idPrefix: () => "room",
331
603
  resolve(node, ctx) {
332
604
  const n = node;
333
- const id = ctx.idOf(n);
334
- const at = ctx.snapPt(n.at);
335
- const size = { w: ctx.snap(n.size.w), h: ctx.snap(n.size.h) };
605
+ const id = ctx.id;
606
+ const at = ctx.snapPt(ctx.evalPt(n.at));
607
+ const size = { w: ctx.snap(ctx.eval(n.size.w)), h: ctx.snap(ctx.eval(n.size.h)) };
336
608
  if (size.w <= 0 || size.h <= 0) {
337
609
  ctx.diag({ severity: "error", message: `Room "${id}" must have a positive size`, code: "E_ROOM_SIZE", span: n.span });
338
610
  }
@@ -375,7 +647,7 @@ var door = {
375
647
  ctx.eatKeyword("at");
376
648
  const at = ctx.parsePoint();
377
649
  ctx.eatKeyword("width");
378
- const width = ctx.eatNumber();
650
+ const width = ctx.parseExpr();
379
651
  const node = { kind: "door", id, at, width, hinge: "left", swing: "in", line: kw.line };
380
652
  if (ctx.isKeyword("wall")) {
381
653
  ctx.next();
@@ -398,9 +670,10 @@ var door = {
398
670
  idPrefix: () => "door",
399
671
  resolve(node, ctx) {
400
672
  const n = node;
401
- const id = ctx.idOf(n);
402
- const at = ctx.snapPt(n.at);
403
- const width = ctx.snap(n.width) || n.width;
673
+ const id = ctx.id;
674
+ const at = ctx.snapPt(ctx.evalPt(n.at));
675
+ const wv = ctx.eval(n.width);
676
+ const width = ctx.snap(wv) || wv;
404
677
  if (width <= 0) {
405
678
  ctx.diag({ severity: "error", message: `Door "${id}" must have a positive width`, code: "E_DOOR_WIDTH", span: n.span });
406
679
  }
@@ -455,7 +728,7 @@ var windowEl = {
455
728
  ctx.eatKeyword("at");
456
729
  const at = ctx.parsePoint();
457
730
  ctx.eatKeyword("width");
458
- const width = ctx.eatNumber();
731
+ const width = ctx.parseExpr();
459
732
  const node = { kind: "window", id, at, width, line: kw.line };
460
733
  if (ctx.isKeyword("wall")) {
461
734
  ctx.next();
@@ -466,9 +739,10 @@ var windowEl = {
466
739
  idPrefix: () => "window",
467
740
  resolve(node, ctx) {
468
741
  const n = node;
469
- const id = ctx.idOf(n);
470
- const at = ctx.snapPt(n.at);
471
- const width = ctx.snap(n.width) || n.width;
742
+ const id = ctx.id;
743
+ const at = ctx.snapPt(ctx.evalPt(n.at));
744
+ const wv = ctx.eval(n.width);
745
+ const width = ctx.snap(wv) || wv;
472
746
  if (width <= 0) {
473
747
  ctx.diag({ severity: "error", message: `Window "${id}" must have a positive width`, code: "E_WINDOW_WIDTH", span: n.span });
474
748
  }
@@ -525,8 +799,8 @@ var furniture = {
525
799
  ctx.eatKeyword("at");
526
800
  const at = ctx.parsePoint();
527
801
  ctx.eatKeyword("size");
528
- const dim2 = ctx.eat("dimension");
529
- const node = { kind: "furniture", id, category, at, size: { w: dim2.num, h: dim2.num2 }, line: kw.line };
802
+ const size = ctx.parseDimensions();
803
+ const node = { kind: "furniture", id, category, at, size, line: kw.line };
530
804
  if (ctx.isKeyword("label")) {
531
805
  ctx.next();
532
806
  node.label = ctx.eatString();
@@ -536,9 +810,9 @@ var furniture = {
536
810
  idPrefix: (node) => node.category || "furniture",
537
811
  resolve(node, ctx) {
538
812
  const n = node;
539
- const id = ctx.idOf(n);
540
- const at = ctx.snapPt(n.at);
541
- const size = { w: ctx.snap(n.size.w), h: ctx.snap(n.size.h) };
813
+ const id = ctx.id;
814
+ const at = ctx.snapPt(ctx.evalPt(n.at));
815
+ const size = { w: ctx.snap(ctx.eval(n.size.w)), h: ctx.snap(ctx.eval(n.size.h)) };
542
816
  if (size.w <= 0 || size.h <= 0) {
543
817
  ctx.diag({ severity: "error", message: `Furniture "${id}" must have a positive size`, code: "E_FURN_SIZE", span: n.span });
544
818
  }
@@ -578,10 +852,10 @@ var dim = {
578
852
  const from = ctx.parsePoint();
579
853
  ctx.eat("arrow");
580
854
  const to = ctx.parsePoint();
581
- const node = { kind: "dim", id: "", from, to, offset: 300, line: kw.line };
855
+ const node = { kind: "dim", id: "", from, to, offset: { t: "num", value: 300 }, line: kw.line };
582
856
  if (ctx.isKeyword("offset")) {
583
857
  ctx.next();
584
- node.offset = ctx.eatNumber();
858
+ node.offset = ctx.parseExpr();
585
859
  }
586
860
  if (ctx.isKeyword("text")) {
587
861
  ctx.next();
@@ -594,10 +868,10 @@ var dim = {
594
868
  const n = node;
595
869
  return {
596
870
  kind: "dim",
597
- id: ctx.idOf(n),
598
- from: ctx.snapPt(n.from),
599
- to: ctx.snapPt(n.to),
600
- offset: n.offset,
871
+ id: ctx.id,
872
+ from: ctx.snapPt(ctx.evalPt(n.from)),
873
+ to: ctx.snapPt(ctx.evalPt(n.to)),
874
+ offset: ctx.eval(n.offset),
601
875
  text: n.text,
602
876
  span: n.span
603
877
  };
@@ -660,15 +934,15 @@ var column = {
660
934
  ctx.eatKeyword("at");
661
935
  const at = ctx.parsePoint();
662
936
  ctx.eatKeyword("size");
663
- const dim2 = ctx.eat("dimension");
664
- return { kind: "column", id, at, size: { w: dim2.num, h: dim2.num2 }, line: kw.line };
937
+ const size = ctx.parseDimensions();
938
+ return { kind: "column", id, at, size, line: kw.line };
665
939
  },
666
940
  idPrefix: () => "column",
667
941
  resolve(node, ctx) {
668
942
  const n = node;
669
- const id = ctx.idOf(n);
670
- const at = ctx.snapPt(n.at);
671
- const size = { w: ctx.snap(n.size.w), h: ctx.snap(n.size.h) };
943
+ const id = ctx.id;
944
+ const at = ctx.snapPt(ctx.evalPt(n.at));
945
+ const size = { w: ctx.snap(ctx.eval(n.size.w)), h: ctx.snap(ctx.eval(n.size.h)) };
672
946
  if (size.w <= 0 || size.h <= 0) {
673
947
  ctx.diag({ severity: "error", message: `Column "${id}" must have a positive size`, code: "E_COLUMN_SIZE", span: n.span });
674
948
  }
@@ -707,7 +981,7 @@ register(dim);
707
981
  register(column);
708
982
 
709
983
  // src/parser.ts
710
- var SETTINGS = ["units", "grid", "scale", "north", "title"];
984
+ var SETTINGS = ["units", "grid", "scale", "north", "title", "theme", "let", "component"];
711
985
  var STATEMENT_STARTS = /* @__PURE__ */ new Set([...SETTINGS, ...registry.keys()]);
712
986
  var ParseError = class extends Error {
713
987
  constructor(message, span) {
@@ -752,6 +1026,8 @@ var Parser = class {
752
1026
  isKeyword: (kw, o) => this.isKeyword(kw, o),
753
1027
  isType: (type) => this.isType(type),
754
1028
  parsePoint: () => this.parsePoint(),
1029
+ parseExpr: () => parseExpr(this.ctx),
1030
+ parseDimensions: () => this.parseDimensions(),
755
1031
  parseIdOpt: () => this.parseIdOpt(),
756
1032
  fail: (msg, t) => this.fail(msg, t)
757
1033
  };
@@ -790,12 +1066,12 @@ var Parser = class {
790
1066
  }
791
1067
  eatKeyword(kw) {
792
1068
  const t = this.peek();
793
- if (t.type !== "ident" || t.value !== kw) this.fail(`Expected "${kw}" but found ${describe2(t)}`);
1069
+ if (t.type !== "ident" || t.value !== kw) this.fail(`Expected "${kw}" but found ${describe3(t)}`);
794
1070
  return this.next();
795
1071
  }
796
1072
  eat(type) {
797
1073
  const t = this.peek();
798
- if (t.type !== type) this.fail(`Expected ${type} but found ${describe2(t)}`);
1074
+ if (t.type !== type) this.fail(`Expected ${type} but found ${describe3(t)}`);
799
1075
  return this.next();
800
1076
  }
801
1077
  eatIdent() {
@@ -817,18 +1093,25 @@ var Parser = class {
817
1093
  units: "mm",
818
1094
  grid: 0,
819
1095
  north: "up",
820
- elements: []
1096
+ components: /* @__PURE__ */ new Map(),
1097
+ body: []
821
1098
  };
822
1099
  while (!this.isType("rcurly") && !this.isType("eof")) {
823
1100
  const t = this.peek();
824
1101
  const start = t.start;
825
1102
  try {
826
- if (t.type !== "ident") this.fail(`Expected a statement but found ${describe2(t)}`);
1103
+ if (t.type !== "ident") this.fail(`Expected a statement but found ${describe3(t)}`);
827
1104
  const def = registry.get(t.value);
828
1105
  if (def) {
829
1106
  const node = def.parse(this.ctx);
830
1107
  node.span = this.spanFrom(start);
831
- plan.elements.push(node);
1108
+ plan.body.push(node);
1109
+ continue;
1110
+ }
1111
+ if (plan.components.has(t.value) && this.peek(1).type === "lparen") {
1112
+ const node = this.parseInstance();
1113
+ node.span = this.spanFrom(start);
1114
+ plan.body.push(node);
832
1115
  continue;
833
1116
  }
834
1117
  switch (t.value) {
@@ -861,6 +1144,25 @@ var Parser = class {
861
1144
  plan.title = n;
862
1145
  break;
863
1146
  }
1147
+ case "theme": {
1148
+ plan.theme = { ...plan.theme, ...this.parseTheme() };
1149
+ break;
1150
+ }
1151
+ case "let": {
1152
+ const n = this.parseLet();
1153
+ n.span = this.spanFrom(start);
1154
+ plan.body.push(n);
1155
+ break;
1156
+ }
1157
+ case "component": {
1158
+ const def2 = this.parseComponent(plan.components);
1159
+ def2.span = this.spanFrom(start);
1160
+ if (plan.components.has(def2.name)) {
1161
+ this.fail(`Component "${def2.name}" is already defined`, t);
1162
+ }
1163
+ plan.components.set(def2.name, def2);
1164
+ break;
1165
+ }
864
1166
  default:
865
1167
  this.fail(`Unknown statement "${t.value}"`, t);
866
1168
  }
@@ -906,23 +1208,35 @@ var Parser = class {
906
1208
  this.next();
907
1209
  return t.value;
908
1210
  }
909
- this.fail(`Expected a north direction (up|down|left|right|<degrees>) but found ${describe2(t)}`);
1211
+ this.fail(`Expected a north direction (up|down|left|right|<degrees>) but found ${describe3(t)}`);
910
1212
  }
911
1213
  parsePoint() {
912
1214
  this.eat("lparen");
913
- const x = this.eatNumber();
1215
+ const x = parseExpr(this.ctx);
914
1216
  this.eat("comma");
915
- const y = this.eatNumber();
1217
+ const y = parseExpr(this.ctx);
916
1218
  this.eat("rparen");
917
1219
  return { x, y };
918
1220
  }
1221
+ /** A size: either a `WxH` literal dimension token or `<expr> x <expr>`. */
1222
+ parseDimensions() {
1223
+ if (this.isType("dimension")) {
1224
+ const d = this.eat("dimension");
1225
+ return { w: { t: "num", value: d.num }, h: { t: "num", value: d.num2 } };
1226
+ }
1227
+ const w = parseExpr(this.ctx);
1228
+ if (this.isKeyword("x")) this.next();
1229
+ else this.fail(`Expected "x" between width and height but found ${describe3(this.peek())}`);
1230
+ const h = parseExpr(this.ctx);
1231
+ return { w, h };
1232
+ }
919
1233
  parseTitle() {
920
1234
  const kw = this.eatKeyword("title");
921
1235
  this.eat("lcurly");
922
1236
  const node = { line: kw.line };
923
1237
  while (!this.isType("rcurly") && !this.isType("eof")) {
924
1238
  const t = this.peek();
925
- if (t.type !== "ident") this.fail(`Expected a title field but found ${describe2(t)}`);
1239
+ if (t.type !== "ident") this.fail(`Expected a title field but found ${describe3(t)}`);
926
1240
  switch (t.value) {
927
1241
  case "project":
928
1242
  this.next();
@@ -943,20 +1257,150 @@ var Parser = class {
943
1257
  this.eat("rcurly");
944
1258
  return node;
945
1259
  }
1260
+ /** `theme { key: <value> … }` — colours (strings), `lineWeight` (number), `font` (string). */
1261
+ parseTheme() {
1262
+ this.eatKeyword("theme");
1263
+ this.eat("lcurly");
1264
+ const t = {};
1265
+ while (!this.isType("rcurly") && !this.isType("eof")) {
1266
+ const keyTok = this.eatIdent();
1267
+ if (this.isType("colon")) this.next();
1268
+ const resolved = resolveThemeKey(keyTok.value);
1269
+ if (!resolved) {
1270
+ this.diagnostics.push({
1271
+ severity: "warning",
1272
+ message: `Unknown theme key "${keyTok.value}"`,
1273
+ code: "W_UNKNOWN_THEME_KEY",
1274
+ span: { start: keyTok.start, end: keyTok.end }
1275
+ });
1276
+ if (this.isType("string") || this.isType("number")) this.next();
1277
+ else this.fail(`Expected a value for theme key "${keyTok.value}"`);
1278
+ continue;
1279
+ }
1280
+ if (isNumericThemeKey(resolved)) {
1281
+ t[resolved] = this.eatNumber();
1282
+ } else {
1283
+ t[resolved] = this.eatString();
1284
+ }
1285
+ }
1286
+ this.eat("rcurly");
1287
+ return t;
1288
+ }
1289
+ parseLet() {
1290
+ const kw = this.eatKeyword("let");
1291
+ const name = this.eatIdent().value;
1292
+ this.eat("equals");
1293
+ const value = parseExpr(this.ctx);
1294
+ return { kind: "let", id: "", name, value, line: kw.line };
1295
+ }
1296
+ parseInstance() {
1297
+ const nameTok = this.eatIdent();
1298
+ this.eat("lparen");
1299
+ const args = [];
1300
+ while (!this.isType("rparen") && !this.isType("eof")) {
1301
+ args.push(parseExpr(this.ctx));
1302
+ if (this.isType("comma")) this.next();
1303
+ else break;
1304
+ }
1305
+ this.eat("rparen");
1306
+ return { kind: "instance", id: "", name: nameTok.value, args, line: nameTok.line };
1307
+ }
1308
+ /** `component NAME(p1, p2, …) { <statements> }`. */
1309
+ parseComponent(components) {
1310
+ const kw = this.eatKeyword("component");
1311
+ const name = this.eatIdent().value;
1312
+ this.eat("lparen");
1313
+ const params = [];
1314
+ while (!this.isType("rparen") && !this.isType("eof")) {
1315
+ params.push(this.eatIdent().value);
1316
+ if (this.isType("comma")) this.next();
1317
+ else break;
1318
+ }
1319
+ this.eat("rparen");
1320
+ this.eat("lcurly");
1321
+ const body = [];
1322
+ while (!this.isType("rcurly") && !this.isType("eof")) {
1323
+ const t = this.peek();
1324
+ const start = t.start;
1325
+ const def = registry.get(t.value);
1326
+ if (def) {
1327
+ const node = def.parse(this.ctx);
1328
+ node.span = this.spanFrom(start);
1329
+ body.push(node);
1330
+ continue;
1331
+ }
1332
+ if (t.value === "let") {
1333
+ const n = this.parseLet();
1334
+ n.span = this.spanFrom(start);
1335
+ body.push(n);
1336
+ continue;
1337
+ }
1338
+ if ((components.has(t.value) || t.value === name) && this.peek(1).type === "lparen") {
1339
+ const n = this.parseInstance();
1340
+ n.span = this.spanFrom(start);
1341
+ body.push(n);
1342
+ continue;
1343
+ }
1344
+ this.fail(`Expected an element, "let", or component call in component body but found ${describe3(t)}`, t);
1345
+ }
1346
+ this.eat("rcurly");
1347
+ return { name, params, body, line: kw.line };
1348
+ }
946
1349
  };
947
- function describe2(t) {
1350
+ function describe3(t) {
948
1351
  if (t.type === "eof") return "end of input";
949
1352
  if (t.type === "string") return `string ${JSON.stringify(t.value)}`;
950
1353
  return `"${t.value}"`;
951
1354
  }
952
1355
 
953
1356
  // src/ir.ts
1357
+ var MAX_DEPTH = 64;
1358
+ function expandScope(body, env, defined, global, components, diagnostics, depth) {
1359
+ const diag = (d) => diagnostics.push(d);
1360
+ const out = [];
1361
+ for (const stmt of body) {
1362
+ if (stmt.kind === "let") {
1363
+ if (defined.has(stmt.name)) {
1364
+ diag({ severity: "error", message: `"${stmt.name}" is already defined in this scope`, code: "E_REDEF", span: stmt.span });
1365
+ continue;
1366
+ }
1367
+ env.set(stmt.name, evalExpr(stmt.value, env, diag));
1368
+ defined.add(stmt.name);
1369
+ } else if (stmt.kind === "instance") {
1370
+ const comp = components.get(stmt.name);
1371
+ if (!comp) {
1372
+ const hint = closest(stmt.name, [...components.keys()]);
1373
+ diag({ severity: "error", message: `Unknown component "${stmt.name}"`, code: "E_UNKNOWN_COMPONENT", span: stmt.span, hints: hint ? [`did you mean "${hint}"?`] : void 0 });
1374
+ continue;
1375
+ }
1376
+ if (depth >= MAX_DEPTH) {
1377
+ diag({ severity: "error", message: `Component recursion too deep (limit ${MAX_DEPTH}) instantiating "${stmt.name}"`, code: "E_RECURSION", span: stmt.span });
1378
+ continue;
1379
+ }
1380
+ if (stmt.args.length !== comp.params.length) {
1381
+ diag({ severity: "error", message: `Component "${stmt.name}" expects ${comp.params.length} argument(s) but got ${stmt.args.length}`, code: "E_ARGCOUNT", span: stmt.span });
1382
+ }
1383
+ const argVals = comp.params.map((_, i) => stmt.args[i] !== void 0 ? evalExpr(stmt.args[i], env, diag) : 0);
1384
+ const childEnv = new Map(global);
1385
+ const childDefined = /* @__PURE__ */ new Set();
1386
+ comp.params.forEach((p, i) => {
1387
+ childEnv.set(p, argVals[i]);
1388
+ childDefined.add(p);
1389
+ });
1390
+ out.push(...expandScope(comp.body, childEnv, childDefined, global, components, diagnostics, depth + 1));
1391
+ } else {
1392
+ out.push({ node: stmt, env: new Map(env), id: "" });
1393
+ }
1394
+ }
1395
+ return out;
1396
+ }
954
1397
  function resolve(ast) {
955
1398
  const diagnostics = [];
956
1399
  const g = ast.grid;
957
1400
  const snap = (v) => g > 0 ? Math.round(v / g) * g : v;
958
1401
  const snapPt = (p) => ({ x: snap(p.x), y: snap(p.y) });
959
- const idMap = /* @__PURE__ */ new Map();
1402
+ const globalEnv = /* @__PURE__ */ new Map();
1403
+ const entries = expandScope(ast.body, globalEnv, /* @__PURE__ */ new Set(), globalEnv, ast.components, diagnostics, 0);
960
1404
  const seen = /* @__PURE__ */ new Set();
961
1405
  const assignId = (provided, prefix, idx, span) => {
962
1406
  if (provided) {
@@ -973,33 +1417,39 @@ function resolve(ast) {
973
1417
  };
974
1418
  for (const def of registryOrder) {
975
1419
  let idx = 0;
976
- for (const node of ast.elements) {
977
- if (node.kind !== def.kind) continue;
1420
+ for (const e of entries) {
1421
+ if (e.node.kind !== def.kind) continue;
978
1422
  idx++;
979
- idMap.set(node, assignId(node.id, def.idPrefix(node), idx, node.span));
1423
+ e.id = assignId(e.node.id, def.idPrefix(e.node), idx, e.node.span);
980
1424
  }
981
1425
  }
982
1426
  const walls = [];
1427
+ let activeEnv = /* @__PURE__ */ new Map();
1428
+ const evalNum = (e) => evalExpr(e, activeEnv, (d) => diagnostics.push(d));
1429
+ const evalPt = (p) => ({ x: evalNum(p.x), y: evalNum(p.y) });
983
1430
  const ctx = {
984
1431
  grid: g,
985
1432
  snap,
986
1433
  snapPt,
987
- idOf: (node) => idMap.get(node) ?? node.id,
1434
+ eval: evalNum,
1435
+ evalPt,
1436
+ id: "",
988
1437
  walls,
989
1438
  hostSegment: (at, ref) => hostSegmentForWalls(walls, at, ref),
990
1439
  isOnWall: (at, ref) => isOnSomeWall(walls, at, ref),
991
1440
  diag: (d) => diagnostics.push(d)
992
1441
  };
993
- const rmap = /* @__PURE__ */ new Map();
994
1442
  for (const def of registryOrder) {
995
- for (const node of ast.elements) {
996
- if (node.kind !== def.kind) continue;
997
- const r = def.resolve(node, ctx);
998
- rmap.set(node, r);
1443
+ for (const e of entries) {
1444
+ if (e.node.kind !== def.kind) continue;
1445
+ activeEnv = e.env;
1446
+ ctx.id = e.id;
1447
+ const r = def.resolve(e.node, ctx);
1448
+ e.resolved = r;
999
1449
  if (r.kind === "wall") walls.push(r);
1000
1450
  }
1001
1451
  }
1002
- const elements = ast.elements.map((n) => rmap.get(n));
1452
+ const elements = entries.map((e) => e.resolved);
1003
1453
  const drawable = elements.some(
1004
1454
  (e) => e.kind === "wall" || e.kind === "room" || e.kind === "furniture" || e.kind === "column"
1005
1455
  );
@@ -1034,6 +1484,7 @@ function resolve(ast) {
1034
1484
  scale: ast.scale,
1035
1485
  north: ast.north,
1036
1486
  title: ast.title,
1487
+ theme: ast.theme,
1037
1488
  elements,
1038
1489
  walls
1039
1490
  };
@@ -1053,26 +1504,104 @@ var RENDER_PASSES = [
1053
1504
  "annotations"
1054
1505
  ];
1055
1506
 
1507
+ // src/geometry/union.ts
1508
+ function uniqSorted(values) {
1509
+ const out = [...new Set(values)].sort((a, b) => a - b);
1510
+ return out;
1511
+ }
1512
+ function rectUnionOutline(rects) {
1513
+ if (rects.length === 0) return [];
1514
+ const xs = uniqSorted(rects.flatMap((r) => [r.x0, r.x1]));
1515
+ const ys = uniqSorted(rects.flatMap((r) => [r.y0, r.y1]));
1516
+ const nx = xs.length - 1;
1517
+ const ny = ys.length - 1;
1518
+ const filled = (i, j) => {
1519
+ if (i < 0 || j < 0 || i >= nx || j >= ny) return false;
1520
+ const cx = (xs[i] + xs[i + 1]) / 2;
1521
+ const cy = (ys[j] + ys[j + 1]) / 2;
1522
+ return rects.some((r) => cx > r.x0 && cx < r.x1 && cy > r.y0 && cy < r.y1);
1523
+ };
1524
+ const key = (x, y) => `${x},${y}`;
1525
+ const starts = /* @__PURE__ */ new Map();
1526
+ const pushEdge = (ax, ay, bx, by) => {
1527
+ const k = key(ax, ay);
1528
+ const list = starts.get(k);
1529
+ if (list) list.push({ x: bx, y: by });
1530
+ else starts.set(k, [{ x: bx, y: by }]);
1531
+ };
1532
+ for (let i = 0; i < nx; i++) {
1533
+ for (let j = 0; j < ny; j++) {
1534
+ if (!filled(i, j)) continue;
1535
+ const x0 = xs[i];
1536
+ const x1 = xs[i + 1];
1537
+ const y0 = ys[j];
1538
+ const y1 = ys[j + 1];
1539
+ if (!filled(i, j - 1)) pushEdge(x1, y0, x0, y0);
1540
+ if (!filled(i - 1, j)) pushEdge(x0, y0, x0, y1);
1541
+ if (!filled(i, j + 1)) pushEdge(x0, y1, x1, y1);
1542
+ if (!filled(i + 1, j)) pushEdge(x1, y1, x1, y0);
1543
+ }
1544
+ }
1545
+ const used = /* @__PURE__ */ new Set();
1546
+ const edgeKey = (a, b) => `${a.x},${a.y}->${b.x},${b.y}`;
1547
+ const loops = [];
1548
+ const takeNext = (from, prevDir) => {
1549
+ const list = starts.get(key(from.x, from.y));
1550
+ if (!list) return null;
1551
+ const candidates = list.filter((to) => !used.has(edgeKey(from, to)));
1552
+ if (candidates.length === 0) return null;
1553
+ if (candidates.length === 1 || !prevDir) return candidates[0];
1554
+ let best = candidates[0];
1555
+ let bestScore = -Infinity;
1556
+ for (const c of candidates) {
1557
+ const dir = { x: c.x - from.x, y: c.y - from.y };
1558
+ const cross = prevDir.x * dir.y - prevDir.y * dir.x;
1559
+ if (cross > bestScore) {
1560
+ bestScore = cross;
1561
+ best = c;
1562
+ }
1563
+ }
1564
+ return best;
1565
+ };
1566
+ for (const [startKey, ends] of starts) {
1567
+ for (const firstEnd of ends) {
1568
+ const [sx, sy] = startKey.split(",").map(Number);
1569
+ const start = { x: sx, y: sy };
1570
+ if (used.has(edgeKey(start, firstEnd))) continue;
1571
+ const loop = [start];
1572
+ let cur = start;
1573
+ let next = firstEnd;
1574
+ let dir = null;
1575
+ while (next) {
1576
+ used.add(edgeKey(cur, next));
1577
+ if (next.x === start.x && next.y === start.y) break;
1578
+ loop.push(next);
1579
+ dir = { x: next.x - cur.x, y: next.y - cur.y };
1580
+ cur = next;
1581
+ next = takeNext(cur, dir);
1582
+ }
1583
+ if (loop.length >= 4) loops.push(mergeCollinear(loop));
1584
+ }
1585
+ }
1586
+ return loops;
1587
+ }
1588
+ function mergeCollinear(loop) {
1589
+ const n = loop.length;
1590
+ const out = [];
1591
+ for (let i = 0; i < n; i++) {
1592
+ const prev = loop[(i - 1 + n) % n];
1593
+ const cur = loop[i];
1594
+ const next = loop[(i + 1) % n];
1595
+ const d1x = cur.x - prev.x;
1596
+ const d1y = cur.y - prev.y;
1597
+ const d2x = next.x - cur.x;
1598
+ const d2y = next.y - cur.y;
1599
+ if (d1x * d2y - d1y * d2x !== 0) out.push(cur);
1600
+ }
1601
+ return out.length >= 3 ? out : loop;
1602
+ }
1603
+
1056
1604
  // src/render.ts
1057
- var THEME = {
1058
- bg: "#ffffff",
1059
- pocheBase: "#e9e4db",
1060
- pocheHatch: "#b9b1a4",
1061
- wallStroke: "#1b1b1b",
1062
- roomFill: "#fbfaf7",
1063
- roomLabel: "#222222",
1064
- areaLabel: "#7a7a7a",
1065
- furnitureStroke: "#a8a29a",
1066
- furnitureFill: "#f4f2ee",
1067
- furnitureLabel: "#9a948c",
1068
- opening: "#ffffff",
1069
- doorLeaf: "#555555",
1070
- windowPane: "#3a6ea5",
1071
- dim: "#0E5484",
1072
- annotation: "#333333",
1073
- annotationMuted: "#888888",
1074
- column: "#4a4a4a"
1075
- };
1076
1605
  function fmt(v) {
1077
1606
  const r = Math.round(v * 100) / 100;
1078
1607
  return Object.is(r, -0) ? "0" : String(r);
@@ -1099,15 +1628,56 @@ function planBounds(ir) {
1099
1628
  }
1100
1629
  return b;
1101
1630
  }
1631
+ function allOrthogonal(walls) {
1632
+ return walls.every((w) => segmentsOfWall(w).every((s) => s.a.x === s.b.x || s.a.y === s.b.y));
1633
+ }
1634
+ function loopsToPath(loops) {
1635
+ return loops.map((loop) => "M " + loop.map(pt).join(" L ") + " Z").join(" ");
1636
+ }
1637
+ function materialsUsed(walls) {
1638
+ return [...new Set(walls.map((w) => w.material))].sort();
1639
+ }
1640
+ function renderWalls(walls, ctx) {
1641
+ if (walls.length === 0) return [];
1642
+ const ops = [];
1643
+ for (const mat of materialsUsed(walls)) {
1644
+ const group = walls.filter((w) => w.material === mat);
1645
+ if (!allOrthogonal(group)) {
1646
+ const def = registry.get("wall");
1647
+ ops.push(...group.flatMap((w) => def.render(w, ctx)));
1648
+ continue;
1649
+ }
1650
+ const rects = [];
1651
+ for (const w of group) {
1652
+ for (const s of segmentsOfWall(w)) {
1653
+ const corners = segmentRectangle(s.a, s.b, s.thickness);
1654
+ const xsv = corners.map((c) => c.x);
1655
+ const ysv = corners.map((c) => c.y);
1656
+ rects.push({ x0: Math.min(...xsv), y0: Math.min(...ysv), x1: Math.max(...xsv), y1: Math.max(...ysv) });
1657
+ }
1658
+ }
1659
+ const loops = rectUnionOutline(rects);
1660
+ if (loops.length === 0) continue;
1661
+ const d = loopsToPath(loops);
1662
+ ops.push({ pass: "wallFill", svg: `<path d="${d}" fill="url(#${patternId(mat)})" fill-rule="nonzero"/>` });
1663
+ ops.push({
1664
+ pass: "wallFace",
1665
+ svg: `<path d="${d}" fill="none" stroke="${ctx.theme.wallStroke}" stroke-width="${ctx.fmt(ctx.sizes.wallStroke)}" stroke-linejoin="miter"/>`
1666
+ });
1667
+ }
1668
+ return ops;
1669
+ }
1102
1670
  function render(ir, opts = {}) {
1671
+ const THEME = mergeTheme(DEFAULT_THEME, ir.theme, opts.theme);
1672
+ const lw = THEME.lineWeight;
1103
1673
  const b = planBounds(ir);
1104
1674
  const drawW = b.maxX - b.minX;
1105
1675
  const drawH = b.maxY - b.minY;
1106
1676
  const refDim = Math.max(drawW, drawH, 1);
1107
1677
  const sizes = {
1108
1678
  refDim,
1109
- wallStroke: refDim * 28e-4,
1110
- thin: refDim * 16e-4,
1679
+ wallStroke: refDim * 28e-4 * lw,
1680
+ thin: refDim * 16e-4 * lw,
1111
1681
  roomFont: refDim * 0.03,
1112
1682
  areaFont: refDim * 0.022,
1113
1683
  dimFont: refDim * 0.02,
@@ -1123,28 +1693,30 @@ function render(ir, opts = {}) {
1123
1693
  const out = [];
1124
1694
  const svgAttrs = opts.width ? `width="${fmt(opts.width)}" height="${fmt(opts.width * vbH / vbW)}"` : "";
1125
1695
  out.push(
1126
- `<svg xmlns="http://www.w3.org/2000/svg" ${svgAttrs} viewBox="${fmt(vbX)} ${fmt(vbY)} ${fmt(vbW)} ${fmt(vbH)}" font-family="Helvetica, Arial, sans-serif">`
1127
- );
1128
- out.push(
1129
- `<defs><pattern id="poche" patternUnits="userSpaceOnUse" width="${fmt(hatchGap)}" height="${fmt(hatchGap)}" patternTransform="rotate(45)"><rect width="${fmt(hatchGap)}" height="${fmt(hatchGap)}" fill="${THEME.pocheBase}"/><line x1="0" y1="0" x2="0" y2="${fmt(hatchGap)}" stroke="${THEME.pocheHatch}" stroke-width="${fmt(thin * 0.7)}"/></pattern></defs>`
1696
+ `<svg xmlns="http://www.w3.org/2000/svg" ${svgAttrs} viewBox="${fmt(vbX)} ${fmt(vbY)} ${fmt(vbW)} ${fmt(vbH)}" font-family="${xml(THEME.font)}">`
1130
1697
  );
1698
+ const hatchCtx = { fmt, gap: hatchGap, thin, base: THEME.pocheBase, line: THEME.pocheHatch };
1699
+ const patterns = materialsUsed(ir.walls).map((m) => hatchPattern(m, hatchCtx)).join("");
1700
+ out.push(`<defs>${patterns}</defs>`);
1131
1701
  out.push(`<rect x="${fmt(vbX)}" y="${fmt(vbY)}" width="${fmt(vbW)}" height="${fmt(vbH)}" fill="${THEME.bg}"/>`);
1132
1702
  const ctx = { fmt, pt, xml, theme: THEME, sizes, bounds: b };
1133
1703
  const ops = ir.elements.flatMap((el) => {
1704
+ if (el.kind === "wall") return [];
1134
1705
  const def = registry.get(el.kind);
1135
1706
  return def ? def.render(el, ctx) : [];
1136
1707
  });
1708
+ ops.push(...renderWalls(ir.walls, ctx));
1137
1709
  for (const pass of RENDER_PASSES) {
1138
1710
  for (const op of ops) if (op.pass === pass) out.push(op.svg);
1139
1711
  }
1140
- out.push(northArrow(ir, b, margin, refDim));
1141
- out.push(scaleBar(b, margin, refDim, thin));
1142
- const tb = titleBlock(ir, b, margin, refDim, thin);
1712
+ out.push(northArrow(ir, b, margin, refDim, THEME));
1713
+ out.push(scaleBar(b, margin, refDim, thin, THEME));
1714
+ const tb = titleBlock(ir, b, margin, refDim, thin, THEME);
1143
1715
  if (tb) out.push(tb);
1144
1716
  out.push("</svg>");
1145
1717
  return out.join("\n");
1146
1718
  }
1147
- function northArrow(ir, b, margin, refDim) {
1719
+ function northArrow(ir, b, margin, refDim, THEME) {
1148
1720
  const r = refDim * 0.045;
1149
1721
  const cx = b.maxX - r;
1150
1722
  const cy = b.minY - margin * 0.55;
@@ -1174,7 +1746,7 @@ function northArrow(ir, b, margin, refDim) {
1174
1746
  const ly = cy + ny * (r + fs * 0.8);
1175
1747
  return `<g><polygon points="${tri}" fill="${THEME.annotation}" transform="rotate(${fmt(deg)} ${fmt(cx)} ${fmt(cy)})"/><text x="${fmt(lx)}" y="${fmt(ly)}" font-size="${fmt(fs)}" fill="${THEME.annotation}" text-anchor="middle" dominant-baseline="central">N</text></g>`;
1176
1748
  }
1177
- function scaleBar(b, margin, refDim, thin) {
1749
+ function scaleBar(b, margin, refDim, thin, THEME) {
1178
1750
  const barLen = niceBarLength(refDim * 0.3);
1179
1751
  const x0 = b.minX;
1180
1752
  const y0 = b.maxY + margin * 0.55;
@@ -1194,7 +1766,7 @@ function scaleBar(b, margin, refDim, thin) {
1194
1766
  );
1195
1767
  return `<g>${parts.join("")}</g>`;
1196
1768
  }
1197
- function titleBlock(ir, b, margin, refDim, thin) {
1769
+ function titleBlock(ir, b, margin, refDim, thin, THEME) {
1198
1770
  const t = ir.title;
1199
1771
  if (!t && !ir.scale) return null;
1200
1772
  const boxW = refDim * 0.34;
@@ -1287,7 +1859,7 @@ function formatDiagnostic(source, d) {
1287
1859
  var cache = /* @__PURE__ */ new Map();
1288
1860
  var CACHE_MAX = 64;
1289
1861
  function compile(source, opts = {}) {
1290
- const key = JSON.stringify([source, opts.width ?? null]);
1862
+ const key = JSON.stringify([source, opts.width ?? null, opts.theme ?? null]);
1291
1863
  if (!opts.noCache) {
1292
1864
  const hit = cache.get(key);
1293
1865
  if (hit) return hit;
@@ -1327,4 +1899,4 @@ export {
1327
1899
  compile,
1328
1900
  clearCache
1329
1901
  };
1330
- //# sourceMappingURL=chunk-3YUQPQPZ.js.map
1902
+ //# sourceMappingURL=chunk-GUNWYUR2.js.map