wadi-mcp 0.1.31 → 0.1.32

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.
Files changed (2) hide show
  1. package/dist/server.mjs +1557 -194
  2. package/package.json +1 -1
package/dist/server.mjs CHANGED
@@ -14927,12 +14927,12 @@ var init_util = __esm({
14927
14927
  function flattenError2(error53, mapper = (issue3) => issue3.message) {
14928
14928
  const fieldErrors = {};
14929
14929
  const formErrors = [];
14930
- for (const sub of error53.issues) {
14931
- if (sub.path.length > 0) {
14932
- fieldErrors[sub.path[0]] = fieldErrors[sub.path[0]] || [];
14933
- fieldErrors[sub.path[0]].push(mapper(sub));
14930
+ for (const sub2 of error53.issues) {
14931
+ if (sub2.path.length > 0) {
14932
+ fieldErrors[sub2.path[0]] = fieldErrors[sub2.path[0]] || [];
14933
+ fieldErrors[sub2.path[0]].push(mapper(sub2));
14934
14934
  } else {
14935
- formErrors.push(mapper(sub));
14935
+ formErrors.push(mapper(sub2));
14936
14936
  }
14937
14937
  }
14938
14938
  return { formErrors, fieldErrors };
@@ -25307,13 +25307,13 @@ function _promise(Class3, innerType) {
25307
25307
  }
25308
25308
  // @__NO_SIDE_EFFECTS__
25309
25309
  function _custom2(Class3, fn, _params) {
25310
- const norm = normalizeParams2(_params);
25311
- norm.abort ?? (norm.abort = true);
25310
+ const norm2 = normalizeParams2(_params);
25311
+ norm2.abort ?? (norm2.abort = true);
25312
25312
  const schema = new Class3({
25313
25313
  type: "custom",
25314
25314
  check: "custom",
25315
25315
  fn,
25316
- ...norm
25316
+ ...norm2
25317
25317
  });
25318
25318
  return schema;
25319
25319
  }
@@ -29624,7 +29624,20 @@ var init_houseConfig = __esm({
29624
29624
  rise_height: positive().optional(),
29625
29625
  step_rise: positive(),
29626
29626
  step_tread: positive(),
29627
- step_width: positive(),
29627
+ // BOX MODEL (preferred): give a `width` (X) × `length` (Y) rectangle and the
29628
+ // WHOLE staircase — flights + turn landings — is packed to fit INSIDE it.
29629
+ // (start_x,start_y) is then the box's min corner, NOT the first step, and each
29630
+ // flight's width is DERIVED from the box: two switchback lanes =
29631
+ // (lateral − flight_gap)/2, or the full box for a single flight. `direction`
29632
+ // picks the run axis (N/S → run along length/Y; E/W → run along width/X). The
29633
+ // model errors if the box is too small for even the tightest split. Omit both
29634
+ // to use the legacy `step_width` + `max_run` form below.
29635
+ width: positive().optional(),
29636
+ length: positive().optional(),
29637
+ // Legacy per-step width (required by the old form; derived from the box in the
29638
+ // box model). The expanded single-flight staircases the renderer consumes always
29639
+ // carry it.
29640
+ step_width: positive().optional(),
29628
29641
  // The direction the stair EXTENDS from its top — the whole assembly fills the
29629
29642
  // allocated box from (start_x,start_y) going this way for up to `max_run`.
29630
29643
  direction: side,
@@ -95426,7 +95439,7 @@ var require_three = __commonJS({
95426
95439
  * @param {Color} cross - The cross line color.
95427
95440
  * @return {CameraHelper} A reference to this helper.
95428
95441
  */
95429
- setColors(frustum, cone, up, target, cross) {
95442
+ setColors(frustum, cone, up, target, cross2) {
95430
95443
  const geometry = this.geometry;
95431
95444
  const colorAttribute = geometry.getAttribute("color");
95432
95445
  colorAttribute.setXYZ(0, frustum.r, frustum.g, frustum.b);
@@ -95469,16 +95482,16 @@ var require_three = __commonJS({
95469
95482
  colorAttribute.setXYZ(37, up.r, up.g, up.b);
95470
95483
  colorAttribute.setXYZ(38, target.r, target.g, target.b);
95471
95484
  colorAttribute.setXYZ(39, target.r, target.g, target.b);
95472
- colorAttribute.setXYZ(40, cross.r, cross.g, cross.b);
95473
- colorAttribute.setXYZ(41, cross.r, cross.g, cross.b);
95474
- colorAttribute.setXYZ(42, cross.r, cross.g, cross.b);
95475
- colorAttribute.setXYZ(43, cross.r, cross.g, cross.b);
95476
- colorAttribute.setXYZ(44, cross.r, cross.g, cross.b);
95477
- colorAttribute.setXYZ(45, cross.r, cross.g, cross.b);
95478
- colorAttribute.setXYZ(46, cross.r, cross.g, cross.b);
95479
- colorAttribute.setXYZ(47, cross.r, cross.g, cross.b);
95480
- colorAttribute.setXYZ(48, cross.r, cross.g, cross.b);
95481
- colorAttribute.setXYZ(49, cross.r, cross.g, cross.b);
95485
+ colorAttribute.setXYZ(40, cross2.r, cross2.g, cross2.b);
95486
+ colorAttribute.setXYZ(41, cross2.r, cross2.g, cross2.b);
95487
+ colorAttribute.setXYZ(42, cross2.r, cross2.g, cross2.b);
95488
+ colorAttribute.setXYZ(43, cross2.r, cross2.g, cross2.b);
95489
+ colorAttribute.setXYZ(44, cross2.r, cross2.g, cross2.b);
95490
+ colorAttribute.setXYZ(45, cross2.r, cross2.g, cross2.b);
95491
+ colorAttribute.setXYZ(46, cross2.r, cross2.g, cross2.b);
95492
+ colorAttribute.setXYZ(47, cross2.r, cross2.g, cross2.b);
95493
+ colorAttribute.setXYZ(48, cross2.r, cross2.g, cross2.b);
95494
+ colorAttribute.setXYZ(49, cross2.r, cross2.g, cross2.b);
95482
95495
  colorAttribute.needsUpdate = true;
95483
95496
  return this;
95484
95497
  }
@@ -116060,11 +116073,11 @@ No matching component was found for:
116060
116073
  return reconciler.flushSyncFromReconciler(fn);
116061
116074
  }
116062
116075
  function createSubs(callback, subs) {
116063
- const sub = {
116076
+ const sub2 = {
116064
116077
  callback
116065
116078
  };
116066
- subs.add(sub);
116067
- return () => void subs.delete(sub);
116079
+ subs.add(sub2);
116080
+ return () => void subs.delete(sub2);
116068
116081
  }
116069
116082
  var globalEffects = /* @__PURE__ */ new Set();
116070
116083
  var globalAfterEffects = /* @__PURE__ */ new Set();
@@ -131121,11 +131134,11 @@ No matching component was found for:
131121
131134
  return reconciler.flushSyncFromReconciler(fn);
131122
131135
  }
131123
131136
  function createSubs(callback, subs) {
131124
- const sub = {
131137
+ const sub2 = {
131125
131138
  callback
131126
131139
  };
131127
- subs.add(sub);
131128
- return () => void subs.delete(sub);
131140
+ subs.add(sub2);
131141
+ return () => void subs.delete(sub2);
131129
131142
  }
131130
131143
  var globalEffects = /* @__PURE__ */ new Set();
131131
131144
  var globalAfterEffects = /* @__PURE__ */ new Set();
@@ -134039,8 +134052,8 @@ var require_triangle_9e5a8229_cjs_prod = __commonJS({
134039
134052
  if (arePointsCollinear(points)) return false;
134040
134053
  var p2p1 = mv1.subVectors(p2, p1);
134041
134054
  var p3p1 = mv2.subVectors(p3, p1);
134042
- var cross = p3p1.cross(p2p1);
134043
- return cross > 0;
134055
+ var cross2 = p3p1.cross(p2p1);
134056
+ return cross2 > 0;
134044
134057
  }
134045
134058
  var triangle = /* @__PURE__ */ Object.freeze({
134046
134059
  __proto__: null,
@@ -134292,7 +134305,7 @@ var require_vector2_49e42f03_cjs_prod = __commonJS({
134292
134305
  function addValue(a, n3) {
134293
134306
  return [a[0] + n3, a[1] + n3];
134294
134307
  }
134295
- function sub(a, b) {
134308
+ function sub2(a, b) {
134296
134309
  return [a[0] - b[0], a[1] - b[1]];
134297
134310
  }
134298
134311
  function subValue(a, n3) {
@@ -134319,7 +134332,7 @@ var require_vector2_49e42f03_cjs_prod = __commonJS({
134319
134332
  one,
134320
134333
  add,
134321
134334
  addValue,
134322
- sub,
134335
+ sub: sub2,
134323
134336
  subValue,
134324
134337
  scale,
134325
134338
  dot: dot2,
@@ -134335,7 +134348,7 @@ var require_vector2_49e42f03_cjs_prod = __commonJS({
134335
134348
  exports2.lengthSqr = lengthSqr;
134336
134349
  exports2.one = one;
134337
134350
  exports2.scale = scale;
134338
- exports2.sub = sub;
134351
+ exports2.sub = sub2;
134339
134352
  exports2.subValue = subValue;
134340
134353
  exports2.vector2 = vector2;
134341
134354
  exports2.zero = zero;
@@ -134358,7 +134371,7 @@ var require_vector3_4bbdb053_cjs_prod = __commonJS({
134358
134371
  function addValue(a, n3) {
134359
134372
  return [a[0] + n3, a[1] + n3, a[2] + n3];
134360
134373
  }
134361
- function sub(a, b) {
134374
+ function sub2(a, b) {
134362
134375
  return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
134363
134376
  }
134364
134377
  function subValue(a, n3) {
@@ -134370,7 +134383,7 @@ var require_vector3_4bbdb053_cjs_prod = __commonJS({
134370
134383
  function dot2(a, b) {
134371
134384
  return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
134372
134385
  }
134373
- function cross(a, b) {
134386
+ function cross2(a, b) {
134374
134387
  var x = a[1] * b[2] - a[2] * b[1];
134375
134388
  var y = a[2] * b[0] - a[0] * b[2];
134376
134389
  var z2 = a[0] * b[1] - a[1] * b[0];
@@ -134391,25 +134404,25 @@ var require_vector3_4bbdb053_cjs_prod = __commonJS({
134391
134404
  one,
134392
134405
  add,
134393
134406
  addValue,
134394
- sub,
134407
+ sub: sub2,
134395
134408
  subValue,
134396
134409
  scale,
134397
134410
  dot: dot2,
134398
- cross,
134411
+ cross: cross2,
134399
134412
  lengthSqr,
134400
134413
  length,
134401
134414
  distance
134402
134415
  });
134403
134416
  exports2.add = add;
134404
134417
  exports2.addValue = addValue;
134405
- exports2.cross = cross;
134418
+ exports2.cross = cross2;
134406
134419
  exports2.distance = distance;
134407
134420
  exports2.dot = dot2;
134408
134421
  exports2.length = length;
134409
134422
  exports2.lengthSqr = lengthSqr;
134410
134423
  exports2.one = one;
134411
134424
  exports2.scale = scale;
134412
- exports2.sub = sub;
134425
+ exports2.sub = sub2;
134413
134426
  exports2.subValue = subValue;
134414
134427
  exports2.vector3 = vector3;
134415
134428
  exports2.zero = zero;
@@ -136124,8 +136137,8 @@ var require_triangle_33ffdfef_cjs_dev = __commonJS({
136124
136137
  if (arePointsCollinear(points)) return false;
136125
136138
  var p2p1 = mv1.subVectors(p2, p1);
136126
136139
  var p3p1 = mv2.subVectors(p3, p1);
136127
- var cross = p3p1.cross(p2p1);
136128
- return cross > 0;
136140
+ var cross2 = p3p1.cross(p2p1);
136141
+ return cross2 > 0;
136129
136142
  }
136130
136143
  var triangle = /* @__PURE__ */ Object.freeze({
136131
136144
  __proto__: null,
@@ -136377,7 +136390,7 @@ var require_vector2_f44fd63e_cjs_dev = __commonJS({
136377
136390
  function addValue(a, n3) {
136378
136391
  return [a[0] + n3, a[1] + n3];
136379
136392
  }
136380
- function sub(a, b) {
136393
+ function sub2(a, b) {
136381
136394
  return [a[0] - b[0], a[1] - b[1]];
136382
136395
  }
136383
136396
  function subValue(a, n3) {
@@ -136404,7 +136417,7 @@ var require_vector2_f44fd63e_cjs_dev = __commonJS({
136404
136417
  one,
136405
136418
  add,
136406
136419
  addValue,
136407
- sub,
136420
+ sub: sub2,
136408
136421
  subValue,
136409
136422
  scale,
136410
136423
  dot: dot2,
@@ -136420,7 +136433,7 @@ var require_vector2_f44fd63e_cjs_dev = __commonJS({
136420
136433
  exports2.lengthSqr = lengthSqr;
136421
136434
  exports2.one = one;
136422
136435
  exports2.scale = scale;
136423
- exports2.sub = sub;
136436
+ exports2.sub = sub2;
136424
136437
  exports2.subValue = subValue;
136425
136438
  exports2.vector2 = vector2;
136426
136439
  exports2.zero = zero;
@@ -136443,7 +136456,7 @@ var require_vector3_5e723d1a_cjs_dev = __commonJS({
136443
136456
  function addValue(a, n3) {
136444
136457
  return [a[0] + n3, a[1] + n3, a[2] + n3];
136445
136458
  }
136446
- function sub(a, b) {
136459
+ function sub2(a, b) {
136447
136460
  return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
136448
136461
  }
136449
136462
  function subValue(a, n3) {
@@ -136455,7 +136468,7 @@ var require_vector3_5e723d1a_cjs_dev = __commonJS({
136455
136468
  function dot2(a, b) {
136456
136469
  return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
136457
136470
  }
136458
- function cross(a, b) {
136471
+ function cross2(a, b) {
136459
136472
  var x = a[1] * b[2] - a[2] * b[1];
136460
136473
  var y = a[2] * b[0] - a[0] * b[2];
136461
136474
  var z2 = a[0] * b[1] - a[1] * b[0];
@@ -136476,25 +136489,25 @@ var require_vector3_5e723d1a_cjs_dev = __commonJS({
136476
136489
  one,
136477
136490
  add,
136478
136491
  addValue,
136479
- sub,
136492
+ sub: sub2,
136480
136493
  subValue,
136481
136494
  scale,
136482
136495
  dot: dot2,
136483
- cross,
136496
+ cross: cross2,
136484
136497
  lengthSqr,
136485
136498
  length,
136486
136499
  distance
136487
136500
  });
136488
136501
  exports2.add = add;
136489
136502
  exports2.addValue = addValue;
136490
- exports2.cross = cross;
136503
+ exports2.cross = cross2;
136491
136504
  exports2.distance = distance;
136492
136505
  exports2.dot = dot2;
136493
136506
  exports2.length = length;
136494
136507
  exports2.lengthSqr = lengthSqr;
136495
136508
  exports2.one = one;
136496
136509
  exports2.scale = scale;
136497
- exports2.sub = sub;
136510
+ exports2.sub = sub2;
136498
136511
  exports2.subValue = subValue;
136499
136512
  exports2.vector3 = vector3;
136500
136513
  exports2.zero = zero;
@@ -164912,7 +164925,7 @@ var require_MarchingCubes = __commonJS({
164912
164925
  }
164913
164926
  return numtris;
164914
164927
  }
164915
- function posnormtriv(pos, norm, colors, o1, o2, o3) {
164928
+ function posnormtriv(pos, norm2, colors, o1, o2, o3) {
164916
164929
  const c = scope.count * 3;
164917
164930
  scope.positionArray[c + 0] = pos[o1];
164918
164931
  scope.positionArray[c + 1] = pos[o1 + 1];
@@ -164924,9 +164937,9 @@ var require_MarchingCubes = __commonJS({
164924
164937
  scope.positionArray[c + 7] = pos[o3 + 1];
164925
164938
  scope.positionArray[c + 8] = pos[o3 + 2];
164926
164939
  if (scope.material.flatShading === true) {
164927
- const nx = (norm[o1 + 0] + norm[o2 + 0] + norm[o3 + 0]) / 3;
164928
- const ny = (norm[o1 + 1] + norm[o2 + 1] + norm[o3 + 1]) / 3;
164929
- const nz = (norm[o1 + 2] + norm[o2 + 2] + norm[o3 + 2]) / 3;
164940
+ const nx = (norm2[o1 + 0] + norm2[o2 + 0] + norm2[o3 + 0]) / 3;
164941
+ const ny = (norm2[o1 + 1] + norm2[o2 + 1] + norm2[o3 + 1]) / 3;
164942
+ const nz = (norm2[o1 + 2] + norm2[o2 + 2] + norm2[o3 + 2]) / 3;
164930
164943
  scope.normalArray[c + 0] = nx;
164931
164944
  scope.normalArray[c + 1] = ny;
164932
164945
  scope.normalArray[c + 2] = nz;
@@ -164937,15 +164950,15 @@ var require_MarchingCubes = __commonJS({
164937
164950
  scope.normalArray[c + 7] = ny;
164938
164951
  scope.normalArray[c + 8] = nz;
164939
164952
  } else {
164940
- scope.normalArray[c + 0] = norm[o1 + 0];
164941
- scope.normalArray[c + 1] = norm[o1 + 1];
164942
- scope.normalArray[c + 2] = norm[o1 + 2];
164943
- scope.normalArray[c + 3] = norm[o2 + 0];
164944
- scope.normalArray[c + 4] = norm[o2 + 1];
164945
- scope.normalArray[c + 5] = norm[o2 + 2];
164946
- scope.normalArray[c + 6] = norm[o3 + 0];
164947
- scope.normalArray[c + 7] = norm[o3 + 1];
164948
- scope.normalArray[c + 8] = norm[o3 + 2];
164953
+ scope.normalArray[c + 0] = norm2[o1 + 0];
164954
+ scope.normalArray[c + 1] = norm2[o1 + 1];
164955
+ scope.normalArray[c + 2] = norm2[o1 + 2];
164956
+ scope.normalArray[c + 3] = norm2[o2 + 0];
164957
+ scope.normalArray[c + 4] = norm2[o2 + 1];
164958
+ scope.normalArray[c + 5] = norm2[o2 + 2];
164959
+ scope.normalArray[c + 6] = norm2[o3 + 0];
164960
+ scope.normalArray[c + 7] = norm2[o3 + 1];
164961
+ scope.normalArray[c + 8] = norm2[o3 + 2];
164949
164962
  }
164950
164963
  if (scope.enableUvs) {
164951
164964
  const d = scope.count * 2;
@@ -193390,7 +193403,7 @@ var require_TeapotGeometry = __commonJS({
193390
193403
  const vert = [];
193391
193404
  const sdir = [];
193392
193405
  const tdir = [];
193393
- const norm = new THREE.Vector3();
193406
+ const norm2 = new THREE.Vector3();
193394
193407
  let tcoord;
193395
193408
  let sstep, tstep;
193396
193409
  let vertPerRow;
@@ -193475,12 +193488,12 @@ var require_TeapotGeometry = __commonJS({
193475
193488
  }
193476
193489
  vsdir.fromArray(sdir);
193477
193490
  vtdir.fromArray(tdir);
193478
- norm.crossVectors(vtdir, vsdir);
193479
- norm.normalize();
193491
+ norm2.crossVectors(vtdir, vsdir);
193492
+ norm2.normalize();
193480
193493
  if (vert[0] === 0 && vert[1] === 0) {
193481
193494
  normOut.set(0, vert[2] > maxHeight2 ? 1 : -1, 0);
193482
193495
  } else {
193483
- normOut.set(norm.x, norm.z, -norm.y);
193496
+ normOut.set(norm2.x, norm2.z, -norm2.y);
193484
193497
  }
193485
193498
  vertices[vertCount++] = trueSize * vert[0];
193486
193499
  vertices[vertCount++] = trueSize * (vert[2] - maxHeight2);
@@ -232411,7 +232424,7 @@ var require_lottie = __commonJS({
232411
232424
  return 0;
232412
232425
  }
232413
232426
  var add = sum;
232414
- function sub(a, b) {
232427
+ function sub2(a, b) {
232415
232428
  var tOfA = typeof a;
232416
232429
  var tOfB = typeof b;
232417
232430
  if (isNumerable(tOfA, a) && isNumerable(tOfB, b)) {
@@ -232514,7 +232527,7 @@ var require_lottie = __commonJS({
232514
232527
  return a % b;
232515
232528
  }
232516
232529
  var $bm_sum = sum;
232517
- var $bm_sub = sub;
232530
+ var $bm_sub = sub2;
232518
232531
  var $bm_mul = mul;
232519
232532
  var $bm_div = div;
232520
232533
  var $bm_mod = mod;
@@ -240714,26 +240727,26 @@ var require_opentype = __commonJS({
240714
240727
  }
240715
240728
  return void 0;
240716
240729
  };
240717
- Substitution.prototype.add = function(feature, sub, script, language) {
240730
+ Substitution.prototype.add = function(feature, sub2, script, language) {
240718
240731
  if (/ss\d\d/.test(feature)) {
240719
- return this.addSingle(feature, sub, script, language);
240732
+ return this.addSingle(feature, sub2, script, language);
240720
240733
  }
240721
240734
  switch (feature) {
240722
240735
  case "aalt":
240723
240736
  case "salt":
240724
- if (typeof sub.by === "number") {
240725
- return this.addSingle(feature, sub, script, language);
240737
+ if (typeof sub2.by === "number") {
240738
+ return this.addSingle(feature, sub2, script, language);
240726
240739
  }
240727
- return this.addAlternate(feature, sub, script, language);
240740
+ return this.addAlternate(feature, sub2, script, language);
240728
240741
  case "dlig":
240729
240742
  case "liga":
240730
240743
  case "rlig":
240731
- return this.addLigature(feature, sub, script, language);
240744
+ return this.addLigature(feature, sub2, script, language);
240732
240745
  case "ccmp":
240733
- if (sub.by instanceof Array) {
240734
- return this.addMultiple(feature, sub, script, language);
240746
+ if (sub2.by instanceof Array) {
240747
+ return this.addMultiple(feature, sub2, script, language);
240735
240748
  }
240736
- return this.addLigature(feature, sub, script, language);
240749
+ return this.addLigature(feature, sub2, script, language);
240737
240750
  }
240738
240751
  return void 0;
240739
240752
  };
@@ -297708,11 +297721,11 @@ var require_LightProbeGenerator = __commonJS({
297708
297721
  }
297709
297722
  }
297710
297723
  }
297711
- const norm = 4 * Math.PI / totalWeight;
297724
+ const norm2 = 4 * Math.PI / totalWeight;
297712
297725
  for (let j = 0; j < 9; j++) {
297713
- shCoefficients[j].x *= norm;
297714
- shCoefficients[j].y *= norm;
297715
- shCoefficients[j].z *= norm;
297726
+ shCoefficients[j].x *= norm2;
297727
+ shCoefficients[j].y *= norm2;
297728
+ shCoefficients[j].z *= norm2;
297716
297729
  }
297717
297730
  return new THREE.LightProbe(sh);
297718
297731
  },
@@ -297773,11 +297786,11 @@ var require_LightProbeGenerator = __commonJS({
297773
297786
  }
297774
297787
  }
297775
297788
  }
297776
- const norm = 4 * Math.PI / totalWeight;
297789
+ const norm2 = 4 * Math.PI / totalWeight;
297777
297790
  for (let j = 0; j < 9; j++) {
297778
- shCoefficients[j].x *= norm;
297779
- shCoefficients[j].y *= norm;
297780
- shCoefficients[j].z *= norm;
297791
+ shCoefficients[j].x *= norm2;
297792
+ shCoefficients[j].y *= norm2;
297793
+ shCoefficients[j].z *= norm2;
297781
297794
  }
297782
297795
  return new THREE.LightProbe(sh);
297783
297796
  }
@@ -369769,7 +369782,7 @@ __export(FurnitureItem_exports, {
369769
369782
  function ItemGLB(props) {
369770
369783
  const { src, dimensions, offset, corrRotation, corrScale, rig, cx, cz, baseY, yawDeg, userScale, unitsScale } = props;
369771
369784
  const gltf = (0, import_drei.useGLTF)(src);
369772
- const { node, seatX, seatY, seatZ, norm } = (0, import_react3.useMemo)(() => {
369785
+ const { node, seatX, seatY, seatZ, norm: norm2 } = (0, import_react3.useMemo)(() => {
369773
369786
  const clone3 = gltf.scene.clone(true);
369774
369787
  if (offset) clone3.position.set(offset[0], offset[1], offset[2]);
369775
369788
  if (corrRotation) {
@@ -369795,7 +369808,7 @@ function ItemGLB(props) {
369795
369808
  norm: targetHorizUnits / nativeHoriz
369796
369809
  };
369797
369810
  }, [gltf.scene, offset, corrRotation, corrScale, rig, dimensions, unitsScale]);
369798
- return /* @__PURE__ */ React.createElement("group", { position: [cx, baseY, cz], rotation: [0, MathUtils.degToRad(yawDeg ?? 0), 0] }, /* @__PURE__ */ React.createElement("group", { scale: norm * (userScale ?? 1) }, /* @__PURE__ */ React.createElement("group", { position: [seatX, seatY, seatZ] }, /* @__PURE__ */ React.createElement("primitive", { object: node }))));
369811
+ return /* @__PURE__ */ React.createElement("group", { position: [cx, baseY, cz], rotation: [0, MathUtils.degToRad(yawDeg ?? 0), 0] }, /* @__PURE__ */ React.createElement("group", { scale: norm2 * (userScale ?? 1) }, /* @__PURE__ */ React.createElement("group", { position: [seatX, seatY, seatZ] }, /* @__PURE__ */ React.createElement("primitive", { object: node }))));
369799
369812
  }
369800
369813
  function GhostBox({ dimensions, cx, cz, baseY, yawDeg, userScale, unitsScale, loading }) {
369801
369814
  const s = unitsScale * (userScale ?? 1);
@@ -370177,6 +370190,9 @@ function stripMulti(sc) {
370177
370190
  return rest;
370178
370191
  }
370179
370192
  function expandStaircase(sc, slabThickness, floorBelowHeight, floorOwnHeight) {
370193
+ if (sc.width != null && sc.length != null) {
370194
+ return expandStaircaseBox(sc, slabThickness, floorBelowHeight, floorOwnHeight);
370195
+ }
370180
370196
  const up = (sc.climb ?? "down") === "up";
370181
370197
  const tread = n(sc.step_tread);
370182
370198
  const riser = n(sc.step_rise);
@@ -370247,7 +370263,7 @@ function expandStaircase(sc, slabThickness, floorBelowHeight, floorOwnHeight) {
370247
370263
  if (t < numFlights - 1) {
370248
370264
  items.push({
370249
370265
  isStair: false,
370250
- o: { type: "floor_slab", x: landingX, y: even ? flightRun : 0, width: landingWidth, length: landingDepth, thickness: landingThickness, z_offset: zTopFlight - landingThickness }
370266
+ o: { type: "floor_slab", x: landingX, y: even ? flightRun : -landingDepth, width: landingWidth, length: landingDepth, thickness: landingThickness, z_offset: zTopFlight - landingThickness }
370251
370267
  });
370252
370268
  }
370253
370269
  zCur = zTopFlight;
@@ -370260,7 +370276,7 @@ function expandStaircase(sc, slabThickness, floorBelowHeight, floorOwnHeight) {
370260
370276
  if (t < numFlights - 1) {
370261
370277
  items.push({
370262
370278
  isStair: false,
370263
- o: { type: "floor_slab", x: landingX, y: even ? flightRun : 0, width: landingWidth, length: landingDepth, thickness: landingThickness, z_offset: zBottom - landingThickness }
370279
+ o: { type: "floor_slab", x: landingX, y: even ? flightRun : -landingDepth, width: landingWidth, length: landingDepth, thickness: landingThickness, z_offset: zBottom - landingThickness }
370264
370280
  });
370265
370281
  }
370266
370282
  zCur = zBottom;
@@ -370285,6 +370301,114 @@ function expandStaircase(sc, slabThickness, floorBelowHeight, floorOwnHeight) {
370285
370301
  return o;
370286
370302
  });
370287
370303
  }
370304
+ function expandStaircaseBox(sc, slabThickness, floorBelowHeight, floorOwnHeight) {
370305
+ const up = (sc.climb ?? "down") === "up";
370306
+ const tread = n(sc.step_tread);
370307
+ const riser = n(sc.step_rise);
370308
+ const direction = sc.direction ?? "south";
370309
+ const runAlongY = direction === "south" || direction === "north";
370310
+ const boxW = n(sc.width);
370311
+ const boxL = n(sc.length);
370312
+ const runBudget = runAlongY ? boxL : boxW;
370313
+ const lateralExtent = runAlongY ? boxW : boxL;
370314
+ const name = sc.name ?? "Stair";
370315
+ const defaultRise = up ? floorOwnHeight : floorBelowHeight;
370316
+ const riseHeight = typeof sc.rise_height === "number" && sc.rise_height > 0 ? sc.rise_height : defaultRise;
370317
+ const totalSteps = Math.max(1, Math.round(riseHeight / riser));
370318
+ const anchorZ = sc.z_offset !== void 0 ? n(sc.z_offset) : slabThickness;
370319
+ const gap = typeof sc.flight_gap === "number" && sc.flight_gap > 0 ? sc.flight_gap : 0;
370320
+ const laneWidth = (lateralExtent - gap) / 2;
370321
+ const landingDepth = typeof sc.landing_depth === "number" && sc.landing_depth > 0 ? sc.landing_depth : Math.max(1, laneWidth);
370322
+ const landingThickness = typeof sc.landing_thickness === "number" ? sc.landing_thickness : riser;
370323
+ const flightRunFor = (nf) => (Math.ceil(totalSteps / nf) - 1) * tread;
370324
+ const reserveFor = (_nf) => 2 * landingDepth;
370325
+ let numFlights = 0;
370326
+ for (let nf = 1; nf <= totalSteps; nf++) {
370327
+ if (nf >= 2 && Math.ceil(totalSteps / nf) < 2) break;
370328
+ if (flightRunFor(nf) + reserveFor(nf) <= runBudget + 1e-6) {
370329
+ numFlights = nf;
370330
+ break;
370331
+ }
370332
+ }
370333
+ if (numFlights === 0) {
370334
+ const minLen = tread + 2 * landingDepth;
370335
+ throw new Error(
370336
+ `staircase "${name}" doesn't fit its box along ${runAlongY ? "length (Y)" : "width (X)"}: need at least ${Math.ceil(minLen)} (one ${tread}-unit tread + a bottom approach + a turn landing, ${landingDepth} each), but only ${Math.floor(runBudget)} is allocated. Enlarge the box or reduce landing_depth.`
370337
+ );
370338
+ }
370339
+ const nLanes = numFlights >= 2 ? 2 : 1;
370340
+ const stairWidth = nLanes === 2 ? laneWidth : lateralExtent;
370341
+ if (!(stairWidth > 0)) {
370342
+ throw new Error(
370343
+ `staircase "${name}" doesn't fit its box across the stairs: the ${Math.floor(lateralExtent)}-unit side leaves no room for ${nLanes === 2 ? "two lanes" : "a flight"} past flight_gap ${gap}.`
370344
+ );
370345
+ }
370346
+ const perFlight = Math.ceil(totalSteps / numFlights);
370347
+ const risersFor = (t) => Math.max(0, Math.min(perFlight, totalSteps - t * perFlight));
370348
+ const flightRun = Math.max(1, perFlight - 1) * tread;
370349
+ const nearOffset = landingDepth;
370350
+ const anti = sc.turn === "anticlockwise";
370351
+ const laneA = anti ? stairWidth + gap : 0;
370352
+ const laneB = anti ? 0 : stairWidth + gap;
370353
+ const landingWidth = nLanes === 2 ? 2 * stairWidth + gap : stairWidth;
370354
+ const items = [];
370355
+ const stepFields = { step_rise: riser, step_tread: tread, step_width: stairWidth };
370356
+ let zCur = 0;
370357
+ for (let t = 0; t < numFlights; t++) {
370358
+ const risers = risersFor(t);
370359
+ if (risers <= 0) break;
370360
+ const treads = Math.max(1, risers - 1);
370361
+ const even = t % 2 === 0;
370362
+ const lane = even ? laneA : laneB;
370363
+ const run = treads * tread;
370364
+ if (up) {
370365
+ const zBottomFlight = zCur;
370366
+ const zTopFlight = zCur + risers * riser;
370367
+ items.push({
370368
+ isStair: true,
370369
+ o: even ? { type: "staircase", direction: "south", start_x: lane, start_y: nearOffset, num_steps: treads, ...stepFields, z_offset: zBottomFlight } : { type: "staircase", direction: "north", start_x: lane, start_y: nearOffset + flightRun, num_steps: treads, ...stepFields, z_offset: zBottomFlight }
370370
+ });
370371
+ {
370372
+ const ly = even ? nearOffset + flightRun : nearOffset - landingDepth;
370373
+ items.push({ isStair: false, o: { type: "floor_slab", x: 0, y: ly, width: landingWidth, length: landingDepth, thickness: landingThickness, z_offset: zTopFlight - landingThickness } });
370374
+ }
370375
+ zCur = zTopFlight;
370376
+ } else {
370377
+ const zBottom = zCur - risers * riser;
370378
+ items.push({
370379
+ isStair: true,
370380
+ o: even ? { type: "staircase", direction: "north", start_x: lane, start_y: nearOffset + run, num_steps: treads, ...stepFields, z_offset: zBottom } : { type: "staircase", direction: "south", start_x: lane, start_y: nearOffset + flightRun - run, num_steps: treads, ...stepFields, z_offset: zBottom }
370381
+ });
370382
+ {
370383
+ const ly = even ? nearOffset + flightRun : nearOffset - landingDepth;
370384
+ items.push({ isStair: false, o: { type: "floor_slab", x: 0, y: ly, width: landingWidth, length: landingDepth, thickness: landingThickness, z_offset: zBottom - landingThickness } });
370385
+ }
370386
+ zCur = zBottom;
370387
+ }
370388
+ }
370389
+ const rotated = items.map((it) => ({ ...it, o: rotateObject(it.o, direction) }));
370390
+ const R = ROT[direction];
370391
+ let minX = Infinity, minY = Infinity;
370392
+ for (const [cx, cy] of [[0, 0], [lateralExtent, 0], [0, runBudget], [lateralExtent, runBudget]]) {
370393
+ const [rx, ry] = R.m(cx, cy);
370394
+ if (rx < minX) minX = rx;
370395
+ if (ry < minY) minY = ry;
370396
+ }
370397
+ const dx = n(sc.start_x) - minX;
370398
+ const dy = n(sc.start_y) - minY;
370399
+ let fi = 0, li = 0;
370400
+ return rotated.map(({ o, isStair }) => {
370401
+ if (typeof o.x === "number") o.x += dx;
370402
+ if (typeof o.y === "number") o.y += dy;
370403
+ if (typeof o.start_x === "number") o.start_x += dx;
370404
+ if (typeof o.start_y === "number") o.start_y += dy;
370405
+ o.z_offset = n(o.z_offset) + anchorZ;
370406
+ if (sc.layer !== void 0) o.layer = sc.layer;
370407
+ if (isStair && sc.material !== void 0) o.material = sc.material;
370408
+ o.name = isStair ? `${name}_F${++fi}` : `${name}_L${++li}`;
370409
+ return o;
370410
+ });
370411
+ }
370288
370412
  function flip180(d) {
370289
370413
  return d === "south" ? "north" : d === "north" ? "south" : d === "east" ? "west" : "east";
370290
370414
  }
@@ -370474,27 +370598,6 @@ function expandRoomWalls(houseConfig, wallThickness, opts, _depth = 0) {
370474
370598
  if (typeof r2.y === "number") r2.y = r2.y - ot / 2;
370475
370599
  if (typeof r2.width === "number") r2.width = r2.width + ot;
370476
370600
  if (typeof r2.length === "number") r2.length = r2.length + ot;
370477
- } else if (o.type === "roof") {
370478
- const segs = r2.segments;
370479
- if (Array.isArray(segs)) {
370480
- const half = t / 2;
370481
- for (const s of segs) {
370482
- const st = s.start;
370483
- const en = s.end;
370484
- if (Array.isArray(st) && Array.isArray(en) && typeof st[0] === "number" && typeof st[1] === "number" && typeof en[0] === "number" && typeof en[1] === "number") {
370485
- const dx = en[0] - st[0];
370486
- const dy = en[1] - st[1];
370487
- const len = Math.hypot(dx, dy);
370488
- if (len > 0) {
370489
- const ux = dx / len;
370490
- const uy = dy / len;
370491
- s.start = [st[0] - ux * half, st[1] - uy * half];
370492
- s.end = [en[0] + ux * half, en[1] + uy * half];
370493
- }
370494
- }
370495
- if (typeof s.width === "number") s.width = s.width + t;
370496
- }
370497
- }
370498
370601
  }
370499
370602
  }
370500
370603
  }
@@ -370699,16 +370802,16 @@ function rotateSide(side2, cos, sin) {
370699
370802
  return ry >= 0 ? "south" : "north";
370700
370803
  }
370701
370804
  function placeComponent(objs, dx, dy, dz, deg, wallThickness) {
370702
- const norm = (deg % 360 + 360) % 360;
370703
- const rad = norm * Math.PI / 180;
370805
+ const norm2 = (deg % 360 + 360) % 360;
370806
+ const rad = norm2 * Math.PI / 180;
370704
370807
  const clean = (v) => Math.round(v * 1e12) / 1e12;
370705
370808
  const cos = clean(Math.cos(rad));
370706
370809
  const sin = clean(Math.sin(rad));
370707
- const swap = norm === 90 || norm === 270;
370810
+ const swap = norm2 === 90 || norm2 === 270;
370708
370811
  const rp = (x, y) => [x * cos + y * sin, -x * sin + y * cos];
370709
370812
  return objs.map((o) => {
370710
370813
  const n3 = { ...o };
370711
- if (norm !== 0) {
370814
+ if (norm2 !== 0) {
370712
370815
  if (BOX_TYPES.has(n3.type)) {
370713
370816
  const w = typeof n3.width === "number" ? n3.width : wallThickness;
370714
370817
  const l = typeof n3.length === "number" ? n3.length : wallThickness;
@@ -370732,7 +370835,7 @@ function placeComponent(objs, dx, dy, dz, deg, wallThickness) {
370732
370835
  for (const [k, v] of Object.entries(n3.wall_heights)) wh[isSide(k) ? rotateSide(k, cos, sin) : k] = v;
370733
370836
  n3.wall_heights = wh;
370734
370837
  }
370735
- if (n3.type === "item" || n3.type === "model") n3.rotation = (((typeof n3.rotation === "number" ? n3.rotation : 0) + norm) % 360 + 360) % 360;
370838
+ if (n3.type === "item" || n3.type === "model") n3.rotation = (((typeof n3.rotation === "number" ? n3.rotation : 0) + norm2) % 360 + 360) % 360;
370736
370839
  }
370737
370840
  if (typeof n3.x === "number") n3.x += dx;
370738
370841
  if (typeof n3.y === "number") n3.y += dy;
@@ -372782,8 +372885,8 @@ var ZodError = class _ZodError extends Error {
372782
372885
  constructor(issues) {
372783
372886
  super();
372784
372887
  this.issues = [];
372785
- this.addIssue = (sub) => {
372786
- this.issues = [...this.issues, sub];
372888
+ this.addIssue = (sub2) => {
372889
+ this.issues = [...this.issues, sub2];
372787
372890
  };
372788
372891
  this.addIssues = (subs = []) => {
372789
372892
  this.issues = [...this.issues, ...subs];
@@ -372850,13 +372953,13 @@ var ZodError = class _ZodError extends Error {
372850
372953
  flatten(mapper = (issue3) => issue3.message) {
372851
372954
  const fieldErrors = {};
372852
372955
  const formErrors = [];
372853
- for (const sub of this.issues) {
372854
- if (sub.path.length > 0) {
372855
- const firstEl = sub.path[0];
372956
+ for (const sub2 of this.issues) {
372957
+ if (sub2.path.length > 0) {
372958
+ const firstEl = sub2.path[0];
372856
372959
  fieldErrors[firstEl] = fieldErrors[firstEl] || [];
372857
- fieldErrors[firstEl].push(mapper(sub));
372960
+ fieldErrors[firstEl].push(mapper(sub2));
372858
372961
  } else {
372859
- formErrors.push(mapper(sub));
372962
+ formErrors.push(mapper(sub2));
372860
372963
  }
372861
372964
  }
372862
372965
  return { formErrors, fieldErrors };
@@ -377156,12 +377259,12 @@ var $ZodRealError = $constructor("$ZodError", initializer, { Parent: Error });
377156
377259
  function flattenError(error53, mapper = (issue3) => issue3.message) {
377157
377260
  const fieldErrors = {};
377158
377261
  const formErrors = [];
377159
- for (const sub of error53.issues) {
377160
- if (sub.path.length > 0) {
377161
- fieldErrors[sub.path[0]] = fieldErrors[sub.path[0]] || [];
377162
- fieldErrors[sub.path[0]].push(mapper(sub));
377262
+ for (const sub2 of error53.issues) {
377263
+ if (sub2.path.length > 0) {
377264
+ fieldErrors[sub2.path[0]] = fieldErrors[sub2.path[0]] || [];
377265
+ fieldErrors[sub2.path[0]].push(mapper(sub2));
377163
377266
  } else {
377164
- formErrors.push(mapper(sub));
377267
+ formErrors.push(mapper(sub2));
377165
377268
  }
377166
377269
  }
377167
377270
  return { formErrors, fieldErrors };
@@ -379566,13 +379669,13 @@ function _array(Class3, element, params) {
379566
379669
  });
379567
379670
  }
379568
379671
  function _custom(Class3, fn, _params) {
379569
- const norm = normalizeParams(_params);
379570
- norm.abort ?? (norm.abort = true);
379672
+ const norm2 = normalizeParams(_params);
379673
+ norm2.abort ?? (norm2.abort = true);
379571
379674
  const schema = new Class3({
379572
379675
  type: "custom",
379573
379676
  check: "custom",
379574
379677
  fn,
379575
- ...norm
379678
+ ...norm2
379576
379679
  });
379577
379680
  return schema;
379578
379681
  }
@@ -405189,6 +405292,8 @@ var Roof = {
405189
405292
  name: "name",
405190
405293
  parapet_height: "parapet_height",
405191
405294
  parapet_thickness: "parapet_thickness",
405295
+ purlin_pitch: "purlin_pitch",
405296
+ rafter_pitch: "rafter_pitch",
405192
405297
  roof_type: "roof_type",
405193
405298
  segments: "segments",
405194
405299
  slab_thickness: "slab_thickness",
@@ -405256,7 +405361,9 @@ var Segment = {
405256
405361
  sx: "sx",
405257
405362
  sy: "sy",
405258
405363
  tie_beam_count: "tie_beam_count",
405259
- width: "width"
405364
+ width: "width",
405365
+ width_left: "width_left",
405366
+ width_right: "width_right"
405260
405367
  };
405261
405368
  var SelectInput = {
405262
405369
  $type: "SelectInput",
@@ -405311,6 +405418,8 @@ var SpiralStaircase = {
405311
405418
  };
405312
405419
  var Staircase = {
405313
405420
  $type: "Staircase",
405421
+ box_length: "box_length",
405422
+ box_width: "box_width",
405314
405423
  climb: "climb",
405315
405424
  direction: "direction",
405316
405425
  enabled: "enabled",
@@ -406594,6 +406703,14 @@ var WadiAstReflection = class extends AbstractAstReflection {
406594
406703
  name: Roof.parapet_thickness,
406595
406704
  optional: true
406596
406705
  },
406706
+ purlin_pitch: {
406707
+ name: Roof.purlin_pitch,
406708
+ optional: true
406709
+ },
406710
+ rafter_pitch: {
406711
+ name: Roof.rafter_pitch,
406712
+ optional: true
406713
+ },
406597
406714
  roof_type: {
406598
406715
  name: Roof.roof_type
406599
406716
  },
@@ -406834,7 +406951,16 @@ var WadiAstReflection = class extends AbstractAstReflection {
406834
406951
  optional: true
406835
406952
  },
406836
406953
  width: {
406837
- name: Segment.width
406954
+ name: Segment.width,
406955
+ optional: true
406956
+ },
406957
+ width_left: {
406958
+ name: Segment.width_left,
406959
+ optional: true
406960
+ },
406961
+ width_right: {
406962
+ name: Segment.width_right,
406963
+ optional: true
406838
406964
  }
406839
406965
  },
406840
406966
  superTypes: []
@@ -406996,6 +407122,14 @@ var WadiAstReflection = class extends AbstractAstReflection {
406996
407122
  Staircase: {
406997
407123
  name: Staircase.$type,
406998
407124
  properties: {
407125
+ box_length: {
407126
+ name: Staircase.box_length,
407127
+ optional: true
407128
+ },
407129
+ box_width: {
407130
+ name: Staircase.box_width,
407131
+ optional: true
407132
+ },
406999
407133
  climb: {
407000
407134
  name: Staircase.climb,
407001
407135
  optional: true
@@ -407052,7 +407186,8 @@ var WadiAstReflection = class extends AbstractAstReflection {
407052
407186
  name: Staircase.step_tread
407053
407187
  },
407054
407188
  step_width: {
407055
- name: Staircase.step_width
407189
+ name: Staircase.step_width,
407190
+ optional: true
407056
407191
  },
407057
407192
  turn: {
407058
407193
  name: Staircase.turn,
@@ -413264,6 +413399,52 @@ var WadiGrammar = () => loadedWadiGrammar ?? (loadedWadiGrammar = loadGrammarFro
413264
413399
  "$type": "Keyword",
413265
413400
  "value": ")"
413266
413401
  },
413402
+ {
413403
+ "$type": "Group",
413404
+ "elements": [
413405
+ {
413406
+ "$type": "Keyword",
413407
+ "value": "box"
413408
+ },
413409
+ {
413410
+ "$type": "Keyword",
413411
+ "value": "("
413412
+ },
413413
+ {
413414
+ "$type": "Assignment",
413415
+ "feature": "box_width",
413416
+ "operator": "=",
413417
+ "terminal": {
413418
+ "$type": "RuleCall",
413419
+ "rule": {
413420
+ "$ref": "#/rules@17"
413421
+ },
413422
+ "arguments": []
413423
+ }
413424
+ },
413425
+ {
413426
+ "$type": "Keyword",
413427
+ "value": ","
413428
+ },
413429
+ {
413430
+ "$type": "Assignment",
413431
+ "feature": "box_length",
413432
+ "operator": "=",
413433
+ "terminal": {
413434
+ "$type": "RuleCall",
413435
+ "rule": {
413436
+ "$ref": "#/rules@17"
413437
+ },
413438
+ "arguments": []
413439
+ }
413440
+ },
413441
+ {
413442
+ "$type": "Keyword",
413443
+ "value": ")"
413444
+ }
413445
+ ],
413446
+ "cardinality": "?"
413447
+ },
413267
413448
  {
413268
413449
  "$type": "Keyword",
413269
413450
  "value": "step"
@@ -413301,20 +413482,26 @@ var WadiGrammar = () => loadedWadiGrammar ?? (loadedWadiGrammar = loadGrammarFro
413301
413482
  }
413302
413483
  },
413303
413484
  {
413304
- "$type": "Keyword",
413305
- "value": ","
413306
- },
413307
- {
413308
- "$type": "Assignment",
413309
- "feature": "step_width",
413310
- "operator": "=",
413311
- "terminal": {
413312
- "$type": "RuleCall",
413313
- "rule": {
413314
- "$ref": "#/rules@17"
413485
+ "$type": "Group",
413486
+ "elements": [
413487
+ {
413488
+ "$type": "Keyword",
413489
+ "value": ","
413315
413490
  },
413316
- "arguments": []
413317
- }
413491
+ {
413492
+ "$type": "Assignment",
413493
+ "feature": "step_width",
413494
+ "operator": "=",
413495
+ "terminal": {
413496
+ "$type": "RuleCall",
413497
+ "rule": {
413498
+ "$ref": "#/rules@17"
413499
+ },
413500
+ "arguments": []
413501
+ }
413502
+ }
413503
+ ],
413504
+ "cardinality": "?"
413318
413505
  },
413319
413506
  {
413320
413507
  "$type": "Keyword",
@@ -415153,6 +415340,50 @@ var WadiGrammar = () => loadedWadiGrammar ?? (loadedWadiGrammar = loadGrammarFro
415153
415340
  ],
415154
415341
  "cardinality": "?"
415155
415342
  },
415343
+ {
415344
+ "$type": "Group",
415345
+ "elements": [
415346
+ {
415347
+ "$type": "Keyword",
415348
+ "value": "rafter_pitch"
415349
+ },
415350
+ {
415351
+ "$type": "Assignment",
415352
+ "feature": "rafter_pitch",
415353
+ "operator": "=",
415354
+ "terminal": {
415355
+ "$type": "RuleCall",
415356
+ "rule": {
415357
+ "$ref": "#/rules@17"
415358
+ },
415359
+ "arguments": []
415360
+ }
415361
+ }
415362
+ ],
415363
+ "cardinality": "?"
415364
+ },
415365
+ {
415366
+ "$type": "Group",
415367
+ "elements": [
415368
+ {
415369
+ "$type": "Keyword",
415370
+ "value": "purlin_pitch"
415371
+ },
415372
+ {
415373
+ "$type": "Assignment",
415374
+ "feature": "purlin_pitch",
415375
+ "operator": "=",
415376
+ "terminal": {
415377
+ "$type": "RuleCall",
415378
+ "rule": {
415379
+ "$ref": "#/rules@17"
415380
+ },
415381
+ "arguments": []
415382
+ }
415383
+ }
415384
+ ],
415385
+ "cardinality": "?"
415386
+ },
415156
415387
  {
415157
415388
  "$type": "RuleCall",
415158
415389
  "rule": {
@@ -415438,20 +415669,75 @@ var WadiGrammar = () => loadedWadiGrammar ?? (loadedWadiGrammar = loadGrammarFro
415438
415669
  "value": ")"
415439
415670
  },
415440
415671
  {
415441
- "$type": "Keyword",
415442
- "value": "width"
415443
- },
415444
- {
415445
- "$type": "Assignment",
415446
- "feature": "width",
415447
- "operator": "=",
415448
- "terminal": {
415449
- "$type": "RuleCall",
415450
- "rule": {
415451
- "$ref": "#/rules@17"
415672
+ "$type": "Alternatives",
415673
+ "elements": [
415674
+ {
415675
+ "$type": "Group",
415676
+ "elements": [
415677
+ {
415678
+ "$type": "Keyword",
415679
+ "value": "width"
415680
+ },
415681
+ {
415682
+ "$type": "Assignment",
415683
+ "feature": "width",
415684
+ "operator": "=",
415685
+ "terminal": {
415686
+ "$type": "RuleCall",
415687
+ "rule": {
415688
+ "$ref": "#/rules@17"
415689
+ },
415690
+ "arguments": []
415691
+ }
415692
+ }
415693
+ ]
415452
415694
  },
415453
- "arguments": []
415454
- }
415695
+ {
415696
+ "$type": "Group",
415697
+ "elements": [
415698
+ {
415699
+ "$type": "Keyword",
415700
+ "value": "widths"
415701
+ },
415702
+ {
415703
+ "$type": "Keyword",
415704
+ "value": "("
415705
+ },
415706
+ {
415707
+ "$type": "Assignment",
415708
+ "feature": "width_left",
415709
+ "operator": "=",
415710
+ "terminal": {
415711
+ "$type": "RuleCall",
415712
+ "rule": {
415713
+ "$ref": "#/rules@17"
415714
+ },
415715
+ "arguments": []
415716
+ }
415717
+ },
415718
+ {
415719
+ "$type": "Keyword",
415720
+ "value": ","
415721
+ },
415722
+ {
415723
+ "$type": "Assignment",
415724
+ "feature": "width_right",
415725
+ "operator": "=",
415726
+ "terminal": {
415727
+ "$type": "RuleCall",
415728
+ "rule": {
415729
+ "$ref": "#/rules@17"
415730
+ },
415731
+ "arguments": []
415732
+ }
415733
+ },
415734
+ {
415735
+ "$type": "Keyword",
415736
+ "value": ")"
415737
+ }
415738
+ ]
415739
+ }
415740
+ ]
415455
415741
  },
415456
415742
  {
415457
415743
  "$type": "Group",
@@ -416551,9 +416837,14 @@ function staircase(s) {
416551
416837
  start_y: put("start_y", s.start_y, 0),
416552
416838
  step_rise: put("step_rise", s.step_rise, 1),
416553
416839
  step_tread: put("step_tread", s.step_tread, 1),
416554
- step_width: put("step_width", s.step_width, 1),
416555
416840
  direction: s.direction
416556
416841
  };
416842
+ if (s.box_width !== void 0 && s.box_length !== void 0) {
416843
+ o.width = put("width", s.box_width, 1);
416844
+ o.length = put("length", s.box_length, 1);
416845
+ }
416846
+ const sw = put("step_width", s.step_width, 1);
416847
+ if (sw !== void 0) o.step_width = sw;
416557
416848
  if (s.name) o.name = unquote2(s.name);
416558
416849
  if (s.climb) o.climb = s.climb;
416559
416850
  const rh = put("rise_height", s.rise_height, 1);
@@ -416780,6 +417071,18 @@ function roof(r2) {
416780
417071
  const gwt = put("gable_wall_thickness", r2.gable_wall_thickness);
416781
417072
  if (gwt !== void 0) o.gable_wall_thickness = gwt;
416782
417073
  if (r2.framing) o.framing = jsonObject(r2.framing);
417074
+ if (r2.rafter_pitch !== void 0 || r2.purlin_pitch !== void 0) {
417075
+ const framing = o.framing ?? {};
417076
+ const setPitch = (key, e, ph) => {
417077
+ if (e === void 0) return;
417078
+ const { value, formula } = fieldAndFormula(e, ph);
417079
+ framing[key] = value;
417080
+ if (formula) formulas[`framing.${key}`] = formula;
417081
+ };
417082
+ setPitch("rafter_spacing_in", r2.rafter_pitch, 36);
417083
+ setPitch("purlin_spacing_in", r2.purlin_pitch, 12);
417084
+ o.framing = framing;
417085
+ }
416783
417086
  if (r2.segments.length) o.segments = r2.segments.map(roofSegment);
416784
417087
  if (r2.trusses.length) o.trusses = r2.trusses.map(roofTruss);
416785
417088
  applyCommon(o, formulas, r2);
@@ -416790,9 +417093,25 @@ function roofSegment(s) {
416790
417093
  const seg = {
416791
417094
  id: unquote2(s.id),
416792
417095
  start: [put("start_x", s.sx, 0), put("start_y", s.sy, 0)],
416793
- end: [put("end_x", s.ex, 0), put("end_y", s.ey, 0)],
416794
- width: put("width", s.width, 1)
417096
+ end: [put("end_x", s.ex, 0), put("end_y", s.ey, 0)]
416795
417097
  };
417098
+ if (s.width !== void 0) {
417099
+ seg.width = put("width", s.width, 1);
417100
+ } else if (s.width_left !== void 0 && s.width_right !== void 0) {
417101
+ const wl = put("width_left", s.width_left, 1) ?? 1;
417102
+ const wr = put("width_right", s.width_right, 1) ?? 1;
417103
+ seg.width_left = wl;
417104
+ seg.width_right = wr;
417105
+ seg.width = wl + wr;
417106
+ const lf = formulas.width_left, rf = formulas.width_right;
417107
+ if (lf || rf) {
417108
+ const lExpr = lf ? `(${lf.replace(/^=\s*/, "")})` : String(wl);
417109
+ const rExpr = rf ? `(${rf.replace(/^=\s*/, "")})` : String(wr);
417110
+ formulas.width = `= ${lExpr} + ${rExpr}`;
417111
+ }
417112
+ } else {
417113
+ seg.width = 1;
417114
+ }
416796
417115
  if (s.shed_high_side) seg.shed_high_side = s.shed_high_side;
416797
417116
  if (s.start_endpoint) seg.start_endpoint = s.start_endpoint;
416798
417117
  if (s.end_endpoint) seg.end_endpoint = s.end_endpoint;
@@ -417336,7 +417655,26 @@ function isLeafEndpoint(entries, segmentId, which) {
417336
417655
  if (!entry) return true;
417337
417656
  return !entry.isJoint;
417338
417657
  }
417339
- function ringBeamMembersForRect(rect, z2, segmentId) {
417658
+ function insetQuad(rect, d) {
417659
+ if (!(d > 0)) return [rect[0], rect[1], rect[2], rect[3]];
417660
+ const cx = (rect[0][0] + rect[1][0] + rect[2][0] + rect[3][0]) / 4;
417661
+ const cy = (rect[0][1] + rect[1][1] + rect[2][1] + rect[3][1]) / 4;
417662
+ const ax = [rect[1][0] - rect[0][0], rect[1][1] - rect[0][1]];
417663
+ const bx = [rect[3][0] - rect[0][0], rect[3][1] - rect[0][1]];
417664
+ const al = Math.hypot(ax[0], ax[1]) || 1, bl = Math.hypot(bx[0], bx[1]) || 1;
417665
+ const ua = [ax[0] / al, ax[1] / al];
417666
+ const ub = [bx[0] / bl, bx[1] / bl];
417667
+ const move = (p) => {
417668
+ const dx = p[0] - cx, dy = p[1] - cy;
417669
+ let a = dx * ua[0] + dy * ua[1];
417670
+ let b = dx * ub[0] + dy * ub[1];
417671
+ a -= Math.sign(a) * Math.min(d, Math.abs(a));
417672
+ b -= Math.sign(b) * Math.min(d, Math.abs(b));
417673
+ return [cx + a * ua[0] + b * ub[0], cy + a * ua[1] + b * ub[1]];
417674
+ };
417675
+ return [move(rect[0]), move(rect[1]), move(rect[2]), move(rect[3])];
417676
+ }
417677
+ function ringBeamMembersForRect(rect, z2, segmentId, inset = 0) {
417340
417678
  const out = [];
417341
417679
  const labels = [
417342
417680
  "right",
@@ -417344,9 +417682,10 @@ function ringBeamMembersForRect(rect, z2, segmentId) {
417344
417682
  "left",
417345
417683
  "back"
417346
417684
  ];
417685
+ const r2 = insetQuad(rect, inset);
417347
417686
  for (let i2 = 0; i2 < 4; i2++) {
417348
- const a = rect[i2];
417349
- const b = rect[(i2 + 1) % 4];
417687
+ const a = r2[i2];
417688
+ const b = r2[(i2 + 1) % 4];
417350
417689
  out.push({
417351
417690
  id: `${segmentId}.ring_beam.${labels[i2]}`,
417352
417691
  start: [a[0], a[1], z2],
@@ -417369,6 +417708,11 @@ function slopeAngleRad(spec, crossHalf) {
417369
417708
  return Math.atan(spec.ridge_h / crossHalf);
417370
417709
  }
417371
417710
  function resolvePitch(seg, cfg, crossHalf) {
417711
+ if (seg.width_left != null && seg.width_right != null && seg.width_left > 0 && seg.width_right > 0) {
417712
+ const L = seg.width_left, R = seg.width_right;
417713
+ const ridgeH2 = resolveRise(seg.slope_override ?? cfg.slope, crossHalf);
417714
+ return { ridgeH: ridgeH2, ridgeShift: (R - L) / 2, leftPitchTan: ridgeH2 / L, rightPitchTan: ridgeH2 / R };
417715
+ }
417372
417716
  const left = seg.slope_left_override ?? cfg.slope_left;
417373
417717
  const right = seg.slope_right_override ?? cfg.slope_right;
417374
417718
  if (left && right) {
@@ -417426,7 +417770,7 @@ function derivePitchedRoof(cfg, opts) {
417426
417770
  for (const seg of cfg.segments) {
417427
417771
  const alongLen = segmentLength(seg);
417428
417772
  if (alongLen === 0) continue;
417429
- const crossHalf = seg.width / 2;
417773
+ const crossHalf = seg.width_left != null && seg.width_right != null ? (seg.width_left + seg.width_right) / 2 : seg.width / 2;
417430
417774
  const { ridgeH, ridgeShift, leftPitchTan, rightPitchTan } = resolvePitch(seg, cfg, crossHalf);
417431
417775
  const asym = ridgeShift !== 0;
417432
417776
  if (!(ridgeH > 0)) {
@@ -417598,7 +417942,8 @@ function derivePitchedRoof(cfg, opts) {
417598
417942
  );
417599
417943
  }
417600
417944
  const rect = segmentRect(seg);
417601
- for (const rb of ringBeamMembersForRect(rect, opts.wallTopZ, seg.id)) {
417945
+ const ringInset = (opts.wallThickness ?? 0) / 2;
417946
+ for (const rb of ringBeamMembersForRect(rect, opts.wallTopZ, seg.id, ringInset)) {
417602
417947
  if (startRes === "open" && rb.id.endsWith(".back")) continue;
417603
417948
  if (endRes === "open" && rb.id.endsWith(".front")) continue;
417604
417949
  members.push(rb);
@@ -417916,7 +418261,7 @@ function deriveShedRoof(cfg, opts) {
417916
418261
  const leftN = [-unit[1], unit[0]];
417917
418262
  const highIsLeft = highSide === "left";
417918
418263
  const rect = segmentRect(seg);
417919
- for (const rb of ringBeamMembersForRect(rect, opts.wallTopZ, seg.id)) {
418264
+ for (const rb of ringBeamMembersForRect(rect, opts.wallTopZ, seg.id, (opts.wallThickness ?? 0) / 2)) {
417920
418265
  if (startIsLeaf && rb.id.endsWith(".back")) continue;
417921
418266
  if (endIsLeaf && rb.id.endsWith(".front")) continue;
417922
418267
  const isHighEave = highIsLeft && rb.id.endsWith(".left") || !highIsLeft && rb.id.endsWith(".right");
@@ -418082,8 +418427,8 @@ function resolveJoints(cfg, spec, opts) {
418082
418427
  if (segmentLength(segA) === 0 || segmentLength(segB) === 0) continue;
418083
418428
  const uA = segmentUnitVector(segA);
418084
418429
  const uB = segmentUnitVector(segB);
418085
- const cross = uA[0] * uB[1] - uA[1] * uB[0];
418086
- if (Math.abs(cross) < 1e-9) continue;
418430
+ const cross2 = uA[0] * uB[1] - uA[1] * uB[0];
418431
+ if (Math.abs(cross2) < 1e-9) continue;
418087
418432
  const insideA = insideSideMultiplier(segA, segB);
418088
418433
  const insideB = insideSideMultiplier(segB, segA);
418089
418434
  const wallA = offsetLine(segA, insideA * segA.width / 2);
@@ -419038,6 +419383,13 @@ function computeMergedV2Spec(config3, opts = {}) {
419038
419383
  merged.members.push(...spec.members);
419039
419384
  merged.planes.push(...spec.planes);
419040
419385
  merged.trusses.push(...spec.trusses);
419386
+ merged.framing = {
419387
+ ring_beam_size_in: framing.ring_beam_size_in,
419388
+ ridge_size_in: framing.ridge_size_in,
419389
+ rafter_size_in: framing.rafter_size_in,
419390
+ purlin_size_in: framing.purlin_size_in,
419391
+ wall_thickness_u: wallThickness
419392
+ };
419041
419393
  } catch (e) {
419042
419394
  const name = obj.name;
419043
419395
  const label = name ? `roof "${name}"` : "roof";
@@ -423944,9 +424296,9 @@ var Line$1 = class Line2 extends Shape2 {
423944
424296
  return;
423945
424297
  }
423946
424298
  if (args.length === 1 && args[0] instanceof Object && args[0].name === "line") {
423947
- let { pt, norm } = args[0];
424299
+ let { pt, norm: norm2 } = args[0];
423948
424300
  this.pt = new Flatten.Point(pt);
423949
- this.norm = new Flatten.Vector(norm);
424301
+ this.norm = new Flatten.Vector(norm2);
423950
424302
  return;
423951
424303
  }
423952
424304
  if (args.length === 2) {
@@ -430325,6 +430677,27 @@ function legendSvg(x, y) {
430325
430677
  lines.push(`</g>`);
430326
430678
  return lines.join("");
430327
430679
  }
430680
+ function sectionBubble(x, y, letter, color) {
430681
+ return `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="9" fill="#ffffff" stroke="${color}" stroke-width="1.4" /><text x="${x.toFixed(1)}" y="${(y + 3.5).toFixed(1)}" text-anchor="middle" font-size="11" font-weight="700" fill="${color}">${escapeXml(letter)}</text>`;
430682
+ }
430683
+ function detailMarker(x, y, side2, text, color, flip) {
430684
+ const tx = flip ? x - 12 : x + 12;
430685
+ const anchor = flip ? "end" : "start";
430686
+ return `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="8" fill="${color}" stroke="#ffffff" stroke-width="1" /><text x="${x.toFixed(1)}" y="${(y + 3).toFixed(1)}" text-anchor="middle" font-size="9" font-weight="700" fill="#ffffff">${escapeXml(side2)}</text><text x="${tx.toFixed(1)}" y="${(y + 3).toFixed(1)}" text-anchor="${anchor}" font-size="9" font-weight="600" fill="none" stroke="#ffffff" stroke-width="3" stroke-linejoin="round">${escapeXml(text)}</text><text x="${tx.toFixed(1)}" y="${(y + 3).toFixed(1)}" text-anchor="${anchor}" font-size="9" font-weight="600" fill="${color}">${escapeXml(text)}</text>`;
430687
+ }
430688
+ function faceTag(x, y, label) {
430689
+ const common2 = `x="${x.toFixed(1)}" y="${(y + 4).toFixed(1)}" text-anchor="middle" font-size="12" font-weight="800"`;
430690
+ return `<text ${common2} fill="none" stroke="#ffffff" stroke-width="3.5" stroke-linejoin="round">${escapeXml(label)}</text><text ${common2} fill="#334155">${escapeXml(label)}</text>`;
430691
+ }
430692
+ function polyCentroid(verts) {
430693
+ let sx = 0, sy = 0;
430694
+ for (const v of verts) {
430695
+ sx += v[0];
430696
+ sy += v[1];
430697
+ }
430698
+ const n3 = verts.length || 1;
430699
+ return [sx / n3, sy / n3];
430700
+ }
430328
430701
  function renderTopViewPanel(spec, opts) {
430329
430702
  const { width, height } = opts;
430330
430703
  const padding = opts.padding ?? 20;
@@ -430368,9 +430741,58 @@ function renderTopViewPanel(spec, opts) {
430368
430741
  let deg = Math.atan2(dy, dx) * 180 / Math.PI;
430369
430742
  if (deg > 90) deg -= 180;
430370
430743
  else if (deg < -90) deg += 180;
430371
- body.push(
430372
- `<text x="${lx.toFixed(1)}" y="${ly.toFixed(1)}" text-anchor="middle" dominant-baseline="middle" font-size="10" font-weight="700" fill="#b91c1c" paint-order="stroke" stroke="#ffffff" stroke-width="3" stroke-linejoin="round" transform="rotate(${deg.toFixed(1)} ${lx.toFixed(1)} ${ly.toFixed(1)})">ridge ${escapeXml(formatDimension(lenW))}</text>`
430373
- );
430744
+ const ridgeCommon = `x="${lx.toFixed(1)}" y="${ly.toFixed(1)}" text-anchor="middle" dominant-baseline="middle" font-size="10" font-weight="700" transform="rotate(${deg.toFixed(1)} ${lx.toFixed(1)} ${ly.toFixed(1)})"`;
430745
+ const ridgeLabel = `ridge ${escapeXml(formatDimension(lenW))}`;
430746
+ body.push(`<text ${ridgeCommon} fill="none" stroke="#ffffff" stroke-width="3" stroke-linejoin="round">${ridgeLabel}</text>`);
430747
+ body.push(`<text ${ridgeCommon} fill="#b91c1c">${ridgeLabel}</text>`);
430748
+ }
430749
+ if (opts.markers) {
430750
+ const mk = opts.markers;
430751
+ const cx = (bounds.x_min + bounds.x_max) / 2;
430752
+ const cy = (bounds.y_min + bounds.y_max) / 2;
430753
+ const spanX = bounds.x_max - bounds.x_min || 1;
430754
+ const spanY = bounds.y_max - bounds.y_min || 1;
430755
+ const SECT = "#7c3aed", EAVE = "#0e7490";
430756
+ for (const s of mk.sections) {
430757
+ const p1 = s.axis === "x" ? toSvg(s.coord, bounds.y_min) : toSvg(bounds.x_min, s.coord);
430758
+ const p2 = s.axis === "x" ? toSvg(s.coord, bounds.y_max) : toSvg(bounds.x_max, s.coord);
430759
+ let ux = p2[0] - p1[0], uy = p2[1] - p1[1];
430760
+ const ul = Math.hypot(ux, uy) || 1;
430761
+ ux /= ul;
430762
+ uy /= ul;
430763
+ const e1 = [p1[0] - ux * 18, p1[1] - uy * 18];
430764
+ const e2 = [p2[0] + ux * 18, p2[1] + uy * 18];
430765
+ body.push(`<line x1="${e1[0].toFixed(1)}" y1="${e1[1].toFixed(1)}" x2="${e2[0].toFixed(1)}" y2="${e2[1].toFixed(1)}" stroke="${SECT}" stroke-width="1.3" stroke-dasharray="12 3 3 3" />`);
430766
+ body.push(sectionBubble(e1[0], e1[1], s.label, SECT));
430767
+ body.push(sectionBubble(e2[0], e2[1], s.label, SECT));
430768
+ }
430769
+ for (const e of mk.eaves) {
430770
+ for (const side2 of e.sides) {
430771
+ let wx = cx, wy = cy;
430772
+ if (side2 === "N") {
430773
+ wy = bounds.y_min + spanY * 0.05;
430774
+ } else if (side2 === "S") {
430775
+ wy = bounds.y_max - spanY * 0.05;
430776
+ } else if (side2 === "W") {
430777
+ wx = bounds.x_min + spanX * 0.05;
430778
+ } else if (side2 === "E") {
430779
+ wx = bounds.x_max - spanX * 0.05;
430780
+ }
430781
+ const [sx, sy] = toSvg(wx, wy);
430782
+ const flip = side2 === "E";
430783
+ body.push(detailMarker(sx, sy, side2, `${e.label} eave`, EAVE, flip));
430784
+ }
430785
+ }
430786
+ for (const f2 of mk.faces) {
430787
+ for (const id of f2.planeIds) {
430788
+ const pl = spec.planes.find((p) => p.id === id);
430789
+ if (!pl) continue;
430790
+ const [cxw, cyw] = polyCentroid(pl.vertices);
430791
+ const [fx, fy] = toSvg(cxw, cyw);
430792
+ body.push(faceTag(fx, fy, f2.label));
430793
+ }
430794
+ }
430795
+ body.push(`<text x="10" y="${(height - 8).toFixed(1)}" font-size="9" fill="#475569"><tspan fill="${SECT}" font-weight="700">A</tspan>\u2013A section cut \xB7 <tspan fill="${EAVE}" font-weight="700">\u25CF</tspan> eave detail \xB7 <tspan fill="#334155" font-weight="800">F#</tspan> roof face</text>`);
430374
430796
  }
430375
430797
  } else {
430376
430798
  body.push(`<text x="${(width / 2).toFixed(1)}" y="${(height / 2).toFixed(1)}" text-anchor="middle" font-size="11" fill="#94a3b8">(empty roof spec)</text>`);
@@ -430386,8 +430808,11 @@ function escapeXml(s) {
430386
430808
  }
430387
430809
 
430388
430810
  // ../editor/src/svg2d/roof/v2/perspectivePanel.ts
430811
+ init_format();
430389
430812
  var COS30 = Math.cos(30 * Math.PI / 180);
430390
430813
  var SIN30 = Math.sin(30 * Math.PI / 180);
430814
+ var DIM_COLOR = "#b91c1c";
430815
+ var WIT_COLOR = "#e59aa0";
430391
430816
  function iso(p) {
430392
430817
  return [(p[0] - p[1]) * COS30, p[2] - (p[0] + p[1]) * SIN30];
430393
430818
  }
@@ -430414,11 +430839,99 @@ function frameWidth(role) {
430414
430839
  if (role === "pani_patti" || role === "eave_L_channel" || role === "corner_double_angle") return 1;
430415
430840
  return 1.6;
430416
430841
  }
430842
+ function buildFrameDimensions(spec) {
430843
+ const lines = [];
430844
+ const labels = [];
430845
+ const ring = spec.members.filter((m) => m.role === "ring_beam");
430846
+ const src = ring.length ? ring : spec.members;
430847
+ if (!src.length) return { lines, labels };
430848
+ let aX = Infinity, bX = -Infinity, aY = Infinity, bY = -Infinity, loZ = Infinity;
430849
+ for (const m of src) for (const p of [m.start, m.end]) {
430850
+ if (p[0] < aX) aX = p[0];
430851
+ if (p[0] > bX) bX = p[0];
430852
+ if (p[1] < aY) aY = p[1];
430853
+ if (p[1] > bY) bY = p[1];
430854
+ if (p[2] < loZ) loZ = p[2];
430855
+ }
430856
+ const baseZ = ring.length ? ring[0].start[2] : loZ;
430857
+ let apexZ = -Infinity;
430858
+ for (const m of spec.members) for (const p of [m.start, m.end]) if (p[2] > apexZ) apexZ = p[2];
430859
+ for (const t of spec.trusses) if (t.apex[2] > apexZ) apexZ = t.apex[2];
430860
+ if (!isFinite(apexZ)) apexZ = baseZ;
430861
+ const boxW = bX - aX, boxL = bY - aY, rise = apexZ - baseZ;
430862
+ const off = Math.max(0.12 * Math.max(boxW, boxL), 12);
430863
+ const addDim = (a, b, text) => {
430864
+ lines.push({ a, b, color: DIM_COLOR, w: 1, tick: true });
430865
+ labels.push({ at: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2], text });
430866
+ };
430867
+ const addWit = (a, b) => lines.push({ a, b, color: WIT_COLOR, w: 0.5, dash: "3 2", tick: false });
430868
+ {
430869
+ const dy = aY - off;
430870
+ addDim([aX, dy, baseZ], [bX, dy, baseZ], formatDimension(boxW));
430871
+ addWit([aX, aY, baseZ], [aX, dy, baseZ]);
430872
+ addWit([bX, aY, baseZ], [bX, dy, baseZ]);
430873
+ }
430874
+ {
430875
+ const dx = aX - off;
430876
+ addDim([dx, aY, baseZ], [dx, bY, baseZ], formatDimension(boxL));
430877
+ addWit([aX, aY, baseZ], [dx, aY, baseZ]);
430878
+ addWit([aX, bY, baseZ], [dx, bY, baseZ]);
430879
+ }
430880
+ {
430881
+ const cx = aX - off, cy = aY - off;
430882
+ addDim([cx, cy, baseZ], [cx, cy, apexZ], formatDimension(rise));
430883
+ addWit([aX, aY, baseZ], [cx, cy, baseZ]);
430884
+ }
430885
+ if (spec.trusses.length > 1) {
430886
+ const cen = spec.trusses.map((t) => [
430887
+ (t.bottom_left[0] + t.bottom_right[0]) / 2,
430888
+ (t.bottom_left[1] + t.bottom_right[1]) / 2
430889
+ ]);
430890
+ const spanX = Math.max(...cen.map((c) => c[0])) - Math.min(...cen.map((c) => c[0]));
430891
+ const spanY = Math.max(...cen.map((c) => c[1])) - Math.min(...cen.map((c) => c[1]));
430892
+ const axis = spanX >= spanY ? 0 : 1;
430893
+ const pos = cen.map((c) => c[axis]).sort((p, q) => p - q);
430894
+ if (axis === 1) {
430895
+ const dx = bX + off;
430896
+ const marks = [aY, ...pos, bY];
430897
+ for (let i2 = 0; i2 + 1 < marks.length; i2++) {
430898
+ const g = Math.abs(marks[i2 + 1] - marks[i2]);
430899
+ if (g < 0.5) continue;
430900
+ addDim([dx, marks[i2], baseZ], [dx, marks[i2 + 1], baseZ], formatDimension(g));
430901
+ }
430902
+ for (const mk of marks) addWit([bX, mk, baseZ], [dx, mk, baseZ]);
430903
+ } else {
430904
+ const dy = bY + off;
430905
+ const marks = [aX, ...pos, bX];
430906
+ for (let i2 = 0; i2 + 1 < marks.length; i2++) {
430907
+ const g = Math.abs(marks[i2 + 1] - marks[i2]);
430908
+ if (g < 0.5) continue;
430909
+ addDim([marks[i2], dy, baseZ], [marks[i2 + 1], dy, baseZ], formatDimension(g));
430910
+ }
430911
+ for (const mk of marks) addWit([mk, bY, baseZ], [mk, dy, baseZ]);
430912
+ }
430913
+ }
430914
+ return { lines, labels };
430915
+ }
430916
+ function haloText(x, y, text, opts) {
430917
+ const size = opts.size ?? 10;
430918
+ const weight = opts.weight ?? 600;
430919
+ const halo = opts.halo ?? 3;
430920
+ const xf = x.toFixed(1), yf = y.toFixed(1);
430921
+ const rot = opts.rotate != null ? ` transform="rotate(${opts.rotate.toFixed(1)} ${xf} ${yf})"` : "";
430922
+ const mid2 = opts.middle ? ` dominant-baseline="middle"` : "";
430923
+ const base = `x="${xf}" y="${yf}" text-anchor="middle"${mid2} font-size="${size}" font-weight="${weight}"${rot}`;
430924
+ return `<text ${base} fill="none" stroke="#fdfcfa" stroke-width="${halo}" stroke-linejoin="round">${text}</text>
430925
+ <text ${base} fill="${opts.fill}">${text}</text>
430926
+ `;
430927
+ }
430417
430928
  function v2PerspectivePanel(x0, y0, width, height, spec, opts = {}) {
430929
+ const doDim = opts.dimensions === true;
430418
430930
  const titleH = opts.title ? 40 : 0;
430419
- const innerPad = 20;
430931
+ const innerPad = doDim ? 40 : 20;
430420
430932
  const drawW = width - 2 * innerPad;
430421
430933
  const drawH = height - titleH - 2 * innerPad;
430934
+ const dim = doDim ? buildFrameDimensions(spec) : { lines: [], labels: [] };
430422
430935
  let minIx = Infinity, maxIx = -Infinity;
430423
430936
  let minIy = Infinity, maxIy = -Infinity;
430424
430937
  const consider = (p) => {
@@ -430433,6 +430946,10 @@ function v2PerspectivePanel(x0, y0, width, height, spec, opts = {}) {
430433
430946
  consider(m.start);
430434
430947
  consider(m.end);
430435
430948
  }
430949
+ for (const wl of dim.lines) {
430950
+ consider(wl.a);
430951
+ consider(wl.b);
430952
+ }
430436
430953
  if (!isFinite(minIx)) return "";
430437
430954
  const isoW = maxIx - minIx || 1;
430438
430955
  const isoH = maxIy - minIy || 1;
@@ -430466,12 +430983,218 @@ function v2PerspectivePanel(x0, y0, width, height, spec, opts = {}) {
430466
430983
  const members = t.members ?? buildTrussMembers(t);
430467
430984
  for (const m of members) svg += line2(m);
430468
430985
  }
430986
+ if (doDim) {
430987
+ const pc = [x0 + width / 2, y0 + height / 2];
430988
+ for (const wl of dim.lines) {
430989
+ const [x1, y1] = toSvg(wl.a);
430990
+ const [x2, y2] = toSvg(wl.b);
430991
+ svg += `<line x1="${x1.toFixed(2)}" y1="${y1.toFixed(2)}" x2="${x2.toFixed(2)}" y2="${y2.toFixed(2)}" stroke="${wl.color}" stroke-width="${wl.w}"${wl.dash ? ` stroke-dasharray="${wl.dash}"` : ""} stroke-linecap="round"/>
430992
+ `;
430993
+ if (wl.tick) {
430994
+ let ux = x2 - x1, uy = y2 - y1;
430995
+ const d = Math.hypot(ux, uy) || 1;
430996
+ ux /= d;
430997
+ uy /= d;
430998
+ const px = -uy * 4, py = ux * 4;
430999
+ for (const [tx, ty] of [[x1, y1], [x2, y2]]) {
431000
+ svg += `<line x1="${(tx - px).toFixed(2)}" y1="${(ty - py).toFixed(2)}" x2="${(tx + px).toFixed(2)}" y2="${(ty + py).toFixed(2)}" stroke="${wl.color}" stroke-width="${wl.w}"/>
431001
+ `;
431002
+ }
431003
+ }
431004
+ }
431005
+ for (const lb of dim.labels) {
431006
+ const [mx, my] = toSvg(lb.at);
431007
+ let dx = mx - pc[0], dy = my - pc[1];
431008
+ const d = Math.hypot(dx, dy) || 1;
431009
+ dx /= d;
431010
+ dy /= d;
431011
+ const lx = mx + dx * 12, ly = my + dy * 12;
431012
+ svg += haloText(lx, ly + 3, lb.text, { fill: DIM_COLOR, size: 11, weight: 700 });
431013
+ }
431014
+ const majorEnds = [];
431015
+ for (const t of spec.trusses) majorEnds.push(t.apex);
431016
+ for (const mm of spec.members) {
431017
+ if (mm.role === "ridge" || mm.role === "hip" || mm.role === "valley" || mm.role === "ring_beam") {
431018
+ majorEnds.push(mm.start);
431019
+ majorEnds.push(mm.end);
431020
+ }
431021
+ }
431022
+ const segNodes = (m) => {
431023
+ const d = [m.end[0] - m.start[0], m.end[1] - m.start[1], m.end[2] - m.start[2]];
431024
+ const len2 = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
431025
+ const len = Math.sqrt(len2);
431026
+ if (len < 1e-6) return [0, 1];
431027
+ const raw = [];
431028
+ for (const p of majorEnds) {
431029
+ const t = ((p[0] - m.start[0]) * d[0] + (p[1] - m.start[1]) * d[1] + (p[2] - m.start[2]) * d[2]) / len2;
431030
+ if (t <= 0.02 || t >= 0.98) continue;
431031
+ const proj = [m.start[0] + t * d[0], m.start[1] + t * d[1], m.start[2] + t * d[2]];
431032
+ if (Math.hypot(p[0] - proj[0], p[1] - proj[1], p[2] - proj[2]) < 1) raw.push(t);
431033
+ }
431034
+ raw.sort((a, b) => a - b);
431035
+ const mid2 = [];
431036
+ for (const t of raw) if (!mid2.length || (t - mid2[mid2.length - 1]) * len > 2) mid2.push(t);
431037
+ return [0, ...mid2, 1];
431038
+ };
431039
+ const lerp3 = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
431040
+ for (const m of spec.members) {
431041
+ if (m.role !== "ridge" && m.role !== "hip" && m.role !== "valley") continue;
431042
+ const L = Math.hypot(m.end[0] - m.start[0], m.end[1] - m.start[1], m.end[2] - m.start[2]);
431043
+ if (L < 1) continue;
431044
+ const col = FRAME_STROKES[m.role] ?? "#334155";
431045
+ const nodes = segNodes(m);
431046
+ for (let k = 1; k < nodes.length - 1; k++) {
431047
+ const [nx0, ny0] = toSvg(lerp3(m.start, m.end, nodes[k]));
431048
+ const [ex, ey] = toSvg(m.end);
431049
+ const [sx, sy] = toSvg(m.start);
431050
+ let ux = ex - sx, uy = ey - sy;
431051
+ const ul = Math.hypot(ux, uy) || 1;
431052
+ ux /= ul;
431053
+ uy /= ul;
431054
+ svg += `<line x1="${(nx0 + uy * 4).toFixed(1)}" y1="${(ny0 - ux * 4).toFixed(1)}" x2="${(nx0 - uy * 4).toFixed(1)}" y2="${(ny0 + ux * 4).toFixed(1)}" stroke="${col}" stroke-width="1.2"/>
431055
+ `;
431056
+ }
431057
+ for (let k = 0; k + 1 < nodes.length; k++) {
431058
+ const segLen = (nodes[k + 1] - nodes[k]) * L;
431059
+ if (segLen < 1) continue;
431060
+ const [x1, y1] = toSvg(lerp3(m.start, m.end, nodes[k]));
431061
+ const [x2, y2] = toSvg(lerp3(m.start, m.end, nodes[k + 1]));
431062
+ const mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
431063
+ let deg = Math.atan2(y2 - y1, x2 - x1) * 180 / Math.PI;
431064
+ if (deg > 90) deg -= 180;
431065
+ else if (deg < -90) deg += 180;
431066
+ let nx = -(y2 - y1), ny = x2 - x1;
431067
+ const nl = Math.hypot(nx, ny) || 1;
431068
+ nx /= nl;
431069
+ ny /= nl;
431070
+ if (ny > 0) {
431071
+ nx = -nx;
431072
+ ny = -ny;
431073
+ }
431074
+ const lx = mx + nx * 8, ly = my + ny * 8;
431075
+ svg += haloText(lx, ly, formatDimension(segLen), { fill: col, size: 10, weight: 600, middle: true, rotate: deg });
431076
+ }
431077
+ }
431078
+ const eaves = spec.members.filter((m) => m.role === "pani_patti");
431079
+ if (eaves.length) {
431080
+ let rminX = Infinity, rmaxX = -Infinity, rminY = Infinity, rmaxY = -Infinity, wallTopZ = -Infinity;
431081
+ for (const m of spec.members) if (m.role === "ring_beam") for (const p of [m.start, m.end]) {
431082
+ if (p[0] < rminX) rminX = p[0];
431083
+ if (p[0] > rmaxX) rmaxX = p[0];
431084
+ if (p[1] < rminY) rminY = p[1];
431085
+ if (p[1] > rmaxY) rmaxY = p[1];
431086
+ if (p[2] > wallTopZ) wallTopZ = p[2];
431087
+ }
431088
+ const halfWall = (spec.framing?.wall_thickness_u ?? 0) / 2;
431089
+ rminX -= halfWall;
431090
+ rmaxX += halfWall;
431091
+ rminY -= halfWall;
431092
+ rmaxY += halfWall;
431093
+ const cx0 = (rminX + rmaxX) / 2, cy0 = (rminY + rmaxY) / 2;
431094
+ const front = eaves.map((m) => ({ m, key: (m.start[0] + m.end[0]) / 2 + (m.start[1] + m.end[1]) / 2 })).sort((a, b) => b.key - a.key).slice(0, 2).map((s) => s.m);
431095
+ const EAVE = "#0e7490";
431096
+ for (const m of front) {
431097
+ const L = Math.hypot(m.end[0] - m.start[0], m.end[1] - m.start[1], m.end[2] - m.start[2]);
431098
+ const [x1, y1] = toSvg(m.start), [x2, y2] = toSvg(m.end);
431099
+ const mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
431100
+ let deg = Math.atan2(y2 - y1, x2 - x1) * 180 / Math.PI;
431101
+ if (deg > 90) deg -= 180;
431102
+ else if (deg < -90) deg += 180;
431103
+ let tx = mx - pc[0], ty = my - pc[1];
431104
+ const tl = Math.hypot(tx, ty) || 1;
431105
+ const lx = mx - tx / tl * 12, ly = my - ty / tl * 12;
431106
+ svg += haloText(lx, ly, `eave ${formatDimension(L)}`, { fill: EAVE, size: 10, weight: 700, middle: true, rotate: deg });
431107
+ const vertical = Math.abs(m.start[0] - m.end[0]) < 1;
431108
+ const s = lerp3(m.start, m.end, 0.28);
431109
+ let inward, oh;
431110
+ if (vertical) {
431111
+ const X = m.start[0];
431112
+ const wallX = X > cx0 ? rmaxX : rminX;
431113
+ oh = Math.abs(X - wallX);
431114
+ inward = [wallX, s[1], s[2]];
431115
+ } else {
431116
+ const Y = m.start[1];
431117
+ const wallY = Y > cy0 ? rmaxY : rminY;
431118
+ oh = Math.abs(Y - wallY);
431119
+ inward = [s[0], wallY, s[2]];
431120
+ }
431121
+ const [ex, ey] = toSvg(s), [ix, iy] = toSvg(inward);
431122
+ svg += `<line x1="${ex.toFixed(1)}" y1="${ey.toFixed(1)}" x2="${ix.toFixed(1)}" y2="${iy.toFixed(1)}" stroke="${EAVE}" stroke-width="1"/>
431123
+ `;
431124
+ for (const [tx2, ty2] of [[ex, ey], [ix, iy]]) {
431125
+ let ux = ix - ex, uy = iy - ey;
431126
+ const ul = Math.hypot(ux, uy) || 1;
431127
+ ux /= ul;
431128
+ uy /= ul;
431129
+ svg += `<line x1="${(tx2 - uy * 3).toFixed(1)}" y1="${(ty2 + ux * 3).toFixed(1)}" x2="${(tx2 + uy * 3).toFixed(1)}" y2="${(ty2 - ux * 3).toFixed(1)}" stroke="${EAVE}" stroke-width="1"/>
431130
+ `;
431131
+ }
431132
+ const [wx, wy] = toSvg([inward[0], inward[1], wallTopZ]);
431133
+ svg += `<line x1="${ix.toFixed(1)}" y1="${iy.toFixed(1)}" x2="${wx.toFixed(1)}" y2="${wy.toFixed(1)}" stroke="${EAVE}" stroke-width="0.5" stroke-dasharray="2 2"/>
431134
+ `;
431135
+ const omx = (ex + ix) / 2, omy = (ey + iy) / 2;
431136
+ svg += haloText(omx, omy - 4, `\u21A4 ${formatDimension(oh)}`, { fill: EAVE, size: 9, weight: 600, halo: 2.5 });
431137
+ }
431138
+ }
431139
+ svg += `<text x="${(x0 + 12).toFixed(1)}" y="${(y0 + height - 12).toFixed(1)}" text-anchor="start" font-size="9" fill="#b91c1c">red = overall + truss spacing \xB7 brown = ridge &amp; hip segments \xB7 <tspan fill="#0e7490">teal = front eave length + overhang from wall</tspan></text>
431140
+ `;
431141
+ }
430469
431142
  svg += `</g>
430470
431143
  `;
430471
431144
  return svg;
430472
431145
  }
430473
431146
 
431147
+ // ../editor/src/svg2d/roof/v2/panelDraw.ts
431148
+ function escapeXml2(s) {
431149
+ return s.replace(
431150
+ /[<>&'"]/g,
431151
+ (c) => c === "<" ? "&lt;" : c === ">" ? "&gt;" : c === "&" ? "&amp;" : c === "'" ? "&#39;" : "&quot;"
431152
+ );
431153
+ }
431154
+ function dimLineH(x1, x2, y, label) {
431155
+ const lo = Math.min(x1, x2), hi = Math.max(x1, x2);
431156
+ return `<line x1="${lo.toFixed(1)}" y1="${y.toFixed(1)}" x2="${hi.toFixed(1)}" y2="${y.toFixed(1)}" stroke="#334155" stroke-width="0.8"/>
431157
+ <line x1="${lo.toFixed(1)}" y1="${(y - 3).toFixed(1)}" x2="${lo.toFixed(1)}" y2="${(y + 3).toFixed(1)}" stroke="#334155" stroke-width="0.8"/>
431158
+ <line x1="${hi.toFixed(1)}" y1="${(y - 3).toFixed(1)}" x2="${hi.toFixed(1)}" y2="${(y + 3).toFixed(1)}" stroke="#334155" stroke-width="0.8"/>
431159
+ <text x="${((lo + hi) / 2).toFixed(1)}" y="${(y - 4).toFixed(1)}" text-anchor="middle" font-size="10" fill="#334155">${escapeXml2(label)}</text>
431160
+ `;
431161
+ }
431162
+ function dimLineV(y1, y2, x, label) {
431163
+ const lo = Math.min(y1, y2), hi = Math.max(y1, y2);
431164
+ return `<line x1="${x.toFixed(1)}" y1="${lo.toFixed(1)}" x2="${x.toFixed(1)}" y2="${hi.toFixed(1)}" stroke="#334155" stroke-width="0.8"/>
431165
+ <line x1="${(x - 3).toFixed(1)}" y1="${lo.toFixed(1)}" x2="${(x + 3).toFixed(1)}" y2="${lo.toFixed(1)}" stroke="#334155" stroke-width="0.8"/>
431166
+ <line x1="${(x - 3).toFixed(1)}" y1="${hi.toFixed(1)}" x2="${(x + 3).toFixed(1)}" y2="${hi.toFixed(1)}" stroke="#334155" stroke-width="0.8"/>
431167
+ <text x="${(x + 4).toFixed(1)}" y="${((lo + hi) / 2).toFixed(1)}" text-anchor="middle" dominant-baseline="middle" font-size="10" fill="#334155" transform="rotate(-90 ${(x + 4).toFixed(1)} ${((lo + hi) / 2).toFixed(1)})">${escapeXml2(label)}</text>
431168
+ `;
431169
+ }
431170
+ function panelFrame(x0, y0, width, height, titleH, title, cls) {
431171
+ const cx = x0 + width / 2;
431172
+ return `<g class="${cls}">
431173
+ <rect x="${x0}" y="${y0}" width="${width}" height="${height}" fill="#fdfcfa" stroke="#333" stroke-width="1"/>
431174
+ <rect x="${x0}" y="${y0}" width="${width}" height="${titleH}" fill="#f2ede4" stroke="#333" stroke-width="1"/>
431175
+ <text x="${cx}" y="${y0 + 26}" text-anchor="middle" font-size="14" font-weight="600" fill="#222">${escapeXml2(title)}</text>
431176
+ `;
431177
+ }
431178
+ function keyPlanDisc(cx, cy, r2, letter, fill, stroke, textColor) {
431179
+ return `<circle cx="${cx.toFixed(1)}" cy="${cy.toFixed(1)}" r="${r2}" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/><text x="${cx.toFixed(1)}" y="${(cy + r2 * 0.36).toFixed(1)}" text-anchor="middle" font-size="${Math.round(r2 * 1.1)}" font-weight="700" fill="${textColor}">${escapeXml2(letter)}</text>`;
431180
+ }
431181
+ function keyPlanTag(xRight, yTop, label, fill, textColor) {
431182
+ const w = 12 + label.length * 8;
431183
+ const x = xRight - w;
431184
+ return `<rect x="${x.toFixed(1)}" y="${yTop.toFixed(1)}" width="${w}" height="22" rx="5" fill="${fill}" stroke="#334155" stroke-width="0.9"/><text x="${(x + w / 2).toFixed(1)}" y="${(yTop + 15.5).toFixed(1)}" text-anchor="middle" font-size="13" font-weight="800" fill="${textColor}">${escapeXml2(label)}</text>`;
431185
+ }
431186
+ function sectionFtLabel(s) {
431187
+ if (!s) return null;
431188
+ const inch = (ft) => {
431189
+ const totalIn = Math.round(ft * 12);
431190
+ if (totalIn % 12 === 0 && totalIn >= 12) return `${totalIn / 12}'`;
431191
+ return `${totalIn}"`;
431192
+ };
431193
+ return `${inch(s[0])}\xD7${inch(s[1])}`;
431194
+ }
431195
+
430474
431196
  // ../editor/src/svg2d/roof/v2/sectionPanel.ts
431197
+ init_format();
430475
431198
  function planePolygonSectionSegments(poly, cutAxis, cutCoord) {
430476
431199
  if (poly.length < 3) return [];
430477
431200
  const idx = cutAxis === "x" ? 0 : 1;
@@ -430508,6 +431231,26 @@ function planePolygonSectionSegments(poly, cutAxis, cutCoord) {
430508
431231
  }
430509
431232
  return segs;
430510
431233
  }
431234
+ function trussMembers(t) {
431235
+ if (t.members && t.members.length) return t.members;
431236
+ return (t.kind === "mono_pitch" ? buildMonoPitchTrussMembers : buildFinkTrussMembers)(t);
431237
+ }
431238
+ function trussForSection(spec, cutAxis) {
431239
+ const horIdx = cutAxis === "x" ? 1 : 0;
431240
+ let best = null;
431241
+ let bestScore = 0.7;
431242
+ for (const t of spec.trusses) {
431243
+ const dx = t.bottom_right[0] - t.bottom_left[0];
431244
+ const dy = t.bottom_right[1] - t.bottom_left[1];
431245
+ const len = Math.hypot(dx, dy) || 1;
431246
+ const along = Math.abs((horIdx === 0 ? dx : dy) / len);
431247
+ if (along > bestScore) {
431248
+ bestScore = along;
431249
+ best = t;
431250
+ }
431251
+ }
431252
+ return best;
431253
+ }
430511
431254
  var PLANE_STROKES = {
430512
431255
  slope: "#8B4513",
430513
431256
  hip_face: "#8B4513",
@@ -430518,8 +431261,10 @@ var PLANE_STROKES = {
430518
431261
  function v2SectionPanel(x0, y0, width, height, spec, opts) {
430519
431262
  const titleH = opts.title ? 40 : 0;
430520
431263
  const innerPad = 20;
430521
- const drawW = width - 2 * innerPad;
430522
- const drawH = height - titleH - 2 * innerPad;
431264
+ const dimL = opts.dimensions ? 46 : 0;
431265
+ const dimB = opts.dimensions ? 58 : 0;
431266
+ const drawW = width - 2 * innerPad - dimL;
431267
+ const drawH = height - titleH - 2 * innerPad - dimB;
430523
431268
  const allSegs = [];
430524
431269
  for (const p of spec.planes) {
430525
431270
  if (p.role === "parapet") continue;
@@ -430547,7 +431292,7 @@ function v2SectionPanel(x0, y0, width, height, spec, opts) {
430547
431292
  const horSpan = maxH - minH || 1;
430548
431293
  const zSpan = maxZ - minZ || 1;
430549
431294
  const scale = Math.min(drawW / horSpan, drawH / zSpan);
430550
- const offX = x0 + innerPad + (drawW - horSpan * scale) / 2 - minH * scale;
431295
+ const offX = x0 + innerPad + dimL + (drawW - horSpan * scale) / 2 - minH * scale;
430551
431296
  const offY = y0 + titleH + innerPad + (drawH - zSpan * scale) / 2 + maxZ * scale;
430552
431297
  const toSvg = (p) => [
430553
431298
  offX + p[horIdx] * scale,
@@ -430561,6 +431306,7 @@ function v2SectionPanel(x0, y0, width, height, spec, opts) {
430561
431306
  svg += `<text x="${x0 + width / 2}" y="${y0 + 24}" text-anchor="middle" font-size="14" font-weight="bold" fill="#222">${opts.title}</text>
430562
431307
  `;
430563
431308
  }
431309
+ if (opts.keyLabel) svg += keyPlanDisc(x0 + width - 26, y0 + 20, 13, opts.keyLabel, "#ffffff", "#7c3aed", "#7c3aed");
430564
431310
  if (opts.wallTopZ != null) {
430565
431311
  const [x1, y1] = toSvg([minH, minH, opts.wallTopZ]);
430566
431312
  const [x2, y2] = toSvg([maxH, maxH, opts.wallTopZ]);
@@ -430593,6 +431339,66 @@ function v2SectionPanel(x0, y0, width, height, spec, opts) {
430593
431339
  const color = MEMBER_MARK_COLORS[m.role] ?? "#334155";
430594
431340
  const half = 3;
430595
431341
  svg += `<rect x="${(sx - half).toFixed(2)}" y="${(sy - half).toFixed(2)}" width="${(half * 2).toFixed(2)}" height="${(half * 2).toFixed(2)}" fill="${color}" stroke="#111" stroke-width="0.4"/>
431342
+ `;
431343
+ }
431344
+ if (opts.overlayTruss) {
431345
+ const t = trussForSection(spec, opts.cutAxis);
431346
+ if (t) {
431347
+ const kindLabel = (t.kind ?? "fink") === "mono_pitch" ? "mono-pitch" : "fink";
431348
+ for (const m of trussMembers(t)) {
431349
+ const isChord = m.role === "truss_top_chord" || m.role === "truss_bottom_chord";
431350
+ const [x1, y1] = toSvg(m.start);
431351
+ const [x2, y2] = toSvg(m.end);
431352
+ svg += `<line x1="${x1.toFixed(2)}" y1="${y1.toFixed(2)}" x2="${x2.toFixed(2)}" y2="${y2.toFixed(2)}" stroke="${isChord ? "#7c2d12" : "#b45309"}" stroke-width="${isChord ? 1.8 : 1}" stroke-dasharray="${isChord ? "6 3" : "3 3"}" stroke-linecap="round"/>
431353
+ `;
431354
+ }
431355
+ svg += `<text x="${(x0 + width - 12).toFixed(1)}" y="${(y0 + height - 12).toFixed(1)}" text-anchor="end" font-size="9" fill="#7c2d12">\u2014 \u2014 ${kindLabel} truss profile at this bay (see truss drawing for member lengths)</text>
431356
+ `;
431357
+ }
431358
+ }
431359
+ if (opts.dimensions) {
431360
+ const hz = (h, z2) => {
431361
+ const p = [0, 0, z2];
431362
+ p[horIdx] = h;
431363
+ return p;
431364
+ };
431365
+ const ringHs = [];
431366
+ for (const m of spec.members) if (m.role === "ring_beam") ringHs.push(m.start[horIdx], m.end[horIdx]);
431367
+ const half = (spec.framing?.wall_thickness_u ?? 0) / 2;
431368
+ const eMinH = minH, eMaxH = maxH;
431369
+ const rMinH = (ringHs.length ? Math.min(...ringHs) : eMinH) - half;
431370
+ const rMaxH = (ringHs.length ? Math.max(...ringHs) : eMaxH) + half;
431371
+ let segMinZ = Infinity, segMaxZ = -Infinity;
431372
+ for (const { seg: [a, b] } of allSegs) for (const p of [a, b]) {
431373
+ if (p[2] < segMinZ) segMinZ = p[2];
431374
+ if (p[2] > segMaxZ) segMaxZ = p[2];
431375
+ }
431376
+ const eaveZ = isFinite(segMinZ) ? segMinZ : minZ;
431377
+ const ridgeZ = isFinite(segMaxZ) ? segMaxZ : maxZ;
431378
+ const wtZ = opts.wallTopZ ?? eaveZ;
431379
+ const EAVE = "#0e7490";
431380
+ const el = toSvg(hz(eMinH, eaveZ)), er = toSvg(hz(eMaxH, eaveZ));
431381
+ svg += `<line x1="${el[0].toFixed(1)}" y1="${el[1].toFixed(1)}" x2="${er[0].toFixed(1)}" y2="${er[1].toFixed(1)}" stroke="${EAVE}" stroke-width="1" stroke-dasharray="4 2"/>
431382
+ `;
431383
+ const drop = wtZ - eaveZ + Math.max((ridgeZ - eaveZ) * 0.18, 8);
431384
+ for (const h of [rMinH, rMaxH]) {
431385
+ const a = toSvg(hz(h, wtZ)), b = toSvg(hz(h, wtZ - drop));
431386
+ svg += `<line x1="${a[0].toFixed(1)}" y1="${a[1].toFixed(1)}" x2="${b[0].toFixed(1)}" y2="${b[1].toFixed(1)}" stroke="#8a6a3f" stroke-width="1.4"/>
431387
+ `;
431388
+ }
431389
+ const wallBottomY = toSvg(hz(rMinH, wtZ - drop))[1];
431390
+ const y1 = Math.max(el[1], wallBottomY) + 22;
431391
+ const y2 = y1 + 22;
431392
+ const sx = (h) => toSvg(hz(h, eaveZ))[0];
431393
+ if (rMinH - eMinH > 0.5) svg += dimLineH(sx(eMinH), sx(rMinH), y1, formatDimension(rMinH - eMinH));
431394
+ if (eMaxH - rMaxH > 0.5) svg += dimLineH(sx(rMaxH), sx(eMaxH), y1, formatDimension(eMaxH - rMaxH));
431395
+ svg += dimLineH(sx(rMinH), sx(rMaxH), y1, `wall ${formatDimension(rMaxH - rMinH)}`);
431396
+ svg += dimLineH(sx(eMinH), sx(eMaxH), y2, `eave ${formatDimension(eMaxH - eMinH)}`);
431397
+ if (ridgeZ - wtZ > 0.5) {
431398
+ const riseX = Math.min(el[0], er[0]) - 26;
431399
+ svg += dimLineV(toSvg(hz(eMinH, wtZ))[1], toSvg(hz(eMinH, ridgeZ))[1], riseX, `rise ${formatDimension(ridgeZ - wtZ)}`);
431400
+ }
431401
+ svg += `<text x="${(x0 + 12).toFixed(1)}" y="${(y0 + height - 12).toFixed(1)}" text-anchor="start" font-size="9" fill="${EAVE}">\u2014 \u2014 eave datum \xB7 brown = wall</text>
430596
431402
  `;
430597
431403
  }
430598
431404
  svg += `</g>
@@ -430745,7 +431551,7 @@ function groupFaces(spec) {
430745
431551
  (a, b) => b.geom.uMax - b.geom.uMin - (a.geom.uMax - a.geom.uMin)
430746
431552
  );
430747
431553
  }
430748
- function v2FacePanel(x0, y0, width, height, group) {
431554
+ function v2FacePanel(x0, y0, width, height, group, keyLabel) {
430749
431555
  const titleH = 40;
430750
431556
  const innerPad = 30;
430751
431557
  const drawW = width - 2 * innerPad;
@@ -430770,8 +431576,9 @@ function v2FacePanel(x0, y0, width, height, group) {
430770
431576
  `;
430771
431577
  svg += `<rect x="${x0}" y="${y0}" width="${width}" height="${titleH}" fill="#f2ede4" stroke="#333" stroke-width="1"/>
430772
431578
  `;
430773
- svg += `<text x="${x0 + width / 2}" y="${y0 + 26}" text-anchor="middle" font-size="14" font-weight="600" fill="#222">${escapeXml2(title)}</text>
431579
+ svg += `<text x="${x0 + width / 2}" y="${y0 + 26}" text-anchor="middle" font-size="14" font-weight="600" fill="#222">${escapeXml3(title)}</text>
430774
431580
  `;
431581
+ if (keyLabel) svg += keyPlanTag(x0 + width - 12, y0 + 9, keyLabel, "#eef2f7", "#334155");
430775
431582
  const outlinePts = g.uv.map(toSvg).map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(" ");
430776
431583
  svg += `<polygon points="${outlinePts}" fill="#f5e0c9" stroke="#5a2e0b" stroke-width="1.5" fill-opacity="0.5"/>
430777
431584
  `;
@@ -430874,10 +431681,523 @@ function buildTitle(group) {
430874
431681
  const ids = group.planes.map((p) => p.id).join(", ");
430875
431682
  return `${first2.role.toUpperCase()}S (${shape}, ${n3} identical) \u2014 ${ids}`;
430876
431683
  }
430877
- function escapeXml2(s) {
431684
+ function escapeXml3(s) {
430878
431685
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
430879
431686
  }
430880
431687
 
431688
+ // ../editor/src/svg2d/roof/v2/trussPanel.ts
431689
+ init_format();
431690
+ function escapeXml4(s) {
431691
+ return s.replace(
431692
+ /[<>&'"]/g,
431693
+ (c) => c === "<" ? "&lt;" : c === ">" ? "&gt;" : c === "&" ? "&amp;" : c === "'" ? "&#39;" : "&quot;"
431694
+ );
431695
+ }
431696
+ var len3 = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
431697
+ function trussMembers2(t) {
431698
+ if (t.members && t.members.length) return t.members;
431699
+ return (t.kind === "mono_pitch" ? buildMonoPitchTrussMembers : buildFinkTrussMembers)(
431700
+ { ...t, id: t.id, source_segment_id: t.source_segment_id }
431701
+ );
431702
+ }
431703
+ function trussGeom(t) {
431704
+ const bl = t.bottom_left, br = t.bottom_right;
431705
+ const span = Math.hypot(br[0] - bl[0], br[1] - bl[1]);
431706
+ const rise = t.apex[2] - bl[2];
431707
+ return { span, rise, kind: t.kind ?? "fink" };
431708
+ }
431709
+ function groupTrusses(spec) {
431710
+ const groups = /* @__PURE__ */ new Map();
431711
+ for (const t of spec.trusses) {
431712
+ const g = trussGeom(t);
431713
+ const r2 = (x) => Math.round(x * 10) / 10;
431714
+ const sig = `${g.kind}|${r2(g.span)}|${r2(g.rise)}`;
431715
+ const existing = groups.get(sig);
431716
+ if (existing) {
431717
+ existing.count += 1;
431718
+ } else {
431719
+ groups.set(sig, { signature: sig, rep: t, members: trussMembers2(t), geom: g, count: 1 });
431720
+ }
431721
+ }
431722
+ return [...groups.values()].sort((a, b) => b.geom.span - a.geom.span);
431723
+ }
431724
+ function v2TrussPanel(x0, y0, width, height, group) {
431725
+ const titleH = 40;
431726
+ const innerPad = 34;
431727
+ const drawW = width - 2 * innerPad;
431728
+ const drawH = height - titleH - 2 * innerPad - 20;
431729
+ const t = group.rep;
431730
+ const bl = t.bottom_left;
431731
+ const br = t.bottom_right;
431732
+ const { span, rise, kind } = group.geom;
431733
+ const dx = br[0] - bl[0], dy = br[1] - bl[1];
431734
+ const horiz = Math.hypot(dx, dy) || 1;
431735
+ const ux = dx / horiz, uy = dy / horiz;
431736
+ const baseZ = bl[2];
431737
+ const toUV = (p) => [
431738
+ (p[0] - bl[0]) * ux + (p[1] - bl[1]) * uy,
431739
+ p[2] - baseZ
431740
+ ];
431741
+ const scale = Math.min(drawW / (span || 1), drawH / (rise || 1)) * 0.8;
431742
+ const cx = x0 + width / 2;
431743
+ const cy = y0 + titleH + innerPad + drawH / 2;
431744
+ const toSvg = (uv) => [
431745
+ cx + (uv[0] - span / 2) * scale,
431746
+ cy - (uv[1] - rise / 2) * scale
431747
+ ];
431748
+ const pitchDeg = Math.atan2(rise, span / 2) * 180 / Math.PI;
431749
+ const nice = kind === "mono_pitch" ? "MONO-PITCH TRUSS" : "FINK TRUSS";
431750
+ const title = `${nice} \u2014 Elevation${group.count > 1 ? ` (\xD7 ${group.count} identical)` : ""}`;
431751
+ let svg = `<g class="v2-truss-panel">
431752
+ `;
431753
+ svg += `<rect x="${x0}" y="${y0}" width="${width}" height="${height}" fill="#fdfcfa" stroke="#333" stroke-width="1"/>
431754
+ `;
431755
+ svg += `<rect x="${x0}" y="${y0}" width="${width}" height="${titleH}" fill="#f2ede4" stroke="#333" stroke-width="1"/>
431756
+ `;
431757
+ svg += `<text x="${cx}" y="${y0 + 26}" text-anchor="middle" font-size="14" font-weight="600" fill="#222">${escapeXml4(title)}</text>
431758
+ `;
431759
+ const isChord = (m) => m.role === "truss_top_chord" || m.role === "truss_bottom_chord";
431760
+ for (const m of group.members) {
431761
+ const [x1, y1] = toSvg(toUV(m.start));
431762
+ const [x2, y2] = toSvg(toUV(m.end));
431763
+ const stroke = isChord(m) ? "#5a2e0b" : "#b45309";
431764
+ const sw = isChord(m) ? 2.2 : 1.2;
431765
+ svg += `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="${stroke}" stroke-width="${sw}" stroke-linecap="round"/>
431766
+ `;
431767
+ }
431768
+ const seen = /* @__PURE__ */ new Set();
431769
+ for (const m of group.members) {
431770
+ const L = len3(m.start, m.end);
431771
+ if (L < 1) continue;
431772
+ const a = toSvg(toUV(m.start));
431773
+ const b = toSvg(toUV(m.end));
431774
+ const mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2;
431775
+ let deg = Math.atan2(b[1] - a[1], b[0] - a[0]) * 180 / Math.PI;
431776
+ if (deg > 90) deg -= 180;
431777
+ else if (deg < -90) deg += 180;
431778
+ let nx = -(b[1] - a[1]), ny = b[0] - a[0];
431779
+ const nl = Math.hypot(nx, ny) || 1;
431780
+ nx /= nl;
431781
+ ny /= nl;
431782
+ if (ny > 0) {
431783
+ nx = -nx;
431784
+ ny = -ny;
431785
+ }
431786
+ const lx = mx + nx * 9, ly = my + ny * 9;
431787
+ const key = `${Math.round(L)}|${Math.round(Math.abs(deg))}`;
431788
+ if (seen.has(key) && isChord(m) === false) continue;
431789
+ seen.add(key);
431790
+ svg += `<text x="${lx.toFixed(1)}" y="${ly.toFixed(1)}" text-anchor="middle" dominant-baseline="middle" font-size="10" fill="#7c2d12" transform="rotate(${deg.toFixed(1)} ${lx.toFixed(1)} ${ly.toFixed(1)})">${formatDimension(L)}</text>
431791
+ `;
431792
+ }
431793
+ const blS = toSvg(toUV(bl));
431794
+ const brS = toSvg(toUV(br));
431795
+ const spanY = Math.max(blS[1], brS[1]) + 26;
431796
+ svg += dimLineH2(blS[0], brS[0], spanY, `span ${formatDimension(span)}`);
431797
+ svg += `<line x1="${blS[0].toFixed(1)}" y1="${blS[1].toFixed(1)}" x2="${blS[0].toFixed(1)}" y2="${spanY.toFixed(1)}" stroke="#94a3b8" stroke-width="0.5"/>
431798
+ `;
431799
+ svg += `<line x1="${brS[0].toFixed(1)}" y1="${brS[1].toFixed(1)}" x2="${brS[0].toFixed(1)}" y2="${spanY.toFixed(1)}" stroke="#94a3b8" stroke-width="0.5"/>
431800
+ `;
431801
+ const apexS = toSvg(toUV(t.apex));
431802
+ const baseAtApex = toSvg([span / 2, 0]);
431803
+ const riseX = apexS[0] + (kind === "mono_pitch" ? 22 : Math.min(brS[0] - apexS[0] + 22, 40));
431804
+ svg += dimLineV2(apexS[1], baseAtApex[1], riseX, `rise ${formatDimension(rise)}`);
431805
+ svg += `<line x1="${apexS[0].toFixed(1)}" y1="${apexS[1].toFixed(1)}" x2="${riseX.toFixed(1)}" y2="${apexS[1].toFixed(1)}" stroke="#94a3b8" stroke-width="0.5" stroke-dasharray="2 2"/>
431806
+ `;
431807
+ svg += `<line x1="${baseAtApex[0].toFixed(1)}" y1="${baseAtApex[1].toFixed(1)}" x2="${riseX.toFixed(1)}" y2="${baseAtApex[1].toFixed(1)}" stroke="#94a3b8" stroke-width="0.5" stroke-dasharray="2 2"/>
431808
+ `;
431809
+ svg += `<text x="${cx}" y="${(y0 + titleH + 18).toFixed(1)}" text-anchor="middle" font-size="13" font-weight="600" fill="#8B4513">TOP-CHORD PITCH: ${pitchDeg.toFixed(1)}\xB0</text>
431810
+ `;
431811
+ const chord = group.members.find(isChord)?.section_size ?? DEFAULT_TRUSS_SECTION.chord_size_in;
431812
+ const web = group.members.find((m) => m.role === "truss_web")?.section_size ?? DEFAULT_TRUSS_SECTION.web_size_in;
431813
+ const secStr = (s) => `${s[0]}"\xD7${s[1]}"`;
431814
+ const footY = y0 + height - 12;
431815
+ svg += `<text x="${(x0 + 12).toFixed(1)}" y="${footY.toFixed(1)}" text-anchor="start" font-size="10" fill="#475569">chord ${secStr(chord)} \xB7 web ${secStr(web)}${group.rep.members?.[0]?.material ? " \xB7 " + escapeXml4(group.rep.members[0].material) : ""}</text>
431816
+ `;
431817
+ svg += `<text x="${(x0 + width - 12).toFixed(1)}" y="${footY.toFixed(1)}" text-anchor="end" font-size="9" fill="#94a3b8">lengths = true member lengths (for fabrication)</text>
431818
+ `;
431819
+ svg += `</g>
431820
+ `;
431821
+ return svg;
431822
+ }
431823
+ function dimLineH2(x1, x2, y, label) {
431824
+ const lo = Math.min(x1, x2), hi = Math.max(x1, x2);
431825
+ return `<line x1="${lo.toFixed(1)}" y1="${y.toFixed(1)}" x2="${hi.toFixed(1)}" y2="${y.toFixed(1)}" stroke="#334155" stroke-width="0.8"/>
431826
+ <line x1="${lo.toFixed(1)}" y1="${(y - 3).toFixed(1)}" x2="${lo.toFixed(1)}" y2="${(y + 3).toFixed(1)}" stroke="#334155" stroke-width="0.8"/>
431827
+ <line x1="${hi.toFixed(1)}" y1="${(y - 3).toFixed(1)}" x2="${hi.toFixed(1)}" y2="${(y + 3).toFixed(1)}" stroke="#334155" stroke-width="0.8"/>
431828
+ <text x="${((lo + hi) / 2).toFixed(1)}" y="${(y - 4).toFixed(1)}" text-anchor="middle" font-size="10" fill="#334155">${escapeXml4(label)}</text>
431829
+ `;
431830
+ }
431831
+ function dimLineV2(y1, y2, x, label) {
431832
+ const lo = Math.min(y1, y2), hi = Math.max(y1, y2);
431833
+ return `<line x1="${x.toFixed(1)}" y1="${lo.toFixed(1)}" x2="${x.toFixed(1)}" y2="${hi.toFixed(1)}" stroke="#334155" stroke-width="0.8"/>
431834
+ <line x1="${(x - 3).toFixed(1)}" y1="${lo.toFixed(1)}" x2="${(x + 3).toFixed(1)}" y2="${lo.toFixed(1)}" stroke="#334155" stroke-width="0.8"/>
431835
+ <line x1="${(x - 3).toFixed(1)}" y1="${hi.toFixed(1)}" x2="${(x + 3).toFixed(1)}" y2="${hi.toFixed(1)}" stroke="#334155" stroke-width="0.8"/>
431836
+ <text x="${(x + 4).toFixed(1)}" y="${((lo + hi) / 2).toFixed(1)}" text-anchor="middle" dominant-baseline="middle" font-size="10" fill="#334155" transform="rotate(-90 ${(x + 4).toFixed(1)} ${((lo + hi) / 2).toFixed(1)})">${escapeXml4(label)}</text>
431837
+ `;
431838
+ }
431839
+
431840
+ // ../editor/src/svg2d/roof/v2/frameDimPanel.ts
431841
+ init_format();
431842
+ function membersBounds(members) {
431843
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
431844
+ for (const m of members) {
431845
+ for (const p of [m.start, m.end]) {
431846
+ if (p[0] < minX) minX = p[0];
431847
+ if (p[0] > maxX) maxX = p[0];
431848
+ if (p[1] < minY) minY = p[1];
431849
+ if (p[1] > maxY) maxY = p[1];
431850
+ }
431851
+ }
431852
+ if (!isFinite(minX)) return null;
431853
+ return { minX, maxX, minY, maxY };
431854
+ }
431855
+ function v2FrameDimPanel(x0, y0, width, height, spec) {
431856
+ const titleH = 40;
431857
+ const padL = 104, padR = 40, padT = titleH + 30, padB = 96;
431858
+ const drawW = width - padL - padR;
431859
+ const drawH = height - padT - padB;
431860
+ const ring = spec.members.filter((m) => m.role === "ring_beam");
431861
+ const ridges = spec.members.filter((m) => m.role === "ridge");
431862
+ const hips = spec.members.filter((m) => m.role === "hip");
431863
+ const ties = spec.members.filter((m) => m.role === "tie_beam");
431864
+ const eaves = spec.members.filter((m) => m.role === "pani_patti");
431865
+ const trusses = spec.trusses;
431866
+ const datum = membersBounds(ring.length ? ring : spec.members);
431867
+ if (!datum) return panelFrame(x0, y0, width, height, titleH, "Main frame \u2014 dimensions", "v2-frame-dim") + "</g>\n";
431868
+ const { minX, maxX, minY, maxY } = datum;
431869
+ const spanX = maxX - minX || 1;
431870
+ const spanY = maxY - minY || 1;
431871
+ const half = (spec.framing?.wall_thickness_u ?? 0) / 2;
431872
+ const wallSpanX = spanX + 2 * half, wallSpanY = spanY + 2 * half;
431873
+ const fit = membersBounds([...ring, ...hips, ...ridges, ...ties, ...eaves]) ?? datum;
431874
+ const fSpanX = fit.maxX - fit.minX || 1;
431875
+ const fSpanY = fit.maxY - fit.minY || 1;
431876
+ const scale = Math.min(drawW / fSpanX, drawH / fSpanY);
431877
+ const offX = x0 + padL + (drawW - fSpanX * scale) / 2 - fit.minX * scale;
431878
+ const offY = y0 + padT + (drawH - fSpanY * scale) / 2 - fit.minY * scale;
431879
+ const toSvg = (p) => [
431880
+ offX + p[0] * scale,
431881
+ offY + p[1] * scale
431882
+ ];
431883
+ const title = "MAIN FRAME \u2014 Fabrication dimensions (wall outer face = floor plan \xB7 ring on wall centre)";
431884
+ let svg = panelFrame(x0, y0, width, height, titleH, title, "v2-frame-dim");
431885
+ const line2 = (a, b, stroke, w, dash) => `<line x1="${a[0].toFixed(1)}" y1="${a[1].toFixed(1)}" x2="${b[0].toFixed(1)}" y2="${b[1].toFixed(1)}" stroke="${stroke}" stroke-width="${w}"${dash ? ` stroke-dasharray="${dash}"` : ""} stroke-linecap="round"/>
431886
+ `;
431887
+ const c0 = toSvg([minX, minY]);
431888
+ const c1 = toSvg([maxX, maxY]);
431889
+ const f0 = toSvg([fit.minX, fit.minY]);
431890
+ const f1 = toSvg([fit.maxX, fit.maxY]);
431891
+ const ringBottom = Math.max(c0[1], c1[1]), ringLeft = Math.min(c0[0], c1[0]);
431892
+ const fullBottom = Math.max(f0[1], f1[1]), fullLeft = Math.min(f0[0], f1[0]);
431893
+ const rectX = Math.min(c0[0], c1[0]), rectY = Math.min(c0[1], c1[1]);
431894
+ const rectW = Math.abs(c1[0] - c0[0]), rectH = Math.abs(c1[1] - c0[1]);
431895
+ svg += `<rect x="${rectX.toFixed(1)}" y="${rectY.toFixed(1)}" width="${rectW.toFixed(1)}" height="${rectH.toFixed(1)}" fill="#ecfdf5" stroke="none"/>
431896
+ `;
431897
+ for (const m of ring) svg += line2(toSvg(m.start), toSvg(m.end), "#16a34a", 3);
431898
+ for (const m of eaves) svg += line2(toSvg(m.start), toSvg(m.end), "#94a3b8", 1.2);
431899
+ for (const m of hips) svg += line2(toSvg(m.start), toSvg(m.end), "#ea580c", 1.4, "5 3");
431900
+ for (const m of ridges) svg += line2(toSvg(m.start), toSvg(m.end), "#dc2626", 2, "6 3");
431901
+ for (const m of ties) svg += line2(toSvg(m.start), toSvg(m.end), "#0ea5e9", 1.2, "3 3");
431902
+ const centroid = (i2) => {
431903
+ const t = trusses[i2];
431904
+ return (t.bottom_left[0] + t.bottom_right[0] + t.apex[0]) / 3;
431905
+ };
431906
+ const centroidY = (i2) => {
431907
+ const t = trusses[i2];
431908
+ return (t.bottom_left[1] + t.bottom_right[1] + t.apex[1]) / 3;
431909
+ };
431910
+ let spreadX = 0, spreadY = 0;
431911
+ if (trusses.length > 1) {
431912
+ const xs = trusses.map((_, i2) => centroid(i2));
431913
+ const ys = trusses.map((_, i2) => centroidY(i2));
431914
+ spreadX = Math.max(...xs) - Math.min(...xs);
431915
+ spreadY = Math.max(...ys) - Math.min(...ys);
431916
+ }
431917
+ const distAxis = spreadX >= spreadY ? 0 : 1;
431918
+ const stations = trusses.map((t, i2) => ({ i: i2, pos: distAxis === 0 ? centroid(i2) : centroidY(i2), t })).sort((a, b) => a.pos - b.pos);
431919
+ stations.forEach((s, k) => {
431920
+ const a = toSvg(s.t.bottom_left);
431921
+ const b = toSvg(s.t.bottom_right);
431922
+ svg += line2(a, b, "#7c2d12", 2);
431923
+ const lx = (a[0] + b[0]) / 2, ly = (a[1] + b[1]) / 2;
431924
+ svg += `<circle cx="${lx.toFixed(1)}" cy="${ly.toFixed(1)}" r="8" fill="#fff7ed" stroke="#7c2d12" stroke-width="0.8"/>
431925
+ `;
431926
+ svg += `<text x="${lx.toFixed(1)}" y="${(ly + 3).toFixed(1)}" text-anchor="middle" font-size="9" font-weight="700" fill="#7c2d12">T${k + 1}</text>
431927
+ `;
431928
+ });
431929
+ const chainY = fullBottom + 20;
431930
+ const edgeLo = distAxis === 0 ? minX : minY;
431931
+ const edgeHi = distAxis === 0 ? maxX : maxY;
431932
+ const svgOf = (pos) => distAxis === 0 ? toSvg([pos, minY])[0] : toSvg([minX, pos])[1];
431933
+ if (distAxis === 0) {
431934
+ const marks = [edgeLo, ...stations.map((s) => s.pos), edgeHi];
431935
+ for (let j = 0; j + 1 < marks.length; j++) {
431936
+ const gap = Math.abs(marks[j + 1] - marks[j]);
431937
+ if (gap < 0.5) continue;
431938
+ svg += dimLineH(svgOf(marks[j]), svgOf(marks[j + 1]), chainY, formatDimension(gap));
431939
+ }
431940
+ for (const mk of marks) {
431941
+ const sx = svgOf(mk);
431942
+ svg += line2([sx, ringBottom], [sx, chainY], "#94a3b8", 0.5, "2 2");
431943
+ }
431944
+ svg += dimLineH(toSvg([minX - half, minY])[0], toSvg([maxX + half, minY])[0], chainY + 22, `WALL WIDTH ${formatDimension(wallSpanX)}`);
431945
+ svg += dimLineV(toSvg([minX, minY - half])[1], toSvg([minX, maxY + half])[1], fullLeft - 24, `WALL LENGTH ${formatDimension(wallSpanY)}`);
431946
+ } else {
431947
+ const chainX = fullLeft - 20;
431948
+ const marks = [edgeLo, ...stations.map((s) => s.pos), edgeHi];
431949
+ for (let j = 0; j + 1 < marks.length; j++) {
431950
+ const gap = Math.abs(marks[j + 1] - marks[j]);
431951
+ if (gap < 0.5) continue;
431952
+ svg += dimLineV(svgOf(marks[j]), svgOf(marks[j + 1]), chainX, formatDimension(gap));
431953
+ }
431954
+ for (const mk of marks) {
431955
+ const sy = svgOf(mk);
431956
+ svg += line2([ringLeft, sy], [chainX, sy], "#94a3b8", 0.5, "2 2");
431957
+ }
431958
+ svg += dimLineV(toSvg([minX, minY - half])[1], toSvg([minX, maxY + half])[1], chainX - 22, `WALL LENGTH ${formatDimension(wallSpanY)}`);
431959
+ svg += dimLineH(toSvg([minX - half, minY])[0], toSvg([maxX + half, minY])[0], fullBottom + 20, `WALL WIDTH ${formatDimension(wallSpanX)}`);
431960
+ }
431961
+ if (eaves.length) {
431962
+ const half2 = (spec.framing?.wall_thickness_u ?? 0) / 2;
431963
+ const wfMinX = minX - half2, wfMaxX = maxX + half2, wfMinY = minY - half2, wfMaxY = maxY + half2;
431964
+ const w0 = toSvg([wfMinX, wfMinY]), w1 = toSvg([wfMaxX, wfMaxY]);
431965
+ const wfL = Math.min(w0[0], w1[0]), wfR = Math.max(w0[0], w1[0]);
431966
+ const wfT = Math.min(w0[1], w1[1]), wfB = Math.max(w0[1], w1[1]);
431967
+ const midXsvg = (wfL + wfR) / 2, midYsvg = (wfT + wfB) / 2;
431968
+ if (half2 > 0.01) svg += `<rect x="${wfL.toFixed(1)}" y="${wfT.toFixed(1)}" width="${(wfR - wfL).toFixed(1)}" height="${(wfB - wfT).toFixed(1)}" fill="none" stroke="#8a6a3f" stroke-width="0.9"/>
431969
+ `;
431970
+ const eW = fit.maxX - fit.minX, eL = fit.maxY - fit.minY;
431971
+ svg += dimLineH(f0[0], f1[0], fullBottom + 42, `EAVE WIDTH ${formatDimension(eW)}`);
431972
+ svg += dimLineV(f0[1], f1[1], fullLeft - 66, `EAVE LENGTH ${formatDimension(eL)}`);
431973
+ const ohL = wfMinX - fit.minX, ohR = fit.maxX - wfMaxX, ohT = wfMinY - fit.minY, ohB = fit.maxY - wfMaxY;
431974
+ if (ohL > 0.5) svg += dimLineH(f0[0], wfL, midYsvg, formatDimension(ohL));
431975
+ if (ohR > 0.5) svg += dimLineH(wfR, f1[0], midYsvg, formatDimension(ohR));
431976
+ if (ohT > 0.5) svg += dimLineV(f0[1], wfT, midXsvg, formatDimension(ohT));
431977
+ if (ohB > 0.5) svg += dimLineV(wfB, f1[1], midXsvg, formatDimension(ohB));
431978
+ }
431979
+ if (stations.length) {
431980
+ const rep = stations[Math.floor(stations.length / 2)].t;
431981
+ const span = Math.hypot(rep.bottom_right[0] - rep.bottom_left[0], rep.bottom_right[1] - rep.bottom_left[1]);
431982
+ const a = toSvg(rep.bottom_left);
431983
+ const b = toSvg(rep.bottom_right);
431984
+ const midY = Math.min(a[1], b[1]) - 12;
431985
+ svg += dimLineH(a[0], b[0], midY, `truss span ${formatDimension(span)}`);
431986
+ }
431987
+ const inLabel = (s) => s ? `${Math.round(s[0] * 10) / 10}"\xD7${Math.round(s[1] * 10) / 10}"` : null;
431988
+ const ringSec = inLabel(spec.framing?.ring_beam_size_in) ?? sectionFtLabel(ring[0]?.section_size);
431989
+ const ridgeSec = inLabel(spec.framing?.ridge_size_in) ?? sectionFtLabel(ridges[0]?.section_size);
431990
+ const footY = y0 + height - 12;
431991
+ const parts = [];
431992
+ parts.push(`ring beam ${formatDimension(spanX)} \xD7 ${formatDimension(spanY)} (wall centre)`);
431993
+ if (ringSec) parts.push(`ring ${ringSec}`);
431994
+ if (ridgeSec) parts.push(`ridge ${ridgeSec}`);
431995
+ parts.push(`${stations.length} truss station${stations.length === 1 ? "" : "s"}`);
431996
+ svg += `<text x="${(x0 + 12).toFixed(1)}" y="${footY.toFixed(1)}" text-anchor="start" font-size="10" fill="#475569">${escapeXml2(parts.join(" \xB7 "))}</text>
431997
+ `;
431998
+ svg += `<text x="${(x0 + width - 12).toFixed(1)}" y="${footY.toFixed(1)}" text-anchor="end" font-size="9" fill="#475569"><tspan fill="#16a34a">green = ring beam (wall centre)</tspan> \xB7 <tspan fill="#8a6a3f">brown = wall face</tspan> \xB7 grey = roof edge (eave)</text>
431999
+ `;
432000
+ svg += `</g>
432001
+ `;
432002
+ return svg;
432003
+ }
432004
+
432005
+ // ../editor/src/svg2d/roof/v2/eavePanel.ts
432006
+ init_format();
432007
+ var IN_TO_U2 = 10 / 12;
432008
+ var sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
432009
+ var cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
432010
+ var norm = (a) => Math.hypot(a[0], a[1], a[2]);
432011
+ var secInLabel = (s) => `${Math.round(s[0] * 10) / 10}"\xD7${Math.round(s[1] * 10) / 10}"`;
432012
+ function planePitch(vs) {
432013
+ if (vs.length < 3) return 0;
432014
+ const n3 = cross(sub(vs[1], vs[0]), sub(vs[2], vs[0]));
432015
+ const nl = norm(n3) || 1;
432016
+ const nz = Math.abs(n3[2] / nl);
432017
+ return Math.acos(Math.min(1, nz)) * 180 / Math.PI;
432018
+ }
432019
+ function lowEdgeMid(vs) {
432020
+ const minZ = Math.min(...vs.map((v) => v[2]));
432021
+ const low = vs.filter((v) => Math.abs(v[2] - minZ) < 1);
432022
+ if (low.length < 2) return null;
432023
+ return [(low[0][0] + low[1][0]) / 2, (low[0][1] + low[1][1]) / 2, minZ];
432024
+ }
432025
+ function groupEaves(spec) {
432026
+ const eaves = spec.members.filter((m) => m.role === "pani_patti");
432027
+ if (!eaves.length) return [];
432028
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
432029
+ for (const m of spec.members) if (m.role === "ring_beam") for (const p of [m.start, m.end]) {
432030
+ if (p[0] < minX) minX = p[0];
432031
+ if (p[0] > maxX) maxX = p[0];
432032
+ if (p[1] < minY) minY = p[1];
432033
+ if (p[1] > maxY) maxY = p[1];
432034
+ }
432035
+ if (!isFinite(minX)) return [];
432036
+ const halfWall = (spec.framing?.wall_thickness_u ?? 0) / 2;
432037
+ minX -= halfWall;
432038
+ maxX += halfWall;
432039
+ minY -= halfWall;
432040
+ maxY += halfWall;
432041
+ const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
432042
+ const facePlanes = spec.planes.filter((p) => p.role === "slope" || p.role === "hip_face");
432043
+ const faceInfo = facePlanes.map((p) => ({ role: p.role, pitch: planePitch(p.vertices), mid: lowEdgeMid(p.vertices) })).filter((f2) => f2.mid != null);
432044
+ const groups = /* @__PURE__ */ new Map();
432045
+ for (const e of eaves) {
432046
+ const emid = [(e.start[0] + e.end[0]) / 2, (e.start[1] + e.end[1]) / 2, (e.start[2] + e.end[2]) / 2];
432047
+ const vertical = Math.abs(e.start[0] - e.end[0]) < 1;
432048
+ let overhang, side2;
432049
+ if (vertical) {
432050
+ const X = e.start[0];
432051
+ const wallX = X > cx ? maxX : minX;
432052
+ overhang = Math.abs(X - wallX);
432053
+ side2 = X > cx ? "E" : "W";
432054
+ } else {
432055
+ const Y = e.start[1];
432056
+ const wallY = Y > cy ? maxY : minY;
432057
+ overhang = Math.abs(Y - wallY);
432058
+ side2 = Y > cy ? "S" : "N";
432059
+ }
432060
+ let best = null;
432061
+ let bestD = Infinity;
432062
+ for (const f2 of faceInfo) {
432063
+ const d = Math.hypot(f2.mid[0] - emid[0], f2.mid[1] - emid[1]);
432064
+ if (d < bestD) {
432065
+ bestD = d;
432066
+ best = { role: f2.role, pitch: f2.pitch };
432067
+ }
432068
+ }
432069
+ const roleLabel = best?.role === "hip_face" ? "Hip end" : best?.role === "slope" ? "Main slope" : "Eave";
432070
+ const pitchDeg = best?.pitch ?? 0;
432071
+ const sig = `${roleLabel}|${Math.round(pitchDeg)}|${Math.round(overhang)}`;
432072
+ const existing = groups.get(sig);
432073
+ if (existing) {
432074
+ existing.count += 1;
432075
+ if (!existing.sides.includes(side2)) existing.sides.push(side2);
432076
+ } else groups.set(sig, { signature: sig, roleLabel, pitchDeg, overhang, count: 1, sides: [side2] });
432077
+ }
432078
+ return [...groups.values()].sort((a, b) => a.roleLabel === b.roleLabel ? b.overhang - a.overhang : a.roleLabel < b.roleLabel ? -1 : 1);
432079
+ }
432080
+ function v2EavePanel(x0, y0, width, height, spec, group) {
432081
+ const titleH = 40;
432082
+ const sidesStr = group.sides.length ? ` (${group.sides.join(", ")})` : "";
432083
+ const title = `EAVE \u2014 ${group.roleLabel}${group.count > 1 ? ` \xD7 ${group.count}` : ""}${sidesStr}`;
432084
+ let svg = panelFrame(x0, y0, width, height, titleH, title, "v2-eave");
432085
+ group.sides.forEach((side2, i2) => {
432086
+ svg += keyPlanDisc(x0 + width - 24 - i2 * 30, y0 + 20, 12, side2, "#0e7490", "#ffffff", "#ffffff");
432087
+ });
432088
+ const ringIn = spec.framing?.ring_beam_size_in ?? [4, 2];
432089
+ const rafterIn = spec.framing?.rafter_size_in ?? [2, 4];
432090
+ const purlinIn = spec.framing?.purlin_size_in ?? [2, 1];
432091
+ const ringW = ringIn[0] * IN_TO_U2;
432092
+ const ringD = ringIn[1] * IN_TO_U2;
432093
+ const rafterD = rafterIn[1] * IN_TO_U2;
432094
+ const purlinD = purlinIn[1] * IN_TO_U2;
432095
+ const wallT = spec.framing?.wall_thickness_u ?? 7.5;
432096
+ const ringH0 = Math.max(0, (wallT - ringW) / 2);
432097
+ const pitchDeg = group.pitchDeg || 25;
432098
+ const th = pitchDeg * Math.PI / 180;
432099
+ const cos = Math.cos(th), sin = Math.sin(th), tan = Math.tan(th);
432100
+ const overhang = group.overhang || 2 * 10;
432101
+ const wallH = Math.max(ringD * 4, 20);
432102
+ const interiorRun = wallT + Math.max(wallT * 1.2, 12);
432103
+ const battenGap = Math.max(rafterD * 0.35, 2);
432104
+ const bottomAt = (h) => [h, ringD + (h - ringH0) * tan];
432105
+ const perpUp = [-sin, cos];
432106
+ const top = (p) => [p[0] + perpUp[0] * rafterD, p[1] + perpUp[1] * rafterD];
432107
+ const tailB = bottomAt(-overhang);
432108
+ const inB = bottomAt(interiorRun);
432109
+ const tailT = top(tailB), inT = top(inB);
432110
+ const tileOff = [perpUp[0] * battenGap, perpUp[1] * battenGap];
432111
+ const tileTail = [tailT[0] + tileOff[0] - cos * 2, tailT[1] + tileOff[1] - sin * 2];
432112
+ const tileIn = [inT[0] + tileOff[0], inT[1] + tileOff[1]];
432113
+ const pAlong = wallT + Math.max(wallT * 0.5, 8);
432114
+ const pOnTop = top(bottomAt(pAlong));
432115
+ const pts = [
432116
+ [0, 0],
432117
+ [wallT, 0],
432118
+ [0, -wallH],
432119
+ [wallT, -wallH],
432120
+ [ringH0, 0],
432121
+ [ringH0 + ringW, 0],
432122
+ [ringH0, ringD],
432123
+ [ringH0 + ringW, ringD],
432124
+ tailB,
432125
+ inB,
432126
+ tailT,
432127
+ inT,
432128
+ tileTail,
432129
+ tileIn,
432130
+ pOnTop
432131
+ ];
432132
+ let minH = Infinity, maxH = -Infinity, minZ = Infinity, maxZ = -Infinity;
432133
+ for (const [h, z2] of pts) {
432134
+ if (h < minH) minH = h;
432135
+ if (h > maxH) maxH = h;
432136
+ if (z2 < minZ) minZ = z2;
432137
+ if (z2 > maxZ) maxZ = z2;
432138
+ }
432139
+ const padL = 60, padR = 74, padT = titleH + 26, padB = 54;
432140
+ const drawW = width - padL - padR, drawH = height - padT - padB;
432141
+ const spanH = maxH - minH || 1, spanZ = maxZ - minZ || 1;
432142
+ const scale = Math.min(drawW / spanH, drawH / spanZ);
432143
+ const offX = x0 + padL + (drawW - spanH * scale) / 2 - minH * scale;
432144
+ const offY = y0 + padT + (drawH - spanZ * scale) / 2 + maxZ * scale;
432145
+ const S = (p) => [offX + p[0] * scale, offY - p[1] * scale];
432146
+ const poly = (ps, fill, stroke, w) => `<polygon points="${ps.map((p) => {
432147
+ const q = S(p);
432148
+ return `${q[0].toFixed(1)},${q[1].toFixed(1)}`;
432149
+ }).join(" ")}" fill="${fill}" stroke="${stroke}" stroke-width="${w}"/>
432150
+ `;
432151
+ const seg = (a, b, stroke, w, dash) => {
432152
+ const p = S(a), q = S(b);
432153
+ return `<line x1="${p[0].toFixed(1)}" y1="${p[1].toFixed(1)}" x2="${q[0].toFixed(1)}" y2="${q[1].toFixed(1)}" stroke="${stroke}" stroke-width="${w}"${dash ? ` stroke-dasharray="${dash}"` : ""} stroke-linecap="round"/>
432154
+ `;
432155
+ };
432156
+ const label = (p, text, anchor = "start", fill = "#334155") => {
432157
+ const q = S(p);
432158
+ return `<text x="${q[0].toFixed(1)}" y="${q[1].toFixed(1)}" text-anchor="${anchor}" font-size="10" fill="${fill}">${escapeXml2(text)}</text>
432159
+ `;
432160
+ };
432161
+ svg += poly([[0, 0], [wallT, 0], [wallT, -wallH], [0, -wallH]], "#efe7db", "#8a6a3f", 1);
432162
+ for (let hh = 1; hh < 6; hh++) {
432163
+ const zc = -wallH * (hh / 6);
432164
+ svg += seg([0, zc], [wallT, zc], "#d9c9ad", 0.5);
432165
+ }
432166
+ svg += poly([[ringH0, 0], [ringH0 + ringW, 0], [ringH0 + ringW, ringD], [ringH0, ringD]], "#dcfce7", "#16a34a", 1.6);
432167
+ svg += seg([Math.min(minH, -overhang), 0], [maxH, 0], "#94a3b8", 0.6, "5 3");
432168
+ svg += poly([tailB, inB, inT, tailT], "#e2e8f0", "#475569", 1.4);
432169
+ svg += seg(tailB, [tailB[0], tailB[1] - rafterD * 1.1], "#475569", 1.4);
432170
+ const pHalf = purlinD / 2;
432171
+ svg += poly([
432172
+ [pOnTop[0] - pHalf * cos, pOnTop[1] - pHalf * sin],
432173
+ [pOnTop[0] + pHalf * cos, pOnTop[1] + pHalf * sin],
432174
+ [pOnTop[0] + pHalf * cos - perpUp[0] * purlinD, pOnTop[1] + pHalf * sin - perpUp[1] * purlinD],
432175
+ [pOnTop[0] - pHalf * cos - perpUp[0] * purlinD, pOnTop[1] - pHalf * sin - perpUp[1] * purlinD]
432176
+ ], "#64748b", "#334155", 0.8);
432177
+ svg += seg(tileTail, tileIn, "#b45309", 2);
432178
+ svg += label([wallT / 2, -wallH * 0.66], "WALL", "middle", "#8a6a3f");
432179
+ svg += label([ringH0 + ringW + 1.5, ringD / 2], `RING BEAM ${secInLabel(ringIn)}`, "start", "#16a34a");
432180
+ svg += label(inT, "RAFTER", "start", "#475569");
432181
+ svg += label([pOnTop[0], pOnTop[1] + 1], "PURLIN", "middle", "#334155");
432182
+ svg += label(tileIn, "TILE", "start", "#b45309");
432183
+ const dimY = S([0, minZ])[1] + 22;
432184
+ svg += dimLineH(S(tailB)[0], S([0, 0])[0], dimY, `overhang ${formatDimension(overhang)}`);
432185
+ svg += seg(tailB, [tailB[0], minZ - overhang * 0.02], "#94a3b8", 0.4, "2 2");
432186
+ svg += dimLineH(S([0, -wallH])[0], S([wallT, -wallH])[0], S([0, -wallH])[1] + 16, `wall ${formatDimension(wallT)}`);
432187
+ const o = S([ringH0, ringD]);
432188
+ const r2 = 26;
432189
+ svg += `<path d="M ${(o[0] + r2).toFixed(1)} ${o[1].toFixed(1)} A ${r2} ${r2} 0 0 0 ${(o[0] + r2 * cos).toFixed(1)} ${(o[1] - r2 * sin).toFixed(1)}" fill="none" stroke="#8B4513" stroke-width="1"/>
432190
+ `;
432191
+ svg += `<text x="${(o[0] + r2 + 4).toFixed(1)}" y="${(o[1] - 6).toFixed(1)}" text-anchor="start" font-size="11" font-weight="600" fill="#8B4513">${pitchDeg.toFixed(1)}\xB0</text>
432192
+ `;
432193
+ const footY = y0 + height - 12;
432194
+ svg += `<text x="${(x0 + 12).toFixed(1)}" y="${footY.toFixed(1)}" text-anchor="start" font-size="9" fill="#475569">rafter ${secInLabel(rafterIn)} \xB7 purlin ${secInLabel(purlinIn)} \xB7 sizes from the roof framing</text>
432195
+ `;
432196
+ svg += `</g>
432197
+ `;
432198
+ return svg;
432199
+ }
432200
+
430881
432201
  // ../editor/src/svg2d/roof/v2/compose.ts
430882
432202
  var TILE_W = 900;
430883
432203
  var TILE_H = 600;
@@ -430894,10 +432214,32 @@ function computeV2RoofSections(cfg) {
430894
432214
  const cutY = (bounds.minY + bounds.maxY) / 2;
430895
432215
  const zMax = roofMaxZ(spec);
430896
432216
  const wallTopZ = inferWallTopZ(spec);
432217
+ const eaveGroups = groupEaves(spec);
432218
+ const faceGroups = groupFaces(spec);
432219
+ const markers = {
432220
+ sections: [
432221
+ { label: "A", axis: "x", coord: cutX },
432222
+ { label: "B", axis: "y", coord: cutY }
432223
+ ],
432224
+ // one eave callout per distinct eave, on each compass side it occurs
432225
+ eaves: eaveGroups.map((g) => ({ sides: g.sides, label: g.roleLabel })),
432226
+ // roof faces labelled F1.. (matches the "Face N" fabrication panels below)
432227
+ faces: faceGroups.map((group, idx) => ({
432228
+ label: `F${idx + 1}`,
432229
+ planeIds: group.planes.map((p) => p.id)
432230
+ }))
432231
+ };
430897
432232
  const defs = [
432233
+ // Isometric first — the at-a-glance frame — then the key plan that indexes
432234
+ // every other view.
432235
+ {
432236
+ id: "perspective",
432237
+ title: "Frame (isometric)",
432238
+ render: (x0, y0, w, h) => v2PerspectivePanel(x0, y0, w, h, spec, { title: "Frame (isometric)", dimensions: true })
432239
+ },
430898
432240
  {
430899
432241
  id: "top_view",
430900
- title: "Top View",
432242
+ title: "Top view \u2014 key plan",
430901
432243
  // renderTopViewPanel returns a full <svg> — nest it inside our
430902
432244
  // master via a positioned <svg> wrapper. Inner viewBox handles
430903
432245
  // its own scaling; outer x/y positions it in the master grid.
@@ -430906,17 +432248,13 @@ function computeV2RoofSections(cfg) {
430906
432248
  width: w,
430907
432249
  height: h,
430908
432250
  padding: 20,
430909
- title: "Top View"
432251
+ title: "Top view \u2014 key plan",
432252
+ markers
430910
432253
  });
430911
432254
  const stripped = inner.replace(/^\s*<svg[^>]*>/, "").replace(/<\/svg>\s*$/, "");
430912
432255
  return `<svg x="${x0}" y="${y0}" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${stripped}</svg>`;
430913
432256
  }
430914
432257
  },
430915
- {
430916
- id: "perspective",
430917
- title: "Frame (isometric)",
430918
- render: (x0, y0, w, h) => v2PerspectivePanel(x0, y0, w, h, spec, { title: "Frame (isometric)" })
430919
- },
430920
432258
  {
430921
432259
  id: "section_a_a",
430922
432260
  title: `Section A-A (cut at X=${cutX.toFixed(0)})`,
@@ -430924,7 +432262,10 @@ function computeV2RoofSections(cfg) {
430924
432262
  title: `Section A-A (cut at X=${cutX.toFixed(0)})`,
430925
432263
  cutAxis: "x",
430926
432264
  cutCoord: cutX,
430927
- wallTopZ
432265
+ wallTopZ,
432266
+ overlayTruss: true,
432267
+ dimensions: true,
432268
+ keyLabel: "A"
430928
432269
  })
430929
432270
  },
430930
432271
  {
@@ -430934,19 +432275,41 @@ function computeV2RoofSections(cfg) {
430934
432275
  title: `Section B-B (cut at Y=${cutY.toFixed(0)})`,
430935
432276
  cutAxis: "y",
430936
432277
  cutCoord: cutY,
430937
- wallTopZ
432278
+ wallTopZ,
432279
+ overlayTruss: true,
432280
+ dimensions: true,
432281
+ keyLabel: "B"
430938
432282
  })
432283
+ },
432284
+ {
432285
+ id: "frame_dims",
432286
+ title: "Main frame \u2014 fabrication dimensions",
432287
+ render: (x0, y0, w, h) => v2FrameDimPanel(x0, y0, w, h, spec)
430939
432288
  }
430940
432289
  ];
430941
- const groups = groupFaces(spec);
430942
- groups.forEach((group, idx) => {
432290
+ eaveGroups.forEach((g, idx) => {
432291
+ const sides = g.sides.length ? ` (${g.sides.join("")})` : "";
432292
+ defs.push({
432293
+ id: `eave_${idx}`,
432294
+ title: `Eave \u2014 ${g.roleLabel}${g.count > 1 ? ` \xD7 ${g.count}` : ""}${sides}`,
432295
+ render: (x0, y0, w, h) => v2EavePanel(x0, y0, w, h, spec, g)
432296
+ });
432297
+ });
432298
+ faceGroups.forEach((group, idx) => {
430943
432299
  const id = `face_${idx}`;
430944
432300
  const shape = group.geom.ridgeLen > 0.5 ? "trapezoid" : "triangle";
430945
432301
  const nice = group.planes.length === 1 ? `Face ${idx + 1} (${shape}) \u2014 ${group.planes[0].id}` : `Face ${idx + 1} (${shape}, ${group.planes.length} identical)`;
430946
432302
  defs.push({
430947
432303
  id,
430948
432304
  title: nice,
430949
- render: (x0, y0, w, h) => v2FacePanel(x0, y0, w, h, group)
432305
+ render: (x0, y0, w, h) => v2FacePanel(x0, y0, w, h, group, `F${idx + 1}`)
432306
+ });
432307
+ });
432308
+ groupTrusses(spec).forEach((group, idx) => {
432309
+ defs.push({
432310
+ id: `truss_${idx}`,
432311
+ title: `${group.geom.kind === "mono_pitch" ? "Mono-pitch" : "Fink"} truss ${idx + 1} (elevation${group.count > 1 ? `, \xD7 ${group.count}` : ""})`,
432312
+ render: (x0, y0, w, h) => v2TrussPanel(x0, y0, w, h, group)
430950
432313
  });
430951
432314
  });
430952
432315
  const cols = 2;
@@ -431141,7 +432504,7 @@ var DOCS = {
431141
432504
  },
431142
432505
  "data-model": {
431143
432506
  "title": "The underlying .wadi schema (generated from Zod)",
431144
- "body": '# Wadi data model (`.wadi` / `house_config.json`)\n\n> **Generated from `editor/src/schema/houseConfig.ts` \u2014 do not edit by hand.**\n> Regenerate: `node scripts/gen-schema-doc.mjs <path/to/houseConfig.ts> reference/data-model.md`\n> Some primitives (beam, floor_slab, pillar, plinth, ground) are generated from their\n> `fields` (schema/fields/\\*) into generated/objects.generated.ts \u2014 run `npm run gen-primitives`\n> in editor/ first if you changed those, so the generated schemas (which this doc reads) are current.\n> The Zod schema is the single source of truth; this file mirrors it (structure + the\n> semantics carried in its comments) so it can\'t drift.\n\nA `.wadi` file is one JSON object matching **HouseConfig**. Geometry is in **project\nunits** (a unitless grid; by default `units.per_unit = 10` means 10 units = 1 ft).\nPlan coordinates are **Inkscape-style**: origin top-left, **X \u2192 right, Y \u2192 down**.\nSee `coordinate-system.md` for the coordinate/units detail and `parametric-conventions.md`\nfor variables/points/formulas.\n\n## Fields shared by (almost) every object\n\nThese appear on most object types; documented once here, marked *(cross-cutting)* below.\n\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `room` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | |\n| `layer` | string | | |\n| `name` | string | **yes** | |\n| `material` | string | | |\n| `z_offset` | number | | Vertical position of the room (its floor + walls), as a lift above the FLOOR BASE (slabZ = plinth top for floor 0, else the floor below\'s top; project units, 10 = 1 ft). This is the UNIFIED z_offset convention: every object is placed at `slabZ + z_offset`. When OMITTED, on-slab objects (room, wall, staircase, kitchen_platform) default z_offset to the floor\'s resolved slab thickness (floor.slab_thickness \u2192 house.defaults.slab_thickness \u2192 code default), so by default they sit on top of the slab, exactly as before. Set it explicitly for split-level floors \u2014 e.g. a room raised onto a thicker slab uses the same value the raised slab\'s top sits at. |\n\n\n- `type` \u2014 the discriminated-union tag; selects the object shape (values below).\n- `formulas` \u2014 per-field `"= expression"` overrides; the resolver evaluates each into the\n matching numeric field. See `parametric-conventions.md`.\n- `z_offset` \u2014 vertical lift above the floor base (slab top). On-slab objects (room, wall,\n staircase, kitchen_platform) default it to the floor\'s slab thickness; slab/beam/pillar/\n roof default to 0.\n\n## Top level \u2014 HouseConfig\n\n| field | type | req | notes |\n|---|---|---|---|\n| `coord_convention` | enum: `outer` `center` | | How a rectangular object\'s x/y/width/length relate to its walls (plans/grid-convention.md). "center" (new/canonical): coordinates are wall CENTRELINES \u2014 adjacent rooms ABUT on a shared line (no overlap), walls are centred on the boundary, and expandRoomWalls grows each footprint by wall_thickness/2 to the outer face. "outer" / absent (legacy): coordinates are the OUTER wall face and adjacent rooms must overlap by wall_thickness. |\n| `plinth` | any (freeform) | | Legacy top-level plinth (pre-"Plinth floor"). Tolerated but IGNORED so an un-migrated file still loads (it just renders without a plinth/ground) instead of failing .strict() validation. New configs put the plinth on the Plinth floor as a `plinth` object. |\n| `defaults` | [houseDefaults](#housedefaults) | | |\n| `units` | [units](#units) | | |\n| `layers` | array of [LayerDef](#layerdef) | | Configurable 3D visibility layers (optional; defaults applied when absent). Objects opt in via their own `layer` field. |\n| `variables` | map: string \u2192 number, or `"= formula"` string | | Parametric layer (plans/object-relationships-plan.md). Named scalar variables (number or "= formula", may reference other variables) and named 2D points; object `formulas` maps reference these. Optional \u2014 absent = a plain non-parametric house, resolved as a no-op. |\n| `points` | map: string \u2192 inline object | | |\n| `components` | map: string \u2192 [ComponentDef](#componentdef) | | Reusable-component library (in-file). Map of id \u2192 ComponentDef. A `component` object instantiates one by `ref`. Stored once; referenced by many instances; edit here to update every instance. |\n| `grids` | map: string \u2192 `gridDef` | | First-class parametric grids (plans/grid-convention.md). Map of id \u2192 GridDef (named X/Y wall centrelines). Rooms/slabs bind via `grid`+`cell`, pillars via `grid`+`node`; the resolver derives their geometry from the centrelines + wall thickness. Optional; reusable across templates. |\n| `configurator` | [configurator](#configurator) | | Configurator metadata (Gharkul owner UI). Optional; see plans/configurator-plan.md. |\n| `thumbnails` | array of string | | Preview snapshots (data: URLs) captured by the architect editor and saved WITH the template so the owner gallery can show real previews \u2014 multiple angles + the floor plan. `thumbnails[0]` is the gallery cover. Optional; excluded from share links (a preview isn\'t model data \u2014 see io/shareLink.ts). `thumbnail` (singular) is the legacy one-image form, still read as a fallback so old template files keep working. |\n| `thumbnail` | string | | |\n| `template` | inline object | | Catalog metadata that makes a `.wadi` SELF-DESCRIBING: the editorial fields a gallery card needs that can\'t be derived from geometry (title, blurb, style/roof tags, min plot). With this block + `thumbnails[]`, a folder of `.wadi` files IS the catalog \u2014 the app lists the folder and indexes each file, with no separate index.json to maintain (see io/templateSource.ts). Non-strict so newer editorial fields don\'t break an older build. |\n| `floors` | array of [floor](#floor) | **yes** | |\n| `_walls_expanded` | boolean | | |\n\n\n## floor\n\n| field | type | req | notes |\n|---|---|---|---|\n| `floor_number` | integer \u2265 0 | **yes** | |\n| `name` | string | **yes** | |\n| `height` | number > 0 | | Per-floor overrides for the default heights in GlobalConfig. In project units (10 units = 1 ft). All three are INDEPENDENT \u2014 no relationship enforced between them: height \u2014 floor-to-floor rise (drives roof wallTop-Z stack) wall_height \u2014 standing wall height (floor top \u2192 ceiling) slab_thickness \u2014 RCC deck between this floor and the one above All fall back to GlobalConfig defaults when omitted. |\n| `wall_height` | number > 0 | | |\n| `slab_thickness` | number \u2265 0 | | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | |\n| `objects` | array of [Object types](#object-types) | **yes** | |\n\n\n## Object types (`floors[].objects[]`)\n\nEvery entry in a floor\'s `objects` array is one of these, tagged by `type`:\n\n### `plinth`\n\nThe plinth is now a normal object placed on the "Plinth" floor (the first floor, number 0), not a top-level config key. Its footprint + height match the old top-level plinth; the plinth floor\'s `height` drives the rise to the floor above (replacing the old hardcoded plinth_height seed).\n\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `plinth` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n| `layer` | string | | *(shared \u2014 see top)* |\n| `name` | string | | Label |\n| `material` | string | | Material key |\n| `x` | number | **yes** | Top-left X (project units) |\n| `y` | number | **yes** | Top-left Y (project units) |\n| `width` | number > 0 | **yes** | X extent (project units) |\n| `length` | number > 0 | **yes** | Y extent (project units) |\n| `height` | number > 0 | **yes** | Plinth height (project units) |\n| `z_offset` | number | | Lift above ground (project units) |\n\n\n### `ground`\n\nThe ground plane, also on the Plinth floor. Extent defaults to the site plot when authored by the migration. `height` is an optional thickness (0 = a flat plane); slope fields are a later phase.\n\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `ground` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n| `layer` | string | | *(shared \u2014 see top)* |\n| `name` | string | | Label |\n| `material` | string | | Material key |\n| `x` | number | **yes** | Top-left X (project units) |\n| `y` | number | **yes** | Top-left Y (project units) |\n| `width` | number > 0 | **yes** | X extent (project units) |\n| `length` | number > 0 | **yes** | Y extent (project units) |\n| `height` | number \u2265 0 | | Thickness (0 = flat) (project units) |\n| `z_offset` | number | | Lift above origin (project units) |\n\n\n### `component`\n\nAn INSTANCE of a reusable component from the in-file `components` library. It references a component by id (`ref`), overrides the component\'s input variables via `params`, and places it at (x, y) with a `z_offset` lift on its parent floor. At render time `expandRoomWalls` flattens it into concrete objects (resolve component with param+origin overrides \u2192 recurse \u2192 offset), so no renderer needs to know about `component`.\n\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `component` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n| `layer` | string | | *(shared \u2014 see top)* |\n| `name` | string | | |\n| `ref` | string | **yes** | |\n| `params` | map: string \u2192 union \u2014 see notes | | Overrides for the component\'s declared input variables. A string starting with "=" is a formula evaluated in the HOST scope (so it can reference the host\'s variables/points); a number is used directly. |\n| `x` | number | **yes** | |\n| `y` | number | **yes** | |\n| `rotation` | number | | Standard placement: yaw\xB0 about the instance origin (clockwise, same sense as item rotation: 0=south, 90=east). Right angles (0/90/180/270) are exact for any component; a non-right angle is allowed only for furniture-only ones. |\n| `z_offset` | number | | |\n\n\n### `item`\n\nA free-standing GLB furniture / decor instance placed directly on a floor (for pieces that aren\'t inside an enclosed room \u2014 outdoor/site/verandah decor, a loft item, etc.). `x`/`y` are the item\'s plan CENTRE. It MAY instead anchor to a named room via `anchor_to` + `anchor` + `gap`, in which case `x`/`y` are DERIVED at expand time (same anchor model as room-nested items). `rotation` is yaw\xB0; `scale` is a uniform resize; `z_offset` lifts it above the floor base (default = slab thickness). (`itemAsset`, `itemAnchor`, `gapField` are defined above `room`.)\n\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `item` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n| `layer` | string | | *(shared \u2014 see top)* |\n| `name` | string | | |\n| `asset` | [ItemAsset](#itemasset) | **yes** | |\n| `x` | number | **yes** | |\n| `y` | number | **yes** | |\n| `rotation` | number | | |\n| `scale` | number > 0 | | |\n| `z_offset` | number | | |\n| `anchor_to` | string | | Optional room-relative anchoring (for a free item that should follow a room). |\n| `anchor` | [ItemAnchor](#itemanchor) | | |\n| `gap_x` | number | | |\n| `gap_y` | number | | |\n\n\n### `model`\n\nA GLB placed at real scale and manipulated by a `rig` of named-node ops. Distinct from `item` (furniture, catalog + anchoring): `model` is a rigged structural asset. `asset.dimensions` is the real metre size, used for the 2D footprint and the scale.\n\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `model` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n| `layer` | string | | *(shared \u2014 see top)* |\n| `name` | string | | |\n| `asset` | [ItemAsset](#itemasset) | **yes** | |\n| `x` | number | **yes** | |\n| `y` | number | **yes** | |\n| `rotation` | number | | |\n| `scale` | number > 0 | | |\n| `z_offset` | number | | |\n| `rig` | array of `rigOp` | | |\n\n\n### `floor_slab`\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `floor_slab` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n| `layer` | string | | *(shared \u2014 see top)* |\n| `name` | string | | Label |\n| `x` | number | **yes** | Top-left X (project units) |\n| `y` | number | **yes** | Top-left Y (project units) |\n| `width` | number > 0 | **yes** | X extent (project units) |\n| `length` | number > 0 | **yes** | Y extent (project units) |\n| `thickness` | number \u2265 0 | | Slab thickness (defaults to floor\'s) (project units) |\n| `z_offset` | number | | Lift above floor base (project units) |\n\n\n### `pillar`\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `pillar` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n| `layer` | string | | *(shared \u2014 see top)* |\n| `name` | string | **yes** | Label |\n| `x` | number | **yes** | Top-left corner X (project units) |\n| `y` | number | **yes** | Top-left corner Y (project units) |\n| `width` | number > 0 | | X extent (project units) |\n| `length` | number > 0 | | Y extent (project units) |\n| `height` | number > 0 | **yes** | Column height (project units) |\n| `z_offset` | number | | Lift above floor base (project units) |\n\n\n### `beam`\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `beam` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n| `layer` | string | | *(shared \u2014 see top)* |\n| `name` | string | | Label |\n| `x` | number | **yes** | Top-left X (project units) |\n| `y` | number | **yes** | Top-left Y (project units) |\n| `width` | number > 0 | **yes** | X extent (project units) |\n| `length` | number > 0 | **yes** | Y extent (project units) |\n| `height` | number > 0 | | Vertical thickness (project units) |\n| `z_offset` | number | | Lift above floor base (project units) |\n\n\n### `room`\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `room` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n| `layer` | string | | *(shared \u2014 see top)* |\n| `name` | string | **yes** | |\n| `x` | number | **yes** | |\n| `y` | number | **yes** | |\n| `width` | number > 0 | **yes** | |\n| `length` | number > 0 | **yes** | |\n| `height` | number \u2265 0 | | 0 accepted \u2014 semantically the same as absent ("use floor default"). Old configs that accidentally saved height: 0 keep loading; the form treats 0 as "no override" and doesn\'t write it back. |\n| `material` | string | | |\n| `z_offset` | number | | Vertical position of the room (its floor + walls), as a lift above the FLOOR BASE (slabZ = plinth top for floor 0, else the floor below\'s top; project units, 10 = 1 ft). This is the UNIFIED z_offset convention: every object is placed at `slabZ + z_offset`. When OMITTED, on-slab objects (room, wall, staircase, kitchen_platform) default z_offset to the floor\'s resolved slab thickness (floor.slab_thickness \u2192 house.defaults.slab_thickness \u2192 code default), so by default they sit on top of the slab, exactly as before. Set it explicitly for split-level floors \u2014 e.g. a room raised onto a thicker slab uses the same value the raised slab\'s top sits at. |\n| `walls` | union \u2014 see notes | | |\n| `wall_heights` | map: string \u2192 [wall_heights entry](#wall-heights-entry) | | |\n| `items` | array of [RoomItem](#roomitem) | | Furniture nested in this room. Each piece is anchored to the room\'s inner footprint (see roomItem), so it reflows when the room resizes. Expanded into top-level `item` objects at render time. |\n\n\n### `wall`\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `wall` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n| `layer` | string | | *(shared \u2014 see top)* |\n| `name` | string | **yes** | |\n| `start_x` | number | **yes** | |\n| `start_y` | number | **yes** | |\n| `end_x` | number | **yes** | |\n| `end_y` | number | **yes** | |\n| `height` | number > 0 | | |\n| `height_end` | number | | |\n| `material` | string | | |\n| `facing` | enum: `north` `south` `east` `west` | | |\n| `z_offset` | number | | Lift above the FLOOR BASE (slabZ), project units. Omitted \u2192 defaults to the floor\'s resolved slab thickness (sits on the slab, as before). Set it for a split-level wall. Same convention as `room`. |\n| `openings` | array of [Opening](#opening) | | |\n\n\n### `staircase`\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `staircase` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n| `layer` | string | | *(shared \u2014 see top)* |\n| `name` | string | | |\n| `climb` | enum: `up` `down` | | `climb` picks which end (start_x, start_y) is and which way the flight runs in z as it extends into `direction`: \u2022 "up" (recommended): BOTTOM-anchored. Put the stair on the LOWER floor it rises FROM; (start_x, start_y) is the bottom step\'s near corner on that floor and the flight ASCENDS into `direction`. `rise_height` defaults to THIS floor\'s height (climb to the next level). The intuitive way. \u2022 "down" (DEFAULT, kept for older configs): TOP-anchored. Put the stair on the upper DESTINATION floor; (start_x, start_y) is the top connection and the flight DESCENDS into `direction`. `rise_height` defaults to the floor immediately BELOW this one. Either way the body + landings fill the box [start, start + max_run] along `direction`, and `z_offset` is the ANCHORED end\'s height above the floor base (omitted \u2192 this floor\'s slab thickness, flush with the walking surface). |\n| `start_x` | number | **yes** | |\n| `start_y` | number | **yes** | |\n| `rise_height` | number > 0 | | Total height the stair covers, top \u2192 floor below. The step COUNT is derived: num_steps = round(rise_height / step_rise). Omitted \u2192 defaults to the height of the floor immediately below this one. Formula-capable (e.g. "= floor_height"). Replaces the old explicit `num_steps`. |\n| `step_rise` | number > 0 | **yes** | |\n| `step_tread` | number > 0 | **yes** | |\n| `step_width` | number > 0 | **yes** | |\n| `direction` | enum: `north` `south` `east` `west` | **yes** | The direction the stair EXTENDS from its top \u2014 the whole assembly fills the allocated box from (start_x,start_y) going this way for up to `max_run`. |\n| `max_run` | number > 0 | | ALLOCATED run: the length of space reserved for the stair along `direction`. The WHOLE assembly (flights + turn landings) is kept within [start, start+max_run]; when the run won\'t fit as one flight it auto-splits into switchback flights (more flights when tight), expanded in expandRoomWalls into plain staircases + floor_slab landings so every renderer is unchanged. Omit \u2192 one flight, no length limit. |\n| `landing_depth` | number > 0 | | Turn-landing depth (along the run). Omitted \u2192 equals step_width. |\n| `landing_thickness` | number \u2265 0 | | Turn-landing slab thickness. Omitted \u2192 equals step_rise. |\n| `turn` | enum: `clockwise` `anticlockwise` | | Switchback handedness, reckoned DESCENDING from the top. Omitted \u2192 "clockwise". Only affects split stairs. |\n| `flight_gap` | number > 0 | | Lateral gap between the two switchback flights (a stairwell void for a spine wall). Omitted/0 \u2192 flights are adjacent. The turn landings widen to bridge the gap. Only affects split stairs. |\n| `z_offset` | number | | Height of the stair\'s TOP above the floor base (slabZ; project units, 10 = 1 ft). Omitted \u2192 this floor\'s slab thickness, so the top is flush with the walking surface and the flights descend to the floor below. Raise it for an internal step whose top sits above the floor. |\n| `material` | string | | |\n\n\n### `spiral_staircase`\n\nA helical staircase: `steps` treads winding `turns` revolutions around a central pole, from the floor to `total_height`, within `radius`. Placed by its CENTRE (x, y). Optional fields fall back to sensible defaults at render time.\n\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `spiral_staircase` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n| `layer` | string | | *(shared \u2014 see top)* |\n| `name` | string | | Label |\n| `x` | number | **yes** | Centre X (project units) |\n| `y` | number | **yes** | Centre Y (project units) |\n| `radius` | number > 0 | **yes** | Outer radius (project units) |\n| `total_height` | number > 0 | **yes** | Total rise (floor to top step) (project units) |\n| `turns` | number > 0 | | Revolutions (default 1) |\n| `steps` | integer | | Number of treads (default ~12 per turn) |\n| `tread_thickness` | number > 0 | | Tread slab thickness (project units) |\n| `pole_radius` | number > 0 | | Central pole radius (project units) |\n| `z_offset` | number | | Lift above floor base (project units) |\n\n\n### `door`\n\nFlat door/window remain valid as a legacy schema \u2014 new configs nest them inside room.walls[side].openings or wall.openings.\n\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `door` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n| `layer` | string | | *(shared \u2014 see top)* |\n| `name` | string | **yes** | |\n| `x` | number | **yes** | |\n| `y` | number | **yes** | |\n| `width` | number > 0 | **yes** | |\n| `height` | number > 0 | **yes** | |\n| `direction` | enum: `north` `south` `east` `west` | **yes** | |\n| `room` | string | | |\n| `wall` | string | | |\n| `open` | boolean | | |\n\n\n### `window`\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `window` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n| `layer` | string | | *(shared \u2014 see top)* |\n| `name` | string | **yes** | |\n| `x` | number | **yes** | |\n| `y` | number | **yes** | |\n| `width` | number > 0 | **yes** | |\n| `height` | number > 0 | **yes** | |\n| `sill_height` | number \u2265 0 | | |\n| `direction` | enum: `north` `south` `east` `west` | **yes** | |\n| `room` | string | | |\n| `wall` | string | | |\n| `open` | boolean | | |\n\n\n### `kitchen_platform`\n\nKitchen platform \u2014 a polyline countertop / cooking slab that runs along the base of walls. Path is the wall-side edge; the platform extends `depth` units perpendicular to each segment on the given `side`. Renders as one box per path segment; corners meet at the shared point (no fancy mitering in v1).\n\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `kitchen_platform` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n| `layer` | string | | *(shared \u2014 see top)* |\n| `name` | string | | |\n| `path` | array of tuple `[n,n]` | **yes** | |\n| `side` | enum: `left` `right` | **yes** | |\n| `depth` | number > 0 | **yes** | |\n| `height` | number > 0 | **yes** | |\n| `z_offset` | number | | Lift above the FLOOR BASE (slabZ), project units. Omitted \u2192 defaults to the floor\'s resolved slab thickness (sits on the slab top, as before). Same convention as `room`. |\n| `base_z` | number | | |\n| `material` | string | | |\n\n\n### `roof`\n\nv2 roof \u2014 unified segment-based type that replaces hip/gable/flat/shed. Schema is permissive; the v2 pipeline (svg2d/roof/v2/) validates segments + slope + endpoint style at derivation time.\n\n\n> **Freeform:** extra fields are allowed (`.catchall`) and validated at derivation time. See `roof-v2-guide.md`.\n\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `roof` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n\n\n## Shared & nested schemas\n\n### site\n\nObjects don\'t bind to the grid with a special field \u2014 a grid line\'s position is published as a formula symbol (`<gridId>.x<name>` / `.y<name>`, see param/resolve.ts), so a room places itself with ordinary `formulas`, e.g. { x: "= main.x1", width: "= main.x5 - main.x1" }. With coord_convention:"center" those are wall centrelines and expandRoomWalls handles the wall extent.\n\n| field | type | req | notes |\n|---|---|---|---|\n| `reference_x` | number | **yes** | |\n| `reference_y` | number | **yes** | |\n| `plot_length` | number > 0 | **yes** | |\n| `plot_width` | number > 0 | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | |\n\n\n### houseDefaults\n\nHouse-level overrides for the built-in GlobalConfig defaults. Every floor without its own value falls back to these; if these are absent too, the code defaults in DEFAULT_GLOBAL_CONFIG apply.\n\n| field | type | req | notes |\n|---|---|---|---|\n| `floor_height` | number > 0 | | |\n| `wall_height` | number > 0 | | |\n| `slab_thickness` | number \u2265 0 | | |\n| `wall_thickness` | number > 0 | | House-wide wall thickness (project units). Per-object `wall_thickness`/`thickness` overrides still win. Falls back to the code default (DEFAULT_GLOBAL_CONFIG.wall_thickness = 8) when omitted. |\n| `formulas` | map: field name \u2192 `"= formula"` string | | |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | |\n\n\n### units\n\nHow dimensions are LABELLED on the drawings. Display-only \u2014 geometry always stays in project units; this just controls the text on the dimension lines. Omitted = the built-in default (feet & inches, 10 project units = 1 ft).\n\n| field | type | req | notes |\n|---|---|---|---|\n| `system` | enum: `feet_inches` `feet` `meters` `centimeters` `millimeters` | | feet_inches \u2192 12\' 6" ; the rest \u2192 decimal with a unit suffix. |\n| `per_unit` | number > 0 | | Project units that equal ONE display unit (10 \u2192 10 units = 1 ft; 100 \u2192 100 units = 1 m). Default 10. |\n| `precision` | integer \u2265 0 | | Decimal places for the non-feet_inches systems. |\n\n\n### Opening\n\nThe plinth is now a normal object placed on the "Plinth" floor (the first floor, number 0), not a top-level config key. Its footprint + height match the old top-level plinth; the plinth floor\'s `height` drives the rise to the floor above (replacing the old hardcoded plinth_height seed). `plinth` + `ground` are GENERATED from fields (schema/fields/{plinth,ground}.ts), imported above as plinthObject / groundObject. (P2b)\n\n| field | type | req | notes |\n|---|---|---|---|\n| `kind` | enum: `door` `window` | **yes** | |\n| `name` | string | | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | Numeric fields hold the RESOLVED value; a `= formula` for any of them lives in `formulas` (e.g. formulas.offset), evaluated by resolveParametric against the house variables/points \u2014 same pattern as every other object. |\n| `offset` | number | **yes** | Any number: `center` anchor takes a signed shift; the resolved placement is fit-checked against the wall in expand.ts, so a bad value errors there. |\n| `anchor` | enum: `start` `center` `end` | | Which end of the wall `offset` is measured from, so the opening holds its place when the wall/room scales \u2014 no formula needed. `start` (default, legacy): offset from the wall start to the near edge. `end`: offset from the wall end to the far edge (0 = flush to the end). `center`: signed shift of the opening centre from the wall midpoint (0 = centred; may be negative). Resolved to a start-based offset in expand.ts (openingAnchor.ts). |\n| `width` | number > 0 | **yes** | |\n| `height` | number > 0 | **yes** | |\n| `sill_height` | number | | |\n| `direction` | enum: `north` `south` `east` `west` | | |\n| `facing` | enum: `north` `south` `east` `west` | | |\n| `open` | boolean | | When true, the opening is left BARE (just a hole) \u2014 no glazing/frame for a window, no leaf for a door \u2014 e.g. an open doorway or unglazed vent. |\n\n\n### RoomWallSide\n| field | type | req | notes |\n|---|---|---|---|\n| `height` | number | | |\n| `height_end` | number | | |\n| `openings` | array of [Opening](#opening) | | |\n\n\n### RoomItem\n\nA furniture piece nested INSIDE a room (room.items[]). It has NO x/y \u2014 its plan position is DERIVED at expand time from the parent room\'s footprint + `anchor` + per-axis gap (+ its own `rotation`). Flattened into a top-level `item` for every renderer. `gap_x`/`gap_y` are the inset (project units) kept from the anchor into the room (edge/corner anchor \u2192 clears the wall; centre anchor \u2192 signed offset, +x east / +y south). `gap_x`/`gap_y`/`rotation`/`scale`/`z_offset` are all plain numeric fields so each can be driven by a `= formula` (via the `formulas` map).\n\n| field | type | req | notes |\n|---|---|---|---|\n| `name` | string | | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | |\n| `layer` | string | | |\n| `asset` | [ItemAsset](#itemasset) | **yes** | |\n| `anchor` | [ItemAnchor](#itemanchor) | | |\n| `gap_x` | number | | |\n| `gap_y` | number | | |\n| `rotation` | number | | |\n| `scale` | number > 0 | | |\n| `z_offset` | number | | |\n\n\n### ItemAsset\n\nFurniture (GLB `item`) \u2014 shared schema pieces Defined BEFORE `room` so a room can nest its own `items[]`. Asset distances are METRES (the GLB\'s native unit); the 3D/2D layers scale them into project units. See registry/nodes/item + three/units. The asset backing a furniture item \u2014 stored INLINE so a .wadi is self-contained (share links / web load the GLB from `src`). A catalog is just a picker convenience.\n\n| field | type | req | notes |\n|---|---|---|---|\n| `id` | string | **yes** | |\n| `name` | string | | |\n| `src` | string | **yes** | |\n| `dimensions` | tuple `[n>0,n>0,n>0]` | **yes** | |\n| `thumbnail` | string | | |\n| `floorPlanUrl` | string | | |\n| `category` | string | | |\n| `tags` | array of string | | |\n| `offset` | tuple `[n,n,n]` | | |\n| `corrRotation` | tuple `[n,n,n]` | | |\n| `corrScale` | tuple `[n>0,n>0,n>0]` | | |\n\n\n### ComponentDef\n| field | type | req | notes |\n|---|---|---|---|\n| `name` | string | | |\n| `goal` | string | | A short natural-language description of what this component accomplishes (the discovery key for goal-based module lookup, e.g. "climb to the next floor"). Purely metadata \u2014 renderers ignore it. |\n| `params` | array of [ComponentParam](#componentparam) | | |\n| `variables` | map: string \u2192 number, or `"= formula"` string | | |\n| `points` | map: string \u2192 inline object | | |\n| `objects` | array of [Object types](#object-types) | **yes** | |\n| `expose` | inline object | | Promote this component to a typed primitive at load time (plans/declarative-plugins.md P0). When present, the component registers a NodeDefinition of type `expose.type` whose fields come from `params`; it can then be used like any core object type. `type` is namespaced (`pack.thing`). |\n\n\n### ComponentParam\n\nA reusable component DEFINITION in the in-file `components` library. It is a mini-house: its own `variables`/`points` and a flat `objects` body authored in LOCAL coords (origin 0,0). `params` names which variables are the public inputs (label/default for the instance form). A `component` instance overrides those variables and places the body at its (x,y,z_offset). Stored once; referenced by many instances.\n\n| field | type | req | notes |\n|---|---|---|---|\n| `name` | string | **yes** | |\n| `label` | string | | |\n| `description` | string | | |\n| `default` | number | | |\n| `kind` | string | | Field-projection annotations, used only when the component is `expose`d as a typed primitive (plans/declarative-plugins.md). `kind` is a FieldKind preset (coord/extent/nonneg/int/text/flag/enum); when absent the kind is inferred from the default\'s type. `unit` is a doc-only unit hint. |\n| `unit` | string | | |\n\n\n### LayerDef\n\nA visibility layer for the 3D view. Each object may reference a layer by `id` (via its `layer` field); the layers menu toggles whole layers on/off. Display-only \u2014 never affects geometry. Optional: when absent, a built-in default layer set is used, and objects fall back to an automatic per-type/floor mapping.\n\n| field | type | req | notes |\n|---|---|---|---|\n| `id` | string (non-empty) | **yes** | |\n| `label` | string | **yes** | |\n| `color` | string | | |\n| `group` | string | | Friendly group for the owner "Show/hide layers" menu (e.g. "Roof", "Walls"). Layers sharing a group toggle together. Optional. |\n\n\n### configurator\n| field | type | req | notes |\n|---|---|---|---|\n| `title` | string | | |\n| `description` | string | | |\n| `groups` | array of inline object | | |\n| `inputs` | array of [ConfiguratorInput](#configuratorinput) | **yes** | |\n\n\n### ConfiguratorInput\n\nConfigurator (Gharkul owner UI) Optional, author-supplied metadata: which `variables`/`points` a template exposes to end users, and how to present them. IGNORED by the resolver and every geometry consumer \u2014 read only by the owner-facing Configurator UI. `target` is a variable name (e.g. "floorH") or a point coordinate ("House.W" \u2192 points.House.x; W/L/X/Y/x/y are resolver synonyms). `min`/`max`/ `step` are in RAW project units; `unit` only affects display.\n\n| field | type | req | notes |\n|---|---|---|---|\n| `target` | string (non-empty) | **yes** | |\n| `label` | string (non-empty) | **yes** | |\n| `description` | string | | |\n| `control` | enum: `slider` `number` `select` `toggle` | | |\n| `unit` | enum: `ft` `in` `m` `units` `percent` `count` `none` | | |\n| `min` | number | | |\n| `max` | number | | |\n| `step` | number > 0 | | |\n| `options` | array of inline object | | |\n| `group` | string | | |\n\n\n### ItemAnchor\n\n9-point anchor on a room\'s INNER footprint. First token = vertical (top = north \u2026 bottom = south), second = horizontal (left = west \u2026 right = east); "center" alone = both. The item aligns its matching edge/corner to this spot, held `gap` off it, into the room \u2014 so it reflows when the room is resized.\n\nEnum: `top-left` `top-center` `top-right` `center-left` `center` `center-right` `bottom-left` `bottom-center` `bottom-right`\n'
432507
+ "body": '# Wadi data model (`.wadi` / `house_config.json`)\n\n> **Generated from `editor/src/schema/houseConfig.ts` \u2014 do not edit by hand.**\n> Regenerate: `node scripts/gen-schema-doc.mjs <path/to/houseConfig.ts> reference/data-model.md`\n> Some primitives (beam, floor_slab, pillar, plinth, ground) are generated from their\n> `fields` (schema/fields/\\*) into generated/objects.generated.ts \u2014 run `npm run gen-primitives`\n> in editor/ first if you changed those, so the generated schemas (which this doc reads) are current.\n> The Zod schema is the single source of truth; this file mirrors it (structure + the\n> semantics carried in its comments) so it can\'t drift.\n\nA `.wadi` file is one JSON object matching **HouseConfig**. Geometry is in **project\nunits** (a unitless grid; by default `units.per_unit = 10` means 10 units = 1 ft).\nPlan coordinates are **Inkscape-style**: origin top-left, **X \u2192 right, Y \u2192 down**.\nSee `coordinate-system.md` for the coordinate/units detail and `parametric-conventions.md`\nfor variables/points/formulas.\n\n## Fields shared by (almost) every object\n\nThese appear on most object types; documented once here, marked *(cross-cutting)* below.\n\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `room` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | |\n| `layer` | string | | |\n| `name` | string | **yes** | |\n| `material` | string | | |\n| `z_offset` | number | | Vertical position of the room (its floor + walls), as a lift above the FLOOR BASE (slabZ = plinth top for floor 0, else the floor below\'s top; project units, 10 = 1 ft). This is the UNIFIED z_offset convention: every object is placed at `slabZ + z_offset`. When OMITTED, on-slab objects (room, wall, staircase, kitchen_platform) default z_offset to the floor\'s resolved slab thickness (floor.slab_thickness \u2192 house.defaults.slab_thickness \u2192 code default), so by default they sit on top of the slab, exactly as before. Set it explicitly for split-level floors \u2014 e.g. a room raised onto a thicker slab uses the same value the raised slab\'s top sits at. |\n\n\n- `type` \u2014 the discriminated-union tag; selects the object shape (values below).\n- `formulas` \u2014 per-field `"= expression"` overrides; the resolver evaluates each into the\n matching numeric field. See `parametric-conventions.md`.\n- `z_offset` \u2014 vertical lift above the floor base (slab top). On-slab objects (room, wall,\n staircase, kitchen_platform) default it to the floor\'s slab thickness; slab/beam/pillar/\n roof default to 0.\n\n## Top level \u2014 HouseConfig\n\n| field | type | req | notes |\n|---|---|---|---|\n| `coord_convention` | enum: `outer` `center` | | How a rectangular object\'s x/y/width/length relate to its walls (plans/grid-convention.md). "center" (new/canonical): coordinates are wall CENTRELINES \u2014 adjacent rooms ABUT on a shared line (no overlap), walls are centred on the boundary, and expandRoomWalls grows each footprint by wall_thickness/2 to the outer face. "outer" / absent (legacy): coordinates are the OUTER wall face and adjacent rooms must overlap by wall_thickness. |\n| `plinth` | any (freeform) | | Legacy top-level plinth (pre-"Plinth floor"). Tolerated but IGNORED so an un-migrated file still loads (it just renders without a plinth/ground) instead of failing .strict() validation. New configs put the plinth on the Plinth floor as a `plinth` object. |\n| `defaults` | [houseDefaults](#housedefaults) | | |\n| `units` | [units](#units) | | |\n| `layers` | array of [LayerDef](#layerdef) | | Configurable 3D visibility layers (optional; defaults applied when absent). Objects opt in via their own `layer` field. |\n| `variables` | map: string \u2192 number, or `"= formula"` string | | Parametric layer (plans/object-relationships-plan.md). Named scalar variables (number or "= formula", may reference other variables) and named 2D points; object `formulas` maps reference these. Optional \u2014 absent = a plain non-parametric house, resolved as a no-op. |\n| `points` | map: string \u2192 inline object | | |\n| `components` | map: string \u2192 [ComponentDef](#componentdef) | | Reusable-component library (in-file). Map of id \u2192 ComponentDef. A `component` object instantiates one by `ref`. Stored once; referenced by many instances; edit here to update every instance. |\n| `grids` | map: string \u2192 `gridDef` | | First-class parametric grids (plans/grid-convention.md). Map of id \u2192 GridDef (named X/Y wall centrelines). Rooms/slabs bind via `grid`+`cell`, pillars via `grid`+`node`; the resolver derives their geometry from the centrelines + wall thickness. Optional; reusable across templates. |\n| `configurator` | [configurator](#configurator) | | Configurator metadata (Gharkul owner UI). Optional; see plans/configurator-plan.md. |\n| `thumbnails` | array of string | | Preview snapshots (data: URLs) captured by the architect editor and saved WITH the template so the owner gallery can show real previews \u2014 multiple angles + the floor plan. `thumbnails[0]` is the gallery cover. Optional; excluded from share links (a preview isn\'t model data \u2014 see io/shareLink.ts). `thumbnail` (singular) is the legacy one-image form, still read as a fallback so old template files keep working. |\n| `thumbnail` | string | | |\n| `template` | inline object | | Catalog metadata that makes a `.wadi` SELF-DESCRIBING: the editorial fields a gallery card needs that can\'t be derived from geometry (title, blurb, style/roof tags, min plot). With this block + `thumbnails[]`, a folder of `.wadi` files IS the catalog \u2014 the app lists the folder and indexes each file, with no separate index.json to maintain (see io/templateSource.ts). Non-strict so newer editorial fields don\'t break an older build. |\n| `floors` | array of [floor](#floor) | **yes** | |\n| `_walls_expanded` | boolean | | |\n\n\n## floor\n\n| field | type | req | notes |\n|---|---|---|---|\n| `floor_number` | integer \u2265 0 | **yes** | |\n| `name` | string | **yes** | |\n| `height` | number > 0 | | Per-floor overrides for the default heights in GlobalConfig. In project units (10 units = 1 ft). All three are INDEPENDENT \u2014 no relationship enforced between them: height \u2014 floor-to-floor rise (drives roof wallTop-Z stack) wall_height \u2014 standing wall height (floor top \u2192 ceiling) slab_thickness \u2014 RCC deck between this floor and the one above All fall back to GlobalConfig defaults when omitted. |\n| `wall_height` | number > 0 | | |\n| `slab_thickness` | number \u2265 0 | | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | |\n| `objects` | array of [Object types](#object-types) | **yes** | |\n\n\n## Object types (`floors[].objects[]`)\n\nEvery entry in a floor\'s `objects` array is one of these, tagged by `type`:\n\n### `plinth`\n\nThe plinth is now a normal object placed on the "Plinth" floor (the first floor, number 0), not a top-level config key. Its footprint + height match the old top-level plinth; the plinth floor\'s `height` drives the rise to the floor above (replacing the old hardcoded plinth_height seed).\n\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `plinth` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n| `layer` | string | | *(shared \u2014 see top)* |\n| `name` | string | | Label |\n| `material` | string | | Material key |\n| `x` | number | **yes** | Top-left X (project units) |\n| `y` | number | **yes** | Top-left Y (project units) |\n| `width` | number > 0 | **yes** | X extent (project units) |\n| `length` | number > 0 | **yes** | Y extent (project units) |\n| `height` | number > 0 | **yes** | Plinth height (project units) |\n| `z_offset` | number | | Lift above ground (project units) |\n\n\n### `ground`\n\nThe ground plane, also on the Plinth floor. Extent defaults to the site plot when authored by the migration. `height` is an optional thickness (0 = a flat plane); slope fields are a later phase.\n\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `ground` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n| `layer` | string | | *(shared \u2014 see top)* |\n| `name` | string | | Label |\n| `material` | string | | Material key |\n| `x` | number | **yes** | Top-left X (project units) |\n| `y` | number | **yes** | Top-left Y (project units) |\n| `width` | number > 0 | **yes** | X extent (project units) |\n| `length` | number > 0 | **yes** | Y extent (project units) |\n| `height` | number \u2265 0 | | Thickness (0 = flat) (project units) |\n| `z_offset` | number | | Lift above origin (project units) |\n\n\n### `component`\n\nAn INSTANCE of a reusable component from the in-file `components` library. It references a component by id (`ref`), overrides the component\'s input variables via `params`, and places it at (x, y) with a `z_offset` lift on its parent floor. At render time `expandRoomWalls` flattens it into concrete objects (resolve component with param+origin overrides \u2192 recurse \u2192 offset), so no renderer needs to know about `component`.\n\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `component` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n| `layer` | string | | *(shared \u2014 see top)* |\n| `name` | string | | |\n| `ref` | string | **yes** | |\n| `params` | map: string \u2192 union \u2014 see notes | | Overrides for the component\'s declared input variables. A string starting with "=" is a formula evaluated in the HOST scope (so it can reference the host\'s variables/points); a number is used directly. |\n| `x` | number | **yes** | |\n| `y` | number | **yes** | |\n| `rotation` | number | | Standard placement: yaw\xB0 about the instance origin (clockwise, same sense as item rotation: 0=south, 90=east). Right angles (0/90/180/270) are exact for any component; a non-right angle is allowed only for furniture-only ones. |\n| `z_offset` | number | | |\n\n\n### `item`\n\nA free-standing GLB furniture / decor instance placed directly on a floor (for pieces that aren\'t inside an enclosed room \u2014 outdoor/site/verandah decor, a loft item, etc.). `x`/`y` are the item\'s plan CENTRE. It MAY instead anchor to a named room via `anchor_to` + `anchor` + `gap`, in which case `x`/`y` are DERIVED at expand time (same anchor model as room-nested items). `rotation` is yaw\xB0; `scale` is a uniform resize; `z_offset` lifts it above the floor base (default = slab thickness). (`itemAsset`, `itemAnchor`, `gapField` are defined above `room`.)\n\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `item` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n| `layer` | string | | *(shared \u2014 see top)* |\n| `name` | string | | |\n| `asset` | [ItemAsset](#itemasset) | **yes** | |\n| `x` | number | **yes** | |\n| `y` | number | **yes** | |\n| `rotation` | number | | |\n| `scale` | number > 0 | | |\n| `z_offset` | number | | |\n| `anchor_to` | string | | Optional room-relative anchoring (for a free item that should follow a room). |\n| `anchor` | [ItemAnchor](#itemanchor) | | |\n| `gap_x` | number | | |\n| `gap_y` | number | | |\n\n\n### `model`\n\nA GLB placed at real scale and manipulated by a `rig` of named-node ops. Distinct from `item` (furniture, catalog + anchoring): `model` is a rigged structural asset. `asset.dimensions` is the real metre size, used for the 2D footprint and the scale.\n\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `model` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n| `layer` | string | | *(shared \u2014 see top)* |\n| `name` | string | | |\n| `asset` | [ItemAsset](#itemasset) | **yes** | |\n| `x` | number | **yes** | |\n| `y` | number | **yes** | |\n| `rotation` | number | | |\n| `scale` | number > 0 | | |\n| `z_offset` | number | | |\n| `rig` | array of `rigOp` | | |\n\n\n### `floor_slab`\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `floor_slab` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n| `layer` | string | | *(shared \u2014 see top)* |\n| `name` | string | | Label |\n| `x` | number | **yes** | Top-left X (project units) |\n| `y` | number | **yes** | Top-left Y (project units) |\n| `width` | number > 0 | **yes** | X extent (project units) |\n| `length` | number > 0 | **yes** | Y extent (project units) |\n| `thickness` | number \u2265 0 | | Slab thickness (defaults to floor\'s) (project units) |\n| `z_offset` | number | | Lift above floor base (project units) |\n\n\n### `pillar`\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `pillar` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n| `layer` | string | | *(shared \u2014 see top)* |\n| `name` | string | **yes** | Label |\n| `x` | number | **yes** | Top-left corner X (project units) |\n| `y` | number | **yes** | Top-left corner Y (project units) |\n| `width` | number > 0 | | X extent (project units) |\n| `length` | number > 0 | | Y extent (project units) |\n| `height` | number > 0 | **yes** | Column height (project units) |\n| `z_offset` | number | | Lift above floor base (project units) |\n\n\n### `beam`\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `beam` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n| `layer` | string | | *(shared \u2014 see top)* |\n| `name` | string | | Label |\n| `x` | number | **yes** | Top-left X (project units) |\n| `y` | number | **yes** | Top-left Y (project units) |\n| `width` | number > 0 | **yes** | X extent (project units) |\n| `length` | number > 0 | **yes** | Y extent (project units) |\n| `height` | number > 0 | | Vertical thickness (project units) |\n| `z_offset` | number | | Lift above floor base (project units) |\n\n\n### `room`\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `room` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n| `layer` | string | | *(shared \u2014 see top)* |\n| `name` | string | **yes** | |\n| `x` | number | **yes** | |\n| `y` | number | **yes** | |\n| `width` | number > 0 | **yes** | |\n| `length` | number > 0 | **yes** | |\n| `height` | number \u2265 0 | | 0 accepted \u2014 semantically the same as absent ("use floor default"). Old configs that accidentally saved height: 0 keep loading; the form treats 0 as "no override" and doesn\'t write it back. |\n| `material` | string | | |\n| `z_offset` | number | | Vertical position of the room (its floor + walls), as a lift above the FLOOR BASE (slabZ = plinth top for floor 0, else the floor below\'s top; project units, 10 = 1 ft). This is the UNIFIED z_offset convention: every object is placed at `slabZ + z_offset`. When OMITTED, on-slab objects (room, wall, staircase, kitchen_platform) default z_offset to the floor\'s resolved slab thickness (floor.slab_thickness \u2192 house.defaults.slab_thickness \u2192 code default), so by default they sit on top of the slab, exactly as before. Set it explicitly for split-level floors \u2014 e.g. a room raised onto a thicker slab uses the same value the raised slab\'s top sits at. |\n| `walls` | union \u2014 see notes | | |\n| `wall_heights` | map: string \u2192 [wall_heights entry](#wall-heights-entry) | | |\n| `items` | array of [RoomItem](#roomitem) | | Furniture nested in this room. Each piece is anchored to the room\'s inner footprint (see roomItem), so it reflows when the room resizes. Expanded into top-level `item` objects at render time. |\n\n\n### `wall`\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `wall` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n| `layer` | string | | *(shared \u2014 see top)* |\n| `name` | string | **yes** | |\n| `start_x` | number | **yes** | |\n| `start_y` | number | **yes** | |\n| `end_x` | number | **yes** | |\n| `end_y` | number | **yes** | |\n| `height` | number > 0 | | |\n| `height_end` | number | | |\n| `material` | string | | |\n| `facing` | enum: `north` `south` `east` `west` | | |\n| `z_offset` | number | | Lift above the FLOOR BASE (slabZ), project units. Omitted \u2192 defaults to the floor\'s resolved slab thickness (sits on the slab, as before). Set it for a split-level wall. Same convention as `room`. |\n| `openings` | array of [Opening](#opening) | | |\n\n\n### `staircase`\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `staircase` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n| `layer` | string | | *(shared \u2014 see top)* |\n| `name` | string | | |\n| `climb` | enum: `up` `down` | | `climb` picks which end (start_x, start_y) is and which way the flight runs in z as it extends into `direction`: \u2022 "up" (recommended): BOTTOM-anchored. Put the stair on the LOWER floor it rises FROM; (start_x, start_y) is the bottom step\'s near corner on that floor and the flight ASCENDS into `direction`. `rise_height` defaults to THIS floor\'s height (climb to the next level). The intuitive way. \u2022 "down" (DEFAULT, kept for older configs): TOP-anchored. Put the stair on the upper DESTINATION floor; (start_x, start_y) is the top connection and the flight DESCENDS into `direction`. `rise_height` defaults to the floor immediately BELOW this one. Either way the body + landings fill the box [start, start + max_run] along `direction`, and `z_offset` is the ANCHORED end\'s height above the floor base (omitted \u2192 this floor\'s slab thickness, flush with the walking surface). |\n| `start_x` | number | **yes** | |\n| `start_y` | number | **yes** | |\n| `rise_height` | number > 0 | | Total height the stair covers, top \u2192 floor below. The step COUNT is derived: num_steps = round(rise_height / step_rise). Omitted \u2192 defaults to the height of the floor immediately below this one. Formula-capable (e.g. "= floor_height"). Replaces the old explicit `num_steps`. |\n| `step_rise` | number > 0 | **yes** | |\n| `step_tread` | number > 0 | **yes** | |\n| `width` | number > 0 | | BOX MODEL (preferred): give a `width` (X) \xD7 `length` (Y) rectangle and the WHOLE staircase \u2014 flights + turn landings \u2014 is packed to fit INSIDE it. (start_x,start_y) is then the box\'s min corner, NOT the first step, and each flight\'s width is DERIVED from the box: two switchback lanes = (lateral \u2212 flight_gap)/2, or the full box for a single flight. `direction` picks the run axis (N/S \u2192 run along length/Y; E/W \u2192 run along width/X). The model errors if the box is too small for even the tightest split. Omit both to use the legacy `step_width` + `max_run` form below. |\n| `length` | number > 0 | | |\n| `step_width` | number > 0 | | Legacy per-step width (required by the old form; derived from the box in the box model). The expanded single-flight staircases the renderer consumes always carry it. |\n| `direction` | enum: `north` `south` `east` `west` | **yes** | The direction the stair EXTENDS from its top \u2014 the whole assembly fills the allocated box from (start_x,start_y) going this way for up to `max_run`. |\n| `max_run` | number > 0 | | ALLOCATED run: the length of space reserved for the stair along `direction`. The WHOLE assembly (flights + turn landings) is kept within [start, start+max_run]; when the run won\'t fit as one flight it auto-splits into switchback flights (more flights when tight), expanded in expandRoomWalls into plain staircases + floor_slab landings so every renderer is unchanged. Omit \u2192 one flight, no length limit. |\n| `landing_depth` | number > 0 | | Turn-landing depth (along the run). Omitted \u2192 equals step_width. |\n| `landing_thickness` | number \u2265 0 | | Turn-landing slab thickness. Omitted \u2192 equals step_rise. |\n| `turn` | enum: `clockwise` `anticlockwise` | | Switchback handedness, reckoned DESCENDING from the top. Omitted \u2192 "clockwise". Only affects split stairs. |\n| `flight_gap` | number > 0 | | Lateral gap between the two switchback flights (a stairwell void for a spine wall). Omitted/0 \u2192 flights are adjacent. The turn landings widen to bridge the gap. Only affects split stairs. |\n| `z_offset` | number | | Height of the stair\'s TOP above the floor base (slabZ; project units, 10 = 1 ft). Omitted \u2192 this floor\'s slab thickness, so the top is flush with the walking surface and the flights descend to the floor below. Raise it for an internal step whose top sits above the floor. |\n| `material` | string | | |\n\n\n### `spiral_staircase`\n\nA helical staircase: `steps` treads winding `turns` revolutions around a central pole, from the floor to `total_height`, within `radius`. Placed by its CENTRE (x, y). Optional fields fall back to sensible defaults at render time.\n\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `spiral_staircase` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n| `layer` | string | | *(shared \u2014 see top)* |\n| `name` | string | | Label |\n| `x` | number | **yes** | Centre X (project units) |\n| `y` | number | **yes** | Centre Y (project units) |\n| `radius` | number > 0 | **yes** | Outer radius (project units) |\n| `total_height` | number > 0 | **yes** | Total rise (floor to top step) (project units) |\n| `turns` | number > 0 | | Revolutions (default 1) |\n| `steps` | integer | | Number of treads (default ~12 per turn) |\n| `tread_thickness` | number > 0 | | Tread slab thickness (project units) |\n| `pole_radius` | number > 0 | | Central pole radius (project units) |\n| `z_offset` | number | | Lift above floor base (project units) |\n\n\n### `door`\n\nFlat door/window remain valid as a legacy schema \u2014 new configs nest them inside room.walls[side].openings or wall.openings.\n\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `door` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n| `layer` | string | | *(shared \u2014 see top)* |\n| `name` | string | **yes** | |\n| `x` | number | **yes** | |\n| `y` | number | **yes** | |\n| `width` | number > 0 | **yes** | |\n| `height` | number > 0 | **yes** | |\n| `direction` | enum: `north` `south` `east` `west` | **yes** | |\n| `room` | string | | |\n| `wall` | string | | |\n| `open` | boolean | | |\n\n\n### `window`\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `window` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n| `layer` | string | | *(shared \u2014 see top)* |\n| `name` | string | **yes** | |\n| `x` | number | **yes** | |\n| `y` | number | **yes** | |\n| `width` | number > 0 | **yes** | |\n| `height` | number > 0 | **yes** | |\n| `sill_height` | number \u2265 0 | | |\n| `direction` | enum: `north` `south` `east` `west` | **yes** | |\n| `room` | string | | |\n| `wall` | string | | |\n| `open` | boolean | | |\n\n\n### `kitchen_platform`\n\nKitchen platform \u2014 a polyline countertop / cooking slab that runs along the base of walls. Path is the wall-side edge; the platform extends `depth` units perpendicular to each segment on the given `side`. Renders as one box per path segment; corners meet at the shared point (no fancy mitering in v1).\n\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `kitchen_platform` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n| `layer` | string | | *(shared \u2014 see top)* |\n| `name` | string | | |\n| `path` | array of tuple `[n,n]` | **yes** | |\n| `side` | enum: `left` `right` | **yes** | |\n| `depth` | number > 0 | **yes** | |\n| `height` | number > 0 | **yes** | |\n| `z_offset` | number | | Lift above the FLOOR BASE (slabZ), project units. Omitted \u2192 defaults to the floor\'s resolved slab thickness (sits on the slab top, as before). Same convention as `room`. |\n| `base_z` | number | | |\n| `material` | string | | |\n\n\n### `roof`\n\nv2 roof \u2014 unified segment-based type that replaces hip/gable/flat/shed. Schema is permissive; the v2 pipeline (svg2d/roof/v2/) validates segments + slope + endpoint style at derivation time.\n\n\n> **Freeform:** extra fields are allowed (`.catchall`) and validated at derivation time. See `roof-v2-guide.md`.\n\n| field | type | req | notes |\n|---|---|---|---|\n| `type` | literal `roof` | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | *(shared \u2014 see top)* |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | *(shared \u2014 see top)* |\n\n\n## Shared & nested schemas\n\n### site\n\nObjects don\'t bind to the grid with a special field \u2014 a grid line\'s position is published as a formula symbol (`<gridId>.x<name>` / `.y<name>`, see param/resolve.ts), so a room places itself with ordinary `formulas`, e.g. { x: "= main.x1", width: "= main.x5 - main.x1" }. With coord_convention:"center" those are wall centrelines and expandRoomWalls handles the wall extent.\n\n| field | type | req | notes |\n|---|---|---|---|\n| `reference_x` | number | **yes** | |\n| `reference_y` | number | **yes** | |\n| `plot_length` | number > 0 | **yes** | |\n| `plot_width` | number > 0 | **yes** | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | |\n\n\n### houseDefaults\n\nHouse-level overrides for the built-in GlobalConfig defaults. Every floor without its own value falls back to these; if these are absent too, the code defaults in DEFAULT_GLOBAL_CONFIG apply.\n\n| field | type | req | notes |\n|---|---|---|---|\n| `floor_height` | number > 0 | | |\n| `wall_height` | number > 0 | | |\n| `slab_thickness` | number \u2265 0 | | |\n| `wall_thickness` | number > 0 | | House-wide wall thickness (project units). Per-object `wall_thickness`/`thickness` overrides still win. Falls back to the code default (DEFAULT_GLOBAL_CONFIG.wall_thickness = 8) when omitted. |\n| `formulas` | map: field name \u2192 `"= formula"` string | | |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | |\n\n\n### units\n\nHow dimensions are LABELLED on the drawings. Display-only \u2014 geometry always stays in project units; this just controls the text on the dimension lines. Omitted = the built-in default (feet & inches, 10 project units = 1 ft).\n\n| field | type | req | notes |\n|---|---|---|---|\n| `system` | enum: `feet_inches` `feet` `meters` `centimeters` `millimeters` | | feet_inches \u2192 12\' 6" ; the rest \u2192 decimal with a unit suffix. |\n| `per_unit` | number > 0 | | Project units that equal ONE display unit (10 \u2192 10 units = 1 ft; 100 \u2192 100 units = 1 m). Default 10. |\n| `precision` | integer \u2265 0 | | Decimal places for the non-feet_inches systems. |\n\n\n### Opening\n\nThe plinth is now a normal object placed on the "Plinth" floor (the first floor, number 0), not a top-level config key. Its footprint + height match the old top-level plinth; the plinth floor\'s `height` drives the rise to the floor above (replacing the old hardcoded plinth_height seed). `plinth` + `ground` are GENERATED from fields (schema/fields/{plinth,ground}.ts), imported above as plinthObject / groundObject. (P2b)\n\n| field | type | req | notes |\n|---|---|---|---|\n| `kind` | enum: `door` `window` | **yes** | |\n| `name` | string | | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | Numeric fields hold the RESOLVED value; a `= formula` for any of them lives in `formulas` (e.g. formulas.offset), evaluated by resolveParametric against the house variables/points \u2014 same pattern as every other object. |\n| `offset` | number | **yes** | Any number: `center` anchor takes a signed shift; the resolved placement is fit-checked against the wall in expand.ts, so a bad value errors there. |\n| `anchor` | enum: `start` `center` `end` | | Which end of the wall `offset` is measured from, so the opening holds its place when the wall/room scales \u2014 no formula needed. `start` (default, legacy): offset from the wall start to the near edge. `end`: offset from the wall end to the far edge (0 = flush to the end). `center`: signed shift of the opening centre from the wall midpoint (0 = centred; may be negative). Resolved to a start-based offset in expand.ts (openingAnchor.ts). |\n| `width` | number > 0 | **yes** | |\n| `height` | number > 0 | **yes** | |\n| `sill_height` | number | | |\n| `direction` | enum: `north` `south` `east` `west` | | |\n| `facing` | enum: `north` `south` `east` `west` | | |\n| `open` | boolean | | When true, the opening is left BARE (just a hole) \u2014 no glazing/frame for a window, no leaf for a door \u2014 e.g. an open doorway or unglazed vent. |\n\n\n### RoomWallSide\n| field | type | req | notes |\n|---|---|---|---|\n| `height` | number | | |\n| `height_end` | number | | |\n| `openings` | array of [Opening](#opening) | | |\n\n\n### RoomItem\n\nA furniture piece nested INSIDE a room (room.items[]). It has NO x/y \u2014 its plan position is DERIVED at expand time from the parent room\'s footprint + `anchor` + per-axis gap (+ its own `rotation`). Flattened into a top-level `item` for every renderer. `gap_x`/`gap_y` are the inset (project units) kept from the anchor into the room (edge/corner anchor \u2192 clears the wall; centre anchor \u2192 signed offset, +x east / +y south). `gap_x`/`gap_y`/`rotation`/`scale`/`z_offset` are all plain numeric fields so each can be driven by a `= formula` (via the `formulas` map).\n\n| field | type | req | notes |\n|---|---|---|---|\n| `name` | string | | |\n| `formulas` | map: field name \u2192 `"= formula"` string | | |\n| `enabled` | boolean or number (`false`/`0` = hidden) | | |\n| `layer` | string | | |\n| `asset` | [ItemAsset](#itemasset) | **yes** | |\n| `anchor` | [ItemAnchor](#itemanchor) | | |\n| `gap_x` | number | | |\n| `gap_y` | number | | |\n| `rotation` | number | | |\n| `scale` | number > 0 | | |\n| `z_offset` | number | | |\n\n\n### ItemAsset\n\nFurniture (GLB `item`) \u2014 shared schema pieces Defined BEFORE `room` so a room can nest its own `items[]`. Asset distances are METRES (the GLB\'s native unit); the 3D/2D layers scale them into project units. See registry/nodes/item + three/units. The asset backing a furniture item \u2014 stored INLINE so a .wadi is self-contained (share links / web load the GLB from `src`). A catalog is just a picker convenience.\n\n| field | type | req | notes |\n|---|---|---|---|\n| `id` | string | **yes** | |\n| `name` | string | | |\n| `src` | string | **yes** | |\n| `dimensions` | tuple `[n>0,n>0,n>0]` | **yes** | |\n| `thumbnail` | string | | |\n| `floorPlanUrl` | string | | |\n| `category` | string | | |\n| `tags` | array of string | | |\n| `offset` | tuple `[n,n,n]` | | |\n| `corrRotation` | tuple `[n,n,n]` | | |\n| `corrScale` | tuple `[n>0,n>0,n>0]` | | |\n\n\n### ComponentDef\n| field | type | req | notes |\n|---|---|---|---|\n| `name` | string | | |\n| `goal` | string | | A short natural-language description of what this component accomplishes (the discovery key for goal-based module lookup, e.g. "climb to the next floor"). Purely metadata \u2014 renderers ignore it. |\n| `params` | array of [ComponentParam](#componentparam) | | |\n| `variables` | map: string \u2192 number, or `"= formula"` string | | |\n| `points` | map: string \u2192 inline object | | |\n| `objects` | array of [Object types](#object-types) | **yes** | |\n| `expose` | inline object | | Promote this component to a typed primitive at load time (plans/declarative-plugins.md P0). When present, the component registers a NodeDefinition of type `expose.type` whose fields come from `params`; it can then be used like any core object type. `type` is namespaced (`pack.thing`). |\n\n\n### ComponentParam\n\nA reusable component DEFINITION in the in-file `components` library. It is a mini-house: its own `variables`/`points` and a flat `objects` body authored in LOCAL coords (origin 0,0). `params` names which variables are the public inputs (label/default for the instance form). A `component` instance overrides those variables and places the body at its (x,y,z_offset). Stored once; referenced by many instances.\n\n| field | type | req | notes |\n|---|---|---|---|\n| `name` | string | **yes** | |\n| `label` | string | | |\n| `description` | string | | |\n| `default` | number | | |\n| `kind` | string | | Field-projection annotations, used only when the component is `expose`d as a typed primitive (plans/declarative-plugins.md). `kind` is a FieldKind preset (coord/extent/nonneg/int/text/flag/enum); when absent the kind is inferred from the default\'s type. `unit` is a doc-only unit hint. |\n| `unit` | string | | |\n\n\n### LayerDef\n\nA visibility layer for the 3D view. Each object may reference a layer by `id` (via its `layer` field); the layers menu toggles whole layers on/off. Display-only \u2014 never affects geometry. Optional: when absent, a built-in default layer set is used, and objects fall back to an automatic per-type/floor mapping.\n\n| field | type | req | notes |\n|---|---|---|---|\n| `id` | string (non-empty) | **yes** | |\n| `label` | string | **yes** | |\n| `color` | string | | |\n| `group` | string | | Friendly group for the owner "Show/hide layers" menu (e.g. "Roof", "Walls"). Layers sharing a group toggle together. Optional. |\n\n\n### configurator\n| field | type | req | notes |\n|---|---|---|---|\n| `title` | string | | |\n| `description` | string | | |\n| `groups` | array of inline object | | |\n| `inputs` | array of [ConfiguratorInput](#configuratorinput) | **yes** | |\n\n\n### ConfiguratorInput\n\nConfigurator (Gharkul owner UI) Optional, author-supplied metadata: which `variables`/`points` a template exposes to end users, and how to present them. IGNORED by the resolver and every geometry consumer \u2014 read only by the owner-facing Configurator UI. `target` is a variable name (e.g. "floorH") or a point coordinate ("House.W" \u2192 points.House.x; W/L/X/Y/x/y are resolver synonyms). `min`/`max`/ `step` are in RAW project units; `unit` only affects display.\n\n| field | type | req | notes |\n|---|---|---|---|\n| `target` | string (non-empty) | **yes** | |\n| `label` | string (non-empty) | **yes** | |\n| `description` | string | | |\n| `control` | enum: `slider` `number` `select` `toggle` | | |\n| `unit` | enum: `ft` `in` `m` `units` `percent` `count` `none` | | |\n| `min` | number | | |\n| `max` | number | | |\n| `step` | number > 0 | | |\n| `options` | array of inline object | | |\n| `group` | string | | |\n\n\n### ItemAnchor\n\n9-point anchor on a room\'s INNER footprint. First token = vertical (top = north \u2026 bottom = south), second = horizontal (left = west \u2026 right = east); "center" alone = both. The item aligns its matching edge/corner to this spot, held `gap` off it, into the room \u2014 so it reflows when the room is resized.\n\nEnum: `top-left` `top-center` `top-right` `center-left` `center` `center-right` `bottom-left` `bottom-center` `bottom-right`\n'
431145
432508
  }
431146
432509
  };
431147
432510
  var MODULES = {