@things-factory/figure-ui 10.1.129 → 10.1.141

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 (95) hide show
  1. package/client/modeller/align.ts +24 -18
  2. package/client/modeller/editor.ts +165 -253
  3. package/client/modeller/fasten-geometry.ts +12 -9
  4. package/client/modeller/figure-facts.ts +12 -14
  5. package/client/modeller/figure-links.ts +16 -12
  6. package/client/modeller/figure-preview.ts +13 -4
  7. package/client/modeller/figure-properties.ts +6 -5
  8. package/client/modeller/figure-verdict.ts +14 -8
  9. package/client/modeller/joint-fan.ts +15 -17
  10. package/client/modeller/links.ts +17 -23
  11. package/client/modeller/live.ts +6 -3
  12. package/client/modeller/motion-ends.ts +6 -7
  13. package/client/modeller/occupancy.ts +2 -3
  14. package/client/modeller/product-session.ts +15 -0
  15. package/client/modeller/revision.ts +66 -0
  16. package/client/pages/figure-modeller-page.ts +5 -18
  17. package/dist-client/modeller/align.d.ts +12 -7
  18. package/dist-client/modeller/align.js +21 -17
  19. package/dist-client/modeller/align.js.map +1 -1
  20. package/dist-client/modeller/editor.d.ts +8 -34
  21. package/dist-client/modeller/editor.js +175 -250
  22. package/dist-client/modeller/editor.js.map +1 -1
  23. package/dist-client/modeller/fasten-geometry.d.ts +3 -2
  24. package/dist-client/modeller/fasten-geometry.js +11 -9
  25. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  26. package/dist-client/modeller/figure-facts.d.ts +9 -1
  27. package/dist-client/modeller/figure-facts.js +11 -15
  28. package/dist-client/modeller/figure-facts.js.map +1 -1
  29. package/dist-client/modeller/figure-links.d.ts +4 -5
  30. package/dist-client/modeller/figure-links.js +14 -13
  31. package/dist-client/modeller/figure-links.js.map +1 -1
  32. package/dist-client/modeller/figure-preview.d.ts +5 -2
  33. package/dist-client/modeller/figure-preview.js +13 -4
  34. package/dist-client/modeller/figure-preview.js.map +1 -1
  35. package/dist-client/modeller/figure-properties.d.ts +3 -2
  36. package/dist-client/modeller/figure-properties.js +6 -6
  37. package/dist-client/modeller/figure-properties.js.map +1 -1
  38. package/dist-client/modeller/figure-verdict.d.ts +4 -2
  39. package/dist-client/modeller/figure-verdict.js +12 -9
  40. package/dist-client/modeller/figure-verdict.js.map +1 -1
  41. package/dist-client/modeller/joint-fan.d.ts +8 -4
  42. package/dist-client/modeller/joint-fan.js +15 -16
  43. package/dist-client/modeller/joint-fan.js.map +1 -1
  44. package/dist-client/modeller/links.d.ts +6 -5
  45. package/dist-client/modeller/links.js +17 -24
  46. package/dist-client/modeller/links.js.map +1 -1
  47. package/dist-client/modeller/live.d.ts +3 -2
  48. package/dist-client/modeller/live.js +7 -3
  49. package/dist-client/modeller/live.js.map +1 -1
  50. package/dist-client/modeller/motion-ends.d.ts +2 -2
  51. package/dist-client/modeller/motion-ends.js +5 -6
  52. package/dist-client/modeller/motion-ends.js.map +1 -1
  53. package/dist-client/modeller/occupancy.d.ts +2 -2
  54. package/dist-client/modeller/occupancy.js +2 -3
  55. package/dist-client/modeller/occupancy.js.map +1 -1
  56. package/dist-client/modeller/product-session.d.ts +44 -39
  57. package/dist-client/modeller/product-session.js +17 -0
  58. package/dist-client/modeller/product-session.js.map +1 -1
  59. package/dist-client/modeller/revision.d.ts +37 -0
  60. package/dist-client/modeller/revision.js +39 -0
  61. package/dist-client/modeller/revision.js.map +1 -0
  62. package/dist-client/pages/figure-modeller-page.d.ts +0 -6
  63. package/dist-client/pages/figure-modeller-page.js +6 -14
  64. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  65. package/dist-client/tsconfig.tsbuildinfo +1 -1
  66. package/package.json +7 -7
  67. package/test/align.test.ts +14 -12
  68. package/test/browser/modeller-page.js +52 -4
  69. package/test/browser/modeller-revision.check.mjs +144 -0
  70. package/test/command-preview.test.ts +9 -8
  71. package/test/fasten-drawing.test.ts +1 -1
  72. package/test/fasten-geometry.test.ts +10 -9
  73. package/test/fields.test.ts +1 -1
  74. package/test/first-minute.test.ts +1 -1
  75. package/test/gizmo.test.ts +1 -1
  76. package/test/joint-fan.test.ts +30 -14
  77. package/test/joint-frames.test.ts +9 -8
  78. package/test/links.test.ts +13 -12
  79. package/test/live.test.ts +3 -3
  80. package/test/parts-wiring.test.ts +1 -1
  81. package/test/properties.test.ts +6 -5
  82. package/test/release-panel.test.ts +3 -3
  83. package/test/resize-with-placement.test.ts +8 -6
  84. package/test/revision-of.ts +22 -0
  85. package/test/revision.test.ts +172 -0
  86. package/test/section-editor.test.ts +4 -3
  87. package/test/section-panel.test.ts +5 -4
  88. package/test/verdict.test.ts +1 -1
  89. package/client/modeller/evaluated.ts +0 -47
  90. package/dist-client/modeller/evaluated.d.ts +0 -12
  91. package/dist-client/modeller/evaluated.js +0 -42
  92. package/dist-client/modeller/evaluated.js.map +0 -1
  93. package/dist-client/tsconfig.scratch.tsbuildinfo +0 -1
  94. package/test/evaluated.test.ts +0 -40
  95. package/test/mode-switch.test.ts +0 -43
@@ -13,6 +13,7 @@ import { jointAxisUnit, jointAxisWord } from '../client/modeller/joint-axis.ts'
13
13
  import { alignOf, endPlaceKeyOf, fasteningOf, fixedJointOf, motionFormOf, motionFormRefusalOf, motionJoint, motionOf, rowAxesOf, rowBlockOf } from '../client/modeller/links.ts'
14
14
  import { jointDragOf } from '../client/modeller/joint-drag.ts'
15
15
  import { jointFanOf } from '../client/modeller/joint-fan.ts'
16
+ import { revisionOf } from './revision-of.ts'
16
17
 
17
18
  type Dynamic = any
18
19
  const set = (asset: FigureAsset, joint: Dynamic) => applyFigureAuthoring(asset, { kind: 'set-joint', joint } as FigureAuthoringAction)
@@ -71,7 +72,7 @@ const frameOf = (j: Dynamic, keepType = true) => ({
71
72
 
72
73
  /** The fastening form's 붙이기, as figure-links builds it from what `fasteningOf` reads; nothing where it writes none. */
73
74
  function fasteningSave(asset: FigureAsset, part: string): FigureAuthoringAction | null {
74
- const f = fasteningOf(asset, part) as Dynamic
75
+ const f = fasteningOf(revisionOf(asset), part) as Dynamic
75
76
  if (!f || f.atOrigin) return null
76
77
  const aim = f.toPlane ? ('plane' as const) : { part: f.target, face: f.targetFace }
77
78
  const align = alignOf(asset, part, aim, f.align, f.alignMm, !!f.twist, f.alignInput)
@@ -200,10 +201,10 @@ test('placed by an origin: said as the origin, no row offered, and the fastening
200
201
  const asset = framed()
201
202
  assert.equal(endPlaceKeyOf(jointOf(asset, 'tool').body0), 'figure.label.joint-origin')
202
203
  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)
204
+ assert.equal((fasteningOf(revisionOf(asset), 'tool') as Dynamic).atOrigin, true)
205
+ assert.equal((fasteningOf(revisionOf(asset), 'cap') as Dynamic).atOrigin, true)
206
+ assert.equal((fasteningOf(revisionOf(asset), 'cap') as Dynamic).face, 'centre')
207
+ assert.equal((fasteningOf(revisionOf(asset), 'link') as Dynamic).atOrigin, undefined)
207
208
  assert.equal(fasteningSave(asset, 'tool'), null)
208
209
  assert.deepEqual(rowAxesOf(asset, 'cap'), [])
209
210
  assert.equal(rowBlockOf(asset, 'cap'), 'at-origin')
@@ -265,12 +266,12 @@ test('a fan stands about a vector axis, as it does about the same token', frames
265
266
  a = box(a, 'arm', 400, 50, 50, 125)
266
267
  a = set(a, { id: 'base', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'base', face: 'bottom' } })
267
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 })
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[][] } }
269
+ const token = jointFanOf(revisionOf(arm('Y')), 'arm') as never as { pose: { t: number[]; r: number[][] } }
270
+ const vector = jointFanOf(revisionOf(arm({ x: 0, y: 2, z: 0 })), 'arm') as never as { pose: { t: number[]; r: number[][] } }
270
271
  assert.ok(token && vector, 'a fan for both')
271
272
  const off = (p: number[][], q: number[][]) => Math.max(...p.flatMap((row, i) => row.map((v, j) => Math.abs(v - q[i]![j]!))))
272
273
  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
+ const tilted = jointFanOf(revisionOf(arm({ x: 0, y: 3, z: 4 })), 'arm') as never as { pose: { r: number[][] } }
274
275
  assert.ok(tilted, 'a tilted axis has a fan too')
275
276
  /* The fan lies in its local X–Z: its local Y is the axis. */
276
277
  const normal = [0, 1, 2].map(i => tilted.pose.r[i]![1]!)
@@ -11,6 +11,7 @@ import { addFigureBox, addFigurePrimitive, applyFigureAuthoring, createFigureAss
11
11
  import type { FigureAsset } from '@hatiolab/figure-model'
12
12
 
13
13
  import { acceptsOf, canFasten, canFollow, dimensionNow, instanceRangeOf, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow, fastenedTo, followableAxes, followOf, hasInstanceSize, positionIsShare, holdsThings, motionOf, placeFacesOf, placeIdFor, placesOn, positionsWorkedOut } from '../client/modeller/links.ts'
14
+ import { revisionOf } from './revision-of.ts'
14
15
 
15
16
  const pair = (): FigureAsset => {
16
17
  let asset = createFigureAsset('Pair')
@@ -91,7 +92,7 @@ test('편집기는 세 칸의 명령을 세션의 이력 하나로 돌리고,
91
92
  test('떼면 지금 크기로 고정한다 — 따르던 값을 잃지 않는다', () => {
92
93
  const links = read('../client/modeller/figure-links.ts')
93
94
  assert.ok(links.includes("this.command({ kind: 'unlink-dimension', part: this.partId, axis, mm: Math.max(1, Math.round(current)) })"))
94
- assert.ok(links.includes('const current = dimensionNow(asset, this.partId, axis) ?? this.dims?.[AXES.indexOf(axis)] ?? 100'))
95
+ assert.ok(links.includes('const current = dimensionNow(this.revision!, this.partId, axis) ?? boxOf(this.revision!, this.partId)?.dims[AXES.indexOf(axis)] ?? 100'))
95
96
  })
96
97
 
97
98
  test('세 칸의 말은 다섯 말 모두에 있다', () => {
@@ -170,8 +171,8 @@ const sizedPair = () => {
170
171
 
171
172
  test('비례 — 인스턴스 크기의 몫을 따르는 치수는 비례로 읽고, 「남은 만큼 채우기」는 비례가 아니다', () => {
172
173
  const asset = sizedPair()
173
- assert.equal(dimensionNow(asset, 'flange', 'y'), 10, '원기둥의 Y 는 길이')
174
- assert.equal(dimensionNow(asset, 'flange', 'x'), 20, '원기둥의 X 는 반지름')
174
+ assert.equal(dimensionNow(revisionOf(asset), 'flange', 'y'), 10, '원기둥의 Y 는 길이')
175
+ assert.equal(dimensionNow(revisionOf(asset), 'flange', 'x'), 20, '원기둥의 X 는 반지름')
175
176
  const share = applyFigureAuthoring(asset, { kind: 'link-dimension', part: 'flange', axis: 'y', source: { of: 'instance', axis: 'y' }, times: 0.1 })
176
177
  assert.deepEqual(followOf(share, 'flange', 'y'), { of: 'instance', axis: 'y', times: 0.1 })
177
178
 
@@ -259,10 +260,10 @@ test('한 비율로 커지는 도형 — 자기 숫자인 길이를 세고, 한
259
260
  asset = applyFigureAuthoring(asset, { kind: 'set-joint', joint: { ...lamp, type: 'prismatic', axis: 'Y', lowerLimit: 0, upperLimit: 50, state: { id: 'rise' } } })
260
261
  asset = applyFigureAuthoring(asset, { kind: 'follow-size-in-proportion' })
261
262
  assert.deepEqual(proportionToFollow(asset), { count: 0, kept: ['lamp.motion'] }, '다 따르고, mm 로 움직이는 것만 남는다')
262
- assert.equal(Math.round(valueNow(asset, 'lamp.dimension.x.value')! * 1e6) / 1e6, 60, '따르게 해도 지금 크기는 그대로')
263
+ assert.equal(Math.round(valueNow(revisionOf(asset), 'lamp.dimension.x.value')! * 1e6) / 1e6, 60, '따르게 해도 지금 크기는 그대로')
263
264
  const editor = read('../client/modeller/editor.ts')
264
265
  assert.ok(editor.includes("this.linkCommand({ kind: 'follow-size-in-proportion' })"))
265
- assert.ok(editor.includes("?? valueNow(asset, layout?.args[4])"), '길이를 따르는 간격도 숫자로 보인다')
266
+ assert.ok(editor.includes("?? valueNow(shown, layout?.args[4])"), '길이를 따르는 간격도 숫자로 보인다')
266
267
  })
267
268
 
268
269
  test('관절이 있는 도형에는 한 비율로 키우기를 권하고, 선언하면 규칙으로 보인다(수석 아키텍트 판정 09-24)', () => {
@@ -311,16 +312,16 @@ test('붙여도 제자리 — 폼은 지금 서 있는 자리를 가운데서
311
312
  asset = addFigureBox(asset, { id: 'leaf', size: { x: 1400, y: 2200, z: 40 }, position: { x: 250, y: 1100, z: 100 } })
312
313
  const where = (a: FigureAsset) => compileFigureAsset(a).evaluate().geometry.find(g => g.id === 'leaf')!.pose.t.map(v => Math.round(v))
313
314
  for (const target of ['plane', { part: 'frame', face: 'top' }] as const) {
314
- const kept = placeKeptOf(asset, 'leaf', target)
315
+ const kept = placeKeptOf(revisionOf(asset), 'leaf', target)
315
316
  const align = alignOf(asset, 'leaf', target, kept.align, kept.alignMm)
316
317
  const to = target === 'plane' ? { plane: 'mounting-plane' as const } : target
317
318
  const fastened = applyFigureAuthoring(asset, { kind: 'attach', part: 'leaf', face: 'bottom', to, ...(align ? { align } : {}) } as never)
318
319
  const [before, after] = [where(asset), where(fastened)]
319
320
  assert.deepEqual([after[0], after[2]], [before[0], before[2]], `${JSON.stringify(target)}: 앞뒤·좌우는 그대로`)
320
321
  }
321
- assert.deepEqual(placeKeptOf(asset, 'leaf', { part: 'frame', face: 'top' }), { align: { x: 'mm', z: 'mm' }, alignMm: { x: -50, z: 100 }, alignInput: {} })
322
+ assert.deepEqual(placeKeptOf(revisionOf(asset), 'leaf', { part: 'frame', face: 'top' }), { align: { x: 'mm', z: 'mm' }, alignMm: { x: -50, z: 100 }, alignInput: {} })
322
323
  const centred = addFigureBox(createFigureAsset('C'), { id: 'a', size: { x: 10, y: 10, z: 10 }, position: { x: 0, y: 5, z: 0 } })
323
- assert.deepEqual(placeKeptOf(centred, 'a', 'plane'), { align: {}, alignMm: {}, alignInput: {} }, '가운데 있던 것은 가운데로')
324
+ assert.deepEqual(placeKeptOf(revisionOf(centred), 'a', 'plane'), { align: {}, alignMm: {}, alignInput: {} }, '가운데 있던 것은 가운데로')
324
325
 
325
326
  /* The form fills it in whenever the aim changes, and does not offer a part that hangs from this one. */
326
327
  const { hangsFrom } = await import('../client/modeller/links.ts')
@@ -648,17 +649,17 @@ test('붙이기 칸은 부품 제 붙임으로 열린다 — 가운데로 붙은
648
649
  asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'bed', face: 'bottom', to: { plane: 'mounting-plane' } })
649
650
  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 } })
650
651
  asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'roller', face: 'centre', to: { part: 'bed', face: 'top' }, gap: 40, align: { x: 'min' } })
651
- const roller = fasteningOf(asset, 'roller')!
652
+ const roller = fasteningOf(revisionOf(asset), 'roller')!
652
653
  assert.equal(roller.face, 'centre')
653
654
  assert.equal(roller.target, 'bed')
654
655
  assert.equal(roller.targetFace, 'top')
655
656
  assert.equal(roller.gap, 40)
656
657
  assert.equal(roller.align.x, 'min', '기록의 정렬')
657
- const bed = fasteningOf(asset, 'bed')!
658
+ const bed = fasteningOf(revisionOf(asset), 'bed')!
658
659
  assert.deepEqual([bed.face, bed.toPlane], ['bottom', true])
659
- assert.equal(fasteningOf(addFigureBox(createFigureAsset('Loose'), { id: 'b', size: { x: 1, y: 1, z: 1 }, position: { x: 0, y: 0, z: 0 } }), 'b'), null, '안 붙은 부품은 기본값으로')
660
+ 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, '안 붙은 부품은 기본값으로')
660
661
  const panel = readFileSync(fileURLToPath(new URL('../client/modeller/figure-links.ts', import.meta.url)), 'utf8')
661
- assert.ok(panel.includes('fasteningOf(this.asset, this.partId)'), '부품을 고르면 제 붙임으로 연다')
662
+ assert.ok(panel.includes('fasteningOf(this.revision, this.partId)'), '부품을 고르면 제 붙임으로 연다')
662
663
  assert.ok(panel.includes("...(f.toPlane ? [] : (['centre'] as const))"), '내 면에 가운데가 있다')
663
664
  })
664
665
 
package/test/live.test.ts CHANGED
@@ -11,6 +11,7 @@ import { addFigureBox, applyFigureAuthoring, compileFigureAsset, createFigureAss
11
11
 
12
12
  import { liveOverridesOf, liveOverSession, livePartsOf, NOTHING_LIVE, scrubbed, scrubStep } from '../client/modeller/live.ts'
13
13
  import { moving } from './joints.ts'
14
+ import { revisionOf } from './revision-of.ts'
14
15
 
15
16
  const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
16
17
 
@@ -74,7 +75,7 @@ test('속성창 숫자 칸은 치는 동안 알리고, 이름표를 끌 수 있
74
75
  assert.ok(editor.includes('@input=${(e: Event) => this.typing(field as never, e)}'))
75
76
  assert.ok(editor.includes('@pointerdown=${(e: PointerEvent) => this.scrub(group.cells[axis] as never, e, unavailable)}'))
76
77
  for (const event of ['@state-live=', '@states-live=', '@part-live=']) assert.ok(editor.includes(event), event)
77
- assert.ok(editor.includes('liveOverSession(asset as FigureAsset, this.currentInputs(), this.live)'), '캔버스는 세션 위에 끄는 값을 그린다 (행동은 아래 시험)')
78
+ assert.ok(editor.includes('liveOverSession(shown, this.live)'), '캔버스는 세션 위에 끄는 값을 그린다 (행동은 아래 시험)')
78
79
  const run = editor.slice(editor.indexOf(' private run(action'), editor.indexOf(' private hold('))
79
80
  assert.ok(run.includes('this.live = NOTHING_LIVE'), '기록하거나 거절되면 끄는 값을 버린다')
80
81
  const material = read('../client/modeller/figure-material-panel.ts')
@@ -107,10 +108,9 @@ test('끄는 동안은 세션이 지금 든 자세 위에 그린다 — 다른
107
108
  asset = addFigureBox(asset, { id: 'arm', size: { x: 100, y: 20, z: 20 }, position: { x: 400, y: 110, z: 0 } })
108
109
  asset = applyFigureAuthoring(asset, { kind: 'attach', part: 'arm', face: 'bottom', to: { part: 'post', face: 'top' }, align: { x: 'min' } })
109
110
  asset = moving(asset, 'arm', { kind: 'turn', axis: 'y', id: 'swing', min: -180, max: 180 })
110
- const current = { ...asset.designInputs, ...asset.stateDefaults, swing: 90 }
111
111
  const armAt = (parts: { id: string; pose: { t: number[] } }[]) => parts.find(p => p.id === 'arm')!.pose.t.map(v => Math.round(v))
112
112
  const committed = armAt(compileFigureAsset({ ...asset, stateDefaults: { ...asset.stateDefaults, swing: 90 } }).evaluate().geometry as never)
113
- const dragging = armAt(liveOverSession(asset, current, { values: { shift: 30 } }) as never)
113
+ const dragging = armAt(liveOverSession(revisionOf(asset, { swing: 90 }), { values: { shift: 30 } }) as never)
114
114
  assert.deepEqual(dragging, committed, '사용자 2026-09-24: 「조작을 끝내면 완전히 다른 위치로 된다」 — swing 90 이 끄는 동안에도 남는다')
115
115
  const overSaved = armAt(livePartsOf(asset, { values: { shift: 30 } }) as never)
116
116
  assert.notDeepEqual(overSaved, committed, '전제: 저장된 기본값 위에 그리면 swing 이 0 으로 돌아간다')
@@ -29,7 +29,7 @@ test('복제 · 순서 · 삭제는 figure-model 의 부품 편집으로, 세션
29
29
  })
30
30
 
31
31
  test('작업대에서 가린 부품은 캔버스에서 빠지고, 다른 도형을 열면 풀린다', () => {
32
- assert.ok(editor.includes('.parts=${this.drawnParts(view.geometry, asset)}'))
32
+ assert.ok(editor.includes('.parts=${this.drawnParts(view.geometry, live)}'))
33
33
  const drawn = editor.slice(editor.indexOf(' private drawnParts('), editor.indexOf(' private hold('))
34
34
  assert.ok(drawn.includes('parts.filter(body => !this.hiddenParts.has('), '끄는 값을 그릴 때도 가린 부품은 빠진다')
35
35
  const reset = editor.slice(editor.indexOf('protected willUpdate'), editor.indexOf('private run('))
@@ -13,6 +13,7 @@ import { addFigureBox, applyFigureAuthoring, createFigureAsset, inspectFigureAss
13
13
 
14
14
  import { tagsOf, figureFigureFacts } from '../client/modeller/figure-facts.ts'
15
15
  import { occupancyFromParts } from '../client/modeller/occupancy.ts'
16
+ import { revisionOf } from './revision-of.ts'
16
17
 
17
18
  const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
18
19
  const probe = () => {
@@ -20,7 +21,7 @@ const probe = () => {
20
21
  asset = addFigureBox(asset, { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
21
22
  return asset
22
23
  }
23
- const facts = (asset: ReturnType<typeof probe>) => figureFigureFacts(serializeFigureAsset(asset))
24
+ const facts = (asset: ReturnType<typeof probe>) => figureFigureFacts(revisionOf(asset))
24
25
 
25
26
  test('탭이 읽는 사실 — 선언 전에는 배치 기준도 장착면도 「없음」이다', () => {
26
27
  const read = facts(probe())
@@ -33,7 +34,7 @@ test('탭이 읽는 사실 — 선언 전에는 배치 기준도 장착면도
33
34
 
34
35
  test('배치 기준을 고르면 그 기준으로 공간을 선언한다 — 판정도 풀린다', () => {
35
36
  const source = serializeFigureAsset(probe())
36
- const declared = applyFigureAuthoring(parseFigureAsset(source), occupancyFromParts(source, 'ceiling'))
37
+ const declared = applyFigureAuthoring(parseFigureAsset(source), occupancyFromParts(parseFigureAsset(source), 'ceiling'))
37
38
 
38
39
  assert.equal(facts(declared).placement, 'ceiling')
39
40
  assert.equal(facts(declared).declared, true)
@@ -42,7 +43,7 @@ test('배치 기준을 고르면 그 기준으로 공간을 선언한다 — 판
42
43
 
43
44
  test('장착면을 정하면 탭이 그 부품과 면을 다시 읽고, 해제하면 없어진다', () => {
44
45
  const source = serializeFigureAsset(probe())
45
- const declared = applyFigureAuthoring(parseFigureAsset(source), occupancyFromParts(source))
46
+ const declared = applyFigureAuthoring(parseFigureAsset(source), occupancyFromParts(parseFigureAsset(source)))
46
47
  const mounted = applyFigureAuthoring(declared, { kind: 'set-mounting-face', part: 'body', face: 'bottom' })
47
48
 
48
49
  assert.deepEqual(facts(mounted).mounting, { part: 'body', face: 'bottom' })
@@ -84,7 +85,7 @@ test('발행한 뒤에도 행의 타입이 남는다 — 새로 짜면 잠긴
84
85
 
85
86
  test('속성 탭의 말은 다섯 말 모두에 있다', () => {
86
87
  const tab = read('../client/modeller/figure-properties.ts')
87
- assert.ok(tab.includes('occupancyFromParts(this.source, placement)'), '고른 배치 기준으로 선언한다')
88
+ assert.ok(tab.includes('occupancyFromParts(this.revision.asset, placement)'), '고른 배치 기준으로 선언한다')
88
89
  const keys = [...tab.matchAll(/'(figure\.[a-z.-]*[a-z])'/g)].map(m => m[1])
89
90
  for (const face of ['left', 'right', 'bottom', 'top', 'back', 'front']) keys.push(`figure.label.face-${face}`)
90
91
  for (const one of ['floor', 'ceiling', 'center']) keys.push(`figure.label.placement-${one}`)
@@ -100,7 +101,7 @@ test('장착면은 도형이 평면에 닿는 부품으로 권한다 — 다리
100
101
  let asset = fm.addFigureBox(fm.createFigureAsset('Conveyor'), { id: 'bed', size: { x: 3000, y: 700, z: 800 }, position: { x: 0, y: 1050, z: 0 } })
101
102
  asset = fm.addFigureBox(asset, { id: 'leg', size: { x: 60, y: 700, z: 60 }, position: { x: -1400, y: 350, z: -300 } })
102
103
  asset = fm.applyFigureAuthoring(asset, { kind: 'attach', part: 'leg', face: 'top', to: { part: 'bed', face: 'bottom' }, align: { x: 'min', z: 'min' } })
103
- const facts = figureFigureFacts(fm.serializeFigureAsset(asset))
104
+ const facts = figureFigureFacts(revisionOf(asset))
104
105
  assert.deepEqual(facts.suggested, { part: 'leg', face: 'bottom' })
105
106
  assert.equal(facts.parts[0], 'bed', '목록의 첫 부품은 바닥이다')
106
107
  const panel = readFileSync(fileURLToPath(new URL('../client/modeller/figure-properties.ts', import.meta.url)), 'utf8')
@@ -32,7 +32,7 @@ test('새로 만든 도형의 「공간 선언 없음」을 「부품에 맞추
32
32
  asset = addFigureBox(asset, { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
33
33
  assert.deepEqual(codes(asset), ['OCCUPANCY_UNDECLARED'], '전제: 선언 없는 도형은 막힌다')
34
34
 
35
- const fitted = applyFigureAuthoring(asset, occupancyFromParts(serializeFigureAsset(asset)))
35
+ const fitted = applyFigureAuthoring(asset, occupancyFromParts(asset))
36
36
 
37
37
  assert.deepEqual(codes(fitted), [])
38
38
  assert.equal(fitted.placement ?? 'floor', 'floor', '처음 선언하는 도형은 바닥에 선다')
@@ -46,7 +46,7 @@ test('선언한 공간을 넘은 부품도 같은 한 번으로 풀리고, 배
46
46
  asset = addFigureBox(asset, { id: 'arm', size: { x: 100, y: 100, z: 100 }, position: { x: 900, y: 50, z: 0 } })
47
47
  assert.ok(codes(asset).includes('PART_OUTSIDE_OCCUPANCY'), '전제: 넘은 부품은 막힌다')
48
48
 
49
- const fitted = applyFigureAuthoring(asset, occupancyFromParts(serializeFigureAsset(asset)))
49
+ const fitted = applyFigureAuthoring(asset, occupancyFromParts(asset))
50
50
 
51
51
  assert.deepEqual(codes(fitted), [])
52
52
  assert.equal(fitted.placement, 'center', '이미 선언된 배치 기준을 바꾸지 않는다')
@@ -61,7 +61,7 @@ test('모델러가 발행 패널을 걸고, 패널이 내는 셋을 받는다',
61
61
  for (const event of ['fit-base-to-parts', 'figure-released', 'figure-reverted']) {
62
62
  assert.ok(page.includes(`@${event}=`), `패널이 내는 ${event} 를 받는 자리가 없다 — 눌러도 아무 일이 없다`)
63
63
  }
64
- assert.ok(/occupancyFromParts\(this\.session\.source\(\)\)/.test(page), '「부품에 맞추기」가 공간 선언으로 이어진다')
64
+ assert.ok(/occupancyFromParts\(shown\.asset\)/.test(page), '「부품에 맞추기」가 공간 선언으로 이어진다')
65
65
  /* 없으면 셸이 페이지를 내용 폭(편집기 660 + 패널 320)으로 세워 패널이 오른쪽 밖으로 나간다 — 945 px 에서 봤다. */
66
66
  assert.ok(/:host \{[^}]*overflow: hidden/.test(page), '페이지가 주어진 폭 안에 든다')
67
67
  })
@@ -7,6 +7,7 @@ import { createFigureProductSession } from '../client/modeller/product-session.j
7
7
  import { resizeRefusalOf } from '../client/modeller/refusals.js'
8
8
  import { resizeAxesOf } from '../client/modeller/links.js'
9
9
  import { groupFields } from '../client/modeller/fields.js'
10
+ import { revisionOf } from './revision-of.ts'
10
11
 
11
12
  /*
12
13
  * ADR-0097: the modeller's "follow the size and keep where the figure is placed" is figure-model's plan, applied through
@@ -43,8 +44,9 @@ for (const mode of ['floor', 'ceiling', 'wall', 'center'] as const) {
43
44
  await session.open('ux')
44
45
  assert.equal(session.source(), after)
45
46
  /* At twice the height the kept face is where it was. */
46
- const asset = parseFigureAsset(session.source())
47
- asset.designInputs['size.y'] = asset.designInputs['size.y']! * 2
47
+ /* A parsed asset is sealed (figure-model ADR-0119): the doubled height goes into a copy, which is compiled afresh. */
48
+ const parsed = parseFigureAsset(session.source())
49
+ const asset = { ...parsed, designInputs: { ...parsed.designInputs, 'size.y': parsed.designInputs['size.y']! * 2 } }
48
50
  const g = compileFigureAsset(asset).evaluate().geometry.find(g => g.id === 'box')! as unknown as { pose: { t: number[] }; shape: { dimensions: Record<string, number> } }
49
51
  const y = g.pose.t[1]!, h = g.shape.dimensions.length!
50
52
  if (mode === 'floor') assert.equal(y - h / 2, 0)
@@ -144,7 +146,7 @@ test('the part component carries the face a size drag keeps, for each placement'
144
146
  assert.equal(componentOf(body, {}, { width: 1000, height: 1000 })!.scaleAnchor, undefined, 'none given, none set')
145
147
  }
146
148
  const editor = readFileSync(new URL('../client/modeller/editor.ts', import.meta.url), 'utf-8')
147
- assert.ok(editor.includes('.anchors=${this.anchorsOf(asset as FigureAsset | undefined)}'))
149
+ assert.ok(editor.includes('.anchors=${this.anchorsOf(asset)}'))
148
150
  })
149
151
 
150
152
  /* Designer 2026-09-27 (acceptance round 2): what the screen shows for a part a rule places, and what it withholds. */
@@ -194,7 +196,7 @@ test('"open attachment settings" opens this part\'s fastening, as it is now, and
194
196
  let a = addFigureBox(createFigureAsset('UX'), { id: 'box', size: { x: 100, y: 100, z: 100 }, position: { x: 20, y: 150, z: 30 } })
195
197
  a = applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a), placement: 'floor' })
196
198
  const kept = applyFigureProposal(a, planFigureResize(a, { part: 'box', gap: 'keep' }).steps)
197
- const form = fasteningOf(kept, 'box')!
199
+ const form = fasteningOf(revisionOf(kept), 'box')!
198
200
  assert.equal(form.toPlane, true, 'on the plane')
199
201
  assert.equal(form.face, 'bottom')
200
202
  assert.equal(form.gap, 100, 'the gap it keeps')
@@ -202,7 +204,7 @@ test('"open attachment settings" opens this part\'s fastening, as it is now, and
202
204
  const editor = readFileSync(new URL('../client/modeller/editor.ts', import.meta.url), 'utf-8')
203
205
  assert.ok(links.includes('@click=${() => this.openFastening(asset)}'))
204
206
  const open = links.slice(links.indexOf(' private async openFastening('), links.indexOf(' /**\n * What holds the part, said as its state'))
205
- assert.ok(open.includes('const own = fasteningOf(asset, this.partId)'), 'the form is loaded from the fastening it has now')
207
+ assert.ok(open.includes('const own = fasteningOf(this.revision!, this.partId)'), 'the form is loaded from the fastening it has now')
206
208
  assert.ok(open.includes("querySelector<HTMLElement>('section[fastening]')"), 'the fastening section is what comes into view')
207
209
  assert.ok(!editor.includes("@open-fastening=${() => (this.tab = 'capability')}"), 'it does not switch to the capability tab')
208
210
  assert.ok(links.includes('<section fastening ?spotlit=${this.spotlit}'))
@@ -217,7 +219,7 @@ test('the fastening form keeps a place that follows a position field, and fasten
217
219
  let a = addFigureBox(createFigureAsset('UX'), { id: 'box', size: { x: 100, y: 100, z: 100 }, position: { x: 200, y: 50, z: 150 } })
218
220
  a = applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a), placement: 'floor' })
219
221
  const kept = applyFigureProposal(a, planFigureResize(a, { part: 'box' }).steps)
220
- const form = fasteningOf(kept, 'box')!
222
+ const form = fasteningOf(revisionOf(kept), 'box')!
221
223
  assert.deepEqual([form.align.x, form.alignInput.x, form.align.z, form.alignInput.z], ['input', 'box.position.x', 'input', 'box.position.z'])
222
224
  const align = alignOf(kept, 'box', 'plane', form.align, form.alignMm, false, form.alignInput)
223
225
  const again = applyFigureAuthoring(kept, { kind: 'attach', part: 'box', face: 'bottom', to: { plane: 'mounting-plane' }, ...(align ? { align } : {}), replace: true } as never)
@@ -0,0 +1,22 @@
1
+ /*
2
+ * A figure as the modeller holds it at one version: opened in an editor workspace, the values given set on it (a pose
3
+ * turned with the controls, a design value typed), and read as the product session reads it.
4
+ */
5
+ import { createFigureEditorWorkspace, serializeFigureAsset } from '@hatiolab/figure-model'
6
+ import type { FigureAsset } from '@hatiolab/figure-model'
7
+
8
+ import { revisionFromView, type FigureRevision } from '../client/modeller/revision.ts'
9
+
10
+ export function revisionOf(asset: FigureAsset, values: Record<string, number> = {}): FigureRevision {
11
+ const w = createFigureEditorWorkspace()
12
+ const opened = w.open(serializeFigureAsset(asset))
13
+ const context = opened.context!
14
+ const changes = Object.entries(values).flatMap(([id, value]) => {
15
+ const field = opened.fields.find(f => f.id === id)
16
+ if (!field) throw Error(`no input ${id}`)
17
+ return field.value === value ? [] : [{ kind: field.role === 'state' ? ('set-state' as const) : ('set-design' as const), input: id, unit: field.unit, value }]
18
+ })
19
+ if (changes.length) w.apply({ context, origin: 'ui', expectedRevision: 0, changes })
20
+ const view = w.read() as Parameters<typeof revisionFromView>[0]
21
+ return revisionFromView(view, () => w.export({ context }).text)
22
+ }
@@ -0,0 +1,172 @@
1
+ /*
2
+ * The figure on screen at one version (`revision.ts`): one object per version, from the product session, and everything
3
+ * the modeller works out from it kept with it. Its asset is the kernel's (ADR-0119 decision 6), so the screen's questions
4
+ * of it are answered without compiling or evaluating the figure again.
5
+ *
6
+ * The budgets are counts of whole-figure work (figure-model `measureFigureWork`), not times: the same on every machine.
7
+ * Selecting a part asks every question below; before the version was one object, a selection on the forklift compiled
8
+ * it twice and evaluated it three times on top of the session's own work (2026-10-10).
9
+ */
10
+ import { test } from 'node:test'
11
+ import assert from 'node:assert/strict'
12
+ import { readFileSync } from 'node:fs'
13
+ import { fileURLToPath } from 'node:url'
14
+
15
+ import { addFigureBox, applyFigureAuthoring, createFigureAsset, figureFaceOverhangOf, figurePartFaceOf, figurePlaneAlignChanges, figureRoomOnlyInputsOf, figureRowOf, figureScaleAnchorOf, measureFigureWork, serializeFigureAsset } from '@hatiolab/figure-model'
16
+ import type { FigureAsset } from '@hatiolab/figure-model'
17
+
18
+ import { createFigureProductSession } from '../client/modeller/product-session.ts'
19
+ import { derivedHeld, derivedOf, type FigureRevision } from '../client/modeller/revision.ts'
20
+ import { alignChanges, boxOf, canMirror, gridStepOf } from '../client/modeller/align.ts'
21
+ import { jointFanOf } from '../client/modeller/joint-fan.ts'
22
+ import { fasteningOf, measureNow, partSummaryOf, placeKeptOf } from '../client/modeller/links.ts'
23
+ import { faceViewOf, myFaceFor } from '../client/modeller/fasten-geometry.ts'
24
+ import { motionEndsOf } from '../client/modeller/motion-ends.ts'
25
+ import { figureFigureFacts } from '../client/modeller/figure-facts.ts'
26
+ import { gizmoReachOf } from '../client/modeller/gizmo.ts'
27
+ import { moving } from './joints.ts'
28
+ import { revisionOf } from './revision-of.ts'
29
+
30
+ const arm = readFileSync(fileURLToPath(new URL('./fixtures/robot-arm-6dof.json', import.meta.url)), 'utf8')
31
+
32
+ async function opened(source: string) {
33
+ const session = createFigureProductSession({ load: async id => ({ id, source }), save: async request => ({ id: request.id ?? 'saved', source: request.source }) })
34
+ await session.open('probe')
35
+ return session
36
+ }
37
+
38
+ test('one version is one object, from the session; another version is another, and undo is another again', async () => {
39
+ const session = await opened(arm)
40
+ const first = session.revisionNow()!
41
+ assert.equal(session.revisionNow(), first)
42
+ assert.equal(first.asset, session.read().asset)
43
+ assert.ok(Object.isFrozen(first.asset))
44
+ assert.equal(first.text, session.source())
45
+ const field = first.fields.find(f => f.role !== 'state')!
46
+ session.apply([{ kind: 'set-design', input: field.id, unit: field.unit, value: field.value + 10 }])
47
+ const second = session.revisionNow()!
48
+ assert.notEqual(second, first)
49
+ assert.equal(second.inputs[field.id], field.value + 10)
50
+ session.undo()
51
+ assert.notEqual(session.revisionNow(), first, 'a version made again is a new object')
52
+ assert.equal(session.revisionNow()!.inputs[field.id], field.value)
53
+ })
54
+
55
+ test('what is worked out from a version is kept with it, and goes with it', async () => {
56
+ const session = await opened(arm)
57
+ const first = session.revisionNow()!
58
+ let made = 0
59
+ const ask = (r: FigureRevision) => derivedOf(r, 'probe', () => ++made)
60
+ assert.equal(ask(first), 1)
61
+ assert.equal(ask(first), 1)
62
+ assert.equal(derivedHeld(first, 'probe'), 1)
63
+ const field = first.fields.find(f => f.role !== 'state')!
64
+ session.apply([{ kind: 'set-design', input: field.id, unit: field.unit, value: field.value + 10 }])
65
+ assert.equal(derivedHeld(session.revisionNow()!, 'probe'), undefined)
66
+ assert.equal(ask(session.revisionNow()!), 2)
67
+ })
68
+
69
+ test('a version is readable while it is saved, and a save hands out the same text', async () => {
70
+ const session = await opened(arm)
71
+ const field = session.revisionNow()!.fields.find(f => f.role !== 'state')!
72
+ session.apply([{ kind: 'set-design', input: field.id, unit: field.unit, value: field.value + 10 }])
73
+ const before = session.revisionNow()!
74
+ const saving = session.save()
75
+ assert.ok(session.read().saving)
76
+ assert.equal(session.revisionNow(), before)
77
+ await saving
78
+ assert.equal(session.revisionNow()!.text, before.text)
79
+ })
80
+
81
+ /* Every question a selection asks of the version, as the editor and its panels ask them. */
82
+ function selectionQuestions(shown: FigureRevision, part: string) {
83
+ const asset = shown.asset
84
+ const reach = gizmoReachOf(asset as never, part)
85
+ figureScaleAnchorOf(asset, part)
86
+ boxOf(shown, part)
87
+ gridStepOf(shown)
88
+ jointFanOf(shown, part)
89
+ partSummaryOf(asset, part, key => key, 'ko')
90
+ figureRoomOnlyInputsOf(asset, part)
91
+ for (const axis of ['x', 'y', 'z'] as const) measureNow(shown, part, axis)
92
+ for (const axis of ['x', 'z'] as const) canMirror(shown, part, axis, reach)
93
+ figurePlaneAlignChanges(asset, part)
94
+ figureFaceOverhangOf(asset, part, shown.inputs)
95
+ figurePartFaceOf(asset, part, 'top', shown.inputs)
96
+ figureRowOf(asset, part, shown.inputs)
97
+ fasteningOf(shown, part)
98
+ placeKeptOf(shown, part, 'plane')
99
+ myFaceFor(shown, part, 'plane', 'meet')
100
+ faceViewOf(shown, part, 'plane')
101
+ figureFigureFacts(shown)
102
+ }
103
+
104
+ test('selecting a part compiles nothing and evaluates nothing: every question is answered from the version', async () => {
105
+ const session = await opened(arm)
106
+ const shown = session.revisionNow()!
107
+ const parts = shown.geometry.map(g => g.id).filter(id => !id.startsWith('['))
108
+ const work = measureFigureWork(() => {
109
+ for (const part of parts) selectionQuestions(session.revisionNow()!, part)
110
+ })
111
+ assert.deepEqual([work.compiles, work.evaluations], [0, 0], `${parts.length} parts`)
112
+ })
113
+
114
+ test('the ends of a motion evaluate the figure at its two ends, and nothing is compiled', () => {
115
+ const shown = revisionOf(slide())
116
+ const work = measureFigureWork(() => motionEndsOf(shown, 'carriage'))
117
+ assert.ok(work.result, 'the carriage slides')
118
+ assert.deepEqual([work.compiles, work.evaluations], [0, 2])
119
+ })
120
+
121
+ /* A compile also evaluates the figure once (its stored values must run): three compiles, and the read's evaluation. */
122
+ test('a value changed, read again as the next version, is three compiles at most: the edit, its read and its asset', async () => {
123
+ const session = await opened(arm)
124
+ const field = session.revisionNow()!.fields.find(f => f.role !== 'state')!
125
+ const parts = session.revisionNow()!.geometry.map(g => g.id).filter(id => !id.startsWith('['))
126
+ const work = measureFigureWork(() => {
127
+ session.apply([{ kind: 'set-design', input: field.id, unit: field.unit, value: field.value + 10 }])
128
+ session.read()
129
+ for (const part of parts) selectionQuestions(session.revisionNow()!, part)
130
+ session.source()
131
+ })
132
+ assert.ok(work.compiles <= 3, `compiles ${work.compiles}`)
133
+ assert.ok(work.evaluations <= 4, `evaluations ${work.evaluations}`)
134
+ })
135
+
136
+ /*
137
+ * The version is read as drawn: the pose set with the controls, which is also what a command sees (the workspace
138
+ * captures the live inputs before it runs one). Where the pose on screen differs from the saved one, fastening a part
139
+ * keeps it where it is drawn, and centring centres what is drawn (chief architect's ruling 2026-10-10).
140
+ */
141
+ function slide(): FigureAsset {
142
+ let a = addFigureBox(createFigureAsset('Slide'), { id: 'rail', size: { x: 1000, y: 100, z: 200 }, position: { x: 0, y: 50, z: 0 } })
143
+ a = addFigureBox(a, { id: 'carriage', size: { x: 200, y: 100, z: 200 }, position: { x: 0, y: 150, z: 0 } })
144
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'carriage', face: 'bottom', to: { part: 'rail', face: 'top' } })
145
+ a = moving(a, 'carriage', { kind: 'slide', axis: 'x', id: 'travel', min: 0, max: 400 })
146
+ return addFigureBox(a, { id: 'tag', size: { x: 50, y: 50, z: 50 }, position: { x: 300, y: 225, z: 0 } })
147
+ }
148
+
149
+ test('fastening keeps a part where it stands against what is drawn: a target moved by its control is met where it is now', () => {
150
+ const kept = (shown: FigureRevision) => placeKeptOf(shown, 'tag', { part: 'carriage', face: 'top' }).alignMm.x
151
+ assert.equal(kept(revisionOf(slide())), 300)
152
+ assert.equal(kept(revisionOf(slide(), { travel: 250 })), 50, 'the carriage drawn 250 along: the tag is 50 past its centre')
153
+ /* What the attach command then does, from the version as drawn: the tag does not jump. */
154
+ const shown = revisionOf(slide(), { travel: 250 })
155
+ const w = createFigureProductSession({ load: async id => ({ id, source: serializeFigureAsset(slide()) }), save: async r => ({ id: 'x', source: r.source }) })
156
+ return w.open('x').then(() => {
157
+ w.apply([{ kind: 'set-state', input: 'travel', unit: 'mm', value: 250 }])
158
+ const before = boxOf(w.revisionNow()!, 'tag')!.centre.x
159
+ w.command({ kind: 'attach', part: 'tag', face: 'bottom', to: { part: 'carriage', face: 'top' }, align: { x: { mm: placeKeptOf(shown, 'tag', { part: 'carriage', face: 'top' }).alignMm.x! } } })
160
+ assert.equal(Math.round(boxOf(w.revisionNow()!, 'tag')!.centre.x), Math.round(before))
161
+ })
162
+ })
163
+
164
+ test('centring reads the box as drawn', () => {
165
+ const drawn = revisionOf(slide(), { travel: 250 })
166
+ const box = boxOf(drawn, 'carriage')!
167
+ assert.equal(Math.round(box.centre.x), 250)
168
+ assert.equal(Math.round(boxOf(revisionOf(slide()), 'carriage')!.centre.x), 0)
169
+ /* The tag stands on its own numbers; centring it goes by where it is drawn, which for it is the same either way. */
170
+ const changes = alignChanges(drawn, 'tag', gizmoReachOf(drawn.asset as never, 'tag'), 'centre-x')
171
+ assert.deepEqual(changes.map(c => [c.input, c.value]), [['tag.position.x', 0]])
172
+ })
@@ -14,6 +14,7 @@ import type { FigureAsset, FigureEvaluatedShape, FigureSectionPoint } from '@hat
14
14
 
15
15
  import { apexOver, extentOf, sectionOf as sectionOfWorking, workingIssuesOf as issuesOfWorking, workingOf as workingOfShape, insertAfter, issueWords, issuesOf, markedBy, movePoint, nextPointId, pointLabel, removePoint } from '../client/modeller/section-drawing.ts'
16
16
  import { livePartsOf } from '../client/modeller/live.ts'
17
+ import { revisionOf } from './revision-of.ts'
17
18
 
18
19
  const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
19
20
  /* A source file is a big string: a failing assert.match on it spends minutes building the diff and looks hung. */
@@ -162,13 +163,13 @@ test('치는 동안의 명령이 쓴 입력은 명령의 값으로 그린다 —
162
163
  const { liveOverSession } = await import('../client/modeller/live.ts')
163
164
  const t = triangle()
164
165
  const moved = pointsOf(t).map(p => (p.id === 'p3' ? { ...p, x: -30 } : p))
165
- const drawn = liveOverSession(t, { ...t.designInputs }, { values: {}, action: { kind: 'set-section', part: 'wedge', points: moved } })!
166
+ const drawn = liveOverSession(revisionOf(t), { values: {}, action: { kind: 'set-section', part: 'wedge', points: moved } })!
166
167
  const wedge = drawn.find(p => p.id === 'wedge') as unknown as { shape: FigureEvaluatedShape }
167
168
  assert.equal((wedge.shape.extrusion!.section as { points: FigureSectionPoint[] }).points.find(p => p.id === 'p3')!.x, -30)
168
169
  /* Stage 1's corner, typed a second time: the radius drawn is the one typed, not the one applied before. */
169
170
  const { addFigureBox } = await import('@hatiolab/figure-model')
170
171
  const rounded = applyFigureAuthoring(addFigureBox(createFigureAsset('B'), { id: 'b', size: { x: 400, y: 100, z: 300 }, position: { x: 0, y: 50, z: 0 } }), { kind: 'set-section-corner', part: 'b', corner: 20 })
171
- const again = liveOverSession(rounded, { ...rounded.designInputs }, { values: {}, action: { kind: 'set-section-corner', part: 'b', corner: 80 } })!
172
+ const again = liveOverSession(revisionOf(rounded), { values: {}, action: { kind: 'set-section-corner', part: 'b', corner: 80 } })!
172
173
  const b = again.find(p => p.id === 'b') as unknown as { shape: FigureEvaluatedShape }
173
174
  assert.deepEqual((b.shape.extrusion!.section as { corner: unknown }).corner, { x: 80, z: 80 })
174
175
  })
@@ -327,7 +328,7 @@ test('편집 창에서 끝을 한 점으로: 켜면 점들의 가운데 위에
327
328
  })
328
329
 
329
330
  test('끝면에 붙은 부품이 있으면 한 점으로 모으기를 막고 그 면을 말한다 — 커널의 거절로 판정한다', () => {
330
- has(editor, /this\.session\.previewCommand\(\{ kind: 'set-section', part: this\.selected!, apex: apexOver\(drawn\.section\.points, drawn\.axis\) \}\)/)
331
+ has(editor, /const apex = apexOver\(drawn\.section\.points, drawn\.axis\)[\s\S]*?session\.previewCommand\(\{ kind: 'set-section', part, apex \}\)/)
331
332
  has(editor, /\.code === 'FEATURE_MAPPING_REQUIRED'\)/)
332
333
  has(editorPanel, /const blocked = !apex && !this\.opened\?\.apex \? this\.pointBlocked : ''/)
333
334
  has(editorPanel, /\?disabled=\$\{!!blocked\}/)
@@ -24,6 +24,7 @@ import {
24
24
  } from '../client/modeller/section-controls.ts'
25
25
  import { livePartsOf } from '../client/modeller/live.ts'
26
26
  import { editRefusalOf } from '../client/modeller/refusals.ts'
27
+ import { revisionOf } from './revision-of.ts'
27
28
 
28
29
  const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
29
30
  /* A source file is a big string: a failing assert.match on it spends minutes building the diff and looks hung. */
@@ -171,10 +172,10 @@ test('치는 동안 그려진 도형이 칸의 글자를 덮어쓰지 않는다
171
172
  test('면마다 벽 두께를 치는 동안에도 그려진다 — 명령이 없앤 입력(한 벽 두께)의 지금 값은 미리보기를 막지 않는다', async () => {
172
173
  const { liveOverSession } = await import('../client/modeller/live.ts')
173
174
  const tray = applyFigureAuthoring(probe(), { kind: 'set-cavity', part: 'body', cavity: { wall: 30, start: { closed: 30 }, end: 'open' } })
174
- /* The editor draws over every value as it stands: the one wall is among them. */
175
- const current = { ...tray.designInputs }
176
- assert.ok('body.dimension.wall' in current)
177
- const drawn = liveOverSession(tray, current, {
175
+ /* The editor draws over every value of the version as it stands: the one wall is among them. */
176
+ const shown = revisionOf(tray)
177
+ assert.ok('body.dimension.wall' in shown.inputs)
178
+ const drawn = liveOverSession(shown, {
178
179
  values: {},
179
180
  action: { kind: 'set-cavity', part: 'body', cavity: { wall: { 'x-': 100, 'x+': 30, 'z-': 30, 'z+': 30 }, start: { closed: 30 }, end: 'open' } }
180
181
  })
@@ -62,7 +62,7 @@ test('판정 탭은 발행 패널과 같은 두 판정에 「부품에 맞추기
62
62
 
63
63
  test('판정과 이력은 편집기의 탭에 서고, 감추되 떼지 않는다', () => {
64
64
  const editor = read('../client/modeller/editor.ts')
65
- assert.ok(/<figure-verdict[\s\S]*?\.source=\$\{this\.draftSource\(\)\}/.test(page), '편집 중인 원본을 넘긴다')
65
+ assert.ok(/<figure-verdict[\s\S]*?\.revision=\$\{this\.session\.revisionNow\(\)\}/.test(page), '편집 중인 원본을 넘긴다')
66
66
  assert.ok(/<figure-verdict[\s\S]*?\.figureType=/.test(page), '타입을 바꾸면 다시 판정한다')
67
67
  assert.ok(/<figure-verdict\s+slot="verdict"/.test(page) && /<figure-history-panel\s+slot="history"/.test(page))
68
68
  assert.ok(editor.includes(`<div panel flush ?off=\${tab !== 'verdict'}><slot name="verdict"></slot></div>`), '고르지 않은 탭은 감춘다 — 판정은 마지막 답을 지킨다')