@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.
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/v3-asset-types.d.ts +89 -16
- package/dist/v3-asset-types.d.ts.map +1 -1
- package/dist/v3-asset.d.ts.map +1 -1
- package/dist/v3-asset.js +34 -25
- package/dist/v3-asset.js.map +1 -1
- package/dist/v3-authoring-actions.d.ts +226 -0
- package/dist/v3-authoring-actions.d.ts.map +1 -0
- package/dist/v3-authoring-actions.js +812 -0
- package/dist/v3-authoring-actions.js.map +1 -0
- package/dist/v3-capabilities.d.ts.map +1 -1
- package/dist/v3-capabilities.js +4 -77
- package/dist/v3-capabilities.js.map +1 -1
- package/dist/v3-cost.d.ts +14 -0
- package/dist/v3-cost.d.ts.map +1 -0
- package/dist/v3-cost.js +69 -0
- package/dist/v3-cost.js.map +1 -0
- package/dist/v3-driver.d.ts +24 -1
- package/dist/v3-driver.d.ts.map +1 -1
- package/dist/v3-driver.js +68 -2
- package/dist/v3-driver.js.map +1 -1
- package/dist/v3-from-v2.d.ts +25 -1
- package/dist/v3-from-v2.d.ts.map +1 -1
- package/dist/v3-from-v2.js +598 -21
- package/dist/v3-from-v2.js.map +1 -1
- package/dist/v3-gate.d.ts +59 -6
- package/dist/v3-gate.d.ts.map +1 -1
- package/dist/v3-gate.js +291 -45
- package/dist/v3-gate.js.map +1 -1
- package/dist/v3-graph-types.d.ts +9 -1
- package/dist/v3-graph-types.d.ts.map +1 -1
- package/dist/v3-graph.d.ts.map +1 -1
- package/dist/v3-graph.js +53 -1
- package/dist/v3-graph.js.map +1 -1
- package/dist/v3-json.d.ts +0 -1
- package/dist/v3-json.d.ts.map +1 -1
- package/dist/v3-json.js +16 -1
- package/dist/v3-json.js.map +1 -1
- package/dist/v3-kernel-version.d.ts +1 -1
- package/dist/v3-kernel-version.js +1 -1
- package/dist/v3-mesh-compare.d.ts +2 -5
- package/dist/v3-mesh-compare.d.ts.map +1 -1
- package/dist/v3-mesh-compare.js +2 -145
- package/dist/v3-mesh-compare.js.map +1 -1
- package/dist/v3-shape-sampling.d.ts +14 -0
- package/dist/v3-shape-sampling.d.ts.map +1 -0
- package/dist/v3-shape-sampling.js +120 -0
- package/dist/v3-shape-sampling.js.map +1 -0
- package/dist/v3-surface.d.ts +15 -0
- package/dist/v3-surface.d.ts.map +1 -0
- package/dist/v3-surface.js +104 -0
- package/dist/v3-surface.js.map +1 -0
- package/docs/prototypes/v3-asset.schema.json +0 -8
- package/docs/v3-asset-persistence.md +1 -1
- package/docs/v3-legacy-risk-audit-2026-09-23.md +39 -0
- package/docs/v3-operator-contracts.md +1 -0
- package/docs/v3-shape-dimension-contract.md +111 -0
- 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-
|
|
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
|
|
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
|
|
68
|
-
|
|
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[];
|
package/dist/v3-gate.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"v3-gate.d.ts","sourceRoot":"","sources":["../src/v3-gate.ts"],"names":[],"mappings":"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
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
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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 =
|
|
477
|
+
const boxes = v3OccupiedBoxesOf(geometry, v3WritersOf(asset));
|
|
258
478
|
const round = (v) => Math.round(v * 1e4) / 1e4;
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
534
|
+
part,
|
|
290
535
|
axis: 'y',
|
|
291
|
-
mm: round(
|
|
292
|
-
detail: `the
|
|
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
|
-
|
|
296
|
-
const
|
|
297
|
-
|
|
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: '
|
|
300
|
-
part
|
|
545
|
+
code: 'PLACEMENT_FACE_TILTED',
|
|
546
|
+
part,
|
|
301
547
|
axis: 'y',
|
|
302
|
-
|
|
303
|
-
detail: `the
|
|
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
|
}
|