@things-factory/figure-ui 10.1.203 → 10.1.205

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 (86) hide show
  1. package/client/modeller/canvas.ts +91 -9
  2. package/client/modeller/editor-styles.ts +7 -22
  3. package/client/modeller/editor.ts +176 -145
  4. package/client/modeller/fasten-form.ts +91 -9
  5. package/client/modeller/figure-fasten-dialog.ts +42 -9
  6. package/client/modeller/figure-fasten-drawing.ts +233 -12
  7. package/client/modeller/figure-section-editor.ts +6 -6
  8. package/client/modeller/figure-slot-layout-panel.ts +1 -1
  9. package/client/modeller/gizmo.ts +12 -1
  10. package/client/modeller/links.ts +48 -6
  11. package/client/modeller/section-drawing.ts +7 -7
  12. package/client/modeller/size-drag.ts +112 -0
  13. package/client/modeller/slot-layout-host.ts +3 -3
  14. package/client/modeller/stage-model.ts +6 -9
  15. package/client/pages/figure-modeller-page.ts +1 -4
  16. package/dist-client/modeller/canvas.d.ts +24 -3
  17. package/dist-client/modeller/canvas.js +96 -11
  18. package/dist-client/modeller/canvas.js.map +1 -1
  19. package/dist-client/modeller/editor-styles.js +7 -22
  20. package/dist-client/modeller/editor-styles.js.map +1 -1
  21. package/dist-client/modeller/editor.d.ts +28 -32
  22. package/dist-client/modeller/editor.js +176 -153
  23. package/dist-client/modeller/editor.js.map +1 -1
  24. package/dist-client/modeller/fasten-form.d.ts +36 -3
  25. package/dist-client/modeller/fasten-form.js +86 -8
  26. package/dist-client/modeller/fasten-form.js.map +1 -1
  27. package/dist-client/modeller/figure-fasten-dialog.d.ts +17 -3
  28. package/dist-client/modeller/figure-fasten-dialog.js +47 -10
  29. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  30. package/dist-client/modeller/figure-fasten-drawing.d.ts +33 -1
  31. package/dist-client/modeller/figure-fasten-drawing.js +235 -11
  32. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  33. package/dist-client/modeller/figure-section-editor.d.ts +3 -3
  34. package/dist-client/modeller/figure-section-editor.js +4 -4
  35. package/dist-client/modeller/figure-section-editor.js.map +1 -1
  36. package/dist-client/modeller/figure-slot-layout-panel.js +1 -1
  37. package/dist-client/modeller/figure-slot-layout-panel.js.map +1 -1
  38. package/dist-client/modeller/gizmo.d.ts +12 -1
  39. package/dist-client/modeller/gizmo.js +8 -0
  40. package/dist-client/modeller/gizmo.js.map +1 -1
  41. package/dist-client/modeller/links.d.ts +14 -2
  42. package/dist-client/modeller/links.js +42 -4
  43. package/dist-client/modeller/links.js.map +1 -1
  44. package/dist-client/modeller/section-drawing.d.ts +3 -4
  45. package/dist-client/modeller/section-drawing.js +10 -9
  46. package/dist-client/modeller/section-drawing.js.map +1 -1
  47. package/dist-client/modeller/size-drag.d.ts +38 -0
  48. package/dist-client/modeller/size-drag.js +85 -0
  49. package/dist-client/modeller/size-drag.js.map +1 -0
  50. package/dist-client/modeller/slot-layout-host.d.ts +1 -3
  51. package/dist-client/modeller/slot-layout-host.js +3 -3
  52. package/dist-client/modeller/slot-layout-host.js.map +1 -1
  53. package/dist-client/modeller/stage-model.d.ts +5 -7
  54. package/dist-client/modeller/stage-model.js +6 -6
  55. package/dist-client/modeller/stage-model.js.map +1 -1
  56. package/dist-client/pages/figure-modeller-page.d.ts +0 -2
  57. package/dist-client/pages/figure-modeller-page.js +1 -6
  58. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  59. package/dist-client/tsconfig.tsbuildinfo +1 -1
  60. package/dist-server/tsconfig.tsbuildinfo +1 -1
  61. package/package.json +7 -7
  62. package/test/align.test.ts +7 -11
  63. package/test/browser/acceptance-page.js +10 -0
  64. package/test/browser/editing.check.mjs +17 -13
  65. package/test/browser/links-page.js +2 -2
  66. package/test/browser/links.check.mjs +94 -56
  67. package/test/browser/modeller-page.js +30 -18
  68. package/test/browser/modeller-revision.check.mjs +39 -0
  69. package/test/browser/modeller.check.mjs +32 -10
  70. package/test/browser/screen-page.js +0 -2
  71. package/test/browser/screen.check.mjs +11 -13
  72. package/test/browser/section.check.mjs +2 -2
  73. package/test/fasten-form.test.ts +82 -2
  74. package/test/gizmo.test.ts +13 -15
  75. package/test/links.test.ts +8 -3
  76. package/test/resize-with-placement.test.ts +14 -13
  77. package/test/revision.test.ts +3 -2
  78. package/test/section-editor.test.ts +9 -10
  79. package/test/size-drag.test.ts +114 -0
  80. package/test/slot-layout-host.test.ts +13 -8
  81. package/translations/en.json +25 -22
  82. package/translations/es.json +25 -22
  83. package/translations/ja.json +25 -22
  84. package/translations/ko.json +25 -22
  85. package/translations/ms.json +25 -22
  86. package/translations/zh.json +25 -22
@@ -6,10 +6,10 @@
6
6
  import { test } from 'node:test'
7
7
  import assert from 'node:assert/strict'
8
8
 
9
- import { addFigureBox, applyFigureAuthoring, createFigureAsset } from '@hatiolab/figure-model'
9
+ import { addFigureBox, applyFigureAuthoring, compileFigureAsset, createFigureAsset } from '@hatiolab/figure-model'
10
10
  import type { FigureAsset } from '@hatiolab/figure-model'
11
11
 
12
- import { aimFasten, fastenChanged, fastenDetailWordsOf, fastenFormOf, fastenPlanOf, fastenSentenceOf, fastenTargetsOf, freshFasten } from '../client/modeller/fasten-form.ts'
12
+ import { aimFasten, aimFastenAt, fastenChanged, fastenDetailWordsOf, fastenFormOf, fastenPlanOf, fastenSentenceOf, fastenTargetsOf, freshFasten } from '../client/modeller/fasten-form.ts'
13
13
  import { revisionOf } from './revision-of.ts'
14
14
 
15
15
  const box = (asset: FigureAsset, id: string, [x, y, z]: number[], at = [0, 0, 0]) => addFigureBox(asset, { id, size: { x, y, z }, position: { x: at[0]!, y: at[1]!, z: at[2]! } })
@@ -40,6 +40,25 @@ test('on the plane: a floor takes a part standing on it or hanging below it, a w
40
40
  assert.equal((fastenPlanOf(revisionOf(wall), 'lamp', hung).action as { facing?: string }).facing, undefined, 'a wall: standing out of it')
41
41
  })
42
42
 
43
+ test('aimed at the plane, the way starts toward the side the placement uses: below a ceiling, on a floor, out of a wall', () => {
44
+ const lamp = (placement?: 'ceiling' | 'wall') => {
45
+ const loose = addFigureBox(createFigureAsset('Lamp'), { id: 'lamp', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 0, z: 0 } })
46
+ return revisionOf(placement ? applyFigureAuthoring(loose, { kind: 'set-placement', placement }) : loose)
47
+ }
48
+ const aimed = (r: ReturnType<typeof lamp>) => aimFasten(r, 'lamp', fastenFormOf(r, 'lamp'), { plane: true })
49
+ const ceiling = lamp('ceiling')
50
+ const form = aimed(ceiling)
51
+ assert.equal(form.facing, 'flush', 'a ceiling: hung below it')
52
+ const applied = applyFigureAuthoring(ceiling.asset, fastenPlanOf(ceiling, 'lamp', form).action!)
53
+ const at = (compileFigureAsset(applied).evaluate().geometry as Array<{ id: string; pose?: { t: number[] } }>).find(g => g.id === 'lamp')!.pose!.t
54
+ assert.equal(at[1], -50, 'at -h/2, below the ceiling')
55
+ assert.equal(aimed(lamp()).facing, 'meet', 'a floor: standing on it')
56
+ assert.equal(aimed(lamp('wall')).facing, 'meet', 'a wall: standing out of it')
57
+ /* Only where it starts: set on the ceiling plane when the author says so. */
58
+ const set = fastenChanged(ceiling, 'lamp', form, { mode: 'meet' })
59
+ assert.equal((fastenPlanOf(ceiling, 'lamp', set).action as { facing?: string }).facing, 'meet')
60
+ })
61
+
43
62
  test('the one it has, unchanged, comes to the attach that replaces it with the same place across the face', () => {
44
63
  const r = conveyor()
45
64
  const plan = fastenPlanOf(r, 'roller', fastenFormOf(r, 'roller'))
@@ -83,3 +102,64 @@ test('the target list: every other part, the one hanging from this part listed a
83
102
  ])
84
103
  assert.deepEqual(fastenTargetsOf(r, 'lid').map(t => t.part), ['frame', 'roller'])
85
104
  })
105
+
106
+ /* A drum lying on the floor along Y and a ball, with a tag standing beside each (ADR-0140 3.9). */
107
+ async function curved() {
108
+ const { addFigurePrimitive } = await import('@hatiolab/figure-model')
109
+ let a = addFigurePrimitive(createFigureAsset('Curved'), { id: 'drum', kind: 'cylinder', dimensions: { radius: 100, length: 1600 }, position: { x: 0, y: 0, z: 0 } } as never)
110
+ a = addFigurePrimitive(a, { id: 'ball', kind: 'sphere', dimensions: { radiusX: 100, radiusY: 100, radiusZ: 100 }, position: { x: 0, y: 0, z: 3000 } } as never)
111
+ a = box(a, 'tag', [40, 40, 40], [600, 600, 600])
112
+ return box(a, 'cap', [40, 40, 40], [0, 600, 3000])
113
+ }
114
+
115
+ test('aimed at a cylinder it stands beside, it goes on the side at the point it faces; aimed at a sphere, on its round', async () => {
116
+ const r = revisionOf(await curved())
117
+ const drum = aimFasten(r, 'tag', freshFasten(), { part: 'drum' })
118
+ assert.deepEqual(drum.surface, { around: 315, along: { mm: 600 } }, 'beside it, 600 up its length, toward +X +Z')
119
+ const plan = fastenPlanOf(r, 'tag', drum)
120
+ assert.deepEqual((plan.action as { to: unknown }).to, { part: 'drum', surface: { around: 315, along: { mm: 600 } } })
121
+ assert.equal((plan.action as { align?: unknown }).align, undefined, 'on a surface, where is its own words')
122
+ assert.deepEqual(plan.surface, { form: 'around', axis: 'y', place: { around: 315, along: { mm: 600 } } })
123
+ assert.equal(fastenSentenceOf(drum, plan, '바닥', say), 'figure.text.fasten-sentence-meet(to=drum의 figure.label.surface-around())')
124
+ const ball = aimFasten(r, 'cap', freshFasten(), { part: 'ball' })
125
+ assert.deepEqual(ball.surface, { toward: { x: 0, y: 1, z: 0 } }, 'straight above it')
126
+ /* Applied, and opened again: on the surface as it was. */
127
+ const done = applyFigureAuthoring(r.asset, plan.action!)
128
+ assert.deepEqual(fastenFormOf(revisionOf(done), 'tag').surface, { around: 315, along: { mm: 600 } })
129
+ const { rowBlockOf } = await import('../client/modeller/links.ts')
130
+ assert.equal(rowBlockOf(done, 'tag'), 'on-surface', 'a point of a curved surface has no face to lay a row across')
131
+ })
132
+
133
+ test('a face or the surface, as the drawing asks; the surface chosen starts where the part stands', async () => {
134
+ const r = revisionOf(await curved())
135
+ const side = aimFasten(r, 'tag', freshFasten(), { part: 'drum' })
136
+ const top = fastenChanged(r, 'tag', side, { targetFace: 'top' })
137
+ assert.equal(top.surface, undefined, 'its end disc is a face')
138
+ assert.deepEqual((fastenPlanOf(r, 'tag', top).action as { to: unknown }).to, { part: 'drum', face: 'top' })
139
+ const back = fastenChanged(r, 'tag', top, { onSurface: true })
140
+ assert.deepEqual(back.surface, { around: 315, along: { mm: 600 } })
141
+ const turned = fastenChanged(r, 'tag', back, { surface: { around: 90, along: 'max' } })
142
+ assert.deepEqual((fastenPlanOf(r, 'tag', turned).action as { to: unknown }).to, { part: 'drum', surface: { around: 90, along: 'max' } })
143
+ })
144
+
145
+ test("pressed on the canvas: the face or the point pressed, read in that part's own frame; itself and a curve with no fastening said (ADR-0140 stage 4)", async () => {
146
+ const { addFigurePrimitive } = await import('@hatiolab/figure-model')
147
+ /* A drum lying along X (turned a quarter about Z), a rounded box, and the tag to fasten. */
148
+ let a = addFigurePrimitive(createFigureAsset('Pressed'), { id: 'drum', kind: 'cylinder', dimensions: { radius: 100, length: 400 }, position: { x: 0, y: 500, z: 0 }, rotation: { x: 0, y: 0, z: 90 } } as never)
149
+ a = addFigurePrimitive(a, { id: 'soft', kind: 'rounded-box', dimensions: { width: 300, height: 200, depth: 300, round: 40 }, position: { x: 0, y: 100, z: 1500 } } as never)
150
+ const r = revisionOf(box(a, 'tag', [40, 40, 40], [900, 900, 900]))
151
+ const pose = (r.geometry as Array<{ id: string; pose: { r: number[][]; t: number[] } }>).find(g => g.id === 'drum')!.pose
152
+ const world = (v: number[], move = true) => [0, 1, 2].map(i => [0, 1, 2].reduce((s, c) => s + pose.r[i]![c]! * v[c]!, move ? pose.t[i]! : 0))
153
+ const rad = (d: number) => (d * Math.PI) / 180
154
+ const at = (deg: number) => [Math.cos(rad(deg)), 0, -Math.sin(rad(deg))]
155
+ /* A 12-sided drawing: the facet between 0° and 30°, pressed where the ray at 20° meets it, 50 up its own Y. */
156
+ const s = (100 * Math.cos(rad(15))) / Math.cos(rad(5))
157
+ const press = { id: 'drum', point: world(at(20).map((v, i) => (i === 1 ? 50 : v * s))), normal: world(at(15), false) }
158
+ const side = aimFastenAt(r, 'tag', freshFasten(), press)
159
+ assert.deepEqual([side.note, side.form?.target, side.form?.surface], [undefined, 'drum', { around: 20, along: { mm: 50 } }])
160
+ const end = aimFastenAt(r, 'tag', freshFasten(), { id: 'drum', point: world([10, 200, 20]), normal: world([0, 1, 0], false) })
161
+ assert.deepEqual([end.form?.surface, end.form?.targetFace, end.form?.align, end.form?.alignMm], [undefined, 'top', { x: 'mm', z: 'mm' }, { x: 10, z: 20 }], "its end, wherever the drum's own Y lies, its middle where pressed")
162
+ assert.deepEqual(aimFastenAt(r, 'tag', freshFasten(), { id: 'tag', point: [900, 920, 900], normal: [0, 1, 0] }), { note: 'aim-self', part: 'tag' })
163
+ const corner = aimFastenAt(r, 'tag', freshFasten(), { id: 'soft', point: [145, 100, 1645], normal: [Math.SQRT1_2, 0, Math.SQRT1_2] })
164
+ assert.deepEqual([corner.note, corner.form?.target], ['aim-not-here', 'soft'], 'a rounded corner: aimed at the part, said')
165
+ })
@@ -127,33 +127,31 @@ test('원기둥 · 구도 크기 기즈모가 먹는다 — 단면은 원으로'
127
127
  })
128
128
 
129
129
  /*
130
- * 높이를 숫자로 적거나 손잡이로 키워도, 도형의 배치가 지키는 면이 그 자리에 있다 — figure-model 의 한 규칙
131
- * (`figureSizeEditChanges`, ADR-0097). 전에는 화면이 제 산수로 배치와 상관없이 아랫면을 지켰다. 2200 을 적은 기둥이
132
- * 바닥 아래로 933 mm 묻혔던 일(09-26)은 바닥 도형에서 그대로 막힌다. 돌려 세운 부품은 그 높이가 위아래가 아니라 건드리지 않는다.
130
+ * 크기는 커널의 한 함수로 바뀐다(ADR-0140 4.1 · 4.2, `figureSizeEditOf`): 숫자 칸은 부품의 가운데를, 손잡이는 잡은
131
+ * 쪽의 반대편을 그 자리에 둔다 — 배치(바닥 · 천장)와 상관없이, 누운 원기둥이든 서 있는 기둥이든. 같은 치수 · 같은
132
+ * 기준이면 칸과 손잡이가 같은 결과다.
133
133
  */
134
- test('크기 칸과 손잡이 — 배치가 지키는 면을 지킨다, 커널의 한 규칙으로', async () => {
135
- const { figureSizeEditChanges, applyFigureAuthoring, proposeFigureOccupancy } = await import('@hatiolab/figure-model')
134
+ test('크기 칸은 가운데를, 손잡이는 반대쪽을 — 배치와 상관없이 커널의 한 함수로', async () => {
135
+ const { figureSizeEditOf, applyFigureAuthoring } = await import('@hatiolab/figure-model')
136
136
  let asset = createFigureAsset('Post')
137
137
  asset = addFigureBox(asset, { id: 'post', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
138
- asset = addFigurePrimitive(asset, { id: 'drum', kind: 'cylinder', dimensions: { radius: 50, length: 100 }, position: { x: 0, y: 300, z: 0 } })
139
138
  asset = addFigurePrimitive(asset, { id: 'roller', kind: 'cylinder', dimensions: { radius: 30, length: 500 }, position: { x: 0, y: 30, z: 0 }, rotation: { x: 90, y: 0, z: 0 } })
140
139
  const centreY = (a: typeof asset, part: string) => {
141
140
  const g = compileFigureAsset(a).evaluate().geometry.find(o => o.id === part)! as unknown as { pose: { t: number[] } }
142
141
  return g.pose.t[1]!
143
142
  }
144
143
  const set = (input: string, value: number) => ({ kind: 'set-design', input, unit: 'mm', value }) as never
145
- const grow = (a: typeof asset, part: string, input: string, value: number) => apply(a, figureSizeEditChanges(a, part, [set(input, value)]))
144
+ const grow = (a: typeof asset, part: string, input: string, value: number, keep: never) => apply(a, figureSizeEditOf(a, part, [set(input, value)], keep).changes)
146
145
  const postH = gizmoReachOf(asset, 'post').sizes![1]
147
- assert.equal(centreY(grow(asset, 'post', postH, 2200), 'post') - 2200 / 2, 0, '바닥 도형(선언 전 기본): 기둥 아랫면이 바닥에')
148
- const drumH = gizmoReachOf(asset, 'drum').round!.along[0]!
149
- assert.equal(centreY(grow(asset, 'drum', drumH, 300), 'drum') - 300 / 2, 300 - 100 / 2, '원기둥도 아랫면이 제자리')
146
+ assert.equal(centreY(grow(asset, 'post', postH, 2200, { keep: 'centre' } as never), 'post'), 50, '숫자 칸: 가운데가 그 자리')
147
+ assert.equal(centreY(grow(asset, 'post', postH, 2200, { keep: 'opposite', axis: 'y', side: 1 } as never), 'post') - 2200 / 2, 0, '위 손잡이: 아랫면이 그 자리')
148
+ assert.equal(centreY(grow(asset, 'post', postH, 2200, { keep: 'opposite', axis: 'y', side: -1 } as never), 'post') + 2200 / 2, 100, '아래 손잡이: 윗면이 그 자리')
150
149
  const rollerLength = gizmoReachOf(asset, 'roller').round!.along[0]!
151
- assert.deepEqual(figureSizeEditChanges(asset, 'roller', [set(rollerLength, 800)]), [set(rollerLength, 800)], '누운 롤러의 길이는 위아래가 아니다')
152
- assert.deepEqual(figureSizeEditChanges(asset, 'post', [set(gizmoReachOf(asset, 'post').sizes![0], 800)]), [set(gizmoReachOf(asset, 'post').sizes![0], 800)], '폭은 가운데 그대로')
153
- /* 천장 도형이면 윗면을 지킨다 — 전에는 여기서도 아랫면을 지켜 천장을 뚫고 올라갔다. */
150
+ assert.deepEqual(figureSizeEditOf(asset, 'roller', [set(rollerLength, 800)], { keep: 'centre' }).changes, [set(rollerLength, 800)], '누운 롤러의 길이: 가운데가 원점이라 자리는 그대로')
151
+ /* 천장 도형도 같다 — 배치가 지키는 면을 따로 두지 않는다(ADR-0140 4.1 이 ADR-0097 §4.3 을 대신함). */
154
152
  let hung = addFigureBox(createFigureAsset('Lamp'), { id: 'lamp', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: -50, z: 0 } })
155
- hung = applyFigureAuthoring(hung, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(hung), placement: 'ceiling' })
156
- assert.equal(centreY(grow(hung, 'lamp', gizmoReachOf(hung, 'lamp').sizes![1], 300), 'lamp') + 300 / 2, 0, '천장 도형: 윗면이 천장에')
153
+ hung = applyFigureAuthoring(hung, { kind: 'set-placement', placement: 'ceiling' })
154
+ assert.equal(centreY(grow(hung, 'lamp', gizmoReachOf(hung, 'lamp').sizes![1], 300, { keep: 'centre' } as never), 'lamp'), -50, '천장 도형: 숫자 칸도 가운데')
157
155
  })
158
156
 
159
157
  /* 손잡이는 키를 몰라도 찾는다 — 도구 막대에 옮기기 · 돌리기 · 크기, 제 숫자로 놓인 부품에만 (09-26). */
@@ -367,8 +367,8 @@ test('따라 움직이는 관절 — 같은 방식으로 움직이고 제 조작
367
367
  assert.deepEqual(motionOf(asset, 'right')?.follows, { sum: [{ joint: 'left', multiplier: -1 }], offset: 0 }, '화면은 무엇을 따르는지 읽는다')
368
368
  })
369
369
 
370
- test('면은 도형이 정한다 — 문틀(portal)은 바깥 여섯에 개구부 셋, 공은 없음, 원뿔대는 위아래뿐 (2026-09-25)', async () => {
371
- const { facesOf, faceAxisOf, canFasten, alignAxesOf } = await import('../client/modeller/links.ts')
370
+ test('면은 도형이 정한다 — 문틀(portal)은 바깥 여섯에 개구부 셋, 공은 없고 곡면, 원뿔대는 위아래뿐, 원기둥 옆은 면이 아님 (2026-09-25, ADR-0140 3.9)', async () => {
371
+ const { facesOf, faceAxisOf, canFasten, alignAxesOf, surfaceOf } = await import('../client/modeller/links.ts')
372
372
  let asset = createFigureAsset('Doorway')
373
373
  asset = addFigurePrimitive(asset, {
374
374
  id: 'frame',
@@ -379,10 +379,15 @@ test('면은 도형이 정한다 — 문틀(portal)은 바깥 여섯에 개구
379
379
  asset = addFigurePrimitive(asset, { id: 'ball', kind: 'sphere', dimensions: { radiusX: 50, radiusY: 50, radiusZ: 50 }, position: { x: 0, y: 0, z: 0 } })
380
380
  asset = addFigurePrimitive(asset, { id: 'cone', kind: 'frustum', dimensions: { radiusTop: 20, radiusBottom: 60, height: 100 }, position: { x: 0, y: 0, z: 0 } })
381
381
  asset = addFigureBox(asset, { id: 'leaf', size: { x: 890, y: 2095, z: 40 }, position: { x: 0, y: 0, z: 0 } })
382
+ asset = addFigurePrimitive(asset, { id: 'drum', kind: 'cylinder', dimensions: { radius: 100, length: 400 }, position: { x: 0, y: 0, z: 0 } })
382
383
  assert.deepEqual(facesOf(asset, 'frame'), ['left', 'right', 'bottom', 'top', 'back', 'front', 'opening-left', 'opening-right', 'opening-top'])
383
384
  assert.equal(faceAxisOf(asset, 'frame', 'opening-top'), 'y')
384
385
  assert.deepEqual(facesOf(asset, 'ball'), [])
385
- assert.equal(canFasten(asset, 'ball'), false, '공은 붙일 면이 없다')
386
+ assert.equal(canFasten(asset, 'ball'), true, '공은 면이 없어도 곡면 위 점에 붙인다')
387
+ assert.deepEqual(surfaceOf(asset, 'ball'), { form: 'toward', axis: 'y' })
388
+ assert.deepEqual(facesOf(asset, 'drum'), ['bottom', 'top'], '원기둥 옆은 곡면이라 면이 아니다')
389
+ assert.deepEqual(surfaceOf(asset, 'drum'), { form: 'around', axis: 'y' })
390
+ assert.equal(surfaceOf(asset, 'cone'), undefined, '원뿔대 옆은 아직 받지 않는다')
386
391
  assert.equal(canFasten(asset, 'frame'), true)
387
392
  assert.deepEqual(facesOf(asset, 'cone'), ['bottom', 'top'])
388
393
  assert.deepEqual(alignAxesOf(asset, { part: 'frame', face: 'opening-left' }), [{ axis: 'y', edges: true }, { axis: 'z', edges: true }])
@@ -117,22 +117,20 @@ test('a field only the room reads is taken out of the part\'s position row and s
117
117
  })
118
118
 
119
119
  /*
120
- * Designer 2026-09-27, 1: what the drag shows is what is written. The part component carries figure-model's anchor, and
121
- * the scene's gizmo keeps that face while dragging (things-scene `scaleAnchor`); the commit keeps the same face.
120
+ * ADR-0140 4.4: what the drag shows is what is written. The part component asks the scene to leave its place to the
121
+ * modeller (things-scene `scalePlacement: 'owner'`), which draws each frame from figure-model's one size function --
122
+ * whatever the placement; no face is named to the scene.
122
123
  */
123
- test('the part component carries the face a size drag keeps, for each placement', async () => {
124
+ test('the part component leaves its place in a size drag to the modeller, for each placement', async () => {
124
125
  const { componentOf } = await import('../client/modeller/stage-model.js')
125
- const { figureScaleAnchorOf } = await import('@hatiolab/figure-model')
126
- const expected = { floor: { x: 0, y: 1, z: 0 }, ceiling: { x: 0, y: -1, z: 0 }, wall: { x: 0, y: 0, z: 1 }, center: { x: 0, y: 0, z: 0 } } as const
127
126
  for (const mode of ['floor', 'ceiling', 'wall', 'center'] as const) {
128
127
  let a = addFigureBox(createFigureAsset('UX'), { id: 'box', size: { x: 100, y: 100, z: 100 }, position: START[mode] })
129
128
  a = applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a), placement: mode })
130
- const anchor = figureScaleAnchorOf(a, 'box')!
131
- assert.deepEqual(anchor, expected[mode], mode)
132
129
  const body = compileFigureAsset(a).evaluate().geometry.find(g => g.id === 'box') as never
133
- const state = componentOf(body, {}, { width: 1000, height: 1000 }, anchor)!
134
- assert.deepEqual(state.scaleAnchor, expected[mode])
135
- assert.equal(componentOf(body, {}, { width: 1000, height: 1000 })!.scaleAnchor, undefined, 'none given, none set')
130
+ const state = componentOf(body, {}, { width: 1000, height: 1000 }, true)!
131
+ assert.equal(state.scalePlacement, 'owner', mode)
132
+ assert.equal(state.scaleAnchor, undefined, `${mode}: no face named to the scene`)
133
+ assert.equal(componentOf(body, {}, { width: 1000, height: 1000 })!.scalePlacement, undefined, 'a shown-only body is not')
136
134
  }
137
135
  })
138
136
 
@@ -155,12 +153,15 @@ test('size response and attachment are separate screen paths', () => {
155
153
  assert.equal(ko['figure.text.held-on-plane'].replace('{place}', '바닥').replace('{gap}', '0'), '바닥에 붙어 있음 (간격 0 mm)')
156
154
  })
157
155
 
158
- test('a part fastened tilted off a quarter turn has no side figure-model can keep for a drag, so the size gizmo is withheld', async () => {
159
- const { figureScaleAnchorOf } = await import('@hatiolab/figure-model')
156
+ test('a part fastened at any angle is sized by the gizmo too: its fastening keeps it, and nothing else is written', async () => {
157
+ const { figureSizeEditOf } = await import('@hatiolab/figure-model')
158
+ const { gizmoReachOf } = await import('../client/modeller/gizmo.js')
160
159
  let a = addFigureBox(createFigureAsset('UX'), { id: 'hub', size: { x: 200, y: 100, z: 200 }, position: { x: 0, y: 50, z: 0 } })
161
160
  a = addFigureBox(a, { id: 'blade', size: { x: 300, y: 20, z: 60 }, position: { x: 0, y: 110, z: 0 } })
162
161
  a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { id: 'blade', type: 'fixed', body0: { part: 'hub', face: 'top' }, body1: { part: 'blade' }, origin: { childRotate: { z: 30 } } } })
163
- assert.equal(figureScaleAnchorOf(a, 'blade'), undefined)
162
+ assert.ok(gizmoReachOf(a, 'blade').scale, 'offered')
163
+ const size = [{ kind: 'set-design', input: 'blade.size.x', unit: 'mm', value: 400 }] as never
164
+ assert.deepEqual(figureSizeEditOf(a, 'blade', size, { keep: 'opposite', axis: 'x', side: 1 }), { changes: size, kept: 'relation' })
164
165
  })
165
166
 
166
167
  /*
@@ -12,7 +12,7 @@ import assert from 'node:assert/strict'
12
12
  import { readFileSync } from 'node:fs'
13
13
  import { fileURLToPath } from 'node:url'
14
14
 
15
- import { addFigureBox, applyFigureAuthoring, createFigureAsset, figureFaceOverhangOf, figurePartFaceOf, figurePlaneAlignChanges, figureRoomOnlyInputsOf, figureRowOf, figureScaleAnchorOf, measureFigureWork, serializeFigureAsset } from '@hatiolab/figure-model'
15
+ import { addFigureBox, applyFigureAuthoring, createFigureAsset, figureFaceOverhangOf, figurePartFaceOf, figurePlaneAlignChanges, figureRoomOnlyInputsOf, figureRowOf, measureFigureWork, serializeFigureAsset } from '@hatiolab/figure-model'
16
16
  import type { FigureAsset } from '@hatiolab/figure-model'
17
17
 
18
18
  import { createFigureProductSession } from '../client/modeller/product-session.ts'
@@ -24,6 +24,7 @@ import { faceViewOf } from '../client/modeller/fasten-geometry.ts'
24
24
  import { motionEndsOf } from '../client/modeller/motion-ends.ts'
25
25
  import { figureFigureFacts } from '../client/modeller/figure-facts.ts'
26
26
  import { gizmoReachOf } from '../client/modeller/gizmo.ts'
27
+ import { keptBySizeOf } from '../client/modeller/size-drag.ts'
27
28
  import { moving } from './joints.ts'
28
29
  import { revisionOf } from './revision-of.ts'
29
30
 
@@ -82,7 +83,7 @@ test('a version is readable while it is saved, and a save hands out the same tex
82
83
  function selectionQuestions(shown: FigureRevision, part: string) {
83
84
  const asset = shown.asset
84
85
  const reach = gizmoReachOf(asset as never, part)
85
- figureScaleAnchorOf(asset, part)
86
+ keptBySizeOf(asset, part)
86
87
  boxOf(shown, part)
87
88
  gridStepOf(shown)
88
89
  jointFanOf(shown, part)
@@ -173,7 +173,7 @@ test('사각형·원도 같은 창에서 — 폭·지름·길이를 한 명령
173
173
  const next = applyFigureAuthoring(box, { kind: 'set-section', part: 'b', across: { x: 500 }, length: 260 })
174
174
  const b = (compileFigureAsset(next).evaluate().geometry.find(g => (g as { id: string }).id === 'b') as unknown as { shape: FigureEvaluatedShape; pose: { t: number[] } })
175
175
  assert.deepEqual(b.shape.extrusion!.section, { kind: 'rectangle', size: { x: 500, z: 300 } })
176
- assert.equal(b.pose.t[1]! + b.shape.extrusion!.start, 0, 'a floor figure keeps its bottom')
176
+ assert.deepEqual([b.pose.t[1]! + b.shape.extrusion!.start, b.pose.t[1]! + b.shape.extrusion!.end], [-30, 230], 'a free part keeps its middle (ADR-0140 4.1)')
177
177
  /* A corner that no longer fits, and a length of 0, are said before anything is sent. */
178
178
  const rounded = applyFigureAuthoring(box, { kind: 'set-section-corner', part: 'b', corner: 100 })
179
179
  const r = (compileFigureAsset(rounded).evaluate().geometry.find(g => (g as { id: string }).id === 'b') as unknown as { shape: FigureEvaluatedShape }).shape
@@ -217,15 +217,14 @@ test('창 안에서 폭과 속 공간을 함께 — 넓힌 폭에서만 들어
217
217
  assert.deepEqual(sectionActionOf('t', 'rectangle', w, { ...w, cavity: null }), { kind: 'set-section', part: 't', cavity: null })
218
218
  })
219
219
 
220
- test('치수 칸 밑의 말: 다른 값을 따르면 그렇다고, 길이는 관계가 남기는 면을 — 말할 것이 없으면 아무것도', () => {
221
- assert.deepEqual(measureNoteOf('linked', 1, 'y'), { key: 'figure.text.section-editor-linked' }, 'following another value is said first')
222
- assert.deepEqual(measureNoteOf('editable', 1, 'y'), { key: 'figure.text.section-editor-keeps', face: 'bottom' }, 'a floor part keeps its start: the bottom')
223
- assert.deepEqual(measureNoteOf('editable', -1, 'y'), { key: 'figure.text.section-editor-keeps', face: 'top' })
224
- assert.deepEqual(measureNoteOf('editable', -1, 'x'), { key: 'figure.text.section-editor-keeps', face: 'right' })
225
- assert.deepEqual(measureNoteOf('editable', 0, 'y'), { key: 'figure.text.section-editor-keeps-centre' })
226
- assert.equal(measureNoteOf('editable', undefined, 'y'), null, 'the relations cannot say: nothing')
227
- assert.equal(measureNoteOf(undefined, undefined, 'x'), null, 'a width across says only that it follows')
228
- assert.equal(say('figure.text.section-editor-keeps', { face: ko['figure.label.face-bottom']! }), '아랫면 유지')
220
+ test('치수 칸 밑의 말: 다른 값을 따르면 그렇다고, 길이는 가운데나 붙인 위치를 지킨다고 — 말할 것이 없으면 아무것도', () => {
221
+ assert.deepEqual(measureNoteOf('linked', 'centre'), { key: 'figure.text.section-editor-linked' }, 'following another value is said first')
222
+ assert.deepEqual(measureNoteOf('editable', 'centre'), { key: 'figure.text.size-keeps-centre' }, 'a free part keeps its middle (ADR-0140 4.1)')
223
+ assert.deepEqual(measureNoteOf('editable', 'relation'), { key: 'figure.text.size-keeps-fastening' }, 'a fastened part keeps where it is fastened')
224
+ assert.deepEqual(measureNoteOf('editable', 'none'), { key: 'figure.text.size-keeps-none' }, 'a position worked out: the size alone changes')
225
+ assert.equal(measureNoteOf('editable', undefined), null, 'nothing known: nothing')
226
+ assert.equal(measureNoteOf(undefined, undefined), null, 'a width across says only that it follows')
227
+ assert.deepEqual([say('figure.text.size-keeps-centre', {}), say('figure.text.size-keeps-fastening', {})], ['가운데 유지', '붙인 위치 유지'])
229
228
  })
230
229
 
231
230
  test('ADR-0100 A1: 상자·판·벽은 같은 돌출에 처음 치수만 다르고, 더해도 숨은 부착이나 연결이 생기지 않는다', async () => {
@@ -0,0 +1,114 @@
1
+ /*
2
+ * A size drag drawn from figure-model frame by frame (ADR-0140 4.4, `size-drag.ts`): each frame is what its release
3
+ * writes -- the same changes, and every body where the committed figure puts it, to the bit. A free part keeps the
4
+ * side opposite the handle; a fastened one moves what hangs from it, by its fastening.
5
+ */
6
+ import { test } from 'node:test'
7
+ import assert from 'node:assert/strict'
8
+
9
+ import { addFigureBox, applyFigureAuthoring, compileFigureAsset, createFigureAsset, figureSizeEditOf } from '@hatiolab/figure-model'
10
+ import type { FigureAsset, FigureInputChange, FigureSizeKeep } from '@hatiolab/figure-model'
11
+
12
+ import { partsAtFrame, startSizeDrag } from '../client/modeller/size-drag.ts'
13
+ import { drawableFigureParts, type FigureRenderPart } from '../client/modeller/renderer.ts'
14
+
15
+ const set = (input: string, value: number): FigureInputChange => ({ kind: 'set-design', input, unit: 'mm', value })
16
+ const committed = (asset: FigureAsset, changes: FigureInputChange[]) =>
17
+ drawableFigureParts(compileFigureAsset({ ...asset, designInputs: { ...asset.designInputs, ...Object.fromEntries(changes.map(c => [c.input, c.value])) } }).evaluate().geometry)
18
+ const drawn = (asset: FigureAsset) => drawableFigureParts(compileFigureAsset(asset).evaluate().geometry)
19
+ const posesOf = (parts: FigureRenderPart[]) => Object.fromEntries(parts.map(p => [p.id, { r: p.pose.r, t: p.pose.t, dimensions: p.shape.dimensions }]))
20
+
21
+ /* A turned box on the floor, and a lid fastened on its top. */
22
+ function figure() {
23
+ let a = addFigureBox(createFigureAsset('Drag'), { id: 'box', size: { x: 200, y: 100, z: 150 }, position: { x: 40, y: 300, z: -20 }, rotation: { x: 10, y: 30, z: 0 } })
24
+ a = addFigureBox(a, { id: 'base', size: { x: 400, y: 100, z: 300 }, position: { x: 0, y: 50, z: 0 } })
25
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'base', to: { plane: 'mounting-plane' } })
26
+ a = addFigureBox(a, { id: 'lid', size: { x: 100, y: 20, z: 100 }, position: { x: 0, y: 0, z: 0 } })
27
+ return applyFigureAuthoring(a, { kind: 'attach', part: 'lid', to: { part: 'base', face: 'top' } })
28
+ }
29
+
30
+ test('each frame of a free part is what its release writes: the same changes, every body where the commit puts it', () => {
31
+ const asset = figure()
32
+ const keep: FigureSizeKeep = { keep: 'opposite', axis: 'x', side: 1 }
33
+ const drag = startSizeDrag(asset, 'box', {}, keep)
34
+ for (const width of [230, 300, 180]) {
35
+ const sizes = [set('box.size.x', width)]
36
+ const frame = drag.frame(sizes)
37
+ assert.deepEqual(frame.changes, figureSizeEditOf(asset, 'box', sizes, keep).changes, `${width}: the changes`)
38
+ assert.deepEqual(posesOf(partsAtFrame(drawn(asset), frame.moved)), posesOf(committed(asset, frame.changes)), `${width}: the bodies`)
39
+ }
40
+ })
41
+
42
+ test('a fastened part dragged: what hangs from it moves by its fastening, frame by frame, as the commit puts it', () => {
43
+ const asset = figure()
44
+ const drag = startSizeDrag(asset, 'base', {}, { keep: 'opposite', axis: 'y', side: 1 })
45
+ for (const height of [140, 220]) {
46
+ const frame = drag.frame([set('base.size.y', height)])
47
+ assert.equal(frame.changes.length, 1, 'its size alone: its fastening places it')
48
+ assert.ok(frame.moved.has('lid'), 'the lid on its top moves with it')
49
+ assert.deepEqual(posesOf(partsAtFrame(drawn(asset), frame.moved)), posesOf(committed(asset, frame.changes)), `${height}`)
50
+ }
51
+ })
52
+
53
+ test('a body that moved and then stood still is drawn where it stopped, not where the drag began', () => {
54
+ const asset = figure()
55
+ const drag = startSizeDrag(asset, 'base', {}, { keep: 'centre' })
56
+ drag.frame([set('base.size.y', 200)])
57
+ /* The same height again: nothing changes in this frame, and the lid stays up where the last frame put it. */
58
+ const frame = drag.frame([set('base.size.y', 200)])
59
+ assert.deepEqual(posesOf(partsAtFrame(drawn(asset), frame.moved)), posesOf(committed(asset, frame.changes)))
60
+ })
61
+
62
+ test('the handle held is what is kept from: one axis keeps the side opposite it; the uniform handle, or none, the middle', async () => {
63
+ const { sizeKeepOf } = await import('../client/modeller/gizmo.ts')
64
+ assert.deepEqual(sizeKeepOf({ x: 0, y: 1, z: 0 }), { keep: 'opposite', axis: 'y', side: 1 })
65
+ assert.deepEqual(sizeKeepOf({ x: -1, y: 0, z: 0 }), { keep: 'opposite', axis: 'x', side: -1 })
66
+ assert.deepEqual(sizeKeepOf({ x: 1, y: 1, z: 1 }), { keep: 'centre' }, 'the uniform handle')
67
+ assert.deepEqual(sizeKeepOf(undefined), { keep: 'centre' }, 'no handle known')
68
+ })
69
+
70
+ test('what a size typed keeps, as the panel says it: the middle of a free part, the fastening of a fastened one', async () => {
71
+ const { keptBySizeOf } = await import('../client/modeller/size-drag.ts')
72
+ const asset = figure()
73
+ assert.deepEqual(['box', 'base', 'lid'].map(part => keptBySizeOf(asset, part)), ['centre', 'relation', 'relation'])
74
+ /* Its height placed by a rule: no point is kept, only the size changes -- said as such (chief architect 10-20). */
75
+ const ruled = structuredClone(asset) as FigureAsset
76
+ ;(ruled.document.model as unknown as { nodes: Array<{ id: string; args: string[] }> }).nodes.find(n => n.id === 'box.pose')!.args[1] = 'box.position.x'
77
+ assert.equal(keptBySizeOf(ruled, 'box'), 'none')
78
+ })
79
+
80
+ test('how far a free part goes past its mounting plane: its real extreme against the side its placement uses', async () => {
81
+ const { throughPlaneOf } = await import('../client/modeller/size-drag.ts')
82
+ const box = (placement: 'floor' | 'ceiling' | 'wall' | 'center', position: { x: number; y: number; z: number }, rotation = { x: 0, y: 0, z: 0 }) =>
83
+ applyFigureAuthoring(addFigureBox(createFigureAsset('P'), { id: 'b', size: { x: 100, y: 100, z: 100 }, position, rotation }), { kind: 'set-placement', placement })
84
+ assert.equal(throughPlaneOf(box('floor', { x: 0, y: 20, z: 0 }), drawn(box('floor', { x: 0, y: 20, z: 0 })), 'b'), 30, 'its bottom 30 under the floor')
85
+ assert.equal(throughPlaneOf(box('floor', { x: 0, y: 50, z: 0 }), drawn(box('floor', { x: 0, y: 50, z: 0 })), 'b'), 0, 'standing on it')
86
+ assert.equal(throughPlaneOf(box('ceiling', { x: 0, y: -40, z: 0 }), drawn(box('ceiling', { x: 0, y: -40, z: 0 })), 'b'), 10, 'its top 10 above the ceiling')
87
+ assert.equal(throughPlaneOf(box('wall', { x: 0, y: 0, z: 45 }), drawn(box('wall', { x: 0, y: 0, z: 45 })), 'b'), 5, 'its back 5 into the wall')
88
+ assert.equal(throughPlaneOf(box('center', { x: 0, y: -500, z: 0 }), drawn(box('center', { x: 0, y: -500, z: 0 })), 'b'), 0, 'a figure placed by its centre has no plane')
89
+ /* Turned 45° about Z, its lowest corner is √2·50 under its middle. */
90
+ const turned = box('floor', { x: 0, y: 60, z: 0 }, { x: 0, y: 0, z: 45 })
91
+ assert.equal(throughPlaneOf(turned, drawn(turned), 'b'), Math.round((50 * Math.SQRT2 - 60) * 10) / 10, 'its real corner, not its upright box')
92
+ const fastened = applyFigureAuthoring(box('floor', { x: 0, y: 20, z: 0 }), { kind: 'attach', part: 'b', to: { plane: 'mounting-plane' } })
93
+ assert.equal(throughPlaneOf(fastened, drawn(fastened), 'b'), 0, 'its fastening places it')
94
+ })
95
+
96
+ /*
97
+ A part whose height is placed by a rule keeps nothing a handle could keep (kept 'none'): only its size is written. The
98
+ drag draws exactly that, frame by frame -- it does not look held at the opposite side and then land elsewhere (ADR-0097
99
+ §4.3: a drag whose frames differ from its release is not done; chief architect 10-20).
100
+ */
101
+ test('a position placed by a rule: each frame draws what the release writes -- the size alone, kept none', () => {
102
+ /* A command's result is sealed (ADR-0119): the rule goes into a copy. Its height now reads its x. */
103
+ const ruled = structuredClone(figure()) as FigureAsset
104
+ ;(ruled.document.model as unknown as { nodes: Array<{ id: string; args: string[] }> }).nodes.find(n => n.id === 'box.pose')!.args[1] = 'box.position.x'
105
+ const keep: FigureSizeKeep = { keep: 'opposite', axis: 'y', side: 1 }
106
+ const drag = startSizeDrag(ruled, 'box', {}, keep)
107
+ for (const height of [160, 240]) {
108
+ const sizes = [set('box.size.y', height)]
109
+ const frame = drag.frame(sizes)
110
+ assert.deepEqual(figureSizeEditOf(ruled, 'box', sizes, keep), { changes: sizes, kept: 'none' }, `${height}: the size alone`)
111
+ assert.deepEqual(frame.changes, sizes, `${height}: the frame writes the same`)
112
+ assert.deepEqual(posesOf(partsAtFrame(drawn(ruled), frame.moved)), posesOf(committed(ruled, frame.changes)), `${height}: drawn as written`)
113
+ }
114
+ })
@@ -37,22 +37,21 @@ test("no layout yet: a form on the selected part's top, and the equipment preset
37
37
  assert.equal(layout, undefined)
38
38
  const faces = anchorFacesOf(form.anchors, compileFigureAsset(asset).evaluate().geometry as never)
39
39
  assert.deepEqual(faces, { fork: { width: 1200, depth: 900 } })
40
- const say = (key: string) => key
41
- const presets = slotPresetsOf(form.anchors, say)
40
+ const presets = slotPresetsOf(form.anchors)
42
41
  assert.deepEqual(presets.map(p => p.key)[0], 'single', 'a single slot first: where an author starts')
43
42
  assert.equal(presets.length, 9)
44
43
  const size = { ...faces.fork!, slotHeight: 1000 }
45
44
  const single = presets[0]!.build(size, {})
46
45
  assert.deepEqual(single.tiers[0]!, { tierNo: 1, anchorId: 'fork', mount: 'ON', position: { x: 0, y: 0, z: 0 }, rotation: 0, layout: { pattern: 'SINGLE', slot: { allowed: { width: 1200, depth: 900, height: 1000 } } } })
47
46
  assert.deepEqual(presets.find(p => p.key === 'floorBlock')!.params, [
48
- { key: 'rows', label: 'figure.label.slot-preset-param-rows', integer: true },
49
- { key: 'cols', label: 'figure.label.slot-preset-param-cols', integer: true },
50
- { key: 'stackLimit', label: 'figure.label.slot-preset-param-stackLimit', integer: true }
47
+ { key: 'rows', integer: true },
48
+ { key: 'cols', integer: true },
49
+ { key: 'stackLimit', integer: true }
51
50
  ])
52
51
  /* Two anchors: the twin fork stands one tier on each. A bottom anchor hangs its single slot. */
53
- const twin = slotPresetsOf([{ id: 'left', part: 'a', face: 'top' }, { id: 'right', part: 'b', face: 'top' }], say)
52
+ const twin = slotPresetsOf([{ id: 'left', part: 'a', face: 'top' }, { id: 'right', part: 'b', face: 'top' }])
54
53
  assert.deepEqual(twin.find(p => p.key === 'craneTwinFork')!.build(size, {}).tiers.map(t => t.anchorId), ['left', 'right'])
55
- assert.equal(slotPresetsOf([{ id: 'under', part: 'fork', face: 'bottom' }], say)[0]!.build(size, {}).tiers[0]!.mount, 'HANG')
54
+ assert.equal(slotPresetsOf([{ id: 'under', part: 'fork', face: 'bottom' }])[0]!.build(size, {}).tiers[0]!.mount, 'HANG')
56
55
  })
57
56
 
58
57
  test('the editor is told what figure-model asks beyond its own checks: every slot names a type', () => {
@@ -103,7 +102,7 @@ test('refusals are said from their details, never the kernel\'s sentence', () =>
103
102
  })
104
103
 
105
104
  test('the slot layout panel starts from presets that keep where they started as a record', () => {
106
- const [single] = slotPresetsOf([{ id: 'fork', part: 'fork', face: 'top' }], k => k)
105
+ const [single] = slotPresetsOf([{ id: 'fork', part: 'fork', face: 'top' }])
107
106
  assert.deepEqual(single!.build({ width: 1200, depth: 900, slotHeight: 1000 }, {}).preset, { key: 'single', size: { width: 1200, depth: 900, slotHeight: 1000 }, params: {} })
108
107
  })
109
108
 
@@ -115,3 +114,9 @@ test('the places words are in every language', () => {
115
114
  assert.ok(t[key], `${lang} ${key}`)
116
115
  }
117
116
  })
117
+
118
+ /* A preset's name is the shared editor's (label.slot-layout.preset.*): figure-ui keeps no second copy of it. */
119
+ test('the preset names are the slot layout editor\'s, not a copy here', () => {
120
+ for (const lang of ['en', 'es', 'ja', 'ko', 'ms', 'zh'])
121
+ assert.deepEqual(Object.keys(JSON.parse(read(`../translations/${lang}.json`))).filter(key => key.includes('slot-preset')), [], lang)
122
+ })
@@ -201,8 +201,11 @@
201
201
  "figure.label.section-editor-across": "{axis} width (mm)",
202
202
  "figure.label.section-editor-diameter": "diameter (mm)",
203
203
  "figure.text.section-editor-linked": "Follows another value.",
204
- "figure.text.section-editor-keeps": "keeps {face}",
205
- "figure.text.section-editor-keeps-centre": "keeps the centre",
204
+ "figure.text.size-keeps-centre": "keeps the centre",
205
+ "figure.text.size-keeps-fastening": "keeps where it is fastened",
206
+ "figure.text.size-keeps-none": "keeps its worked-out position",
207
+ "figure.text.size-through-plane": "{part} goes {mm} mm past the {plane}.",
208
+ "figure.button.fasten-to-plane": "Fasten to the {plane}",
206
209
  "figure.text.section-issue-corner": "At this width the corner radius can be at most {max} mm. Widen it or make the corner smaller.",
207
210
  "figure.text.section-issue-length": "The push length must be more than 0.",
208
211
  "figure.text.section-issue-cavity-walls": "The inside walls take the whole width. Widen it or make the walls thinner.",
@@ -559,6 +562,7 @@
559
562
  "figure.label.fasten-what": "Which part",
560
563
  "figure.label.fasten-face-on-part": "Which face of {part}",
561
564
  "figure.label.fasten-face-on-plane": "Where on the {plane}",
565
+ "figure.label.fasten-face-on-surface": "Where on {part}",
562
566
  "figure.label.fasten-how": "How it sits",
563
567
  "figure.label.facing": "How faces meet",
564
568
  "figure.text.fasten-summary-gap": "gap {mm} mm",
@@ -576,6 +580,11 @@
576
580
  "figure.label.face-top": "top",
577
581
  "figure.label.face-back": "back",
578
582
  "figure.label.face-front": "front",
583
+ "figure.label.surface-around": "curved side",
584
+ "figure.label.surface-toward": "curved surface",
585
+ "figure.label.surface-seen-end": "{part} seen from its end",
586
+ "figure.label.surface-seen-above": "from above",
587
+ "figure.label.surface-seen-side": "from the side",
579
588
  "figure.text.motion-explained": "How this part moves. Added on top of its fastening, never instead of it.",
580
589
  "figure.label.motion-none": "does not move",
581
590
  "figure.label.motion-slide": "slide",
@@ -702,6 +711,10 @@
702
711
  "figure.text.row-blocked-on-plane": "Fasten it to another part, not the mounting plane, to arrange it in a row along that part.",
703
712
  "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.",
704
713
  "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.",
714
+ "figure.text.row-blocked-on-surface": "It is fastened to a point on a curved surface, so it cannot be arranged in a row. Fasten it to a face to arrange it in a row.",
715
+ "figure.text.aim-self": "{part} cannot be fastened to itself.",
716
+ "figure.text.aim-cannot": "{part} has no face or curved surface to fasten to.",
717
+ "figure.text.aim-not-here": "That spot on {part} cannot be fastened to; choose a face.",
705
718
  "figure.button.go-fasten": "go to fastening",
706
719
  "figure.text.row-count-pitch": "{count} × {part} · along {axis} · pitch {pitch} mm (centre to centre)",
707
720
  "figure.text.row-count-uneven": "{count} × {part} · along {axis} · unevenly spaced",
@@ -709,8 +722,7 @@
709
722
  "figure.text.row-ends": "{low} mm left at the low end · {high} mm at the high end",
710
723
  "figure.text.row-ends-unknown": "What is left at the ends could not be read.",
711
724
  "figure.text.row-companions": "copied along: {parts}",
712
- "figure.text.instance-size-undeclared": "set a range and a drawing can resize it.",
713
- "figure.button.declare-instance-size": "set the range it stretches over",
725
+ "figure.button.declare-instance-size": "let a drawing resize it",
714
726
  "figure.text.proposal-applied": "applied {n} commands as one step.",
715
727
  "figure.text.proposal-saved-into": "Saved into: {names}",
716
728
  "figure.text.proposal-needs-modeller": "this proposal can be applied only while its figure is open in the modeller.",
@@ -842,9 +854,6 @@
842
854
  "figure.text.held-on-part": "Attached to the {otherFace} of {other}",
843
855
  "figure.button.open-fastening": "Open attachment settings",
844
856
  "figure.button.detach-held": "Detach",
845
- "figure.text.gizmo-size-turned-fastening": "{part} is attached turned, so its size cannot be dragged.",
846
- "figure.text.gizmo-size-tilted": "{part} is tilted, so its size cannot be dragged.",
847
- "figure.button.to-size-fields": "Go to size",
848
857
  "figure.label.align-input": "Follows its field ({field})",
849
858
  "figure.label.tab-short-properties": "Properties",
850
859
  "figure.label.tab-short-capability": "Capability",
@@ -1004,19 +1013,6 @@
1004
1013
  "figure.text.slot-layout-issues": "the value at {path} in the slot layout is not valid.",
1005
1014
  "figure.text.slot-accepts-empty": "slot {slot} takes no type.",
1006
1015
  "figure.text.slot-id-taken": "{slot} is already the name of a place in this figure.",
1007
- "figure.label.slot-preset-single": "single slot",
1008
- "figure.label.slot-preset-agvTandem": "AGV tandem (front/rear)",
1009
- "figure.label.slot-preset-agvParallel": "AGV parallel (left/right)",
1010
- "figure.label.slot-preset-agv2x2": "AGV 2×2",
1011
- "figure.label.slot-preset-bufferPort2x2": "buffer port 2×2",
1012
- "figure.label.slot-preset-multiBufferPort": "multi-buffer port (4)",
1013
- "figure.label.slot-preset-dualTierConveyor": "dual-tier conveyor",
1014
- "figure.label.slot-preset-craneTwinFork": "crane twin fork",
1015
- "figure.label.slot-preset-floorBlock": "floor block",
1016
- "figure.label.slot-preset-param-upperElevation": "upper tier elevation (mm)",
1017
- "figure.label.slot-preset-param-rows": "rows",
1018
- "figure.label.slot-preset-param-cols": "columns",
1019
- "figure.label.slot-preset-param-stackLimit": "stack limit",
1020
1016
  "figure.label.placement": "fastening",
1021
1017
  "figure.label.motion-groups": "motion groups",
1022
1018
  "figure.label.motion-group": "motion group",
@@ -1187,7 +1183,7 @@
1187
1183
  "figure.text.size-and-space-declared-to-follow": "the figure size was set from its parts, {x} × {y} × {z} mm, and the space it takes was fitted to them.",
1188
1184
  "figure.button.release-blocked": "{count} blocks release",
1189
1185
  "figure.label.preview-way-one": "one",
1190
- "figure.label.preview-way-eight": "eight sizes",
1186
+ "figure.label.preview-way-eight": "various sizes",
1191
1187
  "figure.text.preview-needs-space": "set the space it takes to try other sizes.",
1192
1188
  "figure.text.released-figure-not-deletable": "{name} has been released and cannot be deleted. retire it in its details.",
1193
1189
  "figure.label.retired": "retired",
@@ -1205,5 +1201,12 @@
1205
1201
  "figure.button.make-public": "make public",
1206
1202
  "figure.button.make-private": "make private",
1207
1203
  "figure.text.category-choose": "choose a category",
1208
- "figure.text.property-section-failed": "the section {section} could not be drawn: {reason}"
1204
+ "figure.text.property-section-failed": "the section {section} could not be drawn: {reason}",
1205
+ "figure.label.board-size": "size on a drawing",
1206
+ "figure.text.board-size-fixed": "On a drawing it is always drawn at {x} × {y} × {z} mm.",
1207
+ "figure.text.board-size-fixed-explained": "Press it to let a drawing resize this figure. Pick a part and press 「fit to the figure size」 to make that part grow with it.",
1208
+ "figure.text.board-size-range-explained": "A drawing can resize it within these ranges. Only the parts that follow its size grow with it; the rest stay as they are.",
1209
+ "figure.text.board-size-followers": "parts that change with its size: {parts}",
1210
+ "figure.text.board-size-followers-more": "{first} and {rest} more",
1211
+ "figure.text.board-size-no-followers": "no part changes with its size"
1209
1212
  }