@hatiolab/figure-model 0.1.33 → 0.1.35

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 (41) hide show
  1. package/dist/index.d.ts +6 -2
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +6 -2
  4. package/dist/index.js.map +1 -1
  5. package/dist/v3-asset-types.d.ts +26 -0
  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 +7 -2
  9. package/dist/v3-asset.js.map +1 -1
  10. package/dist/v3-capabilities.js +1 -0
  11. package/dist/v3-capabilities.js.map +1 -1
  12. package/dist/v3-driver.d.ts +16 -0
  13. package/dist/v3-driver.d.ts.map +1 -0
  14. package/dist/v3-driver.js +145 -0
  15. package/dist/v3-driver.js.map +1 -0
  16. package/dist/v3-from-v2.d.ts +109 -9
  17. package/dist/v3-from-v2.d.ts.map +1 -1
  18. package/dist/v3-from-v2.js +912 -119
  19. package/dist/v3-from-v2.js.map +1 -1
  20. package/dist/v3-gate.d.ts +56 -4
  21. package/dist/v3-gate.d.ts.map +1 -1
  22. package/dist/v3-gate.js +178 -21
  23. package/dist/v3-gate.js.map +1 -1
  24. package/dist/v3-graph-types.d.ts +6 -1
  25. package/dist/v3-graph-types.d.ts.map +1 -1
  26. package/dist/v3-graph.d.ts +17 -1
  27. package/dist/v3-graph.d.ts.map +1 -1
  28. package/dist/v3-graph.js +144 -20
  29. package/dist/v3-graph.js.map +1 -1
  30. package/dist/v3-kernel-version.d.ts +8 -0
  31. package/dist/v3-kernel-version.d.ts.map +1 -0
  32. package/dist/v3-kernel-version.js +11 -0
  33. package/dist/v3-kernel-version.js.map +1 -0
  34. package/dist/v3-mesh-compare.d.ts +27 -0
  35. package/dist/v3-mesh-compare.d.ts.map +1 -0
  36. package/dist/v3-mesh-compare.js +198 -0
  37. package/dist/v3-mesh-compare.js.map +1 -0
  38. package/docs/v3-motion-contract.md +178 -0
  39. package/docs/v3-operator-contracts.md +4 -0
  40. package/docs/v3-shape-dimension-contract.md +146 -0
  41. package/package.json +2 -1
@@ -0,0 +1,178 @@
1
+ # V3 motion contract — joints, parameters, animations
2
+
3
+ Status: **contract, fixed 2026-09-22; revised the same day after the chief architect's review; implemented the
4
+ same day** (ADR-0087 decision 2, ruling 4). Implementation: `src/v3-graph.ts` (`axis-turn@1`, `axis-slide@1`),
5
+ `src/v3-driver.ts` (`driverValue`, `driverStates`, `validateV3Drivers`), the motion part of `src/v3-from-v2.ts`
6
+ (`planMotion`), tests in `src/v3-motion.test.ts`. The converter and every consumer use this contract; none of them
7
+ defines motion on its own. Not yet covered by the converter: prismatic joints (none in the live assets; V2 scaled
8
+ their travel by the instance, the contract does not), scale channels (refused as shape deformation), a rotation
9
+ channel with a pivot off the part centre, channels that vary more than one axis, and parameter curves other than
10
+ two linear keys. The review changed three things: the direction of every transform is written down, continuous
11
+ rotation is split into an accumulated amount and a phase, and drivers carry an explicit time convention.
12
+
13
+ ## What a person sees
14
+
15
+ An author opens the six-axis robot arm. The arm stands in its rest pose. A panel lists six joint values, each with
16
+ its unit and range, and the default is where the arm was drawn. Dragging the elbow value bends the forearm about the
17
+ elbow, and everything hung from the forearm (wrist, tool flange, the box in the gripper) comes along. Nothing else
18
+ moves. Setting the value back to its default puts the arm exactly where it was drawn.
19
+
20
+ On a board, the same arm has the same six values. A connector writes them; the arm follows. Stretching the arm's
21
+ instance size does not change where its joints turn: a joint sits on the part that carries it and moves with that
22
+ part under sizing, as ADR-0066 decision 3 already says for V2.
23
+
24
+ ## One rule for how a child moves, with every direction written down
25
+
26
+ `X → Y` below means "takes coordinates written in X and gives them in Y".
27
+
28
+ | value | transform direction |
29
+ |---|---|
30
+ | parent world pose | parent → world |
31
+ | parent joint frame | jointParent → parent |
32
+ | `A = compose(parent world pose, parent joint frame)` | jointParent → world |
33
+ | `O = motion(q)` | **jointChild → jointParent** |
34
+ | `B` = child joint frame | jointChild → child |
35
+ | `attach@1(A, O, B) = A × O × inverse(B)` | child → world |
36
+
37
+ - `jointParent` and `jointChild` are two named frames per joint, `<joint>.onParent` and `<joint>.onChild`. They
38
+ **coincide at `q = 0`**: `motion(0)` is the identity, and the two frames are placed so that the child then sits in
39
+ its rest pose.
40
+ - A part with no joint to its parent has `motion = identity` (V2 `FigurePart.parent` without a joint).
41
+ - The parent and child joint frames come from the rest pose and sizing (`jointOriginPosition`), so a joint moves
42
+ with the part that carries it when the instance is resized.
43
+ - `attach@1` already computes `A × O × inverse(B)`, and its type checker refuses a chain whose frames do not meet.
44
+ No parent-chain node is added; `compose@1` and `attach@1` are enough.
45
+
46
+ **`q = 0` is the rest pose. That is not the same claim as "the default is the rest pose."** A V2 parameter whose
47
+ default is not 0 drew its rest pose at that default. The converter keeps V2's meaning: the joint frames are placed
48
+ for the drawn pose, and the state default stays what V2 stored. It never subtracts the default or forces it to 0.
49
+
50
+ ## Joint kinds, their state input, and the two motion operators
51
+
52
+ ```
53
+ axis-turn@1 args [ax, ay, az (ratio), q (angle)] params { from, to }
54
+ → pose from → to: right-handed rotation about the unit axis (ax, ay, az) by q, no translation
55
+ axis-slide@1 args [ax, ay, az (ratio), q (length)] params { from, to }
56
+ → pose from → to: translation by q along the unit axis (ax, ay, az), identity rotation
57
+ ```
58
+
59
+ - The axis is a unit vector **written in the jointParent frame**. Both operators fail on a non-unit axis
60
+ (|a| − 1 beyond 1e-9).
61
+ - Right-handed: looking down the axis toward the origin, positive `q` turns counter-clockwise. Positive `q` on a
62
+ slide moves along the axis direction.
63
+ - For a joint, `from = <joint>.onChild`, `to = <joint>.onParent`, so the operator's output is `O` above.
64
+ - An arbitrary axis is carried as three numbers and turned with axis–angle. It is **never** collapsed into one
65
+ Euler component.
66
+
67
+ | V2 `FigureJoint.type` | state input | unit stored | `motion(q)` |
68
+ |---|---|---|---|
69
+ | `revolute` | one angle, `role: 'state'`, V2 limits as range | rad (an input may declare `deg`; the kernel converts) | `axis-turn@1` |
70
+ | `prismatic` | one length, `role: 'state'`, V2 limits as range | mm | `axis-slide@1` |
71
+ | `continuous` | one **phase** angle, `role: 'state'`, range `[−180°, 180°)` | rad | `axis-turn@1` |
72
+
73
+ ### Continuous rotation: an amount and a phase, not a limit
74
+
75
+ A finite input range on a continuous joint is **not** a mechanical limit. Refusing 361° would make it not
76
+ continuous. So the value is split:
77
+
78
+ - **Accumulated rotation** (turns, total angle) lives at the boundary that produces it: the driver or the external
79
+ input. If turn counts are product data, they are preserved there, outside the kernel.
80
+ - **Phase** is what the kernel receives: the accumulated angle normalised into `[−180°, 180°)`. The kernel's finite
81
+ range is the range of poses it can express, nothing more.
82
+
83
+ **Normalise after interpolating, never before.** A driver going forward from 350° to 370° interpolates in
84
+ accumulated angle (350 → 360 → 370) and normalises each sample afterwards (350 → −180/180 → 10). Wrapping the key
85
+ values first would interpolate 350 → 10 and turn the wrong way. Widening the range does not fix this; the split does.
86
+
87
+ ## Parameters
88
+
89
+ A V2 `FigureParameter` keeps its **name, unit, range, default and target**. In V3 it is the state input itself:
90
+ `id` = the parameter name, `unit` and `min`/`max` from the range, `role: 'state'`, and the asset's `stateDefaults`
91
+ carries the default as V2 stored it. Its target is the joint (or part channel) whose `motion(q)` reads it. A
92
+ parameter that V2 let drive several joints becomes one input read by several `axis-turn@1` nodes: one writer, many
93
+ readers, which the graph already allows.
94
+
95
+ ## Drivers: the asset declares, figure-model evaluates, the consumer owns time
96
+
97
+ The kernel has **no clock**. Same inputs, same result, always. A V2 `AnimationClip` becomes a **driver**
98
+ declaration on the asset, not a graph node. Responsibilities:
99
+
100
+ | who | does |
101
+ |---|---|
102
+ | the asset | carries the driver data |
103
+ | figure-model | the one pure evaluation function `driverValue(driver, time) → state value`, and the validation rules |
104
+ | the consumer (things-scene, a thumbnail renderer, a test) | supplies time, plays and stops, chooses which control source is active |
105
+ | the spatial kernel | receives final state values and evaluates shape and pose |
106
+
107
+ **No consumer implements interpolation or looping on its own.** All of them call the same function.
108
+
109
+ ```
110
+ drivers: [{
111
+ id: 'run',
112
+ state: 'roller.spin', // the state input it writes
113
+ time: { unit: 's', duration: 0.833 },
114
+ keys: [{ at: 0, value: 0 }, { at: 1, value: 360 }], // `at` is a fraction 0..1 of duration
115
+ interpolation: 'linear',
116
+ loop: 'wrap', // 'wrap' | 'hold'
117
+ accumulates: true // continuous: keys are accumulated angle; the phase is taken after interpolation
118
+ }]
119
+ ```
120
+
121
+ - **Time convention, one for all drivers.** `time.unit` is seconds; `duration` is the clip length in seconds; every
122
+ key `at` is a fraction of the duration in `[0, 1]`. The converter normalises V2's two habits (seconds in
123
+ `duration`, fractions in `at`, ADR-0051 ②) into exactly this and records the source form in the report. A driver
124
+ with a missing or non-positive duration is refused.
125
+ - **Loop end.** `wrap`: time `t` maps to `t mod duration`, and the sample at `duration` equals the sample at 0 (the
126
+ last key's value is reached at `at = 1` and the next period starts from key 0). For an accumulating driver, wrapping
127
+ adds the full-period delta to the accumulated value, so the roller keeps turning rather than snapping back.
128
+ `hold`: for `t ≥ duration` the value stays at the last key.
129
+ - **One writer per state at a time.** A state input may be named by a driver and also written by a connector. The
130
+ asset's stateDefaults, drivers and external inputs are three sources; the consumer selects the **active control
131
+ source per state** explicitly (`driver` or `external`). Values are never summed and "last call wins" is not a
132
+ rule. figure-model's validation refuses two drivers naming the same state.
133
+
134
+ A V2 part channel (`path: 'rotation'` on a part with no joint, like the conveyor roller) converts to a continuous
135
+ state input on that part, `<part>.spin`, driving an `axis-turn@1` about the part's own axis, and a driver for the
136
+ clip with `accumulates: true`. The conveyor's rotation animation is therefore carried, not lost.
137
+
138
+ ## What the converter does with the seven motion assets
139
+
140
+ 1. For every `FigureJoint`: two joint frames from the rest pose and sizing, one state input, one `axis-turn@1` or
141
+ `axis-slide@1`, one `attach@1`, and the child's `place@1` reads the attached pose.
142
+ 2. For every part with `parent` and no joint: `compose@1` of the parent's pose and the rigid offset.
143
+ 3. For every `parameter`: a state input as above; refuse (`PARAMETER_TARGET`) if its target is not a joint or a part
144
+ channel this contract covers.
145
+ 4. For every animation clip: a driver, with the clip's channels mapped to state inputs; refuse
146
+ (`ANIMATION_CHANNEL`) for a channel path this contract does not cover (`scale`).
147
+
148
+ ## Two corrections to earlier wording
149
+
150
+ - **Rigid does not mean affine in the state.** A rigid pose is affine in the spatial coordinates it maps, but a
151
+ rotation matrix is a sin/cos function of the joint angle. So `axis-turn@1` is **not affine in `q`**, and the
152
+ operator-list guard is restated: `geomean@1` is the only node that is non-affine in the *design inputs*;
153
+ `axis-turn@1` is non-affine in a *state input*. The 8-size comparison therefore stays a limited regression check for
154
+ the rest pose and does not become a proof of equivalence under motion.
155
+ - **Rotation is compared as a relative angle**, the angle of `R₂ · R₁ᵀ`, never as a difference of Euler components.
156
+
157
+ ## "The same" for motion (completion criterion)
158
+
159
+ For every converted motion asset, V2 and V3 are compared at the base size and the doubled sizes with:
160
+
161
+ - each joint at its default, its mid-point and both limits;
162
+ - **parent and child joints changed at the same time**, not one at a time;
163
+ - at least one joint with an arbitrary (non-principal) axis;
164
+ - a non-zero default, checked at the default and away from it;
165
+ - a continuous joint just before and just after the phase boundary (e.g. 179° and 181°);
166
+ - a looping driver just before and just after the loop end (e.g. 0.99 and 1.01 durations), and a `hold` driver past
167
+ its end.
168
+
169
+ Same part list, same centre and extents within 1 mm, same relative rotation within 0.1°. For each driver, the state
170
+ value at time 0, half and end must equal V2's clip value at those times. Exact conversion and deliberate
171
+ re-authoring are marked separately in the report.
172
+
173
+ ## Not in this contract
174
+
175
+ - Inverse kinematics, reach checks, collision between links. Consumers may add them on top; the kernel does not.
176
+ - Physical time (velocity limits, easing between commanded values). ADR-0051 ② puts transition time on the instance;
177
+ a driver may implement it, the graph does not know it.
178
+ - Per-axis corner radii (ruling 6) and theme mapping (ruling 5): reviewed separately before the renderer starts.
@@ -78,6 +78,10 @@ args 순서와 output 이름은 버전별 연산 계약이다. 전체 연산 cat
78
78
  | geomean@1 | a,b[,c] (동일 차원) | value=(a·b[·c])^(1/n) | 음수 입력 |
79
79
  | sphere-shape@1 | radiusX,radiusY,radiusZ (길이) | shape(sphere) | 반지름≤0 |
80
80
  | polygon-shape@1 | height, x0,z0, x1,z1, … (길이, 점 3개 이상) | shape(polygon; 단면은 X–Z, Y 로 민다) | height≤0, 단면이 한 축으로 퍼지지 않음 |
81
+ | frustum-shape@1 | radiusTop,radiusBottom,height (길이) | shape(frustum; 축은 Y, radiusTop 이 +Y) | height≤0, 반지름 음수, 둘 다 0 |
82
+ | cylinder-shape@2 | radiusX,radiusZ,length (길이) | shape(ellipticCylinder; 축은 Y) | 반지름·길이≤0 |
83
+ | rounded-box@2 | width,height,depth,roundX,roundZ (길이) | shape(roundedBoxXZ; 모서리는 반축 roundX·roundZ 의 2차 Bézier) | 치수≤0, 반축이 단면의 절반 초과 |
84
+ | hollow-box@2 | width,height,depth,roundX,roundZ,wallX,wallZ,floor (길이) | shape(hollowBoxXZ) | rounded-box@2 의 조건 + hollow-box@1 의 벽·바닥 조건 |
81
85
  | hollow-box@1 | width,height,depth,round,wallX,wallZ,floor (길이) | shape(hollowBox) | 치수≤0, 라운드 범위 밖, 벽이 단면의 절반 이상, 바닥이 높이 이상 |
82
86
  | sin@1 | angle | value=sin(angle) | nonfinite |
83
87
  | between@1 | low,high,marginLow,marginHigh (길이) | length,center | 음수 여백, length≤0 |
@@ -0,0 +1,146 @@
1
+ # V3 shape-dimension contract — per-axis radii, elliptic sections, and dimensions that follow state
2
+
3
+ Status: **contract, fixed 2026-09-22 by the V3 designer** (ADR-0087, stage-review rulings 3 and 5). §1 (per-axis
4
+ dimensions) implemented the same day: `cylinder-shape@2`, `rounded-box@2`, `hollow-box@2`, the converter writing
5
+ them, the sampler reading them. §2–3 implemented the same night: dependence on state is tracked per value in the
6
+ compiler and printed as `dependence` beside `outputTypes`; every operator carries a `state` policy (`follows` or
7
+ `refuses`; an operator without one fails with `STATE_POLICY`); `occupancy.extent` may not depend on state
8
+ (`OCCUPANCY_STATE`); `inspectV3Asset` covers the state range and reports `proven` (corners; no state-dependent
9
+ rotation), `sampled` (a rotation depends on state; corners plus per-input sweeps, step stated, not a proof) or
10
+ `defaults` (one state, as asked). The `bounded` method of §3 (swept envelope) is not implemented; such assets report
11
+ `sampled`. The converter writes a two-key scale channel as dimensions × affine(parameter) for percent or ratio
12
+ parameters (`STATE_DIMENSION` note). This is one rule for what a shape's dimensions are and what they may depend on. It is not three
13
+ exceptions bolted on for the three assets that needed them.
14
+
15
+ ## What a person sees
16
+
17
+ An author opens the electrode roll cradle. A "fed" value sits at 0 %. As they drag it toward 100 %, the roll on the
18
+ cradle gets thinner: its radius shrinks, its length does not. The edge plates that ride on the roll shrink with it and
19
+ stay on its surface. Nothing else moves. At 0 % the roll is exactly the drawn roll.
20
+
21
+ An author stretches a kiosk to twice its width. The bezel's corners stay the same round in front view where the
22
+ section did not stretch, and go wider than they are tall where it did, because V2 drew them that way. Nothing pops
23
+ back to a circle on its own.
24
+
25
+ An author opens the worker figure and stretches it sideways. The legs, which the author chose not to keep round,
26
+ become oval like V2 drew them. The renderer draws the oval it is given. It never "fixes" it to a circle.
27
+
28
+ ## 1. Every round thing has per-axis dimensions
29
+
30
+ A section is round only when its two cross dimensions are equal. The providers write both:
31
+
32
+ | operator | evaluated provider | dimensions | round when | status |
33
+ |---|---|---|---|---|
34
+ | `cylinder-shape@2` | `ellipticCylinder` | `radiusX`, `radiusZ`, `length` (axis Y) | `radiusX = radiusZ` | implemented 2026-09-22 |
35
+ | `frustum-shape@2` | — | `topRadiusX`, `topRadiusZ`, `bottomRadiusX`, `bottomRadiusZ`, `height` | pairs equal | not implemented; no V2 shape needs it (V2 draws no frustum) |
36
+ | `sphere-shape@1` | `sphere` | `radiusX`, `radiusY`, `radiusZ` (already so) | all equal | implemented |
37
+ | `rounded-box@2` | `roundedBoxXZ` | `width`, `height`, `depth`, `roundX`, `roundZ` | `roundX = roundZ` | implemented 2026-09-22 |
38
+ | `hollow-box@2` | `hollowBoxXZ` | as `hollow-box@1` with `roundX`, `roundZ` in place of `round` | same | implemented 2026-09-22 |
39
+
40
+ The evaluated provider name is distinct from the `@1` one so a renderer can never mistake per-axis dimensions for a
41
+ single radius. Features on `ellipticCylinder` are `start` and `end` only; `radialBottom` has no single meaning on an
42
+ ellipse.
43
+
44
+ - `@1` of each provider stays valid and means the equal case; the converter writes `@2` only where V2 drew an
45
+ unequal section. Nothing that exists today is invalidated.
46
+ - **The renderer draws what it is given.** An elliptic section is an ellipse; an elliptic corner is the quadratic
47
+ Bézier from `(hw − roundX, −hd)` to `(hw, −hd + roundZ)` with the corner `(hw, −hd)` as control point, which is
48
+ what things-scene's `roundedRect` draws and what `src/v3-mesh-compare.ts` samples. It is not a circular or
49
+ elliptic arc: an arc differs from the Bézier by up to 0.06 × round at the corner's middle. A renderer that snaps a
50
+ section to a circle is wrong, and the mesh-level comparison of the completion criterion is what catches it.
51
+ - Validity: every dimension positive (a corner radius may be 0), `roundX ≤ width/2`, `roundZ ≤ depth/2`. A
52
+ declared corner larger than the section is refused at compile; the converter normalises V2's declared value to
53
+ what V2 drew (ruling 2) and reports it.
54
+
55
+ ### What `keepRound` becomes
56
+
57
+ V2's `keepRound` was a rule about **what happens to a round section under non-uniform sizing**: keep it round by
58
+ the geometric mean of the two factors. In V3 that is not a flag; it is two possible expressions for two dimensions:
59
+
60
+ ```
61
+ kept round radiusX = radiusZ = r₀ · geomean(fx, fz) (V2 keepRound on, the default)
62
+ follows axes radiusX = r₀ · fx, radiusZ = r₀ · fz (V2 keepRound off)
63
+ ```
64
+
65
+ Both are ordinary length expressions. The converter chooses by the V2 flag and writes the expression; the
66
+ `ELLIPTIC_SECTION` refusal is now a note on the report (implemented 2026-09-22), and `CORNER_PER_AXIS` notes a
67
+ corner written with two semi-axes. The same two shapes apply to corners: V2 scaled a rounded box's finished mesh
68
+ per axis, so a corner drawn with radius `r₀` becomes `roundX = r₀·fx`, `roundZ = r₀·fz`. That is what
69
+ `ROUND_NOT_SCALED` (40 parts in the last run) was reporting as a loss; with `@2` corners it is no longer a loss.
70
+
71
+ ## 2. A dimension may depend on state
72
+
73
+ A state input (`role: 'state'`) may be an argument of a shape or length node. A consumed roll's radius is
74
+ `r₀ − fed · k`; a lamp's glow radius grows with the gate. This is the core-semantics §2 invariant ("state changes the
75
+ actual shape dimension") made concrete, and it is what a V2 `scale` channel means: `dims × affine(p)`.
76
+
77
+ ### Dependence is tracked in the type, apart from unit and frame
78
+
79
+ Every value carries, next to its unit or frame, a **dependence set**: which state inputs its value can change with.
80
+ Inputs of role `state` start with `{self}`; constants and design inputs with `∅`; every operator's outputs carry the
81
+ union of its arguments' sets, unless the operator's contract says otherwise. This is the existing `stateful`
82
+ tracking in `compileV3Graph`, made a first-class part of the type and printed in `outputTypes`.
83
+
84
+ ### Every consuming operator declares its policy
85
+
86
+ An operator that receives a state-dependent argument does one of three things, **written in its contract**, and a
87
+ new operator that says nothing fails to compile the moment such an argument reaches it. There is no list of banned
88
+ operators to keep in sync.
89
+
90
+ | policy | meaning | operators |
91
+ |---|---|---|
92
+ | `follows` | the output depends on state; fine | shape providers, `rigid`, `compose`, `attach`, `axis-turn`, `axis-slide`, `add`, `mul`, `div`, `geomean`, `feature`, `place`, `member` |
93
+ | `refuses` | a state-dependent argument is a compile error | `fit-pitch@1`, `fixed-count@1` (count and pitch: structure and cost stay design-only; this is today's `TOPOLOGY_STATE`), the three lengths named by `occupancy.extent` |
94
+ | `bounded` | allowed, and the contract states the resource limit that holds over the whole state range | none today; the slot for a future layout that may follow state |
95
+
96
+ So: shape, attachment position and pose may follow state (a plate riding on the thinning roll follows it); the
97
+ declared occupancy is a state-independent contract; structure- and cost-changing operations say so themselves.
98
+
99
+ ## 3. The occupancy holds over the whole valid state range
100
+
101
+ `occupancy` is the contract a board's placement relies on (ADR-0087 decision 7). A volume that is true at the
102
+ default state and false at another is not that contract. So `inspectV3Asset` answers for the **whole** valid state
103
+ range, and says how it knows:
104
+
105
+ | how the geometry depends on state | how the check is done | what the report says |
106
+ |---|---|---|
107
+ | no state dependence | one evaluation at the defaults | `proven` |
108
+ | every state-dependent world coordinate is affine or monotone in each state input (shape dimensions from `add`/`mul`/`geomean` of a single state input; slides along an axis) | evaluate at every corner of the state box (each input at min and max); the extremes are at the corners | `proven`, listing the corners |
109
+ | a rotation or other non-linear composition is in the chain (`axis-turn` under a state input) | a conservative bound: the swept envelope of the turning subtree over the joint's range, or an extremum search on the offending coordinate with a stated step | `bounded`, with the method |
110
+ | neither is available | sample the range with a stated step | `sampled`, with the range and the samples; **never** worded as a whole-range guarantee |
111
+
112
+ - A violation found anywhere in the range is a violation. The gate never widens the occupancy to make it go away.
113
+ The author either declares a volume that covers the valid range, or narrows the allowed state range.
114
+ - The endpoint-only shortcut is **wrong** for a rotating long part: inside at both end angles, out in the middle.
115
+ That case takes the `bounded` row, not the `proven` one.
116
+
117
+ ## 4. What the converter writes with this contract
118
+
119
+ - V2 cylinder with `keepRound: false` and differing cross factors → `cylinder-shape@2` with `radiusX`, `radiusZ`
120
+ following their own axes. WORKER converts; `ELLIPTIC_SECTION` is retired.
121
+ - V2 rect or hollow rect whose section follows the instance → `rounded-box@2` / `hollow-box@2` with per-axis corner
122
+ radii `r·fx`, `r·fz`. `ROUND_NOT_SCALED` is retired.
123
+ - V2 `scale` channel on a part, two linear keys, from a parameter → each scaled dimension becomes
124
+ `dim × affine(p)`; the part's centre is unchanged (V2 scaled about the part centre). The dependence set of every
125
+ affected length includes the parameter. DRY_ROOM's lamps and ROLL_CRADLE's roll convert; `ANIMATION_CHANNEL`
126
+ stays only for channels the contract does not cover (pivots, compound axes, curves other than two linear keys).
127
+ - The report marks every state-dependent dimension it wrote, and the occupancy inspection of a converted asset
128
+ runs over the parameter ranges as §3 says.
129
+
130
+ ## 5. Completion criterion for this contract
131
+
132
+ - The four providers' `@2` forms compile, evaluate, and their `@1` forms are unchanged (the operator-list guard
133
+ gains the four names).
134
+ - Dependence appears in `outputTypes`; a synthetic new operator with no policy fails to compile when handed a
135
+ state-dependent value; `fit-pitch@1` and `fixed-count@1` still refuse it; a shape provider accepts it.
136
+ - The three assets convert and match V2 at box level at 8 sizes × states, and the WORKER's legs match as ovals.
137
+ - An occupancy check on the roll cradle reports `proven` over the `fed` range, and a synthetic rotating long part
138
+ reports `bounded`, with a violation found at a middle angle that the endpoints alone would have missed.
139
+ - The mesh-level comparison of the completion criterion covers sections and corners, so an elliptic section drawn
140
+ round would fail it.
141
+
142
+ ## Not in this contract
143
+
144
+ - Theme mapping and the colour cache (renderer work; ADR-0087 stage review ruling 4).
145
+ - Shape deformation beyond dimensions: bending, twisting, free-form profiles that change with state. Those would be
146
+ new providers with their own contracts.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hatiolab/figure-model",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
4
4
  "description": "Figure 저작 결과의 정본 형식과 검증 — 재질별로 병합되고 이름이 붙은 부품 그래프. 3D 로 저작한 형상 하나가 씬 컴포넌트로 서는 데 필요한 것을 담는다.",
5
5
  "keywords": [
6
6
  "figure",
@@ -62,6 +62,7 @@
62
62
  "access": "public"
63
63
  },
64
64
  "scripts": {
65
+ "version": "node scripts/sync-kernel-version.mjs && git add src/v3-kernel-version.ts",
65
66
  "build": "tsc -p tsconfig.build.json",
66
67
  "check": "tsc -p tsconfig.json --noEmit",
67
68
  "test": "npm run check && node --test src/*.test.ts",