@things-factory/figure-ui 10.1.116 → 10.1.117

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 (35) hide show
  1. package/client/modeller/editor.ts +2 -2
  2. package/client/modeller/fasten-geometry.ts +1 -1
  3. package/client/modeller/figure-links.ts +33 -11
  4. package/client/modeller/joint-axis.ts +36 -0
  5. package/client/modeller/joint-drag.ts +27 -7
  6. package/client/modeller/joint-fan.ts +2 -1
  7. package/client/modeller/links.ts +43 -10
  8. package/dist-client/modeller/clips.d.ts +1 -1
  9. package/dist-client/modeller/editor.js +2 -2
  10. package/dist-client/modeller/editor.js.map +1 -1
  11. package/dist-client/modeller/fasten-geometry.js +1 -1
  12. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  13. package/dist-client/modeller/figure-links.d.ts +1 -0
  14. package/dist-client/modeller/figure-links.js +31 -12
  15. package/dist-client/modeller/figure-links.js.map +1 -1
  16. package/dist-client/modeller/joint-axis.d.ts +13 -0
  17. package/dist-client/modeller/joint-axis.js +31 -0
  18. package/dist-client/modeller/joint-axis.js.map +1 -0
  19. package/dist-client/modeller/joint-drag.js +26 -6
  20. package/dist-client/modeller/joint-drag.js.map +1 -1
  21. package/dist-client/modeller/joint-fan.js +2 -1
  22. package/dist-client/modeller/joint-fan.js.map +1 -1
  23. package/dist-client/modeller/links.d.ts +16 -1
  24. package/dist-client/modeller/links.js +39 -9
  25. package/dist-client/modeller/links.js.map +1 -1
  26. package/dist-client/route.d.ts +1 -1
  27. package/dist-client/tsconfig.tsbuildinfo +1 -1
  28. package/package.json +5 -5
  29. package/test/command-preview.test.ts +2 -2
  30. package/test/joint-frames.test.ts +293 -0
  31. package/translations/en.json +1 -1
  32. package/translations/ja.json +1 -1
  33. package/translations/ko.json +1 -1
  34. package/translations/ms.json +1 -1
  35. package/translations/zh.json +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.116",
3
+ "version": "10.1.117",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -27,8 +27,8 @@
27
27
  "clean": "npm run clean:server && npm run clean:client"
28
28
  },
29
29
  "dependencies": {
30
- "@hatiolab/figure-model": "^0.3.2",
31
- "@hatiolab/things-scene": "^10.1.71",
30
+ "@hatiolab/figure-model": "^0.3.3",
31
+ "@hatiolab/things-scene": "^10.1.72",
32
32
  "@material/web": "^2.0.0",
33
33
  "@operato/data-grist": "^10.0.0",
34
34
  "@operato/graphql": "^10.0.0",
@@ -41,8 +41,8 @@
41
41
  "@operato/styles": "^10.0.0",
42
42
  "@operato/utils": "^10.0.0",
43
43
  "@things-factory/ai-assistant": "^10.1.116",
44
- "@things-factory/figure-service": "^10.1.116",
44
+ "@things-factory/figure-service": "^10.1.117",
45
45
  "three": "^0.185.1"
46
46
  },
47
- "gitHead": "49fa935cc703be70200eef4d29f473de98f871ce"
47
+ "gitHead": "cab2426c1c6e63b53a0a2087eef28c9c55df71dd"
48
48
  }
@@ -104,7 +104,7 @@ test('the details fold, and what they hold that is not the default stays said --
104
104
  /* ADR-0097 §5.5 (Q2): the list stays flat; a part says what it is fastened to, beside its name. */
105
105
  test('the part list names what each part is fastened to, without reordering or nesting it', () => {
106
106
  const editor = read('../client/modeller/editor.ts')
107
- assert.ok(editor.includes("'plane' in j.body0 ? i18next.t('figure.label.mounting-plane') : `${partLabelOf(j.body0.part)} · ${i18next.t(`figure.label.face-${j.body0.face}`)}`"))
107
+ assert.ok(editor.includes("'plane' in j.body0 ? i18next.t('figure.label.mounting-plane') : `${partLabelOf(j.body0.part)} · ${i18next.t(endPlaceKeyOf(j.body0))}`"))
108
108
  assert.ok(editor.includes('...(fastened.has(id) ? { fastenedTo: fastened.get(id)! } : {})'))
109
109
  const parts = read('../client/modeller/figure-parts.ts')
110
110
  assert.ok(parts.includes('<small fastened-to title=${i18next.t(\'figure.label.fastened-to\')}>${part.fastenedTo}</small>'))
@@ -122,7 +122,7 @@ test('detaching says what it would do: stays, what stays fastened to it, the cli
122
122
  /* Staying where it is is as expected: nothing is said then (user 2026-10-01); a move, a child kept, a clip lost are. */
123
123
  assert.ok(editor.includes(': /* It stays where it is: as expected, so nothing is said (user 2026-10-01). */ []'), 'nothing said when it stays')
124
124
  const links = read('../client/modeller/figure-links.ts')
125
- assert.equal(links.match(/\$\{this\.renderDetachWords\(\)\}/g)?.length, 2, 'beside both buttons that detach')
125
+ assert.equal(links.match(/\$\{this\.renderDetachWords\(\)\}/g)?.length, 3, 'beside every button that detaches (a part at an origin has its own)')
126
126
  for (const lang of ['en', 'ja', 'ko', 'ms', 'zh']) {
127
127
  const t = JSON.parse(read(`../translations/${lang}.json`))
128
128
  for (const key of ['detach-moves', 'detach-keeps-children', 'detach-clips', 'detach-refused']) assert.ok(t[`figure.text.${key}`], `${lang} ${key}`)
@@ -0,0 +1,293 @@
1
+ /*
2
+ * **관절 틀 (ADR-0107 결정 1·4) 을 화면이 읽고 그대로 돌려 쓴다** — 어느 방향이든 되는 축, 부품 기준점에 놓인 관절 끝,
3
+ * `origin.pivot` · `offset` · `rotate`. 움직임 폼 · 붙이기 폼 · 기즈모 끌기 · 움직임 없애기가 이 값들을 바꾸지 않고, 저장하고
4
+ * 다시 열어도 그대로다. 그 틀이 없는 커널(0.3.2)에서는 옛 모양만 같은 길로 본다.
5
+ */
6
+ import { test } from 'node:test'
7
+ import assert from 'node:assert/strict'
8
+
9
+ import { addFigureBox, applyFigureAuthoring, compileFigureAsset, createFigureAsset, parseFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
10
+ import type { FigureAsset, FigureAuthoringAction, FigureJoint } from '@hatiolab/figure-model'
11
+
12
+ import { jointAxisUnit, jointAxisWord } from '../client/modeller/joint-axis.ts'
13
+ import { alignOf, endPlaceKeyOf, fasteningOf, fixedJointOf, motionFormOf, motionFormRefusalOf, motionJoint, motionOf, rowAxesOf, rowBlockOf } from '../client/modeller/links.ts'
14
+ import { jointDragOf } from '../client/modeller/joint-drag.ts'
15
+ import { jointFanOf } from '../client/modeller/joint-fan.ts'
16
+
17
+ type Dynamic = any
18
+ const set = (asset: FigureAsset, joint: Dynamic) => applyFigureAuthoring(asset, { kind: 'set-joint', joint } as FigureAuthoringAction)
19
+ const box = (asset: FigureAsset, id: string, x: number, y: number, z: number, at = 0) => addFigureBox(asset, { id, size: { x, y, z }, position: { x: 0, y: at, z: 0 } })
20
+ const drawn = (asset: FigureAsset, id: string, state: Record<string, number> = {}) =>
21
+ compileFigureAsset(asset).evaluate(state).geometry.find(g => g.id === id) as never as { id: string; pose: { t: number[]; r: number[][] } }
22
+
23
+ /** Whether this kernel knows the joint frames: an axis that is a vector is refused by one that does not. */
24
+ const FRAMES = (() => {
25
+ try {
26
+ let a = box(createFigureAsset('probe'), 'p', 10, 10, 10, 5)
27
+ a = set(a, { id: 'p', type: 'revolute', body0: { plane: 'mounting-plane' }, body1: { part: 'p', face: 'bottom' }, axis: { x: 0, y: 1, z: 0 } })
28
+ return !!a.joints?.some(j => typeof j.axis === 'object')
29
+ } catch {
30
+ return false
31
+ }
32
+ })()
33
+ const framesOnly = { skip: FRAMES ? false : 'the kernel has no ADR-0107 joint frames' }
34
+
35
+ const SUMMED = { sum: [{ from: { of: 'part', part: 'link', axis: 'y' }, times: 0.5 }], plus: 10 }
36
+
37
+ /**
38
+ * A base on the floor; a link turning on its top about (1, 1, 0), by the middle of its bottom, turned and moved; a tool at
39
+ * the link's origin, its frame moved by a summed offset and turned; a cap at the tool's origin by its centre, held.
40
+ */
41
+ function framed(): FigureAsset {
42
+ let a = box(createFigureAsset('Frames'), 'base', 300, 100, 300, 50)
43
+ a = box(a, 'link', 100, 400, 100, 300)
44
+ a = box(a, 'tool', 60, 120, 40)
45
+ a = box(a, 'cap', 20, 20, 20)
46
+ a = set(a, { id: 'base', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'base', face: 'bottom' } })
47
+ a = set(a, { id: 'link', type: 'revolute', body0: { part: 'base', face: 'top' }, body1: { part: 'link', face: 'bottom' }, origin: { pivot: 'child-face', offset: { x: 20 }, rotate: { x: 10 } }, axis: { x: 1, y: 1, z: 0 }, lowerLimit: -90, upperLimit: 90 })
48
+ a = set(a, { id: 'tool', type: 'revolute', body0: { part: 'link', at: 'origin' }, body1: { part: 'tool', at: 'origin' }, origin: { offset: { x: 120, y: SUMMED, z: -30 }, rotate: { x: 15, y: -30, z: 60 } }, axis: 'Z', lowerLimit: -45, upperLimit: 45 })
49
+ return set(a, { id: 'cap', type: 'fixed', body0: { part: 'tool', at: 'origin' }, body1: { part: 'cap', face: 'centre' }, origin: { offset: { y: 40 } } })
50
+ }
51
+
52
+ /** The old shapes only: a token axis, face ends, a gap and an edge. */
53
+ function plain(): FigureAsset {
54
+ let a = box(createFigureAsset('Plain'), 'base', 300, 100, 300, 50)
55
+ a = box(a, 'link', 100, 400, 100, 300)
56
+ a = set(a, { id: 'base', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'base', face: 'bottom' } })
57
+ return set(a, { id: 'link', type: 'revolute', body0: { part: 'base', face: 'top' }, body1: { part: 'link', face: 'bottom' }, origin: { gap: 5, align: { x: 'min' } }, axis: 'Y', lowerLimit: -90, upperLimit: 90 })
58
+ }
59
+
60
+ const reopen = (asset: FigureAsset) => parseFigureAsset(serializeFigureAsset(asset) as unknown as string)
61
+ const jointOf = (asset: FigureAsset, id: string) => asset.joints!.find(j => j.id === id)! as Dynamic
62
+ /** What the frames say of a joint: what no form path may change. */
63
+ const frameOf = (j: Dynamic, keepType = true) => ({
64
+ ...(keepType ? { type: j.type, axis: j.axis } : {}),
65
+ body0: j.body0,
66
+ body1: j.body1,
67
+ pivot: j.origin?.pivot,
68
+ offset: j.origin?.offset,
69
+ rotate: j.origin?.rotate
70
+ })
71
+
72
+ /** The fastening form's 붙이기, as figure-links builds it from what `fasteningOf` reads; nothing where it writes none. */
73
+ function fasteningSave(asset: FigureAsset, part: string): FigureAuthoringAction | null {
74
+ const f = fasteningOf(asset, part) as Dynamic
75
+ if (!f || f.atOrigin) return null
76
+ const aim = f.toPlane ? ('plane' as const) : { part: f.target, face: f.targetFace }
77
+ const align = alignOf(asset, part, aim, f.align, f.alignMm, !!f.twist, f.alignInput)
78
+ return {
79
+ kind: 'attach',
80
+ part,
81
+ face: f.face,
82
+ to: f.toPlane ? { plane: 'mounting-plane' } : { part: f.target, face: f.targetFace },
83
+ ...(f.toPlane ? {} : { facing: f.facing }),
84
+ ...(f.gap ? { gap: f.gap } : {}),
85
+ twist: f.twist,
86
+ ...(align ? { align } : {}),
87
+ replace: true
88
+ } as FigureAuthoringAction
89
+ }
90
+
91
+ /** Every form path that writes a joint, each applied, saved and opened again. */
92
+ function roundTrips(asset: FigureAsset, id: string) {
93
+ const j = jointOf(asset, id)
94
+ const part = j.body1.part
95
+ const out: Record<string, FigureJoint> = {}
96
+ if (j.type !== 'fixed') out.motion = jointOf(reopen(set(asset, motionJoint(j, motionFormOf(j), part))), id)
97
+ const fasten = fasteningSave(asset, part)
98
+ if (fasten) out.fastening = jointOf(reopen(applyFigureAuthoring(asset, fasten)), id)
99
+ out.fixed = jointOf(reopen(set(asset, fixedJointOf(j))), id)
100
+ const at = drawn(asset, part)
101
+ /* 30 along Z: the figure's, or at an origin the parent's own Z, as the offset is measured. */
102
+ const z = 'at' in j.body0 ? [0, 1, 2].map(i => drawn(asset, j.body0.part).pose.r[i]![2]!) : [0, 0, 1]
103
+ const by = z.map(v => v * 30)
104
+ const drag = jointDragOf(asset, part, at as never, { position: at.pose.t.map((v, i) => v + by[i]!) as [number, number, number] }, {})
105
+ assert.equal(drag?.kind, 'joint', `${id}: ${JSON.stringify(drag)}`)
106
+ const dragged = reopen(set(asset, (drag as { joint: FigureJoint }).joint))
107
+ out.drag = jointOf(dragged, id)
108
+ /* The part lands where it was dragged to. */
109
+ const landed = drawn(dragged, part).pose.t
110
+ assert.ok(Math.max(...by.map((d, i) => Math.abs(landed[i]! - at.pose.t[i]! - d))) < 0.2, `${id}: landed ${landed}, from ${at.pose.t}`)
111
+ return out
112
+ }
113
+
114
+ test('the axis reader: a token is its axis, a vector its unit vector, unrounded; said as written', () => {
115
+ assert.deepEqual(jointAxisUnit('Y'), [0, 1, 0])
116
+ assert.deepEqual(jointAxisUnit('z'), [0, 0, 1])
117
+ assert.deepEqual(jointAxisUnit({ x: 0, y: 3, z: 4 }), [0, 0.6, 0.8])
118
+ const tilted = jointAxisUnit({ x: 1, y: 1, z: 0 })!
119
+ assert.ok(Math.abs(tilted[0] - Math.SQRT1_2) < 1e-15 && tilted[0] === tilted[1] && tilted[2] === 0, `not rounded to a token: ${tilted}`)
120
+ assert.equal(jointAxisUnit({ x: 0, y: 0, z: 0 }), undefined)
121
+ assert.equal(jointAxisUnit(undefined), undefined)
122
+ assert.equal(jointAxisWord('X'), 'X')
123
+ assert.equal(jointAxisWord({ x: 1, y: 1, z: 0 }), '(1, 1, 0)')
124
+ })
125
+
126
+ test('a follower is not a turn without end: it has no limits because its leader gives its range', () => {
127
+ let a = box(createFigureAsset('Pair'), 'a', 100, 100, 100, 50)
128
+ a = box(a, 'b', 50, 50, 50, 125)
129
+ a = set(a, { id: 'a', type: 'revolute', body0: { plane: 'mounting-plane' }, body1: { part: 'a', face: 'bottom' }, axis: 'Y', lowerLimit: -90, upperLimit: 90 })
130
+ a = set(a, { id: 'b', type: 'revolute', body0: { part: 'a', face: 'top' }, body1: { part: 'b', face: 'bottom' }, axis: 'Y', mimic: { joint: 'a', multiplier: -1 } })
131
+ assert.equal(motionOf(a, 'b')?.kind, 'turn')
132
+ assert.equal(motionFormOf(jointOf(a, 'b')).kind, 'turn')
133
+ assert.equal(motionOf(a, 'a')?.kind, 'turn')
134
+ const free = set(a, { id: 'a', type: 'revolute', body0: { plane: 'mounting-plane' }, body1: { part: 'a', face: 'bottom' }, axis: 'Y' })
135
+ assert.equal(motionOf(free, 'a')?.kind, 'continuous', 'a joint of its own without limits still turns without end')
136
+ })
137
+
138
+ test('a range need not hold 0; it needs its most above its least', () => {
139
+ const form = motionFormOf(jointOf(plain(), 'link'))
140
+ assert.equal(motionFormRefusalOf({ ...form, min: 10, max: 90 }, ''), undefined)
141
+ assert.equal(motionFormRefusalOf({ ...form, min: -90, max: -10 }, ''), undefined)
142
+ assert.equal(motionFormRefusalOf({ ...form, min: 90, max: 10 }, ''), 'range')
143
+ assert.equal(motionFormRefusalOf({ ...form, min: 10, max: 10 }, ''), 'range')
144
+ assert.equal(motionFormRefusalOf({ ...form, min: NaN, max: 10 }, ''), 'range')
145
+ assert.equal(motionFormRefusalOf({ ...form, multiplier: 0 }, 'other'), 'multiplier')
146
+ /* A new control takes it and starts at the nearest end. */
147
+ const still = set(plain(), fixedJointOf(jointOf(plain(), 'link')))
148
+ const fixed = jointOf(still, 'link')
149
+ const lifted = set(still, motionJoint(fixed, { ...motionFormOf(fixed), kind: 'turn', min: 10, max: 90 }, 'link'))
150
+ assert.equal(lifted.stateDefaults[jointOf(lifted, 'link').state.id], 10)
151
+ /* An existing control keeps its name and its stored value: a range that leaves that value out is the kernel's refusal (R1). */
152
+ const j = jointOf(plain(), 'link')
153
+ assert.throws(() => set(plain(), motionJoint(j, { ...motionFormOf(j), min: 10, max: 90 }, 'link')), /INPUT_RANGE/)
154
+ })
155
+
156
+ test('old shapes: every form path writes the joint back as it was, saved and reopened', () => {
157
+ const asset = plain()
158
+ const was = frameOf(jointOf(asset, 'link'))
159
+ const paths = roundTrips(asset, 'link')
160
+ assert.deepEqual(frameOf(paths.motion), was, 'motion form')
161
+ assert.deepEqual(frameOf(paths.fastening), was, 'fastening form')
162
+ assert.deepEqual(frameOf(paths.fixed, false), frameOf(was, false), 'remove motion')
163
+ assert.deepEqual(frameOf(paths.drag), was, 'drag')
164
+ assert.deepEqual((paths.drag.origin as Dynamic).align, { x: 'min', z: { mm: 30 } }, 'the edge kept')
165
+ assert.equal(paths.drag.origin?.gap, 5)
166
+ })
167
+
168
+ test('a vector axis: the form keeps it, the summary says it, saved and reopened it is the same vector', framesOnly, () => {
169
+ const asset = framed()
170
+ const form = motionFormOf(jointOf(asset, 'link'))
171
+ assert.deepEqual(form.axisVector, { x: 1, y: 1, z: 0 })
172
+ assert.deepEqual(motionOf(asset, 'link')?.vector, { x: 1, y: 1, z: 0 })
173
+ /* Changing the range leaves the axis alone. */
174
+ const saved = reopen(set(asset, motionJoint(jointOf(asset, 'link'), { ...form, min: -30, max: 60 }, 'link')))
175
+ assert.deepEqual(jointOf(saved, 'link').axis, { x: 1, y: 1, z: 0 })
176
+ assert.equal(jointOf(saved, 'link').upperLimit, 60)
177
+ })
178
+
179
+ test('frames: every form path keeps the vector axis, pivot, offset (summed too), rotate and origin ends, saved and reopened', framesOnly, () => {
180
+ const asset = framed()
181
+ for (const id of ['link', 'tool', 'cap']) {
182
+ const was = jointOf(asset, id)
183
+ const paths = roundTrips(asset, id)
184
+ if (paths.motion) assert.deepEqual(frameOf(paths.motion), frameOf(was), `${id}: motion form`)
185
+ if (paths.fastening) assert.deepEqual(frameOf(paths.fastening), frameOf(was), `${id}: fastening form`)
186
+ assert.deepEqual(frameOf(paths.fixed, false), frameOf(was, false), `${id}: remove motion`)
187
+ const dragged = frameOf(paths.drag)
188
+ if ('at' in was.body0) {
189
+ /* At an origin the drag moves the frame by its offset, in the parent's (turned) frame; the summed term stays. */
190
+ assert.deepEqual(dragged, { ...frameOf(was), offset: { ...was.origin.offset, z: (was.origin.offset.z ?? 0) + 30 } }, `${id}: drag`)
191
+ } else assert.deepEqual(dragged, frameOf(was), `${id}: drag`)
192
+ }
193
+ /* A summed offset term is not a number to add to: dragged across it, the drag is refused; along the others it stays. */
194
+ const tool = drawn(asset, 'tool')
195
+ const up = jointDragOf(asset, 'tool', tool as never, { position: [tool.pose.t[0]!, tool.pose.t[1]! + 30, tool.pose.t[2]!] }, {})
196
+ assert.deepEqual(up, { kind: 'refused', reason: 'origin-follows', joint: 'tool' })
197
+ })
198
+
199
+ test('placed by an origin: said as the origin, no row offered, and the fastening form writes nothing over it', framesOnly, () => {
200
+ const asset = framed()
201
+ assert.equal(endPlaceKeyOf(jointOf(asset, 'tool').body0), 'figure.label.joint-origin')
202
+ assert.equal(endPlaceKeyOf(jointOf(asset, 'link').body0), 'figure.label.face-top')
203
+ assert.equal((fasteningOf(asset, 'tool') as Dynamic).atOrigin, true)
204
+ assert.equal((fasteningOf(asset, 'cap') as Dynamic).atOrigin, true)
205
+ assert.equal((fasteningOf(asset, 'cap') as Dynamic).face, 'centre')
206
+ assert.equal((fasteningOf(asset, 'link') as Dynamic).atOrigin, undefined)
207
+ assert.equal(fasteningSave(asset, 'tool'), null)
208
+ assert.deepEqual(rowAxesOf(asset, 'cap'), [])
209
+ assert.equal(rowBlockOf(asset, 'cap'), 'at-origin')
210
+ })
211
+
212
+ test('a drag about a vector axis sets the value; off it, it is refused and the vector said', framesOnly, () => {
213
+ let a = box(createFigureAsset('Tilt'), 'post', 100, 200, 100, 100)
214
+ a = box(a, 'head', 60, 60, 60, 230)
215
+ a = set(a, { id: 'post', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'post', face: 'bottom' } })
216
+ a = set(a, { id: 'head', type: 'revolute', body0: { part: 'post', face: 'top' }, body1: { part: 'head', face: 'bottom' }, axis: { x: 0, y: 3, z: 4 }, lowerLimit: -90, upperLimit: 90 })
217
+ const at = drawn(a, 'head')
218
+ /* 30° about (0, 0.6, 0.8), as XYZ degrees. */
219
+ const q = [0, 0.6, 0.8].map(v => v * Math.sin(Math.PI / 12))
220
+ const w = Math.cos(Math.PI / 12)
221
+ const [x, y, z] = q as [number, number, number]
222
+ const rx = Math.atan2(2 * (w * x - y * z), 1 - 2 * (x * x + y * y))
223
+ const ry = Math.asin(Math.max(-1, Math.min(1, 2 * (w * y + x * z))))
224
+ const rz = Math.atan2(2 * (w * z - x * y), 1 - 2 * (y * y + z * z))
225
+ const deg = [rx, ry, rz].map(r => (r * 180) / Math.PI) as [number, number, number]
226
+ assert.deepEqual(jointDragOf(a, 'head', at as never, { rotation: deg }, {}), { kind: 'value', input: 'head', unit: 'deg', value: 30 })
227
+ assert.deepEqual(jointDragOf(a, 'head', at as never, { rotation: [40, 0, 0] }, {}), { kind: 'refused', reason: 'off-axis', joint: 'head', axis: '(0, 3, 4)' })
228
+ /* A slide along it: the distance along the vector. */
229
+ const slide = set(a, { ...jointOf(a, 'head'), type: 'prismatic', lowerLimit: -100, upperLimit: 100 })
230
+ const s = drawn(slide, 'head')
231
+ assert.deepEqual(jointDragOf(slide, 'head', s as never, { position: [s.pose.t[0]!, s.pose.t[1]! + 30, s.pose.t[2]! + 40] }, {}), { kind: 'value', input: 'head', unit: 'mm', value: 50 })
232
+ })
233
+
234
+ test('a rotated joint frame: a turn about its turned axis is its value, and a move is measured before the rotation', framesOnly, () => {
235
+ let a = box(createFigureAsset('Rotated'), 'post', 100, 200, 100, 100)
236
+ a = box(a, 'link', 60, 300, 60, 350)
237
+ a = set(a, { id: 'post', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'post', face: 'bottom' } })
238
+ a = set(a, { id: 'bend', type: 'revolute', body0: { part: 'post', face: 'top' }, body1: { part: 'link', face: 'bottom' }, origin: { rotate: { x: 90 } }, axis: 'Z', lowerLimit: -90, upperLimit: 90 })
239
+ /* Turned 90° about X, the frame's Z is the figure's -Y: 20° about -Y is 20 on the joint. */
240
+ const at = drawn(a, 'link', { bend: 10 })
241
+ const before = at.pose.r
242
+ const turnY = (deg: number) => {
243
+ const c = Math.cos((deg * Math.PI) / 180),
244
+ s = Math.sin((deg * Math.PI) / 180)
245
+ return [[c, 0, s], [0, 1, 0], [-s, 0, c]]
246
+ }
247
+ const r = turnY(-20).map(row => [0, 1, 2].map(j => row.reduce((sum, v, k) => sum + v * before[k]![j]!, 0)))
248
+ /* As XYZ Euler degrees. */
249
+ const ry = Math.asin(Math.max(-1, Math.min(1, r[0]![2]!)))
250
+ const rx = Math.atan2(-r[1]![2]!, r[2]![2]!)
251
+ const rz = Math.atan2(-r[0]![1]!, r[0]![0]!)
252
+ const drag = jointDragOf(a, 'link', at as never, { rotation: [rx, ry, rz].map(v => (v * 180) / Math.PI) as [number, number, number] }, { bend: 10 })
253
+ assert.deepEqual(drag, { kind: 'value', input: 'bend', unit: 'deg', value: 30 })
254
+ /* Held still, moved 30 along X: an offset across the post's top on X, not along the turned frame. */
255
+ const still = set(a, { ...jointOf(a, 'bend'), type: 'fixed', axis: undefined, lowerLimit: undefined, upperLimit: undefined })
256
+ const s = drawn(still, 'link')
257
+ const moved = jointDragOf(still, 'link', s as never, { position: [s.pose.t[0]!, s.pose.t[1]!, s.pose.t[2]! + 30] }, {}) as { joint: Dynamic }
258
+ assert.deepEqual(moved.joint.origin, { rotate: { x: 90 }, align: { z: { mm: 30 } } })
259
+ const up = jointDragOf(still, 'link', s as never, { position: [s.pose.t[0]!, s.pose.t[1]! + 25, s.pose.t[2]!] }, {}) as { joint: Dynamic }
260
+ assert.deepEqual(up.joint.origin, { rotate: { x: 90 }, gap: 25 })
261
+ })
262
+
263
+ test('a fan stands about a vector axis, as it does about the same token', framesOnly, () => {
264
+ let a = box(createFigureAsset('Fan'), 'base', 200, 100, 200, 50)
265
+ a = box(a, 'arm', 400, 50, 50, 125)
266
+ a = set(a, { id: 'base', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'base', face: 'bottom' } })
267
+ const arm = (axis: unknown) => set(a, { id: 'arm', type: 'revolute', body0: { part: 'base', face: 'top' }, body1: { part: 'arm', face: 'bottom' }, axis, lowerLimit: -45, upperLimit: 90 })
268
+ const token = jointFanOf(arm('Y'), 'arm') as never as { pose: { t: number[]; r: number[][] } }
269
+ const vector = jointFanOf(arm({ x: 0, y: 2, z: 0 }), 'arm') as never as { pose: { t: number[]; r: number[][] } }
270
+ assert.ok(token && vector, 'a fan for both')
271
+ const off = (p: number[][], q: number[][]) => Math.max(...p.flatMap((row, i) => row.map((v, j) => Math.abs(v - q[i]![j]!))))
272
+ assert.ok(off(token.pose.r, vector.pose.r) < 1e-9 && off([token.pose.t], [vector.pose.t]) < 1e-9)
273
+ const tilted = jointFanOf(arm({ x: 0, y: 3, z: 4 }), 'arm') as never as { pose: { r: number[][] } }
274
+ assert.ok(tilted, 'a tilted axis has a fan too')
275
+ /* The fan lies in its local X–Z: its local Y is the axis. */
276
+ const normal = [0, 1, 2].map(i => tilted.pose.r[i]![1]!)
277
+ assert.ok(Math.abs(Math.abs(normal[1]! * 0.6 + normal[2]! * 0.8) - 1) < 1e-9, `normal ${normal}`)
278
+ })
279
+
280
+ test('editing a motion keeps the control named after the joint when it has no state record', async () => {
281
+ const { motionFormOf, motionJoint } = await import('../client/modeller/links.ts')
282
+ const { applyFigureAuthoring, addFigureBox, createFigureAsset } = await import('@hatiolab/figure-model')
283
+ let a = createFigureAsset('Hinge')
284
+ a = addFigureBox(a, { id: 'base', size: { x: 200, y: 100, z: 200 }, position: { x: 0, y: 0, z: 0 } })
285
+ a = addFigureBox(a, { id: 'lid', size: { x: 200, y: 20, z: 200 }, position: { x: 0, y: 0, z: 0 } })
286
+ a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { id: 'base', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'base', face: 'bottom' } } })
287
+ a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { id: 'lid', type: 'revolute', body0: { part: 'base', face: 'top' }, body1: { part: 'lid', face: 'bottom' }, axis: 'X', lowerLimit: -90, upperLimit: 90 } })
288
+ const lid = a.joints!.find(j => j.id === 'lid')!
289
+ const form = motionFormOf(lid)
290
+ assert.equal(form.id, 'lid', 'the form starts from the input the joint is driven by')
291
+ const edited = applyFigureAuthoring(a, { kind: 'set-joint', joint: motionJoint(lid, { ...form, max: 80 }, 'lid') })
292
+ assert.deepEqual(edited.document.model.inputs.filter(i => i.role === 'state').map(i => i.id), ['lid'], 'the control keeps its name after the edit')
293
+ })
@@ -61,7 +61,6 @@
61
61
  "figure.text.fasten-before-moving": "fasten it to another part or the floor first; then it can move.",
62
62
  "figure.text.motion-summary": "{part} · against {against} · {kind} along {axis}",
63
63
  "figure.text.motion-ends-shown": "The canvas shows the poses at both ends of the range ({min} … {max} {unit}), translucent. What lies between is not checked.",
64
- "figure.text.joint-range-holds-zero": "the range must hold 0, the rest pose.",
65
64
  "figure.text.gizmo-joint-mimic": "the {joint} joint follows {leader}. move {leader}.",
66
65
  "figure.text.gizmo-joint-fixed-turn": "the {joint} joint does not turn. give it a turn in the motion tab.",
67
66
  "figure.text.gizmo-joint-turned-slide": "the {joint} joint only slides. use the move handle.",
@@ -707,6 +706,7 @@
707
706
  "figure.text.row-count-spreads": "A set count is spread evenly over the length. Check the spacing in the preview.",
708
707
  "figure.text.row-blocked-unfastened": "Fasten {part} to another part to arrange it in a row along that part.",
709
708
  "figure.text.row-blocked-on-plane": "Fasten it to another part, not the mounting plane, to arrange it in a row along that part.",
709
+ "figure.text.row-blocked-at-origin": "It stands at another part's origin. Fasten it to a face of that part to arrange it in a row along it.",
710
710
  "figure.text.row-blocked-holds-parts": "Parts are fastened to this part, so it cannot be arranged in a row. Arrange it first; parts fastened after ride every copy.",
711
711
  "figure.button.go-fasten": "go to fastening",
712
712
  "figure.text.row-count-pitch": "{count} × {part} · along {axis} · pitch {pitch} mm (centre to centre)",
@@ -61,7 +61,6 @@
61
61
  "figure.text.fasten-before-moving": "先に他の部品か床に取り付けると、動きを付けられます。",
62
62
  "figure.text.motion-summary": "{part} · {against} 基準 · {axis} 軸 {kind}",
63
63
  "figure.text.motion-ends-shown": "キャンバスに範囲の両端({min} … {max} {unit})の姿勢を半透明で表示します。その間は確認しません。",
64
- "figure.text.joint-range-holds-zero": "範囲に 0(基本姿勢)を含めてください。",
65
64
  "figure.text.gizmo-joint-mimic": "{joint} 関節は {leader} に従います。{leader} を動かしてください。",
66
65
  "figure.text.gizmo-joint-fixed-turn": "{joint} 関節は回りません。動きタブで回転を付けてください。",
67
66
  "figure.text.gizmo-joint-turned-slide": "{joint} 関節はスライドのみです。移動ハンドルを使ってください。",
@@ -707,6 +706,7 @@
707
706
  "figure.text.row-count-spreads": "個数を決めると長さの中で均等に広がります。間隔はプレビューで確認してください。",
708
707
  "figure.text.row-blocked-unfastened": "{part} を別の部品に取り付けると、その部品に沿って列に並べられます。",
709
708
  "figure.text.row-blocked-on-plane": "取付平面ではなく別の部品に取り付けると、その部品に沿って列に並べられます。",
709
+ "figure.text.row-blocked-at-origin": "別の部品の基準点に置かれているため、列に並べられません。その部品の面に取り付けると列に並べられます。",
710
710
  "figure.text.row-blocked-holds-parts": "この部品に取り付いた部品があるため列に並べられません。先に列に並べてから取り付けると、コピーごとに付きます。",
711
711
  "figure.button.go-fasten": "取り付けへ",
712
712
  "figure.text.row-count-pitch": "{part} {count} 個 · {axis} 方向 · pitch {pitch} mm(中心間隔)",
@@ -61,7 +61,6 @@
61
61
  "figure.text.fasten-before-moving": "먼저 다른 부품이나 바닥에 붙여야 움직임을 줄 수 있습니다.",
62
62
  "figure.text.motion-summary": "{part} · {against} 기준 · {axis}축 {kind}",
63
63
  "figure.text.motion-ends-shown": "캔버스에 범위 양 끝({min} … {max} {unit})의 자세를 반투명으로 보입니다. 그 사이는 확인하지 않습니다.",
64
- "figure.text.joint-range-holds-zero": "범위에 0이 들어가야 합니다. 0이 기본 자세입니다.",
65
64
  "figure.text.gizmo-joint-mimic": "{joint} 관절은 {leader} 관절을 따라 움직입니다. {leader}을(를) 움직이세요.",
66
65
  "figure.text.gizmo-joint-fixed-turn": "{joint} 관절은 돌지 않습니다. 돌리려면 움직임 탭에서 회전을 주세요.",
67
66
  "figure.text.gizmo-joint-turned-slide": "{joint} 관절은 미끄러지기만 합니다. 이동 손잡이로 움직이세요.",
@@ -707,6 +706,7 @@
707
706
  "figure.text.row-count-spreads": "개수를 정하면 길이 안에 고르게 벌어집니다. 간격은 미리보기에서 확인하세요.",
708
707
  "figure.text.row-blocked-unfastened": "{part}을(를) 다른 부품에 붙이면 그 부품을 따라 줄로 배열할 수 있습니다.",
709
708
  "figure.text.row-blocked-on-plane": "장착 평면이 아니라 다른 부품에 붙여야 그 부품을 따라 줄로 배열할 수 있습니다.",
709
+ "figure.text.row-blocked-at-origin": "다른 부품의 기준점에 놓여 있어 줄로 배열할 수 없습니다. 그 부품의 면에 붙이면 줄로 배열할 수 있습니다.",
710
710
  "figure.text.row-blocked-holds-parts": "이 부품에 붙은 부품이 있어 줄로 배열할 수 없습니다. 먼저 줄로 배열한 뒤 붙이면 복사본마다 함께 붙습니다.",
711
711
  "figure.button.go-fasten": "붙이기로 가기",
712
712
  "figure.text.row-count-pitch": "{part} {count}개 · {axis} 방향 · pitch {pitch} mm (중심 간격)",
@@ -61,7 +61,6 @@
61
61
  "figure.text.fasten-before-moving": "lekatkan pada bahagian lain atau lantai dahulu; kemudian ia boleh bergerak.",
62
62
  "figure.text.motion-summary": "{part} · berbanding {against} · {kind} pada paksi {axis}",
63
63
  "figure.text.motion-ends-shown": "Kanvas menunjukkan pose di kedua-dua hujung julat ({min} … {max} {unit}), lut sinar. Apa yang di antaranya tidak disemak.",
64
- "figure.text.joint-range-holds-zero": "julat mesti mengandungi 0, pose rehat.",
65
64
  "figure.text.gizmo-joint-mimic": "sendi {joint} mengikut {leader}. gerakkan {leader}.",
66
65
  "figure.text.gizmo-joint-fixed-turn": "sendi {joint} tidak berputar. berikan putaran di tab gerakan.",
67
66
  "figure.text.gizmo-joint-turned-slide": "sendi {joint} hanya menggelongsor. gunakan pemegang alih.",
@@ -707,6 +706,7 @@
707
706
  "figure.text.row-count-spreads": "Bilangan tetap disebarkan sekata sepanjang panjang. Semak jaraknya dalam pratonton.",
708
707
  "figure.text.row-blocked-unfastened": "Lekatkan {part} pada bahagian lain untuk menyusunnya sebaris di sepanjang bahagian itu.",
709
708
  "figure.text.row-blocked-on-plane": "Lekatkan pada bahagian lain, bukan satah pemasangan, untuk menyusunnya sebaris.",
709
+ "figure.text.row-blocked-at-origin": "Ia diletakkan pada titik asal bahagian lain. Lekatkan pada muka bahagian itu untuk menyusunnya sebaris.",
710
710
  "figure.text.row-blocked-holds-parts": "Ada bahagian dilekatkan pada bahagian ini, jadi ia tidak boleh disusun sebaris. Susun dahulu; bahagian yang dilekatkan kemudian ikut setiap salinan.",
711
711
  "figure.button.go-fasten": "pergi ke pelekatan",
712
712
  "figure.text.row-count-pitch": "{count} × {part} · sepanjang {axis} · pitch {pitch} mm (tengah ke tengah)",
@@ -61,7 +61,6 @@
61
61
  "figure.text.fasten-before-moving": "请先将其固定到其他部件或地面上,然后才能添加运动。",
62
62
  "figure.text.motion-summary": "{part} · 以 {against} 为基准 · {axis} 轴 {kind}",
63
63
  "figure.text.motion-ends-shown": "画布以半透明显示范围两端({min} … {max} {unit})的姿态。中间不做检查。",
64
- "figure.text.joint-range-holds-zero": "范围必须包含 0(默认姿态)。",
65
64
  "figure.text.gizmo-joint-mimic": "{joint} 关节跟随 {leader}。请移动 {leader}。",
66
65
  "figure.text.gizmo-joint-fixed-turn": "{joint} 关节不会旋转。请在运动标签中添加旋转。",
67
66
  "figure.text.gizmo-joint-turned-slide": "{joint} 关节只能滑动。请使用移动手柄。",
@@ -707,6 +706,7 @@
707
706
  "figure.text.row-count-spreads": "指定数量后会在长度内均匀分布。请在预览中确认间距。",
708
707
  "figure.text.row-blocked-unfastened": "将 {part} 附着到另一个部件后,即可沿该部件排成一列。",
709
708
  "figure.text.row-blocked-on-plane": "需附着到另一个部件而不是安装平面,才能沿该部件排成一列。",
709
+ "figure.text.row-blocked-at-origin": "放在另一个部件的基准点上,无法排成一列。附着到该部件的面上后即可排成一列。",
710
710
  "figure.text.row-blocked-holds-parts": "此部件上附着有其他部件,无法排成一列。先排成一列再附着,附着件会随每个副本复制。",
711
711
  "figure.button.go-fasten": "前往附着",
712
712
  "figure.text.row-count-pitch": "{part} {count} 个 · 沿 {axis} · pitch {pitch} mm(中心间距)",