@obenor/mcp 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,13 +5,8 @@ var __glob = (map) => (path) => {
5
5
  if (fn) return fn();
6
6
  throw new Error("Module not found in bundle: " + path);
7
7
  };
8
- var __esm = (fn, res, err) => function __init() {
9
- if (err) throw err[0];
10
- try {
11
- return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
12
- } catch (e) {
13
- throw err = [e], e;
14
- }
8
+ var __esm = (fn, res) => function __init() {
9
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
15
10
  };
16
11
  var __export = (target, all) => {
17
12
  for (var name in all)
@@ -1498,9 +1493,9 @@ async function createObenorKernel(moduleArg = {}) {
1498
1493
  var mounts = [];
1499
1494
  var check = [mount];
1500
1495
  while (check.length) {
1501
- var m = check.pop();
1502
- mounts.push(m);
1503
- check.push(...m.mounts);
1496
+ var m2 = check.pop();
1497
+ mounts.push(m2);
1498
+ check.push(...m2.mounts);
1504
1499
  }
1505
1500
  return mounts;
1506
1501
  }, syncfs(populate, callback) {
@@ -4961,9 +4956,9 @@ async function createObenorKernel2(moduleArg = {}) {
4961
4956
  var mounts = [];
4962
4957
  var check = [mount];
4963
4958
  while (check.length) {
4964
- var m = check.pop();
4965
- mounts.push(m);
4966
- check.push(...m.mounts);
4959
+ var m2 = check.pop();
4960
+ mounts.push(m2);
4961
+ check.push(...m2.mounts);
4967
4962
  }
4968
4963
  return mounts;
4969
4964
  }, syncfs(populate, callback) {
@@ -8119,8 +8114,8 @@ var Scratch = class {
8119
8114
  used = 0;
8120
8115
  highWater = 0;
8121
8116
  overflow = [];
8122
- constructor(m) {
8123
- this.m = m;
8117
+ constructor(m2) {
8118
+ this.m = m2;
8124
8119
  }
8125
8120
  /**
8126
8121
  * Ends a call. Only here is it safe to move the main block, because no
@@ -8216,19 +8211,19 @@ var Kernel = class _Kernel {
8216
8211
  */
8217
8212
  static LOAD_TIMEOUT_MS = 45e3;
8218
8213
  scratch;
8219
- constructor(m, fallbackNote = null) {
8220
- this.m = m;
8214
+ constructor(m2, fallbackNote = null) {
8215
+ this.m = m2;
8221
8216
  this.fallbackNote = fallbackNote;
8222
- this.scratch = new Scratch(m);
8217
+ this.scratch = new Scratch(m2);
8223
8218
  const required = ["_ob_init", "_ob_version", "_ob_doc_create", "_ob_make_box"];
8224
- const missing = required.filter((name) => typeof m[name] !== "function");
8219
+ const missing = required.filter((name) => typeof m2[name] !== "function");
8225
8220
  if (missing.length > 0) {
8226
8221
  throw new KernelError(-1, `the loaded .wasm does not match its JavaScript glue (missing ${missing.join(", ")}). Re-stage the kernel assets: pnpm --filter @obenor/app run prepare-wasm`, "kernel", "load");
8227
8222
  }
8228
- const status = m._ob_init();
8223
+ const status = m2._ob_init();
8229
8224
  if (status !== 0)
8230
8225
  throw this.error(status, "ob_init");
8231
- const version = m.UTF8ToString(m._ob_version());
8226
+ const version = m2.UTF8ToString(m2._ob_version());
8232
8227
  if (!version) {
8233
8228
  throw new KernelError(-1, "the kernel reported no version, which means the .wasm and its glue are out of step. Re-stage the kernel assets: pnpm --filter @obenor/app run prepare-wasm", "kernel", "load");
8234
8229
  }
@@ -8882,8 +8877,8 @@ var Kernel = class _Kernel {
8882
8877
  throw new KernelError(-3, "transform expects a 16-element row-major matrix");
8883
8878
  }
8884
8879
  this.scratch.reset();
8885
- const m = this.scratch.f64(matrixRowMajor);
8886
- return this.produce((out) => this.m._ob_transform(doc2, shape, m, out), "transform");
8880
+ const m2 = this.scratch.f64(matrixRowMajor);
8881
+ return this.produce((out) => this.m._ob_transform(doc2, shape, m2, out), "transform");
8887
8882
  }
8888
8883
  /**
8889
8884
  * A ruled surface between two curves.
@@ -8998,8 +8993,8 @@ var Kernel = class _Kernel {
8998
8993
  }
8999
8994
  this.scratch.reset();
9000
8995
  const id = this.m._ob_name_intern(doc2, this.scratch.cstring(face));
9001
- const indices = this.scratch.i32(moves.flatMap((m) => [m.i, m.j]));
9002
- const positions = this.scratch.f64(moves.flatMap((m) => [m.to[0], m.to[1], m.to[2]]));
8996
+ const indices = this.scratch.i32(moves.flatMap((m2) => [m2.i, m2.j]));
8997
+ const positions = this.scratch.f64(moves.flatMap((m2) => [m2.to[0], m2.to[1], m2.to[2]]));
9003
8998
  return this.produce((out) => this.m._ob_move_surface_poles(doc2, shape, id, indices, positions, moves.length, out), "moveSurfacePoles");
9004
8999
  }
9005
9000
  /** One face, carried `distance` past its edge on all four sides. */
@@ -9542,6 +9537,546 @@ var Kernel = class _Kernel {
9542
9537
  }
9543
9538
  };
9544
9539
 
9540
+ // ../doc/dist/arch.js
9541
+ var unit = (a, b) => {
9542
+ const dx = b[0] - a[0];
9543
+ const dy = b[1] - a[1];
9544
+ const len = Math.hypot(dx, dy);
9545
+ if (len < 1e-9)
9546
+ return null;
9547
+ return [dx / len, dy / len];
9548
+ };
9549
+ var leftNormal = ([x, y]) => [-y, x];
9550
+ function signedArea2(loop) {
9551
+ let sum = 0;
9552
+ for (let i = 0; i < loop.length; i++) {
9553
+ const a = loop[i];
9554
+ const b = loop[(i + 1) % loop.length];
9555
+ sum += a[0] * b[1] - b[0] * a[1];
9556
+ }
9557
+ return sum;
9558
+ }
9559
+ function offsetSide(points, d, closed, miterLimit = 4) {
9560
+ const n = points.length;
9561
+ const dirs = [];
9562
+ for (let i = 0; i + 1 < n; i++)
9563
+ dirs.push(unit(points[i], points[i + 1]));
9564
+ if (closed)
9565
+ dirs.push(unit(points[n - 1], points[0]));
9566
+ if (dirs.some((v) => v === null)) {
9567
+ throw new KernelError(-3, "a wall path repeats a point, so one of its segments has no direction and there is nothing to offset it from", "", "wall");
9568
+ }
9569
+ const out = [];
9570
+ for (let i = 0; i < n; i++) {
9571
+ const inc = i === 0 ? closed ? dirs[dirs.length - 1] : null : dirs[i - 1];
9572
+ const outg = i < dirs.length && !(!closed && i === n - 1) ? dirs[i] : null;
9573
+ if (inc === null || outg === null) {
9574
+ const s = outg ?? inc;
9575
+ const nrm = leftNormal(s);
9576
+ out.push([points[i][0] + nrm[0] * d, points[i][1] + nrm[1] * d]);
9577
+ continue;
9578
+ }
9579
+ const nA = leftNormal(inc);
9580
+ const nB = leftNormal(outg);
9581
+ const sx = nA[0] + nB[0];
9582
+ const sy = nA[1] + nB[1];
9583
+ const s2 = sx * sx + sy * sy;
9584
+ if (s2 < 1e-12) {
9585
+ const nrm = leftNormal(outg);
9586
+ out.push([points[i][0] + nrm[0] * d, points[i][1] + nrm[1] * d]);
9587
+ continue;
9588
+ }
9589
+ const mx = 2 * sx / s2;
9590
+ const my = 2 * sy / s2;
9591
+ if (Math.hypot(mx, my) > miterLimit) {
9592
+ const nrm = leftNormal(outg);
9593
+ out.push([points[i][0] + nrm[0] * d, points[i][1] + nrm[1] * d]);
9594
+ continue;
9595
+ }
9596
+ out.push([points[i][0] + mx * d, points[i][1] + my * d]);
9597
+ }
9598
+ return out;
9599
+ }
9600
+ function wallLoops(points, thickness, closed, justification = "centre") {
9601
+ const t = thickness;
9602
+ const [dl, dr] = justification === "left" ? [t, 0] : justification === "right" ? [0, -t] : [t / 2, -t / 2];
9603
+ const left = offsetSide(points, dl, closed);
9604
+ const right = offsetSide(points, dr, closed);
9605
+ if (!closed)
9606
+ return [[...left, ...right.slice().reverse()]];
9607
+ return [left, right.slice().reverse()];
9608
+ }
9609
+ function roofHeight(points, pitches, p, base = 0, sign = 1) {
9610
+ let z = Infinity;
9611
+ for (let i = 0; i < points.length; i++) {
9612
+ const pitch = pitches[i];
9613
+ if (pitch === null || pitch === void 0)
9614
+ continue;
9615
+ const a = points[i];
9616
+ const b = points[(i + 1) % points.length];
9617
+ const d = Math.max(0, sign * distanceLeftOf(p, a, b));
9618
+ z = Math.min(z, base + d * Math.tan(pitch));
9619
+ }
9620
+ return Number.isFinite(z) ? z : null;
9621
+ }
9622
+ function distanceLeftOf(p, a, b) {
9623
+ const dx = b[0] - a[0];
9624
+ const dy = b[1] - a[1];
9625
+ const len = Math.hypot(dx, dy);
9626
+ return (dx * (p[1] - a[1]) - dy * (p[0] - a[0])) / len;
9627
+ }
9628
+ var THIN_WALL = 75;
9629
+ var THIN_SLAB = 100;
9630
+ var TIGHT_HEAD = 150;
9631
+ var SHALLOW_PITCH = 2 * Math.PI / 180;
9632
+ var STEEP_PITCH = 60 * Math.PI / 180;
9633
+ var MAX_PITCH = 85 * Math.PI / 180;
9634
+ var DEG = 180 / Math.PI;
9635
+ var m = (mm) => `${(mm / 1e3).toFixed(3)} m`;
9636
+ function polygonCurves(loop, firstId) {
9637
+ return loop.map((p, i) => ({
9638
+ kind: "line",
9639
+ id: firstId + i,
9640
+ from: [p[0], p[1]],
9641
+ to: [loop[(i + 1) % loop.length][0], loop[(i + 1) % loop.length][1]]
9642
+ }));
9643
+ }
9644
+ var translation = (dx, dy, dz) => [
9645
+ 1,
9646
+ 0,
9647
+ 0,
9648
+ dx,
9649
+ 0,
9650
+ 1,
9651
+ 0,
9652
+ dy,
9653
+ 0,
9654
+ 0,
9655
+ 1,
9656
+ dz,
9657
+ 0,
9658
+ 0,
9659
+ 0,
9660
+ 1
9661
+ ];
9662
+ function planPoints(path, ctx, kind, field) {
9663
+ if (!Array.isArray(path) || path.length === 0) {
9664
+ throw new KernelError(-3, `a ${kind} needs '${field}': a list of [x, y, z] plan points`, "", kind);
9665
+ }
9666
+ const pts = path.map((p) => {
9667
+ const v = ctx.vec3(p, "length", [0, 0, 0]);
9668
+ return [v[0], v[1]];
9669
+ });
9670
+ const first = pts[0];
9671
+ const last = pts[pts.length - 1];
9672
+ if (pts.length > 2 && Math.hypot(last[0] - first[0], last[1] - first[1]) < 1e-9)
9673
+ pts.pop();
9674
+ return pts;
9675
+ }
9676
+ function pathLength(points, closed) {
9677
+ let total = 0;
9678
+ const last = closed ? points.length : points.length - 1;
9679
+ for (let i = 0; i < last; i++) {
9680
+ const a = points[i];
9681
+ const b = points[(i + 1) % points.length];
9682
+ total += Math.hypot(b[0] - a[0], b[1] - a[1]);
9683
+ }
9684
+ return total;
9685
+ }
9686
+ function buildLevel(feature, ctx) {
9687
+ const k = ctx.kernel;
9688
+ const elevation = ctx.number(feature.elevation, "length");
9689
+ const size = ctx.number(feature.size, "length", 5e3);
9690
+ if (!(size > 0)) {
9691
+ throw new KernelError(-3, `a level's datum square must have a positive size, not ${size}`, "", "level");
9692
+ }
9693
+ const half = size / 2;
9694
+ return k.buildSketch(ctx.doc, feature.id, { origin: [0, 0, elevation], normal: [0, 0, 1], xAxis: [1, 0, 0] }, {
9695
+ curves: [
9696
+ { kind: "line", id: 1, from: [-half, -half], to: [half, -half] },
9697
+ { kind: "line", id: 2, from: [half, -half], to: [half, half] },
9698
+ { kind: "line", id: 3, from: [half, half], to: [-half, half] },
9699
+ { kind: "line", id: 4, from: [-half, half], to: [-half, -half] }
9700
+ ]
9701
+ });
9702
+ }
9703
+ function wallPlanOf(feature, ctx) {
9704
+ const thickness = ctx.number(feature.thickness, "length");
9705
+ if (!(thickness > 0)) {
9706
+ throw new KernelError(-3, `a wall ${thickness} mm thick encloses nothing: thickness must be positive`, "", "wall");
9707
+ }
9708
+ const base = ctx.number(feature.base, "length", 0);
9709
+ const top = ctx.number(feature.top, "length");
9710
+ if (!(top > base)) {
9711
+ throw new KernelError(-3, `a wall from ${m(base)} to ${m(top)} has no height to extrude. 'top' is an ELEVATION, not a height - level('first') + 2700, or an absolute number in document units`, "", "wall");
9712
+ }
9713
+ const closed = feature.closed === true;
9714
+ const points = planPoints(feature.path, ctx, "wall", "path");
9715
+ if (points.length < 2) {
9716
+ throw new KernelError(-3, `a wall path needs at least two points to have a direction; this one has ${points.length}`, "", "wall");
9717
+ }
9718
+ if (closed && points.length < 3) {
9719
+ throw new KernelError(-3, `a closed wall path needs at least three points: ${points.length} enclose no plan`, "", "wall");
9720
+ }
9721
+ return {
9722
+ id: feature.id,
9723
+ points,
9724
+ closed,
9725
+ thickness,
9726
+ base,
9727
+ top,
9728
+ justification: feature.justification ?? "centre"
9729
+ };
9730
+ }
9731
+ function buildWall(feature, ctx) {
9732
+ const k = ctx.kernel;
9733
+ const doc2 = ctx.doc;
9734
+ const plan2 = wallPlanOf(feature, ctx);
9735
+ const loops = wallLoops(plan2.points, plan2.thickness, plan2.closed, plan2.justification);
9736
+ const curves = [];
9737
+ const loopSpecs = [];
9738
+ const areas = loops.map((loop) => Math.abs(signedArea2(loop)));
9739
+ const outerIndex = areas.indexOf(Math.max(...areas));
9740
+ for (const [index, ring] of loops.entries()) {
9741
+ const loop = signedArea2(ring) < 0 ? ring.slice().reverse() : ring;
9742
+ const made = polygonCurves(loop, curves.length + 1);
9743
+ loopSpecs.push({
9744
+ curveIndices: made.map((_, i) => curves.length + i),
9745
+ outer: index === outerIndex
9746
+ });
9747
+ curves.push(...made);
9748
+ }
9749
+ const profile = k.buildSketch(doc2, `${feature.id}__plan`, { origin: [0, 0, plan2.base], normal: [0, 0, 1], xAxis: [1, 0, 0] }, { curves, loops: loopSpecs });
9750
+ if (plan2.thickness < THIN_WALL) {
9751
+ ctx.warn(`this wall is ${plan2.thickness.toFixed(0)} mm thick, under the ${THIN_WALL} mm a stud with one board comes to - fine as a partition or a screen, thin for anything holding the roof up. Built as asked`);
9752
+ }
9753
+ const centreline = pathLength(plan2.points, plan2.closed);
9754
+ ctx.warn(`${plan2.closed ? "closed" : "open"} wall: centreline ${m(centreline)} over ${plan2.points.length} point${plan2.points.length === 1 ? "" : "s"}, ${plan2.thickness.toFixed(0)} mm thick, ${m(plan2.base)} to ${m(plan2.top)} (${m(plan2.top - plan2.base)} high), ${plan2.justification}-justified`);
9755
+ return k.extrude(doc2, profile, {
9756
+ direction: [0, 0, 1],
9757
+ distance: plan2.top - plan2.base,
9758
+ mode: ExtrudeMode.Blind
9759
+ });
9760
+ }
9761
+ function hostWallOf(host, ctx) {
9762
+ let id = host;
9763
+ const seen = [];
9764
+ for (let hop = 0; hop < 64; hop++) {
9765
+ const feature = ctx.featureOf(id);
9766
+ if (feature.kind === "wall")
9767
+ return wallPlanOf(feature, ctx);
9768
+ if (feature.kind === "opening") {
9769
+ seen.push(id);
9770
+ id = feature.host;
9771
+ continue;
9772
+ }
9773
+ throw new KernelError(-3, `an opening is positioned along its host wall's own centreline, so its host must be a wall or another opening in one. '${id}' is a ${feature.kind}` + (seen.length > 0 ? `, reached through ${seen.join(" -> ")}` : "") + ". Cut the openings into the wall first, then run the other feature on the result", "", "opening");
9774
+ }
9775
+ throw new KernelError(-3, `the chain of openings starting at '${host}' never reaches a wall`, "", "opening");
9776
+ }
9777
+ function hostFrameAt(wall, along, _ctx) {
9778
+ const total = pathLength(wall.points, wall.closed);
9779
+ if (!(along >= 0) || along > total) {
9780
+ throw new KernelError(-3, `'${wall.id}' runs ${m(total)} along its centreline and 'along' is ${m(along)}, which is ${along < 0 ? "before the start" : `${m(along - total)} past the end`}. 'along' is measured from the first point of the wall's path, in document units`, "", "opening");
9781
+ }
9782
+ const last = wall.closed ? wall.points.length : wall.points.length - 1;
9783
+ let runStart = 0;
9784
+ let segment = 0;
9785
+ let runLength = 0;
9786
+ for (let i = 0; i < last; i++) {
9787
+ const a2 = wall.points[i];
9788
+ const b2 = wall.points[(i + 1) % wall.points.length];
9789
+ const length = Math.hypot(b2[0] - a2[0], b2[1] - a2[1]);
9790
+ segment = i;
9791
+ runLength = length;
9792
+ if (runStart + length >= along - 1e-9)
9793
+ break;
9794
+ runStart += length;
9795
+ }
9796
+ const local = along - runStart;
9797
+ const a = wall.points[segment];
9798
+ const b = wall.points[(segment + 1) % wall.points.length];
9799
+ const tangent = unit(a, b);
9800
+ if (tangent === null) {
9801
+ throw new KernelError(-3, `the run of '${wall.id}' at ${m(along)} has no direction: its two points are the same`, "", "opening");
9802
+ }
9803
+ const point = [a[0] + tangent[0] * local, a[1] + tangent[1] * local];
9804
+ const normal = [tangent[1], -tangent[0], 0];
9805
+ const across = wall.justification === "left" ? -wall.thickness / 2 : wall.justification === "right" ? wall.thickness / 2 : 0;
9806
+ return {
9807
+ segment,
9808
+ local,
9809
+ runLength,
9810
+ runStart,
9811
+ total,
9812
+ point,
9813
+ tangent,
9814
+ normal,
9815
+ frame: {
9816
+ origin: [point[0] + normal[0] * across, point[1] + normal[1] * across, wall.base],
9817
+ normal,
9818
+ xAxis: [tangent[0], tangent[1], 0]
9819
+ }
9820
+ };
9821
+ }
9822
+ function buildOpening(feature, ctx) {
9823
+ const k = ctx.kernel;
9824
+ const doc2 = ctx.doc;
9825
+ const body = ctx.shapeOf(feature.host);
9826
+ const wall = hostWallOf(feature.host, ctx);
9827
+ const width = ctx.number(feature.width, "length");
9828
+ const height = ctx.number(feature.height, "length");
9829
+ if (!(width > 0) || !(height > 0)) {
9830
+ throw new KernelError(-3, `an opening ${width} x ${height} removes nothing: width and height must both be positive`, "", "opening");
9831
+ }
9832
+ const sill = ctx.number(feature.sillHeight, "length", 0);
9833
+ const along = ctx.number(feature.along, "length");
9834
+ const at = hostFrameAt(wall, along, ctx);
9835
+ if (at.local - width / 2 < -1e-6 || at.local + width / 2 > at.runLength + 1e-6) {
9836
+ const widest = 2 * Math.min(at.local, at.runLength - at.local);
9837
+ throw new KernelError(-3, `a ${m(width)} opening centred ${m(along)} along '${wall.id}' runs past the end of the straight length of wall it sits on. That run goes from ${m(at.runStart)} to ${m(at.runStart + at.runLength)} and the opening wants ${m(along - width / 2)} to ${m(along + width / 2)}. An opening is cut in the plane of ONE run; past the corner it would be cutting the wall that turns away. ` + (widest > 1e-6 ? `At ${m(along)} the widest that fits is ${m(widest)}; ${m(width)} needs its centre between ${m(at.runStart + width / 2)} and ${m(at.runStart + at.runLength - width / 2)}` : "Move it off the corner"), "", "opening");
9838
+ }
9839
+ const storey = wall.top - wall.base;
9840
+ if (sill < -1e-6) {
9841
+ throw new KernelError(-3, `a sill ${m(-sill)} below the wall's base is under the floor. 'sillHeight' is measured UP from the wall's own base at ${m(wall.base)}`, "", "opening");
9842
+ }
9843
+ if (sill + height > storey + 1e-6) {
9844
+ throw new KernelError(-3, `a ${m(height)} opening on a ${m(sill)} sill reaches ${m(sill + height)}, and the wall is ${m(storey)} high (${m(wall.base)} to ${m(wall.top)}) - so it would breach the top, which is a gap in the roofline rather than a window. There is ${m(storey - sill)} between that sill and the top of the wall`, "", "opening");
9845
+ }
9846
+ if (storey - (sill + height) < TIGHT_HEAD && storey - (sill + height) > 1e-6) {
9847
+ ctx.warn(`the head lands ${(storey - sill - height).toFixed(0)} mm under the top of the wall, under the ${TIGHT_HEAD} mm a lintel wants. It builds; there is just very little wall left over the opening`);
9848
+ }
9849
+ const clearance = ctx.number(feature.clearance, "length", Math.max(1, wall.thickness * 0.02));
9850
+ if (!(clearance > 0)) {
9851
+ throw new KernelError(-3, "clearance is how far the cut runs past each face of the wall and must be positive: a cut that stops exactly on a face is the boolean that fails", "", "opening");
9852
+ }
9853
+ const halfWidth = width / 2;
9854
+ const reveal = k.buildSketch(doc2, `${feature.id}__reveal`, at.frame, {
9855
+ curves: [
9856
+ { kind: "line", id: 1, from: [-halfWidth, sill], to: [halfWidth, sill] },
9857
+ { kind: "line", id: 2, from: [halfWidth, sill], to: [halfWidth, sill + height] },
9858
+ { kind: "line", id: 3, from: [halfWidth, sill + height], to: [-halfWidth, sill + height] },
9859
+ { kind: "line", id: 4, from: [-halfWidth, sill + height], to: [-halfWidth, sill] }
9860
+ ]
9861
+ });
9862
+ const tool = k.extrude(doc2, reveal, {
9863
+ direction: at.normal,
9864
+ distance: wall.thickness + 2 * clearance,
9865
+ mode: ExtrudeMode.Symmetric
9866
+ });
9867
+ ctx.warn(`opening ${m(width)} x ${m(height)} at ${m(along)} along '${wall.id}', sill ${m(wall.base + sill)}, head ${m(wall.base + sill + height)}`);
9868
+ return k.boolean(doc2, BooleanOp.Cut, [body], [tool]);
9869
+ }
9870
+ function buildSlab(feature, ctx) {
9871
+ const k = ctx.kernel;
9872
+ const doc2 = ctx.doc;
9873
+ const thickness = ctx.number(feature.thickness, "length");
9874
+ if (!(thickness > 0)) {
9875
+ throw new KernelError(-3, `a slab ${thickness} mm thick has no substance: thickness must be positive`, "", "slab");
9876
+ }
9877
+ if (thickness < THIN_SLAB) {
9878
+ ctx.warn(`this slab is ${thickness.toFixed(0)} mm, under the ${THIN_SLAB} mm a floor usually needs - a screed or a deck board rather than a structural floor. Built as asked`);
9879
+ }
9880
+ const level = ctx.number(feature.level, "length");
9881
+ const offset2 = ctx.number(feature.offset, "length", 0);
9882
+ const topZ = level + offset2;
9883
+ const hasBoundary = feature.boundary !== void 0;
9884
+ const hasProfile = feature.profile !== void 0;
9885
+ if (hasProfile && hasBoundary) {
9886
+ throw new KernelError(-3, `slab '${feature.id}' was given both a 'boundary' and a 'profile', which are two different plan shapes and no way to say which is meant. Keep one`, "", "slab");
9887
+ }
9888
+ if (!hasProfile && !hasBoundary) {
9889
+ throw new KernelError(-3, "a slab needs a shape: 'boundary' as a list of [x, y, z] plan points, or 'profile' as the id of a sketch already drawn in plan", "", "slab");
9890
+ }
9891
+ if (hasProfile) {
9892
+ const frame = ctx.planeOfFeature(feature.profile);
9893
+ if (Math.abs(frame.normal[2]) < 1 - 1e-6) {
9894
+ const tilt = Math.acos(Math.min(1, Math.abs(frame.normal[2]))) * DEG;
9895
+ ctx.warn(`'${feature.profile}' lies ${tilt.toFixed(1)} degrees off horizontal. The slab is still pushed straight DOWN from it, so it comes out as a leaning prism rather than a flat floor - which is a fine ramp and a strange floor`);
9896
+ }
9897
+ const solid = k.extrude(doc2, ctx.shapeOf(feature.profile), {
9898
+ direction: [0, 0, -1],
9899
+ distance: thickness,
9900
+ mode: ExtrudeMode.Blind
9901
+ });
9902
+ const dz = topZ - frame.origin[2];
9903
+ return Math.abs(dz) > 1e-9 ? k.transform(doc2, solid, translation(0, 0, dz)) : solid;
9904
+ }
9905
+ const points = planPoints(feature.boundary, ctx, "slab", "boundary");
9906
+ if (points.length < 3) {
9907
+ throw new KernelError(-3, `a slab boundary needs at least three points to enclose a floor; this one has ${points.length}`, "", "slab");
9908
+ }
9909
+ if (Math.abs(signedArea2(points)) < 1e-9) {
9910
+ throw new KernelError(-3, "the slab boundary encloses no area: its points are collinear", "", "slab");
9911
+ }
9912
+ const profile = k.buildSketch(doc2, `${feature.id}__plan`, { origin: [0, 0, topZ], normal: [0, 0, 1], xAxis: [1, 0, 0] }, { curves: polygonCurves(points, 1) });
9913
+ ctx.warn(`slab ${(Math.abs(signedArea2(points)) / 2e6).toFixed(2)} m2 in plan, ${thickness.toFixed(0)} mm thick, top at ${m(topZ)}, soffit at ${m(topZ - thickness)}`);
9914
+ return k.extrude(doc2, profile, {
9915
+ direction: [0, 0, -1],
9916
+ distance: thickness,
9917
+ mode: ExtrudeMode.Blind
9918
+ });
9919
+ }
9920
+ function buildRoof(feature, ctx) {
9921
+ const k = ctx.kernel;
9922
+ const doc2 = ctx.doc;
9923
+ const outline = planPoints(feature.boundary, ctx, "roof", "boundary");
9924
+ if (outline.length < 3) {
9925
+ throw new KernelError(-3, `a roof footprint needs at least three points; this one has ${outline.length}`, "", "roof");
9926
+ }
9927
+ const area2 = signedArea2(outline);
9928
+ if (Math.abs(area2) < 1e-9) {
9929
+ throw new KernelError(-3, "the roof footprint encloses no area: its points are collinear", "", "roof");
9930
+ }
9931
+ const sign = area2 > 0 ? 1 : -1;
9932
+ const base = ctx.number(feature.base, "length");
9933
+ const thickness = ctx.number(feature.thickness, "length");
9934
+ if (!(thickness > 0)) {
9935
+ throw new KernelError(-3, `a roof ${thickness} mm thick has no substance: thickness must be positive`, "", "roof");
9936
+ }
9937
+ const overhang = ctx.number(feature.overhang, "length", 0);
9938
+ if (overhang < 0) {
9939
+ ctx.warn(`the overhang is ${m(overhang)}, which pulls the eave INSIDE the footprint rather than out past it. Built that way; it is an unusual thing to want`);
9940
+ }
9941
+ const defaultPitch = ctx.number(feature.pitch, "angle");
9942
+ const pitches = edgePitches(feature, outline.length, defaultPitch, ctx);
9943
+ if (pitches.every((p) => p === null)) {
9944
+ throw new KernelError(-3, `a roof with no pitched edge has no top: every one of its ${outline.length} edges is a gable, so nothing rises and there is no envelope to intersect. Leave at least one edge without a 'gable' override`, "", "roof");
9945
+ }
9946
+ const eaves = Math.abs(overhang) > 1e-9 ? offsetSide(outline, -sign * overhang, true) : outline.slice();
9947
+ const reflex = reflexCorners(eaves, sign);
9948
+ if (reflex.length > 0) {
9949
+ ctx.warn(`this footprint is not convex - ${reflex.length} re-entrant corner${reflex.length === 1 ? "" : "s"}, at ${reflex.map((i) => `point ${i}`).join(", ")}. Every eave is extended to a full plane, so a wing can be roofed by a plane rising from an eave on the far side of the building. The solid is valid and sits inside the footprint; check the ridge lines are the ones you meant, or build the wings as separate roofs and fuse them`);
9950
+ }
9951
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
9952
+ for (const p of eaves) {
9953
+ minX = Math.min(minX, p[0]);
9954
+ maxX = Math.max(maxX, p[0]);
9955
+ minY = Math.min(minY, p[1]);
9956
+ maxY = Math.max(maxY, p[1]);
9957
+ }
9958
+ const span = Math.hypot(maxX - minX, maxY - minY);
9959
+ const steepest = Math.max(...pitches.filter((p) => p !== null));
9960
+ const headroom = span * Math.tan(steepest) + span * 0.1 + 10;
9961
+ const apex = highestPoint(eaves, pitches, sign);
9962
+ ctx.warn(`roof over ${(Math.abs(signedArea2(eaves)) / 2e6).toFixed(2)} m2, ${pitches.filter((p) => p !== null).length} pitched edge(s) and ${pitches.filter((p) => p === null).length} gable(s), default pitch ${(defaultPitch * DEG).toFixed(1)} degrees, ridge about ${m(base + apex)} (${m(apex)} above the eave), ${thickness.toFixed(0)} mm plumb thickness`);
9963
+ const floor = base - thickness - Math.max(1, thickness);
9964
+ const upper = envelopeSolid(feature.id, "top", eaves, pitches, sign, base, floor, headroom, ctx);
9965
+ const lower = envelopeSolid(feature.id, "soffit", eaves, pitches, sign, base - thickness, floor, headroom, ctx);
9966
+ return k.boolean(doc2, BooleanOp.Cut, [upper], [lower]);
9967
+ }
9968
+ function edgePitches(feature, count, fallback, ctx) {
9969
+ const overrides = feature.edges ?? [];
9970
+ if (overrides.length > count) {
9971
+ throw new KernelError(-3, `'edges' has ${overrides.length} entries and the footprint has ${count} edges. Entry i is the edge from boundary point i to point i+1`, "", "roof");
9972
+ }
9973
+ const check = (pitch, where) => {
9974
+ if (!(pitch > 0)) {
9975
+ throw new KernelError(-3, `${where} is ${(pitch * DEG).toFixed(1)} degrees, so it does not rise and contributes no plane to intersect. A flat roof is a slab - use 'slab' - and 'gable' is the way to say an end stands vertical`, "", "roof");
9976
+ }
9977
+ if (pitch >= MAX_PITCH) {
9978
+ throw new KernelError(-3, `${where} is ${(pitch * DEG).toFixed(1)} degrees. At that angle the plane climbs ${Math.tan(pitch).toFixed(1)} m for every metre inboard, which is a wall rather than a roof and needs a bounding prism kilometres tall. The limit is ${(MAX_PITCH * DEG).toFixed(0)} degrees; over about 60 it is a mansard, which is two roofs`, "", "roof");
9979
+ }
9980
+ if (pitch > STEEP_PITCH) {
9981
+ ctx.warn(`${where} is ${(pitch * DEG).toFixed(1)} degrees, steeper than a roof usually goes - that is mansard or spire territory. Built as asked`);
9982
+ } else if (pitch < SHALLOW_PITCH) {
9983
+ ctx.warn(`${where} is ${(pitch * DEG).toFixed(2)} degrees, which reads as flat: it rises ${(1e3 * Math.tan(pitch)).toFixed(0)} mm per metre. If you meant flat, 'slab' is the feature. Built as asked`);
9984
+ }
9985
+ return pitch;
9986
+ };
9987
+ const fallbackChecked = check(fallback, "the roof's 'pitch'");
9988
+ return Array.from({ length: count }, (_, i) => {
9989
+ const entry = overrides[i];
9990
+ if (entry === void 0 || entry === "")
9991
+ return fallbackChecked;
9992
+ const word = String(entry).trim().toLowerCase();
9993
+ if (word === "gable")
9994
+ return null;
9995
+ if (word === "pitch" || word === "pitched")
9996
+ return fallbackChecked;
9997
+ return check(ctx.number(entry, "angle"), `edge ${i}`);
9998
+ });
9999
+ }
10000
+ function reflexCorners(loop, sign) {
10001
+ const out = [];
10002
+ for (let i = 0; i < loop.length; i++) {
10003
+ const prev = loop[(i - 1 + loop.length) % loop.length];
10004
+ const here = loop[i];
10005
+ const next = loop[(i + 1) % loop.length];
10006
+ const cross3 = (here[0] - prev[0]) * (next[1] - here[1]) - (here[1] - prev[1]) * (next[0] - here[0]);
10007
+ if (sign * cross3 < -1e-9)
10008
+ out.push(i);
10009
+ }
10010
+ return out;
10011
+ }
10012
+ function highestPoint(loop, pitches, sign) {
10013
+ let best = 0;
10014
+ for (let i = 0; i < loop.length; i++) {
10015
+ for (let j = i + 1; j < loop.length; j++) {
10016
+ const p = [(loop[i][0] + loop[j][0]) / 2, (loop[i][1] + loop[j][1]) / 2];
10017
+ const z = roofHeight(loop, pitches, p, 0, sign);
10018
+ if (z !== null && z > best)
10019
+ best = z;
10020
+ }
10021
+ }
10022
+ return best;
10023
+ }
10024
+ function envelopeSolid(featureId, tag, loop, pitches, sign, base, floor, headroom, ctx) {
10025
+ const k = ctx.kernel;
10026
+ const doc2 = ctx.doc;
10027
+ const prismProfile = k.buildSketch(doc2, `${featureId}__${tag}_plan`, { origin: [0, 0, floor], normal: [0, 0, 1], xAxis: [1, 0, 0] }, { curves: polygonCurves(loop, 1) });
10028
+ let solid = k.extrude(doc2, prismProfile, {
10029
+ direction: [0, 0, 1],
10030
+ distance: base - floor + headroom,
10031
+ mode: ExtrudeMode.Blind
10032
+ });
10033
+ let reach = 0;
10034
+ for (const p of loop)
10035
+ reach = Math.max(reach, Math.hypot(p[0], p[1]));
10036
+ for (let i = 0; i < loop.length; i++) {
10037
+ const pitch = pitches[i];
10038
+ if (pitch === null || pitch === void 0)
10039
+ continue;
10040
+ const a = loop[i];
10041
+ const b = loop[(i + 1) % loop.length];
10042
+ const dir = unit(a, b);
10043
+ if (dir === null) {
10044
+ throw new KernelError(-3, `the roof footprint repeats point ${i}, so that edge has no direction`, "", "roof");
10045
+ }
10046
+ const inward = [sign * -dir[1], sign * dir[0]];
10047
+ const half = 3 * (reach + headroom) + 1e3;
10048
+ const rise = half * Math.tan(pitch);
10049
+ const drop = rise + half;
10050
+ const wedge = k.buildSketch(doc2, `${featureId}__${tag}_eave${i}`, {
10051
+ origin: [a[0], a[1], base],
10052
+ normal: [dir[0], dir[1], 0],
10053
+ xAxis: [inward[0], inward[1], 0]
10054
+ }, {
10055
+ curves: [
10056
+ { kind: "line", id: 1, from: [-half, -drop], to: [half, -drop] },
10057
+ { kind: "line", id: 2, from: [half, -drop], to: [half, rise] },
10058
+ { kind: "line", id: 3, from: [half, rise], to: [-half, -rise] },
10059
+ { kind: "line", id: 4, from: [-half, -rise], to: [-half, -drop] }
10060
+ ]
10061
+ });
10062
+ const slab = k.extrude(doc2, wedge, {
10063
+ direction: [dir[0], dir[1], 0],
10064
+ distance: 2 * half,
10065
+ mode: ExtrudeMode.Symmetric
10066
+ });
10067
+ solid = k.boolean(doc2, BooleanOp.Common, [solid], [slab]);
10068
+ }
10069
+ return solid;
10070
+ }
10071
+ function elevationOfDatum(id, box) {
10072
+ const low = box.min[2] ?? 0;
10073
+ const high = box.max[2] ?? 0;
10074
+ if (high - low > 1e-6) {
10075
+ throw new Error(`level('${id}') reads the elevation of a level, and '${id}' is ${(high - low).toFixed(1)} mm tall - so it is a body, not an elevation. maxz() and minz() measure a body; level() names a storey datum`);
10076
+ }
10077
+ return low;
10078
+ }
10079
+
9545
10080
  // ../doc/dist/expr.js
9546
10081
  var ExpressionError = class extends Error {
9547
10082
  expression;
@@ -9597,7 +10132,7 @@ function tokenize(src) {
9597
10132
  if (!Number.isFinite(value)) {
9598
10133
  throw new ExpressionError(`'${text}' is not a number`, src, start);
9599
10134
  }
9600
- let unit3;
10135
+ let unit4;
9601
10136
  let j = i;
9602
10137
  while (j < src.length && /\s/.test(src[j]))
9603
10138
  j++;
@@ -9606,10 +10141,10 @@ function tokenize(src) {
9606
10141
  j++;
9607
10142
  const word = src.slice(unitStart, j);
9608
10143
  if (word && Object.hasOwn(UNITS, word)) {
9609
- unit3 = word;
10144
+ unit4 = word;
9610
10145
  i = j;
9611
10146
  }
9612
- tokens.push(unit3 === void 0 ? { t: "num", v: value, at: start } : { t: "num", v: value, unit: unit3, at: start });
10147
+ tokens.push(unit4 === void 0 ? { t: "num", v: value, at: start } : { t: "num", v: value, unit: unit4, at: start });
9613
10148
  continue;
9614
10149
  }
9615
10150
  if (ch === "'" || ch === '"') {
@@ -9729,6 +10264,25 @@ var GEOMETRY = {
9729
10264
  return from + d;
9730
10265
  }
9731
10266
  },
10267
+ /* A storey height, by name.
10268
+ *
10269
+ * `level('first')` is the whole reason the `level` feature exists: a house
10270
+ * built by hand declared `ffl`, `lvl1`, `deck` and `roof_z` as parameters and
10271
+ * typed every z in the model against one of them, which is fine until the
10272
+ * first floor moves and somebody has to work out which of a hundred numbers
10273
+ * were `lvl1` and which merely happened to equal it.
10274
+ *
10275
+ * It reads the datum face the level is realised as, which is why it is
10276
+ * `minz` with a name that says what it means rather than a second lookup
10277
+ * table beside the feature list - and why it can insist the thing it was
10278
+ * pointed at is FLAT. A level is flat by construction; a body is not, and
10279
+ * being handed one is a caller who meant `maxz`. */
10280
+ level: {
10281
+ bodies: 1,
10282
+ arity: 1,
10283
+ result: "length",
10284
+ fn: (g, i) => elevationOfDatum(i[0], g.bounds(i[0]))
10285
+ },
9732
10286
  /** The general form: distance from a point along a direction to the surface. */
9733
10287
  ray: {
9734
10288
  bodies: 1,
@@ -11051,25 +11605,25 @@ function moments(values, t, periodic, ends) {
11051
11605
  const h = Array.from({ length: n }, (_, i) => t[i + 1] - t[i]);
11052
11606
  const slope = (i) => (values[i + 1] - values[i]) / h[i];
11053
11607
  if (periodic) {
11054
- const m2 = n;
11055
- const a2 = Array.from({ length: m2 }, () => new Array(m2).fill(0));
11056
- const rhs2 = new Array(m2).fill(0);
11057
- for (let i = 0; i < m2; i++) {
11058
- const prev = (i - 1 + m2) % m2;
11608
+ const m3 = n;
11609
+ const a2 = Array.from({ length: m3 }, () => new Array(m3).fill(0));
11610
+ const rhs2 = new Array(m3).fill(0);
11611
+ for (let i = 0; i < m3; i++) {
11612
+ const prev = (i - 1 + m3) % m3;
11059
11613
  const hPrev = h[prev];
11060
11614
  const hCur = h[i];
11061
11615
  a2[i][prev] += hPrev;
11062
11616
  a2[i][i] += 2 * (hPrev + hCur);
11063
- a2[i][(i + 1) % m2] += hCur;
11617
+ a2[i][(i + 1) % m3] += hCur;
11064
11618
  const slopePrev = (values[i] - values[prev]) / hPrev;
11065
11619
  rhs2[i] = 6 * (slope(i) - slopePrev);
11066
11620
  }
11067
11621
  const solved = solveDense(a2, rhs2);
11068
11622
  return [...solved, solved[0]];
11069
11623
  }
11070
- const m = n + 1;
11071
- const a = Array.from({ length: m }, () => new Array(m).fill(0));
11072
- const rhs = new Array(m).fill(0);
11624
+ const m2 = n + 1;
11625
+ const a = Array.from({ length: m2 }, () => new Array(m2).fill(0));
11626
+ const rhs = new Array(m2).fill(0);
11073
11627
  for (let i = 1; i < n; i++) {
11074
11628
  a[i][i - 1] = h[i - 1];
11075
11629
  a[i][i] = 2 * (h[i - 1] + h[i]);
@@ -11078,7 +11632,7 @@ function moments(values, t, periodic, ends) {
11078
11632
  }
11079
11633
  if (n === 1) {
11080
11634
  a[0][0] = 1;
11081
- a[m - 1][m - 1] = 1;
11635
+ a[m2 - 1][m2 - 1] = 1;
11082
11636
  return solveDense(a, rhs);
11083
11637
  }
11084
11638
  if (ends === "parabolic") {
@@ -11113,14 +11667,14 @@ function interpolate(points, options = {}) {
11113
11667
  const ends = options.ends ?? "notAKnot";
11114
11668
  const mx = moments(xs, t, periodic, ends);
11115
11669
  const my = moments(ys, t, periodic, ends);
11116
- const evalOne = (values, m, u) => {
11670
+ const evalOne = (values, m2, u) => {
11117
11671
  let i = 0;
11118
11672
  while (i < t.length - 2 && u > t[i + 1])
11119
11673
  i++;
11120
11674
  const h = t[i + 1] - t[i];
11121
11675
  const A = (t[i + 1] - u) / h;
11122
11676
  const B = 1 - A;
11123
- return A * values[i] + B * values[i + 1] + ((A * A * A - A) * m[i] + (B * B * B - B) * m[i + 1]) * (h * h) / 6;
11677
+ return A * values[i] + B * values[i + 1] + ((A * A * A - A) * m2[i] + (B * B * B - B) * m2[i + 1]) * (h * h) / 6;
11124
11678
  };
11125
11679
  return {
11126
11680
  knots: t.map((v) => v / span),
@@ -11286,7 +11840,7 @@ function hermiteAsBspline(points, tangents) {
11286
11840
  }
11287
11841
 
11288
11842
  // ../sketch/dist/prepare.js
11289
- var DEG = Math.PI / 180;
11843
+ var DEG2 = Math.PI / 180;
11290
11844
  var Mode;
11291
11845
  (function(Mode2) {
11292
11846
  Mode2[Mode2["PointPoint"] = 0] = "PointPoint";
@@ -11521,7 +12075,7 @@ function resolveValue(value, dimension, options, fail) {
11521
12075
  fail(`dimension ${value} is not a finite number`);
11522
12076
  return 0;
11523
12077
  }
11524
- return dimension === "angle" ? value * DEG : value;
12078
+ return dimension === "angle" ? value * DEG2 : value;
11525
12079
  }
11526
12080
  if (!options.resolve) {
11527
12081
  fail(`expression "${value}" needs a resolve callback`);
@@ -11532,13 +12086,13 @@ function resolveValue(value, dimension, options, fail) {
11532
12086
  fail(`expression "${value}" resolved to ${String(n)}`);
11533
12087
  return 0;
11534
12088
  }
11535
- return dimension === "angle" ? n * DEG : n;
12089
+ return dimension === "angle" ? n * DEG2 : n;
11536
12090
  }
11537
12091
  function prepare(sketch, options) {
11538
12092
  const errors = [];
11539
- const fail = (m) => {
11540
- if (!errors.includes(m))
11541
- errors.push(m);
12093
+ const fail = (m2) => {
12094
+ if (!errors.includes(m2))
12095
+ errors.push(m2);
11542
12096
  };
11543
12097
  const byId = /* @__PURE__ */ new Map();
11544
12098
  for (const e of sketch.entities) {
@@ -11574,13 +12128,13 @@ function prepare(sketch, options) {
11574
12128
  break;
11575
12129
  case "arc":
11576
12130
  addSlot(e.id, "r", e.radius);
11577
- addSlot(e.id, "a0", e.startAngle * DEG);
11578
- addSlot(e.id, "a1", e.endAngle * DEG);
12131
+ addSlot(e.id, "a0", e.startAngle * DEG2);
12132
+ addSlot(e.id, "a1", e.endAngle * DEG2);
11579
12133
  break;
11580
12134
  case "ellipse":
11581
12135
  addSlot(e.id, "rx", e.majorRadius);
11582
12136
  addSlot(e.id, "ry", e.minorRadius);
11583
- addSlot(e.id, "rot", e.rotation * DEG);
12137
+ addSlot(e.id, "rot", e.rotation * DEG2);
11584
12138
  break;
11585
12139
  case "line":
11586
12140
  case "spline":
@@ -12661,9 +13215,9 @@ function splineRows(row0, row1, res, sys, off, curve, p, slot) {
12661
13215
  const degree = curve.form === "bezier" ? points.length - 1 : Math.max(1, Math.min(curve.degree, points.length - 1));
12662
13216
  const knots = uniformKnots(points.length, degree, curve.closed && curve.form === "bspline");
12663
13217
  for (let j = 0; j < points.length; j++) {
12664
- const unit3 = points.map((_, k) => k === j ? [1, 0] : [0, 0]);
13218
+ const unit4 = points.map((_, k) => k === j ? [1, 0] : [0, 0]);
12665
13219
  const w = bsplinePointAt({
12666
- poles: unit3,
13220
+ poles: unit4,
12667
13221
  degree,
12668
13222
  knots: knots.knots,
12669
13223
  multiplicities: knots.multiplicities
@@ -12678,7 +13232,7 @@ function splineRows(row0, row1, res, sys, off, curve, p, slot) {
12678
13232
  }
12679
13233
 
12680
13234
  // ../sketch/dist/solver.js
12681
- var DEG2 = Math.PI / 180;
13235
+ var DEG3 = Math.PI / 180;
12682
13236
  function evaluateCluster(sys, cl) {
12683
13237
  let sum = 0;
12684
13238
  for (const c of cl.constraints) {
@@ -12697,13 +13251,13 @@ function evaluateCluster(sys, cl) {
12697
13251
  return sum;
12698
13252
  }
12699
13253
  function maxResidual(cl) {
12700
- let m = 0;
13254
+ let m2 = 0;
12701
13255
  for (let i = 0; i < cl.residuals.length; i++) {
12702
13256
  const a = Math.abs(cl.residuals[i]);
12703
- if (a > m)
12704
- m = a;
13257
+ if (a > m2)
13258
+ m2 = a;
12705
13259
  }
12706
- return m;
13260
+ return m2;
12707
13261
  }
12708
13262
  function solveCluster(sys, cl, tolerance, maxIterations) {
12709
13263
  let cost = evaluateCluster(sys, cl);
@@ -12939,15 +13493,15 @@ function readBack(sys) {
12939
13493
  return {
12940
13494
  ...e,
12941
13495
  radius: sys.x[slots[0]],
12942
- startAngle: sys.x[slots[1]] / DEG2,
12943
- endAngle: sys.x[slots[2]] / DEG2
13496
+ startAngle: sys.x[slots[1]] / DEG3,
13497
+ endAngle: sys.x[slots[2]] / DEG3
12944
13498
  };
12945
13499
  case "ellipse":
12946
13500
  return {
12947
13501
  ...e,
12948
13502
  majorRadius: sys.x[slots[0]],
12949
13503
  minorRadius: sys.x[slots[1]],
12950
- rotation: sys.x[slots[2]] / DEG2
13504
+ rotation: sys.x[slots[2]] / DEG3
12951
13505
  };
12952
13506
  case "line":
12953
13507
  case "spline":
@@ -13051,7 +13605,7 @@ function now() {
13051
13605
  var TAU = Math.PI * 2;
13052
13606
 
13053
13607
  // ../doc/dist/curvemath.js
13054
- var DEG3 = Math.PI / 180;
13608
+ var DEG4 = Math.PI / 180;
13055
13609
  function conicAsBspline(id, start, apex, end, rho, construction = false) {
13056
13610
  const r = Math.min(0.999, Math.max(1e-3, rho));
13057
13611
  return {
@@ -13066,14 +13620,14 @@ function conicAsBspline(id, start, apex, end, rho, construction = false) {
13066
13620
  };
13067
13621
  }
13068
13622
  function ellipseAsBspline(id, center, radiusX, radiusY, rotation, startAngle = 0, endAngle = 0, construction = false) {
13069
- const from = startAngle * DEG3;
13070
- let sweep = (endAngle - startAngle) * DEG3;
13623
+ const from = startAngle * DEG4;
13624
+ let sweep = (endAngle - startAngle) * DEG4;
13071
13625
  if (Math.abs(sweep) < 1e-12)
13072
13626
  sweep = Math.PI * 2;
13073
13627
  const pieces = Math.max(1, Math.ceil(Math.abs(sweep) / (Math.PI / 2) - 1e-9));
13074
13628
  const step = sweep / pieces;
13075
- const cr = Math.cos(rotation * DEG3);
13076
- const sr = Math.sin(rotation * DEG3);
13629
+ const cr = Math.cos(rotation * DEG4);
13630
+ const sr = Math.sin(rotation * DEG4);
13077
13631
  const place = (x, y) => {
13078
13632
  const px = x * radiusX;
13079
13633
  const py = y * radiusY;
@@ -13086,13 +13640,13 @@ function ellipseAsBspline(id, center, radiusX, radiusY, rotation, startAngle = 0
13086
13640
  for (let i = 0; i < pieces; i++) {
13087
13641
  const a = from + i * step;
13088
13642
  const b = a + step;
13089
- const m = (a + b) / 2;
13643
+ const m2 = (a + b) / 2;
13090
13644
  const w = Math.cos(step / 2);
13091
13645
  if (i === 0) {
13092
13646
  poles.push(place(Math.cos(a), Math.sin(a)));
13093
13647
  weights.push(1);
13094
13648
  }
13095
- poles.push(place(Math.cos(m) / w, Math.sin(m) / w));
13649
+ poles.push(place(Math.cos(m2) / w, Math.sin(m2) / w));
13096
13650
  weights.push(w);
13097
13651
  poles.push(place(Math.cos(b), Math.sin(b)));
13098
13652
  weights.push(1);
@@ -13125,13 +13679,13 @@ function tangentSplineAsBspline(id, points, startTangent, endTangent, constructi
13125
13679
  const total = t[n - 1];
13126
13680
  for (let i = 0; i < n; i++)
13127
13681
  t[i] = t[i] / total;
13128
- const unit3 = (v) => {
13682
+ const unit4 = (v) => {
13129
13683
  const l = Math.hypot(v[0], v[1]);
13130
13684
  return l < 1e-12 ? [1, 0] : [v[0] / l, v[1] / l];
13131
13685
  };
13132
13686
  const bessel = (atStart) => {
13133
13687
  if (n === 2)
13134
- return unit3([points[1][0] - points[0][0], points[1][1] - points[0][1]]);
13688
+ return unit4([points[1][0] - points[0][0], points[1][1] - points[0][1]]);
13135
13689
  const [i0, i1, i2] = atStart ? [0, 1, 2] : [n - 1, n - 2, n - 3];
13136
13690
  const h1 = t[i1] - t[i0];
13137
13691
  const h2 = t[i2] - t[i1];
@@ -13143,10 +13697,10 @@ function tangentSplineAsBspline(id, points, startTangent, endTangent, constructi
13143
13697
  const s2 = (q2 - q1) / h2;
13144
13698
  return s1 + (s1 - s2) * h1 / (h1 + h2);
13145
13699
  };
13146
- return unit3([d(0), d(1)]);
13700
+ return unit4([d(0), d(1)]);
13147
13701
  };
13148
- const d0 = startTangent === null ? bessel(true) : unit3(startTangent);
13149
- const d1 = endTangent === null ? bessel(false) : unit3(endTangent);
13702
+ const d0 = startTangent === null ? bessel(true) : unit4(startTangent);
13703
+ const d1 = endTangent === null ? bessel(false) : unit4(endTangent);
13150
13704
  const D0 = [d0[0] * total, d0[1] * total];
13151
13705
  const D1 = [d1[0] * total, d1[1] * total];
13152
13706
  const flat = [0, 0, 0, 0, ...t.slice(1, n - 1), 1, 1, 1, 1];
@@ -13433,15 +13987,15 @@ function threadForm(spec, fallbackDiameter) {
13433
13987
  let pitch = spec.pitch ?? 0;
13434
13988
  let designation = spec.designation ?? "";
13435
13989
  if (spec.designation) {
13436
- const m = /^\s*M\s*([\d.]+)\s*(?:[xX*]\s*([\d.]+))?\s*$/.exec(spec.designation);
13437
- if (!m) {
13990
+ const m2 = /^\s*M\s*([\d.]+)\s*(?:[xX*]\s*([\d.]+))?\s*$/.exec(spec.designation);
13991
+ if (!m2) {
13438
13992
  throw new Error(`'${spec.designation}' is not a metric thread designation. Use M10 for coarse or M10x1.25 for fine.`);
13439
13993
  }
13440
- const nominal = Number(m[1]);
13994
+ const nominal = Number(m2[1]);
13441
13995
  if (spec.majorDiameter === void 0)
13442
13996
  major = nominal;
13443
13997
  if (spec.pitch === void 0) {
13444
- const fine = m[2] ? Number(m[2]) : void 0;
13998
+ const fine = m2[2] ? Number(m2[2]) : void 0;
13445
13999
  const coarse = COARSE[`M${nominal}`];
13446
14000
  if (fine === void 0 && coarse === void 0) {
13447
14001
  throw new Error(`M${nominal} has no coarse pitch in ISO 261 - give the pitch, as in M${nominal}x1.5.`);
@@ -13618,23 +14172,23 @@ var round2 = (n) => Number(n.toFixed(4)).toString();
13618
14172
  var sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
13619
14173
  var dot2 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
13620
14174
  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]];
13621
- function unit(a) {
14175
+ function unit2(a) {
13622
14176
  const l = Math.hypot(a[0], a[1], a[2]);
13623
14177
  return l > 1e-12 ? [a[0] / l, a[1] / l, a[2] / l] : [1, 0, 0];
13624
14178
  }
13625
14179
  function frameOf(origin, normal, xAxis) {
13626
- const n = unit(normal);
14180
+ const n = unit2(normal);
13627
14181
  if (xAxis) {
13628
- const x = unit(xAxis);
14182
+ const x = unit2(xAxis);
13629
14183
  const inPlane = sub(x, [n[0] * dot2(x, n), n[1] * dot2(x, n), n[2] * dot2(x, n)]);
13630
- return { origin, normal: n, xAxis: unit(inPlane) };
14184
+ return { origin, normal: n, xAxis: unit2(inPlane) };
13631
14185
  }
13632
14186
  const seed = Math.abs(dot2([1, 0, 0], n)) > 0.9 ? [0, 1, 0] : [1, 0, 0];
13633
14187
  const d = dot2(seed, n);
13634
14188
  return {
13635
14189
  origin,
13636
14190
  normal: n,
13637
- xAxis: unit(sub(seed, [n[0] * d, n[1] * d, n[2] * d]))
14191
+ xAxis: unit2(sub(seed, [n[0] * d, n[1] * d, n[2] * d]))
13638
14192
  };
13639
14193
  }
13640
14194
  var project = (frame, p) => {
@@ -13655,7 +14209,7 @@ function projectEdges(topology, names, frame, mesh) {
13655
14209
  curves.push({ kind: "line", id, from: project(frame, edge.start), to: project(frame, edge.end) });
13656
14210
  continue;
13657
14211
  }
13658
- const square = Math.abs(Math.abs(dot2(unit(edge.axisDir), frame.normal)) - 1) < 1e-7;
14212
+ const square = Math.abs(Math.abs(dot2(unit2(edge.axisDir), frame.normal)) - 1) < 1e-7;
13659
14213
  if (edge.curveType === CurveType.Circle && square && edge.radius > 1e-9) {
13660
14214
  const centre = project(frame, edge.axisOrigin);
13661
14215
  if (edge.closed) {
@@ -14187,6 +14741,17 @@ function inputsOfUnsafe(feature) {
14187
14741
  inputs.push(faceBody);
14188
14742
  return inputs;
14189
14743
  }
14744
+ /* An opening CUTS its host, so the host is a consumed input and not merely
14745
+ * something it reads. Left out, there is no edge in the graph: the opening
14746
+ * builds before the wall it is cutting, the wall can be deleted out from
14747
+ * under it, and an edit to the wall does not mark the opening dirty. All
14748
+ * three are silent. */
14749
+ case "opening":
14750
+ return typeof feature.host === "string" ? [feature.host] : [];
14751
+ // A slab drawn on a sketch consumes that sketch the way an extrude does.
14752
+ // A slab given plan points consumes nothing.
14753
+ case "slab":
14754
+ return typeof feature.profile === "string" ? [feature.profile] : [];
14190
14755
  default:
14191
14756
  return [];
14192
14757
  }
@@ -14252,6 +14817,15 @@ function replaceInputUnsafe(feature, from, to) {
14252
14817
  return null;
14253
14818
  return { ...feature, targets: targets ?? feature.targets, tools: tools ?? feature.tools };
14254
14819
  }
14820
+ /* Both are re-pointed for the same reason every other consumer is: insert a
14821
+ * feature between a wall and the opening cut into it and the opening has to
14822
+ * follow, or the insertion silently does nothing and two bodies come out
14823
+ * where one was meant. The opening still finds the wall's PATH by walking
14824
+ * back through whatever it now sits on. */
14825
+ case "opening":
14826
+ return feature.host === from ? { ...feature, host: to } : null;
14827
+ case "slab":
14828
+ return feature.profile === from ? { ...feature, profile: to } : null;
14255
14829
  default:
14256
14830
  return null;
14257
14831
  }
@@ -14295,6 +14869,15 @@ function consumesInput(feature) {
14295
14869
  case "insertIsoparm":
14296
14870
  case "movePoles":
14297
14871
  return true;
14872
+ /* An opening IS a cut: it hands back the wall with a hole in it, not a
14873
+ * second body beside it. Left out of this list the wall would go on being
14874
+ * drawn, exported and measured alongside the version with the window in it,
14875
+ * two solids in the same space - which is the z-fighting the comment above
14876
+ * describes, at building scale. `level` is not here for the opposite
14877
+ * reason: it consumes nothing and produces no body a person moves, so
14878
+ * neither half of this question applies to it. */
14879
+ case "opening":
14880
+ return true;
14298
14881
  // A frozen body replaces the one it stores: leaving both would show the
14299
14882
  // same part twice and re-solve the half that was frozen to avoid solving.
14300
14883
  case "freeze":
@@ -15010,6 +15593,19 @@ function executeOne(feature, ctx) {
15010
15593
  return buildHole(feature, ctx);
15011
15594
  case "sprocket":
15012
15595
  return buildSprocket(feature, ctx);
15596
+ /* The architectural family. Each returns ordinary B-rep geometry -
15597
+ * nothing downstream can tell a wall from an extrude - so they live in
15598
+ * arch.ts and arrive here as five ordinary cases. */
15599
+ case "level":
15600
+ return buildLevel(feature, ctx);
15601
+ case "wall":
15602
+ return buildWall(feature, ctx);
15603
+ case "opening":
15604
+ return buildOpening(feature, ctx);
15605
+ case "slab":
15606
+ return buildSlab(feature, ctx);
15607
+ case "roof":
15608
+ return buildRoof(feature, ctx);
15013
15609
  case "import":
15014
15610
  return feature.format === "obj" || feature.format === "gltf" ? k.importMesh(doc2, feature.format, feature.data) : k.import(doc2, FORMATS[feature.format], feature.data);
15015
15611
  case "component":
@@ -15069,7 +15665,7 @@ function resolveSketchCurves(curves, ctx) {
15069
15665
  case "ellipse": {
15070
15666
  const rotation = c.rotation === void 0 ? 0 : ang(c.rotation, 0);
15071
15667
  if (Math.abs(rotation) > 1e-12) {
15072
- return ellipseAsBspline(c.id, pt(c.center), len(c.radiusX), len(c.radiusY), rotation / DEG4, ang(c.startAngle, 0) / DEG4, ang(c.endAngle, 0) / DEG4, c.construction === true);
15668
+ return ellipseAsBspline(c.id, pt(c.center), len(c.radiusX), len(c.radiusY), rotation / DEG5, ang(c.startAngle, 0) / DEG5, ang(c.endAngle, 0) / DEG5, c.construction === true);
15073
15669
  }
15074
15670
  return {
15075
15671
  kind: "ellipse",
@@ -15116,7 +15712,7 @@ function resolveSketchCurves(curves, ctx) {
15116
15712
  }
15117
15713
  });
15118
15714
  }
15119
- var DEG4 = Math.PI / 180;
15715
+ var DEG5 = Math.PI / 180;
15120
15716
  function selectionFailure(op, sel) {
15121
15717
  return new KernelError(-8, sel.note ?? `${op} selected no geometry`, "selection", op);
15122
15718
  }
@@ -15915,10 +16511,10 @@ function wallPrism(k, doc2, id, frame, radius, angle, size) {
15915
16511
  t + radius - radius * Math.cos(angle)
15916
16512
  ];
15917
16513
  const d = [Math.cos(angle), Math.sin(angle)];
15918
- const m = [-Math.sin(angle), Math.cos(angle)];
16514
+ const m2 = [-Math.sin(angle), Math.cos(angle)];
15919
16515
  const at = (alongD, alongM) => [
15920
- tangent[0] + d[0] * alongD + m[0] * alongM,
15921
- tangent[1] + d[1] * alongD + m[1] * alongM
16516
+ tangent[0] + d[0] * alongD + m2[0] * alongM,
16517
+ tangent[1] + d[1] * alongD + m2[1] * alongM
15922
16518
  ];
15923
16519
  const profile = k.buildSketch(doc2, id, sheetPlane(frame, -size), {
15924
16520
  curves: [
@@ -15947,7 +16543,7 @@ function buildFlatPattern(feature, ctx) {
15947
16543
  const wanted = new Set(feature.bends);
15948
16544
  const missing = feature.bends.filter((id) => !recorded.some((b) => b.id === id));
15949
16545
  if (missing.length > 0) {
15950
- throw new KernelError(-8, `no bend called ${missing.map((m) => `'${m}'`).join(", ")} on '${feature.input}'. Recorded bends: ${recorded.map((b) => b.id).join(", ")}.`, "", "flatPattern");
16546
+ throw new KernelError(-8, `no bend called ${missing.map((m2) => `'${m2}'`).join(", ")} on '${feature.input}'. Recorded bends: ${recorded.map((b) => b.id).join(", ")}.`, "", "flatPattern");
15951
16547
  }
15952
16548
  picked = recorded.filter((b) => wanted.has(b.id));
15953
16549
  }
@@ -16085,35 +16681,35 @@ function composeTransforms(a, b) {
16085
16681
  }
16086
16682
  return out;
16087
16683
  }
16088
- function transformPoint(m, p) {
16684
+ function transformPoint(m2, p) {
16089
16685
  return [
16090
- m[0] * p[0] + m[1] * p[1] + m[2] * p[2] + m[3],
16091
- m[4] * p[0] + m[5] * p[1] + m[6] * p[2] + m[7],
16092
- m[8] * p[0] + m[9] * p[1] + m[10] * p[2] + m[11]
16686
+ m2[0] * p[0] + m2[1] * p[1] + m2[2] * p[2] + m2[3],
16687
+ m2[4] * p[0] + m2[5] * p[1] + m2[6] * p[2] + m2[7],
16688
+ m2[8] * p[0] + m2[9] * p[1] + m2[10] * p[2] + m2[11]
16093
16689
  ];
16094
16690
  }
16095
- function transformDirection(m, v) {
16691
+ function transformDirection(m2, v) {
16096
16692
  return [
16097
- m[0] * v[0] + m[1] * v[1] + m[2] * v[2],
16098
- m[4] * v[0] + m[5] * v[1] + m[6] * v[2],
16099
- m[8] * v[0] + m[9] * v[1] + m[10] * v[2]
16693
+ m2[0] * v[0] + m2[1] * v[1] + m2[2] * v[2],
16694
+ m2[4] * v[0] + m2[5] * v[1] + m2[6] * v[2],
16695
+ m2[8] * v[0] + m2[9] * v[1] + m2[10] * v[2]
16100
16696
  ];
16101
16697
  }
16102
- function invertTransform(m) {
16103
- const tx = m[3], ty = m[7], tz = m[11];
16698
+ function invertTransform(m2) {
16699
+ const tx = m2[3], ty = m2[7], tz = m2[11];
16104
16700
  return [
16105
- m[0],
16106
- m[4],
16107
- m[8],
16108
- -(m[0] * tx + m[4] * ty + m[8] * tz),
16109
- m[1],
16110
- m[5],
16111
- m[9],
16112
- -(m[1] * tx + m[5] * ty + m[9] * tz),
16113
- m[2],
16114
- m[6],
16115
- m[10],
16116
- -(m[2] * tx + m[6] * ty + m[10] * tz),
16701
+ m2[0],
16702
+ m2[4],
16703
+ m2[8],
16704
+ -(m2[0] * tx + m2[4] * ty + m2[8] * tz),
16705
+ m2[1],
16706
+ m2[5],
16707
+ m2[9],
16708
+ -(m2[1] * tx + m2[5] * ty + m2[9] * tz),
16709
+ m2[2],
16710
+ m2[6],
16711
+ m2[10],
16712
+ -(m2[2] * tx + m2[6] * ty + m2[10] * tz),
16117
16713
  0,
16118
16714
  0,
16119
16715
  0,
@@ -16174,9 +16770,9 @@ function frameTransform(origin, z, x) {
16174
16770
  1
16175
16771
  ];
16176
16772
  }
16177
- function isIdentityTransform(m, tolerance = 1e-9) {
16773
+ function isIdentityTransform(m2, tolerance = 1e-9) {
16178
16774
  const id = identityTransform();
16179
- return m.length === 16 && m.every((v, i) => Math.abs(v - id[i]) <= tolerance);
16775
+ return m2.length === 16 && m2.every((v, i) => Math.abs(v - id[i]) <= tolerance);
16180
16776
  }
16181
16777
  var dot3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
16182
16778
  var scale = (a, s) => [a[0] * s, a[1] * s, a[2] * s];
@@ -16526,9 +17122,9 @@ function motionTransform(input2, edge) {
16526
17122
  const hi = max === void 0 ? Infinity : input2.number(max, dimension, 0);
16527
17123
  const value = Math.min(Math.max(raw, lo), hi);
16528
17124
  if (Math.abs(value - raw) > 1e-12) {
16529
- const unit3 = dimension === "angle" ? "deg" : "mm";
17125
+ const unit4 = dimension === "angle" ? "deg" : "mm";
16530
17126
  const show2 = (v) => (dimension === "angle" ? v * 180 / Math.PI : v).toFixed(3);
16531
- info.warnings.push(`${field} ${show2(raw)} ${unit3} is outside the limits ${show2(lo === -Infinity ? raw : lo)} to ${show2(hi === Infinity ? raw : hi)} ${unit3}; held at ${show2(value)} ${unit3}`);
17127
+ info.warnings.push(`${field} ${show2(raw)} ${unit4} is outside the limits ${show2(lo === -Infinity ? raw : lo)} to ${show2(hi === Infinity ? raw : hi)} ${unit4}; held at ${show2(value)} ${unit4}`);
16532
17128
  }
16533
17129
  return value;
16534
17130
  };
@@ -17662,6 +18258,102 @@ var FEATURE_SCHEMA = {
17662
18258
  expr("boreDiameter", "Bore through the middle. Refused if it reaches the tooth roots, which would cut the teeth off as loose bodies.", false, "no bore")
17663
18259
  ]
17664
18260
  },
18261
+ level: {
18262
+ kind: "level",
18263
+ summary: 'A named elevation - "ground", "first", "eaves" - that everything else can be built against. Write level("first") in ANY numeric field and it reads that height, so a storey is one number and the whole building follows it: move the level, rebuild, and every wall base, slab and roof on it moves too. Build these FIRST. The alternative, which is what a house built by hand here actually did, is four parameters called ffl, lvl1, deck and roof_z with every z in the model typed against one of them - and no way afterwards to tell which numbers meant "first floor" from which merely equalled it. Construction geometry: a flat datum square, never a body, never exported.',
18264
+ fields: [
18265
+ expr("elevation", "Height of this level, in document units. 0 is usually the ground floor.", true),
18266
+ expr("size", "Side of the datum square that shows where it is. Display only.", false, "5000 - a five-metre square, because 100 mm is invisible in a building")
18267
+ ]
18268
+ },
18269
+ wall: {
18270
+ kind: "wall",
18271
+ summary: 'A wall from a plan centreline: mitred at every corner, extruded between two elevations. A closed path builds outer AND inner faces, so four points and one feature is a room rather than a solid block - which by hand is a sketch, an extrude, a second sketch and a boolean, and comes out with a notch on the inside of every corner because an offset that butts the corners cannot close them. Base and top are ELEVATIONS, not heights, so base: level("ground"), top: level("first") is a storey and stays one when the level moves. This is the feature that turns a building into twenty features instead of a hundred and thirty-seven.',
18272
+ fields: [
18273
+ {
18274
+ name: "path",
18275
+ type: "exprVec3[]",
18276
+ required: true,
18277
+ describe: 'The centreline in plan, as [x, y, z] points. The z of every point is IGNORED - "base" and "top" set the height. Repeating the first point at the end is fine and is dropped.'
18278
+ },
18279
+ {
18280
+ name: "closed",
18281
+ type: "boolean",
18282
+ describe: "Close the path back to its first point. A closed path builds two loops, an outer and an inner, so the wall encloses a room.",
18283
+ default: "false"
18284
+ },
18285
+ expr("base", 'Elevation of the underside. Write level("ground") to name one.', false, "0"),
18286
+ expr("top", 'Elevation of the top - an ELEVATION, not a height. level("first") + 2700, or an absolute number.', true),
18287
+ expr("thickness", "Wall thickness, across the centreline.", true),
18288
+ {
18289
+ name: "justification",
18290
+ type: "enum",
18291
+ values: ["centre", "left", "right"],
18292
+ describe: `Which side of the path the wall lies on. "left" is left of the direction of travel, so the path is then the wall's own face rather than its middle.`,
18293
+ default: "centre"
18294
+ }
18295
+ ]
18296
+ },
18297
+ opening: {
18298
+ kind: "opening",
18299
+ summary: `A door or window hole, cut through a wall and positioned ALONG that wall's own centreline. There is no x, y or z to get wrong: "along" is how far up the wall it sits, and the sill is measured from the wall's base, so an opening OUTSIDE its wall is not something this feature can express. That is the point of it - a helper placing eighteen windows by coordinate here mis-parenthesised a depth, put every opening on two of four walls outside the wall, and it was found three scripts later in a render. One feature per opening rather than five (cut box, frame, ring, boolean, glass), and it re-solves: move the wall or the level and every opening in it goes too. For a second opening in the same wall, host it on the FIRST opening - a cut replaces the body it cuts.`,
18300
+ fields: [
18301
+ {
18302
+ name: "host",
18303
+ type: "featureId",
18304
+ required: true,
18305
+ describe: "The wall to cut, or the previous opening in the same wall. The path is always read from the wall at the end of that chain."
18306
+ },
18307
+ expr("along", "Distance from the FIRST point of the host wall's path, measured along its centreline. Refused past the end of the path, or past the corner of the straight run the opening sits on.", true),
18308
+ expr("width", "Width along the wall.", true),
18309
+ expr("height", "Height from the sill to the head.", true),
18310
+ expr("sillHeight", "Height of the sill above the WALL'S BASE. 0 is a doorway; 900 is a typical window.", false, "0 - a doorway"),
18311
+ expr("clearance", "How far the cut runs past each face of the wall, so the boolean does not end exactly on a face.", false, "1 mm, or 2% of the wall thickness if that is more")
18312
+ ]
18313
+ },
18314
+ slab: {
18315
+ kind: "slab",
18316
+ summary: 'A floor, terrace or roof deck: a plan shape given a thickness, hung BELOW its level. That is deliberate and it is what makes a level worth having - the level is the finished floor, the surface you stand on and the elevation every wall on that storey takes as its base, with the structure underneath where it is on site. Give "boundary" as plan points or "profile" as a sketch already drawn in plan, not both.',
18317
+ fields: [
18318
+ {
18319
+ name: "boundary",
18320
+ type: "exprVec3[]",
18321
+ describe: 'The outline in plan, as [x, y, z] points; z is ignored. Give this or "profile", not both.',
18322
+ default: 'take the shape from "profile"'
18323
+ },
18324
+ {
18325
+ name: "profile",
18326
+ type: "featureId",
18327
+ describe: 'A sketch already drawn in plan, as an alternative to "boundary". It is moved to the level; its own height does not matter.',
18328
+ default: 'take the shape from "boundary"'
18329
+ },
18330
+ expr("level", 'The finished floor level: the TOP of the slab. Write level("first").', true),
18331
+ expr("thickness", "How deep the slab is.", true),
18332
+ expr("offset", "Moves the top face off the level. Positive is up.", false, "0 - the slab hangs down from the level, so the level is the floor you stand on")
18333
+ ]
18334
+ },
18335
+ roof: {
18336
+ kind: "roof",
18337
+ summary: 'A pitched roof over a footprint, solved as the LOWER ENVELOPE of one plane per pitched eave: z = base + the distance in from the nearest eave, times tan(pitch). Hip, gable, shed and pyramid are all the same feature - mark an edge "gable" and it stands vertical instead of pitching. Built by hand as a subtraction ("cut everything above a plane rising from the east eave") a 17.2 degree hip on a 7.2 m wing removed the ENTIRE roof and reported success; an intersection cannot do that, because the result is a subset of every plane by construction, so too shallow a pitch gives a low roof and never an absent one. v1 solves convex footprints exactly. A non-convex one still builds a valid solid inside its footprint and warns that a wing may be roofed by a plane from the far side of the building - check the ridges, or build the wings as separate roofs and fuse them.',
18338
+ fields: [
18339
+ {
18340
+ name: "boundary",
18341
+ type: "exprVec3[]",
18342
+ required: true,
18343
+ describe: 'The footprint in plan, as [x, y, z] points; z is ignored and "base" is the eave height. Either direction round is fine.'
18344
+ },
18345
+ expr("base", 'Elevation of the eave - where the roof starts. level("eaves"), or the top of the wall it sits on.', true),
18346
+ expr("pitch", 'The angle every edge rises at, unless "edges" says otherwise. 30 deg is a common tiled pitch.', true),
18347
+ {
18348
+ name: "edges",
18349
+ type: "string[]",
18350
+ describe: 'Per-edge override. Entry i is the edge from boundary point i to point i+1: an angle such as "40 deg", or the word "gable" for an end that stands vertical instead of pitching. An empty string takes the default pitch, and a short list leaves the rest at the default. All four gable is refused - a roof with no pitched edge has no top.',
18351
+ default: 'every edge takes "pitch", which is a hip'
18352
+ },
18353
+ expr("overhang", "How far the eave stands out past the footprint. The pitch planes rise from the overhung line, which is where a real roof starts.", false, "0"),
18354
+ expr("thickness", "Plumb thickness, measured VERTICALLY. On a 30 degree pitch the perpendicular depth is 0.87 of this.", true)
18355
+ ]
18356
+ },
17665
18357
  freeze: {
17666
18358
  kind: "freeze",
17667
18359
  summary: "Stores a solved body so it is read back instead of rebuilt. The stored copy carries the persistent names its faces and edges had when it was frozen, so selections made before the freeze go on resolving; without a stored body it is a pass-through.",
@@ -18034,6 +18726,96 @@ var FEATURE_ALIASES = {
18034
18726
  roller: { form: "rename", to: "rollerDiameter" },
18035
18727
  bore: { form: "rename", to: "boreDiameter" }
18036
18728
  },
18729
+ /* The architectural family. Every alias here is a name from a floor plan or
18730
+ * from Revit and ArchiCAD, which is where a caller reaching for these is
18731
+ * coming from - and the two that matter most are the two that are not
18732
+ * renames at all: `height` on a wall and on an opening mean different things,
18733
+ * and only one of them is a distance. */
18734
+ level: {
18735
+ // A level is an ELEVATION, and every other word for it is a height.
18736
+ height: { form: "rename", to: "elevation" },
18737
+ z: { form: "rename", to: "elevation" },
18738
+ at: { form: "rename", to: "elevation" }
18739
+ },
18740
+ wall: {
18741
+ /* The one that would silently build a wall from the ground to 2.7 m no
18742
+ * matter which storey it was meant for. `top` is an ELEVATION, so a height
18743
+ * given here has to be added to the base rather than renamed onto it -
18744
+ * there is no alias form that does arithmetic, so this one teaches. */
18745
+ height: {
18746
+ form: "rename",
18747
+ to: "top",
18748
+ note: '"top" is an ELEVATION, not a height: write base + the height you meant, or level("first") for the storey above.'
18749
+ },
18750
+ t: { form: "rename", to: "thickness" },
18751
+ width: { form: "rename", to: "thickness" },
18752
+ depth: { form: "rename", to: "thickness" },
18753
+ centreline: { form: "rename", to: "path" },
18754
+ centerline: { form: "rename", to: "path" },
18755
+ points: { form: "rename", to: "path" },
18756
+ line: { form: "rename", to: "path" },
18757
+ bottom: { form: "rename", to: "base" },
18758
+ baseLevel: { form: "rename", to: "base" },
18759
+ topLevel: { form: "rename", to: "top" },
18760
+ align: { form: "rename", to: "justification" },
18761
+ alignment: { form: "rename", to: "justification" },
18762
+ justify: { form: "rename", to: "justification" }
18763
+ },
18764
+ opening: {
18765
+ wall: { form: "rename", to: "host" },
18766
+ input: { form: "rename", to: "host" },
18767
+ distance: { form: "rename", to: "along" },
18768
+ offset: { form: "rename", to: "along" },
18769
+ position: {
18770
+ form: "rename",
18771
+ to: "along",
18772
+ note: `An opening has no x, y or z. "along" is the distance up the host wall's own centreline, which is what makes an opening outside its wall inexpressible.`
18773
+ },
18774
+ sill: { form: "rename", to: "sillHeight" },
18775
+ sillLevel: {
18776
+ form: "rename",
18777
+ to: "sillHeight",
18778
+ note: `"sillHeight" is measured UP FROM THE WALL'S BASE, not from the ground.`
18779
+ },
18780
+ headHeight: {
18781
+ form: "rename",
18782
+ to: "height",
18783
+ note: "The head is sillHeight + height; there is no separate field for it."
18784
+ }
18785
+ },
18786
+ slab: {
18787
+ // Named for the two features either side of it: a roof takes `base`, a
18788
+ // level takes `elevation`, and a slab takes `level`.
18789
+ elevation: { form: "rename", to: "level" },
18790
+ base: { form: "rename", to: "level" },
18791
+ top: { form: "rename", to: "level" },
18792
+ z: { form: "rename", to: "level" },
18793
+ outline: { form: "rename", to: "boundary" },
18794
+ points: { form: "rename", to: "boundary" },
18795
+ path: { form: "rename", to: "boundary" },
18796
+ sketch: { form: "rename", to: "profile" },
18797
+ t: { form: "rename", to: "thickness" },
18798
+ depth: { form: "rename", to: "thickness" }
18799
+ },
18800
+ roof: {
18801
+ pitchAngle: { form: "rename", to: "pitch" },
18802
+ slope: { form: "rename", to: "pitch" },
18803
+ angle: { form: "rename", to: "pitch" },
18804
+ outline: { form: "rename", to: "boundary" },
18805
+ footprint: { form: "rename", to: "boundary" },
18806
+ points: { form: "rename", to: "boundary" },
18807
+ eave: { form: "rename", to: "base" },
18808
+ eaveLevel: { form: "rename", to: "base" },
18809
+ eaves: { form: "rename", to: "base" },
18810
+ overhangs: { form: "rename", to: "overhang" },
18811
+ soffit: { form: "rename", to: "overhang" },
18812
+ t: { form: "rename", to: "thickness" },
18813
+ gables: {
18814
+ form: "rename",
18815
+ to: "edges",
18816
+ note: '"edges" is one entry per boundary edge: an angle, or the word "gable".'
18817
+ }
18818
+ },
18037
18819
  component: {
18038
18820
  origin: { form: "rename", to: "position" },
18039
18821
  translate: { form: "rename", to: "position" }
@@ -18829,7 +19611,7 @@ var MATERIAL_LIBRARY = [
18829
19611
  preset("carbon-fibre", "Carbon fibre", [0.05, 0.052, 0.056], 0, 0.26, "Other")
18830
19612
  ];
18831
19613
  function materialById(id) {
18832
- return MATERIAL_LIBRARY.find((m) => m.id === id);
19614
+ return MATERIAL_LIBRARY.find((m2) => m2.id === id);
18833
19615
  }
18834
19616
 
18835
19617
  // ../material/dist/lzma.js
@@ -19794,18 +20576,18 @@ var PROCEDURAL_HOOK = [
19794
20576
  var clamp01 = (value) => value < 0 ? 0 : value > 1 ? 1 : value;
19795
20577
  var colour = (value, fallback) => value && value.length >= 3 ? [clamp01(value[0]), clamp01(value[1]), clamp01(value[2])] : fallback;
19796
20578
  var range = (value, lo, hi) => typeof value === "number" && Number.isFinite(value) ? value < lo ? lo : value > hi ? hi : value : void 0;
19797
- function optionalSurface(m) {
19798
- const emissive = m.emissive ? colour(m.emissive, [0, 0, 0]) : void 0;
20579
+ function optionalSurface(m2) {
20580
+ const emissive = m2.emissive ? colour(m2.emissive, [0, 0, 0]) : void 0;
19799
20581
  return {
19800
20582
  ...emissive ? { emissive } : {},
19801
- ...pick("emissiveStrength", range(m.emissiveStrength, 0, 1e3)),
19802
- ...pick("transmission", m.transmission === void 0 ? void 0 : clamp01(m.transmission)),
19803
- ...pick("ior", range(m.ior, 1.0001, 2.5)),
19804
- ...pick("anisotropy", m.anisotropy === void 0 ? void 0 : clamp01(m.anisotropy)),
19805
- ...pick("anisotropyRotation", range(m.anisotropyRotation, -Math.PI * 2, Math.PI * 2)),
19806
- ...m.attenuationColor ? { attenuationColor: colour(m.attenuationColor, [1, 1, 1]) } : {},
19807
- ...pick("attenuationDistance", range(m.attenuationDistance, 1e-3, 1e6)),
19808
- ...pick("subsurface", m.subsurface === void 0 ? void 0 : clamp01(m.subsurface))
20583
+ ...pick("emissiveStrength", range(m2.emissiveStrength, 0, 1e3)),
20584
+ ...pick("transmission", m2.transmission === void 0 ? void 0 : clamp01(m2.transmission)),
20585
+ ...pick("ior", range(m2.ior, 1.0001, 2.5)),
20586
+ ...pick("anisotropy", m2.anisotropy === void 0 ? void 0 : clamp01(m2.anisotropy)),
20587
+ ...pick("anisotropyRotation", range(m2.anisotropyRotation, -Math.PI * 2, Math.PI * 2)),
20588
+ ...m2.attenuationColor ? { attenuationColor: colour(m2.attenuationColor, [1, 1, 1]) } : {},
20589
+ ...pick("attenuationDistance", range(m2.attenuationDistance, 1e-3, 1e6)),
20590
+ ...pick("subsurface", m2.subsurface === void 0 ? void 0 : clamp01(m2.subsurface))
19809
20591
  };
19810
20592
  }
19811
20593
  var pick = (key, value) => value === void 0 ? {} : { [key]: value };
@@ -19874,7 +20656,7 @@ var sub2 = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
19874
20656
  var dot4 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
19875
20657
  var norm = (a) => Math.hypot(a[0], a[1], a[2]);
19876
20658
  var scale2 = (a, s) => [a[0] * s, a[1] * s, a[2] * s];
19877
- function unit2(a) {
20659
+ function unit3(a) {
19878
20660
  const l = norm(a);
19879
20661
  return l < 1e-12 || !Number.isFinite(l) ? null : [a[0] / l, a[1] / l, a[2] / l];
19880
20662
  }
@@ -19899,7 +20681,7 @@ function axialFaces(t) {
19899
20681
  const kind = SURFACE[face.surfaceType];
19900
20682
  if (kind !== "cylinder" && kind !== "cone" && kind !== "torus")
19901
20683
  continue;
19902
- const dir = unit2(face.axisDir);
20684
+ const dir = unit3(face.axisDir);
19903
20685
  if (dir)
19904
20686
  out.push({ face, dir });
19905
20687
  }
@@ -19910,7 +20692,7 @@ function planarFaces(t) {
19910
20692
  for (const face of t.faces) {
19911
20693
  if (!face.planar)
19912
20694
  continue;
19913
- const normal = unit2(face.normal) ?? unit2(face.axisDir);
20695
+ const normal = unit3(face.normal) ?? unit3(face.axisDir);
19914
20696
  if (normal)
19915
20697
  out.push({ face, normal });
19916
20698
  }
@@ -20027,12 +20809,12 @@ var PINS = { closed: 3, tube: 2, plane: 1 };
20027
20809
  var RESIDUAL_TIE = 1e-9;
20028
20810
  function residualAt(face, p) {
20029
20811
  if (face.planar) {
20030
- const n = unit2(face.normal) ?? unit2(face.axisDir);
20812
+ const n = unit3(face.normal) ?? unit3(face.axisDir);
20031
20813
  return n ? { value: Math.abs(dot4(sub2(p, face.centroid), n)), pins: PINS.plane } : null;
20032
20814
  }
20033
20815
  const kind = SURFACE[face.surfaceType];
20034
20816
  if (kind === "cylinder" && face.radius1 > 0) {
20035
- const d = unit2(face.axisDir);
20817
+ const d = unit3(face.axisDir);
20036
20818
  return d ? {
20037
20819
  value: Math.abs(norm(reject2(sub2(p, face.axisOrigin), d)) - face.radius1),
20038
20820
  pins: PINS.tube
@@ -20042,7 +20824,7 @@ function residualAt(face, p) {
20042
20824
  return { value: Math.abs(norm(sub2(p, face.axisOrigin)) - face.radius1), pins: PINS.closed };
20043
20825
  }
20044
20826
  if (kind === "torus" && face.radius1 > 0 && face.radius2 > 0) {
20045
- const d = unit2(face.axisDir);
20827
+ const d = unit3(face.axisDir);
20046
20828
  if (!d)
20047
20829
  return null;
20048
20830
  const rel = sub2(p, face.axisOrigin);
@@ -20089,7 +20871,7 @@ function isFit(a, b, alignments, tolMm) {
20089
20871
  for (const alignment of alignments) {
20090
20872
  if (alignment.kind !== "concentric" || Math.abs(alignment.offset) > tolMm)
20091
20873
  continue;
20092
- const axis = unit2(faceNamed(a, alignment.faces[0])?.axisDir ?? [0, 0, 0]);
20874
+ const axis = unit3(faceNamed(a, alignment.faces[0])?.axisDir ?? [0, 0, 0]);
20093
20875
  if (!axis)
20094
20876
  continue;
20095
20877
  const [aLow, aHigh] = spanAlong(a.bbox, axis);
@@ -20164,10 +20946,10 @@ function faceCover(face) {
20164
20946
  outer = face.radius1 + face.radius2;
20165
20947
  } else
20166
20948
  return [face.bbox];
20167
- const d = unit2(face.axisDir);
20949
+ const d = unit3(face.axisDir);
20168
20950
  if (!d)
20169
20951
  return [face.bbox];
20170
- const e1 = unit2(reject2(Math.abs(d[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0], d));
20952
+ const e1 = unit3(reject2(Math.abs(d[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0], d));
20171
20953
  if (!e1)
20172
20954
  return [face.bbox];
20173
20955
  const e2 = [
@@ -22424,7 +23206,7 @@ var Document = class _Document {
22424
23206
  continue;
22425
23207
  if (state.feature.construction)
22426
23208
  continue;
22427
- if (state.feature.kind === "sketch" || state.feature.kind === "plane" || state.feature.kind === "axis")
23209
+ if (state.feature.kind === "sketch" || state.feature.kind === "plane" || state.feature.kind === "axis" || state.feature.kind === "level")
22428
23210
  continue;
22429
23211
  bodies.push({
22430
23212
  featureId: id,
@@ -22855,6 +23637,13 @@ var Document = class _Document {
22855
23637
  }
22856
23638
  return state.shape;
22857
23639
  },
23640
+ featureOf(featureId) {
23641
+ const state = self2.features.get(featureId);
23642
+ if (!state) {
23643
+ throw new KernelError(-3, `feature '${featureId}' does not exist`, "", "input");
23644
+ }
23645
+ return state.feature;
23646
+ },
22858
23647
  // One decode path, not two. A second copy of the caching here meant every
22859
23648
  // topology a FEATURE read skipped the signature ledger, so the one thing
22860
23649
  // a heal report needs was recorded for queries and not for the fillets
@@ -22976,7 +23765,7 @@ var Document = class _Document {
22976
23765
  throw new KernelError(-8, resolved2.note ?? `no ${entity} selected on '${bodyFeatureId}'`, "selection", "axis");
22977
23766
  }
22978
23767
  const wanted = new Set(resolved2.names);
22979
- const unit3 = (v) => {
23768
+ const unit4 = (v) => {
22980
23769
  const n = Math.hypot(v[0], v[1], v[2]) || 1;
22981
23770
  return [v[0] / n, v[1] / n, v[2] / n];
22982
23771
  };
@@ -22988,11 +23777,11 @@ var Document = class _Document {
22988
23777
  if (edge.curveType === CurveType.Line) {
22989
23778
  return {
22990
23779
  origin: edge.midpoint,
22991
- direction: unit3([edge.end[0] - edge.start[0], edge.end[1] - edge.start[1], edge.end[2] - edge.start[2]])
23780
+ direction: unit4([edge.end[0] - edge.start[0], edge.end[1] - edge.start[1], edge.end[2] - edge.start[2]])
22992
23781
  };
22993
23782
  }
22994
23783
  if (edge.radius > 0)
22995
- return { origin: edge.axisOrigin, direction: unit3(edge.axisDir) };
23784
+ return { origin: edge.axisOrigin, direction: unit4(edge.axisDir) };
22996
23785
  throw new KernelError(-10, "that edge is neither straight nor circular, so it defines no axis", "geometry", "axis");
22997
23786
  }
22998
23787
  const matches = topology.faces.filter((f) => wanted.has(f.name));
@@ -23001,12 +23790,12 @@ var Document = class _Document {
23001
23790
  throw new KernelError(-8, `the selected face is not present on '${bodyFeatureId}'`, "selection", "axis");
23002
23791
  }
23003
23792
  if (face.planar)
23004
- return { origin: face.centroid, direction: unit3(face.normal) };
23793
+ return { origin: face.centroid, direction: unit4(face.normal) };
23005
23794
  const spun = Math.hypot(face.axisDir[0], face.axisDir[1], face.axisDir[2]) > 1e-9;
23006
23795
  if (!spun) {
23007
23796
  throw new KernelError(-10, "that face has no axis of its own - pick a cylinder, a cone or a flat face", "geometry", "axis");
23008
23797
  }
23009
- return { origin: face.axisOrigin, direction: unit3(face.axisDir) };
23798
+ return { origin: face.axisOrigin, direction: unit4(face.axisDir) };
23010
23799
  },
23011
23800
  sizeOf(featureId) {
23012
23801
  const topology = this.topologyOf(this.shapeOf(featureId));
@@ -23081,12 +23870,12 @@ var Document = class _Document {
23081
23870
  const props = this.geometryProps;
23082
23871
  const measured = (id) => {
23083
23872
  const shape = shapeOf(id);
23084
- let m = props.get(shape);
23085
- if (!m) {
23086
- m = this.kernel.massProperties(shape);
23087
- props.set(shape, m);
23873
+ let m2 = props.get(shape);
23874
+ if (!m2) {
23875
+ m2 = this.kernel.massProperties(shape);
23876
+ props.set(shape, m2);
23088
23877
  }
23089
- return m;
23878
+ return m2;
23090
23879
  };
23091
23880
  const active = new Set(this.order.slice(0, this.rollbackIndex ?? this.order.length));
23092
23881
  const shapeOf = (id) => {
@@ -24296,7 +25085,7 @@ var DESIGN_GUIDE = [
24296
25085
  id: "index",
24297
25086
  title: "Obenor3D design knowledge base",
24298
25087
  when: "Start here",
24299
- text: '# Obenor3D design knowledge base\n\nDistilled from five CAD textbooks and translated into Obenor3D\'s feature kinds\nand MCP tools. Written for the MCP server and the in-app agent: every recipe\nnames the tool to call, and flags where Obenor3D has no equivalent.\n\nSources, cited in each file by short name:\n\n- **Land** \u2014 K. M. Land, *Improving CAD Designs with Autodesk Fusion 360* (2023)\n- **Barr** \u2014 Barr, Juricic, Krueger, Reyes, *Design Workbook Using SOLIDWORKS 2024*\n- **Shih** \u2014 Shih & Schilling, *Parametric Modeling with SOLIDWORKS 2024*\n- **Tran** \u2014 L. Tran, *Mastering Surface Modeling with SOLIDWORKS 2024*\n- **Almattar** \u2014 T. Almattar, *Learn SOLIDWORKS 2025*, 3rd ed.\n\n## Files\n\n| File | Read it when |\n|---|---|\n| [01-design-intent.md](01-design-intent.md) | Before the first feature: planning the tree, base feature, what should flex |\n| [02-sketching.md](02-sketching.md) | Building any profile: relations, dimensions, fully defined, construction geometry |\n| [03-features-and-order.md](03-features-and-order.md) | Extrude/revolve/sweep/loft/shell/draft, end conditions, patterns, parent/child |\n| [04-fillets-and-blends.md](04-fillets-and-blends.md) | Dress-up: fillets, chamfers, order, failures |\n| [05-assemblies-and-joints.md](05-assemblies-and-joints.md) | More than one part: components, grounding, joints, interference, fits |\n| [06-sheet-metal.md](06-sheet-metal.md) | Flanges, bends, K-factor, flat pattern |\n| [07-surfacing.md](07-surfacing.md) | When a solid feature cannot make a face: continuity, loft, fill, knit, thicken |\n| [08-common-parts-recipes.md](08-common-parts-recipes.md) | Bracket, plate, pulley, housing, shaft, tray, hook, knob \u2014 step by step |\n| [09-checking-a-design.md](09-checking-a-design.md) | Before saying done: measure, mass, render, interference, sketch status |\n\n## The ten rules every book agrees on\n\n1. **Plan the tree before the first feature.** Count the features, pick the base\n feature, order them as a machinist would. (Shih ch.3-4)\n2. **Base sketch on a datum, centred on the origin.** Symmetry and later mirrors\n come free. (Barr Ex 3.1, Shih ch.7)\n3. **Shape before size.** Rough curves, then relations, then dimensions, then\n `sketch_status` says zero freedom. (Shih ch.2, 5; Almattar ch.3)\n4. **Relations say how it flexes; dimensions say how big.** A hole at a midpoint\n moves with the plate; a hole at 20 mm does not. Choose deliberately. (Land ch.3)\n5. **Name the numbers.** Parameters and expressions, not literals repeated.\n (Shih ch.5, Almattar ch.13, Land ch.9)\n6. **End conditions are intent.** `throughAll` for a hole, `toFace` for a pocket\n floor, `symmetric` for anything centred. (Shih ch.3, Land ch.10)\n7. **Features that carry meaning.** `hole`, `thread`, `flange`, `mirror`,\n `pattern*` \u2014 never a cylinder and a boolean. (Shih ch.3, Almattar ch.12)\n8. **Dress-up last**: draft \u2192 outer fillets \u2192 shell \u2192 inner features \u2192 small\n fillets/chamfers. (Barr Ex 4.1, Shih ch.12)\n9. **One component per part; ground the frame first; mate in assembly order.**\n (Land ch.3, Barr Lab 5, Shih ch.14)\n10. **Verify, then say done**: render three views, measure, mass, interference,\n every sketch fully defined. (Barr Lab 6, Tran ch.1, Almattar ch.15)\n\n## Obenor3D vocabulary map (SolidWorks / Fusion \u2192 here)\n\n| Elsewhere | Obenor3D |\n|---|---|\n| Front / Top / Right plane | `base: "xy"` / `"xz"` / `"yz"` on `add_sketch`, `add_plane` |\n| Smart Dimension, Add Relation | `add_dimension`, `add_constraint`; `sketch.constrained` on `add_sketch` |\n| Fully Defined | `sketch_status` reports 0 degrees of freedom |\n| Global variable / user parameter | `set_parameters` |\n| Extruded Boss / Cut | `add_extrude` with `operation` fuse / cut / new body |\n| Up to Surface | `mode: "toFace"` (planar faces only) |\n| Through All | `mode: "throughAll"` |\n| Mid Plane | `mode: "symmetric"` |\n| Hole Wizard | `add_hole` (simple / counterbore / countersink) + `add_thread` for tapped |\n| Mirror Feature / Body | `mirror` (bodies) |\n| Linear / Circular Pattern | `add_pattern` type linear / circular (bodies) |\n| Shell, Draft | `add_shell`, `draft` via `add_feature` |\n| Convert Entities / Project | `sketch.projected` on `add_sketch`; `project_geometry` |\n| Offset Entities | `offset_curve` (writes a new sketch) |\n| Knit, Thicken, Fill, Trim | `add_stitch`, `thicken`, `fillFace`, `add_trim_surface` |\n| Mate | `add_joint` (rigid, revolute, slider, cylindrical, pinSlot, planar, ball) |\n| Fix / ground component | `grounded: true` on `component` / `instance` |\n| Interference Detection | `check_interference`, `expect` feature |\n| Mass Properties | `measure` with `density` |\n| Edge Flange / Sketched Bend / Flatten | `add_flange`, `add_bend`, `flat_pattern` |\n| Rollback bar / Suppress | `set_timeline`, `edit.suppress`, `freeze` |\n| Rib, Dome, Wrap, Boundary Surface, Configurations, Design Table | **none** \u2014 see [textbook-gaps.md](../textbook-gaps.md) |'
25088
+ text: '# Obenor3D design knowledge base\n\nDistilled from five CAD textbooks and translated into Obenor3D\'s feature kinds\nand MCP tools. Written for the MCP server and the in-app agent: every recipe\nnames the tool to call, and flags where Obenor3D has no equivalent.\n\nSources, cited in each file by short name:\n\n- **Land** \u2014 K. M. Land, *Improving CAD Designs with Autodesk Fusion 360* (2023)\n- **Barr** \u2014 Barr, Juricic, Krueger, Reyes, *Design Workbook Using SOLIDWORKS 2024*\n- **Shih** \u2014 Shih & Schilling, *Parametric Modeling with SOLIDWORKS 2024*\n- **Tran** \u2014 L. Tran, *Mastering Surface Modeling with SOLIDWORKS 2024*\n- **Almattar** \u2014 T. Almattar, *Learn SOLIDWORKS 2025*, 3rd ed.\n\n## Files\n\n| File | Read it when |\n|---|---|\n| [01-design-intent.md](01-design-intent.md) | Before the first feature: planning the tree, base feature, what should flex |\n| [02-sketching.md](02-sketching.md) | Building any profile: relations, dimensions, fully defined, construction geometry |\n| [03-features-and-order.md](03-features-and-order.md) | Extrude/revolve/sweep/loft/shell/draft, end conditions, patterns, parent/child |\n| [04-fillets-and-blends.md](04-fillets-and-blends.md) | Dress-up: fillets, chamfers, order, failures |\n| [05-assemblies-and-joints.md](05-assemblies-and-joints.md) | More than one part: components, grounding, joints, interference, fits |\n| [06-sheet-metal.md](06-sheet-metal.md) | Flanges, bends, K-factor, flat pattern |\n| [07-surfacing.md](07-surfacing.md) | When a solid feature cannot make a face: continuity, loft, fill, knit, thicken |\n| [08-common-parts-recipes.md](08-common-parts-recipes.md) | Bracket, plate, pulley, housing, shaft, tray, hook, knob \u2014 step by step |\n| [09-checking-a-design.md](09-checking-a-design.md) | Before saying done: measure, mass, render, interference, sketch status |\n| [10-nurbs-and-freeform.md](10-nurbs-and-freeform.md) | Poles, degree, continuity: control nets, curves in space, matching, rebuilding |\n| [11-architecture.md](11-architecture.md) | Massing a building: levels, walls, openings, slabs, roofs \u2014 and the order they go in |\n\n## The ten rules every book agrees on\n\n1. **Plan the tree before the first feature.** Count the features, pick the base\n feature, order them as a machinist would. (Shih ch.3-4)\n2. **Base sketch on a datum, centred on the origin.** Symmetry and later mirrors\n come free. (Barr Ex 3.1, Shih ch.7)\n3. **Shape before size.** Rough curves, then relations, then dimensions, then\n `sketch_status` says zero freedom. (Shih ch.2, 5; Almattar ch.3)\n4. **Relations say how it flexes; dimensions say how big.** A hole at a midpoint\n moves with the plate; a hole at 20 mm does not. Choose deliberately. (Land ch.3)\n5. **Name the numbers.** Parameters and expressions, not literals repeated.\n (Shih ch.5, Almattar ch.13, Land ch.9)\n6. **End conditions are intent.** `throughAll` for a hole, `toFace` for a pocket\n floor, `symmetric` for anything centred. (Shih ch.3, Land ch.10)\n7. **Features that carry meaning.** `hole`, `thread`, `flange`, `mirror`,\n `pattern*` \u2014 never a cylinder and a boolean. (Shih ch.3, Almattar ch.12)\n8. **Dress-up last**: draft \u2192 outer fillets \u2192 shell \u2192 inner features \u2192 small\n fillets/chamfers. (Barr Ex 4.1, Shih ch.12)\n9. **One component per part; ground the frame first; mate in assembly order.**\n (Land ch.3, Barr Lab 5, Shih ch.14)\n10. **Verify, then say done**: render three views, measure, mass, interference,\n every sketch fully defined. (Barr Lab 6, Tran ch.1, Almattar ch.15)\n\n## Obenor3D vocabulary map (SolidWorks / Fusion \u2192 here)\n\n| Elsewhere | Obenor3D |\n|---|---|\n| Front / Top / Right plane | `base: "xy"` / `"xz"` / `"yz"` on `add_sketch`, `add_plane` |\n| Smart Dimension, Add Relation | `add_dimension`, `add_constraint`; `sketch.constrained` on `add_sketch` |\n| Fully Defined | `sketch_status` reports 0 degrees of freedom |\n| Global variable / user parameter | `set_parameters` |\n| Extruded Boss / Cut | `add_extrude` with `operation` fuse / cut / new body |\n| Up to Surface | `mode: "toFace"` (planar faces only) |\n| Through All | `mode: "throughAll"` |\n| Mid Plane | `mode: "symmetric"` |\n| Hole Wizard | `add_hole` (simple / counterbore / countersink) + `add_thread` for tapped |\n| Mirror Feature / Body | `mirror` (bodies) |\n| Linear / Circular Pattern | `add_pattern` type linear / circular (bodies) |\n| Shell, Draft | `add_shell`, `draft` via `add_feature` |\n| Convert Entities / Project | `sketch.projected` on `add_sketch`; `project_geometry` |\n| Offset Entities | `offset_curve` (writes a new sketch) |\n| Knit, Thicken, Fill, Trim | `add_stitch`, `thicken`, `fillFace`, `add_trim_surface` |\n| Mate | `add_joint` (rigid, revolute, slider, cylindrical, pinSlot, planar, ball) |\n| Fix / ground component | `grounded: true` on `component` / `instance` |\n| Interference Detection | `check_interference`, `expect` feature |\n| Mass Properties | `measure` with `density` |\n| Edge Flange / Sketched Bend / Flatten | `add_flange`, `add_bend`, `flat_pattern` |\n| Rollback bar / Suppress | `set_timeline`, `edit.suppress`, `freeze` |\n| Rib, Dome, Wrap, Boundary Surface, Configurations, Design Table | **none** \u2014 see [textbook-gaps.md](../textbook-gaps.md) |'
24300
25089
  },
24301
25090
  {
24302
25091
  id: "design-intent",
@@ -24338,7 +25127,7 @@ var DESIGN_GUIDE = [
24338
25127
  id: "surfacing",
24339
25128
  title: "07 \u2014 Surfacing",
24340
25129
  when: "When a solid feature cannot make a face: continuity, loft, fill, knit, thicken",
24341
- text: '# 07 \u2014 Surfacing\n\nSource: Tran, *Mastering Surface Modeling* (ch.1 principles, ch.2-11\nrecipes); Barr Lab 4; Land ch.11-13 (FORM, no equivalent).\n\n## 1. When to use surfaces, and when not (Tran ch.1 \xA73-4, \xA77)\n\n- Shapes that extrude, sweep or loft **in one feature** should be solids.\n "Solid features take less time to make."\n- Surfaces are for the face a solid feature cannot make, built **one face at a\n time**, then knitted and thickened into a solid \u2014 "the final design should\n be a solid model" so it can carry material, be analysed, be drawn.\n- **Hybrid modelling** is the preferred method: a surface trims a solid, a\n surface replaces a face, a surface splits a solid into bodies.\n- Surfaces used only as construction are hidden when the model is complete.\n\nObenor3D reads the same way: `extrude`/`revolve` are solids;\n`sweep`/`loft { solid: false }` give surfaces; `ruledSurface`, `fillFace`,\n`surfaceFromPoints`, `projectCurve` are surface/curve makers; `stitchSurfaces`\n+ `thicken` go back to a solid; `split`, `trimSurface`, `splitFace` are the\nhybrid verbs.\n\n## 2. Continuity (Tran ch.1 \xA72, MCP instructions)\n\n| Grade | Meaning | Where it matters |\n|---|---|---|\n| C0 / G0 contact | share an edge | anything that must knit |\n| C1 / G1 tangent | tangent across the edge | fillets, most housings |\n| C2 / G2 curvature | same curvature across the edge | visible styled surfaces; a G1 join with a curvature step "draws a visible line in a reflection" |\n\n`analyze_surface` grades every join and reports `minRadius`; `query_geometry`\ncan answer G1 (a tangent edge); only `analyze_surface` answers G2. A loft\nthrough sketch stations is G1 at best across its seams. `fillFace\n{ continuity: "g1" | "g2" }` needs supporting faces to reach tangency.\n\n## 3. Check after every surface feature (Tran ch.1 \xA76)\n\nSurfaces are built face by face and "are not frequently verified like solid\nfeatures"; small gaps or overlaps stop the knit. Run `check_model` after each\nsurface feature, `inspect_body` to see whether a stitched shell closed\n(`stitchSurfaces.makeSolid` is *attempted*, not assumed \u2014 "a shell with a gap\nbehaves like a solid until the first boolean").\n\n## 4. The surface toolbox, mapped (Tran ch.2-6)\n\n| SolidWorks | Obenor3D | Notes |\n|---|---|---|\n| Extruded / Revolved surface | `sweep` or `loft` with `solid: false`; `ruledSurface` for an extrusion between two curves | no `solid` flag on extrude/revolve |\n| Swept surface | `sweep { solid: false, guide?, scaleEnd? }` | one guide rail |\n| Lofted surface + guide curves | `loft { solid: false }` | **no guide curves** \u2014 add stations |\n| Boundary surface (two-direction network, tangent to faces) | **none** | closest: `fillFace` with g1/g2, or `surfaceFromPoints` on a sampled grid |\n| Curve through reference points, 3D sketch spline | **none** (planar sketches + `helix`) | `surfaceFromPoints` takes a grid; `projectCurve` lays a planar curve on a body |\n| Planar surface | `fillFace` over a planar loop | |\n| Filled surface (4-edge non-planar patch) | `fillFace { boundary, continuity }` | Tran ch.11 patches a raised feature with 4 edges |\n| Knit | `add_stitch { inputs, tolerance, makeSolid }` | raise tolerance for imports |\n| Thicken | `thicken` | fillets after thickening (Tran ch.3) |\n| Trim surface (with sketch / surface / mutual) | `add_trim_surface` | one tool, one side kept; mutual = two calls |\n| Split line | `add_split_face { faces, tool }` or `projectCurve` then split | raised label areas (Tran ch.4) |\n| Delete face | `delete_faces` | heals by extending neighbours |\n| Offset surface | `offset` (whole body) / `offsetFaces` | no free-standing surface offset |\n| Face fillet with hold line, Deform, Flex | **none** | |\n| Intersect, Core/Cavity, Parting line/surface | `split { input, tools }` with a surface as tool; `transform` scale | no parting-line tool |\n| Check Entity, Zebra, Curvature comb | `check_model`, `analyze_surface`; UI `model.zebra`, `model.comb` | |\n\n## 5. Recipes\n\n### A. A styled housing (Tran ch.3, ch.5, ch.6)\n\n1. Two profile sketches on `add_plane { offset }` stations; a third for the flank.\n2. `loft { profiles, solid: false }` for the main skin; add stations where the\n loft would gap (Tran ch.3 "Comparing methods": loft gaps without guides).\n3. Cap the ends: `fillFace` over each end loop.\n4. `add_stitch { makeSolid: true }` \u2192 `inspect_body` says solid.\n5. `thicken { thickness: "wall" }` **or** keep solid and `shell`.\n6. Fillets after thickening; `analyze_surface` for G2 across visible seams.\n7. Split lines for a label: `projectCurve` a sketch onto the body,\n `add_split_face`, then `emboss_faces` the label area by 0.5 mm.\n\n### B. Repairing an imported surface model (Tran ch.11)\n\nMissing hole walls: `loft` between the two edge loops of the hole, keeping the\nconnector alignment (start both loops at corresponding vertices). Missing\n4-sided patch: `fillFace` with the 4 edges. Then `add_stitch`, `check_model`.\n\n### C. Mould core and cavity (Tran ch.10)\n\n1. Finish the part as a solid with draft (`draft`, `pullDirection`).\n2. Build the parting surface as a `ruledSurface` from the part\'s silhouette\n edges outward (no parting-line tool; find silhouette edges with\n `query_geometry`).\n3. Block: `add_primitive box` around the part; `add_boolean cut` the part\n (use `copy` to keep the part); `split { tools: [partingSurface] }` the\n block into core and cavity.\n4. `check_interference` core vs cavity vs part \u2192 zero shared volume.\n\n### D. Fairing a loft (Tran ch.1 spline handles; memory: station differencing)\n\nDifference the station values twice along the loft; a rate that dips and\nrecovers is a saddle. Add or move a station rather than nudging one.\nEvaluate with `analyze_surface`; look with `render_view` under zebra\n(UI `model.zebra`).\n\n## 6. Splines (Tran ch.1 \xA75)\n\nA spline\'s quality is in its handles: few through-points, tangency weighted at\nends, watch inflection points on the curvature comb. In Obenor3D `spline`\npasses through its points and `bezier` is control-point driven; prefer bezier\nfor styled curves and spline for measured data.\n\n## 7. Surfacing checklist\n\n- [ ] Could a solid feature make it? Then make it a solid\n- [ ] `check_model` after every surface feature\n- [ ] Knit reported solid by `inspect_body` before thicken/boolean\n- [ ] G1 minimum everywhere, G2 on visible styled joins (`analyze_surface`)\n- [ ] Construction surfaces removed or absorbed once the solid exists'
25130
+ text: '# 07 \u2014 Surfacing\n\nSource: Tran, *Mastering Surface Modeling* (ch.1 principles, ch.2-11\nrecipes); Barr Lab 4; Land ch.11-13 (FORM, no equivalent).\n\n**The geometry underneath is 10-nurbs-and-freeform.md.** This file is about\nwhen to reach for a surface and how to knit one back into a solid; that one is\nabout poles, degree, continuity and the tools that move them. Two rows of the\ntable below used to read "none" and no longer do \u2014 that file is where they went.\n\n## 1. When to use surfaces, and when not (Tran ch.1 \xA73-4, \xA77)\n\n- Shapes that extrude, sweep or loft **in one feature** should be solids.\n "Solid features take less time to make."\n- Surfaces are for the face a solid feature cannot make, built **one face at a\n time**, then knitted and thickened into a solid \u2014 "the final design should\n be a solid model" so it can carry material, be analysed, be drawn.\n- **Hybrid modelling** is the preferred method: a surface trims a solid, a\n surface replaces a face, a surface splits a solid into bodies.\n- Surfaces used only as construction are hidden when the model is complete.\n\nObenor3D reads the same way: `extrude`/`revolve` are solids;\n`sweep`/`loft { solid: false }` give surfaces; `ruledSurface`, `fillFace`,\n`surfaceFromPoints`, `projectCurve` are surface/curve makers; `stitchSurfaces`\n+ `thicken` go back to a solid; `split`, `trimSurface`, `splitFace` are the\nhybrid verbs.\n\n## 2. Continuity (Tran ch.1 \xA72, MCP instructions)\n\n| Grade | Meaning | Where it matters |\n|---|---|---|\n| C0 / G0 contact | share an edge | anything that must knit |\n| C1 / G1 tangent | tangent across the edge | fillets, most housings |\n| C2 / G2 curvature | same curvature across the edge | visible styled surfaces; a G1 join with a curvature step "draws a visible line in a reflection" |\n\n`analyze_surface` grades every join and reports `minRadius`; `query_geometry`\ncan answer G1 (a tangent edge); only `analyze_surface` answers G2. A loft\nthrough sketch stations is G1 at best across its seams. `fillFace\n{ continuity: "g1" | "g2" }` needs supporting faces to reach tangency.\n\n## 3. Check after every surface feature (Tran ch.1 \xA76)\n\nSurfaces are built face by face and "are not frequently verified like solid\nfeatures"; small gaps or overlaps stop the knit. Run `check_model` after each\nsurface feature, `inspect_body` to see whether a stitched shell closed\n(`stitchSurfaces.makeSolid` is *attempted*, not assumed \u2014 "a shell with a gap\nbehaves like a solid until the first boolean").\n\n## 4. The surface toolbox, mapped (Tran ch.2-6)\n\n| SolidWorks | Obenor3D | Notes |\n|---|---|---|\n| Extruded / Revolved surface | `sweep` or `loft` with `solid: false`; `ruledSurface` for an extrusion between two curves | no `solid` flag on extrude/revolve |\n| Swept surface | `sweep { solid: false, guide?, scaleEnd? }` | one guide rail |\n| Lofted surface + guide curves | `loft { solid: false }` | **no guide curves** \u2014 add stations |\n| Boundary surface (two-direction network, tangent to faces) | `add_blend_surface { a, edgeA, b, edgeB, continuity }`, or `fillFace` with g1/g2 | see 10-nurbs-and-freeform.md \xA74 |\n| Curve through reference points, 3D sketch spline | `add_curve { poles, degree, periodic }` \u2014 a NURBS curve in world space | plus `helix`, `projectCurve`, and `trim_curve`/`extend_curve`/`offset_curve`; see 10 \xA73 |\n| Planar surface | `fillFace` over a planar loop | |\n| Filled surface (4-edge non-planar patch) | `fillFace { boundary, continuity }` | Tran ch.11 patches a raised feature with 4 edges |\n| Knit | `add_stitch { inputs, tolerance, makeSolid }` | raise tolerance for imports |\n| Thicken | `thicken` | fillets after thickening (Tran ch.3) |\n| Trim surface (with sketch / surface / mutual) | `add_trim_surface` | one tool, one side kept; mutual = two calls |\n| Split line | `add_split_face { faces, tool }` or `projectCurve` then split | raised label areas (Tran ch.4) |\n| Delete face | `delete_faces` | heals by extending neighbours |\n| Offset surface | `offset` (whole body) / `offsetFaces` | no free-standing surface offset |\n| Face fillet with hold line, Deform, Flex | **none** | |\n| Intersect, Core/Cavity, Parting line/surface | `split { input, tools }` with a surface as tool; `transform` scale | no parting-line tool |\n| Check Entity, Zebra, Curvature comb | `check_model`, `analyze_surface`; UI `model.zebra`, `model.comb` | |\n\n## 5. Recipes\n\n### A. A styled housing (Tran ch.3, ch.5, ch.6)\n\n1. Two profile sketches on `add_plane { offset }` stations; a third for the flank.\n2. `loft { profiles, solid: false }` for the main skin; add stations where the\n loft would gap (Tran ch.3 "Comparing methods": loft gaps without guides).\n3. Cap the ends: `fillFace` over each end loop.\n4. `add_stitch { makeSolid: true }` \u2192 `inspect_body` says solid.\n5. `thicken { thickness: "wall" }` **or** keep solid and `shell`.\n6. Fillets after thickening; `analyze_surface` for G2 across visible seams.\n7. Split lines for a label: `projectCurve` a sketch onto the body,\n `add_split_face`, then `emboss_faces` the label area by 0.5 mm.\n\n### B. Repairing an imported surface model (Tran ch.11)\n\nMissing hole walls: `loft` between the two edge loops of the hole, keeping the\nconnector alignment (start both loops at corresponding vertices). Missing\n4-sided patch: `fillFace` with the 4 edges. Then `add_stitch`, `check_model`.\n\n### C. Mould core and cavity (Tran ch.10)\n\n1. Finish the part as a solid with draft (`draft`, `pullDirection`).\n2. Build the parting surface as a `ruledSurface` from the part\'s silhouette\n edges outward (no parting-line tool; find silhouette edges with\n `query_geometry`).\n3. Block: `add_primitive box` around the part; `add_boolean cut` the part\n (use `copy` to keep the part); `split { tools: [partingSurface] }` the\n block into core and cavity.\n4. `check_interference` core vs cavity vs part \u2192 zero shared volume.\n\n### D. Fairing a loft (Tran ch.1 spline handles; memory: station differencing)\n\nDifference the station values twice along the loft; a rate that dips and\nrecovers is a saddle. Add or move a station rather than nudging one.\nEvaluate with `analyze_surface`; look with `render_view` under zebra\n(UI `model.zebra`).\n\n## 6. Splines (Tran ch.1 \xA75)\n\nA spline\'s quality is in its handles: few through-points, tangency weighted at\nends, watch inflection points on the curvature comb. In Obenor3D `spline`\npasses through its points and `bezier` is control-point driven; prefer bezier\nfor styled curves and spline for measured data. Both are SKETCH entities and\ntherefore planar; `add_curve` is the same idea in world space, with its poles\nand degree given directly. See 10 \xA73 for the whole curve toolbox.\n\n## 7. Surfacing checklist\n\n- [ ] Could a solid feature make it? Then make it a solid\n- [ ] `check_model` after every surface feature\n- [ ] Knit reported solid by `inspect_body` before thicken/boolean\n- [ ] G1 minimum everywhere, G2 on visible styled joins (`analyze_surface`)\n- [ ] Construction surfaces removed or absorbed once the solid exists'
24342
25131
  },
24343
25132
  {
24344
25133
  id: "common-parts-recipes",
@@ -24351,10 +25140,77 @@ var DESIGN_GUIDE = [
24351
25140
  title: "09 \u2014 Checking a design before saying it is done",
24352
25141
  when: "Before saying done: measure, mass, render, interference, sketch status",
24353
25142
  text: '# 09 \u2014 Checking a design before saying it is done\n\nEvery book ends a tutorial by evaluating: measure, mass properties, a drawing,\nan interference check, and "does it update predictably". The agent prompt\'s\n`finish_check` runs most of it in one call; this file says what to read in\nthe result and what the books add.\n\n## 1. `finish_check` sections (prompt, in order)\n\n1. **`check_model` on every body** \u2014 BUILT is not SOUND. An open shell or a\n sliver still builds; the report says so. Back off the operation.\n2. **`render_view` iso / front / right** \u2014 a shape right in one view is\n routinely wrong in another. Add a `lookAt` close-up of any blend or fit.\n3. **`measure` volume and bounding box** against expectation \u2014 a part ten\n times too big or half hollow is one number.\n4. **`check_interference` / `inspect_assembly`** on multi-body models.\n5. **`sketch_status`** on every sketch \u2014 zero freedom, no conflicts.\n\n## 2. Mass properties (Barr Lab 6, Shih ch.4, Almattar ch.7)\n\nSet the material before trusting a mass. Obenor3D\'s materials are visual;\npass `density` to `measure` (g/cm\xB3 \u2192 the tool\'s unit; state which). Typical\nvalues (Barr Table 6-1, converted): aluminium 2.70, brass 8.50, copper 8.94,\nmild steel 7.85, stainless 7.90, titanium 4.51, ABS/PLA \u2248 1.05\u20131.25 g/cm\xB3.\nReport volume, mass, bounding box. Centre of mass and inertia are not\nexposed by `measure`; say so rather than estimate.\n\nBarr\'s reminder: SolidWorks\' "density" is unit weight; mass = density \xD7\nvolume, weight = unit weight \xD7 volume. Do not mix them.\n\n## 3. Measure tool habits (Barr Lab 6, Shih 13-17)\n\n- Two faces \u2192 distance (Shih measures the inside flange distance = 1.508").\n- An edge \u2192 length; a circular edge \u2192 arc length and diameter.\n- `measure` and `measure_angle` in Obenor3D; use them to confirm a dimension\n the drawing will call out, not to discover geometry you built.\n\n## 4. Test the intent, not just the shape (Barr Ex 3.1, Shih ch.5, ch.7, Land ch.3)\n\n1. Change one driving parameter by \xB130 % with `set_parameters`.\n2. `build`, `render_view`.\n3. Did the hole stay centred? Did the mirror follow? Did the cut still go\n through? Did any fillet fail?\n4. Restore the value.\nThis is the book\'s "double-click the 2.25" dimension and change it between\n1.00" and 3.00"". A model that survives it has its intent in the tree.\n\n## 5. Assemblies (Almattar ch.8, ch.15; Barr Lab 5; MCP instructions)\n\n- `check_interference`: any shared volume is a defect unless it is a press fit\n you meant \u2014 and say so.\n- Zero overlap proves nothing about seating; `probe_ray` down the axis, or\n an `expect { contact | clearance }` feature so the next rebuild tells you.\n- Step joints through their range (`angle`/`offset` at min, mid, max) and\n re-check \u2014 the manual substitute for collision detection.\n- `inspect_assembly` for mass per component and remaining freedom.\n\n## 6. Surfaces (Tran ch.1)\n\n`check_model` after every surface feature; `inspect_body` confirms the knit is\na solid; `analyze_surface` for continuity grades and `minRadius` before any\nfillet on a curved face.\n\n## 7. Sheet metal (Shih 13-25)\n\n`flat_pattern` builds; the blank length matches L = A(R + K\xB7T) by hand;\n`bend_table` lists every bend with radius and K.\n\n## 8. Drawings as a check (Barr Lab 8-10, Shih ch.8, Almattar ch.9-10)\n\nA three-view drawing is a review tool: hidden lines show buried features,\na section shows walls. `export_drawing { views: ["front","top","right","iso"],\ndimensions: true, hiddenLines: true }` and `hidden_line_view`; a section\nthrough `add_section` or the drawing\'s section axis. Detail views, BOM,\nballoons and bend notes are not generated \u2014 describe them in the reply.\n\n## 9. What to say at the end (prompt)\n\nWhat was built, what was assumed (units, material, K-factor, tolerances),\nwhat would be changed next, and every LOOK item from `finish_check` with its\ndisposition. A finished part with stated assumptions beats an interrogation.\n\n## 10. One-page checklist\n\n- [ ] `finish_check` clean or every LOOK explained\n- [ ] Parameters named; a \xB130 % change flexes as intended and is restored\n- [ ] Material stated; mass and volume reported with the density used\n- [ ] Every external edge broken; hole mouths chamfered; draft on moulded faces\n- [ ] Multi-body: interference zero, seats proven, `expect`s in place\n- [ ] Sketches fully defined; no `fix` relations left\n- [ ] Surfaces knitted solid; G1/G2 as required\n- [ ] Sheet metal: flat pattern verified\n- [ ] Reply states assumptions and next changes'
25143
+ },
25144
+ {
25145
+ id: "nurbs-and-freeform",
25146
+ title: "10 \u2014 NURBS and freeform",
25147
+ when: "Poles, degree, continuity: control nets, curves in space, matching, rebuilding",
25148
+ text: '# 10 \u2014 NURBS and freeform\n\nSource: Tran, *Mastering Surface Modeling* (ch.1 \xA75 splines, ch.7-9 repair);\nLand ch.11-13 (FORM/T-splines, a different paradigm \u2014 noted where it differs);\nand this system\'s own kernel, which is OpenCASCADE and speaks B-spline\nnatively.\n\n**Read 07-surfacing.md first.** That file is about *when* to reach for a\nsurface and how to knit one back into a solid. This one is about the geometry\nunderneath: poles, degree, continuity, and the tools that move them. Much of\nwhat 07 calls "none" this file calls by name \u2014 it was written before these\nexisted.\n\n## 1. What a surface here actually is\n\nEvery face this kernel builds is a NURBS patch, whether it came from an\n`extrude` or from `add_blend_surface`. It has:\n\n- a **control net** of poles, `countU` by `countV`. The surface does not pass\n through them; it is pulled toward them.\n- a **degree** in each direction. Degree 1 is faceted, 2 is a conic, **3 is the\n minimum for anything a highlight will travel across**, and above 5 a surface\n is hard to control and slow to evaluate.\n- **knots**, which say where the spans meet. `add_insert_isoparm` adds one.\n\n`surface_poles { id, face }` reads the net: the count in each direction, the\ndegree, and every pole with its `[i, j]`. **i counts along u and j along v, and\nu varies fastest.** Reading it transposed gives poles that exist, in the wrong\nplaces, which is the failure that looks like a working edit.\n\n## 2. The rule that governs pole editing: natural vs trimmed\n\nA face is **natural** when it covers its whole underlying surface, and\n**trimmed** when a boundary cuts a piece out of a larger one.\n\n**Poles can only be moved on a natural face.** Move one under a trimmed face\nand the surface slides out from under the boundary that trims it \u2014 the kernel\nrefuses, and `surface_poles` says `TRIMMED` before you try, so this costs one\nread rather than a failed build.\n\n`add_untrim { input, face }` throws the boundary away and gives you the whole\nunderlying surface back. That is destructive to the trim and is the point: do\nthe pole work, then re-trim with `add_trim_surface`.\n\n## 3. Curves in space\n\n`add_curve { poles, degree?, periodic? }` is a NURBS curve in **world space**,\nnot a sketch entity \u2014 poles as xyz triples, degree defaulting sensibly to the\npole count, `periodic: true` to close it into a loop. This is the thing 07\nlists as "none (planar sketches + helix)"; it exists.\n\nBeside it:\n\n| Want | Tool |\n|---|---|\n| A curve through measured points, in a plane | sketch `spline` (interpolates) |\n| A styled curve you shape by hand, in a plane | sketch `bezier` (control points) |\n| A curve in space | `add_curve` |\n| A screw path | `add_helix` |\n| A curve lying on a body | `project_curve { curve, onto, mode }` |\n| Longer at one end | `extend_curve` |\n| Cut at a point or another curve | `trim_curve` |\n| A parallel curve | `offset_curve` |\n\n**Spline quality is in the handles, not the point count** (Tran ch.1 \xA75). Few\nthrough-points, tangency weighted at the ends, and watch the inflections on the\ncurvature comb. A spline forced through twenty points has nineteen chances to\nwobble.\n\n## 4. Making a surface\n\n| Shape | Tool | Watch for |\n|---|---|---|\n| Skin through sections | `add_loft { profiles, solid: false }` | G1 at best across seams; add stations rather than nudging one |\n| Along a path | `add_sweep { profile, path, guide?, scaleEnd? }` | one guide rail |\n| Between two curves | `add_ruled_surface { a, b }` | straight in the ruling direction, always |\n| Through a grid of points | `add_surface_from_points { points, rows, columns, degreeMin?, degreeMax?, tolerance? }` | **a grid, not a cloud** \u2014 scattered points carry no u,v order |\n| A patch inside a loop of edges | `add_fill_face { boundary, continuity }` | `g1`/`g2` need supporting faces to reach |\n| A fillet-like skin between two edges on two bodies | `add_blend_surface { a, edgeA, b, edgeB, continuity }` | this is 07\'s missing "boundary surface", by another name |\n\n## 5. Editing a surface you already have\n\n| Intent | Tool | Note |\n|---|---|---|\n| Pull the shape | `add_move_poles { input, face, moves: [{ i, j, to }] }` | read `surface_poles` first; natural faces only; poles you do not name stay put |\n| More freedom to pull with | `add_insert_isoparm { input, face, along, parameter, multiplicity }` | adds a knot, so a new row of poles; more rows means finer control and a worse-behaved surface |\n| Change the degree, or simplify | `add_rebuild_surface { input, face, tolerance?, degreeU?, degreeV?, maxSegments? }` | an approximation \u2014 always check `analyze_surface` after |\n| Make an edge tangent to a neighbour | `add_match_surface { input, face, edge, to?, toFace?, continuity }` | this is how a G1 or G2 join is *achieved*, not merely measured |\n| Reach further | `add_extend_surface { input, face, distance }` | extends the underlying surface, then re-trim |\n| Recover the untrimmed surface | `add_untrim { input, face }` | the precondition for pole work |\n| Cut it back | `add_trim_surface` | one side kept per call |\n| Close small gaps in an import | `add_heal { input, tolerance }` | before stitching, not after |\n| Join into a shell or a solid | `add_stitch { inputs, tolerance, makeSolid }` | `makeSolid` is *attempted*; confirm with `inspect_body` |\n| Give it thickness | `add_thicken { input, thickness }` | a thin wall is a wall, not a lens \u2014 the renderer measures it |\n\n## 6. Continuity, and how to reach it rather than only measure it\n\n07 \xA72 has the table. What that file could not say is how to *fix* a join:\n\n1. `analyze_surface { id, what: "continuity" }` grades every edge G0/G1/G2.\n2. A G0 edge that should be G1: `add_match_surface` with `continuity: "g1"`,\n naming the edge and the face to match to.\n3. A G1 edge that should be G2 on visible bodywork: the same call with `"g2"`.\n It needs freedom to spend \u2014 a degree-2 surface cannot be made G2 against a\n degree-5 neighbour, so `add_rebuild_surface` first with `degreeU: 3` or more.\n4. Re-measure. A match that could not reach the grade asked for reports what it\n did reach; it does not silently succeed.\n\n**A G1 join with a curvature step draws a visible line in a reflection** and no\nedge exists to explain it. That is why G2 is worth the cost on anything styled\nand worth nothing on a bracket.\n\n## 7. Recipes\n\n### A. A styled panel that meets a neighbour without a seam\n\n1. `add_loft { solid: false }` through two or three section curves\n (`add_curve` in space, or sketches).\n2. `analyze_surface` \u2014 the shared edge reads G0.\n3. `add_match_surface { face, edge, to: neighbour, toFace, continuity: "g2" }`.\n4. `analyze_surface` again: G2 across it, and `minRadius` has not collapsed.\n5. Look at it: `render_view`, and zebra in the UI (`model.zebra`). Stripes that\n break at the join mean the numbers agreed and the eye does not.\n\n### B. Pulling a highlight straight\n\n1. `surface_poles { id, face }` \u2014 confirm it says natural.\n2. If the net is a 4\xD74 there is nothing to pull with in the middle:\n `add_insert_isoparm { along: "u", parameter: 0.5 }`.\n3. `surface_poles` again for the new indices.\n4. `add_move_poles { moves: [{ i: 2, j: 1, to: [...] }] }` \u2014 a few poles, not\n the whole net.\n5. `analyze_surface` for the curvature, `render_view` for the highlight. Repeat\n from 3; the net has moved, so its numbers are stale.\n\n### C. Repairing an import\n\n`add_heal` with a tolerance a little above the worst gap \u2192 `add_stitch\n{ makeSolid: true }` \u2192 `inspect_body`. Where a face is missing entirely,\n`add_fill_face` over its loop; where the hole is four-sided and wants to be\ntangent, `add_blend_surface` from the two edges either side.\n\n## 8. Where this system has no equivalent\n\nSay so rather than searching:\n\n- **T-splines / subdivision (Fusion FORM, Land ch.11-13).** A different\n paradigm \u2014 a control cage pushed and pulled, converted to NURBS at the end.\n Nothing here does it. The nearest is `add_surface_from_points` on a sampled\n grid, then `add_move_poles`.\n- **Guide curves on a loft.** `add_loft` takes stations; add one where the loft\n gaps rather than reaching for a rail that does not exist.\n- **Face fillet with a hold line, Deform, Flex.** None.\n- **A curve network surface in two directions.** `add_fill_face` with `g2`, or\n `add_blend_surface`, is the closest.\n\n## 9. Checklist\n\n- [ ] Degree 3 or more on anything a highlight crosses\n- [ ] `surface_poles` before `add_move_poles`, and the face reads natural\n- [ ] Poles named individually \u2014 a move sends what moved, not the net\n- [ ] `analyze_surface` after every rebuild, match or pole move\n- [ ] G2 on visible styled joins, G1 everywhere else, and looked at under zebra\n- [ ] Stitched shell confirmed solid by `inspect_body` before any boolean'
25149
+ },
25150
+ {
25151
+ id: "architecture",
25152
+ title: "11 \u2014 Buildings: levels, walls, openings, slabs, roofs",
25153
+ when: "Massing a building: levels, walls, openings, slabs, roofs \u2014 and the order they go in",
25154
+ text: '# 11 \u2014 Buildings: levels, walls, openings, slabs, roofs\n\nFor massing a building fast and showing it to somebody \u2014 a client on a phone,\na planner, yourself at the end of an hour. Not a BIM authoring tool: there is no\nclassification, no fire rating, no layered construction. What there is instead\nis five feature kinds that carry the arithmetic a building is made of, so that\nmassing a house is about twenty features rather than the hundred and\nthirty-seven it took the first time it was done here by hand.\n\nEverything these produce is ordinary B-rep geometry. A wall booleans, fillets,\nsections, measures and exports exactly like an extrude, because that is all it\nis once it has built. What the feature adds is the arithmetic that RE-SOLVES.\n\n## The order. This is most of it.\n\n**`add_level` \u2192 `add_wall` \u2192 `add_opening` \u2192 `add_slab` \u2192 `add_roof`.**\n\nFollow it and the model holds together when something moves; take it out of\norder and you are back to typing z values.\n\n1. **Levels first, always.** One `add_level` per storey \u2014 `ground`, `first`,\n `eaves` \u2014 before any geometry at all. They cost nothing, they are\n construction geometry, and they are what everything else is written against.\n2. **Walls next**, `base` and `top` written as `level("ground")` and\n `level("first")`. Not numbers. A wall whose top is `2900` is a wall that\n will not move when the storey does.\n3. **Openings after the walls they are cut into**, hosted on the wall by id.\n The second opening in a wall is hosted on the FIRST opening, not on the wall\n \u2014 see below.\n4. **Slabs**, at each level, with the boundary they need.\n5. **Roof last**, its `base` at the top of the walls it sits on.\n\nThen change one level\'s `elevation` and rebuild. Every wall on that storey, every\nopening in those walls, the slab and the roof all move together. That is the whole\npromise, and it is the reason to spend the first five features on levels.\n\n## `add_level` \u2014 a named elevation\n\n```\nadd_level { id: "ground", elevation: 0 }\nadd_level { id: "first", elevation: 2900 }\nadd_level { id: "eaves", elevation: 5600 }\n```\n\nThen `level("first")` reads 2900 in ANY numeric field, exactly like `maxz()` or\n`top()` read geometry. It is realised as a flat datum square so there is one\nmechanism for "what height is this?", it never appears as a body, and it is\nnever exported.\n\nThe failure this replaces: a house built here declared `ffl`, `lvl1`, `deck` and\n`roof_z` as parameters and typed every z in the model against one of them. It\nworks \u2014 until the first floor moves and there is no way to tell which of a\nhundred numbers meant "first floor" from which merely happened to equal it.\n\n## `add_wall` \u2014 a centreline, mitred, extruded between two elevations\n\n```\nadd_wall {\n id: "outer", closed: true, thickness: 300,\n base: "level(\\"ground\\")", top: "level(\\"first\\")",\n path: [[0,0,0],[8000,0,0],[8000,6000,0],[0,6000,0]]\n}\n```\n\n- `path` is the CENTRELINE in plan. **The z of every point is ignored** \u2014 `base`\n and `top` set the height. Repeating the first point at the end is fine and is\n dropped.\n- `closed: true` builds an outer loop AND an inner one, so four points and one\n feature is a room rather than a solid block. By hand that is a sketch, an\n extrude, a second sketch and a boolean \u2014 and the corners come out with a notch\n on the inside of every bend, because an offset that butts the corners cannot\n close them. Here the corner is the intersection of the two offset lines, which\n closes exactly.\n- `top` is an **elevation**, not a height. `level("first")` or\n `level("ground") + 2700`, never `2700` on its own.\n- `justification` moves the wall off its line: `left` and `right` are relative to\n the direction of travel, so the path becomes a face of the wall rather than its\n middle. Useful when the plan dimension you have is to the inside face.\n\nA thin wall (under 75 mm) builds and says so. A wall with no thickness, fewer\nthan two points, or a repeated point is refused \u2014 there is nothing to build.\n\n## `add_opening` \u2014 placed ALONG the wall, never at a coordinate\n\n```\nadd_opening { id: "front_door", host: "outer", along: 4000,\n width: 1000, height: 2100 }\nadd_opening { id: "w1", host: "front_door", along: 12000,\n width: 1500, height: 1200, sillHeight: 900 }\n```\n\nThere is no x, y or z, and that is the feature. `along` is the distance from the\nfirst point of the host wall\'s path, measured up its centreline; `sillHeight` is\nmeasured up from the wall\'s own base. **An opening outside its wall is not\nsomething this can express.**\n\nThat is not a nicety. A helper that placed eighteen windows here by coordinate\nmis-parenthesised a depth, and every opening on two of the four walls landed\noutside the wall it belonged to. Nothing failed. Frames and glass were built\ninside solid render, invisible, and it was found three scripts later in a render.\n\n**Host the second opening on the first.** A cut REPLACES the body it cuts, so two\nopenings both hosted on `outer` would be two overlapping walls each missing a\ndifferent hole. Chain them \u2014 `host: "front_door"` \u2014 the way a second `add_hole`\ntakes the first hole\'s id. The path is always read from the wall at the end of\nthe chain, so `along` still means the same thing.\n\nThree refusals, and they stay refusals because they are the bug this exists to\nend: `along` past the end of the path; an opening whose jambs run past the corner\nof the straight run it sits on (it would be cutting the wall that turns away);\nand a head that breaches the top of the wall. Each names the measurement. A head\nwithin 150 mm of the wall top warns and builds.\n\n## `add_slab` \u2014 floors, decks, terraces\n\n```\nadd_slab { id: "gf", boundary: [[0,0,0],[8000,0,0],[8000,6000,0],[0,6000,0]],\n level: "level(\\"ground\\")", thickness: 200 }\n```\n\nThe slab hangs **below** its level by default. That is what makes a level worth\nhaving: the level is the FINISHED FLOOR, the surface you stand on and the\nelevation every wall on that storey takes as its `base`, with the structure\nunderneath where it is on site. `offset` moves the top face off the level if you\nneed it elsewhere.\n\nGive `boundary` as plan points OR `profile` as the id of a sketch already drawn\nin plan \u2014 not both. A sketch is moved to the level; its own height does not\nmatter.\n\n## `add_roof` \u2014 the lower envelope, which is why it cannot vanish\n\n```\nadd_roof { id: "roof", boundary: [[0,0,0],[7200,0,0],[7200,10400,0],[0,10400,0]],\n base: "level(\\"eaves\\")", pitch: "30 deg",\n overhang: 400, thickness: 200 }\n```\n\nThe roof surface is\n\n z(p) = base + min over the pitched edges of distance(p, edge) * tan(pitch)\n\n\u2014 the lower envelope of one plane per pitched eave. Hip, gable, shed and pyramid\nare all this one feature:\n\n| Roof | `edges` |\n|---|---|\n| Hip | omit \u2014 every edge takes `pitch` |\n| Gable | `["30 deg", "gable", "30 deg", "gable"]` \u2014 pitch the long sides |\n| Shed / lean-to | one pitched edge, the rest `"gable"` |\n| Pyramid | omit, on a footprint too narrow to form a ridge |\n| Mixed | any angle per edge: `["40 deg", "gable", "25 deg", ""]` |\n\nAn empty string takes the default `pitch`; a short list leaves the rest at the\ndefault. Entry *i* is the edge from boundary point *i* to point *i+1*.\n\n**Why an envelope and not a cut.** A hip roof built by hand here as "cut away\neverything above a plane rising from the east eave" removed the ENTIRE roof and\nreported success \u2014 at 17.2\xB0 that plane reaches the ridge 10.4 m inboard and the\nwing was only 7.2 m wide. A subtraction that takes everything is still a\nsuccessful subtraction. An intersection cannot do that: the result is a subset of\nevery plane by construction, so too shallow a pitch gives a LOW roof and never an\nabsent one. The only way to get nothing is to pitch no edge at all, and that is\nrefused with a sentence.\n\n`thickness` is a **plumb** thickness, measured vertically; the perpendicular\ndepth of the slab is `thickness * cos(pitch)`. `overhang` pushes the eave line\nout past the footprint, and the planes rise from the overhung line, which is\nwhere a real roof starts.\n\nv1 solves **convex** footprints exactly. A non-convex one \u2014 an L-plan \u2014 still\nbuilds a valid closed solid inside its footprint, and warns: every eave is\nextended to a full plane, so a wing can end up roofed by a plane rising from an\neave on the far side of the building. Check the ridges, or build the wings as\nseparate roofs and fuse them.\n\n## A house in about twenty features\n\n1. `add_level` ground / first / eaves \u2014 3.\n2. `add_slab` ground floor \u2014 1.\n3. `add_wall` closed outer, `base: level("ground")`, `top: level("first")` \u2014 1.\n4. `add_opening` \xD7 the doors and windows, chained \u2014 1 each.\n5. `add_slab` first floor \u2014 1.\n6. `add_wall` closed outer, `base: level("first")`, `top: level("eaves")` \u2014 1.\n7. `add_opening` \xD7 upstairs \u2014 1 each.\n8. `add_wall` open internal partitions, thinner \u2014 1 each.\n9. `add_roof` at `level("eaves")` \u2014 1.\n10. `set_material` on the walls and the roof, `render_view` from three angles.\n\nThen move `level("first")` and rebuild. Everything follows.\n\n## What is NOT here, and what to use instead\n\n| Wanted | Here |\n|---|---|\n| Stair | Not a feature yet. A `patternLinear` of one tread box up a going/rise vector. |\n| Window frame and glazing | Not yet \u2014 `window` and `door` are coming. Today: `add_opening`, then a thin box or two in the reveal. |\n| Curtain wall / mullion grid | `add_wall` thin, plus a `patternLinear` of mullion boxes. |\n| Curved wall in plan | `add_wall` takes straight segments. Approximate with segments, or `add_sketch` an arc and `add_extrude`. |\n| Sloping wall | `add_wall` then `add_boolean cut` with a tilted box. A wall stands between two elevations by definition. |\n| Cavity wall / layers | Two walls on the same path, different `justification`. |\n| Terrain, site | `add_surface_from_points` and `add_thicken`. |\n| Room areas, schedules | `measure`; `inspect_body` for volumes. There is no room object. |\n\n## Checks worth running before you say done\n\n- `inspect_body` the wall: a closed wall is ONE solid with a hole through it \u2014\n if the volume is footprint \xD7 height it came out as a block and the plan is not\n a room.\n- `probe_ray` through an opening: it should pass clean through. An opening that\n built outside its wall leaves the wall solid and the ray stops.\n- Change a level\'s `elevation`, `build`, and check the walls, openings and roof\n all moved. If something stayed put, it was written with a number.\n- `render_view` from outside and from an interior camera, then 09\'s checklist.'
24354
25155
  }
24355
25156
  ];
24356
25157
  var DESIGN_GUIDE_TOPICS = DESIGN_GUIDE.map((topic) => topic.id);
24357
25158
 
25159
+ // ../doc/dist/collapse.js
25160
+ function collapseDocument(kernel2, json) {
25161
+ const started = Date.now();
25162
+ const doc2 = Document.fromJSON(kernel2, json);
25163
+ const built = doc2.rebuild();
25164
+ if (!built.ok) {
25165
+ const failed = built.features.find((feature) => !feature.ok);
25166
+ throw new Error(`This model does not build, so there is no solid to collapse${failed === void 0 ? "" : `: ${failed.error?.message ?? failed.id}`}`);
25167
+ }
25168
+ const absorbed = /* @__PURE__ */ new Set();
25169
+ let bodies = 0;
25170
+ for (const body of built.bodies) {
25171
+ try {
25172
+ const frozen = doc2.freeze(body.featureId);
25173
+ bodies += 1;
25174
+ for (const id of frozen.absorbed)
25175
+ absorbed.add(id);
25176
+ } catch {
25177
+ continue;
25178
+ }
25179
+ }
25180
+ const collapsed = doc2.toJSON();
25181
+ const idOf = (feature) => typeof feature === "object" && feature !== null && "id" in feature && typeof feature.id === "string" ? feature.id : null;
25182
+ const kept = collapsed.features.filter((feature) => {
25183
+ const id = idOf(feature);
25184
+ return id === null || !absorbed.has(id);
25185
+ }).map((feature) => {
25186
+ if (typeof feature !== "object" || feature === null)
25187
+ return feature;
25188
+ const { absorbed: _dropped, ...rest } = feature;
25189
+ return rest;
25190
+ });
25191
+ const parameters = collapsed.parameters?.length ?? 0;
25192
+ const { references: _drawings, ...display } = collapsed.display ?? {};
25193
+ return {
25194
+ document: {
25195
+ ...collapsed,
25196
+ display,
25197
+ features: kept,
25198
+ /* EVERY parameter, and the emptiness is the point rather than a tidy-up.
25199
+ * A parameter is the most direct statement of design intent in the whole
25200
+ * document - `wheel_d / 2 + 62` says what somebody decided and why - and
25201
+ * none of them is referenced by a `freeze` feature, which carries bytes
25202
+ * and not an expression. So they are removed because they can be, and the
25203
+ * role's promise is kept by their absence rather than by a rule about
25204
+ * who may read them. */
25205
+ parameters: []
25206
+ },
25207
+ bodies,
25208
+ removed: collapsed.features.length - kept.length,
25209
+ parameters,
25210
+ ms: Date.now() - started
25211
+ };
25212
+ }
25213
+
24358
25214
  // ../doc/dist/agentTools.js
24359
25215
  var MEASURES = `
24360
25216
 
@@ -24428,6 +25284,12 @@ var ENTRIES = [
24428
25284
  description: 'Applies an ordered list of add / update / remove operations, builds ONCE, checks whatever you assert about the result, and then keeps all of it or none of it.\n\nREPEAT is a LOOP, and it is what makes forty of a thing one call rather than forty. `{op:"repeat", count:8, as:"i", do:[ ... ]}` runs the operations in `do` eight times, with {i} substituted anywhere in them - ids, names and expressions alike - and {#} for the iteration number. `over:[10,25,40]` runs over VALUES instead of a count, which is what most real repetition is: hole diameters, station offsets, a bolt circle.\n\nPUT THE WHOLE ITERATION IN `do`, not one feature. A rib is a sketch AND an extrude AND a fillet, and each can name the one before it by its interpolated id - `{"id":"rib{i}", "kind":"extrude", "profile":"sk{i}"}`. That is the point of the loop: splitting it into one loop per stage does not work, because a feature can only measure something built BEFORE it and the fillet of iteration one would come after the extrude of iteration forty.\n\nEVERY REPEATED `add` NEEDS AN ID THAT VARIES - "rib{i}", not "rib". Forty features with one name cannot be told apart afterwards by an assertion, an update or a person, and the batch is refused rather than applied. An expression keeps its parameters: `"x":"i * pitch"` becomes `"3 * pitch"`, still tied to `pitch`, not flattened to a number.\n\nREACH FOR THIS whenever the next thing you are about to build is more than one feature and you already know what all of them are: a sketch, its extrude and the two fillets on it; a bracket and the four holes through it; a pattern and the boolean that merges it back. What it saves is round trips, which are the expensive thing here - a chainring cost eight calls and a bicycle over two hundred, each one carrying back a full report about a part that was not finished yet.\n\nASSERT what you expect rather than reading it back afterwards. `solid` catches the open shell or the sliver the moment the boolean makes it, which is the only moment the cause is still obvious; `clear` catches the part you placed from numbers read off geometry you did not build, which is where parts end up passing through bodywork; `volume` and `bbox` catch the extrude that went the wrong way or took the wrong profile, and cost nothing to state.\n\nALWAYS ASSERT VOLUME, and do not let bbox stand in for it. A pedal was asserted with `bbox`, `solid` AND `clear`; all three passed, and the pedal had been sliced nearly in half by a bore wider than the plate was thick. It was still a closed solid, still exactly the right size on the outside, still clear of everything - and 8% light. A bounding box is blind to every interior mistake there is. Volume is not, and `components` catches the other half of the same story: a body that should be one solid and is quietly two.\n\nASSERT WHAT TOUCHES WHAT once a part is placed against another. `touching`, `clearance` and `concentric` are what overlap cannot say: a bolted joint and a part floating 2 mm off its mount share no volume either way, so `clear` passes on both. A chainring hung 2 mm off its spider through an entire model that every other check called clean.\n\nALL OR NOTHING, and that is the point. If any operation is rejected, or the model will not build, or any assertion fails, the whole batch is rolled back and the model is exactly what it was before the call - so a batch that fails leaves you nothing to clean up and nothing to guess about. The reply names the operation or assertion that failed and what the model looked like at that point.\n\n`build` is the older, narrower form of this: it only ADDS, and it cannot assert. Use it when a batch is a run of new features and nothing else; use this one to edit or delete in the same unit, or whenever you have something to assert.',
24429
25285
  declaredBy: ["mcp", "app", "web"]
24430
25286
  },
25287
+ {
25288
+ name: "name_model",
25289
+ summary: "Name the model, if nobody has named it already",
25290
+ description: 'Gives the model a name in the person\u2019s library. Call it once you know what you are building - after the first few features, not before - and give it the name a person would use to find this part again.\n\nWHY YOU AND NOT THE APP. A new model is called "Untitled" and stays that way: the person who asked for it is describing a part, not filing it, and a library of eleven "Untitled" cards is what that produces. You are the only party that knows what was asked for and what got built, so you are the one that can name it.\n\nNAME IT FROM THE THING, NOT FROM THE OPERATIONS. "Solar commuter frame", "Hub motor housing", "Dropout jig" - what the part IS. Not "Extrude and 6 fillets", not "Model 1", not the date. Short enough to read on a card: two or three words.\n\nIT WILL NOT OVERWRITE A PERSON\u2019S NAME. The server refuses if the model has ever been named by hand - including if somebody deliberately called it "Untitled" - and says so instead of failing. That refusal is a normal outcome: do not retry it, do not work around it, and do not ask the person to rename it back.\n\nIT NEEDS A CONNECTED MODEL. This writes to the person\u2019s library through the Edit link this session is attached to, so it does nothing for a model that lives only in this process. connect_model is what attaches one.',
25291
+ declaredBy: ["mcp"]
25292
+ },
24431
25293
  {
24432
25294
  name: "connect_model",
24433
25295
  summary: "Point this session at a model in the app, from a pasted link",
@@ -24503,6 +25365,25 @@ var ENTRIES = [
24503
25365
  description: "Measures how a body actually bends, which is the one thing topology and a bounding box cannot tell you.\n\nCURVATURE. For each face, the smallest sphere that fits its surface (minRadius) and where that is. This is what says whether a shape can be pressed, whether a fillet of a given size will fit without eating itself, and whether a panel meant to read as convex has gone saddle-shaped somewhere - which is where a highlight breaks on a real car.\n\nCONTINUITY. For each edge shared by two faces, how smoothly they meet: G0 is a crease, G1 means the tangent planes agree, G2 means the curvature agrees too. query_geometry already answers G1 - a tangent edge is one. G2 is only available here, and it is the level that decides whether a body reads as one surface or as panels stuck together: a tangent join with a curvature step has no crease you can feel and still draws a visible line in a reflection.\n\nUSE IT ON LOFTED BODYWORK. A loft through sketch stations is G1 at best across its seams, and nothing in the model says so until you measure. Also use it before filleting a curved face: a fillet larger than the local minRadius will fail, and the error will blame the fillet rather than the surface.",
24504
25366
  declaredBy: ["mcp", "app", "web"]
24505
25367
  },
25368
+ {
25369
+ name: "surface_poles",
25370
+ summary: "A face's control net",
25371
+ description: `Reads the poles a NURBS face is DEFINED by - the control net a surface modeller pulls, with each pole's (i, j) and position. This is the read that makes add_move_poles aimable: that feature takes moves as {i, j, to}, and without the net there is nothing to compute an i, a j or a "to" from.
25372
+
25373
+ i counts along u and j along v, both from zero. u varies fastest, which is the one thing worth getting right: the net is a grid and reading it transposed gives poles that exist, in the wrong places.
25374
+
25375
+ TRIMMED FACES ARE REFUSED, and this says so before you try. A face that covers only part of its surface cannot have its poles moved - the surface would slide out from under the boundary trimming it - so the answer names that here rather than letting the build fail a step later. add_untrim makes such a face natural.
25376
+
25377
+ Degree and periodicity come back with it, because both decide what a move does: a move on a degree-3 net moves four spans, and a periodic net wraps.`,
25378
+ /* Not the V1 app. That surface is deliberately behind - `design_guide`,
25379
+ * `name_model` and eight others are not on it either - and this is a read
25380
+ * for the NURBS tools, none of which the V1 agent can add. Adding it there
25381
+ * would be a tool with nothing to aim.
25382
+ *
25383
+ * `web` reaches the hosted relay as well: `packages/api/src/agent/mcp.ts`
25384
+ * declares nothing of its own and asks the tab what it has. */
25385
+ declaredBy: ["mcp", "web"]
25386
+ },
24506
25387
  {
24507
25388
  name: "probe_ray",
24508
25389
  summary: "Fire a ray through the model",
@@ -24644,7 +25525,7 @@ var ENTRIES = [
24644
25525
  {
24645
25526
  name: "design_guide",
24646
25527
  summary: "How to design a part here",
24647
- description: `The design knowledge base, distilled from five CAD textbooks into this system's feature kinds and tools. Call it with no topic for the index and the ten rules every book agrees on; call it with a topic before the work that topic covers: "design-intent" before the first feature (plan the tree, pick the base feature, decide what should flex), "sketching" for relations, dimensions and a fully defined profile, "features-and-order" for end conditions, sweep and loft rules and the order of draft, fillets and shell, "fillets-and-blends", "assemblies-and-joints", "sheet-metal", "surfacing", "common-parts-recipes" for a bracket, plate, pulley, housing, shaft, tray, hook or knob step by step, and "checking-a-design" before you say it is done. Each recipe names the tool to call and says where this system has no equivalent, so you do not go looking for a rib feature or a hole wizard that is not there. It is text, costs no build, and is cheaper than a wrong first feature.`,
25528
+ description: `The design knowledge base, distilled from five CAD textbooks into this system's feature kinds and tools. Call it with no topic for the index and the ten rules every book agrees on; call it with a topic before the work that topic covers: "design-intent" before the first feature (plan the tree, pick the base feature, decide what should flex), "sketching" for relations, dimensions and a fully defined profile, "features-and-order" for end conditions, sweep and loft rules and the order of draft, fillets and shell, "fillets-and-blends", "assemblies-and-joints", "sheet-metal", "surfacing", "nurbs-and-freeform" for poles, degree, continuity and the tools that move them, "architecture" BEFORE the first wall of a building - levels, walls, openings, slabs and roofs, and the order they go in, which is most of what makes a building hold together when a storey height changes - "common-parts-recipes" for a bracket, plate, pulley, housing, shaft, tray, hook or knob step by step, and "checking-a-design" before you say it is done. Each recipe names the tool to call and says where this system has no equivalent, so you do not go looking for a rib feature or a hole wizard that is not there. It is text, costs no build, and is cheaper than a wrong first feature.`,
24648
25529
  declaredBy: ["mcp", "web"]
24649
25530
  },
24650
25531
  {
@@ -24784,6 +25665,8 @@ for (; ; ) {
24784
25665
  let result;
24785
25666
  if (method === "__reset") {
24786
25667
  result = reset2(args[0]);
25668
+ } else if (method === "__collapse") {
25669
+ result = collapseDocument(kernel, args[0]);
24787
25670
  } else if (method === "__info") {
24788
25671
  result = {
24789
25672
  version: kernel.version,