@things-factory/figure-ui 10.1.198 → 10.1.201

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 (74) hide show
  1. package/client/modeller/editor.ts +7 -3
  2. package/client/modeller/fasten-form.ts +18 -35
  3. package/client/modeller/fasten-geometry.ts +67 -115
  4. package/client/modeller/figure-fasten-dialog.ts +4 -12
  5. package/client/modeller/figure-links.ts +6 -7
  6. package/client/modeller/joint-drag.ts +9 -11
  7. package/client/modeller/links.ts +35 -99
  8. package/client/modeller/refusals.ts +2 -6
  9. package/dist-client/modeller/editor.js +7 -2
  10. package/dist-client/modeller/editor.js.map +1 -1
  11. package/dist-client/modeller/fasten-form.d.ts +5 -8
  12. package/dist-client/modeller/fasten-form.js +16 -28
  13. package/dist-client/modeller/fasten-form.js.map +1 -1
  14. package/dist-client/modeller/fasten-geometry.d.ts +20 -19
  15. package/dist-client/modeller/fasten-geometry.js +54 -113
  16. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  17. package/dist-client/modeller/figure-fasten-dialog.js +4 -12
  18. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  19. package/dist-client/modeller/figure-links.d.ts +1 -1
  20. package/dist-client/modeller/figure-links.js +6 -7
  21. package/dist-client/modeller/figure-links.js.map +1 -1
  22. package/dist-client/modeller/joint-drag.js +9 -11
  23. package/dist-client/modeller/joint-drag.js.map +1 -1
  24. package/dist-client/modeller/links.d.ts +19 -26
  25. package/dist-client/modeller/links.js +36 -94
  26. package/dist-client/modeller/links.js.map +1 -1
  27. package/dist-client/modeller/refusals.d.ts +1 -1
  28. package/dist-client/modeller/refusals.js +2 -6
  29. package/dist-client/modeller/refusals.js.map +1 -1
  30. package/dist-client/tsconfig.tsbuildinfo +1 -1
  31. package/dist-server/tsconfig.tsbuildinfo +1 -1
  32. package/package.json +6 -6
  33. package/test/ai-proposal.test.ts +1 -1
  34. package/test/browser/acceptance-page.js +11 -11
  35. package/test/browser/detail-page.js +1 -1
  36. package/test/browser/detail-tabs-page.js +1 -1
  37. package/test/browser/editing-page.js +1 -1
  38. package/test/browser/fix-alignment.check.mjs +3 -3
  39. package/test/browser/links.check.mjs +20 -8
  40. package/test/browser/modeller-page.js +3 -3
  41. package/test/browser/part-ids.check.mjs +2 -2
  42. package/test/browser/parts-page.js +2 -2
  43. package/test/browser/product-page.check.mjs +3 -3
  44. package/test/browser/product-page.js +1 -1
  45. package/test/browser/row-preview.check.mjs +2 -2
  46. package/test/browser/screen-page.js +3 -3
  47. package/test/browser/section-page.js +3 -3
  48. package/test/browser/section.check.mjs +4 -4
  49. package/test/clip-ai-proposal.test.ts +1 -1
  50. package/test/clip-consent.test.ts +1 -1
  51. package/test/command-preview.test.ts +8 -8
  52. package/test/face-mapping.test.ts +2 -2
  53. package/test/fasten-drawing.test.ts +14 -2
  54. package/test/fasten-form.test.ts +11 -3
  55. package/test/fasten-geometry.test.ts +22 -31
  56. package/test/fix-alignment.test.ts +16 -4
  57. package/test/gizmo.test.ts +2 -2
  58. package/test/joint-drag.test.ts +13 -2
  59. package/test/joint-fan.test.ts +1 -1
  60. package/test/joint-frames.test.ts +74 -45
  61. package/test/joints.ts +1 -1
  62. package/test/links.test.ts +68 -68
  63. package/test/live.test.ts +2 -2
  64. package/test/preview-size.test.ts +1 -1
  65. package/test/refusals.test.ts +18 -20
  66. package/test/resize-with-placement.test.ts +6 -6
  67. package/test/revision.test.ts +4 -5
  68. package/test/section-panel.test.ts +1 -1
  69. package/translations/en.json +9 -11
  70. package/translations/es.json +9 -11
  71. package/translations/ja.json +9 -11
  72. package/translations/ko.json +9 -11
  73. package/translations/ms.json +9 -11
  74. package/translations/zh.json +9 -11
@@ -1,7 +1,7 @@
1
1
  /*
2
2
  * **관절 틀 (ADR-0107 결정 1·4) 을 화면이 읽고 그대로 돌려 쓴다** — 어느 방향이든 되는 축, 부품 기준점에 놓인 관절 끝,
3
- * `origin.pivot` · `offset` · `rotate`. 움직임 폼 · 붙이기 폼 · 기즈모 끌기 · 움직임 없애기가 이 값들을 바꾸지 않고, 저장하고
4
- * 다시 열어도 그대로다. 그 틀이 없는 커널(0.3.2)에서는 옛 모양만 같은 길로 본다.
3
+ * `origin.pivot` · `offset` · `parentRotate`(ADR-0140 3.6). 움직임 폼 · 붙이기 폼 · 기즈모 끌기 · 움직임 없애기가 이 값들을
4
+ * 바꾸지 않고, 저장하고 다시 열어도 그대로다.
5
5
  */
6
6
  import { test } from 'node:test'
7
7
  import assert from 'node:assert/strict'
@@ -21,18 +21,6 @@ const box = (asset: FigureAsset, id: string, x: number, y: number, z: number, at
21
21
  const drawn = (asset: FigureAsset, id: string, state: Record<string, number> = {}) =>
22
22
  compileFigureAsset(asset).evaluate(state).geometry.find(g => g.id === id) as never as { id: string; pose: { t: number[]; r: number[][] } }
23
23
 
24
- /** Whether this kernel knows the joint frames: an axis that is a vector is refused by one that does not. */
25
- const FRAMES = (() => {
26
- try {
27
- let a = box(createFigureAsset('probe'), 'p', 10, 10, 10, 5)
28
- a = set(a, { id: 'p', type: 'revolute', body0: { plane: 'mounting-plane' }, body1: { part: 'p', face: 'bottom' }, axis: { x: 0, y: 1, z: 0 } })
29
- return !!a.joints?.some(j => typeof j.axis === 'object')
30
- } catch {
31
- return false
32
- }
33
- })()
34
- const framesOnly = { skip: FRAMES ? false : 'the kernel has no ADR-0107 joint frames' }
35
-
36
24
  const SUMMED = { sum: [{ from: { of: 'part', part: 'link', axis: 'y' }, times: 0.5 }], plus: 10 }
37
25
 
38
26
  /**
@@ -44,18 +32,18 @@ function framed(): FigureAsset {
44
32
  a = box(a, 'link', 100, 400, 100, 300)
45
33
  a = box(a, 'tool', 60, 120, 40)
46
34
  a = box(a, 'cap', 20, 20, 20)
47
- a = set(a, { id: 'base', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'base', face: 'bottom' } })
48
- 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 })
49
- 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 })
50
- return set(a, { id: 'cap', type: 'fixed', body0: { part: 'tool', at: 'origin' }, body1: { part: 'cap', face: 'centre' }, origin: { offset: { y: 40 } } })
35
+ a = set(a, { id: 'base', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'base' } })
36
+ a = set(a, { id: 'link', type: 'revolute', body0: { part: 'base', face: 'top' }, body1: { part: 'link' }, origin: { pivot: 'child-face', offset: { x: 20 }, parentRotate: { x: 10 } }, axis: { x: 1, y: 1, z: 0 }, lowerLimit: -90, upperLimit: 90 })
37
+ 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 }, parentRotate: { x: 15, y: -30, z: 60 } }, axis: 'Z', lowerLimit: -45, upperLimit: 45 })
38
+ return set(a, { id: 'cap', type: 'fixed', body0: { part: 'tool', at: 'origin' }, body1: { part: 'cap' }, origin: { offset: { y: 40 } } })
51
39
  }
52
40
 
53
41
  /** The old shapes only: a token axis, face ends, a gap and an edge. */
54
42
  function plain(): FigureAsset {
55
43
  let a = box(createFigureAsset('Plain'), 'base', 300, 100, 300, 50)
56
44
  a = box(a, 'link', 100, 400, 100, 300)
57
- a = set(a, { id: 'base', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'base', face: 'bottom' } })
58
- 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 })
45
+ a = set(a, { id: 'base', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'base' } })
46
+ return set(a, { id: 'link', type: 'revolute', body0: { part: 'base', face: 'top' }, body1: { part: 'link' }, origin: { gap: 5, align: { x: 'min' } }, axis: 'Y', lowerLimit: -90, upperLimit: 90 })
59
47
  }
60
48
 
61
49
  const reopen = (asset: FigureAsset) => parseFigureAsset(serializeFigureAsset(asset) as unknown as string)
@@ -67,7 +55,7 @@ const frameOf = (j: Dynamic, keepType = true) => ({
67
55
  body1: j.body1,
68
56
  pivot: j.origin?.pivot,
69
57
  offset: j.origin?.offset,
70
- rotate: j.origin?.rotate
58
+ parentRotate: j.origin?.parentRotate
71
59
  })
72
60
 
73
61
  /** The fastening form's 붙이기, as figure-links builds it from what `fasteningOf` reads; nothing where it writes none. */
@@ -75,15 +63,13 @@ function fasteningSave(asset: FigureAsset, part: string): FigureAuthoringAction
75
63
  const f = fasteningOf(revisionOf(asset), part) as Dynamic
76
64
  if (!f || f.atOrigin) return null
77
65
  const aim = f.toPlane ? ('plane' as const) : { part: f.target, face: f.targetFace }
78
- const align = alignOf(asset, part, aim, f.align, f.alignMm, !!f.twist, f.alignInput)
66
+ const align = alignOf(asset, aim, f.align, f.alignMm, f.alignInput)
79
67
  return {
80
68
  kind: 'attach',
81
69
  part,
82
- face: f.face,
83
70
  to: f.toPlane ? { plane: 'mounting-plane' } : { part: f.target, face: f.targetFace },
84
- ...(f.toPlane ? {} : { facing: f.facing }),
71
+ ...(f.toPlane ? (f.facing === 'flush' ? { facing: f.facing } : {}) : { facing: f.facing }),
85
72
  ...(f.gap ? { gap: f.gap } : {}),
86
- twist: f.twist,
87
73
  ...(align ? { align } : {}),
88
74
  replace: true
89
75
  } as FigureAuthoringAction
@@ -127,15 +113,26 @@ test('the axis reader: a token is its axis, a vector its unit vector, unrounded;
127
113
  test('a follower is not a turn without end: it has no limits because its leader gives its range', () => {
128
114
  let a = box(createFigureAsset('Pair'), 'a', 100, 100, 100, 50)
129
115
  a = box(a, 'b', 50, 50, 50, 125)
130
- a = set(a, { id: 'a', type: 'revolute', body0: { plane: 'mounting-plane' }, body1: { part: 'a', face: 'bottom' }, axis: 'Y', lowerLimit: -90, upperLimit: 90 })
131
- a = set(a, { id: 'b', type: 'revolute', body0: { part: 'a', face: 'top' }, body1: { part: 'b', face: 'bottom' }, axis: 'Y', mimic: { joint: 'a', multiplier: -1 } })
116
+ a = set(a, { id: 'a', type: 'revolute', body0: { plane: 'mounting-plane' }, body1: { part: 'a' }, axis: 'Y', lowerLimit: -90, upperLimit: 90 })
117
+ a = set(a, { id: 'b', type: 'revolute', body0: { part: 'a', face: 'top' }, body1: { part: 'b' }, axis: 'Y', mimic: { joint: 'a', multiplier: -1 } })
132
118
  assert.equal(motionOf(a, 'b')?.kind, 'turn')
133
119
  assert.equal(motionFormOf(jointOf(a, 'b')).kind, 'turn')
134
120
  assert.equal(motionOf(a, 'a')?.kind, 'turn')
135
- const free = set(a, { id: 'a', type: 'revolute', body0: { plane: 'mounting-plane' }, body1: { part: 'a', face: 'bottom' }, axis: 'Y' })
121
+ const free = set(a, { id: 'a', type: 'revolute', body0: { plane: 'mounting-plane' }, body1: { part: 'a' }, axis: 'Y' })
136
122
  assert.equal(motionOf(free, 'a')?.kind, 'continuous', 'a joint of its own without limits still turns without end')
137
123
  })
138
124
 
125
+ test('a new motion replaces the old one whole: made a follower, its own limits and control go; made still, its axis goes (ADR-0140 D6)', () => {
126
+ let a = box(createFigureAsset('Pair'), 'a', 100, 100, 100, 50)
127
+ a = box(a, 'b', 50, 50, 50, 125)
128
+ a = set(a, { id: 'a', type: 'revolute', body0: { plane: 'mounting-plane' }, body1: { part: 'a' }, axis: 'Y', lowerLimit: -90, upperLimit: 90 })
129
+ a = set(a, { id: 'b', type: 'revolute', body0: { part: 'a', face: 'top' }, body1: { part: 'b' }, origin: { gap: 5 }, axis: 'Y', lowerLimit: -30, upperLimit: 30, state: { id: 'b-turn' } })
130
+ const follower = motionJoint(jointOf(a, 'b'), { ...motionFormOf(jointOf(a, 'b')), follow: 'a', multiplier: -1 } as Dynamic, 'b') as Dynamic
131
+ assert.deepEqual(Object.keys(follower), ['id', 'body0', 'body1', 'origin', 'type', 'axis', 'mimic'])
132
+ assert.doesNotThrow(() => set(a, follower))
133
+ assert.deepEqual(Object.keys(fixedJointOf(jointOf(a, 'b'))), ['id', 'type', 'body0', 'body1', 'origin'])
134
+ })
135
+
139
136
  test('a range need not hold 0; it needs its most above its least', () => {
140
137
  const form = motionFormOf(jointOf(plain(), 'link'))
141
138
  assert.equal(motionFormRefusalOf({ ...form, min: 10, max: 90 }, ''), undefined)
@@ -166,7 +163,7 @@ test('old shapes: every form path writes the joint back as it was, saved and reo
166
163
  assert.equal(paths.drag.origin?.gap, 5)
167
164
  })
168
165
 
169
- test('a vector axis: the form keeps it, the summary says it, saved and reopened it is the same vector', framesOnly, () => {
166
+ test('a vector axis: the form keeps it, the summary says it, saved and reopened it is the same vector', () => {
170
167
  const asset = framed()
171
168
  const form = motionFormOf(jointOf(asset, 'link'))
172
169
  assert.deepEqual(form.axisVector, { x: 1, y: 1, z: 0 })
@@ -177,7 +174,7 @@ test('a vector axis: the form keeps it, the summary says it, saved and reopened
177
174
  assert.equal(jointOf(saved, 'link').upperLimit, 60)
178
175
  })
179
176
 
180
- test('frames: every form path keeps the vector axis, pivot, offset (summed too), rotate and origin ends, saved and reopened', framesOnly, () => {
177
+ test('frames: every form path keeps the vector axis, pivot, offset (summed too), parentRotate and origin ends, saved and reopened', () => {
181
178
  const asset = framed()
182
179
  for (const id of ['link', 'tool', 'cap']) {
183
180
  const was = jointOf(asset, id)
@@ -197,24 +194,23 @@ test('frames: every form path keeps the vector axis, pivot, offset (summed too),
197
194
  assert.deepEqual(up, { kind: 'refused', reason: 'origin-follows', joint: 'tool' })
198
195
  })
199
196
 
200
- test('placed by an origin: said as the origin, no row offered, and the fastening form writes nothing over it', framesOnly, () => {
197
+ test('placed by an origin: said as the origin, no row offered, and the fastening form writes nothing over it', () => {
201
198
  const asset = framed()
202
199
  assert.equal(endPlaceKeyOf(jointOf(asset, 'tool').body0), 'figure.label.joint-origin')
203
200
  assert.equal(endPlaceKeyOf(jointOf(asset, 'link').body0), 'figure.label.face-top')
204
201
  assert.equal((fasteningOf(revisionOf(asset), 'tool') as Dynamic).atOrigin, true)
205
202
  assert.equal((fasteningOf(revisionOf(asset), 'cap') as Dynamic).atOrigin, true)
206
- assert.equal((fasteningOf(revisionOf(asset), 'cap') as Dynamic).face, 'centre')
207
203
  assert.equal((fasteningOf(revisionOf(asset), 'link') as Dynamic).atOrigin, undefined)
208
204
  assert.equal(fasteningSave(asset, 'tool'), null)
209
205
  assert.deepEqual(rowAxesOf(asset, 'cap'), [])
210
206
  assert.equal(rowBlockOf(asset, 'cap'), 'at-origin')
211
207
  })
212
208
 
213
- test('a drag about a vector axis sets the value; off it, it is refused and the vector said', framesOnly, () => {
209
+ test('a drag about a vector axis sets the value; off it, it is refused and the vector said', () => {
214
210
  let a = box(createFigureAsset('Tilt'), 'post', 100, 200, 100, 100)
215
211
  a = box(a, 'head', 60, 60, 60, 230)
216
- a = set(a, { id: 'post', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'post', face: 'bottom' } })
217
- 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 })
212
+ a = set(a, { id: 'post', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'post' } })
213
+ a = set(a, { id: 'head', type: 'revolute', body0: { part: 'post', face: 'top' }, body1: { part: 'head' }, axis: { x: 0, y: 3, z: 4 }, lowerLimit: -90, upperLimit: 90 })
218
214
  const at = drawn(a, 'head')
219
215
  /* 30° about (0, 0.6, 0.8), as XYZ degrees. */
220
216
  const q = [0, 0.6, 0.8].map(v => v * Math.sin(Math.PI / 12))
@@ -232,11 +228,11 @@ test('a drag about a vector axis sets the value; off it, it is refused and the v
232
228
  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 })
233
229
  })
234
230
 
235
- test('a rotated joint frame: a turn about its turned axis is its value, and a move is measured before the rotation', framesOnly, () => {
231
+ test('a rotated joint frame: a turn about its turned axis is its value, and a move is measured before the rotation', () => {
236
232
  let a = box(createFigureAsset('Rotated'), 'post', 100, 200, 100, 100)
237
233
  a = box(a, 'link', 60, 300, 60, 350)
238
- a = set(a, { id: 'post', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'post', face: 'bottom' } })
239
- 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 })
234
+ a = set(a, { id: 'post', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'post' } })
235
+ a = set(a, { id: 'bend', type: 'revolute', body0: { part: 'post', face: 'top' }, body1: { part: 'link' }, origin: { parentRotate: { x: 90 } }, axis: 'Z', lowerLimit: -90, upperLimit: 90 })
240
236
  /* Turned 90° about X, the frame's Z is the figure's -Y: 20° about -Y is 20 on the joint. */
241
237
  const at = drawn(a, 'link', { bend: 10 })
242
238
  const before = at.pose.r
@@ -256,16 +252,16 @@ test('a rotated joint frame: a turn about its turned axis is its value, and a mo
256
252
  const still = set(a, { ...jointOf(a, 'bend'), type: 'fixed', axis: undefined, lowerLimit: undefined, upperLimit: undefined })
257
253
  const s = drawn(still, 'link')
258
254
  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 }
259
- assert.deepEqual(moved.joint.origin, { rotate: { x: 90 }, align: { z: { mm: 30 } } })
255
+ assert.deepEqual(moved.joint.origin, { parentRotate: { x: 90 }, align: { z: { mm: 30 } } })
260
256
  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 }
261
- assert.deepEqual(up.joint.origin, { rotate: { x: 90 }, gap: 25 })
257
+ assert.deepEqual(up.joint.origin, { parentRotate: { x: 90 }, gap: 25 })
262
258
  })
263
259
 
264
- test('a fan stands about a vector axis, as it does about the same token', framesOnly, () => {
260
+ test('a fan stands about a vector axis, as it does about the same token', () => {
265
261
  let a = box(createFigureAsset('Fan'), 'base', 200, 100, 200, 50)
266
262
  a = box(a, 'arm', 400, 50, 50, 125)
267
- a = set(a, { id: 'base', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'base', face: 'bottom' } })
268
- 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 })
263
+ a = set(a, { id: 'base', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'base' } })
264
+ const arm = (axis: unknown) => set(a, { id: 'arm', type: 'revolute', body0: { part: 'base', face: 'top' }, body1: { part: 'arm' }, axis, lowerLimit: -45, upperLimit: 90 })
269
265
  const token = jointFanOf(revisionOf(arm('Y')), 'arm') as never as { pose: { t: number[]; r: number[][] } }
270
266
  const vector = jointFanOf(revisionOf(arm({ x: 0, y: 2, z: 0 })), 'arm') as never as { pose: { t: number[]; r: number[][] } }
271
267
  assert.ok(token && vector, 'a fan for both')
@@ -284,11 +280,44 @@ test('editing a motion keeps the control named after the joint when it has no st
284
280
  let a = createFigureAsset('Hinge')
285
281
  a = addFigureBox(a, { id: 'base', size: { x: 200, y: 100, z: 200 }, position: { x: 0, y: 0, z: 0 } })
286
282
  a = addFigureBox(a, { id: 'lid', size: { x: 200, y: 20, z: 200 }, position: { x: 0, y: 0, z: 0 } })
287
- a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { id: 'base', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'base', face: 'bottom' } } })
288
- 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 } })
283
+ a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { id: 'base', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'base' } } })
284
+ a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { id: 'lid', type: 'revolute', body0: { part: 'base', face: 'top' }, body1: { part: 'lid' }, axis: 'X', lowerLimit: -90, upperLimit: 90 } })
289
285
  const lid = a.joints!.find(j => j.id === 'lid')!
290
286
  const form = motionFormOf(lid)
291
287
  assert.equal(form.id, 'lid', 'the form starts from the input the joint is driven by')
292
288
  const edited = applyFigureAuthoring(a, { kind: 'set-joint', joint: motionJoint(lid, { ...form, max: 80 }, 'lid') })
293
289
  assert.deepEqual(edited.document.model.inputs.filter(i => i.role === 'state').map(i => i.id), ['lid'], 'the control keeps its name after the edit')
294
290
  })
291
+
292
+ test("a child posed on two axes (childRotate, Rz·Ry·Rx): a turn about the joint's axis is its value, whatever its pose", () => {
293
+ let a = box(createFigureAsset('Leaning'), 'post', 100, 200, 100, 100)
294
+ a = box(a, 'link', 60, 300, 60, 350)
295
+ a = set(a, { id: 'post', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'post' } })
296
+ a = set(a, { id: 'bend', type: 'revolute', body0: { part: 'post', face: 'top' }, body1: { part: 'link' }, origin: { childRotate: { x: 30, z: 40 } }, axis: 'Y', lowerLimit: -90, upperLimit: 90 })
297
+ const at = drawn(a, 'link', { bend: 10 })
298
+ const after = drawn(a, 'link', { bend: 35 }).pose.r
299
+ const ry = Math.asin(Math.max(-1, Math.min(1, after[0]![2]!)))
300
+ const rx = Math.atan2(-after[1]![2]!, after[2]![2]!)
301
+ const rz = Math.atan2(-after[0]![1]!, after[0]![0]!)
302
+ const drag = jointDragOf(a, 'link', at as never, { rotation: [rx, ry, rz].map(v => (v * 180) / Math.PI) as [number, number, number] }, { bend: 10 })
303
+ assert.deepEqual(drag, { kind: 'value', input: 'bend', unit: 'deg', value: 35 })
304
+ })
305
+
306
+ test("a child turned on its own end (childRotate): a turn about the joint's axis is its value -- the child's own turn is not the joint's", () => {
307
+ let a = box(createFigureAsset('Laid'), 'post', 100, 200, 100, 100)
308
+ a = box(a, 'link', 60, 300, 60, 350)
309
+ a = set(a, { id: 'post', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'post' } })
310
+ /* The link laid on its side on the post's top (a quarter turn about X), turning about the figure's Y. */
311
+ a = set(a, { id: 'bend', type: 'revolute', body0: { part: 'post', face: 'top' }, body1: { part: 'link' }, origin: { childRotate: { x: 90 } }, axis: 'Y', lowerLimit: -90, upperLimit: 90 })
312
+ const at = drawn(a, 'link', { bend: 10 })
313
+ const before = at.pose.r
314
+ const c = Math.cos((20 * Math.PI) / 180),
315
+ s = Math.sin((20 * Math.PI) / 180)
316
+ const turnY = [[c, 0, s], [0, 1, 0], [-s, 0, c]]
317
+ const r = turnY.map(row => [0, 1, 2].map(j => row.reduce((sum, v, k) => sum + v * before[k]![j]!, 0)))
318
+ const ry = Math.asin(Math.max(-1, Math.min(1, r[0]![2]!)))
319
+ const rx = Math.atan2(-r[1]![2]!, r[2]![2]!)
320
+ const rz = Math.atan2(-r[0]![1]!, r[0]![0]!)
321
+ const drag = jointDragOf(a, 'link', at as never, { rotation: [rx, ry, rz].map(v => (v * 180) / Math.PI) as [number, number, number] }, { bend: 10 })
322
+ assert.deepEqual(drag, { kind: 'value', input: 'bend', unit: 'deg', value: 30 })
323
+ })
package/test/joints.ts CHANGED
@@ -16,7 +16,7 @@ export function moving(
16
16
  id: part,
17
17
  type: 'fixed',
18
18
  body0: { plane: 'mounting-plane' },
19
- body1: { part, face: 'bottom' },
19
+ body1: { part },
20
20
  origin: { align: { x: at('x'), z: at('z') } }
21
21
  }
22
22
  return applyFigureAuthoring(asset, {
@@ -41,7 +41,7 @@ test('크기 — 제 치수 · 인스턴스 크기 · 다른 부품의 치수를
41
41
  test('붙이기 — 누구에게 붙었나, 붙일 수 있는 모양인가', () => {
42
42
  const asset = pair()
43
43
  assert.equal(fastenedTo(asset, 'block'), null)
44
- const fastened = applyFigureAuthoring(asset, { kind: 'attach', part: 'block', face: 'bottom', to: { part: 'plate', face: 'top' } })
44
+ const fastened = applyFigureAuthoring(asset, { kind: 'attach', part: 'block', to: { part: 'plate', face: 'top' } })
45
45
  assert.equal(fastenedTo(fastened, 'block'), 'plate')
46
46
  assert.equal(fastenedTo(applyFigureAuthoring(fastened, { kind: 'detach', part: 'block' }), 'block'), null)
47
47
  const withDrum = addFigurePrimitive(asset, { id: 'drum', kind: 'cylinder', dimensions: { radius: 50, length: 100 }, position: { x: 0, y: 50, z: 0 } })
@@ -69,7 +69,7 @@ test('움직임 — 미끄러짐 · 돌기, 축, 무엇이 모느냐를 읽는
69
69
 
70
70
  import { readFileSync } from 'node:fs'
71
71
  import { fileURLToPath } from 'node:url'
72
- import { alignRowsOf, choiceOf, followCommandOf, ownFacesOf } from '../client/modeller/links.ts'
72
+ import { alignRowsOf, choiceOf, followCommandOf } from '../client/modeller/links.ts'
73
73
  import { faceViewOf } from '../client/modeller/fasten-geometry.ts'
74
74
  import { moving } from './joints.ts'
75
75
 
@@ -244,8 +244,8 @@ test('한 비율로 커지는 도형 — 자기 숫자인 길이를 세고, 한
244
244
  let asset = createFigureAsset('Probe')
245
245
  asset = addFigureBox(asset, { id: 'base', size: { x: 400, y: 100, z: 400 }, position: { x: 0, y: 0, z: 0 } })
246
246
  asset = addFigureBox(asset, { id: 'lamp', size: { x: 60, y: 60, z: 60 }, position: { x: 0, y: 0, z: 0 } })
247
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'base', face: 'bottom', to: { plane: 'mounting-plane' } })
248
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'lamp', face: 'bottom', to: { part: 'base', face: 'top' }, align: { x: { mm: 120 } } })
247
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'base', to: { plane: 'mounting-plane' } })
248
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'lamp', to: { part: 'base', face: 'top' }, align: { x: { mm: 120 } } })
249
249
  asset = applyFigureAuthoring(asset, { kind: 'declare-instance-size', range: { x: { min: 40, max: 4000 }, y: { min: 16, max: 1600 }, z: { min: 40, max: 4000 } } })
250
250
  assert.equal(proportionToFollow(asset), undefined, '한 비율이 아니면 권하지 않는다')
251
251
  asset = applyFigureAuthoring(asset, { kind: 'scale-uniformly' })
@@ -271,20 +271,20 @@ test('붙일 때 면 위 어디에 — 그 면을 가로지르는 두 축만,
271
271
  two = addFigureBox(two, { id: 'jamb', size: { x: 100, y: 2100, z: 150 }, position: { x: 0, y: 1050, z: 0 } })
272
272
  two = addFigureBox(two, { id: 'leaf', size: { x: 900, y: 2000, z: 40 }, position: { x: 600, y: 1000, z: 0 } })
273
273
  const right = { part: 'jamb', face: 'right' }
274
- assert.deepEqual(alignAxesOf(two, 'leaf', right), [{ axis: 'y', edges: true }, { axis: 'z', edges: true }])
275
- assert.deepEqual(alignAxesOf(two, 'leaf', 'plane'), [{ axis: 'x', edges: false }, { axis: 'z', edges: false }])
274
+ assert.deepEqual(alignAxesOf(two, right), [{ axis: 'y', edges: true }, { axis: 'z', edges: true }])
275
+ assert.deepEqual(alignAxesOf(two, 'plane'), [{ axis: 'x', edges: false }, { axis: 'z', edges: false }])
276
276
  /* A door on the jamb's right face: bottoms level, fronts level. The x choice is along the face and is not sent. */
277
- assert.deepEqual(alignOf(two, 'leaf', right, { x: 'max', y: 'min', z: 'max' }, {}), { y: 'min', z: 'max' })
278
- assert.deepEqual(alignOf(two, 'leaf', { part: 'jamb', face: 'top' }, { x: 'mm', z: 'centre' }, { x: 120 }), { x: { mm: 120 } })
279
- assert.equal(alignOf(two, 'leaf', 'plane', { x: 'min' }, {}), undefined, '바닥에는 맞출 모서리가 없다')
280
- assert.equal(alignOf(two, 'leaf', { part: 'jamb', face: 'top' }, {}, {}), undefined)
277
+ assert.deepEqual(alignOf(two, right, { x: 'max', y: 'min', z: 'max' }, {}), { y: 'min', z: 'max' })
278
+ assert.deepEqual(alignOf(two, { part: 'jamb', face: 'top' }, { x: 'mm', z: 'centre' }, { x: 120 }), { x: { mm: 120 } })
279
+ assert.equal(alignOf(two, 'plane', { x: 'min' }, {}), undefined, '바닥에는 맞출 모서리가 없다')
280
+ assert.equal(alignOf(two, { part: 'jamb', face: 'top' }, {}, {}), undefined)
281
281
 
282
282
  /* What the form sends is what hangs the part on the edge: the door's pivot is on the jamb's front edge line. */
283
283
  let asset = createFigureAsset('Door')
284
284
  asset = addFigureBox(asset, { id: 'jamb', size: { x: 100, y: 2100, z: 150 }, position: { x: 0, y: 1050, z: 0 } })
285
285
  asset = addFigureBox(asset, { id: 'leaf', size: { x: 900, y: 2000, z: 40 }, position: { x: 600, y: 1000, z: 0 } })
286
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'jamb', face: 'bottom', to: { plane: 'mounting-plane' } })
287
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'leaf', face: 'left', to: { part: 'jamb', face: 'right' }, align: alignOf(asset, 'leaf', { part: 'jamb', face: 'right' }, { y: 'min', z: 'max' }, {}) })
286
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'jamb', to: { plane: 'mounting-plane' } })
287
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'leaf', to: { part: 'jamb', face: 'right' }, align: alignOf(asset, { part: 'jamb', face: 'right' }, { y: 'min', z: 'max' }, {}) })
288
288
  const { compileFigureAsset } = await import('@hatiolab/figure-model')
289
289
  const leaf = compileFigureAsset(asset).evaluate().geometry.find(g => g.id === 'leaf')!
290
290
  assert.deepEqual(leaf.pose.t.map(Math.round), [500, 1000, 55], '문짝 아래가 문틀 아래에, 앞이 문틀 앞에')
@@ -295,13 +295,13 @@ test('the fastening form asks across the face in rows only where the drawing can
295
295
  asset = addFigureBox(asset, { id: 'jamb', size: { x: 100, y: 2100, z: 150 }, position: { x: 0, y: 1050, z: 0 } })
296
296
  asset = addFigureBox(asset, { id: 'leaf', size: { x: 900, y: 2000, z: 40 }, position: { x: 600, y: 1000, z: 0 } })
297
297
  const right = { part: 'jamb', face: 'right' }
298
- const view = faceViewOf(revisionOf(asset), 'leaf', right)
298
+ const view = faceViewOf(revisionOf(asset), 'leaf', right, 'meet')
299
299
  assert.deepEqual(view?.axes, ['y', 'z'])
300
- assert.deepEqual(alignRowsOf(asset, 'leaf', right, view, false), [], 'the drawing sets both')
301
- assert.deepEqual(alignRowsOf(asset, 'leaf', right, undefined, false), [{ axis: 'y', edges: true }, { axis: 'z', edges: true }], 'with no drawing, both are asked')
302
- assert.deepEqual(alignRowsOf(asset, 'leaf', right, view, false, { y: 'leaf.position.y' }), [{ axis: 'y', edges: true }], 'the axis that follows a position field')
300
+ assert.deepEqual(alignRowsOf(asset, right, view), [], 'the drawing sets both')
301
+ assert.deepEqual(alignRowsOf(asset, right, undefined), [{ axis: 'y', edges: true }, { axis: 'z', edges: true }], 'with no drawing, both are asked')
302
+ assert.deepEqual(alignRowsOf(asset, right, view, { y: 'leaf.position.y' }), [{ axis: 'y', edges: true }], 'the axis that follows a position field')
303
303
  const sized = applyFigureAuthoring(asset, { kind: 'declare-instance-size', range: { x: { min: 100, max: 5000 }, y: { min: 100, max: 5000 }, z: { min: 10, max: 500 } }, at: { x: 1000, y: 2100, z: 150 } })
304
- assert.deepEqual(alignRowsOf(sized, 'leaf', right, faceViewOf(revisionOf(sized), 'leaf', right), true).map(r => r.axis), ['y', 'z'], 'the axes a share of the figure size may set')
304
+ assert.deepEqual(alignRowsOf(sized, right, faceViewOf(revisionOf(sized), 'leaf', right, 'meet')).map(r => r.axis), ['y', 'z'], 'the axes a share of the figure size may set')
305
305
  })
306
306
 
307
307
  test('붙여도 제자리 — 폼은 지금 서 있는 자리를 가운데서 거리로 채운다 (문짝이 문틀 앞 100 mm 에서 가운데로 옮겨졌다, 09-26)', async () => {
@@ -313,9 +313,9 @@ test('붙여도 제자리 — 폼은 지금 서 있는 자리를 가운데서
313
313
  const where = (a: FigureAsset) => compileFigureAsset(a).evaluate().geometry.find(g => g.id === 'leaf')!.pose.t.map(v => Math.round(v))
314
314
  for (const target of ['plane', { part: 'frame', face: 'top' }] as const) {
315
315
  const kept = placeKeptOf(revisionOf(asset), 'leaf', target)
316
- const align = alignOf(asset, 'leaf', target, kept.align, kept.alignMm)
316
+ const align = alignOf(asset, target, kept.align, kept.alignMm)
317
317
  const to = target === 'plane' ? { plane: 'mounting-plane' as const } : target
318
- const fastened = applyFigureAuthoring(asset, { kind: 'attach', part: 'leaf', face: 'bottom', to, ...(align ? { align } : {}) } as never)
318
+ const fastened = applyFigureAuthoring(asset, { kind: 'attach', part: 'leaf', to, ...(align ? { align } : {}) } as never)
319
319
  const [before, after] = [where(asset), where(fastened)]
320
320
  assert.deepEqual([after[0], after[2]], [before[0], before[2]], `${JSON.stringify(target)}: 앞뒤·좌우는 그대로`)
321
321
  }
@@ -328,8 +328,8 @@ test('붙여도 제자리 — 폼은 지금 서 있는 자리를 가운데서
328
328
  let row = addFigureBox(createFigureAsset('Row'), { id: 'plate', size: { x: 1000, y: 50, z: 400 }, position: { x: 0, y: 700, z: 0 } })
329
329
  row = addFigureBox(row, { id: 'roller', size: { x: 60, y: 60, z: 400 }, position: { x: 0, y: 760, z: 0 } })
330
330
  row = addFigureBox(row, { id: 'cap', size: { x: 20, y: 20, z: 20 }, position: { x: 0, y: 800, z: 0 } })
331
- row = applyFigureAuthoring(row, { kind: 'attach', part: 'roller', face: 'bottom', to: { part: 'plate', face: 'top' } })
332
- row = applyFigureAuthoring(row, { kind: 'attach', part: 'cap', face: 'bottom', to: { part: 'roller', face: 'top' } })
331
+ row = applyFigureAuthoring(row, { kind: 'attach', part: 'roller', to: { part: 'plate', face: 'top' } })
332
+ row = applyFigureAuthoring(row, { kind: 'attach', part: 'cap', to: { part: 'roller', face: 'top' } })
333
333
  assert.deepEqual([hangsFrom(row, 'roller', 'plate'), hangsFrom(row, 'cap', 'plate'), hangsFrom(row, 'plate', 'roller')], [true, true, false])
334
334
  })
335
335
 
@@ -340,10 +340,10 @@ test('따라 움직이는 관절 — 같은 방식으로 움직이고 제 조작
340
340
  asset = addFigureBox(asset, { id: 'palm', size: { x: 240, y: 70, z: 160 }, position: { x: 0, y: 35, z: 0 } })
341
341
  for (const id of ['left', 'right']) asset = addFigureBox(asset, { id, size: { x: 30, y: 140, z: 100 }, position: { x: 0, y: 200, z: 0 } })
342
342
  asset = addFigureBox(asset, { id: 'wrist', size: { x: 60, y: 60, z: 60 }, position: { x: 0, y: 400, z: 0 } })
343
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'palm', face: 'bottom', to: { plane: 'mounting-plane' } })
344
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'left', face: 'bottom', to: { part: 'palm', face: 'top' }, align: { x: 'min' } })
345
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'right', face: 'bottom', to: { part: 'palm', face: 'top' }, align: { x: 'max' } })
346
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'wrist', face: 'top', to: { part: 'palm', face: 'bottom' } })
343
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'palm', to: { plane: 'mounting-plane' } })
344
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'left', to: { part: 'palm', face: 'top' }, align: { x: 'min' } })
345
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'right', to: { part: 'palm', face: 'top' }, align: { x: 'max' } })
346
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'wrist', to: { part: 'palm', face: 'bottom' } })
347
347
  const form = { kind: 'slide' as const, axis: 'x' as const, id: 'grip', min: 0, max: 60, follow: '', multiplier: 1, offset: 0 }
348
348
  const jointOf = (part: string) => asset.joints!.find(j => j.body1.part === part)!
349
349
  asset = applyFigureAuthoring(asset, { kind: 'set-joint', joint: motionJoint(jointOf('left'), form, 'left') })
@@ -385,8 +385,8 @@ test('면은 도형이 정한다 — 문틀(portal)은 바깥 여섯에 개구
385
385
  assert.equal(canFasten(asset, 'ball'), false, '공은 붙일 면이 없다')
386
386
  assert.equal(canFasten(asset, 'frame'), true)
387
387
  assert.deepEqual(facesOf(asset, 'cone'), ['bottom', 'top'])
388
- assert.deepEqual(alignAxesOf(asset, 'leaf', { part: 'frame', face: 'opening-left' }), [{ axis: 'y', edges: true }, { axis: 'z', edges: true }])
389
- assert.deepEqual(alignAxesOf(asset, 'leaf', { part: 'cone', face: 'top' }), [{ axis: 'x', edges: false }, { axis: 'z', edges: false }], '옆면이 평면이 아니면 모서리도 없다')
388
+ assert.deepEqual(alignAxesOf(asset, { part: 'frame', face: 'opening-left' }), [{ axis: 'y', edges: true }, { axis: 'z', edges: true }])
389
+ assert.deepEqual(alignAxesOf(asset, { part: 'cone', face: 'top' }), [{ axis: 'x', edges: false }, { axis: 'z', edges: false }], '옆면이 평면이 아니면 모서리도 없다')
390
390
  })
391
391
 
392
392
  test('되풀이는 붙어 있고 움직이지 않고 아무것도 매달지 않은 부품에 — 붙인 면을 가로지르는 축으로 (빈틈 B, 2026-09-25)', async () => {
@@ -395,24 +395,30 @@ test('되풀이는 붙어 있고 움직이지 않고 아무것도 매달지 않
395
395
  asset = addFigureBox(asset, { id: 'frame', size: { x: 2000, y: 100, z: 600 }, position: { x: 0, y: 0, z: 0 } })
396
396
  asset = addFigureBox(asset, { id: 'slat', size: { x: 60, y: 20, z: 560 }, position: { x: 0, y: 0, z: 0 } })
397
397
  assert.deepEqual(rowAxesOf(asset, 'slat'), [], '붙지 않은 부품은 되풀이를 권하지 않는다')
398
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'frame', face: 'bottom', to: { plane: 'mounting-plane' } })
399
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'slat', face: 'bottom', to: { part: 'frame', face: 'top' } })
398
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'frame', to: { plane: 'mounting-plane' } })
399
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'slat', to: { part: 'frame', face: 'top' } })
400
400
  assert.deepEqual(rowAxesOf(asset, 'frame'), [], '바닥에 선 부품은 채울 길이가 없다')
401
401
  assert.deepEqual(rowAxesOf(asset, 'slat'), ['x', 'z'], '윗면을 가로지르는 두 축')
402
+ assert.deepEqual(rowAxesOf(applyFigureAuthoring(asset, { kind: 'attach', part: 'slat', to: { part: 'frame', face: 'front' }, replace: true }), 'slat'), ['x', 'y'], '앞면이면 앞면을 가로지르는 두 축')
402
403
  asset = applyFigureAuthoring(asset, { kind: 'make-repeat', part: 'slat', axis: 'x', pitch: 100 })
403
404
  assert.deepEqual(rowOf(asset, 'slat'), { axis: 'X', pitch: 100 })
404
405
  assert.deepEqual(rowAxesOf(asset, 'slat'), [], '이미 되풀이면 다시 권하지 않는다')
405
406
  })
406
407
 
407
- test('비튼 부품에는 모서리 맞춤을 내놓지도 보내지도 않는다 — 가운데서 거리만 (빈틈 F, 2026-09-25)', async () => {
408
+ test('돌려 놓인 부품에도 모서리 맞춤을 내놓는다 — 지금 자세의 실제 끝으로 맞춘다 (ADR-0140 3.4)', async () => {
408
409
  const { alignAxesOf, alignOf } = await import('../client/modeller/links.ts')
409
410
  let asset = createFigureAsset('Fan')
410
411
  asset = addFigureBox(asset, { id: 'hub', size: { x: 120, y: 120, z: 80 }, position: { x: 0, y: 0, z: 0 } })
411
- asset = addFigureBox(asset, { id: 'blade', size: { x: 300, y: 60, z: 8 }, position: { x: 0, y: 0, z: 0 } })
412
+ asset = addFigureBox(asset, { id: 'blade', size: { x: 300, y: 60, z: 8 }, position: { x: 0, y: 0, z: 0 }, rotation: { x: 0, y: 0, z: 30 } })
412
413
  const front = { part: 'hub', face: 'front' }
413
- assert.deepEqual(alignAxesOf(asset, 'blade', front, true), [{ axis: 'x', edges: false }, { axis: 'y', edges: false }])
414
- assert.deepEqual(alignOf(asset, 'blade', front, { x: 'max', y: 'mm' }, { y: 50 }, true), { y: { mm: 50 } }, '끝 맞춤은 빠지고 거리만 간다')
415
- assert.deepEqual(alignOf(asset, 'blade', front, { x: 'max' }, {}), { x: 'max' }, '비틀지 않으면 그대로')
414
+ assert.deepEqual(alignAxesOf(asset, front), [{ axis: 'x', edges: true }, { axis: 'y', edges: true }])
415
+ assert.deepEqual(alignOf(asset, front, { x: 'max', y: 'mm' }, { y: 50 }), { x: 'max', y: { mm: 50 } })
416
+ /* Fastened so, its real reach is level with the hub's right side, turned as it is. */
417
+ const { compileFigureAsset, figureFaceOverhangOf } = await import('@hatiolab/figure-model')
418
+ const on = applyFigureAuthoring(asset, { kind: 'attach', part: 'blade', to: front, align: { x: 'max' } })
419
+ assert.equal(figureFaceOverhangOf(on, 'blade')!.state, 'larger', 'wider than the hub: past its left edge, level with its right')
420
+ assert.deepEqual(figureFaceOverhangOf(on, 'blade')!.sides.filter(s => s.axis === 'x').map(s => s.side), ['low'])
421
+ void compileFigureAsset
416
422
  })
417
423
 
418
424
  test('모델러에서 만든 도형은 부품에서 재어 도형 크기를 선언한다 — 그 뒤에야 부품이 도형 크기를 따를 수 있다 (사용자 09-25)', async () => {
@@ -421,7 +427,7 @@ test('모델러에서 만든 도형은 부품에서 재어 도형 크기를 선
421
427
  let asset = createFigureAsset('Conveyor')
422
428
  assert.equal(instanceRangeToDeclare(asset), undefined, '부품이 없으면 잴 것이 없다')
423
429
  asset = addFigureBox(asset, { id: 'frame', size: { x: 2000, y: 100, z: 600 }, position: { x: 0, y: 0, z: 0 } })
424
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'frame', face: 'bottom', to: { plane: 'mounting-plane' } })
430
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'frame', to: { plane: 'mounting-plane' } })
425
431
  assert.equal(hasInstanceSize(asset, 'x'), false, '새 도형에는 도형 크기가 없다')
426
432
  const toDeclare = instanceRangeToDeclare(asset)!
427
433
  assert.deepEqual(toDeclare.at, { x: 2000, y: 100, z: 600 }, '지금 부품의 크기가 선언할 크기')
@@ -456,8 +462,8 @@ test('미끄러짐의 이동 거리가 부품 길이를 따른다 — 레일이
456
462
  let asset = createFigureAsset('Gantry')
457
463
  asset = addFigureBox(asset, { id: 'rail', size: { x: 3000, y: 120, z: 120 }, position: { x: 0, y: 0, z: 0 } })
458
464
  asset = addFigureBox(asset, { id: 'bridge', size: { x: 160, y: 160, z: 1300 }, position: { x: 0, y: 0, z: 0 } })
459
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'rail', face: 'bottom', to: { plane: 'mounting-plane' } })
460
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'bridge', face: 'bottom', to: { part: 'rail', face: 'top' }, align: { x: 'min' } })
465
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'rail', to: { plane: 'mounting-plane' } })
466
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'bridge', to: { part: 'rail', face: 'top' }, align: { x: 'min' } })
461
467
  const joint = asset.joints!.find(j => j.body1.part === 'bridge')!
462
468
  const form = { kind: 'slide' as const, axis: 'x' as const, id: 'x', min: 0, max: 500, follow: '', multiplier: 1, offset: 0, travel: 'rail:x', travelPlus: -160 }
463
469
  const record = motionJoint(joint, form, 'bridge')
@@ -480,14 +486,14 @@ test('면 위 거리를 도형 크기 비율로 — 도형이 커지면 그 자
480
486
  let asset = createFigureAsset('Panel')
481
487
  asset = addFigureBox(asset, { id: 'panel', size: { x: 1000, y: 800, z: 40 }, position: { x: 0, y: 0, z: 0 } })
482
488
  asset = addFigureBox(asset, { id: 'lamp', size: { x: 60, y: 60, z: 30 }, position: { x: 0, y: 0, z: 0 } })
483
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'panel', face: 'bottom', to: { plane: 'mounting-plane' } })
489
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'panel', to: { plane: 'mounting-plane' } })
484
490
  const front = { part: 'panel', face: 'front' }
485
- assert.deepEqual(alignOf(asset, 'lamp', front, { x: 'share' }, { x: 300 }), undefined, '도형 크기가 없으면 비율을 보낼 수 없다')
491
+ assert.deepEqual(alignOf(asset, front, { x: 'share' }, { x: 300 }), undefined, '도형 크기가 없으면 비율을 보낼 수 없다')
486
492
  asset = applyFigureAuthoring(asset, { kind: 'declare-instance-size', range: { x: { min: 200, max: 5000 }, y: { min: 200, max: 5000 }, z: { min: 10, max: 500 } }, at: { x: 1000, y: 800, z: 40 } })
487
493
  asset = applyFigureAuthoring(asset, { kind: 'link-dimension', part: 'panel', axis: 'x', source: { of: 'instance', axis: 'x' } })
488
- const align = alignOf(asset, 'lamp', front, { x: 'share' }, { x: 300 })!
494
+ const align = alignOf(asset, front, { x: 'share' }, { x: 300 })!
489
495
  assert.deepEqual(align, { x: { mm: { from: { of: 'instance', axis: 'x' }, times: 0.3 } } })
490
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'lamp', face: 'back', to: front, align })
496
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'lamp', to: front, align })
491
497
  const x = (size: number) => Math.round(compileFigureAsset({ ...asset, designInputs: { ...asset.designInputs, 'size.x': size } }).evaluate().geometry.find(g => g.id === 'lamp')!.pose.t[0]!)
492
498
  assert.equal(x(1000), 300)
493
499
  assert.equal(x(2000), 600, '도형이 두 배면 거리도 두 배')
@@ -498,8 +504,8 @@ test('선언할 크기는 부품이 쉬고 있을 때의 크기 — 문짝이
498
504
  let asset = createFigureAsset('Door')
499
505
  asset = addFigureBox(asset, { id: 'jamb', size: { x: 100, y: 2100, z: 150 }, position: { x: 0, y: 0, z: 0 } })
500
506
  asset = addFigureBox(asset, { id: 'leaf', size: { x: 890, y: 2095, z: 40 }, position: { x: 0, y: 0, z: 0 } })
501
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'jamb', face: 'bottom', to: { plane: 'mounting-plane' } })
502
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'leaf', face: 'left', to: { part: 'jamb', face: 'right' }, align: { y: 'min', z: 'max' }, gap: 5 })
507
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'jamb', to: { plane: 'mounting-plane' } })
508
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'leaf', to: { part: 'jamb', face: 'right' }, align: { y: 'min', z: 'max' }, gap: 5 })
503
509
  const j = asset.joints!.find(o => o.id === 'leaf')!
504
510
  asset = applyFigureAuthoring(asset, { kind: 'set-joint', joint: { ...j, type: 'revolute', axis: 'Y', lowerLimit: -90, upperLimit: 0, state: { id: 'open' } } })
505
511
  assert.deepEqual(instanceRangeToDeclare(asset)!.at, { x: 995, y: 2100, z: 150 })
@@ -508,15 +514,15 @@ test('선언할 크기는 부품이 쉬고 있을 때의 크기 — 문짝이
508
514
  test('바닥에 선 부품도 도형 크기를 선언한 도형이면 도형의 옆면에 맞출 수 있다 (2026-09-25)', async () => {
509
515
  const { alignAxesOf, alignOf } = await import('../client/modeller/links.ts')
510
516
  let asset = addFigureBox(createFigureAsset('Gate'), { id: 'post', size: { x: 100, y: 2000, z: 150 }, position: { x: 0, y: 0, z: 0 } })
511
- assert.deepEqual(alignAxesOf(asset, 'post', 'plane'), [{ axis: 'x', edges: false }, { axis: 'z', edges: false }], '크기가 없으면 옆면도 없다')
517
+ assert.deepEqual(alignAxesOf(asset, 'plane'), [{ axis: 'x', edges: false }, { axis: 'z', edges: false }], '크기가 없으면 옆면도 없다')
512
518
  asset = applyFigureAuthoring(asset, { kind: 'declare-instance-size', range: { x: { min: 200, max: 5000 }, y: { min: 500, max: 5000 }, z: { min: 50, max: 500 } }, at: { x: 1100, y: 2000, z: 150 } })
513
- assert.deepEqual(alignAxesOf(asset, 'post', 'plane'), [{ axis: 'x', edges: true }, { axis: 'z', edges: true }])
514
- assert.deepEqual(alignOf(asset, 'post', 'plane', { x: 'min' }, {}), { x: 'min' })
519
+ assert.deepEqual(alignAxesOf(asset, 'plane'), [{ axis: 'x', edges: true }, { axis: 'z', edges: true }])
520
+ assert.deepEqual(alignOf(asset, 'plane', { x: 'min' }, {}), { x: 'min' })
515
521
  })
516
522
 
517
523
  test('움직임은 그 자리에서 고친다 — 폼에 지금 관절을 채우고, 그대로 보내면 같은 관절이다 (2026-09-25)', async () => {
518
524
  const { motionFormOf, motionJoint } = await import('../client/modeller/links.ts')
519
- const base = { id: 'j', body0: { part: 'a', face: 'top' as const }, body1: { part: 'b', face: 'bottom' as const } }
525
+ const base = { id: 'j', body0: { part: 'a', face: 'top' as const }, body1: { part: 'b' } }
520
526
  const joints = [
521
527
  { ...base, type: 'prismatic' as const, axis: 'Z' as const, lowerLimit: 0, upperLimit: 2500, state: { id: 'extend' } },
522
528
  { ...base, type: 'revolute' as const, axis: 'Y' as const, lowerLimit: -90, upperLimit: 0, state: { id: 'open' } },
@@ -540,25 +546,21 @@ test('바닥에 선 부품도 무엇에 붙어 있는지 읽는다 — 그래야
540
546
  asset = addFigureBox(asset, { id: 'jamb', size: { x: 100, y: 2100, z: 150 }, position: { x: 0, y: 0, z: 0 } })
541
547
  asset = addFigureBox(asset, { id: 'head', size: { x: 1100, y: 100, z: 150 }, position: { x: 0, y: 0, z: 0 } })
542
548
  assert.equal(heldBy(asset, 'jamb'), null)
543
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'jamb', face: 'bottom', to: { plane: 'mounting-plane' } })
544
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'head', face: 'bottom', to: { part: 'jamb', face: 'top' } })
549
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'jamb', to: { plane: 'mounting-plane' } })
550
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'head', to: { part: 'jamb', face: 'top' } })
545
551
  assert.deepEqual(heldBy(asset, 'jamb'), { plane: true }, '장착 평면')
546
552
  assert.deepEqual(heldBy(asset, 'head'), { part: 'jamb' })
547
553
  /* What the form then sends: a fastening that replaces the one there. */
548
- assert.doesNotThrow(() => applyFigureAuthoring(asset, { kind: 'attach', part: 'jamb', face: 'bottom', to: { plane: 'mounting-plane' }, align: { x: { mm: -500 } }, replace: !!heldBy(asset, 'jamb') }))
554
+ assert.doesNotThrow(() => applyFigureAuthoring(asset, { kind: 'attach', part: 'jamb', to: { plane: 'mounting-plane' }, align: { x: { mm: -500 } }, replace: !!heldBy(asset, 'jamb') }))
549
555
  })
550
556
 
551
- test('벽 배치 — 장착 평면은 Z = 0, 등으로 붙이고 가로질러 X · Y 로 맞춘다(사용자 · 아키텍트 09-25)', async () => {
557
+ test('벽 배치 — 장착 평면은 Z = 0, 벽에서 나와 서고 가로질러 X · Y 로 맞춘다(사용자 · 아키텍트 09-25, ADR-0140 3.11)', async () => {
552
558
  const { mountingPlaneOf, alignAxesOf } = await import('../client/modeller/links.ts')
553
559
  let a = addFigureBox(createFigureAsset('Lamp'), { id: 'plate', size: { x: 200, y: 300, z: 20 }, position: { x: 0, y: 0, z: 0 } })
554
- assert.deepEqual(mountingPlaneOf(a), { normal: 'y', faces: ['bottom', 'top'] }, '배치 선언 전에는 바닥')
560
+ assert.deepEqual(mountingPlaneOf(a), { normal: 'y', facings: ['meet', 'flush'] }, '배치 선언 전에는 바닥 — 올려 서거나 아래에 매달림')
555
561
  a = applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: (await import('@hatiolab/figure-model')).proposeFigureOccupancy(a), placement: 'wall' as never })
556
- assert.deepEqual(mountingPlaneOf(a), { normal: 'z', faces: ['back'] })
557
- assert.deepEqual(alignAxesOf(a, 'plate', 'plane').map(x => x.axis), ['x', 'y'])
558
- assert.deepEqual(ownFacesOf(a, 'plate', true), ['back'], 'on the plane, only the face the plane takes')
559
- assert.deepEqual(ownFacesOf(a, 'plate', false), ['left', 'right', 'bottom', 'top', 'back', 'front', 'centre'], 'to a part, its six outer faces and its centre')
560
- const floor = addFigureBox(createFigureAsset('Floor'), { id: 'plate', size: { x: 200, y: 300, z: 20 }, position: { x: 0, y: 0, z: 0 } })
561
- assert.deepEqual(ownFacesOf(floor, 'plate', true), ['bottom', 'top'], 'on the floor, its bottom or its top')
562
+ assert.deepEqual(mountingPlaneOf(a), { normal: 'z', facings: ['meet'] })
563
+ assert.deepEqual(alignAxesOf(a, 'plane').map(x => x.axis), ['x', 'y'])
562
564
  })
563
565
 
564
566
  test('부품 칸의 두 제목이 같은 말이 아니다 — 자리 숫자는 「위치와 크기」, 붙이기 칸은 「붙이기」 (09-26)', () => {
@@ -578,8 +580,8 @@ test('보이는 때 — 명령이 남긴 것을 읽고, 폼은 그 명령을 낸
578
580
  asset = addFigureBox(asset, { id: 'frame', size: { x: 1200, y: 60, z: 200 }, position: { x: 0, y: 30, z: 0 } })
579
581
  asset = addFigureBox(asset, { id: 'door', size: { x: 1000, y: 2000, z: 40 }, position: { x: 0, y: 1060, z: 0 } })
580
582
  asset = addFigureBox(asset, { id: 'lamp', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 2200, z: 0 } })
581
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'frame', face: 'bottom', to: { plane: 'mounting-plane' } })
582
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'door', face: 'bottom', to: { part: 'frame', face: 'top' } })
583
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'frame', to: { plane: 'mounting-plane' } })
584
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'door', to: { part: 'frame', face: 'top' } })
583
585
  assert.deepEqual(visibilityControlsOf(asset), [], '조작값이 없으면 고를 것도 없다')
584
586
  asset = moving(asset, 'door', { kind: 'slide', axis: 'x', id: 'gate-open', min: 0, max: 900 })
585
587
  assert.deepEqual(visibilityControlsOf(asset).map(c => [c.id, c.min, c.max, c.unit]), [['gate-open', 0, 900, 'mm']])
@@ -629,20 +631,18 @@ import { wordsOf } from './words.ts'
629
631
 
630
632
  test('붙이기 칸은 부품 제 붙임으로 열린다 — 가운데로 붙은 롤러는 가운데 · 상대 · 간격 그대로', () => {
631
633
  let asset = addFigureBox(createFigureAsset('Rollers'), { id: 'bed', size: { x: 2000, y: 400, z: 600 }, position: { x: 0, y: 200, z: 0 } })
632
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'bed', face: 'bottom', to: { plane: 'mounting-plane' } })
634
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'bed', to: { plane: 'mounting-plane' } })
633
635
  asset = addFigurePrimitive(asset, { id: 'roller', kind: 'cylinder', dimensions: { radius: 40, length: 560 }, position: { x: 0, y: 0, z: 0 }, rotation: { x: 90, y: 0, z: 0 } })
634
- asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'roller', face: 'centre', to: { part: 'bed', face: 'top' }, gap: 40, align: { x: 'min' } })
636
+ asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'roller', facing: 'centre', to: { part: 'bed', face: 'top' }, gap: 40, align: { x: 'min' } })
635
637
  const roller = fasteningOf(revisionOf(asset), 'roller')!
636
- assert.equal(roller.face, 'centre')
638
+ assert.equal(roller.facing, 'centre')
637
639
  assert.equal(roller.target, 'bed')
638
640
  assert.equal(roller.targetFace, 'top')
639
641
  assert.equal(roller.gap, 40)
640
642
  assert.equal(roller.align.x, 'min', '기록의 정렬')
641
643
  const bed = fasteningOf(revisionOf(asset), 'bed')!
642
- assert.deepEqual([bed.face, bed.toPlane], ['bottom', true])
644
+ assert.deepEqual([bed.facing, bed.toPlane], ['meet', true])
643
645
  assert.equal(fasteningOf(revisionOf(addFigureBox(createFigureAsset('Loose'), { id: 'b', size: { x: 1, y: 1, z: 1 }, position: { x: 0, y: 0, z: 0 } })), 'b'), null, '안 붙은 부품은 기본값으로')
644
- assert.equal(ownFacesOf(asset, 'roller', false).at(-1), 'centre', 'its own faces include its centre')
645
- assert.ok(!ownFacesOf(asset, 'roller', true).includes('centre'), 'on the floor, by a face only')
646
646
  })
647
647
 
648
648
  test('a worked-out gap is kept by the fastening form: opening it on the part moves nothing (the robot dog, the forklift, 2026-10-13)', async () => {
@@ -652,7 +652,7 @@ test('a worked-out gap is kept by the fastening form: opening it on the part mov
652
652
  Object.fromEntries((compileFigureAsset(asset).evaluate().geometry as unknown as Array<{ id: string; pose?: { t: number[] } }>).map(g => [g.id, g.pose?.t.map(v => Math.round(v * 1000) / 1000)]))
653
653
  /* What the form sends when nothing in it is changed: the attach it would make of the part's own fastening. */
654
654
  const reattach = (asset: FigureAsset, part: string, form: Parameters<typeof fastenGapOf>[0]) =>
655
- applyFigureAuthoring(asset, { kind: 'attach', part, face: 'bottom', to: { plane: 'mounting-plane' }, ...fastenGapOf(form), twist: 0, replace: true } as never)
655
+ applyFigureAuthoring(asset, { kind: 'attach', part, to: { plane: 'mounting-plane' }, ...fastenGapOf(form), replace: true } as never)
656
656
 
657
657
  const dog = robotDogFigure().asset
658
658
  const body = fasteningOf(revisionOf(dog), 'body')!
@@ -665,7 +665,7 @@ test('a worked-out gap is kept by the fastening form: opening it on the part mov
665
665
  let lift = addFigureBox(createFigureAsset('Lift'), { id: 'body', size: { x: 1000, y: 800, z: 600 }, position: { x: 0, y: 500, z: 0 } })
666
666
  lift.document.model.inputs.push({ id: 'size.y', unit: 'mm', min: 100, max: 20000, role: 'design' } as never)
667
667
  lift.designInputs['size.y'] = 1800
668
- lift = applyFigureAuthoring(lift, { kind: 'attach', part: 'body', face: 'bottom', to: { plane: 'mounting-plane' }, gap: { from: { of: 'instance', axis: 'y' }, times: 0.05 } } as never)
668
+ lift = applyFigureAuthoring(lift, { kind: 'attach', part: 'body', to: { plane: 'mounting-plane' }, gap: { from: { of: 'instance', axis: 'y' }, times: 0.05 } } as never)
669
669
  const kept = fasteningOf(revisionOf(lift), 'body')!
670
670
  assert.deepEqual(kept.gapKept, { from: { of: 'instance', axis: 'y' }, times: 0.05 })
671
671
  assert.deepEqual(posesOf(reattach(lift, 'body', kept)), posesOf(lift))