@things-factory/figure-ui 10.1.270 → 10.1.272

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 (113) hide show
  1. package/client/graphql/index.ts +2 -0
  2. package/client/modeller/canvas.ts +4 -2
  3. package/client/modeller/clips.ts +74 -4
  4. package/client/modeller/data-interface.ts +14 -0
  5. package/client/modeller/editor-styles.ts +11 -0
  6. package/client/modeller/editor.ts +39 -11
  7. package/client/modeller/fasten-form.ts +73 -3
  8. package/client/modeller/fasten-geometry.ts +11 -1
  9. package/client/modeller/figure-cavity-drawing.ts +1 -3
  10. package/client/modeller/figure-clips.ts +101 -8
  11. package/client/modeller/figure-fasten-dialog.ts +41 -30
  12. package/client/modeller/figure-fasten-drawing.ts +5 -4
  13. package/client/modeller/figure-first-save.ts +4 -3
  14. package/client/modeller/figure-links.ts +85 -28
  15. package/client/modeller/figure-preview.ts +50 -4
  16. package/client/modeller/figure-size-editor.ts +7 -2
  17. package/client/modeller/first-save.ts +8 -0
  18. package/client/modeller/focus-on-render.ts +4 -0
  19. package/client/modeller/palette.ts +27 -0
  20. package/client/modeller/pivot.ts +76 -0
  21. package/client/modeller/product-session.ts +5 -1
  22. package/client/modeller/size-response.ts +14 -0
  23. package/client/modeller/stage-model.ts +50 -6
  24. package/client/types.ts +2 -0
  25. package/dist-client/graphql/index.js +2 -0
  26. package/dist-client/graphql/index.js.map +1 -1
  27. package/dist-client/modeller/canvas.d.ts +1 -0
  28. package/dist-client/modeller/canvas.js +3 -2
  29. package/dist-client/modeller/canvas.js.map +1 -1
  30. package/dist-client/modeller/clips.d.ts +23 -6
  31. package/dist-client/modeller/clips.js +69 -4
  32. package/dist-client/modeller/clips.js.map +1 -1
  33. package/dist-client/modeller/data-interface.d.ts +5 -0
  34. package/dist-client/modeller/data-interface.js +15 -0
  35. package/dist-client/modeller/data-interface.js.map +1 -1
  36. package/dist-client/modeller/editor-styles.js +11 -0
  37. package/dist-client/modeller/editor-styles.js.map +1 -1
  38. package/dist-client/modeller/editor.d.ts +6 -0
  39. package/dist-client/modeller/editor.js +35 -11
  40. package/dist-client/modeller/editor.js.map +1 -1
  41. package/dist-client/modeller/fasten-form.d.ts +8 -0
  42. package/dist-client/modeller/fasten-form.js +68 -5
  43. package/dist-client/modeller/fasten-form.js.map +1 -1
  44. package/dist-client/modeller/fasten-geometry.d.ts +6 -0
  45. package/dist-client/modeller/fasten-geometry.js +10 -1
  46. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  47. package/dist-client/modeller/figure-cavity-drawing.js +1 -3
  48. package/dist-client/modeller/figure-cavity-drawing.js.map +1 -1
  49. package/dist-client/modeller/figure-clips.d.ts +9 -0
  50. package/dist-client/modeller/figure-clips.js +104 -8
  51. package/dist-client/modeller/figure-clips.js.map +1 -1
  52. package/dist-client/modeller/figure-fasten-dialog.js +39 -24
  53. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  54. package/dist-client/modeller/figure-fasten-drawing.js +5 -4
  55. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  56. package/dist-client/modeller/figure-first-save.js +4 -3
  57. package/dist-client/modeller/figure-first-save.js.map +1 -1
  58. package/dist-client/modeller/figure-links.d.ts +9 -0
  59. package/dist-client/modeller/figure-links.js +89 -35
  60. package/dist-client/modeller/figure-links.js.map +1 -1
  61. package/dist-client/modeller/figure-preview.js +48 -4
  62. package/dist-client/modeller/figure-preview.js.map +1 -1
  63. package/dist-client/modeller/figure-size-editor.js +7 -3
  64. package/dist-client/modeller/figure-size-editor.js.map +1 -1
  65. package/dist-client/modeller/first-save.d.ts +5 -0
  66. package/dist-client/modeller/first-save.js +7 -0
  67. package/dist-client/modeller/first-save.js.map +1 -1
  68. package/dist-client/modeller/focus-on-render.d.ts +2 -0
  69. package/dist-client/modeller/focus-on-render.js +4 -0
  70. package/dist-client/modeller/focus-on-render.js.map +1 -0
  71. package/dist-client/modeller/links.d.ts +1 -1
  72. package/dist-client/modeller/palette.d.ts +14 -0
  73. package/dist-client/modeller/palette.js +23 -0
  74. package/dist-client/modeller/palette.js.map +1 -1
  75. package/dist-client/modeller/pivot.d.ts +12 -0
  76. package/dist-client/modeller/pivot.js +59 -0
  77. package/dist-client/modeller/pivot.js.map +1 -0
  78. package/dist-client/modeller/product-session.js +6 -2
  79. package/dist-client/modeller/product-session.js.map +1 -1
  80. package/dist-client/modeller/size-response.d.ts +6 -0
  81. package/dist-client/modeller/size-response.js +14 -0
  82. package/dist-client/modeller/size-response.js.map +1 -1
  83. package/dist-client/modeller/stage-model.d.ts +17 -3
  84. package/dist-client/modeller/stage-model.js +44 -6
  85. package/dist-client/modeller/stage-model.js.map +1 -1
  86. package/dist-client/route.d.ts +1 -1
  87. package/dist-client/tsconfig.tsbuildinfo +1 -1
  88. package/dist-client/types.d.ts +2 -0
  89. package/dist-client/types.js.map +1 -1
  90. package/package.json +5 -5
  91. package/test/browser/editing.check.mjs +6 -5
  92. package/test/browser/section.check.mjs +21 -0
  93. package/test/clips.test.ts +49 -4
  94. package/test/data-motion.test.ts +8 -0
  95. package/test/fasten-form.test.ts +57 -0
  96. package/test/fasten-geometry.test.ts +25 -1
  97. package/test/first-part-on-plane.test.ts +52 -0
  98. package/test/first-save.test.ts +11 -1
  99. package/test/pivot.test.ts +57 -0
  100. package/test/save-landed.test.ts +20 -1
  101. package/test/size-response.test.ts +10 -2
  102. package/test/stage-model.test.ts +23 -1
  103. package/translations/en.json +9 -0
  104. package/translations/es.json +9 -0
  105. package/translations/ja.json +9 -0
  106. package/translations/ko.json +9 -0
  107. package/translations/ms.json +9 -0
  108. package/translations/zh.json +9 -0
  109. package/tutorial/humanoid/README.md +22 -0
  110. package/tutorial/humanoid/chapters.mjs +247 -0
  111. package/tutorial/humanoid/encode.mjs +26 -0
  112. package/tutorial/humanoid/lib.mjs +197 -0
  113. package/tutorial/humanoid/record.mjs +65 -0
@@ -125,6 +125,8 @@ export interface FigureGateFinding {
125
125
  /** 갈라진 부품 이름들, 또는 정본 안의 경로. */
126
126
  at?: string;
127
127
  blocking: boolean;
128
+ /** 발행 계약이 깨짐 — 새 피규어 타입으로 저장하면 발행되고, 놓인 피규어는 옛 정의를 유지한다. */
129
+ asNewType?: boolean;
128
130
  }
129
131
  /** 발행해도 되나. `blocked` 면 서버가 거절한다. */
130
132
  export interface FigureInspection {
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../client/types.ts"],"names":[],"mappings":"AAAA;;GAEG","sourcesContent":["/**\n * 화면이 다루는 모양. 피규어 자산의 타입은 `@hatiolab/figure-model` 이 정한다 — 여기서 다시 적지 않는다.\n */\n\n/** 서버가 돌려주는 Figure 한 벌. `source` 는 상세 또는 저작면을 열 때만 받는다. */\nexport interface Figure {\n id: string\n /** 컴포넌트 타입 이름. **만든 뒤에 고칠 수 없다.** */\n type: string\n /** Namespace of the full name {namespace}/{type} (ADR-0114). */\n namespace?: string | null\n name: string\n description?: string\n /** 분류 하나 — 팔레트에서 묶어 보이는 기준. */\n category?: string\n /** 태그 여럿 — 분류로는 못 하는 찾기. */\n tags?: string[]\n state?: 'draft' | 'released'\n /** 재활용 점수 0~100. 서버가 정본에서 계산한다 — 화면이 보내는 값이 아니다. */\n score?: number\n triangles?: number\n groups?: number\n thumbnail?: string\n thumbnailUpdatedAt?: string\n updatedAt?: string\n updater?: { id: string; name: string }\n /** 피규어 자산 JSON 문자열. 목록 질의에는 실려 오지 않는다. */\n source?: string\n properties?: string\n version?: number\n /** Retired: still released and served, no longer offered for new use. */\n deprecated?: boolean\n /** The type to use instead of a retired figure, in the same domain. */\n supersededBy?: string | null\n /** Public: other domains read its releases and draw it by full name (LV figure-server design G2). */\n public?: boolean\n /** The domain it belongs to; carried by another domain's public figure, so a screen says whose it is. */\n domain?: { id: string; name?: string; subdomain?: string } | null\n}\n\n/**\n * 발행된 버전 하나.\n *\n * 저작 중 저장은 여기 오지 않는다 — 버전은 발행에서만 생긴다.\n */\nexport interface FigureVersion {\n version: number\n /** 발행하며 남긴 말. */\n comment?: string\n state?: 'draft' | 'released'\n updatedAt?: string\n updater?: { id: string; name: string }\n score?: number\n triangles?: number\n groups?: number\n}\n\nexport interface FigureListResult {\n items: Figure[]\n total: number\n}\n\n/**\n * 새 Figure.\n *\n * `score`·`triangles`·`groups` 가 없다 — **서버가 정본에서 계산한다.** 화면이 보내면\n * 거짓이 들어오고 목록이 실물과 다른 것을 보여 준다.\n */\nexport interface NewFigure {\n type: string\n name: string\n description?: string\n category?: string\n tags?: string[]\n source: string\n properties?: string\n state?: 'draft' | 'released'\n thumbnail?: string\n}\n\n/**\n * 수정.\n *\n * `type` 이 없다 — 저장되는 식별자라 고칠 수 없다. 배치된 도면이 그 이름으로 타입을\n * 찾으므로, 바꾸면 그 도면이 빈 화면이 된다.\n */\nexport interface FigurePatch {\n /** 발행 전 초안에서만 받는다 — 발행된 뒤에는 문이 거절한다(ADR-0063 결정 4). */\n type?: string\n name?: string\n description?: string\n category?: string\n /** 빈 배열은 「전부 지운다」, 안 주는 것은 「건드리지 않는다」. */\n tags?: string[]\n source?: string\n properties?: string\n state?: 'draft' | 'released'\n thumbnail?: string\n}\n\n/** 검증 결과 한 줄. 코드는 figure-model 의 것을 그대로 쓴다. */\nexport interface FigureFinding {\n code: string\n message: string\n at?: string\n}\n\n/**\n * 발행 판정 한 줄.\n *\n * `FigureFinding` 과 달리 **`blocking` 이 있다.** 저장 쪽 발견은 정의상 막지 않는 것들이고,\n * 발행 판정은 막는 것과 알리는 것을 함께 내므로 어느 쪽인지를 값이 스스로 말한다 — 코드를 보고\n * 화면이 다시 분류하면 규칙이 두 벌이 된다.\n */\nexport interface FigureGateFinding {\n code: string\n message: string\n /** 왜 그것이 문제인가 — 사람이 무엇을 보게 되나. */\n why?: string\n /** 무엇을 바꾸면 되나 — 부품 이름과 값으로. */\n how?: string\n /** 갈라진 부품 이름들, 또는 정본 안의 경로. */\n at?: string\n blocking: boolean\n}\n\n/** 발행해도 되나. `blocked` 면 서버가 거절한다. */\nexport interface FigureInspection {\n blocked: boolean\n findings: FigureGateFinding[]\n}\n"]}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../client/types.ts"],"names":[],"mappings":"AAAA;;GAEG","sourcesContent":["/**\n * 화면이 다루는 모양. 피규어 자산의 타입은 `@hatiolab/figure-model` 이 정한다 — 여기서 다시 적지 않는다.\n */\n\n/** 서버가 돌려주는 Figure 한 벌. `source` 는 상세 또는 저작면을 열 때만 받는다. */\nexport interface Figure {\n id: string\n /** 컴포넌트 타입 이름. **만든 뒤에 고칠 수 없다.** */\n type: string\n /** Namespace of the full name {namespace}/{type} (ADR-0114). */\n namespace?: string | null\n name: string\n description?: string\n /** 분류 하나 — 팔레트에서 묶어 보이는 기준. */\n category?: string\n /** 태그 여럿 — 분류로는 못 하는 찾기. */\n tags?: string[]\n state?: 'draft' | 'released'\n /** 재활용 점수 0~100. 서버가 정본에서 계산한다 — 화면이 보내는 값이 아니다. */\n score?: number\n triangles?: number\n groups?: number\n thumbnail?: string\n thumbnailUpdatedAt?: string\n updatedAt?: string\n updater?: { id: string; name: string }\n /** 피규어 자산 JSON 문자열. 목록 질의에는 실려 오지 않는다. */\n source?: string\n properties?: string\n version?: number\n /** Retired: still released and served, no longer offered for new use. */\n deprecated?: boolean\n /** The type to use instead of a retired figure, in the same domain. */\n supersededBy?: string | null\n /** Public: other domains read its releases and draw it by full name (LV figure-server design G2). */\n public?: boolean\n /** The domain it belongs to; carried by another domain's public figure, so a screen says whose it is. */\n domain?: { id: string; name?: string; subdomain?: string } | null\n}\n\n/**\n * 발행된 버전 하나.\n *\n * 저작 중 저장은 여기 오지 않는다 — 버전은 발행에서만 생긴다.\n */\nexport interface FigureVersion {\n version: number\n /** 발행하며 남긴 말. */\n comment?: string\n state?: 'draft' | 'released'\n updatedAt?: string\n updater?: { id: string; name: string }\n score?: number\n triangles?: number\n groups?: number\n}\n\nexport interface FigureListResult {\n items: Figure[]\n total: number\n}\n\n/**\n * 새 Figure.\n *\n * `score`·`triangles`·`groups` 가 없다 — **서버가 정본에서 계산한다.** 화면이 보내면\n * 거짓이 들어오고 목록이 실물과 다른 것을 보여 준다.\n */\nexport interface NewFigure {\n type: string\n name: string\n description?: string\n category?: string\n tags?: string[]\n source: string\n properties?: string\n state?: 'draft' | 'released'\n thumbnail?: string\n}\n\n/**\n * 수정.\n *\n * `type` 이 없다 — 저장되는 식별자라 고칠 수 없다. 배치된 도면이 그 이름으로 타입을\n * 찾으므로, 바꾸면 그 도면이 빈 화면이 된다.\n */\nexport interface FigurePatch {\n /** 발행 전 초안에서만 받는다 — 발행된 뒤에는 문이 거절한다(ADR-0063 결정 4). */\n type?: string\n name?: string\n description?: string\n category?: string\n /** 빈 배열은 「전부 지운다」, 안 주는 것은 「건드리지 않는다」. */\n tags?: string[]\n source?: string\n properties?: string\n state?: 'draft' | 'released'\n thumbnail?: string\n}\n\n/** 검증 결과 한 줄. 코드는 figure-model 의 것을 그대로 쓴다. */\nexport interface FigureFinding {\n code: string\n message: string\n at?: string\n}\n\n/**\n * 발행 판정 한 줄.\n *\n * `FigureFinding` 과 달리 **`blocking` 이 있다.** 저장 쪽 발견은 정의상 막지 않는 것들이고,\n * 발행 판정은 막는 것과 알리는 것을 함께 내므로 어느 쪽인지를 값이 스스로 말한다 — 코드를 보고\n * 화면이 다시 분류하면 규칙이 두 벌이 된다.\n */\nexport interface FigureGateFinding {\n code: string\n message: string\n /** 왜 그것이 문제인가 — 사람이 무엇을 보게 되나. */\n why?: string\n /** 무엇을 바꾸면 되나 — 부품 이름과 값으로. */\n how?: string\n /** 갈라진 부품 이름들, 또는 정본 안의 경로. */\n at?: string\n blocking: boolean\n /** 발행 계약이 깨짐 — 새 피규어 타입으로 저장하면 발행되고, 놓인 피규어는 옛 정의를 유지한다. */\n asNewType?: boolean\n}\n\n/** 발행해도 되나. `blocked` 면 서버가 거절한다. */\nexport interface FigureInspection {\n blocked: boolean\n findings: FigureGateFinding[]\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.270",
3
+ "version": "10.1.272",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -42,11 +42,11 @@
42
42
  "@operato/slot-layout-editor": "^10.29.58",
43
43
  "@operato/styles": "^10.0.0",
44
44
  "@operato/utils": "^10.29.43",
45
- "@things-factory/ai-assistant": "^10.1.270",
46
- "@things-factory/figure-service": "^10.1.270",
45
+ "@things-factory/ai-assistant": "^10.1.272",
46
+ "@things-factory/figure-service": "^10.1.272",
47
47
  "@things-factory/figure-viewer": "^10.1.266",
48
- "@things-factory/setting-base": "^10.1.270",
48
+ "@things-factory/setting-base": "^10.1.271",
49
49
  "three": "^0.185.1"
50
50
  },
51
- "gitHead": "9fb23e57702302f1a6c6572d9f0f37ef6f12b446"
51
+ "gitHead": "f87fefe0687eedf52a25cda1c54d59efabaf3dc2"
52
52
  }
@@ -353,7 +353,7 @@ describe('the material: colours by purpose, one step each, a glow drawn while dr
353
353
  test('a diameter typed on the arrangement tab is written once when it is left, as the radius it halves to', async () => {
354
354
  await click(await read(() => probe.tab('figure.label.tab-short-response')))
355
355
  const start = await revision()
356
- await type('drum X · figure.label.size-diameter figure.label.size-measure', '600')
356
+ await type('drum X·Z · figure.label.size-diameter figure.label.size-measure', '600')
357
357
  assert.equal(await revision(), start, 'nothing written yet')
358
358
  await page().keyboard.press('Enter')
359
359
  await sleep(400)
@@ -514,7 +514,8 @@ describe('a clip scrubbed and keyed (2026-09-24)', () => {
514
514
  const typeInClips = async (label, text) => {
515
515
  const field = await page().evaluateHandle(label => {
516
516
  const deep = (root = document) => [...root.querySelectorAll('input')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
517
- return deep().find(i => i.type === 'number' && i.getAttribute('aria-label') === label && i.getRootNode().host?.localName === 'figure-clips') ?? null
517
+ /* The clip's own field, not the pose bar's above the clips: the bar keys the clip last scrubbed (tutorial 2026-11-12). */
518
+ return deep().find(i => i.type === 'number' && i.getAttribute('aria-label') === label && i.getRootNode().host?.localName === 'figure-clips' && !i.closest('div[pose-bar]')) ?? null
518
519
  }, label)
519
520
  assert.ok(await field.evaluate(i => !!i), `${label} in the clips`)
520
521
  await field.evaluate(i => i.scrollIntoView({ block: 'center' }))
@@ -576,12 +577,12 @@ describe('a track moved round its loop, a clip copied (robot dog 2026-11-03)', (
576
577
  await button.click()
577
578
  }
578
579
 
579
- test('a clip in a group is copied in one step; one in none is not offered', async () => {
580
- /* The left door's clip is in no group, the right one's is in one. */
580
+ test('a clip in a group is copied in one step; one in none is offered too, and makes its group (review 2026-11-11)', async () => {
581
+ /* The left door's clip is in no group, the right one's is in one: both may be copied now. */
581
582
  assert.equal(await read(() => {
582
583
  const deep = (root = document) => [...root.querySelectorAll('button[copy-clip]')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
583
584
  return deep().map(b => b.disabled).join()
584
- }), 'true,false')
585
+ }), 'false,false')
585
586
  const start = await revision()
586
587
  await pressIn('button[copy-clip]', 1)
587
588
  await until(async () => (await revision()) === start + 1, 'one step')
@@ -748,6 +748,27 @@ describe('the fastening drawing in the fastening window', () => {
748
748
  assert.deepEqual(joint.body0, { part: 'frame', face: 'front' })
749
749
  assert.deepEqual([joint.body1, joint.origin?.facing], [{ part: 'leaf' }, 'meet'], 'set on the front by its real shape (ADR-0140 3.2)')
750
750
  })
751
+
752
+ /* Tutorial 2026-11-11: the field a size opened did not take the keys; 600 typed and Enter fastened the part where it was. */
753
+ test('a size pressed in the drawing opens its field with the keys in it: typed and Enter moves the part there, the window stays', async () => {
754
+ await change()
755
+ await until(() => !!findDeep('text[dim]', { within: 'figure-fasten-drawing' }), 'a size in the drawing')
756
+ const start = await state()
757
+ await press('text[dim]', { within: 'figure-fasten-drawing' })
758
+ await until(() => {
759
+ const field = findDeep('div[where] input', { within: 'figure-fasten-drawing' })
760
+ return !!field && field.getRootNode().activeElement === field
761
+ }, 'the size\'s field, with the keys in it')
762
+ await page().keyboard.type('37')
763
+ await page().keyboard.press('Enter')
764
+ await sleep(300)
765
+ assert.ok(await read(() => !!probe.fastenTargets()), 'the window stays open')
766
+ assert.equal((await state()).revision, start.revision, 'nothing written before 적용')
767
+ await press('[apply]', { within: 'figure-fasten-dialog' })
768
+ await until(r => probe.state().revision === r + 1, 'one step', start.revision)
769
+ const align = (await read(() => probe.fastening('leaf'))).origin?.align ?? {}
770
+ assert.ok(Object.values(align).some(a => a?.mm === 37), `the typed size: ${JSON.stringify(align)}`)
771
+ })
751
772
  })
752
773
 
753
774
  describe('the face choice: asked where the edit was made, and the same edit goes again with it', () => {
@@ -93,7 +93,7 @@ test('a track copied onto another control a while later; a clip copy takes a fre
93
93
  })
94
94
 
95
95
  /* Review 2026-11-09: a track starts from the pose, a pose is kept in one step, a clip on a played control joins a group. */
96
- import { newClipEdits, newTrackKeys, rememberedPoseEdits } from '../client/modeller/clips.ts'
96
+ import { copyClipEdits, newClipEdits, newTrackKeys, rememberedPoseEdits } from '../client/modeller/clips.ts'
97
97
 
98
98
  const applied = (asset: FigureAsset, edits: unknown[]) => edits.reduce<FigureAsset>((a, e) => editFigurePart(a, e as never), asset)
99
99
 
@@ -103,7 +103,7 @@ test('새 트랙은 지금 자세를 처음부터 끝까지 유지 — 범위
103
103
  assert.deepEqual(newTrackKeys({ min: 5, max: 9 }, undefined, 2), [{ seconds: 0, value: 5 }, { seconds: 2, value: 5 }], '값을 모르면 범위의 시작')
104
104
  /* The panel's wiring: the default button holds the pose, the range one is the preset. */
105
105
  const panel = read('../client/modeller/figure-clips.ts')
106
- assert.match(panel, /newTrackKeys\(input!, this\.values\[stateId!\], duration\)\)/)
106
+ assert.match(panel, /newTrackKeys\(input!, this\.values\[stateId!\], duration\), 'smooth'\)/)
107
107
  assert.match(panel, /newTrackKeys\(input!, this\.values\[stateId!\], duration, 'range'\)/)
108
108
  /* Several edits go up as one step, and the editor applies them as one. */
109
109
  assert.match(read('../client/modeller/editor.ts'), /<figure-clips[\s\S]*?@part-edits=\$\{\(e: CustomEvent\) => this\.run\(\(\) => this\.session!\.applyRecipe\(e\.detail\.edits\)\)\}[\s\S]*?<\/figure-clips>/)
@@ -128,13 +128,36 @@ test('재생 중인 조작값에 새 클립 — 그룹 없는 클립과 새 동
128
128
  assert.deepEqual(applied(grouped, half.edits).motionGroups!.map(g => g.clips), [['닫기', '열기', '반열기']])
129
129
  })
130
130
 
131
+ test('이 자세 기억하기 — 되풀이 트랙의 처음과 끝은 한 순간: 둘 중 어디서 기억해도 두 키가 함께 바뀐다', () => {
132
+ const asset = editFigurePart(machine(), { kind: 'set-driver', state: 'shift', clip: 'walk', duration: 4, keys: [{ seconds: 0, value: 0 }, { seconds: 2, value: 60 }, { seconds: 4, value: 0 }] })
133
+ const keysOf = (a: FigureAsset) => clipsOf(a)[0]!.tracks[0]!.keys
134
+ assert.equal(clipsOf(asset)[0]!.tracks[0]!.loop, 'wrap')
135
+
136
+ /* Kept at the end: the end reads as the start, yet the pose is kept -- at both ends, so the loop does not jump. */
137
+ const atEnd = rememberedPoseEdits(asset, 'walk', 4, { shift: 20, spin: 0 }, { shift: 0, spin: 0 })
138
+ assert.equal(atEnd.edits.length, 1)
139
+ assert.deepEqual(keysOf(applied(asset, atEnd.edits)), [{ seconds: 0, value: 20 }, { seconds: 2, value: 60 }, { seconds: 4, value: 20 }])
140
+
141
+ /* Kept at 0: the end key follows. */
142
+ const atStart = rememberedPoseEdits(asset, 'walk', 0, { shift: 10, spin: 0 }, { shift: 0, spin: 0 })
143
+ assert.deepEqual(keysOf(applied(asset, atStart.edits)), [{ seconds: 0, value: 10 }, { seconds: 2, value: 60 }, { seconds: 4, value: 10 }])
144
+
145
+ /* Both ends already there: nothing to keep. */
146
+ assert.deepEqual(rememberedPoseEdits(asset, 'walk', 4, { shift: 0, spin: 0 }, { shift: 0, spin: 0 }).edits, [])
147
+
148
+ /* A loop left open on purpose -- a ramp that starts over -- keeps its other end. */
149
+ const ramp = editFigurePart(machine(), { kind: 'set-driver', state: 'shift', clip: 'walk', duration: 4, keys: [{ seconds: 0, value: 0 }, { seconds: 4, value: 60 }] })
150
+ assert.deepEqual(keysOf(applied(ramp, rememberedPoseEdits(ramp, 'walk', 4, { shift: 50, spin: 0 }, { shift: 0, spin: 0 }).edits)), [{ seconds: 0, value: 0 }, { seconds: 4, value: 50 }])
151
+ assert.deepEqual(keysOf(applied(ramp, rememberedPoseEdits(ramp, 'walk', 0, { shift: 10, spin: 0 }, { shift: 0, spin: 0 }).edits)), [{ seconds: 0, value: 10 }, { seconds: 4, value: 60 }])
152
+ })
153
+
131
154
  test('이 자세 기억하기 — 바뀐 조작값만, 한 묶음으로; 다른 동작이 쓰는 조작값은 이름만', () => {
132
155
  const asset = editFigurePart(machine(), { kind: 'set-driver', state: 'shift', clip: 'cycle', duration: 4, keys: [{ seconds: 0, value: 0 }, { seconds: 4, value: 60 }] })
133
156
  const base = { shift: 30, spin: 0 }
134
157
  /* At 2 s the clip has shift at 30: unchanged, no key. spin moved to 45 since: a new track from 0 to 45 at 2 s. */
135
158
  const kept = rememberedPoseEdits(asset, 'cycle', 2, { shift: 30, spin: 45 }, base)
136
159
  assert.deepEqual(kept.skipped, [])
137
- assert.deepEqual(kept.edits, [{ kind: 'set-driver', state: 'spin', clip: 'cycle', keys: [{ seconds: 0, value: 0 }, { seconds: 2, value: 45 }] }])
160
+ assert.deepEqual(kept.edits, [{ kind: 'set-driver', state: 'spin', clip: 'cycle', keys: [{ seconds: 0, value: 0 }, { seconds: 2, value: 45 }], interpolation: 'smooth' }])
138
161
  const after = applied(asset, kept.edits)
139
162
  assert.equal(poseAt(after, 'cycle', 2).find(c => c.input === 'spin')!.value, 45)
140
163
 
@@ -148,7 +171,7 @@ test('이 자세 기억하기 — 바뀐 조작값만, 한 묶음으로; 다른
148
171
  assert.deepEqual(rememberedPoseEdits(after, 'cycle', 2, { shift: 30, spin: 45 }, { shift: 30, spin: 45 }).edits, [])
149
172
 
150
173
  /* At 0 a new track holds the pose throughout. */
151
- assert.deepEqual(rememberedPoseEdits(asset, 'cycle', 0, { shift: 0, spin: 10 }, base).edits, [{ kind: 'set-driver', state: 'spin', clip: 'cycle', keys: [{ seconds: 0, value: 10 }, { seconds: 4, value: 10 }] }])
174
+ assert.deepEqual(rememberedPoseEdits(asset, 'cycle', 0, { shift: 0, spin: 10 }, base).edits, [{ kind: 'set-driver', state: 'spin', clip: 'cycle', keys: [{ seconds: 0, value: 10 }, { seconds: 4, value: 10 }], interpolation: 'smooth' }])
152
175
 
153
176
  /* spin played by another clip in no group: not taken, named. */
154
177
  const other = editFigurePart(asset, { kind: 'set-driver', state: 'spin', clip: 'turn', duration: 2, keys: [{ seconds: 0, value: 0 }, { seconds: 2, value: 90 }] })
@@ -174,3 +197,25 @@ test('키 사이 움직임 — 트랙마다 일정한 속도 · 부드럽게 ·
174
197
  assert.match(panel, /\['linear', 'smooth', 'step'\] as const/)
175
198
  assert.match(panel, /\?disabled=\$\{this\.locked \|\| same \|\| !!fits\}/)
176
199
  })
200
+
201
+ test('a clip in no group is copied in one step: a group is made with a name of its own, and both are in it (review 2026-11-11)', () => {
202
+ const asset = editFigurePart(machine(), { kind: 'set-driver', state: 'shift', clip: 'walk', duration: 4, keys: [{ seconds: 0, value: 0 }, { seconds: 2, value: 60 }, { seconds: 4, value: 0 }] })
203
+ const after = applied(asset, copyClipEdits(asset, 'walk'))
204
+ assert.deepEqual(clipsOf(after).map(c => c.name), ['walk', 'walk-copy'])
205
+ assert.deepEqual(after.motionGroups!.map(g => [g.label, g.clips, g.defaultClip]), [['motion', ['walk', 'walk-copy'], 'walk']])
206
+ /* A second group takes the next free name. */
207
+ const taken = { ...asset, motionGroups: [{ id: 'motion', label: 'motion', clips: ['lift'], defaultClip: 'lift', transitionSeconds: 0.2 }] }
208
+ assert.equal((copyClipEdits(taken, 'walk', 'motion')[0] as { group: { label: string } }).group.label, 'motion 2')
209
+ /* Already in a group: only the copy. */
210
+ assert.equal(copyClipEdits(after, 'walk').length, 1)
211
+ })
212
+
213
+ test('a clip lists its tracks in the order of the controls, however its drivers were last written (tutorial 2026-11-12)', () => {
214
+ const keys = [{ seconds: 0, value: 0 }, { seconds: 2, value: 10 }]
215
+ let asset = editFigurePart(machine(), { kind: 'set-driver', state: 'shift', clip: 'walk', duration: 2, keys })
216
+ asset = editFigurePart(asset, { kind: 'set-driver', state: 'spin', clip: 'walk', duration: 2, keys })
217
+ /* The first track keyed again: its driver is written anew. */
218
+ const shift = clipsOf(asset)[0]!.tracks.find(t => t.state === 'shift')!
219
+ asset = editFigurePart(asset, trackEdit(clipsOf(asset)[0]!, shift, { keys: withKey(shift.keys, { seconds: 1, value: 5 }) }) as never)
220
+ assert.deepEqual(clipsOf(asset)[0]!.tracks.map(t => t.state), ['shift', 'spin'])
221
+ })
@@ -33,6 +33,7 @@ import {
33
33
  tableWithLook,
34
34
  tableWithTargets,
35
35
  sampleOf,
36
+ sampledStatesOf,
36
37
  schemaWithField,
37
38
  schemaWithoutField,
38
39
  typedValueOf,
@@ -363,3 +364,10 @@ test('the value data gives a row is read down its accessor; left out or null, th
363
364
  assert.equal(dataValueAt(data, 'missing.deep'), undefined)
364
365
  assert.equal(dataValueAt(null, 'status'), undefined)
365
366
  })
367
+
368
+ test('the preview says the state inputs a sample sets, not a slider at their default (tutorial 2026-11-11)', () => {
369
+ /* The dog's head follows headAngle: a sample giving it sets the head; none, or one the figure refuses, sets nothing. */
370
+ assert.deepEqual(sampledStatesOf(DOG, { headAngle: 10 }), { head: 10 })
371
+ assert.deepEqual(sampledStatesOf(DOG, null), {})
372
+ assert.deepEqual(sampledStatesOf(DOG, { headAngle: 999 }), {}, 'refused whole, as a board refuses it')
373
+ })
@@ -203,3 +203,60 @@ test("pressed on the canvas: the face or the point pressed, read in that part's
203
203
  const corner = aimFastenAt(r, 'tag', freshFasten(), { id: 'soft', point: [145, 100, 1645], normal: [Math.SQRT1_2, 0, Math.SQRT1_2] })
204
204
  assert.deepEqual([corner.note, corner.form?.target], ['aim-not-here', 'soft'], 'a rounded corner: aimed at the part, said')
205
205
  })
206
+
207
+ test('a part still where the palette put it goes to the centre of the face; one the author moved keeps its place across it (review 2026-11-11)', () => {
208
+ /* A thigh 85 mm left of the middle of a pelvis 880 mm up; a shin and an arm added from the palette at (0, 50, 0). */
209
+ let a = fasten(box(createFigureAsset('Humanoid'), 'pelvis', [300, 180, 200], [0, 970, 0]), 'pelvis', 'plane', { gap: 880 })
210
+ a = applyFigureAuthoring(box(a, 'thigh', [130, 420, 140], [-85, 670, 0]), { kind: 'attach', part: 'thigh', to: { part: 'pelvis', face: 'bottom' }, facing: 'meet', align: { x: { mm: -85 } } } as never)
211
+ a = box(box(a, 'shin', [110, 420, 120], [0, 50, 0]), 'arm', [90, 300, 90], [0, 50, 0])
212
+ const r = revisionOf(a)
213
+ const shin = aimFasten(r, 'shin', fastenFormOf(r, 'shin'), { part: 'thigh' })
214
+ const onBottom = aimFasten(r, 'shin', shin, { targetFace: 'bottom' })
215
+ assert.deepEqual([onBottom.align, onBottom.alignMm], [{}, {}], 'on the centre of the thigh, not 85 mm off it')
216
+ const arm = aimFasten(r, 'arm', aimFasten(r, 'arm', fastenFormOf(r, 'arm'), { part: 'pelvis' }), { targetFace: 'left' })
217
+ assert.deepEqual([arm.align, arm.alignMm], [{}, {}], 'on the side, not 920 mm below it')
218
+ /* Moved by the author first, it keeps where it stands across the face. */
219
+ const moved = revisionOf(box(a, 'tag', [40, 40, 40], [-60, 500, 30]))
220
+ const kept = aimFasten(moved, 'tag', aimFasten(moved, 'tag', fastenFormOf(moved, 'tag'), { part: 'thigh' }), { targetFace: 'bottom' })
221
+ assert.deepEqual(kept.alignMm, { x: 25, z: 30 })
222
+ })
223
+
224
+ test('a part still where the palette put it is fastened on the face; one the author moved keeps its place (tutorial 2026-11-11)', () => {
225
+ /* A body on the floor; a mast added from the palette stands inside it, at the palette's place (0, 50, 0). */
226
+ let a = fasten(box(createFigureAsset('Fork AGV'), 'body', [1000, 300, 700], [0, 150, 0]), 'body', 'plane')
227
+ a = box(a, 'mast', [80, 1500, 700], [0, 50, 0])
228
+ const r = revisionOf(a)
229
+ const aimed = keptGapOf(r, 'mast', aimFasten(r, 'mast', fastenFormOf(r, 'mast'), { part: 'body' }))
230
+ assert.equal(aimed.gap, 0, 'on the face, not 550 mm inside the body')
231
+ /* Moved by the author, it is fastened where it stands (user 2026-10-20 「현재 상태로 붙이기」). */
232
+ const moved = revisionOf(box(fasten(box(createFigureAsset('Fork AGV'), 'body', [1000, 300, 700], [0, 150, 0]), 'body', 'plane'), 'mast', [80, 1500, 700], [0, 1100, 0]))
233
+ const kept = keptGapOf(moved, 'mast', aimFasten(moved, 'mast', fastenFormOf(moved, 'mast'), { part: 'body' }))
234
+ assert.equal(kept.gap, 50, 'the 50 mm its bottom stands above the body')
235
+ })
236
+
237
+ test('aimed at a part, the form starts on a face nothing is fastened to yet (review 2026-11-11)', () => {
238
+ let a = fasten(box(createFigureAsset('Humanoid'), 'pelvis', [300, 180, 200], [0, 970, 0]), 'pelvis', 'plane', { gap: 880 })
239
+ a = applyFigureAuthoring(box(a, 'torso', [340, 500, 200], [0, 50, 0]), { kind: 'attach', part: 'torso', to: { part: 'pelvis', face: 'top' } } as never)
240
+ a = box(a, 'thigh', [130, 420, 140], [0, 50, 0])
241
+ const r = revisionOf(a)
242
+ const thigh = aimFasten(r, 'thigh', fastenFormOf(r, 'thigh'), { part: 'pelvis' })
243
+ assert.equal(thigh.targetFace, 'bottom', 'across from the top, where the body stands')
244
+ assert.equal(aimFasten(r, 'torso', fastenFormOf(r, 'torso'), { part: 'pelvis' }).targetFace, 'top', 'a part keeps the face it is fastened to')
245
+ const head = aimFasten(r, 'thigh', fastenFormOf(r, 'thigh'), { part: 'torso' })
246
+ assert.equal(head.targetFace, 'top', 'a free top is taken as asked')
247
+ /* A shin aimed at a thigh hung under the pelvis: the thigh's top touches the pelvis, so the shin goes under it. */
248
+ let b = applyFigureAuthoring(a, { kind: 'attach', part: 'thigh', to: { part: 'pelvis', face: 'bottom' } } as never)
249
+ b = box(b, 'shin', [110, 420, 120], [0, 50, 0])
250
+ const rb = revisionOf(b)
251
+ assert.equal(aimFasten(rb, 'shin', fastenFormOf(rb, 'shin'), { part: 'thigh' }).targetFace, 'bottom')
252
+ /* A forearm aimed at an upper arm hung by its side from the torso: on down, under the arm (tutorial run 2026-11-12). */
253
+ let c = applyFigureAuthoring(box(a, 'upperarm', [90, 300, 90], [0, 50, 0]), { kind: 'attach', part: 'upperarm', to: { part: 'torso', face: 'left' } } as never)
254
+ c = box(c, 'forearm', [80, 280, 80], [0, 50, 0])
255
+ const rc = revisionOf(c)
256
+ assert.equal(aimFasten(rc, 'forearm', fastenFormOf(rc, 'forearm'), { part: 'upperarm' }).targetFace, 'bottom')
257
+ /* A flat part hung by its side is not a limb: what goes on it keeps the face asked. */
258
+ let d = applyFigureAuthoring(box(a, 'shelf', [300, 40, 200], [0, 50, 0]), { kind: 'attach', part: 'shelf', to: { part: 'torso', face: 'left' } } as never)
259
+ d = box(d, 'cup', [60, 80, 60], [0, 50, 0])
260
+ const rd = revisionOf(d)
261
+ assert.equal(aimFasten(rd, 'cup', fastenFormOf(rd, 'cup'), { part: 'shelf' }).targetFace, 'top')
262
+ })
@@ -11,7 +11,7 @@ import assert from 'node:assert/strict'
11
11
  import { addFigureBox, addFigurePrimitive, applyFigureAuthoring, compileFigureAsset, createFigureAsset } from '@hatiolab/figure-model'
12
12
  import type { FigureAsset, FigureAuthoringAction } from '@hatiolab/figure-model'
13
13
 
14
- import { alignmentAt, faceViewOf, facesAsDrawnOf, offsetOf, overhangOf, screenOf } from '../client/modeller/fasten-geometry.ts'
14
+ import { alignmentAt, drawingSpanOf, faceViewOf, facesAsDrawnOf, offsetOf, overhangOf, screenOf } from '../client/modeller/fasten-geometry.ts'
15
15
  import { revisionOf } from './revision-of.ts'
16
16
 
17
17
  const scene = (lidRotation?: { x: number; y: number; z: number }): FigureAsset => {
@@ -97,3 +97,27 @@ test('붙일 면 그림은 그려진 자세대로 — 눕힌 원기둥의 위·
97
97
  assert.ok(!['top', 'bottom'].includes(drawn.top) && !['top', 'bottom'].includes(drawn.bottom), `화면의 위·아래는 원기둥의 면이 아니다: ${drawn.top}, ${drawn.bottom}`)
98
98
  assert.deepEqual([drawn.front, drawn.back], ['front', 'back'])
99
99
  })
100
+
101
+ test('a part bigger than the face is caught level with its edges too -- a mast flush with the bottom of a low body (tutorial 2026-11-11)', () => {
102
+ /* A 1500 mm mast on the 300 mm high side of a body: its bottom level with the body's is its centre 600 mm up. */
103
+ assert.deepEqual(alignmentAt(595, 300, 1500, true, 10), { how: 'min', caught: 'inside' })
104
+ assert.deepEqual(alignmentAt(-603, 300, 1500, true, 10), { how: 'max', caught: 'inside' })
105
+ assert.equal(offsetOf('min', undefined, 300, 1500), 600)
106
+ /* What the drawing names, the kernel does: the mast's bottom on the body's bottom, the floor. */
107
+ let a = addFigureBox(createFigureAsset('Fork AGV'), { id: 'body', size: { x: 1000, y: 300, z: 700 }, position: { x: 0, y: 150, z: 0 } })
108
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'body', to: { plane: 'mounting-plane' } })
109
+ a = addFigureBox(a, { id: 'mast', size: { x: 80, y: 1500, z: 700 }, position: { x: 0, y: 750, z: 0 } })
110
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'mast', to: { part: 'body', face: 'left' }, facing: 'meet', align: { y: 'min', z: 'centre' } } as FigureAuthoringAction)
111
+ const t = at(a, 'mast')
112
+ assert.ok(Math.abs(t[1]! - 750) < 1e-6, `mast centre ${t[1]} mm high`)
113
+ assert.ok(Math.abs(t[0]! - -540) < 1e-6, `mast against the left face, ${t[0]}`)
114
+ })
115
+
116
+ test('the face drawing shows a part bigger than the face whole when lined up with an edge', () => {
117
+ /* The mast's top at its centre 600 mm up, half of 1500 above that: 1350 mm from the drawing's middle, inside half its span. */
118
+ const span = drawingSpanOf(300, 1500)
119
+ assert.ok(span / 2 >= Math.abs(offsetOf('min', undefined, 300, 1500)) + 1500 / 2, `span ${span}`)
120
+ /* A part smaller than the face keeps the room it had: the face and half the part past each edge. */
121
+ assert.equal(drawingSpanOf(600, 300), 900)
122
+ assert.equal(drawingSpanOf(undefined, 200), 600)
123
+ })
@@ -0,0 +1,52 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { compileFigureAsset, parseFigureAsset } from '@hatiolab/figure-model'
4
+ import { createFigureProductSession } from '../client/modeller/product-session.js'
5
+ import { firstPartFastensToPlane, paletteRecipe } from '../client/modeller/palette.js'
6
+
7
+ /*
8
+ * A figure that stands on the floor starts with its base: the first box the author adds and then sizes stays on the floor
9
+ * (tutorial 2026-11-11: a 1000 x 300 x 700 box added from the start dialog sank 100 mm through the floor).
10
+ */
11
+ const session = () => createFigureProductSession({ load: async () => ({ id: 'x', source: '' }), save: async q => ({ id: 'x', source: q.source }) })
12
+
13
+ /** The bottom of the part, in mm above the mounting plane, once its height is `height` */
14
+ function bottomAt(source: string, part: string, height: number) {
15
+ const asset = parseFigureAsset(source)
16
+ const key = Object.keys(asset.designInputs).find(k => k.startsWith(part) && /size\.y$|dimension\.y$|height$/.test(k))
17
+ assert.ok(key, `the part's height input among ${Object.keys(asset.designInputs).join(', ')}`)
18
+ const g = compileFigureAsset({ ...asset, designInputs: { ...asset.designInputs, [key]: height } })
19
+ .evaluate()
20
+ .geometry.find(g => g.id === part)!
21
+ return g.pose.t[1] - height / 2
22
+ }
23
+
24
+ test('only the first part of a figure on a plane is fastened to it', () => {
25
+ assert.equal(firstPartFastensToPlane([], undefined), true)
26
+ assert.equal(firstPartFastensToPlane([], 'floor'), true)
27
+ assert.equal(firstPartFastensToPlane([], 'ceiling'), true)
28
+ assert.equal(firstPartFastensToPlane([], 'wall'), true)
29
+ assert.equal(firstPartFastensToPlane([], 'center'), false)
30
+ assert.equal(firstPartFastensToPlane([{ id: 'body' }], 'floor'), false)
31
+ })
32
+
33
+ test('the first box, added fastened to the floor, stays on it when its height changes; one undo takes it away', () => {
34
+ const s = session()
35
+ s.startNew({ name: 'fork AGV' })
36
+ const empty = s.source()
37
+ const part = paletteRecipe('box', 'body')
38
+ s.applyRecipe([
39
+ { kind: 'add-part', part },
40
+ { kind: 'attach', part: 'body', to: { plane: 'mounting-plane' } }
41
+ ])
42
+ for (const height of [100, 300, 1200]) assert.ok(Math.abs(bottomAt(s.source(), 'body', height)) < 1e-6, `bottom on the floor at ${height} mm`)
43
+ s.undo()
44
+ assert.equal(s.source(), empty)
45
+ })
46
+
47
+ test('a box added without fastening sinks as it grows -- what the first part no longer does', () => {
48
+ const s = session()
49
+ s.startNew({ name: 'fork AGV' })
50
+ s.add(paletteRecipe('box', 'body'))
51
+ assert.ok(bottomAt(s.source(), 'body', 300) < -50)
52
+ })
@@ -5,7 +5,7 @@ import { test } from 'node:test'
5
5
  import assert from 'node:assert/strict'
6
6
 
7
7
  import { placeholderType } from '@hatiolab/figure-model'
8
- import { firstSaveRefusalOf, releasableType, typeOf } from '../client/modeller/first-save.ts'
8
+ import { firstSaveRefusalOf, releasableType, typeOf, typingTypeOf } from '../client/modeller/first-save.ts'
9
9
  import { createFigureProductSession, type FigureProductStore } from '../client/modeller/product-session.ts'
10
10
 
11
11
  test('치는 대로 타입의 모양이 된다 — 소문자 · 숫자 · 하이픈 하나', () => {
@@ -14,6 +14,16 @@ test('치는 대로 타입의 모양이 된다 — 소문자 · 숫자 · 하이
14
14
  assert.equal(typeOf('-Roller Conveyor-'), 'roller-conveyor')
15
15
  })
16
16
 
17
+ test('하이픈을 칠 수 있다 — 한 글자씩 쳐도 끝의 하이픈이 남고, 저장할 때 다듬는다', () => {
18
+ let typed = ''
19
+ for (const key of 'fork-agv-tutorial') typed = typingTypeOf(typed + key)
20
+ assert.equal(typed, 'fork-agv-tutorial')
21
+ let trailing = ''
22
+ for (const key of 'Fork AGV ') trailing = typingTypeOf(trailing + key)
23
+ assert.equal(trailing, 'fork-agv-')
24
+ assert.equal(typeOf(trailing), 'fork-agv')
25
+ })
26
+
17
27
  test('발행할 수 없는 이름은 미리 말한다 — 임시 이름 · 형식 · 길이', () => {
18
28
  assert.equal(releasableType('agv-lifter'), true)
19
29
  assert.equal(releasableType(placeholderType()), false, '서버가 붙이는 임시 이름')
@@ -0,0 +1,57 @@
1
+ /*
2
+ * Named points a turn's axis may pass (`pivot.ts`): worked out from the part as it rests, they keep the named point
3
+ * still as the joint turns -- whatever the face, the alignment, the slider.
4
+ */
5
+ import { test } from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+
8
+ import { addFigureBox, applyFigureAuthoring, createFigureAsset } from '@hatiolab/figure-model'
9
+ import type { FigureAsset } from '@hatiolab/figure-model'
10
+
11
+ import { motionJoint } from '../client/modeller/links.ts'
12
+ import { pivotPointOf, pivotPointsOf } from '../client/modeller/pivot.ts'
13
+ import { revisionOf } from './revision-of.ts'
14
+
15
+ const box = (a: FigureAsset, id: string, [x, y, z]: number[], at = [0, 0, 0]) => addFigureBox(a, { id, size: { x, y, z }, position: { x: at[0]!, y: at[1]!, z: at[2]! } })
16
+ const turn = (through = {}) => ({ kind: 'turn', axis: 'x', id: 'sh', min: -90, max: 90, follow: '', multiplier: 1, offset: 0, travel: '', travelPlus: 0, through }) as never
17
+
18
+ /* An arm of 300 mm hung by its side from a torso, level with its top -- the lesson's upper arm -- or under it. */
19
+ function arm(face: 'left' | 'bottom', align?: object) {
20
+ const a = box(createFigureAsset('H'), 'torso', [340, 500, 200], [0, 1000, 0])
21
+ return applyFigureAuthoring(box(a, 'arm', [90, 300, 90], [0, 50, 0]), { kind: 'attach', part: 'arm', to: { part: 'torso', face }, ...(align ? { align } : {}) } as never)
22
+ }
23
+ const endsAt = (a: FigureAsset, through: object, value: number) => {
24
+ const joint = a.joints!.find(j => j.body1.part === 'arm')!
25
+ const b = applyFigureAuthoring(a, { kind: 'set-joint', joint: motionJoint(joint, turn(through), 'arm') } as never)
26
+ const g = (revisionOf(b, { sh: value }).geometry as Array<{ id: string; pose: { r: number[][]; t: number[] } }>).find(p => p.id === 'arm')!.pose
27
+ /* The arm's top and bottom end centres in the figure's frame. */
28
+ return [150, -150].map(y => [0, 1, 2].map(i => g.r[i]![1]! * y + g.t[i]!))
29
+ }
30
+ const near = (a: number[], b: number[]) => a.every((v, i) => Math.abs(v - b[i]!) < 0.5)
31
+
32
+ test('a named pivot stays where it is as the joint turns: top, middle, bottom of a side-hung or an under-hung arm (tutorial 2026-11-12)', () => {
33
+ for (const [face, align] of [['left', { y: 'max' }], ['left', undefined], ['bottom', undefined]] as const) {
34
+ const a = arm(face, align)
35
+ const points = pivotPointsOf(revisionOf(a), 'arm', 'x')!
36
+ assert.ok(points, `${face}: points`)
37
+ const [top0, bottom0] = endsAt(a, points.top, 0)
38
+ assert.ok(near(endsAt(a, points.top, 70)[0]!, top0!), `${face} ${JSON.stringify(align)}: the top end stays`)
39
+ assert.ok(near(endsAt(a, points.bottom, 70)[1]!, bottom0!), `${face} ${JSON.stringify(align)}: the bottom end stays`)
40
+ assert.ok(!near(endsAt(a, points.middle, 70)[0]!, top0!), `${face}: about the middle the top end moves`)
41
+ }
42
+ })
43
+
44
+ test('the lesson upper arm, level with the torso top, already turns about its top end: no number is needed', () => {
45
+ const points = pivotPointsOf(revisionOf(arm('left', { y: 'max' })), 'arm', 'x')!
46
+ assert.deepEqual(points, { top: {}, middle: { y: -150 }, bottom: { y: -300 } })
47
+ assert.equal(pivotPointOf(points, {}, 'x'), 'top')
48
+ assert.equal(pivotPointOf(points, { y: 120 }, 'x'), undefined, 'Y 120 put the axis above the shoulder')
49
+ })
50
+
51
+ test('a part fastened to nothing, or a slider left turned, has no wrong points', () => {
52
+ assert.equal(pivotPointsOf(revisionOf(box(createFigureAsset('H'), 'solo', [10, 10, 10])), 'solo'), null)
53
+ const a = arm('bottom')
54
+ const joint = a.joints!.find(j => j.body1.part === 'arm')!
55
+ const b = applyFigureAuthoring(a, { kind: 'set-joint', joint: motionJoint(joint, turn(), 'arm') } as never)
56
+ assert.deepEqual(pivotPointsOf(revisionOf(b, { sh: 60 }), 'arm'), pivotPointsOf(revisionOf(b), 'arm'))
57
+ })
@@ -258,7 +258,6 @@ test('a conflict kept as a copy: a new figure under a new creation id, the edits
258
258
  const session = createFigureProductSession(store)
259
259
  await session.open('a')
260
260
  const opened = session.read().creationId
261
- assert.throws(() => session.detachAsCopy(), { code: 'SESSION_NO_CONFLICT' }, 'only after a conflict')
262
261
  session.command({ kind: 'rename', name: 'Mine' } as never)
263
262
  session.editCatalog({ category: 'equipment', tags: ['new-tag'], type: 'gate-2' }, { type: 'gate' })
264
263
  const theirs = serializeFigureAsset(createFigureAsset('Theirs'))
@@ -289,3 +288,23 @@ test('the latest not readable: the conflict stands, and it cannot be written ove
289
288
  assert.deepEqual(session.read().conflict, { latest: undefined })
290
289
  assert.throws(() => session.overwriteConflict(), { code: 'SESSION_NO_CONFLICT' })
291
290
  })
291
+
292
+ test('edits a released figure cannot take kept as a new figure type: no conflict needed, the released one untouched', async () => {
293
+ const s = server()
294
+ const saved: { id?: string; creationId?: string; type?: string }[] = []
295
+ const store = {
296
+ ...s.store,
297
+ save: async (q: { id?: string; source: string; creationId?: string; type?: string }) => (saved.push(q), q.id ? s.store.save(q) : { id: q.creationId!, source: q.source })
298
+ }
299
+ const session = createFigureProductSession(store)
300
+ assert.throws(() => session.detachAsCopy(), { code: 'SESSION_NOT_SAVED' }, 'nothing opened, nothing to keep apart')
301
+ await session.open('a')
302
+ const before = s.row()
303
+ session.command({ kind: 'rename', name: 'Mine' } as never)
304
+ const copy = session.detachAsCopy()
305
+ assert.equal(copy.id, undefined)
306
+ assert.equal(copy.name, 'Mine')
307
+ await session.save({ type: 'gate-v2' })
308
+ assert.deepEqual(saved.at(-1), { ...saved.at(-1), id: undefined, creationId: copy.creationId, type: 'gate-v2' })
309
+ assert.equal(s.row(), before, 'the figure it came from is as the server had it')
310
+ })
@@ -1,7 +1,8 @@
1
1
  import { test } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import { applyFigureAuthoring, compileFigureAsset, figureRowOf, figureFollowOf } from '@hatiolab/figure-model'
4
- import { sizeRelationSteps, sizeRelationOf, sizeInputOf } from '../client/modeller/size-response.ts'
3
+ import { addFigureBox, addFigurePrimitive, applyFigureAuthoring, compileFigureAsset, createFigureAsset, figureRowOf, figureFollowOf } from '@hatiolab/figure-model'
4
+ import { sizeAxisWord, sizeRelationSteps, sizeRelationOf, sizeInputOf } from '../client/modeller/size-response.ts'
5
+ import { followableAxes } from '../client/modeller/links.ts'
5
6
  import type { FigureRevision } from '../client/modeller/revision.ts'
6
7
  import { multipartResponseFixture } from './multipart-response-fixture.js'
7
8
  const revision = (asset: ReturnType<typeof multipartResponseFixture>) => ({ asset, inputs: asset.designInputs } as FigureRevision)
@@ -47,3 +48,10 @@ test('repeat settings can change count, direction and mode without losing the at
47
48
  assert.deepEqual(a.joints!.find(j=>j.body1.part==='legLeft')!.body0,initial.body0)
48
49
  assert.deepEqual(figureFollowOf(a,'brace','x'),{of:'part',part:'tabletop',axis:'x',plus:-200})
49
50
  })
51
+
52
+ test('a round body names the diameter that sets the axis it has no row for: X·Z across, Y up (tutorial 2026-11-12)', () => {
53
+ const ball = addFigurePrimitive(createFigureAsset('Ball'), { id: 'ball', kind: 'sphere', dimensions: { radius: 100, radiusY: 120 }, position: { x: 0, y: 0, z: 0 } } as never)
54
+ assert.deepEqual(followableAxes(ball, 'ball').map(a => sizeAxisWord(ball, 'ball', a, '지름')), ['X·Z · 지름', 'Y · 지름'])
55
+ const block = addFigureBox(createFigureAsset('Block'), { id: 'block', size: { x: 10, y: 20, z: 30 }, position: { x: 0, y: 0, z: 0 } })
56
+ assert.deepEqual(followableAxes(block, 'block').map(a => sizeAxisWord(block, 'block', a, '지름')), ['X', 'Y', 'Z'])
57
+ })
@@ -9,7 +9,7 @@ import assert from 'node:assert/strict'
9
9
  import { addFigureBox, compileFigureAsset, createFigureAsset } from '@hatiolab/figure-model'
10
10
 
11
11
  import { drawableFigureParts } from '../client/modeller/renderer.ts'
12
- import { boardFor, needsRefit } from '../client/modeller/stage-model.ts'
12
+ import { boardFor, drawnSpanOf, needsRefit, outgrowsFit } from '../client/modeller/stage-model.ts'
13
13
 
14
14
  const partsOf = (height: number) =>
15
15
  drawableFigureParts(
@@ -33,3 +33,25 @@ test('a size typed far from the view brings the camera to it; a nudge does not (
33
33
  assert.equal(needsRefit(boardFor(parts(3000, 800, 600)), boardFor(parts(100, 100, 100))), true, 'shrunk far: fitted again')
34
34
  assert.equal(needsRefit(undefined, boardFor(parts(3000, 800, 600))), false, 'never fitted: the first standing fits it')
35
35
  })
36
+
37
+ test('the view follows a figure that grows past the fit, or stands higher off the floor (review 2026-11-11)', () => {
38
+ const at = (height: number, y: number) =>
39
+ drawableFigureParts(compileFigureAsset(addFigureBox(createFigureAsset('P'), { id: 'p', size: { x: 300, y: height, z: 200 }, position: { x: 0, y, z: 0 } })).evaluate().geometry)
40
+ /* A pelvis 180 mm tall put 880 mm up: as tall as it stands from the floor, not as its own height. */
41
+ const onFloor = boardFor(at(180, 90))
42
+ const raised = boardFor(at(180, 970))
43
+ assert.ok(raised.depth >= 1060, `reached from the floor: ${raised.depth}`)
44
+ assert.equal(needsRefit(onFloor, raised), true, 'raised far: the view follows it up')
45
+ /* A head added on a 1.56 m body: 1.8 m runs past the fit's margin. */
46
+ assert.equal(needsRefit(boardFor(at(680, 1220)), boardFor(at(920, 1340))), true)
47
+ /* A nudge inside the fit's margin leaves the camera where the author put it. */
48
+ assert.equal(needsRefit(boardFor(at(680, 1220)), boardFor(at(700, 1230))), false)
49
+ })
50
+
51
+ test('the view follows a part hung below what it was fitted round (review 2026-11-11)', () => {
52
+ const body = drawnSpanOf(partsOf(500))
53
+ const longer = drawnSpanOf(partsOf(800))
54
+ assert.equal(outgrowsFit(body, longer), true, 'reaching 300 mm past the top')
55
+ assert.equal(outgrowsFit(body, drawnSpanOf(partsOf(520))), false, 'a little taller stays in view')
56
+ assert.equal(outgrowsFit(undefined, longer), false)
57
+ })
@@ -484,6 +484,8 @@
484
484
  "figure.text.pick-a-shape-above-to-start": "pick a shape above to add the first part.",
485
485
  "figure.text.placement-explained": "sets the anchor plane in 3D scene (floor: AGV/conveyor, ceiling: OHT/hoist, center: sensor/drone).",
486
486
  "figure.text.pose-as-default-explained": "Writes the pose you have set as the parameters’ defaults, so the figure stands like this when nobody gives it a value.",
487
+ "figure.text.control-played": "Playing",
488
+ "figure.text.control-played-explained": "The {clip} clip is moving this control. Stop the clip to give it a value.",
487
489
  "figure.text.clear-tried-explained": "clears the control values tried here. the saved default pose is unchanged, and the clips move the figure again.",
488
490
  "figure.text.preview-blocked-by-errors": "cannot show the preview because of format errors. see the check tab for the reason.",
489
491
  "figure.text.put-on-floor": "Moves the part once so its bottom is on the floor (Y=0). It does not stay there when the size changes; for that, use “Follow size, attached to the floor”.",
@@ -715,6 +717,10 @@
715
717
  "figure.label.motion-own-control": "its own control",
716
718
  "figure.label.motion-multiplier": "multiplier",
717
719
  "figure.label.motion-offset": "offset",
720
+ "figure.label.pivot-at": "turn about",
721
+ "figure.label.pivot-top": "top end",
722
+ "figure.label.pivot-middle": "middle",
723
+ "figure.label.pivot-bottom": "bottom end",
718
724
  "figure.label.motion-through": "axis passes through",
719
725
  "figure.label.motion-follows": "moves with {joint} × {multiplier} + {offset}",
720
726
  "figure.label.face-opening-left": "opening left",
@@ -1020,6 +1026,7 @@
1020
1026
  "figure.text.slot-id-taken": "{slot} is already the name of a place in this figure.",
1021
1027
  "figure.label.placement": "fastening",
1022
1028
  "figure.label.motion-groups": "motion groups",
1029
+ "figure.label.motion-group-named": "motion",
1023
1030
  "figure.label.motion-group": "motion group",
1024
1031
  "figure.label.no-motion-group": "no group",
1025
1032
  "figure.label.motion-group-name": "name",
@@ -1281,6 +1288,7 @@
1281
1288
  "figure.text.conflict-other-changed": "Settings outside the parts (name, inputs, capabilities...) differ too.",
1282
1289
  "figure.text.conflict-latest-unread": "The latest could not be read, so it was not compared. It cannot be overwritten.",
1283
1290
  "figure.button.save-as-copy": "save as a copy",
1291
+ "figure.button.save-as-new-type": "save as a new figure type",
1284
1292
  "figure.button.open-latest": "open the latest in a new tab",
1285
1293
  "figure.button.overwrite-latest": "overwrite with my edits",
1286
1294
  "figure.text.overwrite-latest-warning": "What changed in the latest is replaced by your edits and not kept. Overwrite?",
@@ -1321,6 +1329,7 @@
1321
1329
  "figure.button.shift-track": "shift",
1322
1330
  "figure.button.copy-track-apply": "copy",
1323
1331
  "figure.text.copy-clip-needs-group": "A copy plays the same controls, so only a clip in a motion group can be copied.",
1332
+ "figure.text.copy-clip-makes-group": "Copies every track into a clip with a new name. The copy moves the same controls, so both go into a new motion group named after this clip (one plays at a time).",
1324
1333
  "figure.label.primitive-pipe": "pipe",
1325
1334
  "figure.label.primitive-duct": "duct",
1326
1335
  "figure.label.primitive-tapered-box": "tapered box",