@things-factory/figure-ui 10.1.27 → 10.1.29

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 (91) hide show
  1. package/client/graphql/index.ts +23 -0
  2. package/client/modeller/angle-dial.ts +70 -0
  3. package/client/modeller/base-box.ts +104 -0
  4. package/client/modeller/figure-animations.ts +90 -17
  5. package/client/modeller/figure-canvas.ts +24 -6
  6. package/client/modeller/figure-inspector.ts +169 -5
  7. package/client/modeller/figure-preview.ts +110 -3
  8. package/client/modeller/figure-report.ts +20 -32
  9. package/client/modeller/figure-side.ts +10 -1
  10. package/client/modeller/figure-source.ts +8 -0
  11. package/client/modeller/joint-edits.ts +66 -5
  12. package/client/modeller/joint-gizmo.ts +180 -0
  13. package/client/modeller/part-edits.ts +75 -9
  14. package/client/modeller/pose-defaults.ts +67 -0
  15. package/client/modeller/proposal.ts +9 -3
  16. package/client/modeller/scene-budget.ts +31 -40
  17. package/client/pages/figure-list-page.ts +4 -9
  18. package/client/pages/figure-modeller-page.ts +144 -43
  19. package/client/types.ts +2 -0
  20. package/dist-client/graphql/index.d.ts +7 -0
  21. package/dist-client/graphql/index.js +21 -0
  22. package/dist-client/graphql/index.js.map +1 -1
  23. package/dist-client/modeller/angle-dial.d.ts +32 -0
  24. package/dist-client/modeller/angle-dial.js +41 -0
  25. package/dist-client/modeller/angle-dial.js.map +1 -0
  26. package/dist-client/modeller/base-box.d.ts +47 -0
  27. package/dist-client/modeller/base-box.js +52 -0
  28. package/dist-client/modeller/base-box.js.map +1 -0
  29. package/dist-client/modeller/figure-animations.d.ts +19 -4
  30. package/dist-client/modeller/figure-animations.js +86 -17
  31. package/dist-client/modeller/figure-animations.js.map +1 -1
  32. package/dist-client/modeller/figure-canvas.d.ts +9 -0
  33. package/dist-client/modeller/figure-canvas.js +20 -6
  34. package/dist-client/modeller/figure-canvas.js.map +1 -1
  35. package/dist-client/modeller/figure-inspector.d.ts +37 -0
  36. package/dist-client/modeller/figure-inspector.js +159 -5
  37. package/dist-client/modeller/figure-inspector.js.map +1 -1
  38. package/dist-client/modeller/figure-preview.d.ts +26 -0
  39. package/dist-client/modeller/figure-preview.js +99 -3
  40. package/dist-client/modeller/figure-preview.js.map +1 -1
  41. package/dist-client/modeller/figure-report.d.ts +11 -6
  42. package/dist-client/modeller/figure-report.js +20 -35
  43. package/dist-client/modeller/figure-report.js.map +1 -1
  44. package/dist-client/modeller/figure-side.d.ts +5 -1
  45. package/dist-client/modeller/figure-side.js +13 -0
  46. package/dist-client/modeller/figure-side.js.map +1 -1
  47. package/dist-client/modeller/figure-source.d.ts +7 -0
  48. package/dist-client/modeller/figure-source.js.map +1 -1
  49. package/dist-client/modeller/joint-edits.d.ts +23 -3
  50. package/dist-client/modeller/joint-edits.js +53 -5
  51. package/dist-client/modeller/joint-edits.js.map +1 -1
  52. package/dist-client/modeller/joint-gizmo.d.ts +34 -0
  53. package/dist-client/modeller/joint-gizmo.js +158 -0
  54. package/dist-client/modeller/joint-gizmo.js.map +1 -0
  55. package/dist-client/modeller/part-edits.d.ts +29 -4
  56. package/dist-client/modeller/part-edits.js +61 -9
  57. package/dist-client/modeller/part-edits.js.map +1 -1
  58. package/dist-client/modeller/pose-defaults.d.ts +9 -0
  59. package/dist-client/modeller/pose-defaults.js +35 -0
  60. package/dist-client/modeller/pose-defaults.js.map +1 -0
  61. package/dist-client/modeller/proposal.d.ts +1 -1
  62. package/dist-client/modeller/proposal.js +8 -3
  63. package/dist-client/modeller/proposal.js.map +1 -1
  64. package/dist-client/modeller/scene-budget.d.ts +31 -34
  65. package/dist-client/modeller/scene-budget.js +28 -37
  66. package/dist-client/modeller/scene-budget.js.map +1 -1
  67. package/dist-client/pages/figure-list-page.js +4 -9
  68. package/dist-client/pages/figure-list-page.js.map +1 -1
  69. package/dist-client/pages/figure-modeller-page.d.ts +50 -0
  70. package/dist-client/pages/figure-modeller-page.js +141 -42
  71. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  72. package/dist-client/tsconfig.tsbuildinfo +1 -1
  73. package/dist-client/types.d.ts +2 -0
  74. package/dist-client/types.js.map +1 -1
  75. package/package.json +5 -5
  76. package/test/ai-proposal-contract.test.ts +16 -11
  77. package/test/angle-dial.test.ts +78 -0
  78. package/test/base-box.test.ts +165 -0
  79. package/test/expect.ts +2 -0
  80. package/test/i18n-prefix-guard.test.ts +17 -0
  81. package/test/joint-edits.test.ts +37 -2
  82. package/test/joint-gizmo.test.ts +174 -0
  83. package/test/joint-needle.test.ts +162 -0
  84. package/test/part-edits.test.ts +100 -0
  85. package/test/pose-defaults.test.ts +71 -0
  86. package/test/scene-budget.test.ts +82 -0
  87. package/translations/en.json +16 -9
  88. package/translations/ja.json +16 -9
  89. package/translations/ko.json +16 -9
  90. package/translations/ms.json +16 -9
  91. package/translations/zh.json +16 -9
@@ -81,6 +81,8 @@ export interface NewFigure {
81
81
  * 찾으므로, 바꾸면 그 도면이 빈 화면이 된다.
82
82
  */
83
83
  export interface FigurePatch {
84
+ /** 발행 전 초안에서만 받는다 — 발행된 뒤에는 문이 거절한다(ADR-0063 결정 4). */
85
+ type?: string;
84
86
  name?: string;
85
87
  description?: string;
86
88
  category?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../client/types.ts"],"names":[],"mappings":"","sourcesContent":["/**\n * 화면이 다루는 모양.\n *\n * 정본 형식(`FigureSource`)의 타입은 **여기서 다시 정의하지 않는다** —\n * `@hatiolab/figure-model` 에서 가져온다. 두 벌이면 갈리고, 갈리면 화면이 서버가\n * 거절할 것을 만들어 낸다.\n */\nexport type { FigureSource, FigureBlueprint, FigurePart, ErrorCode, ViolationCode } from '@hatiolab/figure-model'\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 /** `FigureSource` 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 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 * **정본이 아니다.** 저작자가 보고 받아야 정본이 된다 — 서버는 저장하지 않고\n * 돌려주기만 한다.\n */\nexport interface FigureProposal {\n /** 후보 정본. JSON 문자열이다 — 화면이 파싱한다. */\n source: string\n score: number\n grade: string\n triangles: number\n /** 재질 묶음 = 인스턴스 하나가 무는 draw call. */\n groups: number\n /** Rule-based review of measurable design concerns; it is not an aesthetic verdict. */\n quality: {\n status: 'ready' | 'review'\n summary: string\n findings: Array<{ dimension: string; code: string; message: string }>\n visualRegions: number\n visualCoverage: number\n visualReadability: number\n }\n /** 형식은 맞으나 정책을 넘은 것. 막지 않는다 — 저작자가 정한다. */\n violations: FigureFinding[]\n /** 몇 번 만에 됐나. 진단용이다. */\n attempts: number\n}\n\n/** 사람이 후보를 수락하거나 버릴 때 직접 남긴 현재 저작 세션의 참고 신호. */\nexport interface ProposalFeedback {\n outcome: 'accepted' | 'discarded'\n selectedChanges: number\n totalChanges: number\n note?: string\n grade?: string\n quality?: { status?: 'ready' | 'review'; findingCodes?: string[] }\n}\n\n/**\n * 발행 판정 한 줄.\n *\n * `FigureFinding` 과 달리 **`blocking` 이 있다.** 저장 쪽 발견은 정의상 막지 않는 것들이고,\n * 발행 판정은 막는 것과 알리는 것을 함께 내므로 어느 쪽인지를 값이 스스로 말한다 — 코드를 보고\n * 화면이 다시 분류하면 규칙이 두 벌이 된다.\n */\n/** One reversible anchor edit the sizing gate has measured against this exact source. */\nexport interface FigureGateFix {\n findingWay: string\n part: string\n axis: 'x' | 'y' | 'z'\n from?: string\n to: 'min' | 'center' | 'max' | 'span'\n resolved: number\n /** All blocking sizing cases clear after this exact edit was remeasured server-side. */\n safe: boolean\n}\n\nexport interface FigureGateFinding {\n code: string\n message: string\n /** 왜 그것이 문제인가 — 사람이 무엇을 보게 되나. */\n why?: string\n /** 무엇을 바꾸면 되나 — 부품 이름과 값으로. */\n how?: string\n /** 갈라진 부품 이름들, 또는 정본 안의 경로. */\n at?: string\n /** Candidate edits; the UI must preview and explicitly apply these, never auto-save them. */\n fixes?: FigureGateFix[]\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":"","sourcesContent":["/**\n * 화면이 다루는 모양.\n *\n * 정본 형식(`FigureSource`)의 타입은 **여기서 다시 정의하지 않는다** —\n * `@hatiolab/figure-model` 에서 가져온다. 두 벌이면 갈리고, 갈리면 화면이 서버가\n * 거절할 것을 만들어 낸다.\n */\nexport type { FigureSource, FigureBlueprint, FigurePart, ErrorCode, ViolationCode } from '@hatiolab/figure-model'\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 /** `FigureSource` 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 * **정본이 아니다.** 저작자가 보고 받아야 정본이 된다 — 서버는 저장하지 않고\n * 돌려주기만 한다.\n */\nexport interface FigureProposal {\n /** 후보 정본. JSON 문자열이다 — 화면이 파싱한다. */\n source: string\n score: number\n grade: string\n triangles: number\n /** 재질 묶음 = 인스턴스 하나가 무는 draw call. */\n groups: number\n /** Rule-based review of measurable design concerns; it is not an aesthetic verdict. */\n quality: {\n status: 'ready' | 'review'\n summary: string\n findings: Array<{ dimension: string; code: string; message: string }>\n visualRegions: number\n visualCoverage: number\n visualReadability: number\n }\n /** 형식은 맞으나 정책을 넘은 것. 막지 않는다 — 저작자가 정한다. */\n violations: FigureFinding[]\n /** 몇 번 만에 됐나. 진단용이다. */\n attempts: number\n}\n\n/** 사람이 후보를 수락하거나 버릴 때 직접 남긴 현재 저작 세션의 참고 신호. */\nexport interface ProposalFeedback {\n outcome: 'accepted' | 'discarded'\n selectedChanges: number\n totalChanges: number\n note?: string\n grade?: string\n quality?: { status?: 'ready' | 'review'; findingCodes?: string[] }\n}\n\n/**\n * 발행 판정 한 줄.\n *\n * `FigureFinding` 과 달리 **`blocking` 이 있다.** 저장 쪽 발견은 정의상 막지 않는 것들이고,\n * 발행 판정은 막는 것과 알리는 것을 함께 내므로 어느 쪽인지를 값이 스스로 말한다 — 코드를 보고\n * 화면이 다시 분류하면 규칙이 두 벌이 된다.\n */\n/** One reversible anchor edit the sizing gate has measured against this exact source. */\nexport interface FigureGateFix {\n findingWay: string\n part: string\n axis: 'x' | 'y' | 'z'\n from?: string\n to: 'min' | 'center' | 'max' | 'span'\n resolved: number\n /** All blocking sizing cases clear after this exact edit was remeasured server-side. */\n safe: boolean\n}\n\nexport interface FigureGateFinding {\n code: string\n message: string\n /** 왜 그것이 문제인가 — 사람이 무엇을 보게 되나. */\n why?: string\n /** 무엇을 바꾸면 되나 — 부품 이름과 값으로. */\n how?: string\n /** 갈라진 부품 이름들, 또는 정본 안의 경로. */\n at?: string\n /** Candidate edits; the UI must preview and explicitly apply these, never auto-save them. */\n fixes?: FigureGateFix[]\n blocking: 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.27",
3
+ "version": "10.1.29",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -26,8 +26,8 @@
26
26
  "clean": "npm run clean:server && npm run clean:client"
27
27
  },
28
28
  "dependencies": {
29
- "@hatiolab/figure-model": "^0.1.21",
30
- "@hatiolab/things-scene": "^10.1.14",
29
+ "@hatiolab/figure-model": "^0.1.27",
30
+ "@hatiolab/things-scene": "^10.1.22",
31
31
  "@material/web": "^2.0.0",
32
32
  "@operato/data-grist": "^10.0.0",
33
33
  "@operato/graphql": "^10.0.0",
@@ -37,8 +37,8 @@
37
37
  "@operato/shell": "^10.0.0",
38
38
  "@operato/styles": "^10.0.0",
39
39
  "@operato/utils": "^10.0.0",
40
- "@things-factory/figure-service": "^10.1.27",
40
+ "@things-factory/figure-service": "^10.1.29",
41
41
  "three": "^0.185.1"
42
42
  },
43
- "gitHead": "d04dc762cacf2aac8ba0b0f5d768eca365fc09da"
43
+ "gitHead": "e6bf9dec003fe5e794746aaeb5852b3b50478232"
44
44
  }
@@ -128,21 +128,22 @@ test('피드백은 후보 세션의 선택과 측정값만 전송한다', () =>
128
128
  })
129
129
 
130
130
  /*
131
- * The modeller has one AI entrance now: the toolbar button opens the asidebar dock, and the dock
132
- * runs the whole exchange. `figure-ask` — a second, inline form that called `proposeFigure`
133
- * directly — was removed on 2026-09-16. It had already stopped being rendered, so it was dead
134
- * code a reader would still have to understand before touching this path.
131
+ * The modeller opens no AI door of its own. The application header already has one and it opens the
132
+ * same dock, and two doors to one room on one screen make a reader look for the difference between
133
+ * them (2026-09-18, on the author's reading of the screen). `figure-ask` — a second, inline form
134
+ * that called `proposeFigure` directly — went on 2026-09-16.
135
135
  *
136
- * What the entrance owes the author is unchanged and checked here: it must not save anything by
137
- * itself, and it must hand the server the current source and palette so the candidate is a
138
- * revision of what is open rather than something invented from the prompt alone.
136
+ * What the page owes is unchanged and checked here: when the dock asks, it answers with the source
137
+ * as it stands right now — unsaved edits included — so the candidate is a revision of what is open
138
+ * rather than something invented from the prompt alone. And it saves nothing by itself.
139
139
  */
140
- test('the modeller has exactly one AI entrance, and it opens the dock', () => {
140
+ test('모델러는 AI 문을 스스로 두지 않고, 물으면 지금 정본을 답한다', () => {
141
141
  const path = fileURLToPath(new URL('../client/pages/figure-modeller-page.ts', import.meta.url))
142
142
  const source = readFileSync(path, 'utf8')
143
143
 
144
- assert.match(source, /requestFigureAI\(\{ figureId: this\.figure\?\.id, source: this\.folded \}\)/)
145
- assert.match(source, /openOverlay\('figure-ai-dock'/)
144
+ assert.doesNotMatch(source, /openOverlay\('figure-ai-dock'/, '앱 머리줄에 이미 있는 문을 여기 또 두지 않는다')
145
+ assert.match(source, /addEventListener\('figure-ai-context-request'/)
146
+ assert.match(source, /detail: \{ figureId: this\.figure\?\.id, source: this\.folded \}/)
146
147
  assert.doesNotMatch(source, /<figure-ask/, 'the inline ask form is gone; the dock is the entrance')
147
148
  assert.doesNotMatch(source, /proposeFigure\(/, 'the page must not call the propose mutation itself')
148
149
  })
@@ -568,7 +569,11 @@ test('구조 · 저작 가능한 모든 칸은 검토를 지난다 — 조용히
568
569
  styleKit: ['plain', 'factory'],
569
570
  animations: [undefined, animationOnly().animations],
570
571
  parameters: [undefined, parameterOnly().parameters],
571
- capabilities: [['Holdable'], ['Holdable', 'FlowNode']]
572
+ capabilities: [['Holdable'], ['Holdable', 'FlowNode']],
573
+ joints: [
574
+ undefined,
575
+ [{ name: 'motor-joint', child: 'motor', type: 'revolute', origin: { x: 0, y: 50, z: 0 }, axis: { x: 0, y: 1, z: 0 }, limits: { min: -90, max: 90 } }]
576
+ ]
572
577
  }
573
578
 
574
579
  const unsampled = mustBeReviewable.filter(f => !(f in sample))
@@ -0,0 +1,78 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import { readFileSync } from 'node:fs'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ import { dialShape } from '../client/modeller/angle-dial.ts'
8
+
9
+ /*
10
+ * 막대 옆의 각도 눈.
11
+ *
12
+ * 관절이 돌았다는 것을 3D 안에서만 보이려 했다가 잰 값에 막혔다 — 미리보기의 부품에 단 바늘이
13
+ * 4 × 2 픽셀이었다. 여덟 대를 한 화면에 세우는 자리라 한 대가 20 픽셀쯤이고, 그 안의 표시는
14
+ * 무엇이든 몇 픽셀을 못 넘는다. 그래서 각은 3D 의 배율에서 떼어 이 눈이 읽는다.
15
+ */
16
+
17
+ const near = (got: number, want: number, why: string) => assert.ok(Math.abs(got - want) < 0.05, `${why}: ${got}`)
18
+
19
+ describe('각도 눈', () => {
20
+ it('0 도는 위를 가리킨다', () => {
21
+ const dial = dialShape(-90, 90, 0)
22
+
23
+ near(dial.needle.x, dial.centre.x, '가운데에서 안 벗어난다')
24
+ near(dial.needle.y, dial.centre.y - dial.radius, '위로 반지름만큼')
25
+ })
26
+
27
+ it('양수는 시계 방향이다 — 화면의 각과 같은 쪽', () => {
28
+ const dial = dialShape(-180, 180, 90)
29
+
30
+ near(dial.needle.x, dial.centre.x + dial.radius, '오른쪽')
31
+ near(dial.needle.y, dial.centre.y, '높이는 가운데')
32
+ })
33
+
34
+ it('음수는 반대쪽이다', () => {
35
+ const dial = dialShape(-180, 180, -90)
36
+ near(dial.needle.x, dial.centre.x - dial.radius, '왼쪽')
37
+ })
38
+
39
+ it('범위 밖의 값은 범위 끝에 선다 — 밖을 가리키는 바늘은 없는 값을 말한다', () => {
40
+ const over = dialShape(-90, 90, 200)
41
+ const under = dialShape(-90, 90, -200)
42
+
43
+ assert.deepEqual(over.needle, dialShape(-90, 90, 90).needle)
44
+ assert.deepEqual(under.needle, dialShape(-90, 90, -90).needle)
45
+ })
46
+
47
+ it('180 도를 넘는 범위는 큰 호로 그린다 — 안 그러면 부채꼴이 반대쪽으로 패인다', () => {
48
+ const wide = dialShape(-150, 150, 0)
49
+ const narrow = dialShape(-45, 45, 0)
50
+
51
+ assert.ok(/A 9 9 0 1 1/.test(wide.sector), `큰 호: ${wide.sector}`)
52
+ assert.ok(/A 9 9 0 0 1/.test(narrow.sector), `작은 호: ${narrow.sector}`)
53
+ })
54
+
55
+ it('한 바퀴는 원이다 — 부채꼴의 두 변이 겹쳐 그어지지 않는다', () => {
56
+ const full = dialShape(-180, 180, 45)
57
+
58
+ assert.ok(!full.sector.includes('L'), `변을 긋지 않는다: ${full.sector}`)
59
+ assert.equal((full.sector.match(/A /g) ?? []).length, 2, '반원 둘로 원을 만든다')
60
+ })
61
+ })
62
+
63
+ describe('눈이 화면에 붙어 있다', () => {
64
+ const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
65
+
66
+ it('미리보기의 파라미터 줄이 눈을 그린다', () => {
67
+ const preview = read('../client/modeller/figure-preview.ts')
68
+
69
+ assert.ok(/this\.renderDial\(range, value\)/.test(preview), '막대 옆에 있다')
70
+ assert.ok(/dialShape\(range\.min, range\.max, value\)/.test(preview), '값으로 그린다')
71
+ })
72
+
73
+ it('각이 아닌 파라미터에는 안 단다', () => {
74
+ const preview = read('../client/modeller/figure-preview.ts')
75
+
76
+ assert.ok(/range\.unit !== 'deg'/.test(preview), 'deg 만')
77
+ })
78
+ })
@@ -0,0 +1,165 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import { readFileSync } from 'node:fs'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ import { baseFittingParts, baseOf, resizeBase } from '../client/modeller/base-box.ts'
8
+ import { toFigureSource } from '../client/modeller/figure-source.ts'
9
+ import type { FigureDraft, PartModel } from '../client/modeller/figure-source.ts'
10
+
11
+ /*
12
+ * 기준 상자를 고치는 일.
13
+ *
14
+ * 저작면의 자리는 판의 왼쪽 위에서 재고 저장 형식의 자리는 상자 한가운데에서 잰다. 그래서 상자만
15
+ * 바꾸면 **저장되는 부품 자리가 전부 달라진다** — 이 시험이 지키는 것이 그 한 가지다.
16
+ */
17
+
18
+ const DRAFT: FigureDraft = { version: 2, figureType: 'BOX', width: 1000, height: 1000, depth: 500, detailLevel: 'M' }
19
+
20
+ const part = (over: Partial<PartModel> = {}): PartModel => ({
21
+ type: 'figure-part',
22
+ name: 'body',
23
+ primitive: 'cube',
24
+ left: 400,
25
+ top: 400,
26
+ width: 200,
27
+ height: 200,
28
+ zPos: 0,
29
+ depth: 300,
30
+ token: 'palette.primary',
31
+ ...over
32
+ })
33
+
34
+ /** 저장 형식에서 본 부품 자리. 상자를 고쳐도 이 값이 그대로여야 한다. */
35
+ const placed = (draft: FigureDraft, parts: readonly PartModel[]) =>
36
+ toFigureSource(draft, [...parts]).parts.map(one => one.transform.position)
37
+
38
+ describe('기준 상자', () => {
39
+ it('지금 크기를 읽는다', () => {
40
+ assert.deepEqual(baseOf(DRAFT), { width: 1000, height: 1000, depth: 500 })
41
+ })
42
+
43
+ it('상자를 키워도 부품의 저장 자리는 한 값도 안 바뀐다', () => {
44
+ const parts = [part(), part({ name: 'lamp', left: 450, top: 450, width: 100, height: 100, zPos: 300, depth: 60 })]
45
+ const before = placed(DRAFT, parts)
46
+
47
+ const next = resizeBase(DRAFT, parts, { width: 1600, height: 1200, depth: 500 })
48
+
49
+ assert.deepEqual(placed(next.draft, next.parts), before)
50
+ assert.equal(next.draft.width, 1600)
51
+ /* 늘어난 절반만큼 부품이 함께 옮겨 간다 — 저작면의 값은 달라진다. */
52
+ assert.equal(next.parts[0]!.left, 400 + 300)
53
+ assert.equal(next.parts[0]!.top, 400 + 100)
54
+ })
55
+
56
+ it('높이만 바꾸면 부품은 안 움직인다 — 밑면이 바닥이다', () => {
57
+ const parts = [part()]
58
+ const next = resizeBase(DRAFT, parts, { width: 1000, height: 1000, depth: 900 })
59
+
60
+ assert.equal(next.parts, parts, '같은 배열 그대로다')
61
+ assert.equal(next.draft.depth, 900)
62
+ })
63
+
64
+ it('달라질 것이 없으면 받은 것을 그대로 돌려준다', () => {
65
+ const parts = [part()]
66
+ const same = resizeBase(DRAFT, parts, { width: 1000, height: 1000, depth: 500 })
67
+
68
+ assert.equal(same.draft, DRAFT)
69
+ assert.equal(same.parts, parts)
70
+ })
71
+
72
+ it('0 으로 만들 수 없다 — 배율이 0 으로 나뉜다', () => {
73
+ const next = resizeBase(DRAFT, [part()], { width: 0, height: -20, depth: 0 })
74
+ assert.deepEqual(baseOf(next.draft), { width: 1, height: 1, depth: 1 })
75
+ })
76
+
77
+ describe('부품에 맞추기', () => {
78
+ it('부품을 감싸는 가장 작은 상자를 낸다', () => {
79
+ /* 가운데(500,500)에서 가장 먼 모서리가 600 이므로 반지름 100 → 200. 높이는 제일 높은 끝. */
80
+ const fitted = baseFittingParts(DRAFT, [part()])
81
+ assert.deepEqual(fitted, { width: 200, height: 200, depth: 300 })
82
+ })
83
+
84
+ it('치우친 부품은 한가운데를 지키느라 반대쪽에 여유가 남는다', () => {
85
+ /*
86
+ 부품을 가운데로 끌어오지 않는다. 끌어오면 저작자가 놓은 자리가 소리 없이 달라지고,
87
+ 발행된 도형에서는 문의 자리가 함께 움직인다.
88
+ */
89
+ const off = part({ left: 700, top: 400, width: 200, height: 200 })
90
+ const fitted = baseFittingParts(DRAFT, [off])!
91
+
92
+ assert.equal(fitted.width, 800, '가운데에서 900 까지가 400 이므로 양쪽으로 800')
93
+ const next = resizeBase(DRAFT, [off], fitted)
94
+ assert.deepEqual(placed(next.draft, next.parts), placed(DRAFT, [off]))
95
+ })
96
+
97
+ it('부품이 없으면 잴 것이 없다', () => {
98
+ assert.equal(baseFittingParts(DRAFT, []), undefined)
99
+ })
100
+
101
+ it('맞춘 상자 안에서는 부품이 상자를 넘지 않는다', () => {
102
+ const parts = [part(), part({ name: 'arm', left: 300, top: 250, width: 120, height: 500, zPos: 200, depth: 420 })]
103
+ const fitted = baseFittingParts(DRAFT, parts)!
104
+ const next = resizeBase(DRAFT, parts, fitted)
105
+ const source = toFigureSource(next.draft, [...next.parts])
106
+
107
+ for (const one of source.parts) {
108
+ const { position, size } = one.transform
109
+ assert.ok(Math.abs(position.x) + size.x / 2 <= source.base.x / 2 + 0.001, `${one.name} x`)
110
+ assert.ok(Math.abs(position.z) + size.z / 2 <= source.base.z / 2 + 0.001, `${one.name} z`)
111
+ assert.ok(position.y + size.y / 2 <= source.base.y + 0.001, `${one.name} y`)
112
+ }
113
+ })
114
+ })
115
+ })
116
+
117
+ /*
118
+ * 셈이 맞아도 **화면에 안 붙어 있으면** 저작자는 아무것도 못 본다. 이 묶음은 붙어 있는지를 본다 —
119
+ * 이 파일의 다른 시험들이 전부 초록인 채로 칸이 사라질 수 있는 자리다.
120
+ */
121
+ describe('화면에 붙어 있다', () => {
122
+ const source = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
123
+
124
+ it('검사기에 기준 상자 칸과 「부품에 맞추기」가 있다', () => {
125
+ const inspector = source('../client/modeller/figure-inspector.ts')
126
+
127
+ assert.ok(/figure\.label\.base-box/.test(inspector), '칸 이름')
128
+ assert.ok(/this\.resizeBase\(\{ width: value \}\)/.test(inspector), 'X 가 가로를 고친다')
129
+ assert.ok(/this\.resizeBase\(\{ depth: value \}\)/.test(inspector), 'Y 가 높이를 고친다')
130
+ assert.ok(/this\.fitBaseToParts\(\)/.test(inspector), '부품에 맞추기')
131
+ assert.ok(/new CustomEvent\('resize-base'/.test(inspector), '초안과 부품을 함께 올린다')
132
+ })
133
+
134
+ it('초안과 부품이 한 걸음으로 기록된다 — 한 번 되돌리면 둘 다 돌아온다', () => {
135
+ const page = source('../client/pages/figure-modeller-page.ts')
136
+
137
+ assert.ok(/@resize-base=\$\{\(e: CustomEvent\) => this\.resizeBase\(e\.detail\.draft, e\.detail\.parts\)\}/.test(page))
138
+ assert.ok(/private resizeBase\(draft: FigureDraft, parts: PartModel\[\]\) \{\s*\n\s*this\.recordHistory\('base'\)/.test(page))
139
+ })
140
+
141
+ it('발행 전에는 타입 칸이 열려 있다 — 기계가 지은 이름을 고칠 자리가 있어야 한다', () => {
142
+ const inspector = source('../client/modeller/figure-inspector.ts')
143
+ const side = source('../client/modeller/figure-side.ts')
144
+ const page = source('../client/pages/figure-modeller-page.ts')
145
+
146
+ /* 판 번호가 0 이면 고칠 수 있다. 「저장됐나」로 가르면 저장하는 순간 굳는다. */
147
+ assert.ok(/figureVersion \?\? 0\) > 0/.test(inspector), '판 번호로 가른다')
148
+ assert.ok(/<figure-inspector[\s\S]{0,200}\.figureVersion=/.test(side), '판 번호가 검사기까지 온다')
149
+ assert.ok(/type: this\.figure\.type/.test(page), '저장할 때 행의 타입도 함께 보낸다')
150
+ })
151
+
152
+ it('발행된 도형을 고치는 동안 그 자리에서 말한다', () => {
153
+ const page = source('../client/pages/figure-modeller-page.ts')
154
+ const inspector = source('../client/modeller/figure-inspector.ts')
155
+
156
+ /* 견줄 상대(직전 발행판)를 가져오고, 고칠 때마다 다시 잰다. */
157
+ assert.ok(/fetchReleasedSource\(id\)/.test(page), '직전 발행판을 읽는다')
158
+ assert.ok(/contractChanges\(this\.releasedSource, this\.folded\)/.test(page), '고치는 동안 견준다')
159
+ assert.ok(/\.contract=\$\{this\.contract\}/.test(page), '화면으로 내려보낸다')
160
+
161
+ /* 기준 상자에 대한 것은 그 칸 아래에서 말한다. */
162
+ assert.ok(/renderBaseContract\(\)/.test(inspector))
163
+ assert.ok(/base-ratio-changed|base-size-changed/.test(inspector))
164
+ })
165
+ })
package/test/expect.ts CHANGED
@@ -29,6 +29,7 @@ export interface Expectation {
29
29
  toBeUndefined(): void
30
30
  toBeGreaterThan(expected: number): void
31
31
  toBeLessThan(expected: number): void
32
+ toBeGreaterThanOrEqual(expected: number): void
32
33
  toBeLessThanOrEqual(expected: number): void
33
34
  }
34
35
 
@@ -50,6 +51,7 @@ export function expect(actual: any): Expectation {
50
51
  toBeUndefined: () => assert.strictEqual(actual, undefined),
51
52
  toBeGreaterThan: expected => assert.ok(actual > expected, `${actual} is not greater than ${expected}`),
52
53
  toBeLessThan: expected => assert.ok(actual < expected, `${actual} is not less than ${expected}`),
54
+ toBeGreaterThanOrEqual: expected => assert.ok(actual >= expected, `${actual} is not >= ${expected}`),
53
55
  toBeLessThanOrEqual: expected => assert.ok(actual <= expected, `${actual} is not <= ${expected}`)
54
56
  }
55
57
  }
@@ -105,6 +105,23 @@ test('통째로 부르는 키가 번역 파일에 있다 — 없으면 화면에
105
105
  assert.deepEqual(missing, [], `부르는 키가 번역에 없다:\n ${missing.join('\n ')}`)
106
106
  })
107
107
 
108
+ test('★ 한국어를 defaultValue 로 들고 있지 않다 — 번역 파일을 지나지 않는 문구가 된다', () => {
109
+ const hard: string[] = []
110
+ for (const path of FILES) {
111
+ const text = readFileSync(path, 'utf-8')
112
+ for (const match of text.matchAll(/defaultValue:\s*(['"`])([^'"`]*)\1/g)) {
113
+ if (/[가-힣]/.test(match[2]!)) hard.push(`${path.slice(CLIENT.length + 1)} ${match[2]}`)
114
+ }
115
+ }
116
+
117
+ /*
118
+ `t(key, { defaultValue })` 는 키가 없을 때 그 값을 그대로 쓴다. 한국어를 적어 두면 다른
119
+ 언어에서도 그 한국어가 뜨고, 번역 파일에 키를 넣어도 아무도 그 자리를 다시 안 본다.
120
+ ADR-0049 ②.
121
+ */
122
+ assert.deepEqual(hard, [], `한국어 defaultValue:\n ${hard.join('\n ')}`)
123
+ })
124
+
108
125
  /*
109
126
  이어 붙이는 자리는 값을 코드에서 읽어야 알 수 있어 여기서 전수 검사하지 않는다. 대신 **통이
110
127
  갈린 짝**을 못 박는다 — 사다리 이름은 `label`, 그 설명은 `text` 다. 한 번 어긋났고, 그때
@@ -42,8 +42,8 @@ describe('joint edits', () => {
42
42
  assert.deepEqual(parentChoices(PARTS, 'tip'), ['base', 'arm'])
43
43
  })
44
44
 
45
- it('adds a joint at the bottom face of the part, with a parameter that drives it', () => {
46
- const draft = setJointType(DRAFT, figurePart(DRAFT, 'arm'), 'revolute')
45
+ it('adds a joint where the part meets its parent, with a parameter that drives it', () => {
46
+ const draft = setJointType(DRAFT, figurePart(DRAFT, 'arm'), 'revolute', figurePart(DRAFT, 'base'))
47
47
  const joint = jointOf(draft, 'arm')!
48
48
  assert.deepEqual(joint, {
49
49
  name: 'arm-joint',
@@ -120,6 +120,41 @@ describe('joint edits', () => {
120
120
  assert.deepEqual(errorsOf(cleared), [])
121
121
  })
122
122
 
123
+ /*
124
+ * 관절은 두 부품이 **맞닿는 자리**에 선다. 위로 쌓은 부품에서는 아랫면이지만, 옆에 달린
125
+ * 부품에서는 옆면이다 — 그 구분이 없으면 캐비닛 문의 축이 문 바닥에 생긴다.
126
+ */
127
+ describe('새 관절이 서는 자리', () => {
128
+ const at = (over: Partial<PartModel>): PartModel => ({ ...part('door', 0, 100), ...over })
129
+
130
+ it('위에 쌓인 부품은 아랫면에서 만난다', () => {
131
+ /* arm 은 base(0..100) 위 100..500 이다. 맞닿는 면이 100 이고, 가로로는 둘이 겹치는 가운데다. */
132
+ const joint = jointOf(setJointType(DRAFT, figurePart(DRAFT, 'arm'), 'revolute', figurePart(DRAFT, 'base')), 'arm')!
133
+ assert.deepEqual(joint.origin, { x: 0, y: 100, z: 0 })
134
+ })
135
+
136
+ it('옆에 달린 부품은 옆면에서 만난다 — 아랫면이 아니다', () => {
137
+ /* 문은 몸통(가운데 0, 폭 100)의 오른쪽에 붙어 선다. 축은 문의 왼쪽 면, 높이는 겹치는 가운데. */
138
+ const body = at({ name: 'body', left: 0, top: 0, width: 100, height: 100, zPos: 0, depth: 400 })
139
+ const door = at({ name: 'door', left: 100, top: 0, width: 20, height: 100, zPos: 40, depth: 320, parent: 'body' })
140
+ const parts = [body, door]
141
+ const draft: FigureDraft = { ...DRAFT, width: 400, height: 400, depth: 400 }
142
+ const shown = (one: PartModel) => partToFigure(one, draft)
143
+
144
+ const joint = jointOf(setJointType(draft, shown(door), 'revolute', shown(body)), 'door')!
145
+ const half = draft.width / 2
146
+
147
+ assert.equal(joint.origin.x, 100 - half, '문의 왼쪽 면에 선다')
148
+ assert.equal(joint.origin.y, 200, '겹치는 높이(40..360 과 0..400)의 한가운데다')
149
+ assert.deepEqual(parts.map(one => one.name), ['body', 'door'])
150
+ })
151
+
152
+ it('붙은 부품이 없으면 아랫면이다 — 그때 맞닿는 것은 바닥이다', () => {
153
+ const joint = jointOf(setJointType(DRAFT, figurePart(DRAFT, 'base'), 'revolute'), 'base')!
154
+ assert.equal(joint.origin.y, 0)
155
+ })
156
+ })
157
+
123
158
  it('a joint survives opening and saving', () => {
124
159
  const turning = setJointType(DRAFT, figurePart(DRAFT, 'arm'), 'revolute')
125
160
  const saved = toFigureSource(turning, PARTS)
@@ -0,0 +1,174 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import type { FigureJoint } from '@hatiolab/figure-model'
5
+
6
+ import { axisName, gizmoPart, JOINT_GIZMO } from '../client/modeller/joint-gizmo.ts'
7
+ import type { FigureDraft, PartModel } from '../client/modeller/figure-source.ts'
8
+
9
+ /*
10
+ * 관절이 도는 자리를 저작면에 그리는 부채꼴.
11
+ *
12
+ * 저작자가 축을 고를 때 보는 것이라, **잘못 그리면 틀린 쪽을 가리킨다** — 아무것도 안 그리는
13
+ * 것보다 나쁘다. 그래서 그릴 수 없는 축은 그리지 않는다.
14
+ */
15
+
16
+ const DRAFT: FigureDraft = { version: 2, figureType: 'ARM', width: 400, height: 400, depth: 1200, detailLevel: 'M' }
17
+
18
+ const ARM: PartModel = {
19
+ type: 'figure-part',
20
+ name: 'arm',
21
+ primitive: 'cube',
22
+ left: 150,
23
+ top: 150,
24
+ width: 100,
25
+ height: 100,
26
+ zPos: 200,
27
+ depth: 500,
28
+ token: 'palette.primary'
29
+ }
30
+
31
+ const joint = (over: Partial<FigureJoint> = {}): FigureJoint => ({
32
+ name: 'arm-joint',
33
+ child: 'arm',
34
+ type: 'revolute',
35
+ origin: { x: 0, y: 200, z: 0 },
36
+ axis: { x: 0, y: 1, z: 0 },
37
+ limits: { min: -90, max: 90 },
38
+ ...over
39
+ })
40
+
41
+ /** 부채꼴 점들이 도는 각. 0 도에서 시작해 어디까지 벌어졌나. */
42
+ function angles(path: { x: number; y: number }[]) {
43
+ const arc = path.filter(point => Math.hypot(point.x, point.y) > 1)
44
+ return arc.map(point => Math.round((Math.atan2(point.y, point.x) * 180) / Math.PI))
45
+ }
46
+
47
+ describe('관절 부채꼴', () => {
48
+ it('축 벡터를 이름으로 옮긴다', () => {
49
+ assert.equal(axisName({ x: 0, y: 1, z: 0 }), '+y')
50
+ assert.equal(axisName({ x: -1, y: 0, z: 0 }), '-x')
51
+ assert.equal(axisName({ x: 0, y: 0, z: 1 }), '+z')
52
+ })
53
+
54
+ it('비스듬한 축은 이름이 없고, 그리지도 않는다 — 아무 쪽이나 고르면 틀린 쪽을 가리킨다', () => {
55
+ const slanted = { x: 0.7, y: 0.7, z: 0 }
56
+ assert.equal(axisName(slanted), undefined)
57
+ assert.equal(gizmoPart(joint({ axis: slanted }), ARM, DRAFT), undefined)
58
+ })
59
+
60
+ it('가동 범위만큼 벌어지고, 가운데 점이 하나 있다', () => {
61
+ const part = gizmoPart(joint(), ARM, DRAFT)!
62
+ const path = part.path!
63
+
64
+ assert.equal(part.name, JOINT_GIZMO)
65
+ assert.deepEqual(path[0], { x: 0, y: 0 }, '부채꼴은 가운데에서 시작한다')
66
+
67
+ const turned = angles(path)
68
+ const span = Math.abs(turned[turned.length - 1]! - turned[0]!)
69
+ assert.equal(span, 180, '−90 에서 90 까지면 180 도 벌어진다')
70
+ })
71
+
72
+ it('무한 회전은 온전한 원이라 가운데 점이 없다', () => {
73
+ const part = gizmoPart(joint({ type: 'continuous', limits: undefined }), ARM, DRAFT)!
74
+ const path = part.path!
75
+
76
+ assert.ok(path.every(point => Math.hypot(point.x, point.y) > 1), '가운데 점이 없다')
77
+ assert.ok(path.length >= 60, '한 바퀴를 6도씩 그린다')
78
+ })
79
+
80
+ it('반지름은 도는 부품의 크기를 따라간다 — 부품보다 작으면 무엇이 도는지 안 읽힌다', () => {
81
+ const big = gizmoPart(joint(), ARM, DRAFT)!
82
+ const small = gizmoPart(joint(), { ...ARM, width: 40, height: 40, depth: 40 }, DRAFT)!
83
+
84
+ assert.equal(big.width, 2 * Math.round(500 * 0.6))
85
+ assert.equal(small.width, 2 * 60, '작은 부품에도 눈에 보이는 최소 크기가 있다')
86
+ })
87
+
88
+ it('관절의 기준점에 선다', () => {
89
+ const part = gizmoPart(joint({ origin: { x: 50, y: 300, z: -20 } }), ARM, DRAFT)!
90
+ /* 저작면은 왼쪽 위 기준이고 z 는 밑면이다. 기준점이 가운데로 가야 맞다. */
91
+ assert.equal(part.left + part.width / 2, DRAFT.width / 2 + 50)
92
+ assert.equal(part.top + part.height / 2, DRAFT.height / 2 - 20)
93
+ assert.equal(part.zPos + part.depth / 2, 300)
94
+ })
95
+
96
+ it('저작면에만 있고, 씬이 집지 않는다', () => {
97
+ const part = gizmoPart(joint(), ARM, DRAFT)!
98
+ assert.equal(part.locked, true)
99
+ assert.equal(part.transparent, true)
100
+ })
101
+
102
+ /*
103
+ * 가리키는 쪽이 맞나 — **이것이 이 부채꼴의 전부다.**
104
+ *
105
+ * 반대로 벌어진 부채꼴은 없느니만 못하다. 저작자가 그것을 보고 축을 고르기 때문이다. 축마다
106
+ * 씬의 회전과 단면의 각이 도는 쪽이 갈려서, 여섯 축을 하나씩 런타임이 도는 식(로드리게스)과
107
+ * 맞대 본다.
108
+ */
109
+ describe('벌어진 쪽이 실제로 도는 쪽과 같다', () => {
110
+ const AXES: [string, { x: number; y: number; z: number }][] = [
111
+ ['+x', { x: 1, y: 0, z: 0 }],
112
+ ['-x', { x: -1, y: 0, z: 0 }],
113
+ ['+y', { x: 0, y: 1, z: 0 }],
114
+ ['-y', { x: 0, y: -1, z: 0 }],
115
+ ['+z', { x: 0, y: 0, z: 1 }],
116
+ ['-z', { x: 0, y: 0, z: -1 }]
117
+ ]
118
+
119
+ /** 로드리게스 — 런타임이 관절을 돌리는 식(`setFromAxisAngle`)과 같은 회전이다. */
120
+ function turned(v: { x: number; y: number; z: number }, axis: { x: number; y: number; z: number }, degrees: number) {
121
+ const t = (degrees * Math.PI) / 180
122
+ const c = Math.cos(t)
123
+ const s = Math.sin(t)
124
+ const dot = axis.x * v.x + axis.y * v.y + axis.z * v.z
125
+ const cross = {
126
+ x: axis.y * v.z - axis.z * v.y,
127
+ y: axis.z * v.x - axis.x * v.z,
128
+ z: axis.x * v.y - axis.y * v.x
129
+ }
130
+ return {
131
+ x: v.x * c + cross.x * s + axis.x * dot * (1 - c),
132
+ y: v.y * c + cross.y * s + axis.y * dot * (1 - c),
133
+ z: v.z * c + cross.z * s + axis.z * dot * (1 - c)
134
+ }
135
+ }
136
+
137
+ /** 단면의 점을 부품 회전까지 태워 figure 좌표로. 단면은 부품의 x–z 평면에 눕는다. */
138
+ function inFigure(part: PartModel, point: { x: number; y: number }) {
139
+ const v = { x: point.x, y: 0, z: point.y }
140
+ const rx = part.rotationX ?? 0
141
+ const rz = part.rotationY ?? 0
142
+ const afterZ = { x: v.x * Math.cos(rz) - v.y * Math.sin(rz), y: v.x * Math.sin(rz) + v.y * Math.cos(rz), z: v.z }
143
+ return { x: afterZ.x, y: afterZ.y * Math.cos(rx) - afterZ.z * Math.sin(rx), z: afterZ.y * Math.sin(rx) + afterZ.z * Math.cos(rx) }
144
+ }
145
+
146
+ for (const [name, axis] of AXES) {
147
+ it(`${name} 축`, () => {
148
+ const limits = { min: -90, max: 90 }
149
+ const part = gizmoPart(joint({ axis, limits }), ARM, DRAFT)!
150
+ /* 가운데 점 하나에 호가 이어진다. 한 바퀴가 아니면 늘 그렇다. */
151
+ const arc = part.path!.slice(1)
152
+ const at = (degrees: number) =>
153
+ inFigure(part, arc[Math.round(((degrees - limits.min) / (limits.max - limits.min)) * (arc.length - 1))]!)
154
+
155
+ /* 조각이 6 도씩이라 48 도가 점 하나에 정확히 떨어진다 — 사이 값을 쓰면 반올림이 섞인다. */
156
+ const zero = at(0)
157
+ const swung = at(48)
158
+ const runtime = turned(zero, axis, 48)
159
+ const flipped = turned(zero, axis, -48)
160
+
161
+ const off = Math.hypot(swung.x - runtime.x, swung.y - runtime.y, swung.z - runtime.z)
162
+ const offFlipped = Math.hypot(swung.x - flipped.x, swung.y - flipped.y, swung.z - flipped.z)
163
+
164
+ assert.ok(off < 0.5, `${name}: 부채꼴이 ${off.toFixed(1)}mm 어긋난다 — 반대로 재면 ${offFlipped.toFixed(1)}mm`)
165
+ })
166
+ }
167
+ })
168
+
169
+ it('수평축 관절은 눕는다 — 도는 평면이 세로다', () => {
170
+ const part = gizmoPart(joint({ axis: { x: 1, y: 0, z: 0 } }), ARM, DRAFT)!
171
+ /* 씬은 라디안을 쓴다. z 90 도가 `rotationY` 로 간다(`figure-source.ts`). */
172
+ assert.equal(Math.round(((part.rotationY ?? 0) * 180) / Math.PI), 90)
173
+ })
174
+ })