@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.129",
3
+ "version": "10.1.141",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -17,8 +17,8 @@
17
17
  },
18
18
  "scripts": {
19
19
  "build": "npm run build:server && npm run build:client",
20
- "test": "node --import ./test/resolve-ts-siblings.mjs --test --test-force-exit test/*.test.ts",
21
- "test:browser": "node --test --test-force-exit test/browser/modeller.check.mjs",
20
+ "test": "FIGURE_FREEZE_COMPILED=1 node --import ./test/resolve-ts-siblings.mjs --test --test-force-exit test/*.test.ts",
21
+ "test:browser": "node --test --test-force-exit test/browser/modeller.check.mjs test/browser/modeller-revision.check.mjs",
22
22
  "copy:files": "copyfiles -e \"./client/**/*.{ts,js,json}\" -u 1 \"./client/**/*\" dist-client",
23
23
  "build:client": "npm run copy:files && npx tsc --p ./client/tsconfig.json",
24
24
  "build:server": "npx tsc --p ./server/tsconfig.json",
@@ -27,7 +27,7 @@
27
27
  "clean": "npm run clean:server && npm run clean:client"
28
28
  },
29
29
  "dependencies": {
30
- "@hatiolab/figure-model": "^0.4.1",
30
+ "@hatiolab/figure-model": "^0.5.0",
31
31
  "@hatiolab/things-scene": "^10.1.74",
32
32
  "@material/web": "^2.0.0",
33
33
  "@operato/data-grist": "^10.0.0",
@@ -40,9 +40,9 @@
40
40
  "@operato/shell": "^10.0.0",
41
41
  "@operato/styles": "^10.0.0",
42
42
  "@operato/utils": "^10.0.0",
43
- "@things-factory/ai-assistant": "^10.1.129",
44
- "@things-factory/figure-service": "^10.1.129",
43
+ "@things-factory/ai-assistant": "^10.1.141",
44
+ "@things-factory/figure-service": "^10.1.141",
45
45
  "three": "^0.185.1"
46
46
  },
47
- "gitHead": "4d1ef242d9b7e6669a34c27b6d31aed778c07a90"
47
+ "gitHead": "931866a13b9149be42a294f207e245aba11e57f8"
48
48
  }
@@ -13,6 +13,7 @@ import { addFigureBox, createFigureAsset, editFigurePart } from '@hatiolab/figur
13
13
 
14
14
  import { alignChanges, boxOf, canMirror, gridStepOf, mirrorChanges, niceStep } from '../client/modeller/align.ts'
15
15
  import { gizmoReachOf } from '../client/modeller/gizmo.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 = (at = { x: 130, y: 90, z: -70 }, rotation?: { x: number; y: number; z: number }) => {
@@ -26,11 +27,12 @@ const applied = (asset: ReturnType<typeof probe>, changes: ReturnType<typeof ali
26
27
  return next
27
28
  }
28
29
  const run = (asset: ReturnType<typeof probe>, tool: Parameters<typeof alignChanges>[3]) =>
29
- applied(asset, alignChanges(asset, 'body', gizmoReachOf(asset as never, 'body'), tool))
30
+ applied(asset, alignChanges(revisionOf(asset), 'body', gizmoReachOf(asset as never, 'body'), tool))
31
+ const box = (asset: ReturnType<typeof probe>, part: string) => boxOf(revisionOf(asset), part)
30
32
 
31
33
  test('가운데 X · Z 는 부품 상자의 가운데를 0 에 둔다', () => {
32
- assert.equal(Math.round(boxOf(run(probe(), 'centre-x'), 'body')!.centre.x), 0)
33
- assert.equal(Math.round(boxOf(run(probe(), 'centre-z'), 'body')!.centre.z), 0)
34
+ assert.equal(Math.round(box(run(probe(), 'centre-x'), 'body')!.centre.x), 0)
35
+ assert.equal(Math.round(box(run(probe(), 'centre-z'), 'body')!.centre.z), 0)
34
36
  })
35
37
 
36
38
  /*
@@ -40,13 +42,13 @@ test('가운데 X · Z 는 부품 상자의 가운데를 0 에 둔다', () => {
40
42
  test('기준면 맞추기 — 배치를 따르고, 한 번 옮길 뿐 붙이지 않는다', async () => {
41
43
  const { figurePlaneAlignChanges, applyFigureAuthoring } = await import('@hatiolab/figure-model')
42
44
  const floor = probe()
43
- const flat = boxOf(applied(floor, figurePlaneAlignChanges(floor, 'body').changes), 'body')!
45
+ const flat = box(applied(floor, figurePlaneAlignChanges(floor, 'body').changes), 'body')!
44
46
  assert.equal(Math.round(flat.centre.y - flat.extent.y / 2), 0, '바닥: 아랫면이 y 0')
45
47
  const turned = probe({ x: 0, y: 300, z: 0 }, { x: 0, y: 0, z: 45 })
46
- const landed = boxOf(applied(turned, figurePlaneAlignChanges(turned, 'body').changes), 'body')!
48
+ const landed = box(applied(turned, figurePlaneAlignChanges(turned, 'body').changes), 'body')!
47
49
  assert.ok(Math.abs(landed.centre.y - landed.extent.y / 2) <= 1, `45° 돈 부품의 모서리가 바닥에 닿는다 (${landed.centre.y - landed.extent.y / 2})`)
48
50
  const hung = applyFigureAuthoring(probe({ x: 0, y: -300, z: 0 }), { kind: 'set-placement', placement: 'ceiling' })
49
- const top = boxOf(applied(hung, figurePlaneAlignChanges(hung, 'body').changes), 'body')!
51
+ const top = box(applied(hung, figurePlaneAlignChanges(hung, 'body').changes), 'body')!
50
52
  assert.equal(Math.round(top.centre.y + top.extent.y / 2), 0, '천장: 윗면이 y 0')
51
53
  const editor = read('../client/modeller/editor.ts')
52
54
  assert.ok(editor.includes('const { changes, contact, refusal } = figurePlaneAlignChanges(asset, this.selected)'))
@@ -55,7 +57,7 @@ test('기준면 맞추기 — 배치를 따르고, 한 번 옮길 뿐 붙이지
55
57
 
56
58
  test('눈금은 1 · 2 · 5 간격으로 자리와 크기를 맞추고, 크기를 0 으로 만들지 않는다', () => {
57
59
  const asset = probe({ x: 133, y: 47, z: -71 })
58
- const step = gridStepOf(asset)
60
+ const step = gridStepOf(revisionOf(asset))
59
61
  assert.equal(step, 50, '가장 긴 폭 200 을 다섯 칸으로 — 40 이 아니라 50')
60
62
  const snapped = run(asset, 'snap')
61
63
  assert.deepEqual(['x', 'y', 'z'].map(a => snapped.designInputs[`body.position.${a}`]), [150, 50, -50])
@@ -65,14 +67,14 @@ test('눈금은 1 · 2 · 5 간격으로 자리와 크기를 맞추고, 크기
65
67
  test('대칭은 복제본을 가운데 면 반대편 같은 거리에 둔다 · 가운데에 있으면 내지 않는다', () => {
66
68
  const asset = probe()
67
69
  const copied = editFigurePart(asset, { kind: 'duplicate-part', target: 'body' })
68
- const mirrored = applied(copied, mirrorChanges(copied, 'body', 'body-copy', 'x'))
69
- assert.equal(Math.round(boxOf(mirrored, 'body-copy')!.centre.x), -Math.round(boxOf(mirrored, 'body')!.centre.x))
70
- assert.equal(canMirror(probe({ x: 0, y: 50, z: 0 }) as never, 'body', 'x', gizmoReachOf(probe() as never, 'body')), false)
70
+ const mirrored = applied(copied, mirrorChanges(revisionOf(copied), 'body', 'body-copy', 'x'))
71
+ assert.equal(Math.round(box(mirrored, 'body-copy')!.centre.x), -Math.round(box(mirrored, 'body')!.centre.x))
72
+ assert.equal(canMirror(revisionOf(probe({ x: 0, y: 50, z: 0 })), 'body', 'x', gizmoReachOf(probe() as never, 'body')), false)
71
73
  })
72
74
 
73
75
  test('붙인 부품처럼 옮길 수 없는 것은 맞추기도 없다', () => {
74
- assert.deepEqual(alignChanges(probe(), 'body', { translate: false, rotate: false, scale: false }, 'centre-x'), [])
75
- assert.match(read('../client/modeller/editor.ts'), /if \(!this\.selected \|\| !reach\.translate\) return null/)
76
+ assert.deepEqual(alignChanges(revisionOf(probe()), 'body', { translate: false, rotate: false, scale: false }, 'centre-x'), [])
77
+ assert.match(read('../client/modeller/editor.ts'), /if \(!this\.selected \|\| !reach\.translate \|\| !shown\) return null/)
76
78
  })
77
79
 
78
80
  test('the grid step reads well -- 27 or 33 would make coordinates unreadable -- and is never 0', () => {
@@ -6,7 +6,7 @@
6
6
  import 'figure-ui-client/modeller/editor.js'
7
7
  import { budgetOf, figureReportOf } from 'figure-ui-client/modeller/maturity.js'
8
8
  import { createFigureProductSession } from 'figure-ui-client/modeller/product-session.js'
9
- import { addFigureBox, addFigurePrimitive, applyFigureAuthoring, createFigureAsset, parseFigureAsset, robotArmFigure, serializeFigureAsset } from '@hatiolab/figure-model'
9
+ import { addFigureBox, addFigurePrimitive, applyFigureAuthoring, createFigureAsset, figureWork, robotArmFigure, serializeFigureAsset } from '@hatiolab/figure-model'
10
10
 
11
11
  const PALETTE = {
12
12
  'palette.dark': '#2b2b2b',
@@ -234,6 +234,49 @@ window.probe = {
234
234
  fan() {
235
235
  return canvas().scene.root.components.some(c => c.state.name === '__joint-fan')
236
236
  },
237
+ trace() {
238
+ const stacks = (window.__stacks = [])
239
+ for (const key of ['compiles', 'evaluations']) {
240
+ let v = figureWork[key]
241
+ Object.defineProperty(figureWork, key, { configurable: true, get: () => v, set: n => { v = n; stacks.push(key + ' ' + new Error().stack.split('\n').slice(2, 9).map(l => l.trim().replace(/\(http.*?page\.js/, '(').replace(/^at /, '')).join(' < ')) } })
242
+ }
243
+ },
244
+ traced() {
245
+ return window.__stacks
246
+ },
247
+ /** Whole-figure work done so far (figure-model's count): compiles and evaluations. */
248
+ work() {
249
+ return { ...figureWork }
250
+ },
251
+ /**
252
+ * The two middles -- the edit canvas and the preview -- marked, so a later look says whether each is the same element
253
+ * with the same scene, or was made again.
254
+ */
255
+ markMiddles() {
256
+ const preview = deep('figure-preview')[0]
257
+ for (const one of [canvas(), canvas()?.scene, preview, preview?.scene]) if (one) one.__mark = true
258
+ },
259
+ middles() {
260
+ const preview = deep('figure-preview')[0]
261
+ const area = deep('div[middle]')[0]
262
+ const size = canvas()?.scene?.root?._threeCapability?.rendererManager?.domElement
263
+ return {
264
+ canvas: !!canvas()?.__mark,
265
+ canvasScene: !!canvas()?.scene?.__mark,
266
+ editHidden: !!area?.hidden && getComputedStyle(area).display === 'none',
267
+ editSize: size ? [size.width, size.height] : null,
268
+ preview: preview ? { marked: !!preview.__mark, scene: !!preview.scene, sceneMarked: !!preview.scene?.__mark, hidden: preview.hidden, stale: preview.stale } : null
269
+ }
270
+ },
271
+ /** Whether the edit canvas gives the card a picture now. */
272
+ async editPicture() {
273
+ return !!(await canvas().snapshot())
274
+ },
275
+ /** The page point of the edit / preview switch. */
276
+ modeButton(mode) {
277
+ const b = deep('button[mode]').find(b => b.title === `figure.text.mode-${mode}-explained`)
278
+ return b ? centreOf(b) : null
279
+ },
237
280
  /** The drawn position of a part's component, as the canvas shows it now. */
238
281
  drawn(id) {
239
282
  const c = canvas().scene.root.components.find(c => c.state.name === id)
@@ -251,16 +294,21 @@ window.harness = {
251
294
  await session.open('probe')
252
295
  const editor = document.createElement('figure-editor')
253
296
  editor.session = session
254
- /* The cost line as the modeller page gives it: the maturity report of what is authored (figure-verdict). */
297
+ /* The cost line as the modeller page gives it: the maturity report of the version on screen (figure-verdict). */
255
298
  const report = () => {
256
299
  try {
257
- return budgetOf(figureReportOf(parseFigureAsset(session.source())))
300
+ return budgetOf(figureReportOf(session.revisionNow().asset))
258
301
  } catch {
259
302
  return undefined
260
303
  }
261
304
  }
262
305
  editor.budget = report()
263
- editor.addEventListener('changed', () => (editor.budget = report()))
306
+ /* Measured once the figure has settled, as the page's verdict measures it. */
307
+ let settle
308
+ editor.addEventListener('changed', () => {
309
+ clearTimeout(settle)
310
+ settle = setTimeout(() => (editor.budget = report()), 200)
311
+ })
264
312
  editor.templates = templates
265
313
  editor.guideOf = async name => guides[name] ?? ''
266
314
  editor.revision = 0
@@ -0,0 +1,144 @@
1
+ /*
2
+ * The modeller in a real browser, counted: what a selection, a value typed and going to the preview and back cost in
3
+ * whole-figure work (figure-model's compiles and evaluations), and that the two middles -- the edit canvas and the
4
+ * preview -- stay standing rather than being made again. A count is the same on every machine; the milliseconds are
5
+ * measured on the app (figure-model docs/modeller-interaction-performance-2026-10-07.md).
6
+ *
7
+ * Run: npm run test:browser
8
+ * FIGURE_MODEL=<a figure-model build> npm run test:browser (a figure-model not published yet)
9
+ */
10
+ import { test, describe, before, after } from 'node:test'
11
+ import assert from 'node:assert/strict'
12
+
13
+ import { buildPage } from './build.mjs'
14
+ import { openPage } from './serve.mjs'
15
+
16
+ const sleep = ms => new Promise(done => setTimeout(done, ms))
17
+ /* The event handled and the render it asked for done -- not the work told to wait until the screen rests (150 ms). */
18
+ const drawn = () => page().evaluate(() => document.querySelector('figure-editor').updateComplete.then(() => probe.work()))
19
+
20
+ let opened
21
+ before(async () => {
22
+ opened = await openPage(await buildPage())
23
+ })
24
+ after(async () => {
25
+ await opened?.close()
26
+ })
27
+
28
+ const page = () => opened.page
29
+ const read = (fn, ...args) => page().evaluate(fn, ...args)
30
+ const work = () => read(() => probe.work())
31
+ const since = (a, b) => ({ compiles: b.compiles - a.compiles, evaluations: b.evaluations - a.evaluations })
32
+
33
+ /* Nothing still being worked out from before: the count stands still for half a second. */
34
+ async function settled() {
35
+ for (let last = await work(), i = 0; i < 20; i++) {
36
+ await sleep(500)
37
+ const now = await work()
38
+ if (now.compiles === last.compiles && now.evaluations === last.evaluations) return
39
+ last = now
40
+ }
41
+ assert.fail('the modeller kept working')
42
+ }
43
+ async function mount(kind) {
44
+ await read(kind => harness.mount(kind, null), kind)
45
+ await sleep(2500)
46
+ await settled()
47
+ }
48
+ async function click(point) {
49
+ assert.ok(point, 'nothing on the page to click')
50
+ await page().mouse.click(point.x, point.y)
51
+ }
52
+ async function mode(which) {
53
+ await click(await read(m => probe.modeButton(m), which))
54
+ await sleep(1500)
55
+ }
56
+
57
+ describe('work counted on the robot arm', () => {
58
+ before(() => mount('arm'))
59
+
60
+ /* The motion tab is drawn out of sight with the rest: the selected part's motion is evaluated at its two ends. */
61
+ test('picking a part on the canvas compiles nothing; it evaluates only the two ends of the part\'s motion', async () => {
62
+ const part = 'upper-arm'
63
+ const before = await work()
64
+ await click(await read(id => probe.partOnPage(id), part))
65
+ const spent = since(before, await drawn())
66
+ assert.equal((await read(() => probe.state())).selected, part)
67
+ assert.deepEqual(spent, { compiles: 0, evaluations: 2 })
68
+ /*
69
+ Once the screen rests, two commands are seen on copies (ADR-0097 §5.5): taking the part off (its step without the
70
+ joint, and its result) and the fastening the form opens on (its result). Each compiles only what it makes; the
71
+ figure as it stands is not compiled again.
72
+ */
73
+ await settled()
74
+ const rested = since(before, await work())
75
+ assert.ok(rested.compiles <= 3, JSON.stringify(rested))
76
+ })
77
+
78
+ /* The edit and its read are the kernel's two compiles (its version's asset is the edit's own); then the part's motion ends. */
79
+ test('a value typed into its field: the edit and its read, and nothing else while it is drawn', async () => {
80
+ const field = await read(() => {
81
+ const deep = (root = document) => [...root.querySelectorAll('input[type=number]')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
82
+ const input = deep().find(i => !i.disabled && /\.size\.|\.dimension\./.test(i.getAttribute('aria-label') ?? ''))
83
+ if (!input) return null
84
+ input.scrollIntoView({ block: 'center' })
85
+ const r = input.getBoundingClientRect()
86
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2, label: input.getAttribute('aria-label'), value: Number(input.value) }
87
+ })
88
+ assert.ok(field, 'a size field of the part')
89
+ const revision = (await read(() => probe.state())).revision
90
+ await page().mouse.click(field.x, field.y, { clickCount: 3 })
91
+ await page().keyboard.type(String(field.value + 10))
92
+ const before = await work()
93
+ await page().keyboard.press('Enter')
94
+ const spent = since(before, await drawn())
95
+ assert.equal((await read(() => probe.state())).revision, revision + 1, `${field.label} is one step`)
96
+ assert.ok(spent.compiles <= 2 && spent.evaluations <= 5, JSON.stringify(spent))
97
+ })
98
+ })
99
+
100
+ describe('the edit canvas and the preview stay standing', () => {
101
+ before(() => mount('box'))
102
+
103
+ test('the preview is made when first asked for; going back, the edit canvas is the one that was there', async () => {
104
+ assert.equal((await read(() => probe.middles())).preview, null, 'no preview before it is asked for')
105
+ await read(() => probe.markMiddles())
106
+ await mode('preview')
107
+ let m = await read(() => probe.middles())
108
+ assert.ok(m.preview?.scene && !m.preview.hidden, 'the preview stands in front')
109
+ assert.ok(m.editHidden, 'the edit area is out of sight, not taken down')
110
+ assert.ok(m.editSize && m.editSize[0] > 0 && m.editSize[1] > 0, 'and not resized to nothing')
111
+ assert.equal(await read(() => probe.editPicture()), false, 'out of sight it takes no picture, as when there was no canvas')
112
+ await read(() => probe.markMiddles())
113
+ await mode('edit')
114
+ m = await read(() => probe.middles())
115
+ assert.ok(m.canvas && m.canvasScene, 'the same edit canvas, the same scene')
116
+ assert.ok(m.preview?.marked && m.preview.hidden, 'the preview stays, out of sight')
117
+ })
118
+
119
+ test('shown again unchanged, the preview is the one that stood, and nothing is worked out', async () => {
120
+ const before = await work()
121
+ await mode('preview')
122
+ const m = await read(() => probe.middles())
123
+ assert.ok(m.preview?.marked && m.preview.sceneMarked, 'the same preview and scene')
124
+ assert.deepEqual(since(before, await work()), { compiles: 0, evaluations: 0 })
125
+ })
126
+
127
+ test('a value changed beside the preview is drawn inside the scene that stands', async () => {
128
+ await click(await read(() => probe.row('box')))
129
+ await sleep(300)
130
+ const field = await read(() => probe.labelled('box.size.y'))
131
+ assert.ok(field, 'the height field')
132
+ await page().mouse.click(field.x, field.y, { clickCount: 3 })
133
+ await page().keyboard.type('600')
134
+ await page().keyboard.press('Enter')
135
+ await sleep(1500)
136
+ const m = await read(() => probe.middles())
137
+ assert.ok(m.preview?.sceneMarked, 'the same 3D scene')
138
+ assert.equal(await read(() => probe.value('box.size.y')), 600)
139
+ })
140
+ })
141
+
142
+ test('the page raised no error', () => {
143
+ assert.deepEqual(opened.errors, [])
144
+ })
@@ -12,6 +12,7 @@ import { addFigureBox, applyFigureAuthoring, createFigureAsset, figurePartFaceOf
12
12
  import { createFigureProductSession } from '../client/modeller/product-session.js'
13
13
  import { faceHighlightOf, ghostPartsOf, previewWordsOf, type HeldPreview } from '../client/modeller/command-preview.js'
14
14
  import { boxShape } from '../client/modeller/drawn-shapes.ts'
15
+ import { revisionOf } from './revision-of.ts'
15
16
 
16
17
  const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
17
18
  const ko = JSON.parse(read('../translations/ko.json'))
@@ -118,7 +119,7 @@ test('detaching says what it would do: stays, what stays fastened to it, the cli
118
119
  assert.equal(seen.moves.length, 0, 'the lamp stays where it stands')
119
120
  assert.equal(s.read().revision, 0)
120
121
  const editor = read('../client/modeller/editor.ts')
121
- assert.ok(editor.includes("const own = seen.moves.some(m => m.body === this.selected)"))
122
+ assert.ok(editor.includes("const own = seen.moves.some(m => m.body === part)"))
122
123
  /* Staying where it is is as expected: nothing is said then (user 2026-10-01); a move, a child kept, a clip lost are. */
123
124
  assert.ok(editor.includes(': /* It stays where it is: as expected, so nothing is said (user 2026-10-01). */ []'), 'nothing said when it stays')
124
125
  const links = read('../client/modeller/figure-links.ts')
@@ -161,8 +162,8 @@ test('a row is seen before it is made, read as figure-model draws it -- and said
161
162
  assert.ok(links.includes("<section row><details row-fold>") && links.includes("<details row-fold ?open=${this.working === 'row'}>"))
162
163
  assert.ok(links.includes("this.candidate = this.working === 'row' && this.rowCandidate ? { action: this.rowCandidate } : this.fastenCandidate"), 'the form being worked on is the one seen')
163
164
  const editor = read('../client/modeller/editor.ts')
164
- assert.ok(editor.includes("const row = candidate.action.kind === 'make-repeat' ? figureRowOf(seen.asset, this.selected, this.currentInputs()) : undefined"))
165
- assert.ok(editor.includes('<ul row-reading>${rowWordsOf(this.rowNow(asset)'), 'a row made says the same, as it stands')
165
+ assert.ok(editor.includes("const row = candidate.action.kind === 'make-repeat' ? figureRowOf(seen.asset, this.selected, this.shown()!.inputs) : undefined"))
166
+ assert.ok(editor.includes('<ul row-reading>${rowWordsOf(this.rowNow(shown)'), 'a row made says the same, as it stands')
166
167
  for (const lang of ['en', 'ja', 'ko', 'ms', 'zh']) {
167
168
  const t = JSON.parse(read(`../translations/${lang}.json`))
168
169
  for (const key of ['row-count-pitch', 'row-count-uneven', 'row-ends-centred', 'row-ends', 'row-ends-unknown', 'row-companions', 'row-count-spreads', 'row-blocked-unfastened', 'row-blocked-on-plane', 'row-blocked-holds-parts'])
@@ -210,15 +211,15 @@ test('a motion is named by its parts, and drawn at both ends of its range -- onl
210
211
  a = applyFigureAuthoring(a, { kind: 'attach', part: 'leaf', face: 'bottom', to: { part: 'frame', face: 'top' } })
211
212
  a = applyFigureAuthoring(a, { kind: 'attach', part: 'gate', face: 'bottom', to: { part: 'frame', face: 'top' } })
212
213
  const j = (part: string) => a.joints!.find(k => k.body1.part === part)!
213
- assert.equal(motionEndsOf(a, 'leaf'), null, 'not moving: nothing drawn')
214
+ assert.equal(motionEndsOf(revisionOf(a), 'leaf'), null, 'not moving: nothing drawn')
214
215
  a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { ...j('leaf'), type: 'revolute', axis: 'Y', lowerLimit: 0, upperLimit: 90, state: { id: 'open' } } })
215
216
  a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { ...j('gate'), type: 'prismatic', axis: 'X', lowerLimit: 0, upperLimit: 300, state: { id: 'slide' } } })
216
- /* With the values the screen holds -- design and control alike -- as the editor passes them. */
217
- const held = { ...a.designInputs, ...a.stateDefaults, 'frame.size.x': 2400 }
218
- const turn = motionEndsOf(a, 'leaf', held)!
217
+ /* At the version the screen holds, a design value typed since it was opened. */
218
+ const held = revisionOf(a, { 'frame.size.x': 2400 })
219
+ const turn = motionEndsOf(held, 'leaf')!
219
220
  assert.deepEqual([turn.control, turn.min, turn.max], ['open', 0, 90])
220
221
  assert.deepEqual(turn.ghost.map(g => g.id).sort(), ['__preview:max:leaf', '__preview:min:leaf'], 'the leaf at 0 and at 90, nothing else')
221
- const slide = motionEndsOf(a, 'gate', held)!
222
+ const slide = motionEndsOf(held, 'gate')!
222
223
  const line = slide.ghost.find(g => g.id === '__target-face:slide')!
223
224
  assert.equal(Math.round((line.shape as unknown as { extrusion: { section: { size: { x: number } } } }).extrusion.section.size.x), 300, 'the line it travels, its whole stroke')
224
225
  const editor = read('../client/modeller/editor.ts')
@@ -21,7 +21,7 @@ test('부품의 면·맞닿기 고르기 칸이 없고, 그 자리에 그림이
21
21
  assert.ok(!form.includes("i18next.t('figure.label.facing')"), 'no facing list')
22
22
  has(form, /<figure-fasten-drawing[\s\S]*?\.modes=\$\{f\.toPlane \? \(\['meet'\] as FastenMode\[\]\)/, 'the plane is set on only')
23
23
  has(form, /turnsInPlace\(asset, this\.partId\) \? \(\['centre'\] as FastenMode\[\]\) : \[\]/, 'centre on it only for a part that turns')
24
- has(form, /const derived = f\.toPlane \|\| f\.target \? myFaceFor\(asset, this\.partId, aim, mode\) : undefined/, 'the part\'s face follows the target and how')
24
+ has(form, /const derived = f\.toPlane \|\| f\.target \? myFaceFor\(this\.revision!, this\.partId, aim, mode\) : undefined/, 'the part\'s face follows the target and how')
25
25
  })
26
26
 
27
27
  test('상대 목록에 배치 기준의 면이 이름으로 있다: 바닥 · 천장 · 벽 · 중심', () => {
@@ -12,6 +12,7 @@ import { addFigureBox, applyFigureAuthoring, compileFigureAsset, createFigureAss
12
12
  import type { FigureAsset, FigureAuthoringAction } from '@hatiolab/figure-model'
13
13
 
14
14
  import { alignmentAt, faceViewOf, myFaceFor, offsetOf, overhangOf, screenOf } from '../client/modeller/fasten-geometry.ts'
15
+ import { revisionOf } from './revision-of.ts'
15
16
 
16
17
  const scene = (lidRotation?: { x: number; y: number; z: number }): FigureAsset => {
17
18
  let a = addFigureBox(createFigureAsset('Box'), { id: 'bin', size: { x: 600, y: 300, z: 400 }, position: { x: 0, y: 150, z: 0 } })
@@ -22,24 +23,24 @@ const at = (a: FigureAsset, id: string) => (compileFigureAsset(a).evaluate().geo
22
23
 
23
24
  test('부품의 면은 상대 면과 방식으로 정해진다: 올려놓기는 마주 보는 면, 같은 면은 같은 면, 가운데는 가운데', () => {
24
25
  const a = scene()
25
- assert.equal(myFaceFor(a, 'lid', { part: 'bin', face: 'top' }, 'meet'), 'bottom')
26
- assert.equal(myFaceFor(a, 'lid', { part: 'bin', face: 'top' }, 'flush'), 'top')
27
- assert.equal(myFaceFor(a, 'lid', { part: 'bin', face: 'front' }, 'meet'), 'back')
28
- assert.equal(myFaceFor(a, 'lid', { part: 'bin', face: 'top' }, 'centre'), 'centre')
29
- assert.equal(myFaceFor(a, 'lid', 'plane', 'meet'), 'bottom', 'on a floor: the face looking down')
30
- assert.equal(myFaceFor(a, 'lid', 'plane', 'centre'), undefined, 'the plane takes a face, never a centre')
26
+ assert.equal(myFaceFor(revisionOf(a), 'lid', { part: 'bin', face: 'top' }, 'meet'), 'bottom')
27
+ assert.equal(myFaceFor(revisionOf(a), 'lid', { part: 'bin', face: 'top' }, 'flush'), 'top')
28
+ assert.equal(myFaceFor(revisionOf(a), 'lid', { part: 'bin', face: 'front' }, 'meet'), 'back')
29
+ assert.equal(myFaceFor(revisionOf(a), 'lid', { part: 'bin', face: 'top' }, 'centre'), 'centre')
30
+ assert.equal(myFaceFor(revisionOf(a), 'lid', 'plane', 'meet'), 'bottom', 'on a floor: the face looking down')
31
+ assert.equal(myFaceFor(revisionOf(a), 'lid', 'plane', 'centre'), undefined, 'the plane takes a face, never a centre')
31
32
  })
32
33
 
33
34
  test('돌려 놓인 부품이면 지금 아래를 보는 면이다 — 이름이 아니라 방향으로', () => {
34
35
  const a = scene({ x: 90, y: 0, z: 0 })
35
- const face = myFaceFor(a, 'lid', { part: 'bin', face: 'top' }, 'meet')!
36
+ const face = myFaceFor(revisionOf(a), 'lid', { part: 'bin', face: 'top' }, 'meet')!
36
37
  assert.notEqual(face, 'bottom', 'turned a quarter about X, its bottom no longer looks down')
37
38
  const n = figurePartFaceOf(a, 'lid', face)!.normal
38
39
  assert.ok(Math.abs(n[1]! + 1) < 1e-6, `${face} looks down: ${n}`)
39
40
  })
40
41
 
41
42
  test('면 그림: 상대 면의 크기와 부품 발자국, 가장자리 맞춤이 되는 축', () => {
42
- const v = faceViewOf(scene(), 'lid', { part: 'bin', face: 'top' })!
43
+ const v = faceViewOf(revisionOf(scene()), 'lid', { part: 'bin', face: 'top' })!
43
44
  assert.deepEqual(v.axes, ['x', 'z'])
44
45
  assert.deepEqual([v.face.x, v.face.z], [600, 400])
45
46
  assert.deepEqual([v.part.x, v.part.z], [300, 200])
@@ -57,7 +58,7 @@ test('놓은 자리: 가운데, 안쪽 끝(가장자리와 나란히), 바깥
57
58
  })
58
59
 
59
60
  test('면 밖으로 나온 곳과 닿지 않는 것을 말한다', () => {
60
- const v = faceViewOf(scene(), 'lid', { part: 'bin', face: 'top' })!
61
+ const v = faceViewOf(revisionOf(scene()), 'lid', { part: 'bin', face: 'top' })!
61
62
  assert.deepEqual(overhangOf(v, { x: 0, y: 0, z: 0 }), { touching: true, sides: [] })
62
63
  assert.deepEqual(overhangOf(v, { x: 200, y: 0, z: 0 }), { touching: true, sides: [{ axis: 'x', side: 'high', mm: 50 }] })
63
64
  assert.equal(overhangOf(v, { x: 460, y: 0, z: 0 }).touching, false)
@@ -83,7 +83,7 @@ test('편집기는 저작한 부품 단위로 고르고 보인다 — 되풀이
83
83
  assert.ok(canvas.includes('.filter(([name]) => ownerOf(name) === this.selected)'), '사용자 2026-09-24: 「why repeatable parts are not selected?」')
84
84
  const editor = read('../client/modeller/editor.ts')
85
85
  assert.ok(editor.includes('this.selected = id ? ownerOf(id) : id'), '캔버스에서 복사본을 누르면 그 되풀이를 고른다')
86
- assert.ok(editor.includes('.parts=${this.partRows(view.geometry, asset as FigureAsset | undefined)}'), '목록은 저작한 부품 한 줄씩')
86
+ assert.ok(editor.includes('.parts=${this.partRows(view.geometry, asset)}'), '목록은 저작한 부품 한 줄씩')
87
87
  assert.ok(editor.includes("this.session!.command({ kind: 'set-repeat', part: this.selected, pitch })"), 'pitch 는 명령으로 정한다')
88
88
  const parts = read('../client/modeller/figure-parts.ts')
89
89
  assert.ok(parts.includes('?disabled=${this.locked || full}'), '등급 한도에 이르면 팔레트가 잠긴다')
@@ -67,7 +67,7 @@ test('narrow, the parts list folds for the canvas, said once, and stays open onc
67
67
 
68
68
  test('a palette part\'s own sizes are its shape\'s fields, not a rule\'s: no "set by a rule" size row for them', () => {
69
69
  const editor = read('../client/modeller/editor.ts')
70
- assert.ok(editor.includes("sizeRuled: (['x', 'y', 'z'] as const).every(axis => followOf(parsed, ownerOf(this.selected), axis).of !== 'own')"))
70
+ assert.ok(editor.includes("sizeRuled: (['x', 'y', 'z'] as const).every(axis => followOf(shown.asset, ownerOf(this.selected), axis).of !== 'own')"))
71
71
  assert.ok(editor.includes('if (!held?.sizeRuled ||'))
72
72
  })
73
73
 
@@ -168,7 +168,7 @@ test('크기 칸과 손잡이 — 배치가 지키는 면을 지킨다, 커널
168
168
  hung = applyFigureAuthoring(hung, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(hung), placement: 'ceiling' })
169
169
  assert.equal(centreY(grow(hung, 'lamp', gizmoReachOf(hung, 'lamp').sizes![1], 300), 'lamp') + 300 / 2, 0, '천장 도형: 윗면이 천장에')
170
170
  const editor = read('../client/modeller/editor.ts')
171
- assert.ok(editor.includes('figureSizeEditChanges(this.parsedAsset(), part, [change])'), '숫자 칸이 부른다')
171
+ assert.ok(editor.includes('figureSizeEditChanges(this.shown()!.asset, part, [change])'), '숫자 칸이 부른다')
172
172
  assert.ok(editor.includes('figureSizeEditChanges(asset, ownerOf(id), written)'), '손잡이도 부른다')
173
173
  assert.ok(!read('../client/modeller/gizmo.ts').includes('keepBottomChanges'), '화면의 제 산수는 없다')
174
174
  })
@@ -12,6 +12,8 @@ import { addFigureBox, applyFigureAuthoring, compileFigureAsset, createFigureAss
12
12
  import type { FigureAsset } from '@hatiolab/figure-model'
13
13
 
14
14
  import { JOINT_FAN, jointFanOf } from '../client/modeller/joint-fan.ts'
15
+ import { revisionOf } from './revision-of.ts'
16
+ import { liveOverSession } from '../client/modeller/live.ts'
15
17
  import { moving } from './joints.ts'
16
18
 
17
19
  const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
@@ -43,7 +45,7 @@ const direction = (v: number[]) => {
43
45
 
44
46
  test('a part that turns gets a fan from the lowest value to the highest, where the part really goes', () => {
45
47
  const asset = arm()
46
- const fan = jointFanOf(asset, 'arm') as unknown as { id: string; shape: { provider: string; profile: { x: number; y: number }[] }; pose: { t: number[]; r: number[][] } }
48
+ const fan = jointFanOf(revisionOf(asset), 'arm') as unknown as { id: string; shape: { provider: string; profile: { x: number; y: number }[] }; pose: { t: number[]; r: number[][] } }
47
49
  assert.ok(fan)
48
50
  assert.equal(fan.id, JOINT_FAN)
49
51
  assert.equal(fan.shape.provider, 'extrude', 'a section pushed thin, drawn as a part is')
@@ -65,32 +67,46 @@ test('a part that turns gets a fan from the lowest value to the highest, where t
65
67
  })
66
68
 
67
69
  test('a fan is as large as what turns, and never smaller than 60 mm', () => {
68
- const big = jointFanOf(arm(), 'arm') as unknown as { shape: { profile: { x: number; y: number }[] } }
70
+ const big = jointFanOf(revisionOf(arm()), 'arm') as unknown as { shape: { profile: { x: number; y: number }[] } }
69
71
  const radius = Math.max(...profileOf(big).map(p => Math.hypot(p.x, p.y)))
70
72
  assert.ok(Math.abs(radius - 240) < 1e-3, `0.6 × 400 — ${radius}`)
71
73
  })
72
74
 
73
75
  test('no fan for a part that does not turn, nor for a slide -- a fan in a guessed plane would point the wrong way', () => {
74
76
  /* The base does not turn; it shows the joint that sits on it, the arm's -- one joint, one fan (the elbow rule). */
75
- const onBase = jointFanOf(arm(), 'base') as unknown as { shape: { profile: unknown[] } }
76
- const ofArm = jointFanOf(arm(), 'arm') as unknown as { shape: { profile: unknown[] } }
77
+ const onBase = jointFanOf(revisionOf(arm()), 'base') as unknown as { shape: { profile: unknown[] } }
78
+ const ofArm = jointFanOf(revisionOf(arm()), 'arm') as unknown as { shape: { profile: unknown[] } }
77
79
  assert.deepEqual(profileOf(onBase), profileOf(ofArm))
78
- assert.equal(jointFanOf(arm(), ''), null)
80
+ assert.equal(jointFanOf(revisionOf(arm()), ''), null)
79
81
  let slider = createFigureAsset('Probe')
80
82
  slider = addFigureBox(slider, { id: 'block', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
81
83
  slider = moving(slider, 'block', { kind: 'slide', axis: 'x', id: 'shift', min: 0, max: 60 })
82
- assert.equal(jointFanOf(slider, 'block'), null)
84
+ assert.equal(jointFanOf(revisionOf(slider), 'block'), null)
83
85
  })
84
86
 
85
- test('the editor draws it for the selected part, with the pose in hand; the canvas never lets it be picked', () => {
87
+ test('the editor draws it for the selected part; the canvas never lets it be picked', () => {
86
88
  const editor = read('../client/modeller/editor.ts')
87
- assert.ok(editor.includes('.fan=${this.fanOf(asset as FigureAsset | undefined)}'))
88
- assert.ok(editor.includes('return jointFanOf(asset, this.selected, { ...this.currentInputs(), ...this.live.values })'), 'the fan reads the pose the session holds')
89
+ assert.ok(editor.includes('.fan=${this.fanOf(shown, live)}'))
89
90
  const canvas = read('../client/modeller/canvas.ts')
90
91
  /* The fan, and what a command would do, are shown and never picked (ADR-0097 §5.5). */
91
92
  assert.ok(canvas.includes('.map(c => ({ ...c, capturable: false, locked: true }))'))
92
93
  })
93
94
 
95
+ /*
96
+ * The fan stands where the joint is drawn: the pose set with the controls (the version's inputs), and while a control is
97
+ * dragged, the pose in hand -- the same as a version where that pose was set (user 2026-09-24: the arm jumped on release).
98
+ */
99
+ test('the fan follows the pose as drawn: set with the controls, or in hand while a control is dragged', () => {
100
+ const at = (fan: unknown) => (fan as { pose: { t: number[] } }).pose.t.map(v => Math.round(v * 1000) / 1000)
101
+ const rest = jointFanOf(revisionOf(column()), 'tip')
102
+ const set = jointFanOf(revisionOf(column(), { hinge: 40 }), 'tip')
103
+ assert.notDeepEqual(at(set), at(rest), 'the wrist moves with the hinge')
104
+ const shown = revisionOf(column())
105
+ const live = { values: { hinge: 40 } }
106
+ const inHand = jointFanOf(shown, 'tip', { ...live, geometry: liveOverSession(shown, live)! })
107
+ assert.deepEqual(at(inHand), at(set))
108
+ })
109
+
94
110
  /* ── Joints on a chain (user 2026-09-24: 「로봇암 axis와 관절의 각도 표현을 다시 검증해보라」) ───────── */
95
111
 
96
112
  /**
@@ -122,7 +138,7 @@ for (const [part, joint] of [
122
138
  ] as const) {
123
139
  test(`${part} on ${joint}: the axis holds the part's plane, the fan's ends are where it goes, the fan opens as far as it turns`, () => {
124
140
  const asset = column()
125
- const fan = jointFanOf(asset, part) as unknown as { pose: { t: number[]; r: number[][] }; shape: { profile: { x: number; y: number }[] } }
141
+ const fan = jointFanOf(revisionOf(asset), part) as unknown as { pose: { t: number[]; r: number[][] }; shape: { profile: { x: number; y: number }[] } }
126
142
  assert.ok(fan, 'a fan -- the first joint of a chain included')
127
143
  const input = asset.document.model.inputs.find(i => i.id === joint)!
128
144
  const pivot = fan.pose.t
@@ -152,7 +168,7 @@ for (const [part, joint] of [
152
168
 
153
169
  test('a housing shows the joint it houses, not the one that carries it; a part that holds no pivot shows none', () => {
154
170
  const asset = column()
155
- const turns = (part: string) => (jointFanOf(asset, part) as unknown as { turns?: string } | null)?.turns ?? null
171
+ const turns = (part: string) => (jointFanOf(revisionOf(asset), part) as unknown as { turns?: string } | null)?.turns ?? null
156
172
  assert.equal(turns('housing'), 'wrist', 'user 2026-09-24: 「elbow-housing itself is a axis」 -- it rides the hinge, it houses the wrist')
157
173
  assert.equal(turns('link'), 'hinge', 'a link with pivots only at its ends shows the joint that turns it')
158
174
  assert.equal(turns('tip'), 'wrist')
@@ -160,7 +176,7 @@ test('a housing shows the joint it houses, not the one that carries it; a part t
160
176
  assert.equal(turns('base'), null)
161
177
 
162
178
  /* The housing's fan is the wrist's: its ends are where the tip goes. */
163
- const fan = jointFanOf(asset, 'housing') as unknown as { pose: { t: number[]; r: number[][] }; shape: { profile: { x: number; y: number }[] } }
179
+ const fan = jointFanOf(revisionOf(asset), 'housing') as unknown as { pose: { t: number[]; r: number[][] }; shape: { profile: { x: number; y: number }[] } }
164
180
  const axis = [0, 1, 2].map(i => fan.pose.r[i]![1]!)
165
181
  const flat = (v: number[]) => sub(v, axis.map(a => a * dot(v, axis)))
166
182
  const onFan = (p: { x: number; y: number }) => [0, 1, 2].map(i => fan.pose.t[i]! + fan.pose.r[i]![0]! * p.x + fan.pose.r[i]![2]! * p.y)
@@ -173,7 +189,7 @@ test('a housing shows the joint it houses, not the one that carries it; a part t
173
189
  test('one joint, one fan: the housing and the part it turns show the same fan, sized from what turns', () => {
174
190
  const asset = column()
175
191
  const size = (part: string) => {
176
- const fan = jointFanOf(asset, part) as unknown as { shape: { profile: { x: number; y: number }[] }; pose: { t: number[] } }
192
+ const fan = jointFanOf(revisionOf(asset), part) as unknown as { shape: { profile: { x: number; y: number }[] }; pose: { t: number[] } }
177
193
  return { radius: Math.max(...profileOf(fan).map(p => Math.hypot(p.x, p.y))), pivot: fan.pose.t }
178
194
  }
179
195
  const housing = size('housing'),
@@ -186,7 +202,7 @@ test('an axis running through a part does not make it that joint’s: a roll alo
186
202
  /* ROBOT_ARM's forearm showed the wrist roll, whose axis runs its length (2026-09-24 audit). */
187
203
  /* A link on a hinge and a roller on a twist whose axis runs along the link. */
188
204
  const asset = fixture('joint-roller')
189
- const turns = (part: string) => (jointFanOf(asset, part) as unknown as { turns?: string } | null)?.turns ?? null
205
+ const turns = (part: string) => (jointFanOf(revisionOf(asset), part) as unknown as { turns?: string } | null)?.turns ?? null
190
206
  assert.equal(turns('link'), 'hinge', 'the twist axis runs through the link, and the link does not twist')
191
207
  assert.equal(turns('roller'), 'twist')
192
208
  assert.equal(turns('base'), null)