@chanmeng666/archlang 0.2.0 → 0.3.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.
@@ -138,20 +138,577 @@ function lex(src) {
138
138
  return { tokens, errors };
139
139
  }
140
140
 
141
+ // src/geometry.ts
142
+ var sub = (a, b) => ({ x: a.x - b.x, y: a.y - b.y });
143
+ var add = (a, b) => ({ x: a.x + b.x, y: a.y + b.y });
144
+ var mul = (v, s) => ({ x: v.x * s, y: v.y * s });
145
+ var length = (v) => Math.hypot(v.x, v.y);
146
+ function unit(v) {
147
+ const l = length(v);
148
+ return l === 0 ? { x: 0, y: 0 } : { x: v.x / l, y: v.y / l };
149
+ }
150
+ var normal = (v) => ({ x: -v.y, y: v.x });
151
+ var emptyBounds = () => ({
152
+ minX: Infinity,
153
+ minY: Infinity,
154
+ maxX: -Infinity,
155
+ maxY: -Infinity
156
+ });
157
+ function extendBounds(b, x, y) {
158
+ if (x < b.minX) b.minX = x;
159
+ if (y < b.minY) b.minY = y;
160
+ if (x > b.maxX) b.maxX = x;
161
+ if (y > b.maxY) b.maxY = y;
162
+ }
163
+ function distPointToSegment(p, a, b) {
164
+ const abx = b.x - a.x;
165
+ const aby = b.y - a.y;
166
+ const apx = p.x - a.x;
167
+ const apy = p.y - a.y;
168
+ const len2 = abx * abx + aby * aby;
169
+ let t = len2 === 0 ? 0 : (apx * abx + apy * aby) / len2;
170
+ t = Math.max(0, Math.min(1, t));
171
+ const cx = a.x + t * abx;
172
+ const cy = a.y + t * aby;
173
+ return Math.hypot(p.x - cx, p.y - cy);
174
+ }
175
+ function rectCorners(x, y, w, h) {
176
+ return [
177
+ { x, y },
178
+ { x: x + w, y },
179
+ { x: x + w, y: y + h },
180
+ { x, y: y + h }
181
+ ];
182
+ }
183
+ function segmentRectangle(a, b, thickness) {
184
+ const d = unit(sub(b, a));
185
+ const n = normal(d);
186
+ const half = thickness / 2;
187
+ const a2 = add(a, mul(d, -half));
188
+ const b2 = add(b, mul(d, half));
189
+ return [
190
+ add(a2, mul(n, half)),
191
+ add(b2, mul(n, half)),
192
+ add(b2, mul(n, -half)),
193
+ add(a2, mul(n, -half))
194
+ ];
195
+ }
196
+ function segmentsOfWall(w) {
197
+ const segs = [];
198
+ for (let k = 0; k < w.points.length - 1; k++) {
199
+ segs.push({ a: w.points[k], b: w.points[k + 1], thickness: w.thickness, category: w.category });
200
+ }
201
+ if (w.closed && w.points.length > 2) {
202
+ segs.push({ a: w.points[w.points.length - 1], b: w.points[0], thickness: w.thickness, category: w.category });
203
+ }
204
+ return segs;
205
+ }
206
+ function hostSegmentForWalls(walls, at, ref) {
207
+ const candidates = ref ? walls.filter((w) => w.id === ref || w.category === ref) : walls;
208
+ let best = null;
209
+ let bestDist = Infinity;
210
+ for (const w of candidates) {
211
+ for (const s of segmentsOfWall(w)) {
212
+ const dist = distPointToSegment(at, s.a, s.b);
213
+ if (dist < bestDist) {
214
+ bestDist = dist;
215
+ best = s;
216
+ }
217
+ }
218
+ }
219
+ return best;
220
+ }
221
+ function isOnSomeWall(walls, at, ref) {
222
+ const candidates = ref ? walls.filter((w) => w.id === ref || w.category === ref) : walls;
223
+ for (const w of candidates) {
224
+ const tol = w.thickness / 2 + Math.max(w.thickness, 1);
225
+ for (const s of segmentsOfWall(w)) {
226
+ if (distPointToSegment(at, s.a, s.b) <= tol) return true;
227
+ }
228
+ }
229
+ return false;
230
+ }
231
+
232
+ // src/elements/wall.ts
233
+ var wall = {
234
+ kind: "wall",
235
+ keyword: "wall",
236
+ parse(ctx) {
237
+ const kw = ctx.eatKeyword("wall");
238
+ const id = ctx.parseIdOpt();
239
+ const category = ctx.eatIdent().value;
240
+ ctx.eatKeyword("thickness");
241
+ const thickness = ctx.eatNumber();
242
+ ctx.eat("lcurly");
243
+ const points = [];
244
+ let closed = false;
245
+ while (!ctx.isType("rcurly") && !ctx.isType("eof")) {
246
+ if (ctx.isKeyword("close")) {
247
+ ctx.next();
248
+ closed = true;
249
+ break;
250
+ }
251
+ if (ctx.isType("lparen")) {
252
+ points.push(ctx.parsePoint());
253
+ continue;
254
+ }
255
+ ctx.fail(`Expected a point "(x,y)" or "close" in wall body but found ${describe(ctx)}`);
256
+ }
257
+ ctx.eat("rcurly");
258
+ 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 };
260
+ },
261
+ idPrefix: (node) => node.category || "wall",
262
+ resolve(node, ctx) {
263
+ 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;
267
+ if (thickness <= 0) {
268
+ ctx.diag({ severity: "error", message: `Wall "${id}" must have a positive thickness`, code: "E_WALL_THICKNESS", span: n.span });
269
+ }
270
+ return { kind: "wall", id, category: n.category, thickness, points, closed: n.closed, span: n.span };
271
+ },
272
+ bounds(resolved) {
273
+ const w = resolved;
274
+ return segmentsOfWall(w).flatMap((s) => segmentRectangle(s.a, s.b, s.thickness));
275
+ },
276
+ render(resolved, ctx) {
277
+ const w = resolved;
278
+ const { fmt: fmt2, pt: pt2, theme, sizes } = ctx;
279
+ const segs = segmentsOfWall(w);
280
+ const ops = [];
281
+ for (const s of segs) {
282
+ const poly = segmentRectangle(s.a, s.b, s.thickness);
283
+ ops.push({ pass: "wallFill", svg: `<polygon points="${poly.map(pt2).join(" ")}" fill="url(#poche)"/>` });
284
+ }
285
+ for (const s of segs) {
286
+ const d = unit(sub(s.b, s.a));
287
+ const n = normal(d);
288
+ const h = s.thickness / 2;
289
+ const fa1 = add(s.a, mul(n, h));
290
+ const fb1 = add(s.b, mul(n, h));
291
+ const fa2 = add(s.a, mul(n, -h));
292
+ const fb2 = add(s.b, mul(n, -h));
293
+ ops.push({
294
+ pass: "wallFace",
295
+ svg: `<line x1="${fmt2(fa1.x)}" y1="${fmt2(fa1.y)}" x2="${fmt2(fb1.x)}" y2="${fmt2(fb1.y)}" stroke="${theme.wallStroke}" stroke-width="${fmt2(sizes.wallStroke)}" stroke-linecap="square"/>`
296
+ });
297
+ ops.push({
298
+ pass: "wallFace",
299
+ svg: `<line x1="${fmt2(fa2.x)}" y1="${fmt2(fa2.y)}" x2="${fmt2(fb2.x)}" y2="${fmt2(fb2.y)}" stroke="${theme.wallStroke}" stroke-width="${fmt2(sizes.wallStroke)}" stroke-linecap="square"/>`
300
+ });
301
+ }
302
+ return ops;
303
+ }
304
+ };
305
+ function describe(ctx) {
306
+ const t = ctx.peek();
307
+ if (t.type === "eof") return "end of input";
308
+ if (t.type === "string") return `string ${JSON.stringify(t.value)}`;
309
+ return `"${t.value}"`;
310
+ }
311
+
312
+ // src/elements/room.ts
313
+ var room = {
314
+ kind: "room",
315
+ keyword: "room",
316
+ parse(ctx) {
317
+ const kw = ctx.eatKeyword("room");
318
+ const id = ctx.parseIdOpt();
319
+ ctx.eatKeyword("at");
320
+ const at = ctx.parsePoint();
321
+ 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 };
324
+ if (ctx.isKeyword("label")) {
325
+ ctx.next();
326
+ node.label = ctx.eatString();
327
+ }
328
+ return node;
329
+ },
330
+ idPrefix: () => "room",
331
+ resolve(node, ctx) {
332
+ 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) };
336
+ if (size.w <= 0 || size.h <= 0) {
337
+ ctx.diag({ severity: "error", message: `Room "${id}" must have a positive size`, code: "E_ROOM_SIZE", span: n.span });
338
+ }
339
+ return { kind: "room", id, at, size, label: n.label, span: n.span };
340
+ },
341
+ bounds(resolved) {
342
+ const r = resolved;
343
+ return rectCorners(r.at.x, r.at.y, r.size.w, r.size.h);
344
+ },
345
+ render(resolved, ctx) {
346
+ const r = resolved;
347
+ const { fmt: fmt2, pt: pt2, xml: xml2, theme, sizes } = ctx;
348
+ const ops = [];
349
+ const c = rectCorners(r.at.x, r.at.y, r.size.w, r.size.h);
350
+ ops.push({ pass: "floor", svg: `<polygon points="${c.map(pt2).join(" ")}" fill="${theme.roomFill}"/>` });
351
+ const cx = r.at.x + r.size.w / 2;
352
+ const cy = r.at.y + r.size.h / 2;
353
+ const areaM2 = (r.size.w / 1e3 * (r.size.h / 1e3)).toFixed(1);
354
+ if (r.label) {
355
+ ops.push({
356
+ pass: "labels",
357
+ svg: `<text x="${fmt2(cx)}" y="${fmt2(cy - sizes.roomFont * 0.2)}" font-size="${fmt2(sizes.roomFont)}" fill="${theme.roomLabel}" text-anchor="middle" dominant-baseline="central" font-weight="600">${xml2(r.label)}</text>`
358
+ });
359
+ }
360
+ ops.push({
361
+ pass: "labels",
362
+ svg: `<text x="${fmt2(cx)}" y="${fmt2(cy + (r.label ? sizes.roomFont * 0.9 : 0))}" font-size="${fmt2(sizes.areaFont)}" fill="${theme.areaLabel}" text-anchor="middle" dominant-baseline="central">${areaM2} m\xB2</text>`
363
+ });
364
+ return ops;
365
+ }
366
+ };
367
+
368
+ // src/elements/door.ts
369
+ var door = {
370
+ kind: "door",
371
+ keyword: "door",
372
+ parse(ctx) {
373
+ const kw = ctx.eatKeyword("door");
374
+ const id = ctx.parseIdOpt();
375
+ ctx.eatKeyword("at");
376
+ const at = ctx.parsePoint();
377
+ ctx.eatKeyword("width");
378
+ const width = ctx.eatNumber();
379
+ const node = { kind: "door", id, at, width, hinge: "left", swing: "in", line: kw.line };
380
+ if (ctx.isKeyword("wall")) {
381
+ ctx.next();
382
+ node.wall = ctx.eatIdent().value;
383
+ }
384
+ if (ctx.isKeyword("hinge")) {
385
+ ctx.next();
386
+ const h = ctx.eatIdent().value;
387
+ if (h !== "left" && h !== "right") ctx.fail(`Expected hinge "left" or "right" but found "${h}"`);
388
+ node.hinge = h;
389
+ }
390
+ if (ctx.isKeyword("swing")) {
391
+ ctx.next();
392
+ const s = ctx.eatIdent().value;
393
+ if (s !== "in" && s !== "out") ctx.fail(`Expected swing "in" or "out" but found "${s}"`);
394
+ node.swing = s;
395
+ }
396
+ return node;
397
+ },
398
+ idPrefix: () => "door",
399
+ resolve(node, ctx) {
400
+ 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;
404
+ if (width <= 0) {
405
+ ctx.diag({ severity: "error", message: `Door "${id}" must have a positive width`, code: "E_DOOR_WIDTH", span: n.span });
406
+ }
407
+ if (ctx.walls.length > 0 && !ctx.isOnWall(at, n.wall)) {
408
+ ctx.diag({ severity: "warning", message: `Door "${id}" does not lie on any wall`, code: "W_DOOR_OFF_WALL", span: n.span });
409
+ }
410
+ return { kind: "door", id, at, width, hinge: n.hinge, swing: n.swing, host: ctx.hostSegment(at, n.wall), span: n.span };
411
+ },
412
+ bounds: () => [],
413
+ render(resolved, ctx) {
414
+ const dr = resolved;
415
+ const seg = dr.host;
416
+ if (!seg) return [];
417
+ const { fmt: fmt2, pt: pt2, theme, sizes } = ctx;
418
+ const d = unit(sub(seg.b, seg.a));
419
+ const n = normal(d);
420
+ const h = seg.thickness / 2 + sizes.wallStroke;
421
+ const hw = dr.width / 2;
422
+ const cover = [
423
+ add(add(dr.at, mul(d, -hw)), mul(n, h)),
424
+ add(add(dr.at, mul(d, hw)), mul(n, h)),
425
+ add(add(dr.at, mul(d, hw)), mul(n, -h)),
426
+ add(add(dr.at, mul(d, -hw)), mul(n, -h))
427
+ ];
428
+ const ops = [];
429
+ ops.push({ pass: "doors", svg: `<polygon points="${cover.map(pt2).join(" ")}" fill="${theme.opening}"/>` });
430
+ const hinge = dr.hinge === "left" ? add(dr.at, mul(d, -hw)) : add(dr.at, mul(d, hw));
431
+ const farJamb = dr.hinge === "left" ? add(dr.at, mul(d, hw)) : add(dr.at, mul(d, -hw));
432
+ const leafDir = dr.swing === "in" ? n : mul(n, -1);
433
+ const leafEnd = add(hinge, mul(leafDir, dr.width));
434
+ const cross = (leafEnd.x - hinge.x) * (farJamb.y - hinge.y) - (leafEnd.y - hinge.y) * (farJamb.x - hinge.x);
435
+ const sweep = cross < 0 ? 1 : 0;
436
+ ops.push({
437
+ pass: "doors",
438
+ svg: `<line x1="${fmt2(hinge.x)}" y1="${fmt2(hinge.y)}" x2="${fmt2(leafEnd.x)}" y2="${fmt2(leafEnd.y)}" stroke="${theme.doorLeaf}" stroke-width="${fmt2(sizes.thin * 1.3)}"/>`
439
+ });
440
+ ops.push({
441
+ pass: "doors",
442
+ svg: `<path d="M ${pt2(leafEnd)} A ${fmt2(dr.width)} ${fmt2(dr.width)} 0 0 ${sweep} ${pt2(farJamb)}" fill="none" stroke="${theme.doorLeaf}" stroke-width="${fmt2(sizes.thin)}" stroke-dasharray="${fmt2(sizes.thin * 4)} ${fmt2(sizes.thin * 3)}"/>`
443
+ });
444
+ return ops;
445
+ }
446
+ };
447
+
448
+ // src/elements/window.ts
449
+ var windowEl = {
450
+ kind: "window",
451
+ keyword: "window",
452
+ parse(ctx) {
453
+ const kw = ctx.eatKeyword("window");
454
+ const id = ctx.parseIdOpt();
455
+ ctx.eatKeyword("at");
456
+ const at = ctx.parsePoint();
457
+ ctx.eatKeyword("width");
458
+ const width = ctx.eatNumber();
459
+ const node = { kind: "window", id, at, width, line: kw.line };
460
+ if (ctx.isKeyword("wall")) {
461
+ ctx.next();
462
+ node.wall = ctx.eatIdent().value;
463
+ }
464
+ return node;
465
+ },
466
+ idPrefix: () => "window",
467
+ resolve(node, ctx) {
468
+ 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;
472
+ if (width <= 0) {
473
+ ctx.diag({ severity: "error", message: `Window "${id}" must have a positive width`, code: "E_WINDOW_WIDTH", span: n.span });
474
+ }
475
+ if (ctx.walls.length > 0 && !ctx.isOnWall(at, n.wall)) {
476
+ ctx.diag({ severity: "warning", message: `Window "${id}" does not lie on any wall`, code: "W_WINDOW_OFF_WALL", span: n.span });
477
+ }
478
+ return { kind: "window", id, at, width, host: ctx.hostSegment(at, n.wall), span: n.span };
479
+ },
480
+ bounds: () => [],
481
+ render(resolved, ctx) {
482
+ const wn = resolved;
483
+ const seg = wn.host;
484
+ if (!seg) return [];
485
+ const { fmt: fmt2, pt: pt2, theme, sizes } = ctx;
486
+ const d = unit(sub(seg.b, seg.a));
487
+ const n = normal(d);
488
+ const h = seg.thickness / 2;
489
+ const he = h + sizes.wallStroke;
490
+ const hw = wn.width / 2;
491
+ const cover = [
492
+ add(add(wn.at, mul(d, -hw)), mul(n, he)),
493
+ add(add(wn.at, mul(d, hw)), mul(n, he)),
494
+ add(add(wn.at, mul(d, hw)), mul(n, -he)),
495
+ add(add(wn.at, mul(d, -hw)), mul(n, -he))
496
+ ];
497
+ const ops = [];
498
+ ops.push({ pass: "windows", svg: `<polygon points="${cover.map(pt2).join(" ")}" fill="${theme.opening}"/>` });
499
+ const jA = add(wn.at, mul(d, -hw));
500
+ const jB = add(wn.at, mul(d, hw));
501
+ for (const off of [h, -h]) {
502
+ const a = add(jA, mul(n, off));
503
+ const bb = add(jB, mul(n, off));
504
+ ops.push({
505
+ pass: "windows",
506
+ svg: `<line x1="${fmt2(a.x)}" y1="${fmt2(a.y)}" x2="${fmt2(bb.x)}" y2="${fmt2(bb.y)}" stroke="${theme.wallStroke}" stroke-width="${fmt2(sizes.thin)}"/>`
507
+ });
508
+ }
509
+ ops.push({
510
+ pass: "windows",
511
+ svg: `<line x1="${fmt2(jA.x)}" y1="${fmt2(jA.y)}" x2="${fmt2(jB.x)}" y2="${fmt2(jB.y)}" stroke="${theme.windowPane}" stroke-width="${fmt2(sizes.thin)}"/>`
512
+ });
513
+ return ops;
514
+ }
515
+ };
516
+
517
+ // src/elements/furniture.ts
518
+ var furniture = {
519
+ kind: "furniture",
520
+ keyword: "furniture",
521
+ parse(ctx) {
522
+ const kw = ctx.eatKeyword("furniture");
523
+ const id = ctx.parseIdOpt();
524
+ const category = ctx.eatIdent().value;
525
+ ctx.eatKeyword("at");
526
+ const at = ctx.parsePoint();
527
+ 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 };
530
+ if (ctx.isKeyword("label")) {
531
+ ctx.next();
532
+ node.label = ctx.eatString();
533
+ }
534
+ return node;
535
+ },
536
+ idPrefix: (node) => node.category || "furniture",
537
+ resolve(node, ctx) {
538
+ 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) };
542
+ if (size.w <= 0 || size.h <= 0) {
543
+ ctx.diag({ severity: "error", message: `Furniture "${id}" must have a positive size`, code: "E_FURN_SIZE", span: n.span });
544
+ }
545
+ return { kind: "furniture", id, category: n.category, at, size, label: n.label, span: n.span };
546
+ },
547
+ bounds(resolved) {
548
+ const f = resolved;
549
+ return rectCorners(f.at.x, f.at.y, f.size.w, f.size.h);
550
+ },
551
+ render(resolved, ctx) {
552
+ const f = resolved;
553
+ const { fmt: fmt2, pt: pt2, xml: xml2, theme, sizes } = ctx;
554
+ const ops = [];
555
+ const c = rectCorners(f.at.x, f.at.y, f.size.w, f.size.h);
556
+ ops.push({
557
+ pass: "furniture",
558
+ svg: `<polygon points="${c.map(pt2).join(" ")}" fill="${theme.furnitureFill}" stroke="${theme.furnitureStroke}" stroke-width="${fmt2(sizes.thin)}"/>`
559
+ });
560
+ if (f.label) {
561
+ const cx = f.at.x + f.size.w / 2;
562
+ const cy = f.at.y + f.size.h / 2;
563
+ ops.push({
564
+ pass: "furniture",
565
+ svg: `<text x="${fmt2(cx)}" y="${fmt2(cy)}" font-size="${fmt2(sizes.furnFont)}" fill="${theme.furnitureLabel}" text-anchor="middle" dominant-baseline="central">${xml2(f.label)}</text>`
566
+ });
567
+ }
568
+ return ops;
569
+ }
570
+ };
571
+
572
+ // src/elements/dim.ts
573
+ var dim = {
574
+ kind: "dim",
575
+ keyword: "dim",
576
+ parse(ctx) {
577
+ const kw = ctx.eatKeyword("dim");
578
+ const from = ctx.parsePoint();
579
+ ctx.eat("arrow");
580
+ const to = ctx.parsePoint();
581
+ const node = { kind: "dim", id: "", from, to, offset: 300, line: kw.line };
582
+ if (ctx.isKeyword("offset")) {
583
+ ctx.next();
584
+ node.offset = ctx.eatNumber();
585
+ }
586
+ if (ctx.isKeyword("text")) {
587
+ ctx.next();
588
+ node.text = ctx.eatString();
589
+ }
590
+ return node;
591
+ },
592
+ idPrefix: () => "dim",
593
+ resolve(node, ctx) {
594
+ const n = node;
595
+ return {
596
+ kind: "dim",
597
+ id: ctx.idOf(n),
598
+ from: ctx.snapPt(n.from),
599
+ to: ctx.snapPt(n.to),
600
+ offset: n.offset,
601
+ text: n.text,
602
+ span: n.span
603
+ };
604
+ },
605
+ bounds(resolved) {
606
+ const dm = resolved;
607
+ return [dm.from, dm.to];
608
+ },
609
+ render(resolved, ctx) {
610
+ const dm = resolved;
611
+ const { fmt: fmt2, xml: xml2, theme, sizes } = ctx;
612
+ const dir = unit(sub(dm.to, dm.from));
613
+ const n = normal(dir);
614
+ const off = mul(n, dm.offset);
615
+ const p1 = add(dm.from, off);
616
+ const p2 = add(dm.to, off);
617
+ const tick = sizes.refDim * 0.012;
618
+ const ops = [];
619
+ ops.push({
620
+ pass: "dims",
621
+ svg: `<line x1="${fmt2(dm.from.x)}" y1="${fmt2(dm.from.y)}" x2="${fmt2(p1.x)}" y2="${fmt2(p1.y)}" stroke="${theme.dim}" stroke-width="${fmt2(sizes.thin * 0.7)}"/>`
622
+ });
623
+ ops.push({
624
+ pass: "dims",
625
+ svg: `<line x1="${fmt2(dm.to.x)}" y1="${fmt2(dm.to.y)}" x2="${fmt2(p2.x)}" y2="${fmt2(p2.y)}" stroke="${theme.dim}" stroke-width="${fmt2(sizes.thin * 0.7)}"/>`
626
+ });
627
+ ops.push({
628
+ pass: "dims",
629
+ svg: `<line x1="${fmt2(p1.x)}" y1="${fmt2(p1.y)}" x2="${fmt2(p2.x)}" y2="${fmt2(p2.y)}" stroke="${theme.dim}" stroke-width="${fmt2(sizes.thin)}"/>`
630
+ });
631
+ for (const p of [p1, p2]) {
632
+ const t1 = add(p, mul(unit({ x: dir.x + n.x, y: dir.y + n.y }), tick));
633
+ const t2 = add(p, mul(unit({ x: dir.x + n.x, y: dir.y + n.y }), -tick));
634
+ ops.push({
635
+ pass: "dims",
636
+ svg: `<line x1="${fmt2(t1.x)}" y1="${fmt2(t1.y)}" x2="${fmt2(t2.x)}" y2="${fmt2(t2.y)}" stroke="${theme.dim}" stroke-width="${fmt2(sizes.thin)}"/>`
637
+ });
638
+ }
639
+ const mid = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
640
+ const tp = add(mid, mul(n, sizes.dimFont * 0.7));
641
+ let angle = Math.atan2(dir.y, dir.x) * 180 / Math.PI;
642
+ if (angle > 90) angle -= 180;
643
+ if (angle < -90) angle += 180;
644
+ const label = dm.text ?? String(Math.round(length(sub(dm.to, dm.from))));
645
+ ops.push({
646
+ pass: "dims",
647
+ svg: `<text x="${fmt2(tp.x)}" y="${fmt2(tp.y)}" font-size="${fmt2(sizes.dimFont)}" fill="${theme.dim}" text-anchor="middle" dominant-baseline="central" transform="rotate(${fmt2(angle)} ${fmt2(tp.x)} ${fmt2(tp.y)})">${xml2(label)}</text>`
648
+ });
649
+ return ops;
650
+ }
651
+ };
652
+
653
+ // src/elements/column.ts
654
+ var column = {
655
+ kind: "column",
656
+ keyword: "column",
657
+ parse(ctx) {
658
+ const kw = ctx.eatKeyword("column");
659
+ const id = ctx.parseIdOpt();
660
+ ctx.eatKeyword("at");
661
+ const at = ctx.parsePoint();
662
+ 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 };
665
+ },
666
+ idPrefix: () => "column",
667
+ resolve(node, ctx) {
668
+ 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) };
672
+ if (size.w <= 0 || size.h <= 0) {
673
+ ctx.diag({ severity: "error", message: `Column "${id}" must have a positive size`, code: "E_COLUMN_SIZE", span: n.span });
674
+ }
675
+ return { kind: "column", id, at, size, span: n.span };
676
+ },
677
+ bounds(resolved) {
678
+ const c = resolved;
679
+ return rectCorners(c.at.x, c.at.y, c.size.w, c.size.h);
680
+ },
681
+ render(resolved, ctx) {
682
+ const c = resolved;
683
+ const { fmt: fmt2, pt: pt2, theme, sizes } = ctx;
684
+ const pts = rectCorners(c.at.x, c.at.y, c.size.w, c.size.h);
685
+ return [
686
+ {
687
+ pass: "furniture",
688
+ svg: `<polygon points="${pts.map(pt2).join(" ")}" fill="${theme.column}" stroke="${theme.wallStroke}" stroke-width="${fmt2(sizes.thin)}"/>`
689
+ }
690
+ ];
691
+ }
692
+ };
693
+
694
+ // src/elements/index.ts
695
+ var registryOrder = [];
696
+ var registry = /* @__PURE__ */ new Map();
697
+ function register(def) {
698
+ registry.set(def.keyword, def);
699
+ registryOrder.push(def);
700
+ }
701
+ register(wall);
702
+ register(room);
703
+ register(door);
704
+ register(windowEl);
705
+ register(furniture);
706
+ register(dim);
707
+ register(column);
708
+
141
709
  // src/parser.ts
142
- var STATEMENT_STARTS = /* @__PURE__ */ new Set([
143
- "units",
144
- "grid",
145
- "scale",
146
- "north",
147
- "wall",
148
- "room",
149
- "door",
150
- "window",
151
- "furniture",
152
- "dim",
153
- "title"
154
- ]);
710
+ var SETTINGS = ["units", "grid", "scale", "north", "title"];
711
+ var STATEMENT_STARTS = /* @__PURE__ */ new Set([...SETTINGS, ...registry.keys()]);
155
712
  var ParseError = class extends Error {
156
713
  constructor(message, span) {
157
714
  super(message);
@@ -184,10 +741,26 @@ function parse(src) {
184
741
  var Parser = class {
185
742
  constructor(toks) {
186
743
  this.toks = toks;
744
+ this.ctx = {
745
+ peek: (o) => this.peek(o),
746
+ next: () => this.next(),
747
+ eat: (type) => this.eat(type),
748
+ eatKeyword: (kw) => this.eatKeyword(kw),
749
+ eatIdent: () => this.eatIdent(),
750
+ eatNumber: () => this.eatNumber(),
751
+ eatString: () => this.eatString(),
752
+ isKeyword: (kw, o) => this.isKeyword(kw, o),
753
+ isType: (type) => this.isType(type),
754
+ parsePoint: () => this.parsePoint(),
755
+ parseIdOpt: () => this.parseIdOpt(),
756
+ fail: (msg, t) => this.fail(msg, t)
757
+ };
187
758
  }
188
759
  toks;
189
760
  pos = 0;
190
761
  diagnostics = [];
762
+ /** Facade passed to element parse functions (see registry.ts). */
763
+ ctx;
191
764
  peek(o = 0) {
192
765
  return this.toks[Math.min(this.pos + o, this.toks.length - 1)];
193
766
  }
@@ -217,12 +790,12 @@ var Parser = class {
217
790
  }
218
791
  eatKeyword(kw) {
219
792
  const t = this.peek();
220
- if (t.type !== "ident" || t.value !== kw) this.fail(`Expected "${kw}" but found ${describe(t)}`);
793
+ if (t.type !== "ident" || t.value !== kw) this.fail(`Expected "${kw}" but found ${describe2(t)}`);
221
794
  return this.next();
222
795
  }
223
796
  eat(type) {
224
797
  const t = this.peek();
225
- if (t.type !== type) this.fail(`Expected ${type} but found ${describe(t)}`);
798
+ if (t.type !== type) this.fail(`Expected ${type} but found ${describe2(t)}`);
226
799
  return this.next();
227
800
  }
228
801
  eatIdent() {
@@ -244,18 +817,20 @@ var Parser = class {
244
817
  units: "mm",
245
818
  grid: 0,
246
819
  north: "up",
247
- walls: [],
248
- rooms: [],
249
- doors: [],
250
- windows: [],
251
- furniture: [],
252
- dims: []
820
+ elements: []
253
821
  };
254
822
  while (!this.isType("rcurly") && !this.isType("eof")) {
255
823
  const t = this.peek();
256
824
  const start = t.start;
257
825
  try {
258
- if (t.type !== "ident") this.fail(`Expected a statement but found ${describe(t)}`);
826
+ if (t.type !== "ident") this.fail(`Expected a statement but found ${describe2(t)}`);
827
+ const def = registry.get(t.value);
828
+ if (def) {
829
+ const node = def.parse(this.ctx);
830
+ node.span = this.spanFrom(start);
831
+ plan.elements.push(node);
832
+ continue;
833
+ }
259
834
  switch (t.value) {
260
835
  case "units": {
261
836
  this.next();
@@ -280,42 +855,6 @@ var Parser = class {
280
855
  this.next();
281
856
  plan.north = this.parseNorth();
282
857
  break;
283
- case "wall": {
284
- const n = this.parseWall();
285
- n.span = this.spanFrom(start);
286
- plan.walls.push(n);
287
- break;
288
- }
289
- case "room": {
290
- const n = this.parseRoom();
291
- n.span = this.spanFrom(start);
292
- plan.rooms.push(n);
293
- break;
294
- }
295
- case "door": {
296
- const n = this.parseDoor();
297
- n.span = this.spanFrom(start);
298
- plan.doors.push(n);
299
- break;
300
- }
301
- case "window": {
302
- const n = this.parseWindow();
303
- n.span = this.spanFrom(start);
304
- plan.windows.push(n);
305
- break;
306
- }
307
- case "furniture": {
308
- const n = this.parseFurniture();
309
- n.span = this.spanFrom(start);
310
- plan.furniture.push(n);
311
- break;
312
- }
313
- case "dim": {
314
- const n = this.parseDim();
315
- n.span = this.spanFrom(start);
316
- plan.dims.push(n);
317
- break;
318
- }
319
858
  case "title": {
320
859
  const n = this.parseTitle();
321
860
  n.span = this.spanFrom(start);
@@ -367,7 +906,7 @@ var Parser = class {
367
906
  this.next();
368
907
  return t.value;
369
908
  }
370
- this.fail(`Expected a north direction (up|down|left|right|<degrees>) but found ${describe(t)}`);
909
+ this.fail(`Expected a north direction (up|down|left|right|<degrees>) but found ${describe2(t)}`);
371
910
  }
372
911
  parsePoint() {
373
912
  this.eat("lparen");
@@ -377,123 +916,13 @@ var Parser = class {
377
916
  this.eat("rparen");
378
917
  return { x, y };
379
918
  }
380
- parseWall() {
381
- const kw = this.eatKeyword("wall");
382
- const id = this.parseIdOpt();
383
- const kind = this.eatIdent().value;
384
- this.eatKeyword("thickness");
385
- const thickness = this.eatNumber();
386
- this.eat("lcurly");
387
- const points = [];
388
- let closed = false;
389
- while (!this.isType("rcurly") && !this.isType("eof")) {
390
- if (this.isKeyword("close")) {
391
- this.next();
392
- closed = true;
393
- break;
394
- }
395
- if (this.isType("lparen")) {
396
- points.push(this.parsePoint());
397
- continue;
398
- }
399
- this.fail(`Expected a point "(x,y)" or "close" in wall body but found ${describe(this.peek())}`);
400
- }
401
- this.eat("rcurly");
402
- if (points.length < 2) this.fail("A wall needs at least two points", kw);
403
- return { id, kind, thickness, points, closed, line: kw.line };
404
- }
405
- parseRoom() {
406
- const kw = this.eatKeyword("room");
407
- const id = this.parseIdOpt();
408
- this.eatKeyword("at");
409
- const at = this.parsePoint();
410
- this.eatKeyword("size");
411
- const dim = this.eat("dimension");
412
- const node = { id, at, size: { w: dim.num, h: dim.num2 }, line: kw.line };
413
- if (this.isKeyword("label")) {
414
- this.next();
415
- node.label = this.eatString();
416
- }
417
- return node;
418
- }
419
- parseDoor() {
420
- const kw = this.eatKeyword("door");
421
- const id = this.parseIdOpt();
422
- this.eatKeyword("at");
423
- const at = this.parsePoint();
424
- this.eatKeyword("width");
425
- const width = this.eatNumber();
426
- const node = { id, at, width, hinge: "left", swing: "in", line: kw.line };
427
- if (this.isKeyword("wall")) {
428
- this.next();
429
- node.wall = this.eatIdent().value;
430
- }
431
- if (this.isKeyword("hinge")) {
432
- this.next();
433
- const h = this.eatIdent().value;
434
- if (h !== "left" && h !== "right") this.fail(`Expected hinge "left" or "right" but found "${h}"`);
435
- node.hinge = h;
436
- }
437
- if (this.isKeyword("swing")) {
438
- this.next();
439
- const s = this.eatIdent().value;
440
- if (s !== "in" && s !== "out") this.fail(`Expected swing "in" or "out" but found "${s}"`);
441
- node.swing = s;
442
- }
443
- return node;
444
- }
445
- parseWindow() {
446
- const kw = this.eatKeyword("window");
447
- const id = this.parseIdOpt();
448
- this.eatKeyword("at");
449
- const at = this.parsePoint();
450
- this.eatKeyword("width");
451
- const width = this.eatNumber();
452
- const node = { id, at, width, line: kw.line };
453
- if (this.isKeyword("wall")) {
454
- this.next();
455
- node.wall = this.eatIdent().value;
456
- }
457
- return node;
458
- }
459
- parseFurniture() {
460
- const kw = this.eatKeyword("furniture");
461
- const id = this.parseIdOpt();
462
- const kind = this.eatIdent().value;
463
- this.eatKeyword("at");
464
- const at = this.parsePoint();
465
- this.eatKeyword("size");
466
- const dim = this.eat("dimension");
467
- const node = { id, kind, at, size: { w: dim.num, h: dim.num2 }, line: kw.line };
468
- if (this.isKeyword("label")) {
469
- this.next();
470
- node.label = this.eatString();
471
- }
472
- return node;
473
- }
474
- parseDim() {
475
- const kw = this.eatKeyword("dim");
476
- const from = this.parsePoint();
477
- this.eat("arrow");
478
- const to = this.parsePoint();
479
- const node = { id: "", from, to, offset: 300, line: kw.line };
480
- if (this.isKeyword("offset")) {
481
- this.next();
482
- node.offset = this.eatNumber();
483
- }
484
- if (this.isKeyword("text")) {
485
- this.next();
486
- node.text = this.eatString();
487
- }
488
- return node;
489
- }
490
919
  parseTitle() {
491
920
  const kw = this.eatKeyword("title");
492
921
  this.eat("lcurly");
493
922
  const node = { line: kw.line };
494
923
  while (!this.isType("rcurly") && !this.isType("eof")) {
495
924
  const t = this.peek();
496
- if (t.type !== "ident") this.fail(`Expected a title field but found ${describe(t)}`);
925
+ if (t.type !== "ident") this.fail(`Expected a title field but found ${describe2(t)}`);
497
926
  switch (t.value) {
498
927
  case "project":
499
928
  this.next();
@@ -515,157 +944,24 @@ var Parser = class {
515
944
  return node;
516
945
  }
517
946
  };
518
- function describe(t) {
947
+ function describe2(t) {
519
948
  if (t.type === "eof") return "end of input";
520
949
  if (t.type === "string") return `string ${JSON.stringify(t.value)}`;
521
950
  return `"${t.value}"`;
522
951
  }
523
952
 
524
- // src/geometry.ts
525
- var sub = (a, b) => ({ x: a.x - b.x, y: a.y - b.y });
526
- var add = (a, b) => ({ x: a.x + b.x, y: a.y + b.y });
527
- var mul = (v, s) => ({ x: v.x * s, y: v.y * s });
528
- var length = (v) => Math.hypot(v.x, v.y);
529
- function unit(v) {
530
- const l = length(v);
531
- return l === 0 ? { x: 0, y: 0 } : { x: v.x / l, y: v.y / l };
532
- }
533
- var normal = (v) => ({ x: -v.y, y: v.x });
534
- var emptyBounds = () => ({
535
- minX: Infinity,
536
- minY: Infinity,
537
- maxX: -Infinity,
538
- maxY: -Infinity
539
- });
540
- function extendBounds(b, x, y) {
541
- if (x < b.minX) b.minX = x;
542
- if (y < b.minY) b.minY = y;
543
- if (x > b.maxX) b.maxX = x;
544
- if (y > b.maxY) b.maxY = y;
545
- }
546
- function distPointToSegment(p, a, b) {
547
- const abx = b.x - a.x;
548
- const aby = b.y - a.y;
549
- const apx = p.x - a.x;
550
- const apy = p.y - a.y;
551
- const len2 = abx * abx + aby * aby;
552
- let t = len2 === 0 ? 0 : (apx * abx + apy * aby) / len2;
553
- t = Math.max(0, Math.min(1, t));
554
- const cx = a.x + t * abx;
555
- const cy = a.y + t * aby;
556
- return Math.hypot(p.x - cx, p.y - cy);
557
- }
558
- function rectCorners(x, y, w, h) {
559
- return [
560
- { x, y },
561
- { x: x + w, y },
562
- { x: x + w, y: y + h },
563
- { x, y: y + h }
564
- ];
565
- }
566
- function segmentRectangle(a, b, thickness) {
567
- const d = unit(sub(b, a));
568
- const n = normal(d);
569
- const half = thickness / 2;
570
- const a2 = add(a, mul(d, -half));
571
- const b2 = add(b, mul(d, half));
572
- return [
573
- add(a2, mul(n, half)),
574
- add(b2, mul(n, half)),
575
- add(b2, mul(n, -half)),
576
- add(a2, mul(n, -half))
577
- ];
578
- }
579
- function wallSegments(plan) {
580
- const segs = [];
581
- for (const w of plan.walls) {
582
- for (let k = 0; k < w.points.length - 1; k++) {
583
- segs.push({ a: w.points[k], b: w.points[k + 1], thickness: w.thickness, kind: w.kind });
584
- }
585
- if (w.closed && w.points.length > 2) {
586
- segs.push({ a: w.points[w.points.length - 1], b: w.points[0], thickness: w.thickness, kind: w.kind });
587
- }
588
- }
589
- return segs;
590
- }
591
- function hostSegment(plan, at, wallRef) {
592
- const walls = wallRef ? plan.walls.filter((w) => w.id === wallRef || w.kind === wallRef) : plan.walls;
593
- let best = null;
594
- let bestDist = Infinity;
595
- for (const w of walls) {
596
- const segs = [];
597
- for (let k = 0; k < w.points.length - 1; k++) segs.push([w.points[k], w.points[k + 1]]);
598
- if (w.closed && w.points.length > 2) segs.push([w.points[w.points.length - 1], w.points[0]]);
599
- for (const [a, b] of segs) {
600
- const dist = distPointToSegment(at, a, b);
601
- if (dist < bestDist) {
602
- bestDist = dist;
603
- best = { a, b, thickness: w.thickness, kind: w.kind };
604
- }
605
- }
606
- }
607
- return best;
608
- }
609
- function planBounds(plan) {
610
- const b = emptyBounds();
611
- for (const seg of wallSegments(plan)) {
612
- for (const c of segmentRectangle(seg.a, seg.b, seg.thickness)) extendBounds(b, c.x, c.y);
613
- }
614
- for (const r of plan.rooms) {
615
- extendBounds(b, r.at.x, r.at.y);
616
- extendBounds(b, r.at.x + r.size.w, r.at.y + r.size.h);
617
- }
618
- for (const f of plan.furniture) {
619
- extendBounds(b, f.at.x, f.at.y);
620
- extendBounds(b, f.at.x + f.size.w, f.at.y + f.size.h);
621
- }
622
- for (const d of plan.dims) {
623
- extendBounds(b, d.from.x, d.from.y);
624
- extendBounds(b, d.to.x, d.to.y);
625
- }
626
- if (!isFinite(b.minX)) {
627
- return { minX: 0, minY: 0, maxX: 1e3, maxY: 1e3 };
628
- }
629
- return b;
630
- }
631
-
632
- // src/validate.ts
633
- function validate(plan) {
634
- const diags = [];
635
- const error = (message, code, span) => diags.push({ severity: "error", message, code, span });
636
- const warn = (message, code, span) => diags.push({ severity: "warning", message, code, span });
637
- const g = plan.grid;
953
+ // src/ir.ts
954
+ function resolve(ast) {
955
+ const diagnostics = [];
956
+ const g = ast.grid;
638
957
  const snap = (v) => g > 0 ? Math.round(v / g) * g : v;
639
958
  const snapPt = (p) => ({ x: snap(p.x), y: snap(p.y) });
640
- for (const w of plan.walls) {
641
- w.points = w.points.map(snapPt);
642
- w.thickness = snap(w.thickness) || w.thickness;
643
- }
644
- for (const r of plan.rooms) {
645
- r.at = snapPt(r.at);
646
- r.size = { w: snap(r.size.w), h: snap(r.size.h) };
647
- }
648
- for (const f of plan.furniture) {
649
- f.at = snapPt(f.at);
650
- f.size = { w: snap(f.size.w), h: snap(f.size.h) };
651
- }
652
- for (const d of plan.doors) {
653
- d.at = snapPt(d.at);
654
- d.width = snap(d.width) || d.width;
655
- }
656
- for (const win of plan.windows) {
657
- win.at = snapPt(win.at);
658
- win.width = snap(win.width) || win.width;
659
- }
660
- for (const dm of plan.dims) {
661
- dm.from = snapPt(dm.from);
662
- dm.to = snapPt(dm.to);
663
- }
959
+ const idMap = /* @__PURE__ */ new Map();
664
960
  const seen = /* @__PURE__ */ new Set();
665
- const assign = (provided, prefix, idx, span) => {
961
+ const assignId = (provided, prefix, idx, span) => {
666
962
  if (provided) {
667
963
  if (seen.has(provided)) {
668
- error(`Duplicate id "${provided}"`, "E_DUP_ID", span);
964
+ diagnostics.push({ severity: "error", message: `Duplicate id "${provided}"`, code: "E_DUP_ID", span });
669
965
  }
670
966
  seen.add(provided);
671
967
  return provided;
@@ -675,68 +971,88 @@ function validate(plan) {
675
971
  seen.add(auto);
676
972
  return auto;
677
973
  };
678
- plan.walls.forEach((w, i) => w.id = assign(w.id, w.kind || "wall", i + 1, w.span));
679
- plan.rooms.forEach((r, i) => r.id = assign(r.id, "room", i + 1, r.span));
680
- plan.doors.forEach((d, i) => d.id = assign(d.id, "door", i + 1, d.span));
681
- plan.windows.forEach((w, i) => w.id = assign(w.id, "window", i + 1, w.span));
682
- plan.furniture.forEach((f, i) => f.id = assign(f.id, f.kind || "furniture", i + 1, f.span));
683
- plan.dims.forEach((d, i) => d.id = assign(d.id, "dim", i + 1, d.span));
684
- for (const r of plan.rooms) {
685
- if (r.size.w <= 0 || r.size.h <= 0)
686
- error(`Room "${r.id}" must have a positive size`, "E_ROOM_SIZE", r.span);
687
- }
688
- for (const f of plan.furniture) {
689
- if (f.size.w <= 0 || f.size.h <= 0)
690
- error(`Furniture "${f.id}" must have a positive size`, "E_FURN_SIZE", f.span);
691
- }
692
- for (const d of plan.doors) {
693
- if (d.width <= 0) error(`Door "${d.id}" must have a positive width`, "E_DOOR_WIDTH", d.span);
694
- }
695
- for (const w of plan.windows) {
696
- if (w.width <= 0) error(`Window "${w.id}" must have a positive width`, "E_WINDOW_WIDTH", w.span);
697
- }
698
- for (const w of plan.walls) {
699
- if (w.thickness <= 0)
700
- error(`Wall "${w.id}" must have a positive thickness`, "E_WALL_THICKNESS", w.span);
701
- }
702
- if (plan.walls.length === 0 && plan.rooms.length === 0 && plan.furniture.length === 0) {
703
- warn("Plan has no walls, rooms, or furniture \u2014 nothing to draw", "W_EMPTY_PLAN");
704
- }
705
- const onSomeWall = (at, wallRef) => {
706
- const candidates = wallRef ? plan.walls.filter((w) => w.id === wallRef || w.kind === wallRef) : plan.walls;
707
- for (const w of candidates) {
708
- const tol = w.thickness / 2 + Math.max(w.thickness, 1);
709
- for (let k = 0; k < w.points.length - 1; k++) {
710
- if (distPointToSegment(at, w.points[k], w.points[k + 1]) <= tol) return true;
711
- }
712
- if (w.closed && w.points.length > 2) {
713
- if (distPointToSegment(at, w.points[w.points.length - 1], w.points[0]) <= tol) return true;
714
- }
974
+ for (const def of registryOrder) {
975
+ let idx = 0;
976
+ for (const node of ast.elements) {
977
+ if (node.kind !== def.kind) continue;
978
+ idx++;
979
+ idMap.set(node, assignId(node.id, def.idPrefix(node), idx, node.span));
715
980
  }
716
- return false;
981
+ }
982
+ const walls = [];
983
+ const ctx = {
984
+ grid: g,
985
+ snap,
986
+ snapPt,
987
+ idOf: (node) => idMap.get(node) ?? node.id,
988
+ walls,
989
+ hostSegment: (at, ref) => hostSegmentForWalls(walls, at, ref),
990
+ isOnWall: (at, ref) => isOnSomeWall(walls, at, ref),
991
+ diag: (d) => diagnostics.push(d)
717
992
  };
718
- for (const d of plan.doors) {
719
- if (plan.walls.length > 0 && !onSomeWall(d.at, d.wall))
720
- warn(`Door "${d.id}" does not lie on any wall`, "W_DOOR_OFF_WALL", d.span);
993
+ const rmap = /* @__PURE__ */ new Map();
994
+ 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);
999
+ if (r.kind === "wall") walls.push(r);
1000
+ }
721
1001
  }
722
- for (const w of plan.windows) {
723
- if (plan.walls.length > 0 && !onSomeWall(w.at, w.wall))
724
- warn(`Window "${w.id}" does not lie on any wall`, "W_WINDOW_OFF_WALL", w.span);
1002
+ const elements = ast.elements.map((n) => rmap.get(n));
1003
+ const drawable = elements.some(
1004
+ (e) => e.kind === "wall" || e.kind === "room" || e.kind === "furniture" || e.kind === "column"
1005
+ );
1006
+ if (!drawable) {
1007
+ diagnostics.push({
1008
+ severity: "warning",
1009
+ message: "Plan has no walls, rooms, or furniture \u2014 nothing to draw",
1010
+ code: "W_EMPTY_PLAN"
1011
+ });
725
1012
  }
726
- for (let a = 0; a < plan.rooms.length; a++) {
727
- for (let b = a + 1; b < plan.rooms.length; b++) {
728
- const r1 = plan.rooms[a];
729
- const r2 = plan.rooms[b];
1013
+ const rooms = elements.filter((e) => e.kind === "room");
1014
+ for (let a = 0; a < rooms.length; a++) {
1015
+ for (let b = a + 1; b < rooms.length; b++) {
1016
+ const r1 = rooms[a];
1017
+ const r2 = rooms[b];
730
1018
  const ox = Math.max(0, Math.min(r1.at.x + r1.size.w, r2.at.x + r2.size.w) - Math.max(r1.at.x, r2.at.x));
731
1019
  const oy = Math.max(0, Math.min(r1.at.y + r1.size.h, r2.at.y + r2.size.h) - Math.max(r1.at.y, r2.at.y));
732
1020
  if (ox > 1 && oy > 1) {
733
- warn(`Rooms "${r1.id}" and "${r2.id}" overlap`, "W_ROOM_OVERLAP", r2.span);
1021
+ diagnostics.push({
1022
+ severity: "warning",
1023
+ message: `Rooms "${r1.id}" and "${r2.id}" overlap`,
1024
+ code: "W_ROOM_OVERLAP",
1025
+ span: r2.span
1026
+ });
734
1027
  }
735
1028
  }
736
1029
  }
737
- return diags;
1030
+ const ir = {
1031
+ name: ast.name,
1032
+ units: ast.units,
1033
+ grid: ast.grid,
1034
+ scale: ast.scale,
1035
+ north: ast.north,
1036
+ title: ast.title,
1037
+ elements,
1038
+ walls
1039
+ };
1040
+ return { ir, diagnostics };
738
1041
  }
739
1042
 
1043
+ // src/registry.ts
1044
+ var RENDER_PASSES = [
1045
+ "floor",
1046
+ "furniture",
1047
+ "wallFill",
1048
+ "wallFace",
1049
+ "doors",
1050
+ "windows",
1051
+ "labels",
1052
+ "dims",
1053
+ "annotations"
1054
+ ];
1055
+
740
1056
  // src/render.ts
741
1057
  var THEME = {
742
1058
  bg: "#ffffff",
@@ -754,7 +1070,8 @@ var THEME = {
754
1070
  windowPane: "#3a6ea5",
755
1071
  dim: "#0E5484",
756
1072
  annotation: "#333333",
757
- annotationMuted: "#888888"
1073
+ annotationMuted: "#888888",
1074
+ column: "#4a4a4a"
758
1075
  };
759
1076
  function fmt(v) {
760
1077
  const r = Math.round(v * 100) / 100;
@@ -770,19 +1087,35 @@ function niceBarLength(target) {
770
1087
  for (const v of NICE_LENGTHS) if (v <= target) best = v;
771
1088
  return best;
772
1089
  }
773
- function render(plan, opts = {}) {
774
- const b = planBounds(plan);
1090
+ function planBounds(ir) {
1091
+ const b = emptyBounds();
1092
+ for (const el of ir.elements) {
1093
+ const def = registry.get(el.kind);
1094
+ if (!def) continue;
1095
+ for (const p of def.bounds(el)) extendBounds(b, p.x, p.y);
1096
+ }
1097
+ if (!isFinite(b.minX)) {
1098
+ return { minX: 0, minY: 0, maxX: 1e3, maxY: 1e3 };
1099
+ }
1100
+ return b;
1101
+ }
1102
+ function render(ir, opts = {}) {
1103
+ const b = planBounds(ir);
775
1104
  const drawW = b.maxX - b.minX;
776
1105
  const drawH = b.maxY - b.minY;
777
1106
  const refDim = Math.max(drawW, drawH, 1);
778
- const wallStroke = refDim * 28e-4;
779
- const thin = refDim * 16e-4;
780
- const roomFont = refDim * 0.03;
781
- const areaFont = refDim * 0.022;
782
- const dimFont = refDim * 0.02;
783
- const furnFont = refDim * 0.017;
784
- const margin = refDim * 0.17;
785
- const hatchGap = refDim * 0.013;
1107
+ const sizes = {
1108
+ refDim,
1109
+ wallStroke: refDim * 28e-4,
1110
+ thin: refDim * 16e-4,
1111
+ roomFont: refDim * 0.03,
1112
+ areaFont: refDim * 0.022,
1113
+ dimFont: refDim * 0.02,
1114
+ furnFont: refDim * 0.017,
1115
+ margin: refDim * 0.17,
1116
+ hatchGap: refDim * 0.013
1117
+ };
1118
+ const { thin, margin, hatchGap } = sizes;
786
1119
  const vbX = b.minX - margin;
787
1120
  const vbY = b.minY - margin;
788
1121
  const vbW = drawW + margin * 2;
@@ -796,157 +1129,27 @@ function render(plan, opts = {}) {
796
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>`
797
1130
  );
798
1131
  out.push(`<rect x="${fmt(vbX)}" y="${fmt(vbY)}" width="${fmt(vbW)}" height="${fmt(vbH)}" fill="${THEME.bg}"/>`);
799
- for (const r of plan.rooms) {
800
- const c = rectCorners(r.at.x, r.at.y, r.size.w, r.size.h);
801
- out.push(`<polygon points="${c.map(pt).join(" ")}" fill="${THEME.roomFill}"/>`);
802
- }
803
- for (const f of plan.furniture) {
804
- const c = rectCorners(f.at.x, f.at.y, f.size.w, f.size.h);
805
- out.push(
806
- `<polygon points="${c.map(pt).join(" ")}" fill="${THEME.furnitureFill}" stroke="${THEME.furnitureStroke}" stroke-width="${fmt(thin)}"/>`
807
- );
808
- if (f.label) {
809
- const cx = f.at.x + f.size.w / 2;
810
- const cy = f.at.y + f.size.h / 2;
811
- out.push(
812
- `<text x="${fmt(cx)}" y="${fmt(cy)}" font-size="${fmt(furnFont)}" fill="${THEME.furnitureLabel}" text-anchor="middle" dominant-baseline="central">${xml(f.label)}</text>`
813
- );
814
- }
815
- }
816
- const segs = wallSegments(plan);
817
- for (const s of segs) {
818
- const poly = segmentRectangle(s.a, s.b, s.thickness);
819
- out.push(`<polygon points="${poly.map(pt).join(" ")}" fill="url(#poche)"/>`);
820
- }
821
- for (const s of segs) {
822
- const d = unit(sub(s.b, s.a));
823
- const n = normal(d);
824
- const h = s.thickness / 2;
825
- const fa1 = add(s.a, mul(n, h));
826
- const fb1 = add(s.b, mul(n, h));
827
- const fa2 = add(s.a, mul(n, -h));
828
- const fb2 = add(s.b, mul(n, -h));
829
- out.push(
830
- `<line x1="${fmt(fa1.x)}" y1="${fmt(fa1.y)}" x2="${fmt(fb1.x)}" y2="${fmt(fb1.y)}" stroke="${THEME.wallStroke}" stroke-width="${fmt(wallStroke)}" stroke-linecap="square"/>`
831
- );
832
- out.push(
833
- `<line x1="${fmt(fa2.x)}" y1="${fmt(fa2.y)}" x2="${fmt(fb2.x)}" y2="${fmt(fb2.y)}" stroke="${THEME.wallStroke}" stroke-width="${fmt(wallStroke)}" stroke-linecap="square"/>`
834
- );
835
- }
836
- for (const dr of plan.doors) {
837
- const seg = hostSegment(plan, dr.at, dr.wall);
838
- if (!seg) continue;
839
- const d = unit(sub(seg.b, seg.a));
840
- const n = normal(d);
841
- const h = seg.thickness / 2 + wallStroke;
842
- const hw = dr.width / 2;
843
- const cover = [
844
- add(add(dr.at, mul(d, -hw)), mul(n, h)),
845
- add(add(dr.at, mul(d, hw)), mul(n, h)),
846
- add(add(dr.at, mul(d, hw)), mul(n, -h)),
847
- add(add(dr.at, mul(d, -hw)), mul(n, -h))
848
- ];
849
- out.push(`<polygon points="${cover.map(pt).join(" ")}" fill="${THEME.opening}"/>`);
850
- const hinge = dr.hinge === "left" ? add(dr.at, mul(d, -hw)) : add(dr.at, mul(d, hw));
851
- const farJamb = dr.hinge === "left" ? add(dr.at, mul(d, hw)) : add(dr.at, mul(d, -hw));
852
- const leafDir = dr.swing === "in" ? n : mul(n, -1);
853
- const leafEnd = add(hinge, mul(leafDir, dr.width));
854
- const cross = (leafEnd.x - hinge.x) * (farJamb.y - hinge.y) - (leafEnd.y - hinge.y) * (farJamb.x - hinge.x);
855
- const sweep = cross < 0 ? 1 : 0;
856
- out.push(
857
- `<line x1="${fmt(hinge.x)}" y1="${fmt(hinge.y)}" x2="${fmt(leafEnd.x)}" y2="${fmt(leafEnd.y)}" stroke="${THEME.doorLeaf}" stroke-width="${fmt(thin * 1.3)}"/>`
858
- );
859
- out.push(
860
- `<path d="M ${pt(leafEnd)} A ${fmt(dr.width)} ${fmt(dr.width)} 0 0 ${sweep} ${pt(farJamb)}" fill="none" stroke="${THEME.doorLeaf}" stroke-width="${fmt(thin)}" stroke-dasharray="${fmt(thin * 4)} ${fmt(thin * 3)}"/>`
861
- );
862
- }
863
- for (const wn of plan.windows) {
864
- const seg = hostSegment(plan, wn.at, wn.wall);
865
- if (!seg) continue;
866
- const d = unit(sub(seg.b, seg.a));
867
- const n = normal(d);
868
- const h = seg.thickness / 2;
869
- const he = h + wallStroke;
870
- const hw = wn.width / 2;
871
- const cover = [
872
- add(add(wn.at, mul(d, -hw)), mul(n, he)),
873
- add(add(wn.at, mul(d, hw)), mul(n, he)),
874
- add(add(wn.at, mul(d, hw)), mul(n, -he)),
875
- add(add(wn.at, mul(d, -hw)), mul(n, -he))
876
- ];
877
- out.push(`<polygon points="${cover.map(pt).join(" ")}" fill="${THEME.opening}"/>`);
878
- const jA = add(wn.at, mul(d, -hw));
879
- const jB = add(wn.at, mul(d, hw));
880
- for (const off of [h, -h]) {
881
- const a = add(jA, mul(n, off));
882
- const bb = add(jB, mul(n, off));
883
- out.push(
884
- `<line x1="${fmt(a.x)}" y1="${fmt(a.y)}" x2="${fmt(bb.x)}" y2="${fmt(bb.y)}" stroke="${THEME.wallStroke}" stroke-width="${fmt(thin)}"/>`
885
- );
886
- }
887
- out.push(
888
- `<line x1="${fmt(jA.x)}" y1="${fmt(jA.y)}" x2="${fmt(jB.x)}" y2="${fmt(jB.y)}" stroke="${THEME.windowPane}" stroke-width="${fmt(thin)}"/>`
889
- );
890
- }
891
- for (const r of plan.rooms) {
892
- const cx = r.at.x + r.size.w / 2;
893
- const cy = r.at.y + r.size.h / 2;
894
- const areaM2 = (r.size.w / 1e3 * (r.size.h / 1e3)).toFixed(1);
895
- if (r.label) {
896
- out.push(
897
- `<text x="${fmt(cx)}" y="${fmt(cy - roomFont * 0.2)}" font-size="${fmt(roomFont)}" fill="${THEME.roomLabel}" text-anchor="middle" dominant-baseline="central" font-weight="600">${xml(r.label)}</text>`
898
- );
899
- }
900
- out.push(
901
- `<text x="${fmt(cx)}" y="${fmt(cy + (r.label ? roomFont * 0.9 : 0))}" font-size="${fmt(areaFont)}" fill="${THEME.areaLabel}" text-anchor="middle" dominant-baseline="central">${areaM2} m\xB2</text>`
902
- );
903
- }
904
- for (const dm of plan.dims) {
905
- const dir = unit(sub(dm.to, dm.from));
906
- const n = normal(dir);
907
- const off = mul(n, dm.offset);
908
- const p1 = add(dm.from, off);
909
- const p2 = add(dm.to, off);
910
- const tick = refDim * 0.012;
911
- out.push(
912
- `<line x1="${fmt(dm.from.x)}" y1="${fmt(dm.from.y)}" x2="${fmt(p1.x)}" y2="${fmt(p1.y)}" stroke="${THEME.dim}" stroke-width="${fmt(thin * 0.7)}"/>`
913
- );
914
- out.push(
915
- `<line x1="${fmt(dm.to.x)}" y1="${fmt(dm.to.y)}" x2="${fmt(p2.x)}" y2="${fmt(p2.y)}" stroke="${THEME.dim}" stroke-width="${fmt(thin * 0.7)}"/>`
916
- );
917
- out.push(
918
- `<line x1="${fmt(p1.x)}" y1="${fmt(p1.y)}" x2="${fmt(p2.x)}" y2="${fmt(p2.y)}" stroke="${THEME.dim}" stroke-width="${fmt(thin)}"/>`
919
- );
920
- for (const p of [p1, p2]) {
921
- const t1 = add(p, mul(unit({ x: dir.x + n.x, y: dir.y + n.y }), tick));
922
- const t2 = add(p, mul(unit({ x: dir.x + n.x, y: dir.y + n.y }), -tick));
923
- out.push(
924
- `<line x1="${fmt(t1.x)}" y1="${fmt(t1.y)}" x2="${fmt(t2.x)}" y2="${fmt(t2.y)}" stroke="${THEME.dim}" stroke-width="${fmt(thin)}"/>`
925
- );
926
- }
927
- const mid = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
928
- const tp = add(mid, mul(n, dimFont * 0.7));
929
- let angle = Math.atan2(dir.y, dir.x) * 180 / Math.PI;
930
- if (angle > 90) angle -= 180;
931
- if (angle < -90) angle += 180;
932
- const label = dm.text ?? String(Math.round(length(sub(dm.to, dm.from))));
933
- out.push(
934
- `<text x="${fmt(tp.x)}" y="${fmt(tp.y)}" font-size="${fmt(dimFont)}" fill="${THEME.dim}" text-anchor="middle" dominant-baseline="central" transform="rotate(${fmt(angle)} ${fmt(tp.x)} ${fmt(tp.y)})">${xml(label)}</text>`
935
- );
1132
+ const ctx = { fmt, pt, xml, theme: THEME, sizes, bounds: b };
1133
+ const ops = ir.elements.flatMap((el) => {
1134
+ const def = registry.get(el.kind);
1135
+ return def ? def.render(el, ctx) : [];
1136
+ });
1137
+ for (const pass of RENDER_PASSES) {
1138
+ for (const op of ops) if (op.pass === pass) out.push(op.svg);
936
1139
  }
937
- out.push(northArrow(plan, b, margin, refDim));
1140
+ out.push(northArrow(ir, b, margin, refDim));
938
1141
  out.push(scaleBar(b, margin, refDim, thin));
939
- const tb = titleBlock(plan, b, margin, refDim, thin);
1142
+ const tb = titleBlock(ir, b, margin, refDim, thin);
940
1143
  if (tb) out.push(tb);
941
1144
  out.push("</svg>");
942
1145
  return out.join("\n");
943
1146
  }
944
- function northArrow(plan, b, margin, refDim) {
1147
+ function northArrow(ir, b, margin, refDim) {
945
1148
  const r = refDim * 0.045;
946
1149
  const cx = b.maxX - r;
947
1150
  const cy = b.minY - margin * 0.55;
948
1151
  let deg;
949
- switch (plan.north) {
1152
+ switch (ir.north) {
950
1153
  case "up":
951
1154
  deg = 0;
952
1155
  break;
@@ -960,7 +1163,7 @@ function northArrow(plan, b, margin, refDim) {
960
1163
  deg = 90;
961
1164
  break;
962
1165
  default:
963
- deg = typeof plan.north === "object" ? plan.north.deg : 0;
1166
+ deg = typeof ir.north === "object" ? ir.north.deg : 0;
964
1167
  }
965
1168
  const fs = refDim * 0.026;
966
1169
  const tri = `${fmt(cx)},${fmt(cy - r)} ${fmt(cx - r * 0.5)},${fmt(cy + r * 0.6)} ${fmt(cx)},${fmt(cy + r * 0.25)} ${fmt(cx + r * 0.5)},${fmt(cy + r * 0.6)}`;
@@ -991,9 +1194,9 @@ function scaleBar(b, margin, refDim, thin) {
991
1194
  );
992
1195
  return `<g>${parts.join("")}</g>`;
993
1196
  }
994
- function titleBlock(plan, b, margin, refDim, thin) {
995
- const t = plan.title;
996
- if (!t && !plan.scale) return null;
1197
+ function titleBlock(ir, b, margin, refDim, thin) {
1198
+ const t = ir.title;
1199
+ if (!t && !ir.scale) return null;
997
1200
  const boxW = refDim * 0.34;
998
1201
  const boxH = margin * 0.82;
999
1202
  const x0 = b.maxX - boxW;
@@ -1004,7 +1207,7 @@ function titleBlock(plan, b, margin, refDim, thin) {
1004
1207
  if (t?.project) lines.push({ k: "PROJECT", v: t.project });
1005
1208
  if (t?.drawnBy) lines.push({ k: "DRAWN BY", v: t.drawnBy });
1006
1209
  if (t?.date) lines.push({ k: "DATE", v: t.date });
1007
- if (plan.scale) lines.push({ k: "SCALE", v: plan.scale });
1210
+ if (ir.scale) lines.push({ k: "SCALE", v: ir.scale });
1008
1211
  const parts = [];
1009
1212
  parts.push(
1010
1213
  `<rect x="${fmt(x0)}" y="${fmt(y0)}" width="${fmt(boxW)}" height="${fmt(boxH)}" fill="none" stroke="${THEME.annotation}" stroke-width="${fmt(thin)}"/>`
@@ -1106,11 +1309,12 @@ function toLegacy(source, d) {
1106
1309
  }
1107
1310
  function compileUncached(source, opts) {
1108
1311
  const { plan, diagnostics: parseDiags } = parse(source);
1109
- const diagnostics = plan ? [...parseDiags, ...validate(plan)] : [...parseDiags];
1312
+ const resolved = plan ? resolve(plan) : null;
1313
+ const diagnostics = resolved ? [...parseDiags, ...resolved.diagnostics] : [...parseDiags];
1110
1314
  const errs = diagnostics.filter((d) => d.severity === "error");
1111
1315
  const errors = errs.map((d) => toLegacy(source, d));
1112
1316
  const warnings = diagnostics.filter((d) => d.severity === "warning").map((d) => toLegacy(source, d));
1113
- const svg = plan && errs.length === 0 ? render(plan, opts) : "";
1317
+ const svg = resolved && errs.length === 0 ? render(resolved.ir, opts) : "";
1114
1318
  return { svg, errors, warnings, diagnostics, ast: plan };
1115
1319
  }
1116
1320
  function clearCache() {
@@ -1123,4 +1327,4 @@ export {
1123
1327
  compile,
1124
1328
  clearCache
1125
1329
  };
1126
- //# sourceMappingURL=chunk-ICYNEDSM.js.map
1330
+ //# sourceMappingURL=chunk-3YUQPQPZ.js.map