@hatiolab/figure-model 0.1.36 → 0.1.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/index.d.ts +6 -3
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +6 -3
  4. package/dist/index.js.map +1 -1
  5. package/dist/v3-asset-types.d.ts +89 -16
  6. package/dist/v3-asset-types.d.ts.map +1 -1
  7. package/dist/v3-asset.d.ts.map +1 -1
  8. package/dist/v3-asset.js +34 -25
  9. package/dist/v3-asset.js.map +1 -1
  10. package/dist/v3-authoring-actions.d.ts +226 -0
  11. package/dist/v3-authoring-actions.d.ts.map +1 -0
  12. package/dist/v3-authoring-actions.js +812 -0
  13. package/dist/v3-authoring-actions.js.map +1 -0
  14. package/dist/v3-capabilities.d.ts.map +1 -1
  15. package/dist/v3-capabilities.js +4 -77
  16. package/dist/v3-capabilities.js.map +1 -1
  17. package/dist/v3-cost.d.ts +14 -0
  18. package/dist/v3-cost.d.ts.map +1 -0
  19. package/dist/v3-cost.js +69 -0
  20. package/dist/v3-cost.js.map +1 -0
  21. package/dist/v3-driver.d.ts +24 -1
  22. package/dist/v3-driver.d.ts.map +1 -1
  23. package/dist/v3-driver.js +68 -2
  24. package/dist/v3-driver.js.map +1 -1
  25. package/dist/v3-from-v2.d.ts +25 -1
  26. package/dist/v3-from-v2.d.ts.map +1 -1
  27. package/dist/v3-from-v2.js +598 -21
  28. package/dist/v3-from-v2.js.map +1 -1
  29. package/dist/v3-gate.d.ts +59 -6
  30. package/dist/v3-gate.d.ts.map +1 -1
  31. package/dist/v3-gate.js +291 -45
  32. package/dist/v3-gate.js.map +1 -1
  33. package/dist/v3-graph-types.d.ts +9 -1
  34. package/dist/v3-graph-types.d.ts.map +1 -1
  35. package/dist/v3-graph.d.ts.map +1 -1
  36. package/dist/v3-graph.js +53 -1
  37. package/dist/v3-graph.js.map +1 -1
  38. package/dist/v3-json.d.ts +0 -1
  39. package/dist/v3-json.d.ts.map +1 -1
  40. package/dist/v3-json.js +16 -1
  41. package/dist/v3-json.js.map +1 -1
  42. package/dist/v3-kernel-version.d.ts +1 -1
  43. package/dist/v3-kernel-version.js +1 -1
  44. package/dist/v3-mesh-compare.d.ts +2 -5
  45. package/dist/v3-mesh-compare.d.ts.map +1 -1
  46. package/dist/v3-mesh-compare.js +2 -145
  47. package/dist/v3-mesh-compare.js.map +1 -1
  48. package/dist/v3-shape-sampling.d.ts +14 -0
  49. package/dist/v3-shape-sampling.d.ts.map +1 -0
  50. package/dist/v3-shape-sampling.js +120 -0
  51. package/dist/v3-shape-sampling.js.map +1 -0
  52. package/dist/v3-surface.d.ts +15 -0
  53. package/dist/v3-surface.d.ts.map +1 -0
  54. package/dist/v3-surface.js +104 -0
  55. package/dist/v3-surface.js.map +1 -0
  56. package/docs/prototypes/v3-asset.schema.json +0 -8
  57. package/docs/v3-asset-persistence.md +1 -1
  58. package/docs/v3-legacy-risk-audit-2026-09-23.md +39 -0
  59. package/docs/v3-operator-contracts.md +1 -0
  60. package/docs/v3-shape-dimension-contract.md +111 -0
  61. package/package.json +1 -1
package/dist/v3-gate.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Axis, Vec3 } from './types.ts';
2
2
  import type { V3Asset } from './v3-asset-types.ts';
3
3
  import type { V3Geometry } from './v3-graph-types.ts';
4
- import { type Point } from './v3-mesh-compare.ts';
4
+ import { type Point } from './v3-shape-sampling.ts';
5
5
  /** One placed body in the asset frame: its centre, axis-aligned world extents and rotation. */
6
6
  export interface V3WorldBox {
7
7
  part: string;
@@ -18,15 +18,24 @@ export interface V3WorldBox {
18
18
  export declare function v3WorldBoxesOf(geometry: readonly V3Geometry[], writers: ReadonlyMap<string, string>, options?: {
19
19
  points?: boolean;
20
20
  }): V3WorldBox[];
21
+ /**
22
+ * Every placed body including the ones that are not drawn. The declared occupancy does not shrink or grow with
23
+ * visibility (V3 designer's ruling 2026-09-22), so the gate measures against this, not against the drawn set.
24
+ */
25
+ export declare function v3OccupiedBoxesOf(geometry: readonly V3Geometry[], writers: ReadonlyMap<string, string>, options?: {
26
+ points?: boolean;
27
+ }): V3WorldBox[];
21
28
  /** Who writes each graph value, by node id. */
22
29
  export declare function v3WritersOf(asset: V3Asset): Map<string, string>;
23
30
  export interface V3Violation {
24
- code: 'OCCUPANCY_UNDECLARED' | 'PART_OUTSIDE_OCCUPANCY' | 'PARTS_OFF_PLACEMENT_FACE';
25
- /** The part that is out, or the lowest / highest part for the placement face. */
31
+ code: 'OCCUPANCY_UNDECLARED' | 'PART_OUTSIDE_OCCUPANCY' | 'PARTS_OFF_PLACEMENT_FACE' | 'PLACEMENT_FACE_TILTED';
32
+ /** The part that is out, or the one whose face the asset declares as what it is mounted on. */
26
33
  part?: string;
27
34
  axis?: Axis;
28
- /** How far, in mm, rounded to 0.1 µm as V2 does. */
35
+ /** How far, in mm, rounded to 0.1 µm as V2 does. A distance, never an angle. */
29
36
  mm?: number;
37
+ /** How far off parallel, in degrees. Kept apart from `mm` so a reader never adds the two. */
38
+ degrees?: number;
30
39
  detail: string;
31
40
  }
32
41
  /**
@@ -53,6 +62,23 @@ export interface V3Coverage {
53
62
  unprovable?: string[];
54
63
  detail: string;
55
64
  }
65
+ /**
66
+ * What each operator does to a value's extremes over the state box. Every operator in the kernel is listed by
67
+ * name — no pattern, no default — so a new one has no classification until someone writes its basis down, and
68
+ * `v3-state-dimension.test.ts` fails until they do.
69
+ *
70
+ * - `multilinear`: degree one in each state input given multilinear arguments, so the extreme sits at a corner
71
+ * however the value is later combined. Sums; products and quotients under the conditions checked in
72
+ * `cornerExtremalBasis`; poses and the structural operators, which translate and select but never bend a
73
+ * coordinate; the shape providers, whose extents are linear in their arguments; `feature@1`, whose pose is a
74
+ * linear combination of the shape's dimensions with an axis fixed by the feature's name, not by its size.
75
+ * - `monotone`: monotone in each argument, and not multilinear. Such a value may be a final coordinate on its
76
+ * own, and combining it with anything else that moves with the same state input is refused. Each carries its
77
+ * own condition in `cornerExtremalBasis`: `min`/`max` refuse arguments that share an input, `curve` refuses
78
+ * keys that move or turn around, `geomean` refuses factors that share an input.
79
+ * - `unknown`: nothing is claimed. A state-dependent argument here means the coverage falls back to sampling.
80
+ */
81
+ export declare const V3_STATE_EXTREME_BASIS: Readonly<Record<string, 'multilinear' | 'monotone' | 'unknown'>>;
56
82
  /**
57
83
  * Why the extremes of every state-dependent coordinate lie at the corners of the state box, or why that cannot be
58
84
  * shown. The basis: a value that is multilinear in the state inputs (each input to degree one) or a monotone
@@ -61,11 +87,38 @@ export interface V3Coverage {
61
87
  * denominator, and through translations in `rigid`. It does not hold through `sin`, `axis-turn`, a
62
88
  * state-dependent rotation angle in `rigid`, a state-dependent denominator, or a product of two values that share
63
89
  * an input. Returns the reasons it fails, empty when it holds.
90
+ *
91
+ * Monotone in each argument is not the same as monotone in a state input the arguments share, and a value that
92
+ * is merely monotone cannot be combined further. Both were found by the V3 designer, 2026-09-23:
93
+ * `10 + 100·min(q, 1−q)` is widest in the middle, and `10 + 100·(√q − q)` is widest at q = ¼. The gate called
94
+ * both proven.
95
+ *
96
+ * So the property is named rather than the operators that break it. A value is *multilinear* when it is degree
97
+ * one in each state input, which is what makes its extreme sit at a corner however it is later combined. Only
98
+ * the operators in `KEEPS_MULTILINEAR` below carry that property forward, and only under the conditions in the
99
+ * switch — a product of two values sharing an input, or a state-dependent denominator, does not. Every other
100
+ * operator, named or not, yields a value *bent* in each state input it depends on: at best monotone in that
101
+ * input, so it may stand as a final coordinate, but combining it with anything else that moves with the same
102
+ * input — a sum, a product, a pose composed onto it — is refused. A new operator is bent until someone shows
103
+ * it preserves multilinearity and adds it to the list.
64
104
  */
65
105
  export declare function cornerExtremalBasis(asset: V3Asset, dependence: Record<string, string[]>): string[];
66
106
  export interface V3Inspection {
67
- /** The declared volume as evaluated, when declared. Centred on X and Z, from 0 to y on Y. */
68
- extent?: Vec3;
107
+ /** The declared volume as evaluated, when declared: the reach on each side of the asset origin. */
108
+ bounds?: {
109
+ x: {
110
+ min: number;
111
+ max: number;
112
+ };
113
+ y: {
114
+ min: number;
115
+ max: number;
116
+ };
117
+ z: {
118
+ min: number;
119
+ max: number;
120
+ };
121
+ };
69
122
  placement?: 'floor' | 'ceiling' | 'center';
70
123
  coverage: V3Coverage;
71
124
  violations: V3Violation[];
@@ -1 +1 @@
1
- {"version":3,"file":"v3-gate.d.ts","sourceRoot":"","sources":["../src/v3-gate.ts"],"names":[],"mappings":"AAyBA,OAAO,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,YAAY,CAAA;AAC5C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAClD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AACrD,OAAO,EAAkC,KAAK,KAAK,EAAE,MAAM,sBAAsB,CAAA;AAEjF,+FAA+F;AAC/F,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,IAAI,CAAA;IACZ,oEAAoE;IACpE,MAAM,EAAE,IAAI,CAAA;IACZ,sGAAsG;IACtG,IAAI,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;IAC9B,QAAQ,EAAE,MAAM,EAAE,EAAE,CAAA;IACpB,yGAAyG;IACzG,MAAM,CAAC,EAAE,KAAK,EAAE,CAAA;CACjB;AAED,yHAAyH;AACzH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,SAAS,UAAU,EAAE,EAAE,OAAO,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,GAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAA;CAAO,GAAG,UAAU,EAAE,CA6BtJ;AAED,+CAA+C;AAC/C,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAI/D;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,sBAAsB,GAAG,wBAAwB,GAAG,0BAA0B,CAAA;IACpF,iFAAiF;IACjF,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,IAAI,CAAA;IACX,oDAAoD;IACpD,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,MAAM,EAAE,MAAM,CAAA;CACf;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,QAAQ,GAAG,SAAS,GAAG,UAAU,CAAA;IACzC,uDAAuD;IACvD,MAAM,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IACvD,+CAA+C;IAC/C,MAAM,EAAE,MAAM,CAAA;IACd,gEAAgE;IAChE,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,wDAAwD;IACxD,UAAU,CAAC,EAAE,MAAM,EAAE,CAAA;IACrB,MAAM,EAAE,MAAM,CAAA;CACf;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,GAAG,MAAM,EAAE,CA4DlG;AAED,MAAM,WAAW,YAAY;IAC3B,6FAA6F;IAC7F,MAAM,CAAC,EAAE,IAAI,CAAA;IACb,SAAS,CAAC,EAAE,OAAO,GAAG,SAAS,GAAG,QAAQ,CAAA;IAC1C,QAAQ,EAAE,UAAU,CAAA;IACpB,UAAU,EAAE,WAAW,EAAE,CAAA;CAC1B;AAED,MAAM,WAAW,gBAAgB;IAC/B,gHAAgH;IAChH,EAAE,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC3B,mFAAmF;IACnF,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,0GAA0G;IAC1G,eAAe,CAAC,EAAE,MAAM,CAAA;CACzB;AAOD;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,GAAE,gBAAgB,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAAG,YAAY,CAsFpH"}
1
+ {"version":3,"file":"v3-gate.d.ts","sourceRoot":"","sources":["../src/v3-gate.ts"],"names":[],"mappings":"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"}
package/dist/v3-gate.js CHANGED
@@ -20,14 +20,21 @@
20
20
  */
21
21
  import { compileV3Asset } from "./v3-asset.js";
22
22
  import { compileV3Graph } from "./v3-graph.js";
23
+ import { resolveV3Surface, evaluateV3Surface } from "./v3-surface.js";
23
24
  import { AXES, SKIN } from "./types.js";
24
- import { transformPoints, v3ShapePoints } from "./v3-mesh-compare.js";
25
+ /** How far a declared mounting face may lean off the plane before it is not that plane any more. */
26
+ const CONTACT_TILT_DEG = 0.1;
27
+ import { transformPoints, v3ShapePoints } from "./v3-shape-sampling.js";
25
28
  /** Axis-aligned world boxes of evaluated geometry. `part` is the placing node: a `place@1` id or the repeated member. */
26
29
  export function v3WorldBoxesOf(geometry, writers, options = {}) {
27
30
  const out = [];
28
31
  for (const g of geometry) {
29
32
  if (!('pose' in g))
30
33
  continue;
34
+ // A part whose visibility flag is off is not drawn, so it is not a drawn body here. It is still evaluated and
35
+ // still placed; `v3OccupiedBoxesOf` is the reader that wants it (the occupancy does not move with visibility).
36
+ if (g.visible === false)
37
+ continue;
31
38
  const part = g.key ? writers.get(g.key) : g.id;
32
39
  const d = g.shape.dimensions;
33
40
  const local = g.shape.provider === 'cylinder'
@@ -40,7 +47,38 @@ export function v3WorldBoxesOf(geometry, writers, options = {}) {
40
47
  ? [d.radiusX * 2, d.length, d.radiusZ * 2]
41
48
  : [d.width, d.height, d.depth];
42
49
  const r = g.pose.r;
43
- const extent = AXES.map((_, a) => local.reduce((s, v, i) => s + Math.abs(r[a][i]) * v, 0));
50
+ /*
51
+ How far the body reaches along each world axis.
52
+
53
+ A box is bounded by summing its turned sides, and that is what the sum below does. A round body is not:
54
+ measuring a spinning wheel as a turning square says it dips below the floor, which it does not. The
55
+ forklift's wheel read 70 mm under the floor at 45° for exactly this reason — 340·(cos45 + sin45) is the
56
+ square's diagonal, not the wheel's. The V2 gate measures it the same way, so the two agreeing said nothing
57
+ about whether either was right (the V3 designer's caution, 2026-09-22).
58
+
59
+ For a round body the reach is its support along the axis: the axis half-length times how much the body's
60
+ axis points that way, plus the cross-section's own reach. Written per provider below; a box keeps the sum.
61
+ */
62
+ const round = (() => {
63
+ const axis = (a) => r[a][1], // where the body's own Y axis points
64
+ across = (a, i) => r[a][i];
65
+ const support = (a, half, rx, rz) => 2 * (half * Math.abs(axis(a)) + Math.hypot(rx * across(a, 0), rz * across(a, 2)));
66
+ switch (g.shape.provider) {
67
+ case 'cylinder':
68
+ return (a) => support(a, d.length / 2, d.radius, d.radius);
69
+ case 'ellipticCylinder':
70
+ return (a) => support(a, d.length / 2, d.radiusX, d.radiusZ);
71
+ case 'frustum': {
72
+ const widest = Math.max(d.radiusTop, d.radiusBottom);
73
+ return (a) => support(a, d.height / 2, widest, widest);
74
+ }
75
+ case 'sphere':
76
+ return (a) => 2 * Math.hypot(d.radiusX * across(a, 0), d.radiusY * axis(a), d.radiusZ * across(a, 2));
77
+ default:
78
+ return null;
79
+ }
80
+ })();
81
+ const extent = AXES.map((_, a) => (round ? round(a) : local.reduce((s, v, i) => s + Math.abs(r[a][i]) * v, 0)));
44
82
  const box = {
45
83
  part,
46
84
  centre: { x: g.pose.t[0], y: g.pose.t[1], z: g.pose.t[2] },
@@ -54,6 +92,13 @@ export function v3WorldBoxesOf(geometry, writers, options = {}) {
54
92
  }
55
93
  return out;
56
94
  }
95
+ /**
96
+ * Every placed body including the ones that are not drawn. The declared occupancy does not shrink or grow with
97
+ * visibility (V3 designer's ruling 2026-09-22), so the gate measures against this, not against the drawn set.
98
+ */
99
+ export function v3OccupiedBoxesOf(geometry, writers, options = {}) {
100
+ return v3WorldBoxesOf(geometry.map(g => ('pose' in g ? { ...g, visible: true } : g)), writers, options);
101
+ }
57
102
  /** Who writes each graph value, by node id. */
58
103
  export function v3WritersOf(asset) {
59
104
  const writers = new Map();
@@ -62,6 +107,65 @@ export function v3WritersOf(asset) {
62
107
  writers.set(ref, n.id);
63
108
  return writers;
64
109
  }
110
+ /**
111
+ * What each operator does to a value's extremes over the state box. Every operator in the kernel is listed by
112
+ * name — no pattern, no default — so a new one has no classification until someone writes its basis down, and
113
+ * `v3-state-dimension.test.ts` fails until they do.
114
+ *
115
+ * - `multilinear`: degree one in each state input given multilinear arguments, so the extreme sits at a corner
116
+ * however the value is later combined. Sums; products and quotients under the conditions checked in
117
+ * `cornerExtremalBasis`; poses and the structural operators, which translate and select but never bend a
118
+ * coordinate; the shape providers, whose extents are linear in their arguments; `feature@1`, whose pose is a
119
+ * linear combination of the shape's dimensions with an axis fixed by the feature's name, not by its size.
120
+ * - `monotone`: monotone in each argument, and not multilinear. Such a value may be a final coordinate on its
121
+ * own, and combining it with anything else that moves with the same state input is refused. Each carries its
122
+ * own condition in `cornerExtremalBasis`: `min`/`max` refuse arguments that share an input, `curve` refuses
123
+ * keys that move or turn around, `geomean` refuses factors that share an input.
124
+ * - `unknown`: nothing is claimed. A state-dependent argument here means the coverage falls back to sampling.
125
+ */
126
+ export const V3_STATE_EXTREME_BASIS = Object.freeze({
127
+ 'add@1': 'multilinear',
128
+ 'mul@1': 'multilinear',
129
+ 'div@1': 'multilinear',
130
+ 'rigid@1': 'multilinear',
131
+ 'compose@1': 'multilinear',
132
+ 'attach@1': 'multilinear',
133
+ 'map-point@1': 'multilinear',
134
+ 'place@1': 'multilinear',
135
+ 'member@1': 'multilinear',
136
+ 'assembly@1': 'multilinear',
137
+ 'repeat@1': 'multilinear',
138
+ 'select-item@1': 'multilinear',
139
+ 'point@1': 'multilinear',
140
+ 'feature@1': 'multilinear',
141
+ 'between@1': 'multilinear',
142
+ 'box@1': 'multilinear',
143
+ 'cylinder@1': 'multilinear',
144
+ 'axis-slide@1': 'multilinear',
145
+ 'at-least@1': 'multilinear',
146
+ 'box-shape@1': 'multilinear',
147
+ 'cylinder-shape@1': 'multilinear',
148
+ 'cylinder-shape@2': 'multilinear',
149
+ 'sphere-shape@1': 'multilinear',
150
+ 'frustum-shape@1': 'multilinear',
151
+ 'rounded-box@1': 'multilinear',
152
+ 'rounded-box@2': 'multilinear',
153
+ 'hollow-box@1': 'multilinear',
154
+ 'hollow-box@2': 'multilinear',
155
+ 'portal-profile@1': 'multilinear',
156
+ 'fit-pitch@1': 'multilinear',
157
+ 'fixed-count@1': 'multilinear',
158
+ 'min@1': 'monotone',
159
+ 'max@1': 'monotone',
160
+ 'curve@1': 'monotone',
161
+ 'geomean@1': 'monotone',
162
+ // A polygon's world box is the largest of its vertex coordinates. Two vertices moving with one input fold it,
163
+ // the same way min@1 folds, and the vertices are arguments of one node rather than values of their own.
164
+ 'polygon-shape@1': 'unknown',
165
+ 'axis-turn@1': 'unknown',
166
+ 'distance@1': 'unknown',
167
+ 'sin@1': 'unknown'
168
+ });
65
169
  /**
66
170
  * Why the extremes of every state-dependent coordinate lie at the corners of the state box, or why that cannot be
67
171
  * shown. The basis: a value that is multilinear in the state inputs (each input to degree one) or a monotone
@@ -70,26 +174,97 @@ export function v3WritersOf(asset) {
70
174
  * denominator, and through translations in `rigid`. It does not hold through `sin`, `axis-turn`, a
71
175
  * state-dependent rotation angle in `rigid`, a state-dependent denominator, or a product of two values that share
72
176
  * an input. Returns the reasons it fails, empty when it holds.
177
+ *
178
+ * Monotone in each argument is not the same as monotone in a state input the arguments share, and a value that
179
+ * is merely monotone cannot be combined further. Both were found by the V3 designer, 2026-09-23:
180
+ * `10 + 100·min(q, 1−q)` is widest in the middle, and `10 + 100·(√q − q)` is widest at q = ¼. The gate called
181
+ * both proven.
182
+ *
183
+ * So the property is named rather than the operators that break it. A value is *multilinear* when it is degree
184
+ * one in each state input, which is what makes its extreme sit at a corner however it is later combined. Only
185
+ * the operators in `KEEPS_MULTILINEAR` below carry that property forward, and only under the conditions in the
186
+ * switch — a product of two values sharing an input, or a state-dependent denominator, does not. Every other
187
+ * operator, named or not, yields a value *bent* in each state input it depends on: at best monotone in that
188
+ * input, so it may stand as a final coordinate, but combining it with anything else that moves with the same
189
+ * input — a sum, a product, a pose composed onto it — is refused. A new operator is bent until someone shows
190
+ * it preserves multilinearity and adds it to the list.
73
191
  */
74
192
  export function cornerExtremalBasis(asset, dependence) {
75
193
  const reasons = [];
76
194
  const dep = (ref) => dependence[ref] ?? [];
77
195
  const shares = (a, b) => dep(a).some(x => dep(b).includes(x));
196
+ const keepsMultilinear = (op) => V3_STATE_EXTREME_BASIS[op] === 'multilinear';
197
+ // Which state inputs a value is bent in. Nodes may be written in any order, so this follows the arguments.
198
+ const writer = new Map();
199
+ for (const n of asset.document.model.nodes)
200
+ for (const ref of Object.values(n.outputs))
201
+ writer.set(ref, n);
202
+ /*
203
+ `compose(a, b)` is `{ r: a.r·b.r, t: a.r·b.t + a.t }`, and `attach(a, o, b)` is `compose(compose(a, o),
204
+ inverse(b))`, where `inverse(b).t` is `−b.rᵀ·b.t`. Every translation in those is the input translations
205
+ multiplied by rotation matrices. That is multilinear only while the matrices hold still: a state-dependent
206
+ rotation times a state-dependent translation is a product of two things moving with the same input. So the
207
+ rotation component of every pose that reaches a composition must be state-free, and this says when it is.
208
+ A `rigid@1` says so through its three angle arguments; `axis-slide@1` carries no rotation; `feature@1`'s
209
+ axes are fixed by the feature's name, not by the shape's size. Anything else is not established.
210
+ */
211
+ const rotationFreeCache = new Map();
212
+ const rotationStateFree = (ref) => {
213
+ const done = rotationFreeCache.get(ref);
214
+ if (done !== undefined)
215
+ return done;
216
+ rotationFreeCache.set(ref, false);
217
+ const n = writer.get(ref);
218
+ let out;
219
+ if (!n)
220
+ out = true; // an input or a constant is not a pose
221
+ else if (n.op === 'rigid@1')
222
+ out = !n.args.slice(3, 6).some(a => dep(a).length);
223
+ else if (n.op === 'axis-slide@1')
224
+ out = true;
225
+ else if (n.op === 'feature@1')
226
+ out = true;
227
+ else if (n.op === 'compose@1' || n.op === 'attach@1')
228
+ out = n.args.every(rotationStateFree);
229
+ else
230
+ out = false;
231
+ rotationFreeCache.set(ref, out);
232
+ return out;
233
+ };
234
+ const bentCache = new Map();
235
+ const bent = (ref) => {
236
+ const done = bentCache.get(ref);
237
+ if (done)
238
+ return done;
239
+ bentCache.set(ref, []); // a cycle is the compiler's error to report, not this one's
240
+ const n = writer.get(ref);
241
+ if (!n)
242
+ return [];
243
+ const out = keepsMultilinear(n.op) ? [...new Set(n.args.flatMap(bent))] : dep(ref);
244
+ bentCache.set(ref, out);
245
+ return out;
246
+ };
78
247
  for (const n of asset.document.model.nodes) {
79
248
  const stateful = n.args.filter(a => dep(a).length);
80
249
  if (!stateful.length)
81
250
  continue;
251
+ // A bent value meeting anything else that moves with the same input, whatever the operator does with them.
252
+ // `at-least@1` is left out: it yields a flag, which reaches no dimension, pose or occupancy.
253
+ if (n.op !== 'at-least@1')
254
+ for (let i = 0; i < n.args.length; i++)
255
+ for (let j = i + 1; j < n.args.length; j++) {
256
+ const clash = [...new Set([...bent(n.args[i]).filter(x => dep(n.args[j]).includes(x)), ...bent(n.args[j]).filter(x => dep(n.args[i]).includes(x))])];
257
+ if (clash.length)
258
+ reasons.push(`${n.id}: ${n.op} combines a value bent in ${clash.join(', ')} with another that moves with ${clash.join(', ')}, so the extreme may lie inside the range`);
259
+ }
82
260
  switch (n.op) {
83
261
  case 'add@1':
84
- case 'compose@1':
85
- case 'attach@1':
86
262
  case 'place@1':
87
263
  case 'member@1':
88
264
  case 'feature@1':
89
265
  case 'assembly@1':
90
266
  case 'repeat@1':
91
267
  case 'select-item@1':
92
- case 'map-point@1':
93
268
  case 'point@1':
94
269
  break;
95
270
  case 'mul@1':
@@ -112,6 +287,27 @@ export function cornerExtremalBasis(asset, dependence) {
112
287
  if (n.args.slice(0, 3).some(a => dep(a).length))
113
288
  reasons.push(`${n.id}: the slide axis depends on state`);
114
289
  break;
290
+ case 'compose@1':
291
+ case 'attach@1':
292
+ case 'map-point@1':
293
+ for (const a of n.args)
294
+ if (dep(a).length && !rotationStateFree(a))
295
+ reasons.push(`${n.id}: ${n.op} turns a translation by a rotation that depends on ${dep(a).join(', ')}`);
296
+ break;
297
+ case 'max@1':
298
+ case 'min@1':
299
+ // Monotone in each argument. Two arguments moving with the same input turn that into a fold: min(q, 1−q)
300
+ // rises then falls, and its largest value is in the middle, not at either end.
301
+ for (let i = 0; i < n.args.length; i++)
302
+ for (let j = i + 1; j < n.args.length; j++)
303
+ if (shares(n.args[i], n.args[j]))
304
+ reasons.push(`${n.id}: ${n.op} of two values that both depend on ${dep(n.args[i]).filter(x => dep(n.args[j]).includes(x)).join(', ')}`);
305
+ break;
306
+ case 'at-least@1':
307
+ // A flag cannot reach a dimension, a pose or the occupancy, so a step here moves no world coordinate.
308
+ // Visibility never changes the declared volume either (designer's ruling), so the gate checks hidden
309
+ // parts exactly as it checks shown ones.
310
+ break;
115
311
  case 'curve@1': {
116
312
  // A piecewise-linear curve is monotone in x when its key values never change direction; then its extremes
117
313
  // are at x's extremes. Keys that depend on state, or a curve that rises and falls, are not covered.
@@ -132,16 +328,45 @@ export function cornerExtremalBasis(asset, dependence) {
132
328
  break;
133
329
  }
134
330
  default:
135
- // sin, axis-turn, layout operators, distance, providers with a state-dependent argument are not corner-extremal
136
- // by this basis; providers are shapes whose extents are the arguments themselves, which is fine only when the
137
- // arguments are (handled by their own nodes), so treat shape providers as pass-through.
138
- if (n.op.endsWith('-shape@1') || n.op.endsWith('-shape@2') || n.op === 'rounded-box@1' || n.op === 'rounded-box@2' || n.op === 'hollow-box@1' || n.op === 'hollow-box@2' || n.op === 'portal-profile@1')
331
+ // Everything with no condition of its own: the registry decides. An operator it does not list is unknown,
332
+ // so a kernel that grows without writing a basis down loses coverage rather than claiming one.
333
+ if (V3_STATE_EXTREME_BASIS[n.op] === 'multilinear')
139
334
  break;
140
- reasons.push(`${n.id}: ${n.op} with a state-dependent argument (${[...new Set(stateful.flatMap(dep))].join(', ')})`);
335
+ reasons.push(`${n.id}: ${n.op} (${V3_STATE_EXTREME_BASIS[n.op] ?? 'unclassified'}) with a state-dependent argument (${[...new Set(stateful.flatMap(dep))].join(', ')})`);
141
336
  }
142
337
  }
143
338
  return reasons;
144
339
  }
340
+ /*
341
+ Holding every part inside the declared volume and touching the plane the figure is mounted on are two
342
+ different claims, and corner extremality only settles the first. Whether the figure touches used to be read as
343
+ the *smallest* gap over its parts, and a smallest folds the way `min@1` folds: two boxes at 20q and 20(1−q) mm
344
+ above the floor each touch at one end of q and both float 10 mm at the middle (V3 designer's counterexample
345
+ 2026-09-23). The gate evaluated the two ends, found a part on the floor at each, and said proven.
346
+
347
+ The declaration names one face now, so there is no smallest to fold: the check is a single equality. What is
348
+ left is whether that face stays put. A face whose placement does not move with state sits in the same place at
349
+ every state, so one evaluation settles it. A face that moves is not settled at the corners — the equality has
350
+ to hold at every state in between, and corner extremality says nothing about an equality — so the asset is
351
+ sampled, which checks more states and reports the distance it finds without calling it a bound.
352
+
353
+ An asset that does not name a mounting face is not asked to touch anything, and nothing here applies to it.
354
+ */
355
+ function unprovenContact(asset, graph) {
356
+ const contact = asset.occupancy?.contact;
357
+ if (!contact)
358
+ return [];
359
+ const moving = [contact.surface.placement, contact.surface.feature].filter(ref => (graph.dependence[ref] ?? []).length);
360
+ if (!moving.length)
361
+ return [];
362
+ return [
363
+ `the mounting face ${contact.surface.placement}/${contact.surface.feature} moves with ${[...new Set(moving.flatMap(ref => graph.dependence[ref] ?? []))].join(', ')}; it has to lie on the plane at every state, and corners do not settle an equality`
364
+ ];
365
+ }
366
+ /** Which node writes each graph output, the shape the surface reader wants. */
367
+ function nodeWriters(asset) {
368
+ return new Map(asset.document.model.nodes.flatMap(n => Object.values(n.outputs).map(ref => [ref, n])));
369
+ }
145
370
  /** The state inputs of an asset with their declared bounds. */
146
371
  function stateInputs(asset) {
147
372
  return asset.document.model.inputs.filter(i => i.role === 'state').map(i => ({ id: i.id, min: i.min, max: i.max }));
@@ -188,6 +413,7 @@ export function inspectV3Asset(asset, options = {}) {
188
413
  }
189
414
  else {
190
415
  const unprovable = cornerExtremalBasis(asset, graph.dependence);
416
+ unprovable.push(...unprovenContact(asset, graph));
191
417
  const maxCorners = opts.maxCornerInputs ?? 10;
192
418
  const allCorners = inputs.length <= maxCorners;
193
419
  const corners = [];
@@ -237,30 +463,36 @@ export function inspectV3Asset(asset, options = {}) {
237
463
  inspectOne(asset, graph, evaluated.geometry, state, push);
238
464
  }
239
465
  const values = graph.evaluate({ ...asset.designInputs, ...asset.stateDefaults }).values;
240
- const extent = {
241
- x: values[asset.occupancy.extent.x],
242
- y: values[asset.occupancy.extent.y],
243
- z: values[asset.occupancy.extent.z]
244
- };
245
- return { extent, placement: asset.occupancy.placement, coverage, violations };
466
+ const read = (a) => ({
467
+ min: values[asset.occupancy.bounds[a].min],
468
+ max: values[asset.occupancy.bounds[a].max]
469
+ });
470
+ return { bounds: { x: read('x'), y: read('y'), z: read('z') }, placement: asset.occupancy.placement, coverage, violations };
246
471
  }
247
472
  function inspectOne(asset, graph, geometry, state, push) {
248
473
  const occupancy = asset.occupancy;
249
474
  // The asset evaluation returns geometry only; the declared lengths are graph values, so read the graph itself.
250
475
  const values = graph.evaluate({ ...asset.designInputs, ...asset.stateDefaults, ...state }).values;
251
- const extent = {
252
- x: values[occupancy.extent.x],
253
- y: values[occupancy.extent.y],
254
- z: values[occupancy.extent.z]
255
- };
256
476
  const placement = occupancy.placement;
257
- const boxes = v3WorldBoxesOf(geometry, v3WritersOf(asset));
477
+ const boxes = v3OccupiedBoxesOf(geometry, v3WritersOf(asset));
258
478
  const round = (v) => Math.round(v * 1e4) / 1e4;
259
- // The declared box: X and Z centred, Y from the floor (ADR-0065).
260
- const low = { x: -extent.x / 2, y: 0, z: -extent.z / 2 };
261
- const high = { x: extent.x / 2, y: extent.y, z: extent.z / 2 };
262
- let lowest;
263
- let highest;
479
+ /*
480
+ The declared volume is the reach on each side of the asset origin, so it can sit anywhere around it. The
481
+ origin is where the figure is mounted, which is a coordinate reference and not a surface nothing may pass:
482
+ an arm on a bench reaches below the bench, and that is the machine working (V3 designer's ruling 2026-09-23).
483
+ */
484
+ const low = { x: 0, y: 0, z: 0 };
485
+ const high = { x: 0, y: 0, z: 0 };
486
+ for (const axis of AXES) {
487
+ low[axis] = values[occupancy.bounds[axis].min];
488
+ high[axis] = values[occupancy.bounds[axis].max];
489
+ if (!(high[axis] > low[axis]))
490
+ push({
491
+ code: 'OCCUPANCY_UNDECLARED',
492
+ axis,
493
+ detail: `the declared volume on ${axis.toUpperCase()} runs from ${low[axis]} to ${high[axis]}, which encloses nothing`
494
+ });
495
+ }
264
496
  for (const box of boxes) {
265
497
  for (const axis of AXES) {
266
498
  const reach = box.extent[axis] / 2;
@@ -276,31 +508,45 @@ function inspectOne(asset, graph, geometry, state, push) {
276
508
  });
277
509
  }
278
510
  }
279
- if (!lowest || box.centre.y - box.extent.y / 2 < lowest.centre.y - lowest.extent.y / 2)
280
- lowest = box;
281
- if (!highest || box.centre.y + box.extent.y / 2 > highest.centre.y + highest.extent.y / 2)
282
- highest = box;
283
511
  }
284
- if (lowest && placement === 'floor') {
285
- const gap = lowest.centre.y - lowest.extent.y / 2 - low.y;
286
- if (gap > SKIN)
512
+ /*
513
+ Only an asset that names the face it is mounted on is asked to make the contact, and two things have to hold
514
+ for that face to lie in the mounting plane: its reference point is on the plane, and its plane is the same
515
+ plane. Checking the point alone passed a box stood on its side with the right-hand face declared as the
516
+ mounting face, because that face's centre happened to sit at Y = 0 (V3 designer's counterexample
517
+ 2026-09-23). A vertical face and a horizontal floor are not the same plane.
518
+
519
+ Whether the face points into the plane or away from it is a separate question — which way up the figure is
520
+ mounted — and is not asked here. Only that the two planes coincide.
521
+
522
+ The plane is Y = 0 in the asset frame, for a floor figure and a ceiling one alike. The origin is where the
523
+ figure is fastened (V3 designer's ruling 2026-09-23); a ceiling figure hangs from it and names its top face.
524
+ Reading the plane off `occupancy.bounds.y.max` moved the thing the figure is mounted on whenever someone
525
+ widened the volume it takes up, which are two unrelated declarations.
526
+ */
527
+ if (occupancy.contact && (placement === 'floor' || placement === 'ceiling')) {
528
+ const surface = evaluateV3Surface(values, resolveV3Surface({ types: graph.outputTypes, writers: nodeWriters(asset), model: asset.document.model, assetFrame: asset.document.capabilities.assetFrame }, occupancy.contact.surface, 'occupancy.contact.surface'));
529
+ const part = v3WritersOf(asset).get(surface.placement) ?? surface.placement;
530
+ const off = surface.pose.t[1];
531
+ if (Math.abs(off) > SKIN)
287
532
  push({
288
533
  code: 'PARTS_OFF_PLACEMENT_FACE',
289
- part: lowest.part,
534
+ part,
290
535
  axis: 'y',
291
- mm: round(gap),
292
- detail: `the lowest part '${lowest.part}' floats ${round(gap)} mm above the floor the figure is declared to stand on`
536
+ mm: round(Math.abs(off)),
537
+ detail: `the ${surface.face} face of '${part}', which the asset declares as what it is mounted on, sits ${round(Math.abs(off))} mm ${off > 0 ? 'above' : 'below'} the plane the figure is mounted on`
293
538
  });
294
- }
295
- if (highest && placement === 'ceiling') {
296
- const gap = high.y - (highest.centre.y + highest.extent.y / 2);
297
- if (gap > SKIN)
539
+ // The plane's own normal is Y. Parallel either way: coincident planes, not a mounting direction.
540
+ const n = surface.normal;
541
+ const length = Math.hypot(n[0], n[1], n[2]);
542
+ const tilt = length > 0 ? (Math.acos(Math.min(1, Math.abs(n[1]) / length)) * 180) / Math.PI : 90;
543
+ if (tilt > CONTACT_TILT_DEG)
298
544
  push({
299
- code: 'PARTS_OFF_PLACEMENT_FACE',
300
- part: highest.part,
545
+ code: 'PLACEMENT_FACE_TILTED',
546
+ part,
301
547
  axis: 'y',
302
- mm: round(gap),
303
- detail: `the highest part '${highest.part}' hangs ${round(gap)} mm below the ceiling the figure is declared to hang from`
548
+ degrees: round(tilt),
549
+ detail: `the ${surface.face} face of '${part}' is declared as what the figure is mounted on, and it stands ${round(tilt)}° away from that plane; a face that is not parallel to it does not lie in it`
304
550
  });
305
551
  }
306
552
  }