@things-factory/figure-ui 10.1.91 → 10.1.93

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 (56) hide show
  1. package/client/modeller/editor.ts +61 -90
  2. package/client/modeller/fasten-geometry.ts +152 -0
  3. package/client/modeller/fields.ts +8 -1
  4. package/client/modeller/figure-cavity-drawing.ts +259 -0
  5. package/client/modeller/figure-fasten-drawing.ts +406 -0
  6. package/client/modeller/figure-links.ts +71 -35
  7. package/client/modeller/figure-section-close-choice.ts +52 -0
  8. package/client/modeller/figure-section-editor.ts +58 -3
  9. package/client/modeller/figure-section-panel.ts +5 -0
  10. package/client/modeller/section-controls.ts +19 -0
  11. package/client/modeller/side-panel-styles.ts +4 -11
  12. package/dist-client/modeller/editor.d.ts +10 -4
  13. package/dist-client/modeller/editor.js +59 -88
  14. package/dist-client/modeller/editor.js.map +1 -1
  15. package/dist-client/modeller/fasten-geometry.d.ts +60 -0
  16. package/dist-client/modeller/fasten-geometry.js +138 -0
  17. package/dist-client/modeller/fasten-geometry.js.map +1 -0
  18. package/dist-client/modeller/fields.js +8 -1
  19. package/dist-client/modeller/fields.js.map +1 -1
  20. package/dist-client/modeller/figure-cavity-drawing.d.ts +48 -0
  21. package/dist-client/modeller/figure-cavity-drawing.js +253 -0
  22. package/dist-client/modeller/figure-cavity-drawing.js.map +1 -0
  23. package/dist-client/modeller/figure-fasten-drawing.d.ts +74 -0
  24. package/dist-client/modeller/figure-fasten-drawing.js +452 -0
  25. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -0
  26. package/dist-client/modeller/figure-links.d.ts +7 -0
  27. package/dist-client/modeller/figure-links.js +70 -33
  28. package/dist-client/modeller/figure-links.js.map +1 -1
  29. package/dist-client/modeller/figure-section-close-choice.d.ts +17 -0
  30. package/dist-client/modeller/figure-section-close-choice.js +58 -0
  31. package/dist-client/modeller/figure-section-close-choice.js.map +1 -0
  32. package/dist-client/modeller/figure-section-editor.d.ts +11 -1
  33. package/dist-client/modeller/figure-section-editor.js +57 -3
  34. package/dist-client/modeller/figure-section-editor.js.map +1 -1
  35. package/dist-client/modeller/figure-section-panel.d.ts +1 -0
  36. package/dist-client/modeller/figure-section-panel.js +5 -0
  37. package/dist-client/modeller/figure-section-panel.js.map +1 -1
  38. package/dist-client/modeller/section-controls.d.ts +10 -0
  39. package/dist-client/modeller/section-controls.js +17 -0
  40. package/dist-client/modeller/section-controls.js.map +1 -1
  41. package/dist-client/modeller/side-panel-styles.js +4 -11
  42. package/dist-client/modeller/side-panel-styles.js.map +1 -1
  43. package/dist-client/tsconfig.tsbuildinfo +1 -1
  44. package/package.json +2 -2
  45. package/test/browser/acceptance-page.js +10 -0
  46. package/test/cavity-drawing.test.ts +69 -0
  47. package/test/fasten-drawing.test.ts +45 -0
  48. package/test/fasten-geometry.test.ts +79 -0
  49. package/test/layout.test.ts +19 -4
  50. package/test/links.test.ts +3 -1
  51. package/test/section-editor.test.ts +31 -8
  52. package/translations/en.json +29 -15
  53. package/translations/ja.json +25 -11
  54. package/translations/ko.json +29 -15
  55. package/translations/ms.json +25 -11
  56. package/translations/zh.json +25 -11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.91",
3
+ "version": "10.1.93",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -44,5 +44,5 @@
44
44
  "@things-factory/figure-service": "^10.1.84",
45
45
  "three": "^0.185.1"
46
46
  },
47
- "gitHead": "e9a052f7faaeb5884db1ff8b862b8f05567a31f9"
47
+ "gitHead": "cec4765a5a0c3e7540a822c852110bec7f27250c"
48
48
  }
@@ -56,6 +56,16 @@ export const MODELS = {
56
56
  a = applyFigureAuthoring(a, { kind: 'attach', part: 'cap', face: 'bottom', to: { part: 'wedge', face: 'top' } })
57
57
  return serializeFigureAsset(a)
58
58
  },
59
+ /* A lid fastened to the top of a bin standing on the floor: the fastening drawn and dragged (user 2026-10-01). */
60
+ 'lid-on-bin': () => {
61
+ let a = createFigureAsset('Lid on bin')
62
+ a.palette = { ...a.palette, ...PALETTE }
63
+ a = addFigureBox(a, { id: 'bin', size: { x: 600, y: 300, z: 400 }, position: { x: 0, y: 150, z: 0 } })
64
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'bin', face: 'bottom', to: { plane: 'mounting-plane' } })
65
+ a = addFigureBox(a, { id: 'lid', size: { x: 300, y: 20, z: 200 }, position: { x: 0, y: 0, z: 0 } })
66
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'lid', face: 'bottom', to: { part: 'bin', face: 'top' } })
67
+ return serializeFigureAsset(a)
68
+ },
59
69
  /* A box and a drum: rounded corners and an inside from the properties tab (ADR-0100 stage 1). */
60
70
  shapes: () => {
61
71
  let a = createFigureAsset('Box and drum')
@@ -0,0 +1,69 @@
1
+ /*
2
+ * **속 공간 그림** (사용자 2026-10-01: 「속공간 만들기도 시각적으로 편집할 수 있도록」).
3
+ *
4
+ * 벽 손잡이를 끄는 것, 끝을 눌러 여닫는 것이 figure-model 의 set-cavity 한 명령으로 받아들여지는지, 커널이 거절할 모양은
5
+ * 보내지 않고 그 까닭을 말하는지, 끄는 동안은 미리보기만 하고 놓을 때 한 번 보내는지를 잰다.
6
+ */
7
+ import { test } from 'node:test'
8
+ import assert from 'node:assert/strict'
9
+ import { readFileSync } from 'node:fs'
10
+ import { fileURLToPath } from 'node:url'
11
+
12
+ import { addFigureBox, applyFigureAuthoring, compileFigureAsset, createFigureAsset } from '@hatiolab/figure-model'
13
+ import type { FigureAsset, FigureEvaluatedShape } from '@hatiolab/figure-model'
14
+
15
+ import { cavityRefusalOf, cavityResultOf, cavityWorkingOf, endToggled, wallDragged } from '../client/modeller/section-controls.ts'
16
+
17
+ const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
18
+ const has = (source: string, re: RegExp, message?: string) => assert.ok(re.test(source), message ?? `missing: ${re}`)
19
+ const drawing = read('../client/modeller/figure-cavity-drawing.ts')
20
+ const panel = read('../client/modeller/figure-section-panel.ts')
21
+
22
+ const tote = (): FigureAsset => {
23
+ const box = addFigureBox(createFigureAsset('Tote'), { id: 'bin', size: { x: 600, y: 300, z: 400 }, position: { x: 0, y: 150, z: 0 } })
24
+ return applyFigureAuthoring(box, { kind: 'set-cavity', part: 'bin', cavity: { wall: 10, start: { closed: 15 }, end: 'open' } })
25
+ }
26
+ const shapeOf = (asset: FigureAsset) =>
27
+ (compileFigureAsset(asset).evaluate().geometry.find(g => (g as { id: string }).id === 'bin') as unknown as { shape: FigureEvaluatedShape }).shape
28
+ const send = (asset: FigureAsset, w: NonNullable<ReturnType<typeof cavityWorkingOf>>) =>
29
+ applyFigureAuthoring(asset, { kind: 'set-cavity', part: 'bin', cavity: { wall: w.wall, start: w.start, end: w.end } })
30
+
31
+ test('벽 손잡이: 한 벽을 끌면 네 벽이 같이, 면마다 벽이면 그 벽만 — 한 명령으로 들어간다', () => {
32
+ const a = tote()
33
+ const w = cavityWorkingOf(shapeOf(a))!
34
+ assert.deepEqual(w.wall, { 'x-': 10, 'x+': 10, 'z-': 10, 'z+': 10 })
35
+ const together = send(a, { ...w, wall: wallDragged(w.wall, 'x-', 24.6, false) })
36
+ assert.deepEqual(shapeOf(together).cavity!.wall, { 'x-': 25, 'x+': 25, 'z-': 25, 'z+': 25 })
37
+ const one = send(a, { ...w, wall: wallDragged(w.wall, 'z+', 40, true) })
38
+ assert.deepEqual(shapeOf(one).cavity!.wall, { 'x-': 10, 'x+': 10, 'z-': 10, 'z+': 40 })
39
+ assert.deepEqual(wallDragged(w.wall, 'x-', -5, false)['x-'], 1, 'never below 1 mm')
40
+ })
41
+
42
+ test('끝을 누르면 여닫힌다: 위도 아래도 열면 관, 닫으면 기본 두께로', () => {
43
+ const a = tote()
44
+ const s = shapeOf(a)
45
+ const w = cavityWorkingOf(s)!
46
+ const pipe = send(a, { ...w, start: endToggled(s, w.start) })
47
+ assert.equal(cavityResultOf(shapeOf(pipe).cavity!), 'pipe')
48
+ const sealed = send(a, { ...w, end: endToggled(s, w.end) })
49
+ assert.equal(cavityResultOf(shapeOf(sealed).cavity!), 'sealed')
50
+ assert.deepEqual(shapeOf(sealed).cavity!.end, { closed: 40 }, 'the default wall: a tenth of the narrower side')
51
+ })
52
+
53
+ test('커널이 거절할 모양은 보내지 않고 까닭을 말한다 — 끄는 동안은 미리보기, 놓을 때 한 번', () => {
54
+ const s = shapeOf(tote())
55
+ const w = cavityWorkingOf(s)!
56
+ assert.equal(cavityRefusalOf(s, { ...w, wall: wallDragged(w.wall, 'x-', 250, false) })?.code, 'wall-too-thick')
57
+ assert.equal(cavityRefusalOf(s, { ...w, start: { closed: 200 }, end: { closed: 150 } })?.code, 'ends-too-thick')
58
+ has(drawing, /this\.emit\('command-live', this\.refusal \? null : this\.action\(w\)\)/, 'a refused shape is not previewed')
59
+ has(drawing, /if \(this\.refusal\) \{\s*this\.emit\('command-live', null\)\s*return\s*\}\s*this\.emit\('command', this\.action\(w\)\)/, 'let go: one command, or nothing -- and the reason stays said')
60
+ has(drawing, /this\.refusal = this\.judge\(next\)\s*if \(!this\.refusal\) this\.emit\('command', this\.action\(next\)\)/, 'a press toggles by one command')
61
+ })
62
+
63
+ test('단면 칸에 그림이 들어간다: 속 공간이 있을 때, 면마다 벽 토글을 따른다', () => {
64
+ has(panel, /cavity \? html`<figure-cavity-drawing \.body=\$\{body\} \.perSide=\$\{this\.perSide\} \?locked=\$\{this\.locked\}><\/figure-cavity-drawing>` : nothing/)
65
+ for (const lang of ['ko', 'en', 'ja', 'zh', 'ms']) {
66
+ const t = JSON.parse(read(`../translations/${lang}.json`)) as Record<string, string>
67
+ for (const key of ['figure.label.section-cavity-top', 'figure.label.section-cavity-side']) assert.ok(t[key], `${lang} ${key}`)
68
+ }
69
+ })
@@ -0,0 +1,45 @@
1
+ /*
2
+ * **붙이기 그림이 폼에 들어간 모습** (사용자 2026-10-01).
3
+ *
4
+ * 부품의 면은 고르지 않는다. 상대(부품 또는 배치 기준의 면)와 그 면, 방식만 그림으로 고른다. 이미 붙은 부품은 끌기를 놓으면
5
+ * 그 자리로 한 번 고쳐지고, 새로 붙이는 부품은 「붙이기」를 기다린다. 화면에 풀이 문구를 두지 않는다.
6
+ */
7
+ import { test } from 'node:test'
8
+ import assert from 'node:assert/strict'
9
+ import { readFileSync } from 'node:fs'
10
+ import { fileURLToPath } from 'node:url'
11
+
12
+ const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
13
+ const has = (source: string, re: RegExp, message?: string) => assert.ok(re.test(source), message ?? `missing: ${re}`)
14
+ const form = read('../client/modeller/figure-links.ts')
15
+ const drawing = read('../client/modeller/figure-fasten-drawing.ts')
16
+ const ko = JSON.parse(read('../translations/ko.json')) as Record<string, string>
17
+ const say = (key: string, params: Record<string, string>) => Object.entries(params).reduce((s, [k, v]) => s.replace(`{${k}}`, v), ko[key] ?? key)
18
+
19
+ test('부품의 면·맞닿기 고르기 칸이 없고, 그 자리에 그림이 있다', () => {
20
+ assert.ok(!form.includes("i18next.t('figure.label.my-face')"), 'no list of the part\'s own faces')
21
+ assert.ok(!form.includes("i18next.t('figure.label.facing')"), 'no facing list')
22
+ has(form, /<figure-fasten-drawing[\s\S]*?\.modes=\$\{f\.toPlane \? \(\['meet'\] as FastenMode\[\]\)/, 'the plane is set on only')
23
+ has(form, /turnsInPlace\(asset, this\.partId\) \? \(\['centre'\] as FastenMode\[\]\) : \[\]/, 'centre on it only for a part that turns')
24
+ has(form, /const derived = f\.toPlane \|\| f\.target \? myFaceFor\(asset, this\.partId, aim, mode\) : undefined/, 'the part\'s face follows the target and how')
25
+ })
26
+
27
+ test('상대 목록에 배치 기준의 면이 이름으로 있다: 바닥 · 천장 · 벽 · 중심', () => {
28
+ has(form, /const planeWord = i18next\.t\('figure\.label\.resize-place-' \+ \(asset\.placement \?\? 'floor'\)\)/)
29
+ has(form, /<option value=\$\{PLANE\} \?selected=\$\{f\.toPlane\}>\$\{planeWord\}<\/option>/)
30
+ })
31
+
32
+ test('놓으면: 이미 붙은 부품은 한 번 고쳐지고, 새 부품은 붙이기를 기다린다', () => {
33
+ has(form, /private async onFastenRelease\(held: boolean\) \{\s*if \(!held\) return\s*await this\.updateComplete\s*if \(this\.fastenCandidate\) this\.command\(this\.fastenCandidate\.action\)/)
34
+ has(drawing, /private drop\(\) \{[\s\S]*?this\.released\(\)\s*\}/, 'a drag ends in one release')
35
+ has(drawing, /this\.change\(\{ targetFace: f \}\)\s*this\.released\(\)/, 'a face pressed is one change')
36
+ has(drawing, /this\.change\(\{ mode: m \}\)\s*this\.released\(\)/, 'a way pressed is one change')
37
+ })
38
+
39
+ test('화면 문구는 결과와 예외만 — 한 줄 요약, 가장자리 밖, 닿지 않음', () => {
40
+ assert.equal(say('figure.text.fasten-sentence-meet', { part: 'lid', face: '아랫면', to: 'bin의 윗면' }), 'lid의 아랫면을 bin의 윗면에 맞닿게')
41
+ assert.equal(say('figure.text.fasten-sentence-centre', { part: 'roller', face: '', to: 'frame의 앞면' }), 'roller의 가운데를 frame의 앞면 위에')
42
+ for (const key of ['figure.text.fasten-off-face', 'figure.text.fasten-past-edges', 'figure.label.fasten-mode-meet', 'figure.label.fasten-mode-flush', 'figure.label.fasten-mode-centre'])
43
+ for (const lang of ['ko', 'en', 'ja', 'zh', 'ms']) assert.ok((JSON.parse(read(`../translations/${lang}.json`)) as Record<string, string>)[key], `${lang} ${key}`)
44
+ assert.ok(!/끌어 옮깁니다|면 밖으로도/.test(JSON.stringify(ko)), 'no instructions on the screen')
45
+ })
@@ -0,0 +1,79 @@
1
+ /*
2
+ * **붙이기 그림이 읽는 것** (사용자 2026-10-01: 「면을 선택하는 것이 복잡해보인다」).
3
+ *
4
+ * 붙이기는 상대 면과 방식(올려놓기 · 같은 면 · 가운데를 면에)만 고른다. 부품의 면은 거기서 정해진다 — 돌려 놓인 부품이면
5
+ * 지금 그 방향을 보는 면이다. 면 위에 놓은 자리는 가운데·안쪽 끝·바깥 끝에 붙고, 그 밖은 mm 다. figure-model 이 그 명령을
6
+ * 받아 부품을 정말 그 자리에 두는지까지 잰다.
7
+ */
8
+ import { test } from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+
11
+ import { addFigureBox, applyFigureAuthoring, compileFigureAsset, createFigureAsset, figurePartFaceOf } from '@hatiolab/figure-model'
12
+ import type { FigureAsset, FigureAuthoringAction } from '@hatiolab/figure-model'
13
+
14
+ import { alignmentAt, faceViewOf, myFaceFor, offsetOf, overhangOf, screenOf } from '../client/modeller/fasten-geometry.ts'
15
+
16
+ const scene = (lidRotation?: { x: number; y: number; z: number }): FigureAsset => {
17
+ let a = addFigureBox(createFigureAsset('Box'), { id: 'bin', size: { x: 600, y: 300, z: 400 }, position: { x: 0, y: 150, z: 0 } })
18
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'bin', face: 'bottom', to: { plane: 'mounting-plane' } })
19
+ return addFigureBox(a, { id: 'lid', size: { x: 300, y: 20, z: 200 }, position: { x: 0, y: 400, z: 0 }, ...(lidRotation ? { rotation: lidRotation } : {}) })
20
+ }
21
+ const at = (a: FigureAsset, id: string) => (compileFigureAsset(a).evaluate().geometry.find(g => (g as { id: string }).id === id) as unknown as { pose: { t: number[] } }).pose.t
22
+
23
+ test('부품의 면은 상대 면과 방식으로 정해진다: 올려놓기는 마주 보는 면, 같은 면은 같은 면, 가운데는 가운데', () => {
24
+ const a = scene()
25
+ assert.equal(myFaceFor(a, 'lid', { part: 'bin', face: 'top' }, 'meet'), 'bottom')
26
+ assert.equal(myFaceFor(a, 'lid', { part: 'bin', face: 'top' }, 'flush'), 'top')
27
+ assert.equal(myFaceFor(a, 'lid', { part: 'bin', face: 'front' }, 'meet'), 'back')
28
+ assert.equal(myFaceFor(a, 'lid', { part: 'bin', face: 'top' }, 'centre'), 'centre')
29
+ assert.equal(myFaceFor(a, 'lid', 'plane', 'meet'), 'bottom', 'on a floor: the face looking down')
30
+ assert.equal(myFaceFor(a, 'lid', 'plane', 'centre'), undefined, 'the plane takes a face, never a centre')
31
+ })
32
+
33
+ test('돌려 놓인 부품이면 지금 아래를 보는 면이다 — 이름이 아니라 방향으로', () => {
34
+ const a = scene({ x: 90, y: 0, z: 0 })
35
+ const face = myFaceFor(a, 'lid', { part: 'bin', face: 'top' }, 'meet')!
36
+ assert.notEqual(face, 'bottom', 'turned a quarter about X, its bottom no longer looks down')
37
+ const n = figurePartFaceOf(a, 'lid', face)!.normal
38
+ assert.ok(Math.abs(n[1]! + 1) < 1e-6, `${face} looks down: ${n}`)
39
+ })
40
+
41
+ test('면 그림: 상대 면의 크기와 부품 발자국, 가장자리 맞춤이 되는 축', () => {
42
+ const v = faceViewOf(scene(), 'lid', { part: 'bin', face: 'top' })!
43
+ assert.deepEqual(v.axes, ['x', 'z'])
44
+ assert.deepEqual([v.face.x, v.face.z], [600, 400])
45
+ assert.deepEqual([v.part.x, v.part.z], [300, 200])
46
+ assert.deepEqual(v.edges, { x: true, z: true })
47
+ })
48
+
49
+ test('놓은 자리: 가운데, 안쪽 끝(가장자리와 나란히), 바깥 끝(가장자리에 닿게), 그 밖은 mm', () => {
50
+ assert.deepEqual(alignmentAt(4, 600, 300, true, 10), { how: 'centre', caught: 'centre' })
51
+ assert.deepEqual(alignmentAt(146, 600, 300, true, 10), { how: 'max', caught: 'inside' })
52
+ assert.deepEqual(alignmentAt(-150, 600, 300, false, 10), { how: 'mm', mm: -150, caught: 'inside' }, 'no edges to line up: a distance that sits there')
53
+ assert.deepEqual(alignmentAt(452, 600, 300, true, 10), { how: 'mm', mm: 450, caught: 'outside' })
54
+ assert.deepEqual(alignmentAt(212.4, 600, 300, true, 10), { how: 'mm', mm: 212 })
55
+ assert.equal(offsetOf('max', undefined, 600, 300), 150)
56
+ assert.equal(offsetOf('mm', 212, 600, 300), 212)
57
+ })
58
+
59
+ test('면 밖으로 나온 곳과 닿지 않는 것을 말한다', () => {
60
+ const v = faceViewOf(scene(), 'lid', { part: 'bin', face: 'top' })!
61
+ assert.deepEqual(overhangOf(v, { x: 0, y: 0, z: 0 }), { touching: true, sides: [] })
62
+ assert.deepEqual(overhangOf(v, { x: 200, y: 0, z: 0 }), { touching: true, sides: [{ axis: 'x', side: 'high', mm: 50 }] })
63
+ assert.equal(overhangOf(v, { x: 460, y: 0, z: 0 }).touching, false)
64
+ })
65
+
66
+ test('그 자리가 명령이 되면 figure-model 이 부품을 정말 거기 둔다: 오른쪽 끝, 같은 면, 바깥 끝', () => {
67
+ const a = scene()
68
+ const attach = (extra: Partial<Extract<FigureAuthoringAction, { kind: 'attach' }>>) =>
69
+ applyFigureAuthoring(a, { kind: 'attach', part: 'lid', face: 'bottom', to: { part: 'bin', face: 'top' }, ...extra } as FigureAuthoringAction)
70
+ assert.deepEqual(at(attach({ align: { x: 'max' } }), 'lid').map(Math.round), [150, 310, 0])
71
+ assert.deepEqual(at(attach({ face: 'top', facing: 'flush' }), 'lid').map(Math.round), [0, 290, 0])
72
+ assert.deepEqual(at(attach({ align: { x: { mm: 450 } } }), 'lid').map(Math.round), [450, 310, 0], 'past the edge: fastened all the same')
73
+ })
74
+
75
+ test('면은 서 있는 대로 보인다: 앞면은 X 가로·Y 위로, 옆면은 Z 가로·Y 위로, 윗면은 X 가로·Z 아래로', () => {
76
+ assert.deepEqual(screenOf(['x', 'y']), { h: 'x', v: 'y', sign: { x: 1, y: -1, z: 1 } })
77
+ assert.deepEqual(screenOf(['y', 'z']), { h: 'z', v: 'y', sign: { x: 1, y: -1, z: 1 } })
78
+ assert.deepEqual(screenOf(['x', 'z']), { h: 'x', v: 'z', sign: { x: 1, y: 1, z: 1 } })
79
+ })
@@ -37,12 +37,12 @@ test('부품을 고르면 — 도형 전체의 탭(판정 · 이력)은 그 부
37
37
  assert.ok(renderBody.includes(from), from)
38
38
  })
39
39
 
40
- /* B.1 (designer 2026-09-27): the path -- model name › part -- on every tab; the model name is the one way back, the same move as the list's root. */
41
- test('모델명 › 부품 경로가 탭마다 있고, 모델명은 목록 루트와 같은 한 동작으로 모델 설정을 연다', () => {
40
+ /* B.1 (designer 2026-09-27), amended by the user 2026-10-01: no path -- the list and the canvas show the choice; the list's root goes back. */
41
+ test('패널 머리에는 경로가 없고 부품의 세 줄만 — 모델 설정은 목록 루트의 한 동작으로 연다', () => {
42
42
  assert.ok((renderBody.match(/\$\{this\.renderSubject\(\)\}/g) ?? []).length >= 3)
43
43
  const subject = editor.slice(editor.indexOf(' private renderSubject()'), editor.indexOf(' private showModelSettings()'))
44
- assert.ok(subject.includes('@click=${() => this.showModelSettings()}'))
45
- assert.ok(!subject.includes('whole-figure'), 'no separate "whole figure" link')
44
+ assert.ok(!subject.includes('<nav path'), 'no path')
45
+ assert.ok(subject.includes('if (!summary) return nothing'), 'nothing chosen: no head')
46
46
  const show = editor.slice(editor.indexOf(' private showModelSettings()'), editor.indexOf(' render() {'))
47
47
  assert.ok(show.includes("this.selected = ''") && show.includes("this.tab = 'properties'"), 'the model settings, on the properties tab')
48
48
  assert.ok(!/session!?\.(command|apply|edit|undo)/.test(show), 'moving changes no data and no history')
@@ -82,3 +82,18 @@ test('탭 아이콘은 오른쪽 칸 맨 위 — 타입 칸은 속성 탭 안의
82
82
  const page = read('../client/pages/figure-modeller-page.ts')
83
83
  assert.ok(page.includes('<label slot="type" type-field>'))
84
84
  })
85
+
86
+ /* User 2026-10-01: 「맞추기 아이콘 … 무엇을 하는 것인지 모르겠다」 -- words, and what moves the part apart from what makes one. */
87
+ test('맞추기는 글자 단추로 — 옮기는 넷(가운데 X·Z, 면에 대기, 눈금)과 새 부품을 만드는 대칭 복사를 다른 줄에 둔다', () => {
88
+ const tools = editor.slice(editor.indexOf(' private renderTools('), editor.indexOf(" /**\n * A part's name."))
89
+ assert.ok(!tools.includes('<md-icon>'), 'no icon-only buttons')
90
+ assert.ok(tools.includes("i18next.t('figure.label.tools')") && tools.includes("i18next.t('figure.label.mirror-copy')"), 'two rows, each named')
91
+ const plane = editor.slice(editor.indexOf(' private renderPlaneAlign('), editor.indexOf(' private alignToPlane()'))
92
+ assert.ok(!plane.includes('<md-icon>') && plane.includes('figure.button.align-to-${placement}'))
93
+ const ko = JSON.parse(readFileSync(fileURLToPath(new URL('../translations/ko.json', import.meta.url)), 'utf8')) as Record<string, string>
94
+ assert.deepEqual(
95
+ ['center-x', 'center-z', 'align-to-floor', 'snap', 'mirror-x'].map(k => ko[`figure.button.${k}`]),
96
+ ['X 가운데로', 'Z 가운데로', '바닥에 내려놓기', '눈금에 맞추기', 'X 반대편에']
97
+ )
98
+ assert.ok(!ko['figure.text.center-on-x']!.includes('기준 상자'), 'says what the centre is')
99
+ })
@@ -331,7 +331,9 @@ test('붙여도 제자리 — 폼은 지금 서 있는 자리를 가운데서
331
331
  row = applyFigureAuthoring(row, { kind: 'attach', part: 'cap', face: 'bottom', to: { part: 'roller', face: 'top' } })
332
332
  assert.deepEqual([hangsFrom(row, 'roller', 'plate'), hangsFrom(row, 'cap', 'plate'), hangsFrom(row, 'plate', 'roller')], [true, true, false])
333
333
  const form = read('../client/modeller/figure-links.ts')
334
- assert.equal(form.match(/this\.aimAt\(asset, \{ \.\.\.f, (target|targetFace):/g)?.length, 2, '상대 부품 · 상대 면을 바꿀 때마다')
334
+ /* The target (a part or the plane, user 2026-10-01) and its face: the form is filled in whenever either changes. */
335
+ assert.ok(form.includes("this.aimAt(asset, value === PLANE ? { ...f, toPlane: true, target: '' } : { ...f, toPlane: false, target: value })"), '상대를 바꿀 때마다')
336
+ assert.ok(form.includes("this.aimAt(asset, { ...f, targetFace: c.targetFace as FigureFace })"), '상대 면을 바꿀 때마다')
335
337
  assert.ok(!form.includes("figure.label.stands-on-plane"), '부품 붙이기 폼에 모델 설치 기준을 섞지 않는다')
336
338
  /* Kept in the list, not choosable, with the reason (ADR-0097 §5.5). */
337
339
  assert.ok(form.includes('const loops = (one: string) => hangsFrom(asset, one, this.partId)'))
@@ -121,9 +121,10 @@ test('적용은 한 명령, 취소·Esc 는 아무것도 보내지 않는다 —
121
121
  has(editorPanel, /return this\.revision !== this\.openedAt/)
122
122
  has(editor, /<figure-section-editor[\s\S]*?\.revision=\$\{this\.session\?\.read\(\)\.revision \?\? 0\}[\s\S]*?@command=\$\{\(e: CustomEvent\) => this\.linkCommand\(e\.detail\.action\)\}/)
123
123
  has(editor,
124
- /protected willUpdate[\s\S]*?if \(this\.sectionEditing && this\.sectionEditing !== this\.selected\) this\.closeSectionEditor\(\)\s*\}/,
124
+ /protected willUpdate[\s\S]*?if \(this\.sectionEditing && this\.sectionEditing !== this\.selected\) this\.leaveSectionEditor\(this\.selected\)\s*\}/,
125
125
  'the selection moved off the part by any way closes it and lets go of the preview'
126
126
  )
127
+ has(editor, /if \(!editor\?\.dirty \|\| !stillThere\) \{\s*this\.closeSectionEditor\(\)/, 'nothing changed, or nothing to go back to: it just closes')
127
128
  has(editor, /private closeSectionEditor\(\) \{\s*this\.sectionEditing = null\s*this\.holdAction\(null\)/)
128
129
  has(sectionPanel, /section-editor-open/)
129
130
  })
@@ -133,7 +134,7 @@ test('Delete 키는 칸 안에서는 글자를, 밖에서는 고른 점을 지
133
134
  /* The scene listens on its own element and stops the key there: heard later, Esc selected nothing and Delete took the part. */
134
135
  has(editorPanel, /window\.addEventListener\('keydown', this\.keys, true\)/)
135
136
  has(editorPanel, /const take = \(\) => \(e\.preventDefault\(\), e\.stopImmediatePropagation\(\)\)/)
136
- has(editorPanel, /if \(e\.key === 'Escape'\) \{\s*take\(\)\s*this\.cancel\(\)/)
137
+ has(editorPanel, /if \(e\.key === 'Escape'\) \{\s*take\(\)\s*this\.requestClose\(\)/)
137
138
  })
138
139
 
139
140
  test('그림은 점을 다 담고 여유를 둔다 — 50 mm 단위로', () => {
@@ -177,10 +178,14 @@ test('편집 창이 열린 동안 모델러의 키는 비켜선다 — Esc 가
177
178
  assert.ok(onKey.indexOf('if (this.sectionEditing) return') < onKey.indexOf('keyActionOf('), 'before any key is taken')
178
179
  })
179
180
 
180
- test('속성 칸의 점 좌표도 커널 이름이 아니라 「점 1 Z」로 부른다 — 편집 창과 같은 말', () => {
181
- has(editor, /const point = \/\^point\\\.p\(\\d\+\)\\\.\(\[xyz\]\)\$\/\.exec\(name\)/)
182
- has(editor, /i18next\.t\('figure\.label\.section-editor-coordinate', \{ point: point\[1\]!, axis: point\[2\]!\.toUpperCase\(\) \}\)/)
183
- assert.equal(say('figure.label.section-editor-coordinate', { point: '1', axis: 'Z' }), '점 1 Z')
181
+ test('속성의 형상 치수에는 점·꼭짓점 칸이 없다 (사용자 10-01) — 높이만 남고, 단면 칸이 점 개수를 말한다', async () => {
182
+ const { groupFields } = await import('../client/modeller/fields.ts')
183
+ const f = (id: string) => ({ id, value: 0, unit: 'mm' })
184
+ const ids = ['tip.dimension.height', 'tip.point.p1.x', 'tip.point.p1.z', 'tip.point.p12.x', 'tip.apex.x', 'tip.apex.z', 'tip.position.x']
185
+ const { singles } = groupFields(ids.map(f) as never, 'tip')
186
+ assert.deepEqual(singles.map(g => (g as { field: { id: string } }).field.id), ['tip.dimension.height'])
187
+ has(sectionPanel, /e\.apex \? 'figure\.text\.section-points-apex' : 'figure\.text\.section-points', \{ points: String\(e\.section\.points\.length\) \}/)
188
+ assert.equal(say('figure.text.section-points-apex', { points: '3' }), '점 3개 · 꼭짓점 하나')
184
189
  })
185
190
 
186
191
  test('편집 창 옆에 입체를 볼 자리가 없으면 윤곽과 입체 결과를 번갈아 본다 (§7)', async () => {
@@ -286,8 +291,6 @@ test('편집 창에서 끝을 한 점으로: 켜면 점들의 가운데 위에
286
291
  assert.deepEqual(again.apex, { x: 0, z: 0 })
287
292
  assert.deepEqual(sectionOfWorking(p, again), { kind: 'polygon', points: again.points })
288
293
  assert.equal(issuesOfWorking(p, { ...again, apex: { x: Number.NaN, z: 0 } }).words[0]!.key, 'figure.text.section-issue-number')
289
- /* In the properties its apex is called as the editor calls it: 꼭짓점 X. */
290
- has(editor, /\? \[`\$\{i18next\.t\('figure\.label\.section-editor-apex-mark'\)\} \$\{apex\[1\]!\.toUpperCase\(\)\}`\]/)
291
294
  /* Its length is its height: the same field, called so. */
292
295
  has(editorPanel, /this\.working\.apex \? 'figure\.label\.section-editor-height' : 'figure\.label\.section-editor-length'/)
293
296
  const back = applyFigureAuthoring(pyramid, { kind: 'set-section', part: 'wedge', apex: null })
@@ -303,3 +306,23 @@ test('끝면에 붙은 부품이 있으면 한 점으로 모으기를 막고 그
303
306
  /* A pyramid in the section panel: its editor opens, and it has no corners and no inside. */
304
307
  has(sectionPanel, /const e = body\?\.shape \? drawnOf\(body\.shape\) : undefined/)
305
308
  })
309
+
310
+ test('닫기 전에 묻는다 (사용자 10-01): 바꾼 것이 있으면 적용·버리기·계속 편집 — 바꾼 것이 없으면 그냥 닫는다', () => {
311
+ const choice = read('../client/modeller/figure-section-close-choice.ts')
312
+ /* Esc and 취소 both ask; while it asks, the question has the keys. */
313
+ has(editorPanel, /if \(e\.key === 'Escape'\) \{\s*take\(\)\s*this\.requestClose\(\)/)
314
+ has(editorPanel, /<button @click=\$\{\(\) => this\.requestClose\(\)\}>\$\{i18next\.t\('figure\.button\.section-editor-cancel'\)\}/)
315
+ has(editorPanel, /if \(this\.asking\) return\n/)
316
+ has(editorPanel, /if \(!this\.dirty\) \{\s*this\.cancel\(\)\s*return true/)
317
+ /* Apply is only offered when it would be taken: not for a refused shape, not after the figure changed. */
318
+ has(editorPanel, /if \(choice === 'apply' && !held\)/)
319
+ has(choice, /\$\{this\.held\s*\? nothing/)
320
+ /* Picking another part while changes wait keeps the part selected until the editor closed. */
321
+ has(editor, /this\.selected = editing\s*editor\.requestClose\(\)\.then\(closed => \{\s*if \(closed && this\.selected === editing\) this\.selected = next/)
322
+ for (const lang of ['ko', 'en', 'ja', 'zh', 'ms']) {
323
+ const t = JSON.parse(read(`../translations/${lang}.json`)) as Record<string, string>
324
+ for (const key of ['figure.text.section-editor-close-changed', 'figure.text.section-editor-close-invalid', 'figure.button.section-editor-apply-and-close', 'figure.button.section-editor-discard', 'figure.button.keep-editing'])
325
+ assert.ok(t[key], `${lang} ${key}`)
326
+ }
327
+ assert.equal(ko['figure.button.section-editor-apply-and-close'], '적용하고 닫기')
328
+ })
@@ -5,8 +5,8 @@
5
5
  "figure.button.add-parameter": "add parameter",
6
6
  "figure.button.ask": "ask",
7
7
  "figure.button.cancel": "cancel",
8
- "figure.button.center-x": "centre X",
9
- "figure.button.center-z": "centre Z",
8
+ "figure.button.center-x": "to X centre",
9
+ "figure.button.center-z": "to Z centre",
10
10
  "figure.button.close": "close",
11
11
  "figure.button.delete-figure": "delete",
12
12
  "figure.button.discard": "discard",
@@ -20,8 +20,8 @@
20
20
  "figure.button.scale-uniformly": "grow by one ratio",
21
21
  "figure.text.sizing-uniform-proposed": "this figure has joints. growing it by one ratio keeps the joints together at any size.",
22
22
  "figure.text.sizing-uniform-explained": "grows the whole figure by the geometric mean of the three sizes a board gives it. the feet stay on the floor; room left above the figure stays empty.",
23
- "figure.button.mirror-x": "mirror X",
24
- "figure.button.mirror-z": "mirror Z",
23
+ "figure.button.mirror-x": "across X",
24
+ "figure.button.mirror-z": "across Z",
25
25
  "figure.button.move-part-down": "move down",
26
26
  "figure.button.move-part-up": "move up",
27
27
  "figure.button.new-figure": "new figure",
@@ -33,7 +33,7 @@
33
33
  "figure.button.revert": "revert",
34
34
  "figure.button.save": "save",
35
35
  "figure.button.see-the-trend-chart": "see the trend chart",
36
- "figure.button.snap": "snap",
36
+ "figure.button.snap": "snap to grid",
37
37
  "figure.label.declared-space": "declared space",
38
38
  "figure.label.follows-expression": "set by an expression",
39
39
  "figure.label.gizmo-rotate": "turn",
@@ -388,8 +388,8 @@
388
388
  "figure.text.category-explained": "how the palette groups figures. one per figure.",
389
389
  "figure.text.category-placeholder": "e.g. conveyor",
390
390
  "figure.text.ceiling-height-explained": "the height a ceiling figure hangs from in the preview.",
391
- "figure.text.center-on-x": "move to the centre of the base box on X.",
392
- "figure.text.center-on-z": "move to the centre of the base box on Z.",
391
+ "figure.text.center-on-x": "moves it to the figure's X centre (X=0).",
392
+ "figure.text.center-on-z": "moves it to the figure's Z centre (Z=0).",
393
393
  "figure.text.clip-is-time-running": "plays over time. the value an instance gives is the playback speed.",
394
394
  "figure.text.clip-name-is-how-it-is-driven": "an instance gives a speed by this name.",
395
395
  "figure.text.clip-name-taken": "another clip already has this name. enter a different name.",
@@ -502,7 +502,6 @@
502
502
  "figure.text.tags-explained": "tags used for search. separate several with commas.",
503
503
  "figure.text.tags-placeholder": "e.g. outbound, automatic, level 2",
504
504
  "figure.text.thumbnail-not-made-yet": "no thumbnail yet",
505
- "figure.text.tools-explained": "from left: centre X, centre Z, to floor, mirror X, mirror Z, snap to grid.",
506
505
  "figure.text.triangle-count": "{count} triangles",
507
506
  "figure.text.type-name-already-taken": "the type name {type} is already in use. pick another one.",
508
507
  "figure.text.type-name-cannot-change": "the type name cannot be changed after creation.",
@@ -884,8 +883,6 @@
884
883
  "figure.text.placement-is-intent": "What the figure is authored from. You can choose it before any part; it does not change the declared space.",
885
884
  "figure.button.show-model-settings": "Show model settings",
886
885
  "figure.text.new-figure-name": "New figure",
887
- "figure.label.panel-path": "What the panel shows",
888
- "figure.label.model-settings": "Model settings",
889
886
  "figure.text.fasten-editing": "Editing the current attachment: its {face} on {target}",
890
887
  "figure.text.fasten-new": "Attach anew",
891
888
  "figure.text.fasten-start": "Attach this part to a face on another part so they keep their relationship when either changes.",
@@ -894,10 +891,10 @@
894
891
  "figure.text.fasten-target-first": "Choose the part to attach to first.",
895
892
  "figure.text.fasten-edit-on-plane": "This part is connected to the model's placement reference. Detach it before attaching it to another part.",
896
893
  "figure.button.fasten-update": "Update this attachment",
897
- "figure.button.align-to-floor": "Align to the floor",
898
- "figure.button.align-to-ceiling": "Align to the ceiling",
899
- "figure.button.align-to-wall": "Align to the wall",
900
- "figure.button.align-to-center": "Align to the centre height",
894
+ "figure.button.align-to-floor": "set on the floor",
895
+ "figure.button.align-to-ceiling": "up to the ceiling",
896
+ "figure.button.align-to-wall": "back to the wall",
897
+ "figure.button.align-to-center": "to centre height",
901
898
  "figure.text.align-to-floor": "Moves it once so its bottom is on the floor (Y=0). It is not attached.",
902
899
  "figure.text.align-to-ceiling": "Moves it once so its top is on the ceiling (Y=0). It is not attached.",
903
900
  "figure.text.align-to-wall": "Moves it once so its back is on the wall (Z=0). It is not attached.",
@@ -945,5 +942,22 @@
945
942
  "figure.label.section-editor-apex-at": "apex {axis} (mm)",
946
943
  "figure.label.section-editor-apex-mark": "apex",
947
944
  "figure.text.section-editor-apex-blocked": "A part is fastened to its {face}, so it cannot be drawn to one point.",
948
- "figure.label.section-editor-height": "height (mm)"
945
+ "figure.label.section-editor-height": "height (mm)",
946
+ "figure.text.section-editor-close-changed": "The section has changes not applied yet. What should happen to them?",
947
+ "figure.text.section-editor-close-invalid": "The shape as it is cannot be applied. Fix it and apply, or let it go.",
948
+ "figure.button.section-editor-apply-and-close": "apply and close",
949
+ "figure.button.section-editor-discard": "discard and close",
950
+ "figure.text.section-points": "{points} points",
951
+ "figure.text.section-points-apex": "{points} points · one apex",
952
+ "figure.label.mirror-copy": "mirror copy",
953
+ "figure.label.section-cavity-top": "the inside seen along the push",
954
+ "figure.label.section-cavity-side": "cut along the push",
955
+ "figure.label.fasten-mode-meet": "set on it",
956
+ "figure.label.fasten-mode-flush": "flush with it",
957
+ "figure.label.fasten-mode-centre": "centre on it",
958
+ "figure.text.fasten-off-face": "It does not touch {face}.",
959
+ "figure.text.fasten-past-edges": "It comes past the edges: {sides}",
960
+ "figure.text.fasten-sentence-meet": "{part}'s {face} set on {to}",
961
+ "figure.text.fasten-sentence-flush": "{part}'s {face} flush with {to}",
962
+ "figure.text.fasten-sentence-centre": "{part}'s centre on {to}"
949
963
  }
@@ -5,8 +5,8 @@
5
5
  "figure.button.add-parameter": "パラメーターを追加",
6
6
  "figure.button.ask": "ask",
7
7
  "figure.button.cancel": "キャンセル",
8
- "figure.button.center-x": "centre X",
9
- "figure.button.center-z": "centre Z",
8
+ "figure.button.center-x": "X 中央へ",
9
+ "figure.button.center-z": "Z 中央へ",
10
10
  "figure.button.close": "close",
11
11
  "figure.button.delete-figure": "delete",
12
12
  "figure.button.discard": "discard",
@@ -20,8 +20,8 @@
20
20
  "figure.button.scale-uniformly": "一つの比率で拡大",
21
21
  "figure.text.sizing-uniform-proposed": "関節のある図形です。一つの比率で拡大すると、どのサイズでも関節が離れません。",
22
22
  "figure.text.sizing-uniform-explained": "ボードが与えた 3 軸サイズの幾何平均の比率で図形全体を拡大します。足は床に置き、箱の上の余りは空けたままにします。",
23
- "figure.button.mirror-x": "mirror X",
24
- "figure.button.mirror-z": "mirror Z",
23
+ "figure.button.mirror-x": "X 反対側に",
24
+ "figure.button.mirror-z": "Z 反対側に",
25
25
  "figure.button.move-part-down": "move down",
26
26
  "figure.button.move-part-up": "move up",
27
27
  "figure.button.new-figure": "new figure",
@@ -33,7 +33,7 @@
33
33
  "figure.button.revert": "戻す",
34
34
  "figure.button.save": "save",
35
35
  "figure.button.see-the-trend-chart": "see the trend chart",
36
- "figure.button.snap": "snap",
36
+ "figure.button.snap": "グリッドに合わせる",
37
37
  "figure.label.declared-space": "空間宣言",
38
38
  "figure.label.follows-expression": "式で決まる",
39
39
  "figure.label.gizmo-rotate": "回転",
@@ -388,8 +388,8 @@
388
388
  "figure.text.category-explained": "パレットでまとめて表示する基準です。Figure ごとに 1 つだけ決めます。",
389
389
  "figure.text.category-placeholder": "例: コンベヤ",
390
390
  "figure.text.ceiling-height-explained": "天井基準の Figure をプレビューで吊り下げる高さです。",
391
- "figure.text.center-on-x": "move to the centre of the base box on X.",
392
- "figure.text.center-on-z": "move to the centre of the base box on Z.",
391
+ "figure.text.center-on-x": "図形の X 中央 (X=0) へ移動します。",
392
+ "figure.text.center-on-z": "図形の Z 中央 (Z=0) へ移動します。",
393
393
  "figure.text.clip-is-time-running": "時間に沿って再生されます。インスタンスが渡す値は再生速度です。",
394
394
  "figure.text.clip-name-is-how-it-is-driven": "インスタンスはこの名前で再生速度を渡します。",
395
395
  "figure.text.clip-name-taken": "同じ名前の clip がすでにあります。別の名前を入力してください。",
@@ -502,7 +502,6 @@
502
502
  "figure.text.tags-explained": "検索に使うタグです。カンマで区切って複数入力できます。",
503
503
  "figure.text.tags-placeholder": "例: 搬出, 自動, 2 階",
504
504
  "figure.text.thumbnail-not-made-yet": "no thumbnail yet",
505
- "figure.text.tools-explained": "左から X 中央、Z 中央、床に付ける、X 対称、Z 対称、目盛りに合わせるです。",
506
505
  "figure.text.triangle-count": "{count} triangles",
507
506
  "figure.text.type-name-already-taken": "the type name {type} is already in use. pick another one — it cannot be changed later.",
508
507
  "figure.text.type-name-cannot-change": "タイプ名は作成後に変更できません。",
@@ -884,8 +883,6 @@
884
883
  "figure.text.placement-is-intent": "作成の基準です。部品がなくても決められ、空間の宣言は変えません。",
885
884
  "figure.button.show-model-settings": "モデル設定を表示",
886
885
  "figure.text.new-figure-name": "新しい Figure",
887
- "figure.label.panel-path": "表示中の対象",
888
- "figure.label.model-settings": "モデル設定",
889
886
  "figure.text.fasten-editing": "現在の取り付けを編集: {face}を{target}に",
890
887
  "figure.text.fasten-new": "新しく取り付ける",
891
888
  "figure.text.fasten-start": "別の部品の面に取り付けると、相手の部品が変わっても関係を保ちます。",
@@ -945,5 +942,22 @@
945
942
  "figure.label.section-editor-apex-at": "頂点 {axis} (mm)",
946
943
  "figure.label.section-editor-apex-mark": "頂点",
947
944
  "figure.text.section-editor-apex-blocked": "{face}に部品が付いているため、一点にまとめられません。",
948
- "figure.label.section-editor-height": "高さ (mm)"
945
+ "figure.label.section-editor-height": "高さ (mm)",
946
+ "figure.text.section-editor-close-changed": "適用していない断面の変更があります。どうしますか?",
947
+ "figure.text.section-editor-close-invalid": "今の形は適用できません。直してから適用するか、破棄してください。",
948
+ "figure.button.section-editor-apply-and-close": "適用して閉じる",
949
+ "figure.button.section-editor-discard": "破棄して閉じる",
950
+ "figure.text.section-points": "点 {points} 個",
951
+ "figure.text.section-points-apex": "点 {points} 個 · 頂点 1 つ",
952
+ "figure.label.mirror-copy": "対称コピー",
953
+ "figure.label.section-cavity-top": "押し出し方向から見た内部",
954
+ "figure.label.section-cavity-side": "押し出し方向の断面",
955
+ "figure.label.fasten-mode-meet": "載せる",
956
+ "figure.label.fasten-mode-flush": "同じ面に合わせる",
957
+ "figure.label.fasten-mode-centre": "中心を面に",
958
+ "figure.text.fasten-off-face": "{face}に接していません。",
959
+ "figure.text.fasten-past-edges": "縁からはみ出しています: {sides}",
960
+ "figure.text.fasten-sentence-meet": "{part}の{face}を{to}に接して",
961
+ "figure.text.fasten-sentence-flush": "{part}の{face}を{to}と同じ面に",
962
+ "figure.text.fasten-sentence-centre": "{part}の中心を{to}の上に"
949
963
  }