@things-factory/figure-ui 10.1.116 → 10.1.121

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 (62) hide show
  1. package/client/graphql/index.ts +1 -0
  2. package/client/modeller/editor.ts +2 -2
  3. package/client/modeller/fasten-geometry.ts +1 -1
  4. package/client/modeller/figure-first-save.ts +1 -1
  5. package/client/modeller/figure-history-panel.ts +21 -0
  6. package/client/modeller/figure-links.ts +33 -11
  7. package/client/modeller/first-save.ts +6 -6
  8. package/client/modeller/joint-axis.ts +36 -0
  9. package/client/modeller/joint-drag.ts +27 -7
  10. package/client/modeller/joint-fan.ts +2 -1
  11. package/client/modeller/links.ts +43 -10
  12. package/client/pages/figure-list-page.ts +3 -1
  13. package/client/pages/figure-modeller-page.ts +5 -2
  14. package/client/types.ts +2 -0
  15. package/dist-client/graphql/index.js +1 -0
  16. package/dist-client/graphql/index.js.map +1 -1
  17. package/dist-client/modeller/clips.d.ts +1 -1
  18. package/dist-client/modeller/editor.js +2 -2
  19. package/dist-client/modeller/editor.js.map +1 -1
  20. package/dist-client/modeller/fasten-geometry.js +1 -1
  21. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  22. package/dist-client/modeller/figure-first-save.js +1 -1
  23. package/dist-client/modeller/figure-first-save.js.map +1 -1
  24. package/dist-client/modeller/figure-history-panel.d.ts +4 -0
  25. package/dist-client/modeller/figure-history-panel.js +23 -0
  26. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  27. package/dist-client/modeller/figure-links.d.ts +1 -0
  28. package/dist-client/modeller/figure-links.js +31 -12
  29. package/dist-client/modeller/figure-links.js.map +1 -1
  30. package/dist-client/modeller/first-save.d.ts +2 -2
  31. package/dist-client/modeller/first-save.js +6 -6
  32. package/dist-client/modeller/first-save.js.map +1 -1
  33. package/dist-client/modeller/joint-axis.d.ts +13 -0
  34. package/dist-client/modeller/joint-axis.js +31 -0
  35. package/dist-client/modeller/joint-axis.js.map +1 -0
  36. package/dist-client/modeller/joint-drag.js +26 -6
  37. package/dist-client/modeller/joint-drag.js.map +1 -1
  38. package/dist-client/modeller/joint-fan.js +2 -1
  39. package/dist-client/modeller/joint-fan.js.map +1 -1
  40. package/dist-client/modeller/links.d.ts +16 -1
  41. package/dist-client/modeller/links.js +39 -9
  42. package/dist-client/modeller/links.js.map +1 -1
  43. package/dist-client/pages/figure-list-page.js +2 -1
  44. package/dist-client/pages/figure-list-page.js.map +1 -1
  45. package/dist-client/pages/figure-modeller-page.js +4 -1
  46. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  47. package/dist-client/route.d.ts +1 -1
  48. package/dist-client/tsconfig.tsbuildinfo +1 -1
  49. package/dist-client/types.d.ts +2 -0
  50. package/dist-client/types.js.map +1 -1
  51. package/docs/product-cutover.md +7 -7
  52. package/package.json +6 -6
  53. package/test/command-preview.test.ts +2 -2
  54. package/test/first-save-suggest.test.ts +5 -5
  55. package/test/first-save.test.ts +15 -13
  56. package/test/joint-frames.test.ts +293 -0
  57. package/test/package-download.test.ts +16 -0
  58. package/translations/en.json +3 -2
  59. package/translations/ja.json +3 -2
  60. package/translations/ko.json +3 -2
  61. package/translations/ms.json +3 -2
  62. package/translations/zh.json +3 -2
@@ -6,6 +6,8 @@ export interface Figure {
6
6
  id: string;
7
7
  /** 컴포넌트 타입 이름. **만든 뒤에 고칠 수 없다.** */
8
8
  type: string;
9
+ /** Namespace of the full name {namespace}/{type} (ADR-0114). */
10
+ namespace?: string | null;
9
11
  name: string;
10
12
  description?: string;
11
13
  /** 분류 하나 — 팔레트에서 묶어 보이는 기준. */
@@ -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 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}\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}\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,20 +1,20 @@
1
- # V3 제품 현황
1
+ # Figure 제품 현황
2
2
 
3
- 형식은 V3(`figure-asset-1`) 하나다. V1·V2 형식은 2026-09-26 코드와 명세에서 제거했다.
3
+ 형식은 `figure-asset-1` 하나다. 옛 형식 V1·V2는 2026-09-26 코드와 명세에서 제거했다.
4
4
 
5
5
  ## 제품 코드
6
6
 
7
7
  - `client/modeller/product-session.ts`: 모델 ID·로드·저장 경계를 workspace에 연결한다. 다른 모델의 지연 응답을 버리며 로드 실패 시 이전 형상을 노출하지 않는다. UI/AI는 같은 리비전과 undo/redo를 사용한다. AI 편집은 요청 당시 context/revision을 필수로 받는다.
8
- - `client/modeller/canvas.ts`: things-scene 편집 모드의 판 위에 평가된 V3 형상을 부품마다 `figure-part` 컴포넌트로 세운다. 새 문맥에서 렌더링이 실패하면 이전 모델을 지운다.
8
+ - `client/modeller/canvas.ts`: things-scene 편집 모드의 판 위에 평가된 Figure 형상을 부품마다 `figure-part` 컴포넌트로 세운다. 새 문맥에서 렌더링이 실패하면 이전 모델을 지운다.
9
9
  - `client/modeller/editor.ts`: 부품 목록 · 캔버스 · 오른쪽 탭(속성 · 능력 · 움직임 · 판정 · 환경 · 이력). 실행 취소/다시 실행과 저장 연결.
10
10
  - `client/graphql/store.ts`: 정본을 그대로 `saveFigure`로 보낸다.
11
- - figure-service의 `saveFigure`: 도메인 권한 검사, V3 검증, 정확한 이전 정본을 조건으로 하는 atomic UPDATE, 생성 요청 ID를 이용한 재시도. SQLite에서 지원하지 않는 행 잠금은 사용하지 않는다. 재지 않은 성능 점수/삼각형 수는 null로 둔다. 실제 운영 DB 저장 테스트는 아직 하지 않았다.
12
- - `client/pages/figure-modeller-page.ts`: 위 요소를 묶는 페이지. `route.ts`의 `figure-modeller`와 `figure-modeller`가 이 페이지를 연다.
11
+ - figure-service의 `saveFigure`: 도메인 권한 검사, 자산 검증, 정확한 이전 정본을 조건으로 하는 atomic UPDATE, 생성 요청 ID를 이용한 재시도. SQLite에서 지원하지 않는 행 잠금은 사용하지 않는다. 재지 않은 성능 점수/삼각형 수는 null로 둔다. 실제 운영 DB 저장 테스트는 아직 하지 않았다.
12
+ - `client/pages/figure-modeller-page.ts`: 위 요소를 묶는 페이지. `route.ts`의 `figure-modeller`가 이 페이지를 연다.
13
13
 
14
14
  ## 남은 항목
15
15
 
16
- 1. operato-figure의 AI-dock과 figure-service 도구/제안 생성기를 V3 정본 및 승인된 트랜잭션 계약으로 연결한다. 페이지 이벤트만 추가한 것은 AI 통합 완료가 아니다.
17
- 2. 발행 게이트, 썸네일, 카탈로그/배치 소비처가 V3 평가 결과를 사용하는지 확인한다.
16
+ 1. operato-figure의 AI-dock과 figure-service 도구/제안 생성기를 Figure 정본 및 승인된 트랜잭션 계약으로 연결한다. 페이지 이벤트만 추가한 것은 AI 통합 완료가 아니다.
17
+ 2. 발행 게이트, 썸네일, 카탈로그/배치 소비처가 Figure 평가 결과를 사용하는지 확인한다.
18
18
 
19
19
  ## 검증
20
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.116",
3
+ "version": "10.1.121",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -27,8 +27,8 @@
27
27
  "clean": "npm run clean:server && npm run clean:client"
28
28
  },
29
29
  "dependencies": {
30
- "@hatiolab/figure-model": "^0.3.2",
31
- "@hatiolab/things-scene": "^10.1.71",
30
+ "@hatiolab/figure-model": "^0.4.0",
31
+ "@hatiolab/things-scene": "^10.1.74",
32
32
  "@material/web": "^2.0.0",
33
33
  "@operato/data-grist": "^10.0.0",
34
34
  "@operato/graphql": "^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.116",
44
- "@things-factory/figure-service": "^10.1.116",
43
+ "@things-factory/ai-assistant": "^10.1.121",
44
+ "@things-factory/figure-service": "^10.1.121",
45
45
  "three": "^0.185.1"
46
46
  },
47
- "gitHead": "49fa935cc703be70200eef4d29f473de98f871ce"
47
+ "gitHead": "23536a73b10f103048a2c893d3960b2a4e0f03dd"
48
48
  }
@@ -104,7 +104,7 @@ test('the details fold, and what they hold that is not the default stays said --
104
104
  /* ADR-0097 §5.5 (Q2): the list stays flat; a part says what it is fastened to, beside its name. */
105
105
  test('the part list names what each part is fastened to, without reordering or nesting it', () => {
106
106
  const editor = read('../client/modeller/editor.ts')
107
- assert.ok(editor.includes("'plane' in j.body0 ? i18next.t('figure.label.mounting-plane') : `${partLabelOf(j.body0.part)} · ${i18next.t(`figure.label.face-${j.body0.face}`)}`"))
107
+ assert.ok(editor.includes("'plane' in j.body0 ? i18next.t('figure.label.mounting-plane') : `${partLabelOf(j.body0.part)} · ${i18next.t(endPlaceKeyOf(j.body0))}`"))
108
108
  assert.ok(editor.includes('...(fastened.has(id) ? { fastenedTo: fastened.get(id)! } : {})'))
109
109
  const parts = read('../client/modeller/figure-parts.ts')
110
110
  assert.ok(parts.includes('<small fastened-to title=${i18next.t(\'figure.label.fastened-to\')}>${part.fastenedTo}</small>'))
@@ -122,7 +122,7 @@ test('detaching says what it would do: stays, what stays fastened to it, the cli
122
122
  /* Staying where it is is as expected: nothing is said then (user 2026-10-01); a move, a child kept, a clip lost are. */
123
123
  assert.ok(editor.includes(': /* It stays where it is: as expected, so nothing is said (user 2026-10-01). */ []'), 'nothing said when it stays')
124
124
  const links = read('../client/modeller/figure-links.ts')
125
- assert.equal(links.match(/\$\{this\.renderDetachWords\(\)\}/g)?.length, 2, 'beside both buttons that detach')
125
+ assert.equal(links.match(/\$\{this\.renderDetachWords\(\)\}/g)?.length, 3, 'beside every button that detaches (a part at an origin has its own)')
126
126
  for (const lang of ['en', 'ja', 'ko', 'ms', 'zh']) {
127
127
  const t = JSON.parse(read(`../translations/${lang}.json`))
128
128
  for (const key of ['detach-moves', 'detach-keeps-children', 'detach-clips', 'detach-refused']) assert.ok(t[`figure.text.${key}`], `${lang} ${key}`)
@@ -13,18 +13,18 @@ import { suggestTypeOf, releasableType } from '../client/modeller/first-save.js'
13
13
  const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
14
14
 
15
15
  test('an identifier is offered for any name: plain letters as they are, anything else a prefix and a random part', () => {
16
- assert.equal(suggestTypeOf('Lift table', []), 'LIFT_TABLE')
17
- assert.equal(suggestTypeOf('roller-conveyor 2', []), 'ROLLER_CONVEYOR_2')
16
+ assert.equal(suggestTypeOf('Lift table', []), 'lift-table')
17
+ assert.equal(suggestTypeOf('roller-conveyor 2', []), 'roller-conveyor-2')
18
18
  for (const name of ['리프트 테이블', '새 Figure', '', '___', '2층 선반']) {
19
19
  const made = suggestTypeOf(name, [])
20
- assert.match(made, /^FIG_[0-9A-Z]{6}$/, name)
20
+ assert.match(made, /^fig-[0-9a-z]{6}$/, name)
21
21
  assert.ok(releasableType(made) && TYPE_PATTERN.test(made) && made.length >= TYPE_LENGTH.min && !isPlaceholderType(made))
22
22
  }
23
23
  /* Never one already used: a taken name falls back, a taken random part is drawn again. */
24
- assert.match(suggestTypeOf('Lift table', ['LIFT_TABLE']), /^FIG_/)
24
+ assert.match(suggestTypeOf('Lift table', ['lift-table']), /^fig-/)
25
25
  let n = 0
26
26
  const draws = [0, 0, 0, 0, 0, 0, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5]
27
- assert.equal(suggestTypeOf('선반', ['FIG_000000'], () => draws[n++ % draws.length]!), 'FIG_IIIIII')
27
+ assert.equal(suggestTypeOf('선반', ['fig-000000'], () => draws[n++ % draws.length]!), 'fig-iiiiii')
28
28
  })
29
29
 
30
30
  test('the first save asks the name first; the identifier offered stays through retries, and renaming does not change it', () => {
@@ -8,23 +8,25 @@ import { placeholderType } from '@hatiolab/figure-model'
8
8
  import { firstSaveRefusalOf, releasableType, typeOf } from '../client/modeller/first-save.ts'
9
9
  import { createFigureProductSession, type FigureProductStore } from '../client/modeller/product-session.ts'
10
10
 
11
- test('치는 대로 타입의 모양이 된다 — 대문자 · 숫자 · 밑줄', () => {
12
- assert.equal(typeOf(' agv lifter '), 'AGV_LIFTER')
13
- assert.equal(typeOf('oht-2'), 'OHT_2')
11
+ test('치는 대로 타입의 모양이 된다 — 소문자 · 숫자 · 하이픈 하나', () => {
12
+ assert.equal(typeOf(' agv lifter '), 'agv-lifter')
13
+ assert.equal(typeOf('OHT_2'), 'oht-2')
14
+ assert.equal(typeOf('-Roller Conveyor-'), 'roller-conveyor')
14
15
  })
15
16
 
16
17
  test('발행할 수 없는 이름은 미리 말한다 — 임시 이름 · 형식 · 길이', () => {
17
- assert.equal(releasableType('AGV_LIFTER'), true)
18
+ assert.equal(releasableType('agv-lifter'), true)
18
19
  assert.equal(releasableType(placeholderType()), false, '서버가 붙이는 임시 이름')
19
- assert.equal(releasableType('AB'), false, '3 자 미만')
20
- assert.equal(releasableType('A__B'), false, '밑줄 둘')
21
- assert.equal(releasableType('_OHT'), false)
20
+ assert.equal(releasableType('a'), false, '2 자 미만')
21
+ assert.equal(releasableType('a--b'), false, '하이픈 둘')
22
+ assert.equal(releasableType('-oht'), false)
23
+ assert.equal(releasableType('AGV_LIFTER'), false, '대문자 · 밑줄')
22
24
  })
23
25
 
24
26
  test('비었거나 이미 쓰는 이름이면 저장하지 않는다', () => {
25
- assert.deepEqual(firstSaveRefusalOf('', ['OHT']), { key: 'figure.text.enter-a-type-name' })
26
- assert.deepEqual(firstSaveRefusalOf('OHT', ['OHT']), { key: 'figure.text.type-name-already-taken', type: 'OHT' })
27
- assert.equal(firstSaveRefusalOf('AGV', ['OHT']), null)
27
+ assert.deepEqual(firstSaveRefusalOf('', ['oht']), { key: 'figure.text.enter-a-type-name' })
28
+ assert.deepEqual(firstSaveRefusalOf('oht', ['oht']), { key: 'figure.text.type-name-already-taken', type: 'oht' })
29
+ assert.equal(firstSaveRefusalOf('agv', ['oht']), null)
28
30
  })
29
31
 
30
32
  /** A store that remembers what it was asked. */
@@ -46,12 +48,12 @@ test('타입은 새 도형을 만드는 저장에만 실린다 — 이미 있는
46
48
  const { store, requests } = recording()
47
49
  const session = createFigureProductSession(store)
48
50
  session.startNew()
49
- await session.save({ type: 'AGV_LIFTER' })
50
- assert.equal(requests[0]!.type, 'AGV_LIFTER')
51
+ await session.save({ type: 'agv-lifter' })
52
+ assert.equal(requests[0]!.type, 'agv-lifter')
51
53
  assert.equal(requests[0]!.id, undefined, '만드는 저장')
52
54
 
53
55
  session.command({ kind: 'rename', name: '리프터' })
54
- await session.save({ type: 'OTHER' })
56
+ await session.save({ type: 'other' })
55
57
  assert.equal(requests[1]!.id, 'new-id')
56
58
  assert.equal('type' in requests[1]!, false, '있는 도형에는 보내지 않는다')
57
59
  })
@@ -0,0 +1,293 @@
1
+ /*
2
+ * **관절 틀 (ADR-0107 결정 1·4) 을 화면이 읽고 그대로 돌려 쓴다** — 어느 방향이든 되는 축, 부품 기준점에 놓인 관절 끝,
3
+ * `origin.pivot` · `offset` · `rotate`. 움직임 폼 · 붙이기 폼 · 기즈모 끌기 · 움직임 없애기가 이 값들을 바꾸지 않고, 저장하고
4
+ * 다시 열어도 그대로다. 그 틀이 없는 커널(0.3.2)에서는 옛 모양만 같은 길로 본다.
5
+ */
6
+ import { test } from 'node:test'
7
+ import assert from 'node:assert/strict'
8
+
9
+ import { addFigureBox, applyFigureAuthoring, compileFigureAsset, createFigureAsset, parseFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
10
+ import type { FigureAsset, FigureAuthoringAction, FigureJoint } from '@hatiolab/figure-model'
11
+
12
+ import { jointAxisUnit, jointAxisWord } from '../client/modeller/joint-axis.ts'
13
+ import { alignOf, endPlaceKeyOf, fasteningOf, fixedJointOf, motionFormOf, motionFormRefusalOf, motionJoint, motionOf, rowAxesOf, rowBlockOf } from '../client/modeller/links.ts'
14
+ import { jointDragOf } from '../client/modeller/joint-drag.ts'
15
+ import { jointFanOf } from '../client/modeller/joint-fan.ts'
16
+
17
+ type Dynamic = any
18
+ const set = (asset: FigureAsset, joint: Dynamic) => applyFigureAuthoring(asset, { kind: 'set-joint', joint } as FigureAuthoringAction)
19
+ const box = (asset: FigureAsset, id: string, x: number, y: number, z: number, at = 0) => addFigureBox(asset, { id, size: { x, y, z }, position: { x: 0, y: at, z: 0 } })
20
+ const drawn = (asset: FigureAsset, id: string, state: Record<string, number> = {}) =>
21
+ compileFigureAsset(asset).evaluate(state).geometry.find(g => g.id === id) as never as { id: string; pose: { t: number[]; r: number[][] } }
22
+
23
+ /** Whether this kernel knows the joint frames: an axis that is a vector is refused by one that does not. */
24
+ const FRAMES = (() => {
25
+ try {
26
+ let a = box(createFigureAsset('probe'), 'p', 10, 10, 10, 5)
27
+ a = set(a, { id: 'p', type: 'revolute', body0: { plane: 'mounting-plane' }, body1: { part: 'p', face: 'bottom' }, axis: { x: 0, y: 1, z: 0 } })
28
+ return !!a.joints?.some(j => typeof j.axis === 'object')
29
+ } catch {
30
+ return false
31
+ }
32
+ })()
33
+ const framesOnly = { skip: FRAMES ? false : 'the kernel has no ADR-0107 joint frames' }
34
+
35
+ const SUMMED = { sum: [{ from: { of: 'part', part: 'link', axis: 'y' }, times: 0.5 }], plus: 10 }
36
+
37
+ /**
38
+ * A base on the floor; a link turning on its top about (1, 1, 0), by the middle of its bottom, turned and moved; a tool at
39
+ * the link's origin, its frame moved by a summed offset and turned; a cap at the tool's origin by its centre, held.
40
+ */
41
+ function framed(): FigureAsset {
42
+ let a = box(createFigureAsset('Frames'), 'base', 300, 100, 300, 50)
43
+ a = box(a, 'link', 100, 400, 100, 300)
44
+ a = box(a, 'tool', 60, 120, 40)
45
+ a = box(a, 'cap', 20, 20, 20)
46
+ a = set(a, { id: 'base', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'base', face: 'bottom' } })
47
+ a = set(a, { id: 'link', type: 'revolute', body0: { part: 'base', face: 'top' }, body1: { part: 'link', face: 'bottom' }, origin: { pivot: 'child-face', offset: { x: 20 }, rotate: { x: 10 } }, axis: { x: 1, y: 1, z: 0 }, lowerLimit: -90, upperLimit: 90 })
48
+ a = set(a, { id: 'tool', type: 'revolute', body0: { part: 'link', at: 'origin' }, body1: { part: 'tool', at: 'origin' }, origin: { offset: { x: 120, y: SUMMED, z: -30 }, rotate: { x: 15, y: -30, z: 60 } }, axis: 'Z', lowerLimit: -45, upperLimit: 45 })
49
+ return set(a, { id: 'cap', type: 'fixed', body0: { part: 'tool', at: 'origin' }, body1: { part: 'cap', face: 'centre' }, origin: { offset: { y: 40 } } })
50
+ }
51
+
52
+ /** The old shapes only: a token axis, face ends, a gap and an edge. */
53
+ function plain(): FigureAsset {
54
+ let a = box(createFigureAsset('Plain'), 'base', 300, 100, 300, 50)
55
+ a = box(a, 'link', 100, 400, 100, 300)
56
+ a = set(a, { id: 'base', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'base', face: 'bottom' } })
57
+ return set(a, { id: 'link', type: 'revolute', body0: { part: 'base', face: 'top' }, body1: { part: 'link', face: 'bottom' }, origin: { gap: 5, align: { x: 'min' } }, axis: 'Y', lowerLimit: -90, upperLimit: 90 })
58
+ }
59
+
60
+ const reopen = (asset: FigureAsset) => parseFigureAsset(serializeFigureAsset(asset) as unknown as string)
61
+ const jointOf = (asset: FigureAsset, id: string) => asset.joints!.find(j => j.id === id)! as Dynamic
62
+ /** What the frames say of a joint: what no form path may change. */
63
+ const frameOf = (j: Dynamic, keepType = true) => ({
64
+ ...(keepType ? { type: j.type, axis: j.axis } : {}),
65
+ body0: j.body0,
66
+ body1: j.body1,
67
+ pivot: j.origin?.pivot,
68
+ offset: j.origin?.offset,
69
+ rotate: j.origin?.rotate
70
+ })
71
+
72
+ /** The fastening form's 붙이기, as figure-links builds it from what `fasteningOf` reads; nothing where it writes none. */
73
+ function fasteningSave(asset: FigureAsset, part: string): FigureAuthoringAction | null {
74
+ const f = fasteningOf(asset, part) as Dynamic
75
+ if (!f || f.atOrigin) return null
76
+ const aim = f.toPlane ? ('plane' as const) : { part: f.target, face: f.targetFace }
77
+ const align = alignOf(asset, part, aim, f.align, f.alignMm, !!f.twist, f.alignInput)
78
+ return {
79
+ kind: 'attach',
80
+ part,
81
+ face: f.face,
82
+ to: f.toPlane ? { plane: 'mounting-plane' } : { part: f.target, face: f.targetFace },
83
+ ...(f.toPlane ? {} : { facing: f.facing }),
84
+ ...(f.gap ? { gap: f.gap } : {}),
85
+ twist: f.twist,
86
+ ...(align ? { align } : {}),
87
+ replace: true
88
+ } as FigureAuthoringAction
89
+ }
90
+
91
+ /** Every form path that writes a joint, each applied, saved and opened again. */
92
+ function roundTrips(asset: FigureAsset, id: string) {
93
+ const j = jointOf(asset, id)
94
+ const part = j.body1.part
95
+ const out: Record<string, FigureJoint> = {}
96
+ if (j.type !== 'fixed') out.motion = jointOf(reopen(set(asset, motionJoint(j, motionFormOf(j), part))), id)
97
+ const fasten = fasteningSave(asset, part)
98
+ if (fasten) out.fastening = jointOf(reopen(applyFigureAuthoring(asset, fasten)), id)
99
+ out.fixed = jointOf(reopen(set(asset, fixedJointOf(j))), id)
100
+ const at = drawn(asset, part)
101
+ /* 30 along Z: the figure's, or at an origin the parent's own Z, as the offset is measured. */
102
+ const z = 'at' in j.body0 ? [0, 1, 2].map(i => drawn(asset, j.body0.part).pose.r[i]![2]!) : [0, 0, 1]
103
+ const by = z.map(v => v * 30)
104
+ const drag = jointDragOf(asset, part, at as never, { position: at.pose.t.map((v, i) => v + by[i]!) as [number, number, number] }, {})
105
+ assert.equal(drag?.kind, 'joint', `${id}: ${JSON.stringify(drag)}`)
106
+ const dragged = reopen(set(asset, (drag as { joint: FigureJoint }).joint))
107
+ out.drag = jointOf(dragged, id)
108
+ /* The part lands where it was dragged to. */
109
+ const landed = drawn(dragged, part).pose.t
110
+ assert.ok(Math.max(...by.map((d, i) => Math.abs(landed[i]! - at.pose.t[i]! - d))) < 0.2, `${id}: landed ${landed}, from ${at.pose.t}`)
111
+ return out
112
+ }
113
+
114
+ test('the axis reader: a token is its axis, a vector its unit vector, unrounded; said as written', () => {
115
+ assert.deepEqual(jointAxisUnit('Y'), [0, 1, 0])
116
+ assert.deepEqual(jointAxisUnit('z'), [0, 0, 1])
117
+ assert.deepEqual(jointAxisUnit({ x: 0, y: 3, z: 4 }), [0, 0.6, 0.8])
118
+ const tilted = jointAxisUnit({ x: 1, y: 1, z: 0 })!
119
+ assert.ok(Math.abs(tilted[0] - Math.SQRT1_2) < 1e-15 && tilted[0] === tilted[1] && tilted[2] === 0, `not rounded to a token: ${tilted}`)
120
+ assert.equal(jointAxisUnit({ x: 0, y: 0, z: 0 }), undefined)
121
+ assert.equal(jointAxisUnit(undefined), undefined)
122
+ assert.equal(jointAxisWord('X'), 'X')
123
+ assert.equal(jointAxisWord({ x: 1, y: 1, z: 0 }), '(1, 1, 0)')
124
+ })
125
+
126
+ test('a follower is not a turn without end: it has no limits because its leader gives its range', () => {
127
+ let a = box(createFigureAsset('Pair'), 'a', 100, 100, 100, 50)
128
+ a = box(a, 'b', 50, 50, 50, 125)
129
+ a = set(a, { id: 'a', type: 'revolute', body0: { plane: 'mounting-plane' }, body1: { part: 'a', face: 'bottom' }, axis: 'Y', lowerLimit: -90, upperLimit: 90 })
130
+ a = set(a, { id: 'b', type: 'revolute', body0: { part: 'a', face: 'top' }, body1: { part: 'b', face: 'bottom' }, axis: 'Y', mimic: { joint: 'a', multiplier: -1 } })
131
+ assert.equal(motionOf(a, 'b')?.kind, 'turn')
132
+ assert.equal(motionFormOf(jointOf(a, 'b')).kind, 'turn')
133
+ assert.equal(motionOf(a, 'a')?.kind, 'turn')
134
+ const free = set(a, { id: 'a', type: 'revolute', body0: { plane: 'mounting-plane' }, body1: { part: 'a', face: 'bottom' }, axis: 'Y' })
135
+ assert.equal(motionOf(free, 'a')?.kind, 'continuous', 'a joint of its own without limits still turns without end')
136
+ })
137
+
138
+ test('a range need not hold 0; it needs its most above its least', () => {
139
+ const form = motionFormOf(jointOf(plain(), 'link'))
140
+ assert.equal(motionFormRefusalOf({ ...form, min: 10, max: 90 }, ''), undefined)
141
+ assert.equal(motionFormRefusalOf({ ...form, min: -90, max: -10 }, ''), undefined)
142
+ assert.equal(motionFormRefusalOf({ ...form, min: 90, max: 10 }, ''), 'range')
143
+ assert.equal(motionFormRefusalOf({ ...form, min: 10, max: 10 }, ''), 'range')
144
+ assert.equal(motionFormRefusalOf({ ...form, min: NaN, max: 10 }, ''), 'range')
145
+ assert.equal(motionFormRefusalOf({ ...form, multiplier: 0 }, 'other'), 'multiplier')
146
+ /* A new control takes it and starts at the nearest end. */
147
+ const still = set(plain(), fixedJointOf(jointOf(plain(), 'link')))
148
+ const fixed = jointOf(still, 'link')
149
+ const lifted = set(still, motionJoint(fixed, { ...motionFormOf(fixed), kind: 'turn', min: 10, max: 90 }, 'link'))
150
+ assert.equal(lifted.stateDefaults[jointOf(lifted, 'link').state.id], 10)
151
+ /* An existing control keeps its name and its stored value: a range that leaves that value out is the kernel's refusal (R1). */
152
+ const j = jointOf(plain(), 'link')
153
+ assert.throws(() => set(plain(), motionJoint(j, { ...motionFormOf(j), min: 10, max: 90 }, 'link')), /INPUT_RANGE/)
154
+ })
155
+
156
+ test('old shapes: every form path writes the joint back as it was, saved and reopened', () => {
157
+ const asset = plain()
158
+ const was = frameOf(jointOf(asset, 'link'))
159
+ const paths = roundTrips(asset, 'link')
160
+ assert.deepEqual(frameOf(paths.motion), was, 'motion form')
161
+ assert.deepEqual(frameOf(paths.fastening), was, 'fastening form')
162
+ assert.deepEqual(frameOf(paths.fixed, false), frameOf(was, false), 'remove motion')
163
+ assert.deepEqual(frameOf(paths.drag), was, 'drag')
164
+ assert.deepEqual((paths.drag.origin as Dynamic).align, { x: 'min', z: { mm: 30 } }, 'the edge kept')
165
+ assert.equal(paths.drag.origin?.gap, 5)
166
+ })
167
+
168
+ test('a vector axis: the form keeps it, the summary says it, saved and reopened it is the same vector', framesOnly, () => {
169
+ const asset = framed()
170
+ const form = motionFormOf(jointOf(asset, 'link'))
171
+ assert.deepEqual(form.axisVector, { x: 1, y: 1, z: 0 })
172
+ assert.deepEqual(motionOf(asset, 'link')?.vector, { x: 1, y: 1, z: 0 })
173
+ /* Changing the range leaves the axis alone. */
174
+ const saved = reopen(set(asset, motionJoint(jointOf(asset, 'link'), { ...form, min: -30, max: 60 }, 'link')))
175
+ assert.deepEqual(jointOf(saved, 'link').axis, { x: 1, y: 1, z: 0 })
176
+ assert.equal(jointOf(saved, 'link').upperLimit, 60)
177
+ })
178
+
179
+ test('frames: every form path keeps the vector axis, pivot, offset (summed too), rotate and origin ends, saved and reopened', framesOnly, () => {
180
+ const asset = framed()
181
+ for (const id of ['link', 'tool', 'cap']) {
182
+ const was = jointOf(asset, id)
183
+ const paths = roundTrips(asset, id)
184
+ if (paths.motion) assert.deepEqual(frameOf(paths.motion), frameOf(was), `${id}: motion form`)
185
+ if (paths.fastening) assert.deepEqual(frameOf(paths.fastening), frameOf(was), `${id}: fastening form`)
186
+ assert.deepEqual(frameOf(paths.fixed, false), frameOf(was, false), `${id}: remove motion`)
187
+ const dragged = frameOf(paths.drag)
188
+ if ('at' in was.body0) {
189
+ /* At an origin the drag moves the frame by its offset, in the parent's (turned) frame; the summed term stays. */
190
+ assert.deepEqual(dragged, { ...frameOf(was), offset: { ...was.origin.offset, z: (was.origin.offset.z ?? 0) + 30 } }, `${id}: drag`)
191
+ } else assert.deepEqual(dragged, frameOf(was), `${id}: drag`)
192
+ }
193
+ /* A summed offset term is not a number to add to: dragged across it, the drag is refused; along the others it stays. */
194
+ const tool = drawn(asset, 'tool')
195
+ const up = jointDragOf(asset, 'tool', tool as never, { position: [tool.pose.t[0]!, tool.pose.t[1]! + 30, tool.pose.t[2]!] }, {})
196
+ assert.deepEqual(up, { kind: 'refused', reason: 'origin-follows', joint: 'tool' })
197
+ })
198
+
199
+ test('placed by an origin: said as the origin, no row offered, and the fastening form writes nothing over it', framesOnly, () => {
200
+ const asset = framed()
201
+ assert.equal(endPlaceKeyOf(jointOf(asset, 'tool').body0), 'figure.label.joint-origin')
202
+ assert.equal(endPlaceKeyOf(jointOf(asset, 'link').body0), 'figure.label.face-top')
203
+ assert.equal((fasteningOf(asset, 'tool') as Dynamic).atOrigin, true)
204
+ assert.equal((fasteningOf(asset, 'cap') as Dynamic).atOrigin, true)
205
+ assert.equal((fasteningOf(asset, 'cap') as Dynamic).face, 'centre')
206
+ assert.equal((fasteningOf(asset, 'link') as Dynamic).atOrigin, undefined)
207
+ assert.equal(fasteningSave(asset, 'tool'), null)
208
+ assert.deepEqual(rowAxesOf(asset, 'cap'), [])
209
+ assert.equal(rowBlockOf(asset, 'cap'), 'at-origin')
210
+ })
211
+
212
+ test('a drag about a vector axis sets the value; off it, it is refused and the vector said', framesOnly, () => {
213
+ let a = box(createFigureAsset('Tilt'), 'post', 100, 200, 100, 100)
214
+ a = box(a, 'head', 60, 60, 60, 230)
215
+ a = set(a, { id: 'post', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'post', face: 'bottom' } })
216
+ a = set(a, { id: 'head', type: 'revolute', body0: { part: 'post', face: 'top' }, body1: { part: 'head', face: 'bottom' }, axis: { x: 0, y: 3, z: 4 }, lowerLimit: -90, upperLimit: 90 })
217
+ const at = drawn(a, 'head')
218
+ /* 30° about (0, 0.6, 0.8), as XYZ degrees. */
219
+ const q = [0, 0.6, 0.8].map(v => v * Math.sin(Math.PI / 12))
220
+ const w = Math.cos(Math.PI / 12)
221
+ const [x, y, z] = q as [number, number, number]
222
+ const rx = Math.atan2(2 * (w * x - y * z), 1 - 2 * (x * x + y * y))
223
+ const ry = Math.asin(Math.max(-1, Math.min(1, 2 * (w * y + x * z))))
224
+ const rz = Math.atan2(2 * (w * z - x * y), 1 - 2 * (y * y + z * z))
225
+ const deg = [rx, ry, rz].map(r => (r * 180) / Math.PI) as [number, number, number]
226
+ assert.deepEqual(jointDragOf(a, 'head', at as never, { rotation: deg }, {}), { kind: 'value', input: 'head', unit: 'deg', value: 30 })
227
+ assert.deepEqual(jointDragOf(a, 'head', at as never, { rotation: [40, 0, 0] }, {}), { kind: 'refused', reason: 'off-axis', joint: 'head', axis: '(0, 3, 4)' })
228
+ /* A slide along it: the distance along the vector. */
229
+ const slide = set(a, { ...jointOf(a, 'head'), type: 'prismatic', lowerLimit: -100, upperLimit: 100 })
230
+ const s = drawn(slide, 'head')
231
+ assert.deepEqual(jointDragOf(slide, 'head', s as never, { position: [s.pose.t[0]!, s.pose.t[1]! + 30, s.pose.t[2]! + 40] }, {}), { kind: 'value', input: 'head', unit: 'mm', value: 50 })
232
+ })
233
+
234
+ test('a rotated joint frame: a turn about its turned axis is its value, and a move is measured before the rotation', framesOnly, () => {
235
+ let a = box(createFigureAsset('Rotated'), 'post', 100, 200, 100, 100)
236
+ a = box(a, 'link', 60, 300, 60, 350)
237
+ a = set(a, { id: 'post', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'post', face: 'bottom' } })
238
+ a = set(a, { id: 'bend', type: 'revolute', body0: { part: 'post', face: 'top' }, body1: { part: 'link', face: 'bottom' }, origin: { rotate: { x: 90 } }, axis: 'Z', lowerLimit: -90, upperLimit: 90 })
239
+ /* Turned 90° about X, the frame's Z is the figure's -Y: 20° about -Y is 20 on the joint. */
240
+ const at = drawn(a, 'link', { bend: 10 })
241
+ const before = at.pose.r
242
+ const turnY = (deg: number) => {
243
+ const c = Math.cos((deg * Math.PI) / 180),
244
+ s = Math.sin((deg * Math.PI) / 180)
245
+ return [[c, 0, s], [0, 1, 0], [-s, 0, c]]
246
+ }
247
+ const r = turnY(-20).map(row => [0, 1, 2].map(j => row.reduce((sum, v, k) => sum + v * before[k]![j]!, 0)))
248
+ /* As XYZ Euler degrees. */
249
+ const ry = Math.asin(Math.max(-1, Math.min(1, r[0]![2]!)))
250
+ const rx = Math.atan2(-r[1]![2]!, r[2]![2]!)
251
+ const rz = Math.atan2(-r[0]![1]!, r[0]![0]!)
252
+ const drag = jointDragOf(a, 'link', at as never, { rotation: [rx, ry, rz].map(v => (v * 180) / Math.PI) as [number, number, number] }, { bend: 10 })
253
+ assert.deepEqual(drag, { kind: 'value', input: 'bend', unit: 'deg', value: 30 })
254
+ /* Held still, moved 30 along X: an offset across the post's top on X, not along the turned frame. */
255
+ const still = set(a, { ...jointOf(a, 'bend'), type: 'fixed', axis: undefined, lowerLimit: undefined, upperLimit: undefined })
256
+ const s = drawn(still, 'link')
257
+ const moved = jointDragOf(still, 'link', s as never, { position: [s.pose.t[0]!, s.pose.t[1]!, s.pose.t[2]! + 30] }, {}) as { joint: Dynamic }
258
+ assert.deepEqual(moved.joint.origin, { rotate: { x: 90 }, align: { z: { mm: 30 } } })
259
+ const up = jointDragOf(still, 'link', s as never, { position: [s.pose.t[0]!, s.pose.t[1]! + 25, s.pose.t[2]!] }, {}) as { joint: Dynamic }
260
+ assert.deepEqual(up.joint.origin, { rotate: { x: 90 }, gap: 25 })
261
+ })
262
+
263
+ test('a fan stands about a vector axis, as it does about the same token', framesOnly, () => {
264
+ let a = box(createFigureAsset('Fan'), 'base', 200, 100, 200, 50)
265
+ a = box(a, 'arm', 400, 50, 50, 125)
266
+ a = set(a, { id: 'base', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'base', face: 'bottom' } })
267
+ const arm = (axis: unknown) => set(a, { id: 'arm', type: 'revolute', body0: { part: 'base', face: 'top' }, body1: { part: 'arm', face: 'bottom' }, axis, lowerLimit: -45, upperLimit: 90 })
268
+ const token = jointFanOf(arm('Y'), 'arm') as never as { pose: { t: number[]; r: number[][] } }
269
+ const vector = jointFanOf(arm({ x: 0, y: 2, z: 0 }), 'arm') as never as { pose: { t: number[]; r: number[][] } }
270
+ assert.ok(token && vector, 'a fan for both')
271
+ const off = (p: number[][], q: number[][]) => Math.max(...p.flatMap((row, i) => row.map((v, j) => Math.abs(v - q[i]![j]!))))
272
+ assert.ok(off(token.pose.r, vector.pose.r) < 1e-9 && off([token.pose.t], [vector.pose.t]) < 1e-9)
273
+ const tilted = jointFanOf(arm({ x: 0, y: 3, z: 4 }), 'arm') as never as { pose: { r: number[][] } }
274
+ assert.ok(tilted, 'a tilted axis has a fan too')
275
+ /* The fan lies in its local X–Z: its local Y is the axis. */
276
+ const normal = [0, 1, 2].map(i => tilted.pose.r[i]![1]!)
277
+ assert.ok(Math.abs(Math.abs(normal[1]! * 0.6 + normal[2]! * 0.8) - 1) < 1e-9, `normal ${normal}`)
278
+ })
279
+
280
+ test('editing a motion keeps the control named after the joint when it has no state record', async () => {
281
+ const { motionFormOf, motionJoint } = await import('../client/modeller/links.ts')
282
+ const { applyFigureAuthoring, addFigureBox, createFigureAsset } = await import('@hatiolab/figure-model')
283
+ let a = createFigureAsset('Hinge')
284
+ a = addFigureBox(a, { id: 'base', size: { x: 200, y: 100, z: 200 }, position: { x: 0, y: 0, z: 0 } })
285
+ a = addFigureBox(a, { id: 'lid', size: { x: 200, y: 20, z: 200 }, position: { x: 0, y: 0, z: 0 } })
286
+ a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { id: 'base', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'base', face: 'bottom' } } })
287
+ a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { id: 'lid', type: 'revolute', body0: { part: 'base', face: 'top' }, body1: { part: 'lid', face: 'bottom' }, axis: 'X', lowerLimit: -90, upperLimit: 90 } })
288
+ const lid = a.joints!.find(j => j.id === 'lid')!
289
+ const form = motionFormOf(lid)
290
+ assert.equal(form.id, 'lid', 'the form starts from the input the joint is driven by')
291
+ const edited = applyFigureAuthoring(a, { kind: 'set-joint', joint: motionJoint(lid, { ...form, max: 80 }, 'lid') })
292
+ assert.deepEqual(edited.document.model.inputs.filter(i => i.role === 'state').map(i => i.id), ['lid'], 'the control keeps its name after the edit')
293
+ })
@@ -0,0 +1,16 @@
1
+ /*
2
+ * A released version downloads as a package by its release reference {namespace}/{type}@{version} (ADR-0114).
3
+ */
4
+ import { test } from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import { readFileSync } from 'node:fs'
7
+
8
+ const panel = readFileSync(new URL('../client/modeller/figure-history-panel.ts', import.meta.url), 'utf8')
9
+
10
+ test('each released version links to the package door by its full reference, only once the figure has a full name', () => {
11
+ const href = panel.slice(panel.indexOf('private packageHref'), panel.indexOf('private renderVersion'))
12
+ assert.match(href, /if \(!this\.figureNamespace \|\| !this\.figureType\) return undefined/)
13
+ assert.match(href, /`\$\{contextPath\}\/figure-package\?refs=\$\{encodeURIComponent\(`\$\{this\.figureNamespace\}\/\$\{this\.figureType\}@\$\{version\}`\)\}`/)
14
+ const row = panel.slice(panel.indexOf('private renderVersion'), panel.indexOf('private renderKept'))
15
+ assert.match(row, /<a package download href=/)
16
+ })
@@ -61,7 +61,6 @@
61
61
  "figure.text.fasten-before-moving": "fasten it to another part or the floor first; then it can move.",
62
62
  "figure.text.motion-summary": "{part} · against {against} · {kind} along {axis}",
63
63
  "figure.text.motion-ends-shown": "The canvas shows the poses at both ends of the range ({min} … {max} {unit}), translucent. What lies between is not checked.",
64
- "figure.text.joint-range-holds-zero": "the range must hold 0, the rest pose.",
65
64
  "figure.text.gizmo-joint-mimic": "the {joint} joint follows {leader}. move {leader}.",
66
65
  "figure.text.gizmo-joint-fixed-turn": "the {joint} joint does not turn. give it a turn in the motion tab.",
67
66
  "figure.text.gizmo-joint-turned-slide": "the {joint} joint only slides. use the move handle.",
@@ -707,6 +706,7 @@
707
706
  "figure.text.row-count-spreads": "A set count is spread evenly over the length. Check the spacing in the preview.",
708
707
  "figure.text.row-blocked-unfastened": "Fasten {part} to another part to arrange it in a row along that part.",
709
708
  "figure.text.row-blocked-on-plane": "Fasten it to another part, not the mounting plane, to arrange it in a row along that part.",
709
+ "figure.text.row-blocked-at-origin": "It stands at another part's origin. Fasten it to a face of that part to arrange it in a row along it.",
710
710
  "figure.text.row-blocked-holds-parts": "Parts are fastened to this part, so it cannot be arranged in a row. Arrange it first; parts fastened after ride every copy.",
711
711
  "figure.button.go-fasten": "go to fastening",
712
712
  "figure.text.row-count-pitch": "{count} × {part} · along {axis} · pitch {pitch} mm (centre to centre)",
@@ -983,5 +983,6 @@
983
983
  "figure.text.detail-no-motion": "No motion is defined.",
984
984
  "figure.text.detail-playing": "Playing",
985
985
  "figure.label.detail-summary": "Drawing cost of the selected model",
986
- "figure.text.detail-paused": "Paused"
986
+ "figure.text.detail-paused": "Paused",
987
+ "figure.button.download-package": "download package"
987
988
  }
@@ -61,7 +61,6 @@
61
61
  "figure.text.fasten-before-moving": "先に他の部品か床に取り付けると、動きを付けられます。",
62
62
  "figure.text.motion-summary": "{part} · {against} 基準 · {axis} 軸 {kind}",
63
63
  "figure.text.motion-ends-shown": "キャンバスに範囲の両端({min} … {max} {unit})の姿勢を半透明で表示します。その間は確認しません。",
64
- "figure.text.joint-range-holds-zero": "範囲に 0(基本姿勢)を含めてください。",
65
64
  "figure.text.gizmo-joint-mimic": "{joint} 関節は {leader} に従います。{leader} を動かしてください。",
66
65
  "figure.text.gizmo-joint-fixed-turn": "{joint} 関節は回りません。動きタブで回転を付けてください。",
67
66
  "figure.text.gizmo-joint-turned-slide": "{joint} 関節はスライドのみです。移動ハンドルを使ってください。",
@@ -707,6 +706,7 @@
707
706
  "figure.text.row-count-spreads": "個数を決めると長さの中で均等に広がります。間隔はプレビューで確認してください。",
708
707
  "figure.text.row-blocked-unfastened": "{part} を別の部品に取り付けると、その部品に沿って列に並べられます。",
709
708
  "figure.text.row-blocked-on-plane": "取付平面ではなく別の部品に取り付けると、その部品に沿って列に並べられます。",
709
+ "figure.text.row-blocked-at-origin": "別の部品の基準点に置かれているため、列に並べられません。その部品の面に取り付けると列に並べられます。",
710
710
  "figure.text.row-blocked-holds-parts": "この部品に取り付いた部品があるため列に並べられません。先に列に並べてから取り付けると、コピーごとに付きます。",
711
711
  "figure.button.go-fasten": "取り付けへ",
712
712
  "figure.text.row-count-pitch": "{part} {count} 個 · {axis} 方向 · pitch {pitch} mm(中心間隔)",
@@ -983,5 +983,6 @@
983
983
  "figure.text.detail-no-motion": "登録された動きがありません。",
984
984
  "figure.text.detail-playing": "再生中",
985
985
  "figure.label.detail-summary": "選択したモデルの描画コスト",
986
- "figure.text.detail-paused": "一時停止"
986
+ "figure.text.detail-paused": "一時停止",
987
+ "figure.button.download-package": "パッケージをダウンロード"
987
988
  }
@@ -61,7 +61,6 @@
61
61
  "figure.text.fasten-before-moving": "먼저 다른 부품이나 바닥에 붙여야 움직임을 줄 수 있습니다.",
62
62
  "figure.text.motion-summary": "{part} · {against} 기준 · {axis}축 {kind}",
63
63
  "figure.text.motion-ends-shown": "캔버스에 범위 양 끝({min} … {max} {unit})의 자세를 반투명으로 보입니다. 그 사이는 확인하지 않습니다.",
64
- "figure.text.joint-range-holds-zero": "범위에 0이 들어가야 합니다. 0이 기본 자세입니다.",
65
64
  "figure.text.gizmo-joint-mimic": "{joint} 관절은 {leader} 관절을 따라 움직입니다. {leader}을(를) 움직이세요.",
66
65
  "figure.text.gizmo-joint-fixed-turn": "{joint} 관절은 돌지 않습니다. 돌리려면 움직임 탭에서 회전을 주세요.",
67
66
  "figure.text.gizmo-joint-turned-slide": "{joint} 관절은 미끄러지기만 합니다. 이동 손잡이로 움직이세요.",
@@ -707,6 +706,7 @@
707
706
  "figure.text.row-count-spreads": "개수를 정하면 길이 안에 고르게 벌어집니다. 간격은 미리보기에서 확인하세요.",
708
707
  "figure.text.row-blocked-unfastened": "{part}을(를) 다른 부품에 붙이면 그 부품을 따라 줄로 배열할 수 있습니다.",
709
708
  "figure.text.row-blocked-on-plane": "장착 평면이 아니라 다른 부품에 붙여야 그 부품을 따라 줄로 배열할 수 있습니다.",
709
+ "figure.text.row-blocked-at-origin": "다른 부품의 기준점에 놓여 있어 줄로 배열할 수 없습니다. 그 부품의 면에 붙이면 줄로 배열할 수 있습니다.",
710
710
  "figure.text.row-blocked-holds-parts": "이 부품에 붙은 부품이 있어 줄로 배열할 수 없습니다. 먼저 줄로 배열한 뒤 붙이면 복사본마다 함께 붙습니다.",
711
711
  "figure.button.go-fasten": "붙이기로 가기",
712
712
  "figure.text.row-count-pitch": "{part} {count}개 · {axis} 방향 · pitch {pitch} mm (중심 간격)",
@@ -983,5 +983,6 @@
983
983
  "figure.text.detail-no-motion": "등록된 움직임이 없습니다.",
984
984
  "figure.text.detail-playing": "재생 중",
985
985
  "figure.label.detail-summary": "선택한 모델의 그리기 비용",
986
- "figure.text.detail-paused": "일시정지"
986
+ "figure.text.detail-paused": "일시정지",
987
+ "figure.button.download-package": "꾸러미 내려받기"
987
988
  }
@@ -61,7 +61,6 @@
61
61
  "figure.text.fasten-before-moving": "lekatkan pada bahagian lain atau lantai dahulu; kemudian ia boleh bergerak.",
62
62
  "figure.text.motion-summary": "{part} · berbanding {against} · {kind} pada paksi {axis}",
63
63
  "figure.text.motion-ends-shown": "Kanvas menunjukkan pose di kedua-dua hujung julat ({min} … {max} {unit}), lut sinar. Apa yang di antaranya tidak disemak.",
64
- "figure.text.joint-range-holds-zero": "julat mesti mengandungi 0, pose rehat.",
65
64
  "figure.text.gizmo-joint-mimic": "sendi {joint} mengikut {leader}. gerakkan {leader}.",
66
65
  "figure.text.gizmo-joint-fixed-turn": "sendi {joint} tidak berputar. berikan putaran di tab gerakan.",
67
66
  "figure.text.gizmo-joint-turned-slide": "sendi {joint} hanya menggelongsor. gunakan pemegang alih.",
@@ -707,6 +706,7 @@
707
706
  "figure.text.row-count-spreads": "Bilangan tetap disebarkan sekata sepanjang panjang. Semak jaraknya dalam pratonton.",
708
707
  "figure.text.row-blocked-unfastened": "Lekatkan {part} pada bahagian lain untuk menyusunnya sebaris di sepanjang bahagian itu.",
709
708
  "figure.text.row-blocked-on-plane": "Lekatkan pada bahagian lain, bukan satah pemasangan, untuk menyusunnya sebaris.",
709
+ "figure.text.row-blocked-at-origin": "Ia diletakkan pada titik asal bahagian lain. Lekatkan pada muka bahagian itu untuk menyusunnya sebaris.",
710
710
  "figure.text.row-blocked-holds-parts": "Ada bahagian dilekatkan pada bahagian ini, jadi ia tidak boleh disusun sebaris. Susun dahulu; bahagian yang dilekatkan kemudian ikut setiap salinan.",
711
711
  "figure.button.go-fasten": "pergi ke pelekatan",
712
712
  "figure.text.row-count-pitch": "{count} × {part} · sepanjang {axis} · pitch {pitch} mm (tengah ke tengah)",
@@ -983,5 +983,6 @@
983
983
  "figure.text.detail-no-motion": "Tiada gerakan didaftarkan.",
984
984
  "figure.text.detail-playing": "Sedang dimainkan",
985
985
  "figure.label.detail-summary": "Kos lukisan model yang dipilih",
986
- "figure.text.detail-paused": "Dijeda"
986
+ "figure.text.detail-paused": "Dijeda",
987
+ "figure.button.download-package": "muat turun pakej"
987
988
  }