@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.
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/v3-asset-types.d.ts +26 -0
- package/dist/v3-asset-types.d.ts.map +1 -1
- package/dist/v3-asset.d.ts.map +1 -1
- package/dist/v3-asset.js +7 -2
- package/dist/v3-asset.js.map +1 -1
- package/dist/v3-capabilities.js +1 -0
- package/dist/v3-capabilities.js.map +1 -1
- package/dist/v3-driver.d.ts +16 -0
- package/dist/v3-driver.d.ts.map +1 -0
- package/dist/v3-driver.js +145 -0
- package/dist/v3-driver.js.map +1 -0
- package/dist/v3-from-v2.d.ts +109 -9
- package/dist/v3-from-v2.d.ts.map +1 -1
- package/dist/v3-from-v2.js +912 -119
- package/dist/v3-from-v2.js.map +1 -1
- package/dist/v3-gate.d.ts +56 -4
- package/dist/v3-gate.d.ts.map +1 -1
- package/dist/v3-gate.js +178 -21
- package/dist/v3-gate.js.map +1 -1
- package/dist/v3-graph-types.d.ts +6 -1
- package/dist/v3-graph-types.d.ts.map +1 -1
- package/dist/v3-graph.d.ts +17 -1
- package/dist/v3-graph.d.ts.map +1 -1
- package/dist/v3-graph.js +144 -20
- package/dist/v3-graph.js.map +1 -1
- package/dist/v3-kernel-version.d.ts +8 -0
- package/dist/v3-kernel-version.d.ts.map +1 -0
- package/dist/v3-kernel-version.js +11 -0
- package/dist/v3-kernel-version.js.map +1 -0
- package/dist/v3-mesh-compare.d.ts +27 -0
- package/dist/v3-mesh-compare.d.ts.map +1 -0
- package/dist/v3-mesh-compare.js +198 -0
- package/dist/v3-mesh-compare.js.map +1 -0
- package/docs/v3-motion-contract.md +178 -0
- package/docs/v3-operator-contracts.md +4 -0
- package/docs/v3-shape-dimension-contract.md +146 -0
- 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.
|
|
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",
|