@things-factory/figure-ui 10.1.44 → 10.1.46

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 (129) hide show
  1. package/client/modeller/figure-v3-links.ts +394 -0
  2. package/client/modeller/modeller-route.ts +6 -21
  3. package/client/modeller/v3-editor.ts +47 -5
  4. package/client/modeller/v3-gizmo.ts +13 -2
  5. package/client/modeller/v3-links.ts +170 -0
  6. package/client/pages/figure-v3-modeller-page.ts +2 -5
  7. package/client/route.ts +2 -6
  8. package/dist-client/modeller/figure-v3-links.d.ts +47 -0
  9. package/dist-client/modeller/figure-v3-links.js +379 -0
  10. package/dist-client/modeller/figure-v3-links.js.map +1 -0
  11. package/dist-client/modeller/modeller-route.d.ts +4 -11
  12. package/dist-client/modeller/modeller-route.js +5 -22
  13. package/dist-client/modeller/modeller-route.js.map +1 -1
  14. package/dist-client/modeller/v3-editor.d.ts +5 -0
  15. package/dist-client/modeller/v3-editor.js +49 -4
  16. package/dist-client/modeller/v3-editor.js.map +1 -1
  17. package/dist-client/modeller/v3-gizmo.js +11 -1
  18. package/dist-client/modeller/v3-gizmo.js.map +1 -1
  19. package/dist-client/modeller/v3-links.d.ts +67 -0
  20. package/dist-client/modeller/v3-links.js +141 -0
  21. package/dist-client/modeller/v3-links.js.map +1 -0
  22. package/dist-client/pages/figure-v3-modeller-page.d.ts +2 -5
  23. package/dist-client/pages/figure-v3-modeller-page.js +2 -5
  24. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  25. package/dist-client/route.d.ts +1 -1
  26. package/dist-client/route.js +2 -5
  27. package/dist-client/route.js.map +1 -1
  28. package/dist-client/tsconfig.scratch-fm.tsbuildinfo +1 -0
  29. package/dist-client/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +4 -4
  31. package/test/ai-proposal-contract.test.ts +0 -88
  32. package/test/figure-source.test.ts +0 -102
  33. package/test/i18n-prefix-guard.test.ts +0 -24
  34. package/test/modeller-route.test.ts +14 -25
  35. package/test/part-edits.test.ts +0 -7
  36. package/test/scene-budget.test.ts +0 -7
  37. package/test/v3-gizmo.test.ts +10 -0
  38. package/test/v3-links.test.ts +163 -0
  39. package/things-factory.config.js +2 -5
  40. package/translations/en.json +21 -1
  41. package/translations/ja.json +21 -1
  42. package/translations/ko.json +21 -1
  43. package/translations/ms.json +21 -1
  44. package/translations/zh.json +21 -1
  45. package/client/modeller/angle-dial.ts +0 -70
  46. package/client/modeller/base-box.ts +0 -175
  47. package/client/modeller/figure-animations.ts +0 -1249
  48. package/client/modeller/figure-canvas.ts +0 -567
  49. package/client/modeller/figure-capabilities.ts +0 -379
  50. package/client/modeller/figure-inspector.ts +0 -2208
  51. package/client/modeller/figure-parts.ts +0 -479
  52. package/client/modeller/figure-preview.ts +0 -598
  53. package/client/modeller/figure-report.ts +0 -966
  54. package/client/modeller/figure-settings.ts +0 -328
  55. package/client/modeller/figure-side.ts +0 -272
  56. package/client/modeller/figure-thumbnail.ts +0 -123
  57. package/client/modeller/figure-view.ts +0 -152
  58. package/client/modeller/joint-edits.ts +0 -262
  59. package/client/modeller/joint-gizmo.ts +0 -180
  60. package/client/modeller/maturity.ts +0 -288
  61. package/client/modeller/new-figure-entry.ts +0 -4
  62. package/client/modeller/pose-defaults.ts +0 -67
  63. package/client/pages/figure-modeller-page.ts +0 -2082
  64. package/dist-client/modeller/angle-dial.d.ts +0 -32
  65. package/dist-client/modeller/angle-dial.js +0 -41
  66. package/dist-client/modeller/angle-dial.js.map +0 -1
  67. package/dist-client/modeller/base-box.d.ts +0 -71
  68. package/dist-client/modeller/base-box.js +0 -110
  69. package/dist-client/modeller/base-box.js.map +0 -1
  70. package/dist-client/modeller/figure-animations.d.ts +0 -159
  71. package/dist-client/modeller/figure-animations.js +0 -1132
  72. package/dist-client/modeller/figure-animations.js.map +0 -1
  73. package/dist-client/modeller/figure-canvas.d.ts +0 -150
  74. package/dist-client/modeller/figure-canvas.js +0 -506
  75. package/dist-client/modeller/figure-canvas.js.map +0 -1
  76. package/dist-client/modeller/figure-capabilities.d.ts +0 -42
  77. package/dist-client/modeller/figure-capabilities.js +0 -349
  78. package/dist-client/modeller/figure-capabilities.js.map +0 -1
  79. package/dist-client/modeller/figure-inspector.d.ts +0 -329
  80. package/dist-client/modeller/figure-inspector.js +0 -2095
  81. package/dist-client/modeller/figure-inspector.js.map +0 -1
  82. package/dist-client/modeller/figure-parts.d.ts +0 -64
  83. package/dist-client/modeller/figure-parts.js +0 -478
  84. package/dist-client/modeller/figure-parts.js.map +0 -1
  85. package/dist-client/modeller/figure-preview.d.ts +0 -138
  86. package/dist-client/modeller/figure-preview.js +0 -549
  87. package/dist-client/modeller/figure-preview.js.map +0 -1
  88. package/dist-client/modeller/figure-report.d.ts +0 -165
  89. package/dist-client/modeller/figure-report.js +0 -952
  90. package/dist-client/modeller/figure-report.js.map +0 -1
  91. package/dist-client/modeller/figure-settings.d.ts +0 -39
  92. package/dist-client/modeller/figure-settings.js +0 -317
  93. package/dist-client/modeller/figure-settings.js.map +0 -1
  94. package/dist-client/modeller/figure-side.d.ts +0 -78
  95. package/dist-client/modeller/figure-side.js +0 -317
  96. package/dist-client/modeller/figure-side.js.map +0 -1
  97. package/dist-client/modeller/figure-thumbnail.d.ts +0 -3
  98. package/dist-client/modeller/figure-thumbnail.js +0 -95
  99. package/dist-client/modeller/figure-thumbnail.js.map +0 -1
  100. package/dist-client/modeller/figure-view.d.ts +0 -118
  101. package/dist-client/modeller/figure-view.js +0 -78
  102. package/dist-client/modeller/figure-view.js.map +0 -1
  103. package/dist-client/modeller/joint-edits.d.ts +0 -49
  104. package/dist-client/modeller/joint-edits.js +0 -223
  105. package/dist-client/modeller/joint-edits.js.map +0 -1
  106. package/dist-client/modeller/joint-gizmo.d.ts +0 -34
  107. package/dist-client/modeller/joint-gizmo.js +0 -158
  108. package/dist-client/modeller/joint-gizmo.js.map +0 -1
  109. package/dist-client/modeller/maturity.d.ts +0 -55
  110. package/dist-client/modeller/maturity.js +0 -212
  111. package/dist-client/modeller/maturity.js.map +0 -1
  112. package/dist-client/modeller/new-figure-entry.d.ts +0 -2
  113. package/dist-client/modeller/new-figure-entry.js +0 -5
  114. package/dist-client/modeller/new-figure-entry.js.map +0 -1
  115. package/dist-client/modeller/pose-defaults.d.ts +0 -9
  116. package/dist-client/modeller/pose-defaults.js +0 -35
  117. package/dist-client/modeller/pose-defaults.js.map +0 -1
  118. package/dist-client/pages/figure-modeller-page.d.ts +0 -419
  119. package/dist-client/pages/figure-modeller-page.js +0 -2060
  120. package/dist-client/pages/figure-modeller-page.js.map +0 -1
  121. package/test/angle-dial.test.ts +0 -78
  122. package/test/base-box.test.ts +0 -235
  123. package/test/joint-edits.test.ts +0 -165
  124. package/test/joint-gizmo.test.ts +0 -174
  125. package/test/joint-needle.test.ts +0 -162
  126. package/test/modeller-undo-redo.test.ts +0 -33
  127. package/test/new-figure-entry.test.ts +0 -15
  128. package/test/pose-defaults.test.ts +0 -71
  129. package/test/preview-board-depth.test.ts +0 -27
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.44",
3
+ "version": "10.1.46",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -26,7 +26,7 @@
26
26
  "clean": "npm run clean:server && npm run clean:client"
27
27
  },
28
28
  "dependencies": {
29
- "@hatiolab/figure-model": "^0.1.42",
29
+ "@hatiolab/figure-model": "^0.1.48",
30
30
  "@hatiolab/things-scene": "^10.1.26",
31
31
  "@material/web": "^2.0.0",
32
32
  "@operato/data-grist": "^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.44",
40
+ "@things-factory/figure-service": "^10.1.46",
41
41
  "three": "^0.185.1"
42
42
  },
43
- "gitHead": "7c95c4e75e155e51c714409e865d96f317232c23"
43
+ "gitHead": "d3467cde550453f3348fed0ed6c79a12a6ba0db4"
44
44
  }
@@ -137,17 +137,6 @@ test('피드백은 후보 세션의 선택과 측정값만 전송한다', () =>
137
137
  * as it stands right now — unsaved edits included — so the candidate is a revision of what is open
138
138
  * rather than something invented from the prompt alone. And it saves nothing by itself.
139
139
  */
140
- test('모델러는 AI 문을 스스로 두지 않고, 물으면 지금 정본을 답한다', () => {
141
- const path = fileURLToPath(new URL('../client/pages/figure-modeller-page.ts', import.meta.url))
142
- const source = readFileSync(path, 'utf8')
143
-
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 \}/)
147
- assert.doesNotMatch(source, /<figure-ask/, 'the inline ask form is gone; the dock is the entrance')
148
- assert.doesNotMatch(source, /proposeFigure\(/, 'the page must not call the propose mutation itself')
149
- })
150
-
151
140
  test('그림이 붙은 AI 요청은 JSON이 아니라 GraphQL multipart Upload 링크를 탄다', () => {
152
141
  const path = fileURLToPath(new URL('../client/graphql/index.ts', import.meta.url))
153
142
  const source = readFileSync(path, 'utf8')
@@ -156,83 +145,6 @@ test('그림이 붙은 AI 요청은 JSON이 아니라 GraphQL multipart Upload
156
145
  assert.match(propose, /context: \{ hasUpload: !!request\.image \}/)
157
146
  })
158
147
 
159
- test('모델러의 수락·거절 피드백은 다음 인라인 후보 요청에도 같은 Figure 세션에서만 전달한다', () => {
160
- const path = fileURLToPath(new URL('../client/pages/figure-modeller-page.ts', import.meta.url))
161
- const source = readFileSync(path, 'utf8')
162
-
163
- assert.match(source, /new CustomEvent\('figure-ai-feedback'/)
164
- assert.doesNotMatch(source, /<figure-ask/)
165
- assert.match(source, /this\.proposalFeedback = \[\.\.\.this\.proposalFeedback, feedback\]\.slice\(-5\)/)
166
- assert.match(source, /changed\.has\('figure'\).*this\.proposalFeedback = \[\]/s)
167
- })
168
-
169
- test('발행 게이트 수정안은 원본을 바꾸지 않고 후보로만 연다', () => {
170
- const historyPath = fileURLToPath(new URL('../client/modeller/figure-history-panel.ts', import.meta.url))
171
- const modellerPath = fileURLToPath(new URL('../client/pages/figure-modeller-page.ts', import.meta.url))
172
- const sessionPath = fileURLToPath(new URL('../client/modeller/proposal-session.ts', import.meta.url))
173
- const history = readFileSync(historyPath, 'utf8')
174
- const modeller = readFileSync(modellerPath, 'utf8')
175
- const session = readFileSync(sessionPath, 'utf8')
176
-
177
- assert.match(history, /i18next\.t\('figure\.button\.preview-fix'/)
178
- assert.equal(JSON.parse(readFileSync(new URL('../translations/ko.json', import.meta.url), 'utf8'))['figure.button.preview-fix'].startsWith('수정안 미리보기'), true)
179
- assert.match(history, /i18next\.t\('figure\.button\.apply-fix'/)
180
- assert.match(history, /new CustomEvent\('apply-sizing-fix'/)
181
- assert.match(history, /new CustomEvent\('preview-sizing-fix'/)
182
- assert.match(modeller, /@preview-sizing-fix=\$\{\(e: CustomEvent\) => this\.previewSizingFix\(e\.detail\)\}/)
183
- assert.match(modeller, /private previewSizingFix\(fix: FigureGateFix\)/)
184
- assert.match(modeller, /private applySizingFix\(fix: FigureGateFix\)/)
185
- assert.match(modeller, /if \(!fix\.safe \|\| !this\.folded\) return/)
186
- assert.match(modeller, /this\.proposalSession = proposalSessions\.openSizingFix\(this\.folded, fix\)/)
187
- assert.match(modeller, /private take\(\)[\s\S]*applyProposal\(this\.folded, this\.proposalSession\.source, this\.proposalSession\.picked\)[\s\S]*this\.dirty = true/)
188
- assert.match(session, /export function openSizingFix[\s\S]*anchor: \{ \.\.\.\(part\.anchor \?\? \{\}\), \[fix\.axis\]: fix\.to \}/)
189
- })
190
-
191
- test('새 Figure 경로는 MDI에 남아 있는 직전 Figure를 재사용하지 않고 빈 초안을 만든다', () => {
192
- const path = fileURLToPath(new URL('../client/pages/figure-modeller-page.ts', import.meta.url))
193
- const source = readFileSync(path, 'utf8')
194
- const pageUpdated = source.slice(source.indexOf('async pageUpdated'), source.indexOf(' private async load'))
195
- const startNew = source.slice(source.indexOf(' private startNew()'), source.indexOf(' private rename'))
196
-
197
- // resourceId가 없다는 것은 명시적으로 「새 Figure」를 뜻한다.
198
- assert.match(pageUpdated, /if \(!this\.figure\)[\s\S]*this\.startNew\(\)/)
199
- assert.match(pageUpdated, /if \(this\.figure\.id\)[\s\S]*this\.startNew\(\)/)
200
- // 새 초안은 저장 식별자·선택·기존 부품·진단을 모두 초기화한다.
201
- assert.match(startNew, /this\.figure = \{ id: '', type: draftType, name: '' \}/)
202
- assert.match(startNew, /const \{ draft, parts \} = startingModel\(draftType\)/)
203
- assert.match(startNew, /this\.selected = -1/)
204
- assert.match(startNew, /this\.dirty = true/)
205
- assert.match(startNew, /this\.violations = \[\]/)
206
- })
207
-
208
- test('빈 새 Figure는 캔버스의 직전 씬을 폐기하고 안내 상태로 전환한다', () => {
209
- const path = fileURLToPath(new URL('../client/modeller/figure-canvas.ts', import.meta.url))
210
- const source = readFileSync(path, 'utf8')
211
- const standUp = source.slice(source.indexOf(' private standUp()'), source.indexOf(' /**\n * 지금 판 위에'))
212
-
213
- assert.match(standUp, /if \(!draft\) \{[\s\S]*this\.teardown\(\)/)
214
- assert.match(standUp, /if \(this\.parts\.length === 0\) \{[\s\S]*this\.teardown\(\)[\s\S]*canvas-appears-once-a-part-exists/)
215
- })
216
-
217
- test('캔버스는 figureId를 판별에 포함하고 없어진 부품을 씬에서 완전 제거한다', () => {
218
- const canvasPath = fileURLToPath(new URL('../client/modeller/figure-canvas.ts', import.meta.url))
219
- const canvasSource = readFileSync(canvasPath, 'utf8')
220
- const modellerPath = fileURLToPath(new URL('../client/pages/figure-modeller-page.ts', import.meta.url))
221
- const modellerSource = readFileSync(modellerPath, 'utf8')
222
-
223
- // figureId 변경 시 새 씬을 세우도록 signature에 figureId가 포함된다
224
- assert.match(canvasSource, /@property\(\{ type: String \}\) figureId\?: string/)
225
- assert.match(canvasSource, /signature = `\$\{this\.figureId \?\? ''\}:\$\{draft\.width\}x\$\{draft\.height\}x\$\{draft\.depth\}/)
226
-
227
- // 없어진 부품은 root.removeComponent와 component.dispose로 제거된다
228
- assert.match(canvasSource, /root\.removeComponent\?\.\(component\)/)
229
- assert.match(canvasSource, /component\.dispose\?\.\(\)/)
230
-
231
- // 모델러 페이지는 figure-canvas에 figureId를 전달한다
232
- assert.match(modellerSource, /\.figureId=\$\{this\.figure\?\.id \|\| this\.figure\?\.type\}/)
233
- })
234
-
235
-
236
148
  /*
237
149
  * ── The whole round trip, not just one hop ───────────────────────────────────
238
150
  *
@@ -502,112 +502,10 @@ describe('배치는 씬의 좌표 모드가 아니다', () => {
502
502
  )
503
503
  })
504
504
 
505
- it('편집 캔버스가 씬에 배치를 안 싣는다', () => {
506
- const source = read('../client/modeller/figure-canvas.ts')
507
-
508
- assert.doesNotMatch(
509
- source,
510
- /placement:/,
511
- '편집 캔버스가 배치를 씬에 실으면 저작자가 적은 높이의 뜻이 드롭다운마다 바뀐다'
512
- )
513
- })
514
-
515
- it('setPlacement 가 부품을 안 건드린다', () => {
516
- const source = read('../client/modeller/figure-inspector.ts')
517
- const body = source.slice(source.indexOf('private setPlacement'), source.indexOf('private setPlacement') + 500)
518
-
519
- assert.match(body, /detail: \{ draft \}/, '배치는 필드 하나만 쓴다')
520
- assert.doesNotMatch(body, /zPos/, '배치를 바꾸면서 부품 높이를 다시 계산하면 안 된다')
521
- })
522
- })
523
-
524
- describe('미리보기가 높이를 씬에게 맡긴다', () => {
525
- const read = (rel: string) => readFileSync(join(dirname(fileURLToPath(import.meta.url)), rel), 'utf8')
526
- const preview = read('../client/modeller/figure-preview.ts')
527
-
528
- /*
529
- 배치 기준을 높이로 옮기는 규칙은 씬이 갖는다 — `FigureRealObject.effectiveZPos` 가 청사진의
530
- `placement` 와 레이어의 `heights` 로 정한다(ADR-0045). 미리보기는 「도면에 놓았을 때」를
531
- 말하는 자리이므로 도면과 같은 길로 세워야 한다.
532
-
533
- 이 화면이 규칙을 한 벌 더 들고 있었다. 그러면 씬의 규칙이 바뀌어도 이 화면만 옛 답을 내고,
534
- 저작자는 도면에 놓고 나서야 다르다는 것을 안다.
535
- */
536
- it('인스턴스에 zPos 를 적지 않는다 — 적으면 적은 값이 이긴다', () => {
537
- const at = preview.indexOf('const components = PREVIEW_WAYS.map')
538
- const built = preview.slice(at, preview.indexOf('const draft = {', at))
539
-
540
- assert.doesNotMatch(built, /zPos/, '여기서 높이를 정하면 씬이 무엇을 할지가 이 화면에서만 안 보인다')
541
- })
542
-
543
- it('배치를 높이로 옮기는 계산을 들고 있지 않다', () => {
544
- assert.doesNotMatch(preview, /bottomOf|zPosOf/, '규칙이 두 벌이면 갈린다')
545
- })
546
-
547
- it('천장 높이를 씬에 실어 준다 — 값이 사는 자리가 거기다', () => {
548
- assert.match(preview, /heights: \{ floor: 0, ceiling: view\.ceilingHeight \}/)
549
- })
550
-
551
- it('미리보기가 좌표 모드를 보기 설정에서 받는다 — 도형에서가 아니라', () => {
552
- assert.match(preview, /placement: view\.placement/, '보기 설정의 좌표 모드를 씬에 실어야 한다')
553
- assert.doesNotMatch(preview, /placement: (source|toScenePlacement)/, '도형의 배치를 씬 모드로 쓰면 안 된다')
554
- })
555
-
556
- it('미리보기가 보기 설정의 조명·환경을 따른다', () => {
557
- assert.match(preview, /\.\.\.sceneLook\(view\)/, '하드코딩하면 편집 캔버스와 두 화면이 갈린다')
558
- assert.doesNotMatch(preview, /environment: 'studio'/, '환경이 하드코딩으로 남아 있다')
559
- })
560
505
  })
561
506
 
562
507
  describe('보기 설정이 편집 캔버스로 새지 않는다', () => {
563
508
  const read = (rel: string) => readFileSync(join(dirname(fileURLToPath(import.meta.url)), rel), 'utf8')
564
509
 
565
- it('sceneLook 이 좌표 모드와 천장 높이를 뺀다', () => {
566
- const view = read('../client/modeller/figure-view.ts')
567
- assert.match(view, /const \{ placement, ceilingHeight, \.\.\.look \} = view/)
568
- })
569
-
570
- it('편집 캔버스가 rest spread 로 보기 설정을 넘기지 않는다', () => {
571
- /*
572
- 한동안 `const { grid, ..., ...light } = view` 였다. 그러면 설정을 하나 더할 때마다
573
- 그것이 두 화면에 **말없이** 실린다 — 좌표 모드가 바로 그 길로 들어갈 뻔했다.
574
- */
575
- const canvas = read('../client/modeller/figure-canvas.ts')
576
- const fn = canvas.slice(canvas.indexOf('private sceneView'), canvas.indexOf('private sceneView') + 200)
577
-
578
- assert.match(fn, /sceneLook\(/)
579
- assert.doesNotMatch(fn, /\.\.\.light/, '나머지를 통째로 넘기면 새 설정이 조용히 실린다')
580
- })
581
510
  })
582
511
 
583
- /*
584
- * ── 막는 것과 알리는 것을 화면이 갈라야 한다 ──────────────────────────────────
585
- *
586
- * 판정 패널은 위반을 전부 같은 `info` 로 그렸다. 기준 상자 위반 둘이 발행을 막게 된 뒤에도
587
- * 그대로라, 저작자는 다섯 줄의 알림을 보고 **발행을 눌러 보고 나서야** 막힌 것을 안다.
588
- *
589
- * 등급은 형식이 안다(`blocksRelease`). 화면이 제 목록을 들면 코드가 하나 늘 때 조용히
590
- * 「알림」 쪽으로 떨어진다 — 안전하지 않은 쪽으로 기우는 기본값이다.
591
- */
592
- describe('판정 패널이 발행을 막는 것을 갈라 보인다', () => {
593
- const read = (rel: string) => readFileSync(join(dirname(fileURLToPath(import.meta.url)), rel), 'utf8')
594
- const report = read('../client/modeller/figure-report.ts')
595
-
596
- it('등급을 형식에서 받는다 — 제 목록을 들지 않는다', () => {
597
- assert.match(report, /blocksRelease/, '형식이 어느 위반이 막는지 안다')
598
- assert.doesNotMatch(
599
- report,
600
- /'part-outside-base'|'parts-off-placement-face'/,
601
- '화면이 코드 이름을 적으면 형식과 두 벌이 된다'
602
- )
603
- })
604
-
605
- it('막는 것은 다른 아이콘으로 그린다', () => {
606
- assert.match(report, /v\.blocking \? 'block' : 'info'/, '같은 아이콘이면 갈린 뜻이 화면에 안 나온다')
607
- })
608
-
609
- it('얼마나 넘쳤는지 보여 준다', () => {
610
- /* 「상자를 벗어난다」만으로는 2mm 인지 250mm 인지 모르고, 그 둘은 고치는 품이 다르다. */
611
- assert.match(report, /v\.actual/, '넘친 양을 버리면 저작자가 무엇부터 고칠지 못 고른다')
612
- })
613
- })
@@ -122,23 +122,6 @@ test('★ 한국어를 defaultValue 로 들고 있지 않다 — 번역 파일
122
122
  assert.deepEqual(hard, [], `한국어 defaultValue:\n ${hard.join('\n ')}`)
123
123
  })
124
124
 
125
- /*
126
- 이어 붙이는 자리는 값을 코드에서 읽어야 알 수 있어 여기서 전수 검사하지 않는다. 대신 **통이
127
- 갈린 짝**을 못 박는다 — 사다리 이름은 `label`, 그 설명은 `text` 다. 한 번 어긋났고, 그때
128
- 사다리 설명 세 줄이 화면에 키로 떴다. 넓은 앞머리(`figure.text.`)가 미사용 검사를 덮어
129
- 통과시켰다.
130
- */
131
- test('★ 통이 갈린 짝 — 사다리 이름과 설명', () => {
132
- for (const rung of ['none', 'hold', 'flow']) {
133
- assert.ok(`figure.label.rung-${rung}` in KO, `figure.label.rung-${rung} 이 없다`)
134
- assert.ok(`figure.text.rung-${rung}-note` in KO, `figure.text.rung-${rung}-note 이 없다`)
135
- }
136
-
137
- const source = readFileSync(join(CLIENT, 'modeller/figure-capabilities.ts'), 'utf-8')
138
- assert.ok(source.includes('figure.label.rung-${which}'), '사다리 이름을 label 통에서 찾지 않는다')
139
- assert.ok(source.includes('figure.text.rung-${which}-note'), '사다리 설명을 text 통에서 찾지 않는다')
140
- })
141
-
142
125
  /*
143
126
  애니메이션 탭도 열거 값을 키에 이어 붙인다. 사다리와 **같은 모양의 함정**이라 같은 못을 박는다.
144
127
 
@@ -168,13 +151,6 @@ test('★ 열거 값마다 짝이 다 있다 — 경로 · 보간', () => {
168
151
  assert.deepEqual(missing, [], `이어 붙여 부르는 키가 번역에 없다:\n ${missing.join('\n ')}`)
169
152
  })
170
153
 
171
- test('★ 애니메이션 탭이 그 통에서 찾는다 — 이름은 label, 설명은 text', () => {
172
- const source = readFileSync(join(CLIENT, 'modeller/figure-animations.ts'), 'utf-8')
173
-
174
- assert.ok(source.includes("'figure.label.path-' + path"), '경로 이름을 label 통에서 찾지 않는다')
175
- assert.ok(source.includes("'figure.text.path-' + channel.path + '-unit'"), '단위 설명을 text 통에서 찾지 않는다')
176
- })
177
-
178
154
  /*
179
155
  대체 언어(`fallbackLng`)는 **en** 이다 — 클라이언트(`@operato/i18n` 의 config)와 서버
180
156
  (`i18n-base` 의 미들웨어) 양쪽 다.
@@ -1,42 +1,31 @@
1
1
  /*
2
- * **목록이 도형을 그 형식을 고칠 수 있는 모델러로 연다** (2026-09-24, 사용자 지시).
3
- *
4
- * 목록은 모든 도형을 V2 모델러로 보냈다. V3 로 바꾼 복사본에서는 어느 카드를 눌러도 V2 화면이 「Figure를 열지
5
- * 못했습니다 — Cannot read properties of undefined (reading 'x')」로 멈췄고, V3 모델러는 주소를 쳐야만 닿았다.
2
+ * One modeller since ADR-0087 step 5 (2026-09-24, user decision: V2 is not kept). The catalogue, a new figure and
3
+ * an old address all open it.
6
4
  */
7
5
  import { test } from 'node:test'
8
6
  import assert from 'node:assert/strict'
9
7
  import { readFileSync } from 'node:fs'
10
8
  import { fileURLToPath } from 'node:url'
11
9
 
12
- import { isV3Source, modellerPathOf } from '../client/modeller/modeller-route.ts'
10
+ import { modellerPathOf } from '../client/modeller/modeller-route.ts'
13
11
 
14
12
  const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
15
13
 
16
- test('V3 도형은 V3 모델러로, V2 는 V2 로, 새 도형은 V3 로', () => {
17
- assert.equal(modellerPathOf({ id: 'a', format: 'v3' }), 'figure-v3-modeller/a')
18
- assert.equal(modellerPathOf({ id: 'a', format: 'v2' }), 'figure-modeller/a')
19
- assert.equal(modellerPathOf(), 'figure-v3-modeller')
20
- assert.equal(modellerPathOf({ id: 'a', format: null }), 'figure-modeller/a', '서버가 형식을 모르면 V2 화면이 읽지 못한 까닭을 말한다')
14
+ test('every figure and a new one open the one modeller', () => {
15
+ assert.equal(modellerPathOf({ id: 'a' }), 'figure-modeller/a')
16
+ assert.equal(modellerPathOf(), 'figure-modeller')
21
17
  })
22
18
 
23
- test('V3 원본은 자기 판으로 알아본다', () => {
24
- assert.equal(isV3Source(JSON.stringify({ version: 'figure-v3-asset-1' })), true)
25
- assert.equal(isV3Source(JSON.stringify({ version: 2 })), false)
26
- assert.equal(isV3Source('not json'), false)
27
- assert.equal(isV3Source(undefined), false)
19
+ test('the route and the shell table send both addresses to the V3 page, and the V2 page is gone', () => {
20
+ const route = read('../client/route.ts')
21
+ assert.doesNotMatch(route, /figure-modeller-page/)
22
+ const config = read('../things-factory.config.js')
23
+ assert.match(config, /tagname: 'figure-v3-modeller-page', page: 'figure-modeller'/)
24
+ assert.match(config, /tagname: 'figure-v3-modeller-page', page: 'figure-v3-modeller'/)
25
+ assert.doesNotMatch(config, /'figure-modeller-page'/)
28
26
  })
29
27
 
30
- test('목록은 카드와 새 도형 단추 모두 형식으로 고른다', () => {
28
+ test('the catalogue opens figures through the one path', () => {
31
29
  const list = read('../client/pages/figure-list-page.ts')
32
30
  assert.match(list, /navigate\(modellerPathOf\(figure\)\)/)
33
- assert.match(list, /this\.openModeller\(record\)/, '카드는 id 가 아니라 형식을 가진 행을 넘긴다')
34
- assert.doesNotMatch(list, /navigate\(id \? `figure-modeller/)
35
- assert.match(read('../client/graphql/index.ts'), /groups\s+format\s+thumbnailUpdatedAt/, '목록 질의가 형식을 받는다')
36
- })
37
-
38
- test('V2 주소로 온 V3 도형은 V3 모델러로 넘긴다 — 뒤로 가기가 다시 V2 로 돌지 않게 바꿔 넣는다', () => {
39
- const v2 = read('../client/pages/figure-modeller-page.ts')
40
- const load = v2.slice(v2.indexOf('private async load('), v2.indexOf('fromFigureSource(JSON.parse'))
41
- assert.match(load, /if \(isV3Source\(figure\.source\)\) \{\s*navigate\(modellerPathOf\(\{ id, format: 'v3' \}\), true\)\s*return/)
42
31
  })
@@ -89,13 +89,6 @@ describe('씬에서 온 부품을 밀리미터로 맞춘다', () => {
89
89
  expect(roundPart(once)).toEqual(once)
90
90
  })
91
91
 
92
- it('캔버스가 씬의 변경을 받을 때 그것을 지난다 — 안 부르면 위 넷이 전부 초록인 채로 소수가 남는다', () => {
93
- const canvas = readFileSync(fileURLToPath(new URL('../client/modeller/figure-canvas.ts', import.meta.url)), 'utf8')
94
-
95
- assert.ok(/roundPart/.test(canvas), 'import')
96
- assert.ok(/this\.tell\('part-changed', \{ name, part: roundPart\(/.test(canvas), '올려 보내기 전에 맞춘다')
97
- })
98
-
99
92
  it('크기·자리 밖의 값은 건드리지 않는다', () => {
100
93
  const kept = roundPart(part({ name: 'arm', token: 'palette.dark', segments: 12, zPos: 3.4 }))
101
94
  expect(kept.name).toBe('arm')
@@ -66,13 +66,6 @@ describe('한 화면이 감당하는 양', () => {
66
66
  describe('놓는 개수 기준 문구가 화면에 없다', () => {
67
67
  const HERE = fileURLToPath(new URL('.', import.meta.url))
68
68
 
69
- it('보고서가 「N개 기준」 칸을 그리지 않는다', () => {
70
- const report = readFileSync(join(HERE, '../client/modeller/figure-report.ts'), 'utf8')
71
-
72
- assert.ok(!/at-100|at-10k/.test(report), '개수 기준 칸')
73
- assert.ok(!/costOf\(blueprint, \d/.test(report), '개수를 넣어 셈하지 않는다')
74
- })
75
-
76
69
  it('번역에 그 낱말이 남아 있지 않다', () => {
77
70
  const ko = JSON.parse(readFileSync(join(HERE, '../translations/ko.json'), 'utf8')) as Record<string, string>
78
71
  const left = Object.entries(ko).filter(([key]) => /at-100|at-10k/.test(key))
@@ -95,3 +95,13 @@ test('기즈모의 말은 다섯 말 모두에 있다', () => {
95
95
  for (const mode of ['translate', 'rotate', 'scale']) assert.ok(table[`figure.label.gizmo-${mode}`], `${lang}: ${mode}`)
96
96
  }
97
97
  })
98
+
99
+ test('떼어 낸 부품은 다시 옮길 수 있다 — 움직임이 있는 부품은 아니다', () => {
100
+ let asset = probe()
101
+ asset = addV3Box(asset, { id: 'lid', size: { x: 100, y: 20, z: 100 }, position: { x: 0, y: 300, z: 0 } })
102
+ const fastened = applyV3Authoring(asset, { kind: 'attach', part: 'lid', face: 'bottom', to: { part: 'body', face: 'top' } })
103
+ const free = applyV3Authoring(fastened, { kind: 'detach', part: 'lid' })
104
+ assert.equal(gizmoReachOf(free as never, 'lid').translate, true)
105
+ const moving = applyV3Authoring(free, { kind: 'add-motion', part: 'lid', motion: { kind: 'slide', axis: 'x' }, state: { id: 'lid-slide', unit: 'mm', min: 0, max: 100, start: 0 } })
106
+ assert.equal(gizmoReachOf(moving as never, 'lid').translate, false)
107
+ })
@@ -0,0 +1,163 @@
1
+ /*
2
+ * **V3 부품이 무엇에 묶여 있나 — 크기 따르기 · 붙이기 · 움직임** (V3 동등표 ④ ⑤ ⑥, 2026-09-24).
3
+ *
4
+ * 세 칸이 보여 주는 것은 figure-model 명령이 남긴 그래프를 거꾸로 읽은 것이다. 명령을 실제로 적용한 뒤 읽어서,
5
+ * 화면이 명령과 같은 말을 하는지 잰다.
6
+ */
7
+ import { test } from 'node:test'
8
+ import assert from 'node:assert/strict'
9
+
10
+ import { addV3Box, addV3Primitive, applyV3Authoring, createV3Asset } from '@hatiolab/figure-model'
11
+ import type { V3Asset } from '@hatiolab/figure-model'
12
+
13
+ import { acceptsOf, canFasten, canFollow, fastenedTo, followOf, hasInstanceSize, holdsThings, motionOf, placeFacesOf, placeIdFor, placesOn, positionsWorkedOut } from '../client/modeller/v3-links.ts'
14
+
15
+ const pair = (): V3Asset => {
16
+ let asset = createV3Asset('Pair')
17
+ asset = addV3Box(asset, { id: 'plate', size: { x: 600, y: 100, z: 400 }, position: { x: 0, y: 50, z: 0 } })
18
+ asset = addV3Box(asset, { id: 'block', size: { x: 200, y: 60, z: 120 }, position: { x: 0, y: 130, z: 0 } })
19
+ const model = asset.document.model
20
+ for (const axis of ['x', 'y', 'z']) {
21
+ model.inputs.push({ id: `size.${axis}`, unit: 'mm', min: 100, max: 20000, role: 'design' } as never)
22
+ asset.designInputs[`size.${axis}`] = axis === 'y' ? 100 : axis === 'x' ? 600 : 400
23
+ }
24
+ return asset
25
+ }
26
+
27
+ test('크기 — 제 치수 · 인스턴스 크기 · 다른 부품의 치수를 가려 읽는다', () => {
28
+ const asset = pair()
29
+ assert.deepEqual(followOf(asset, 'plate', 'x'), { of: 'own' })
30
+ const byInstance = applyV3Authoring(asset, { kind: 'link-dimension', part: 'plate', axis: 'x', source: { of: 'instance', axis: 'x' } })
31
+ assert.deepEqual(followOf(byInstance, 'plate', 'x'), { of: 'instance', axis: 'x' })
32
+ const byPart = applyV3Authoring(byInstance, { kind: 'link-dimension', part: 'block', axis: 'x', source: { of: 'part', part: 'plate', axis: 'x' }, times: 0.5 })
33
+ assert.deepEqual(followOf(byPart, 'block', 'x'), { of: 'part', part: 'plate', axis: 'x' }, '배율이 끼어도 원천을 찾는다')
34
+ const back = applyV3Authoring(byPart, { kind: 'unlink-dimension', part: 'block', axis: 'x', mm: 300 })
35
+ assert.deepEqual(followOf(back, 'block', 'x'), { of: 'own' }, '떼면 다시 제 치수')
36
+ assert.equal(hasInstanceSize(asset, 'y'), true)
37
+ assert.equal(hasInstanceSize(createV3Asset('Bare'), 'y'), false)
38
+ })
39
+
40
+ test('붙이기 — 누구에게 붙었나, 붙일 수 있는 모양인가', () => {
41
+ const asset = pair()
42
+ assert.equal(fastenedTo(asset, 'block'), null)
43
+ const fastened = applyV3Authoring(asset, { kind: 'attach', part: 'block', face: 'bottom', to: { part: 'plate', face: 'top' } })
44
+ assert.equal(fastenedTo(fastened, 'block'), 'plate')
45
+ assert.equal(fastenedTo(applyV3Authoring(fastened, { kind: 'detach', part: 'block' }), 'block'), null)
46
+ const withDrum = addV3Primitive(asset, { id: 'drum', kind: 'cylinder', dimensions: { radius: 50, length: 100 }, position: { x: 0, y: 50, z: 0 } })
47
+ assert.equal(canFasten(withDrum, 'block'), true)
48
+ assert.equal(canFasten(withDrum, 'drum'), true, '원기둥도 면으로 붙인다(figure-model 0.1.47)')
49
+ assert.equal(canFollow(withDrum, 'drum'), false, '원기둥의 치수 셋은 X·Y·Z 가 아니라 따르기는 상자만')
50
+ })
51
+
52
+ test('움직임 — 미끄러짐 · 돌기, 축, 무엇이 모느냐를 읽는다', () => {
53
+ const asset = pair()
54
+ assert.equal(motionOf(asset, 'block'), undefined)
55
+ const sliding = applyV3Authoring(asset, {
56
+ kind: 'add-motion',
57
+ part: 'block',
58
+ motion: { kind: 'slide', axis: 'x' },
59
+ state: { id: 'travel', unit: 'mm', min: 0, max: 300, start: 0 }
60
+ })
61
+ const motion = motionOf(sliding, 'block')!
62
+ assert.equal(motion.kind, 'slide')
63
+ assert.equal(motion.axis, 'x')
64
+ assert.equal(motion.control?.id, 'travel')
65
+ assert.equal(motion.control?.max, 300)
66
+ const turning = applyV3Authoring(asset, {
67
+ kind: 'add-motion',
68
+ part: 'block',
69
+ motion: { kind: 'turn', axis: 'y' },
70
+ state: { id: 'swing', unit: 'deg', min: -90, max: 90, start: 0 }
71
+ })
72
+ assert.deepEqual([motionOf(turning, 'block')!.kind, motionOf(turning, 'block')!.axis], ['turn', 'y'])
73
+ assert.equal(motionOf(applyV3Authoring(sliding, { kind: 'remove-motion', part: 'block' }), 'block'), undefined)
74
+ })
75
+
76
+ import { readFileSync } from 'node:fs'
77
+ import { fileURLToPath } from 'node:url'
78
+ import { choiceOf } from '../client/modeller/v3-links.ts'
79
+
80
+ const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
81
+
82
+ test('고르개의 값은 지금 따르는 것을 가리킨다', () => {
83
+ assert.equal(choiceOf({ of: 'own' }), 'own')
84
+ assert.equal(choiceOf({ of: 'instance', axis: 'y' }), 'instance:y')
85
+ assert.equal(choiceOf({ of: 'part', part: 'plate', axis: 'x' }), 'part:plate:x')
86
+ })
87
+
88
+ test('편집기는 세 칸의 명령을 세션의 이력 하나로 돌리고, 만날 수 있는 거절을 사람의 말로 한다', () => {
89
+ const editor = read('../client/modeller/v3-editor.ts')
90
+ assert.match(editor, /@v3-command=\$\{\(e: CustomEvent\) => this\.linkCommand\(e\.detail\.action\)\}/)
91
+ assert.match(editor, /this\.session!\.command\(action\)/)
92
+ assert.match(editor, /code === 'DIMENSION_CYCLE'[\s\S]*?figure\.text\.follow-would-loop/)
93
+ assert.match(editor, /code === 'ATTACH_CYCLE'[\s\S]*?figure\.text\.fasten-would-loop/)
94
+ })
95
+
96
+ test('떼면 지금 크기로 고정한다 — 따르던 값을 잃지 않는다', () => {
97
+ const links = read('../client/modeller/figure-v3-links.ts')
98
+ assert.match(links, /kind: 'unlink-dimension', part: this\.partId, axis, mm \}/)
99
+ assert.match(links, /this\.dims\?\.\[AXES\.indexOf\(axis\)\]/)
100
+ })
101
+
102
+ test('세 칸의 말은 다섯 말 모두에 있다', () => {
103
+ const links = read('../client/modeller/figure-v3-links.ts')
104
+ const keys = [...links.matchAll(/'(figure\.[a-z.-]*[a-z])'/g)].map(m => m[1])
105
+ for (const lang of ['ko', 'en', 'ja', 'zh', 'ms']) {
106
+ const table = JSON.parse(read(`../translations/${lang}.json`))
107
+ for (const key of [...keys, 'figure.text.follow-would-loop', 'figure.text.fasten-would-loop', 'figure.label.motion-slide', 'figure.label.motion-turn', 'figure.label.place-grip', 'figure.label.place-seat'])
108
+ assert.ok(table[key], `${lang}: ${key}`)
109
+ }
110
+ })
111
+
112
+ test('도형 크기를 따라 움직이는 자리를 가려 읽고, 여기에 고정하면 사라진다 — 드라이룸 램프의 길', () => {
113
+ let asset = pair()
114
+ asset = addV3Box(asset, { id: 'lamp', size: { x: 40, y: 20, z: 40 }, position: { x: 0, y: 10, z: 0 } })
115
+ const model = asset.document.model
116
+ model.nodes.push({ id: 'lamp.lift', op: 'add@1', args: ['size.y', 'lamp.position.y'], outputs: { value: 'lamp.lift.value' } } as never)
117
+ model.nodes.find(n => n.id === 'lamp.pose')!.args[1] = 'lamp.lift.value'
118
+ assert.deepEqual(positionsWorkedOut(asset, 'lamp'), ['y'])
119
+ assert.deepEqual(positionsWorkedOut(asset, 'plate'), [], '제 숫자로 놓인 부품에는 없다')
120
+ const fixed = applyV3Authoring(asset, { kind: 'fix-position', part: 'lamp', axis: 'y' })
121
+ assert.deepEqual(positionsWorkedOut(fixed, 'lamp'), [])
122
+ assert.equal(fixed.designInputs['lamp.position.y'], 110, '지금 자리 그대로 — 100 + 10')
123
+ })
124
+
125
+ test('자리 — 부품의 면에 둔 집는 자리·놓는 자리를 그 부품에서 읽는다', () => {
126
+ let asset = pair()
127
+ asset = addV3Primitive(asset, { id: 'drum', kind: 'cylinder', dimensions: { radius: 100, length: 300 }, position: { x: 0, y: 400, z: 0 } } as never)
128
+ assert.deepEqual(placeFacesOf(asset, 'block'), ['left', 'right', 'bottom', 'top', 'back', 'front'])
129
+ assert.deepEqual(placeFacesOf(asset, 'drum'), [], '둥근 몸에는 자리를 권하지 않는다 — 명령이 거절할 것이다')
130
+
131
+ assert.equal(placeIdFor(asset, 'block', 'grip'), 'block-grip')
132
+ asset = applyV3Authoring(asset, { kind: 'add-binding', id: placeIdFor(asset, 'block', 'grip'), label: '집는 자리', role: 'grip', part: 'block', face: 'top', accepts: acceptsOf(' BOX, TOTE,, BOX '), capacity: 2 })
133
+ assert.equal(placeIdFor(asset, 'block', 'grip'), 'block-grip-2', '같은 부품에 둘째 자리는 다른 id')
134
+ assert.deepEqual(
135
+ placesOn(asset, 'block').map(p => [p.id, p.role, p.face, p.accepts, p.capacity]),
136
+ [['block-grip', 'grip', 'top', ['BOX', 'TOTE'], 2]]
137
+ )
138
+ assert.deepEqual(placesOn(asset, 'plate'), [], '다른 부품의 자리는 안 섞인다')
139
+ })
140
+
141
+ test('담는 능력이 없는 도형은 자리가 있어도 보드가 아무것도 넘기지 않는다 — 화면이 그것을 말한다', () => {
142
+ const asset = pair()
143
+ assert.equal(holdsThings(asset), false)
144
+ asset.document.capabilities.names = ['Capacity', 'Holdable']
145
+ assert.equal(holdsThings(asset), true)
146
+ asset.document.capabilities.names = ['FlowNode']
147
+ assert.equal(holdsThings(asset), true, 'FlowNode 는 Capacity 를 품는다')
148
+
149
+ const links = read('../client/modeller/figure-v3-links.ts')
150
+ assert.match(links, /holdsThings\(asset\)/)
151
+ assert.match(links, /kind: 'set-capabilities', names/)
152
+ const editor = read('../client/modeller/v3-editor.ts')
153
+ /* A slice and `includes`, not a lazy regex over the whole editor: that hangs when it does not match. */
154
+ const start = editor.indexOf('<figure-v3-links')
155
+ const block = editor.slice(start, editor.indexOf('</figure-v3-links>', start))
156
+ assert.ok(start >= 0 && block.includes('@v3-part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}'), '자리 절의 능력 선언이 편집기에 닿는다')
157
+ })
158
+
159
+ test('받는 종류가 없으면 자리를 만들지 않는다 — 형식에 「아무거나」가 없다', () => {
160
+ const links = read('../client/modeller/figure-v3-links.ts')
161
+ assert.match(links, /\?disabled=\$\{this\.locked \|\| !accepts\.length \|\| !capacityOk\}/)
162
+ assert.deepEqual(acceptsOf(' , ,'), [])
163
+ })
@@ -5,11 +5,8 @@ export default {
5
5
  route,
6
6
  routes: [
7
7
  { tagname: 'figure-list-page', page: 'figure-list' },
8
- { tagname: 'figure-modeller-page', page: 'figure-modeller' },
9
- /*
10
- The V3 modeller under its own name until the catalogue's assets are V3 (ADR-0087 step 5). Without this row
11
- the URL changes and no element is made — the shell builds the DOM from this table, not from route().
12
- */
8
+ /* One modeller since ADR-0087 step 5; the old V3 address is an alias. The shell builds the DOM from this table. */
9
+ { tagname: 'figure-v3-modeller-page', page: 'figure-modeller' },
13
10
  { tagname: 'figure-v3-modeller-page', page: 'figure-v3-modeller' }
14
11
  ],
15
12
  bootstrap
@@ -14,6 +14,7 @@
14
14
  "figure.button.discard": "discard",
15
15
  "figure.button.duplicate-part": "duplicate",
16
16
  "figure.button.fit-base-to-parts": "fit to parts",
17
+ "figure.button.fix-position": "fix here",
17
18
  "figure.button.mirror-x": "mirror X",
18
19
  "figure.button.mirror-z": "mirror Z",
19
20
  "figure.button.move-part-down": "move down",
@@ -37,13 +38,18 @@
37
38
  "figure.button.snap": "snap",
38
39
  "figure.button.take-n-changes": "apply {n}",
39
40
  "figure.label.declared-space": "declared space",
41
+ "figure.label.follows-expression": "set by an expression",
40
42
  "figure.label.gizmo-rotate": "turn",
41
43
  "figure.label.gizmo-scale": "size",
42
44
  "figure.label.gizmo-translate": "move",
45
+ "figure.label.position-worked-out": "moves with the figure size",
43
46
  "figure.label.primitive-rounded-box": "rounded box",
44
47
  "figure.text.catalog-needs-a-save": "can be set once the figure is saved.",
45
48
  "figure.text.declared-space-declared": "declared from the parts.",
46
49
  "figure.text.declared-space-none": "not declared yet.",
50
+ "figure.text.fasten-would-loop": "that part is already fastened to this one, so it cannot be fastened the other way.",
51
+ "figure.text.follow-would-loop": "that dimension already follows this part, so the two cannot follow each other.",
52
+ "figure.text.links-need-a-box": "size following works on box-shaped parts, fastening on boxes and cylinders.",
47
53
  "figure.text.mounting-face-needs-space": "declare the space first to choose one.",
48
54
  "figure.text.part-is-referenced": "'{part}' cannot be deleted: another part is fastened to it or follows its size. Undo that link first.",
49
55
  "figure.text.part-name-invalid": "'{name}' cannot be a part name. Start with a letter and use letters, digits, - and _.",
@@ -559,5 +565,19 @@
559
565
  "figure.label.state-inputs": "state inputs",
560
566
  "figure.label.design-inputs": "design inputs",
561
567
  "figure.text.release-blocked": "Release is blocked — {count}",
562
- "figure.text.release-ready": "Ready to release"
568
+ "figure.text.release-ready": "Ready to release",
569
+ "figure.label.places": "Places",
570
+ "figure.text.places-need-a-box": "A place sits on a face of a box-shaped part.",
571
+ "figure.label.place-grip": "Grip",
572
+ "figure.label.place-seat": "Seat",
573
+ "figure.label.place-holds": "up to {count}",
574
+ "figure.button.remove-place": "Remove place",
575
+ "figure.text.place-explained": "Where a board sets or holds things on this part. When the part moves, what it holds moves with it.",
576
+ "figure.text.place-needs-holding": "This figure does not declare that it holds things, so a board hands nothing to its places.",
577
+ "figure.button.declare-holding": "Make it hold things",
578
+ "figure.label.place-name": "Name",
579
+ "figure.label.place-accepts": "Takes",
580
+ "figure.label.place-capacity": "How many",
581
+ "figure.text.place-accepts-needed": "Name at least one type it takes, separated by commas.",
582
+ "figure.button.add-place": "Add place"
563
583
  }