@things-factory/figure-ui 10.1.68 → 10.1.70

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 (68) hide show
  1. package/client/graphql/index.ts +89 -0
  2. package/client/modeller/figure-v3-links.ts +73 -7
  3. package/client/modeller/figure-v3-parts.ts +153 -2
  4. package/client/modeller/v3-editor.ts +133 -5
  5. package/client/modeller/v3-gizmo.ts +18 -2
  6. package/client/modeller/v3-links.ts +71 -2
  7. package/client/modeller/v3-palette.ts +2 -1
  8. package/client/modeller/v3-product-session.ts +8 -0
  9. package/client/modeller/v3-recipes.ts +69 -0
  10. package/client/pages/figure-recipe-page.ts +338 -0
  11. package/client/pages/figure-v3-modeller-page.ts +25 -2
  12. package/client/route.ts +4 -0
  13. package/dist-client/graphql/index.d.ts +21 -0
  14. package/dist-client/graphql/index.js +75 -0
  15. package/dist-client/graphql/index.js.map +1 -1
  16. package/dist-client/modeller/figure-v3-capability.d.ts +1 -1
  17. package/dist-client/modeller/figure-v3-clips.d.ts +1 -1
  18. package/dist-client/modeller/figure-v3-controls.d.ts +1 -1
  19. package/dist-client/modeller/figure-v3-links.d.ts +11 -1
  20. package/dist-client/modeller/figure-v3-links.js +69 -8
  21. package/dist-client/modeller/figure-v3-links.js.map +1 -1
  22. package/dist-client/modeller/figure-v3-material.d.ts +1 -1
  23. package/dist-client/modeller/figure-v3-parts.d.ts +15 -1
  24. package/dist-client/modeller/figure-v3-parts.js +166 -1
  25. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  26. package/dist-client/modeller/figure-v3-properties.d.ts +1 -1
  27. package/dist-client/modeller/v3-clips.d.ts +1 -1
  28. package/dist-client/modeller/v3-editor.d.ts +26 -0
  29. package/dist-client/modeller/v3-editor.js +147 -4
  30. package/dist-client/modeller/v3-editor.js.map +1 -1
  31. package/dist-client/modeller/v3-gizmo.d.ts +6 -0
  32. package/dist-client/modeller/v3-gizmo.js +20 -2
  33. package/dist-client/modeller/v3-gizmo.js.map +1 -1
  34. package/dist-client/modeller/v3-links.d.ts +27 -0
  35. package/dist-client/modeller/v3-links.js +68 -2
  36. package/dist-client/modeller/v3-links.js.map +1 -1
  37. package/dist-client/modeller/v3-palette.js +2 -1
  38. package/dist-client/modeller/v3-palette.js.map +1 -1
  39. package/dist-client/modeller/v3-product-session.d.ts +34 -0
  40. package/dist-client/modeller/v3-product-session.js +8 -0
  41. package/dist-client/modeller/v3-product-session.js.map +1 -1
  42. package/dist-client/modeller/v3-recipes.d.ts +26 -0
  43. package/dist-client/modeller/v3-recipes.js +69 -0
  44. package/dist-client/modeller/v3-recipes.js.map +1 -0
  45. package/dist-client/pages/figure-recipe-page.d.ts +41 -0
  46. package/dist-client/pages/figure-recipe-page.js +352 -0
  47. package/dist-client/pages/figure-recipe-page.js.map +1 -0
  48. package/dist-client/pages/figure-v3-modeller-page.d.ts +4 -0
  49. package/dist-client/pages/figure-v3-modeller-page.js +29 -2
  50. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  51. package/dist-client/route.d.ts +1 -1
  52. package/dist-client/route.js +3 -0
  53. package/dist-client/route.js.map +1 -1
  54. package/dist-client/tsconfig.fui.tsbuildinfo +1 -1
  55. package/dist-client/tsconfig.tsbuildinfo +1 -1
  56. package/package.json +5 -5
  57. package/test/browser/modeller-page.js +15 -1
  58. package/test/browser/modeller.check.mjs +217 -2
  59. package/test/modeller-route.test.ts +9 -0
  60. package/test/v3-gizmo.test.ts +44 -0
  61. package/test/v3-links.test.ts +91 -1
  62. package/test/v3-recipes.test.ts +60 -0
  63. package/things-factory.config.js +1 -0
  64. package/translations/en.json +40 -8
  65. package/translations/ja.json +40 -8
  66. package/translations/ko.json +40 -8
  67. package/translations/ms.json +40 -8
  68. package/translations/zh.json +40 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.68",
3
+ "version": "10.1.70",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -27,8 +27,8 @@
27
27
  "clean": "npm run clean:server && npm run clean:client"
28
28
  },
29
29
  "dependencies": {
30
- "@hatiolab/figure-model": "^0.1.80",
31
- "@hatiolab/things-scene": "^10.1.52",
30
+ "@hatiolab/figure-model": "^0.1.82",
31
+ "@hatiolab/things-scene": "^10.1.56",
32
32
  "@material/web": "^2.0.0",
33
33
  "@operato/data-grist": "^10.0.0",
34
34
  "@operato/graphql": "^10.0.0",
@@ -40,8 +40,8 @@
40
40
  "@operato/styles": "^10.0.0",
41
41
  "@operato/utils": "^10.0.0",
42
42
  "@things-factory/ai-assistant": "^10.1.58",
43
- "@things-factory/figure-service": "^10.1.68",
43
+ "@things-factory/figure-service": "^10.1.70",
44
44
  "three": "^0.185.1"
45
45
  },
46
- "gitHead": "0afdbab94f3cc477d0db1807d810e9d01aeb67a1"
46
+ "gitHead": "7c9c614b0cb1d02d401211db76ec0ca0ead344af"
47
47
  }
@@ -23,6 +23,19 @@ function sourceOf(kind, text) {
23
23
  asset.palette = { ...asset.palette, ...PALETTE }
24
24
  return serializeV3Asset(addV3Box(asset, { id: 'box', size: { x: 400, y: 400, z: 400 }, position: { x: 0, y: 200, z: 0 } }))
25
25
  }
26
+ if (kind === 'door') {
27
+ /* A frame, and a leaf set off it: 250 mm to the side and 100 mm in front, by its own numbers. */
28
+ let asset = createV3Asset('Door')
29
+ asset.palette = { ...asset.palette, ...PALETTE }
30
+ asset = addV3Box(asset, { id: 'frame', size: { x: 1600, y: 100, z: 150 }, position: { x: 0, y: 50, z: 0 } })
31
+ return serializeV3Asset(addV3Box(asset, { id: 'leaf', size: { x: 1400, y: 2200, z: 40 }, position: { x: 250, y: 1100, z: 100 } }))
32
+ }
33
+ if (kind === 'room') {
34
+ /* A room a recipe is put on: a 4 x 3 x 6 m box standing on the floor. */
35
+ const asset = createV3Asset('Room')
36
+ asset.palette = { ...asset.palette, ...PALETTE }
37
+ return serializeV3Asset(addV3Box(asset, { id: 'room', size: { x: 4000, y: 3000, z: 6000 }, position: { x: 0, y: 1500, z: 0 } }))
38
+ }
26
39
  throw Error(`no figure for ${kind}`)
27
40
  }
28
41
 
@@ -178,7 +191,7 @@ window.probe = {
178
191
  }
179
192
 
180
193
  window.harness = {
181
- async mount(kind, text) {
194
+ async mount(kind, text, recipes = []) {
182
195
  const source = sourceOf(kind, text)
183
196
  const session = createV3ProductSession({
184
197
  load: async id => ({ id, source }),
@@ -187,6 +200,7 @@ window.harness = {
187
200
  await session.open('probe')
188
201
  const editor = document.createElement('figure-v3-editor')
189
202
  editor.session = session
203
+ editor.recipes = recipes
190
204
  editor.revision = 0
191
205
  /* The modeller page counts a change the same way: the editor re-reads the session on a new revision. */
192
206
  editor.addEventListener('v3-changed', () => (editor.revision = editor.revision + 1))
@@ -13,8 +13,9 @@
13
13
  */
14
14
  import { test, describe, before, after } from 'node:test'
15
15
  import assert from 'node:assert/strict'
16
- import { readFileSync } from 'node:fs'
17
- import { dirname, resolve } from 'node:path'
16
+ import { readdirSync, readFileSync } from 'node:fs'
17
+ import { createRequire } from 'node:module'
18
+ import { dirname, join, resolve } from 'node:path'
18
19
  import { fileURLToPath } from 'node:url'
19
20
 
20
21
  import { buildPage } from './build.mjs'
@@ -528,6 +529,220 @@ describe('a motion is changed where it is (2026-09-25)', () => {
528
529
  })
529
530
  })
530
531
 
532
+ describe('the modeller made easier to find and to keep (2026-09-26 baseline)', () => {
533
+ /* The element on the page whose attribute or text is `key` (the check loads no translations: labels are keys). */
534
+ const find = (selector, key) =>
535
+ page().evaluateHandle(
536
+ (selector, key) => {
537
+ const deep = (root = document) => [...root.querySelectorAll(selector)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
538
+ return deep().find(e => e.getAttribute('aria-label') === key || e.textContent?.trim().endsWith(key)) ?? null
539
+ },
540
+ selector,
541
+ key
542
+ )
543
+ const centre = handle => handle.evaluate(e => {
544
+ e.scrollIntoView({ block: 'center' })
545
+ const r = e.getBoundingClientRect()
546
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 }
547
+ })
548
+
549
+ test('the bar offers move, turn and resize; a click puts that gizmo on the part, a second takes it off', async () => {
550
+ await mount('box')
551
+ await click(await read(() => probe.partOnPage('box')))
552
+ const resize = await find('button', 'figure.button.gizmo-scale')
553
+ assert.ok(await resize.evaluate(b => !!b && !b.disabled), 'resize is offered for a part of its own numbers')
554
+ await click(await centre(resize))
555
+ let s = await state()
556
+ assert.equal(s.mode, 'scale')
557
+ assert.ok(s.attached, 'the resize gizmo is on the box')
558
+ assert.equal(await resize.evaluate(b => b.getAttribute('aria-pressed')), 'true')
559
+ await click(await centre(resize))
560
+ s = await state()
561
+ assert.ok(!s.attached, 'pressed again, the gizmo goes')
562
+ assert.equal(await resize.evaluate(b => b.getAttribute('aria-pressed')), 'false')
563
+ })
564
+
565
+ test('a height typed into its field keeps the bottom on the floor, as the gizmo does', async () => {
566
+ await mount('box')
567
+ await click(await read(() => probe.partOnPage('box')))
568
+ const height = await find('input', 'box.size.y')
569
+ assert.ok(await height.evaluate(i => !!i), 'the height field')
570
+ const at = await centre(height)
571
+ await page().mouse.click(at.x, at.y, { clickCount: 3 })
572
+ await page().keyboard.type('1000')
573
+ await page().keyboard.press('Enter')
574
+ await sleep(600)
575
+ const now = await body('box')
576
+ assert.equal(Math.round(now.t[1] - now.dimensions.height / 2), 0, `bottom on the floor: centre ${now.t[1]}, height ${now.dimensions.height}`)
577
+ assert.equal(now.dimensions.height, 1000)
578
+ })
579
+
580
+ test('fastened to the floor, a leaf set off its frame stays where it stood', async () => {
581
+ await mount('door')
582
+ await click(await read(() => probe.row('leaf')))
583
+ const was = await body('leaf')
584
+ const box = await page().evaluate(() => {
585
+ const deep = (root = document) => [...root.querySelectorAll('label[toggle]')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
586
+ const label = deep().find(l => l.textContent.includes('figure.label.stands-on-plane'))
587
+ label.scrollIntoView({ block: 'center' })
588
+ const r = label.querySelector('input').getBoundingClientRect()
589
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 }
590
+ })
591
+ await click(box)
592
+ const fasten = await find('button', 'figure.button.fasten')
593
+ const start = await state()
594
+ await click(await centre(fasten))
595
+ const after = await state()
596
+ assert.equal(after.error, '', 'not refused')
597
+ assert.equal(after.revision, start.revision + 1, 'one step')
598
+ const now = await body('leaf')
599
+ assert.deepEqual([Math.round(now.t[0]), Math.round(now.t[2])], [Math.round(was.t[0]), Math.round(was.t[2])], `left-right and front-back kept: was ${was.t}, now ${now.t}`)
600
+ })
601
+
602
+ test('a part is shown while a control is at least a value: chosen in the motion tab, one step each, drawn or not', async () => {
603
+ await mount('arm')
604
+ await click(await read(() => probe.row('upper-arm')))
605
+ await click(await read(() => probe.tab('figure.label.motion')))
606
+ const shownSelect = () =>
607
+ page().evaluateHandle(() => {
608
+ const deep = (root = document) => [...root.querySelectorAll('select')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
609
+ return deep().find(s => s.previousElementSibling?.textContent?.trim() === 'figure.label.shown-when') ?? null
610
+ })
611
+ const select = await shownSelect()
612
+ assert.ok(await select.evaluate(s => !!s), 'the motion tab asks when the part is shown')
613
+ const control = await select.evaluate(s => s.options[1].value)
614
+ const start = await state()
615
+ await select.evaluate(s => s.scrollIntoView({ block: 'center' }))
616
+ await select.select(control)
617
+ await sleep(500)
618
+ assert.equal((await state()).revision, start.revision + 1, 'one step')
619
+ const drawn = () => read(() => document.querySelector('figure-v3-editor').session.read().geometry.find(g => g.id === 'upper-arm')?.visible)
620
+ const { value, atLeast } = await read(control => {
621
+ const asset = JSON.parse(document.querySelector('figure-v3-editor').session.source())
622
+ const node = asset.document.model.nodes.find(n => n.op === 'at-least@1' && n.args[0] === control)
623
+ return { value: asset.stateDefaults[control], atLeast: asset.document.model.constants.find(c => c.id === node.args[1]).value }
624
+ }, control)
625
+ assert.equal(await drawn(), value >= atLeast, `${control} at ${value}, shown from ${atLeast}`)
626
+ const at = await page().evaluateHandle(() => {
627
+ const deep = (root = document) => [...root.querySelectorAll('input[type=number]')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
628
+ return deep().find(i => i.previousElementSibling?.textContent?.trim().startsWith('figure.label.shown-at-least')) ?? null
629
+ })
630
+ assert.ok(await at.evaluate(i => !!i), 'with a control chosen, its threshold is asked')
631
+ await at.evaluate(i => i.scrollIntoView({ block: 'center' }))
632
+ await at.click({ clickCount: 3 })
633
+ await page().keyboard.type('100000')
634
+ await page().keyboard.press('Tab')
635
+ await sleep(500)
636
+ assert.equal(await drawn(), false, 'past what the control reaches now, the part is not drawn')
637
+ const back = await shownSelect()
638
+ await back.select('')
639
+ await sleep(500)
640
+ assert.equal(await drawn(), true, 'always again')
641
+ })
642
+ })
643
+
644
+ describe('a part follows the figure size in one step (follow-size, 2026-09-26)', () => {
645
+ test('one click on a figure with no size: its size is declared from the part, said in a line, and the part follows it', async () => {
646
+ await mount('box')
647
+ await click(await read(() => probe.row('box')))
648
+ const at = await page().evaluate(() => {
649
+ const deep = (root = document) => [...root.querySelectorAll('button[follow-size]')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
650
+ const b = deep()[0]
651
+ if (!b) return null
652
+ b.scrollIntoView({ block: 'center' })
653
+ const r = b.getBoundingClientRect()
654
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 }
655
+ })
656
+ assert.ok(at, 'the button is offered for a box that follows nothing')
657
+ const start = await state()
658
+ await click(at)
659
+ const after = await state()
660
+ assert.equal(after.error, '', 'not refused')
661
+ assert.equal(after.revision, start.revision + 1, 'one step')
662
+ assert.deepEqual(await read(() => probe.fields('size.')), { 'size.x': 400, 'size.y': 400, 'size.z': 400 }, 'the size is the box as it stands')
663
+ const notice = await page().evaluate(() => {
664
+ const deep = (root = document) => [...root.querySelectorAll('.notice')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
665
+ return deep()[0]?.textContent?.trim() ?? null
666
+ })
667
+ assert.ok(notice?.includes('figure.text.size-declared-to-follow'), `a line says so: ${notice}`)
668
+ const grown = await read(() => {
669
+ const editor = document.querySelector('figure-v3-editor')
670
+ editor.session.apply([{ kind: 'set-design', input: 'size.x', unit: 'mm', value: 800 }])
671
+ return editor.session.read().geometry.find(g => g.id === 'box').shape.dimensions
672
+ })
673
+ assert.equal(Math.round(grown.width), 800, 'twice the size, twice the box')
674
+ })
675
+ })
676
+
677
+ describe('a recipe put onto a part: chosen, filled in, one step (ADR-0095, 2026-09-26)', () => {
678
+ /* The recipes figure-model ships: the build named by FIGURE_MODEL, else the installed one. */
679
+ const recipeDir = () => {
680
+ const base = process.env.FIGURE_MODEL ? resolve(process.env.FIGURE_MODEL) : dirname(createRequire(import.meta.url).resolve('@hatiolab/figure-model/package.json'))
681
+ return join(base, 'recipes')
682
+ }
683
+ const recipes = () => readdirSync(recipeDir()).filter(f => f.endsWith('.json')).sort().map(f => JSON.parse(readFileSync(join(recipeDir(), f), 'utf-8')))
684
+ const at = selector =>
685
+ page().evaluate(selector => {
686
+ const deep = (root = document) => [...root.querySelectorAll(selector)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
687
+ const e = deep()[0]
688
+ if (!e) return null
689
+ e.scrollIntoView({ block: 'center' })
690
+ const r = e.getBoundingClientRect()
691
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2, disabled: !!e.disabled }
692
+ }, selector)
693
+ const parts = () => read(() => document.querySelector('figure-v3-editor').session.read().geometry.map(g => g.id))
694
+
695
+ test('with no part chosen the door waits; on the room it opens its form, and put in makes the frame, the leaves and the lamp', async () => {
696
+ await read((text, list) => harness.mount('room', text, list), null, recipes())
697
+ await sleep(2500)
698
+ const idle = await at('button[recipe="sliding-door"]')
699
+ assert.ok(idle, 'the sliding door is offered')
700
+ assert.ok(idle.disabled, 'it waits for a part to be chosen')
701
+ await click(await read(() => probe.row('room')))
702
+ await click(await at('button[recipe="sliding-door"]'))
703
+ const share = await page().evaluateHandle(() => {
704
+ const deep = (root = document) => [...root.querySelectorAll('input[param="share"]')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
705
+ return deep()[0] ?? null
706
+ })
707
+ assert.equal(await share.evaluate(i => i.value), '35', 'a share is typed as a percentage')
708
+ await share.evaluate(i => i.scrollIntoView({ block: 'center' }))
709
+ await share.click({ clickCount: 3 })
710
+ await page().keyboard.type('50')
711
+ await page().keyboard.press('Tab')
712
+ const start = await state()
713
+ await click(await at('div[recipe-actions] button[insert]'))
714
+ const after = await state()
715
+ assert.equal(after.error, '', 'not refused')
716
+ assert.equal(await read(() => document.querySelector('figure-v3-editor').recipeError), '', 'the recipe went in')
717
+ assert.equal(after.revision, start.revision + 1, 'one step')
718
+ const ids = await parts()
719
+ for (const part of ['frame', 'left', 'right', 'lamp']) assert.ok(ids.includes(`sliding-door-1-${part}`), `${part} is in`)
720
+ const frame = await body('sliding-door-1-frame')
721
+ assert.equal(Math.round(frame.dimensions.right - frame.dimensions.left), 2000, 'half the room\'s 4000 mm')
722
+ })
723
+
724
+ test('a door the room cannot take is refused in the form, in the kernel\'s words, and nothing is added', async () => {
725
+ const before = await parts()
726
+ const share = await page().evaluateHandle(() => {
727
+ const deep = (root = document) => [...root.querySelectorAll('input[param="share"]')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
728
+ return deep()[0] ?? null
729
+ })
730
+ await share.evaluate(i => i.scrollIntoView({ block: 'center' }))
731
+ await share.click({ clickCount: 3 })
732
+ await page().keyboard.type('5')
733
+ await page().keyboard.press('Tab')
734
+ const start = await state()
735
+ await click(await at('div[recipe-actions] button[insert]'))
736
+ assert.equal((await state()).revision, start.revision, 'no step')
737
+ assert.deepEqual(await parts(), before, 'nothing added')
738
+ const said = await page().evaluate(() => {
739
+ const deep = (root = document) => [...root.querySelectorAll('div[recipe-form] p[error]')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
740
+ return deep()[0]?.textContent ?? ''
741
+ })
742
+ assert.match(said, /steps\[\d+\]/, `the kernel's sentence, with the step: ${said}`)
743
+ })
744
+ })
745
+
531
746
  test('the page raised no error', () => {
532
747
  assert.deepEqual(opened.errors, [])
533
748
  })
@@ -28,3 +28,12 @@ test('the catalogue opens figures through the one path', () => {
28
28
  const list = read('../client/pages/figure-list-page.ts')
29
29
  assert.match(list, /navigate\(modellerPathOf\(figure\)\)/)
30
30
  })
31
+
32
+ test('reloading or closing the tab with unsaved work asks first — the shell does not ask canDeactivate then (09-26)', () => {
33
+ const page = read('../client/pages/figure-v3-modeller-page.ts')
34
+ const hold = page.slice(page.indexOf('private holdUnsaved'), page.indexOf('connectedCallback()'))
35
+ assert.match(hold, /session\.read\(\)\.dirty/, 'only when there is something unsaved')
36
+ assert.match(hold, /event\.preventDefault\(\)/)
37
+ assert.match(page, /addEventListener\('beforeunload', this\.holdUnsaved\)/)
38
+ assert.match(page, /removeEventListener\('beforeunload', this\.holdUnsaved\)/)
39
+ })
@@ -138,3 +138,47 @@ test('원기둥 · 구도 크기 기즈모가 먹는다 — 단면은 원으로'
138
138
  const radii = gizmoChanges('ball', ball, { scale: [1, 1, 0.5] }, values).map((c: any) => c.value / values[c.input])
139
139
  assert.equal(new Set(radii.map((r: number) => r.toFixed(2))).size, 1, '구의 세 반지름이 한 배율로 — 타원체가 되지 않는다')
140
140
  })
141
+
142
+ /*
143
+ * 높이를 숫자로 적어도 아랫면이 그 자리에 있다 — 손잡이로 키울 때와 같게. 2200 을 적은 기둥이 바닥 아래로 933 mm
144
+ * 묻혔다(09-26). 돌려 세운 부품은 그 높이가 위아래가 아니라 건드리지 않는다.
145
+ */
146
+ test('높이 칸 — 아랫면을 지킨다, 손잡이처럼', async () => {
147
+ const { keepBottomChanges } = await import('../client/modeller/v3-gizmo.ts')
148
+ let asset = createV3Asset('Post')
149
+ asset = addV3Box(asset, { id: 'post', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
150
+ asset = addV3Primitive(asset, { id: 'drum', kind: 'cylinder', dimensions: { radius: 50, length: 100 }, position: { x: 0, y: 300, z: 0 } })
151
+ asset = addV3Primitive(asset, { id: 'roller', kind: 'cylinder', dimensions: { radius: 30, length: 500 }, position: { x: 0, y: 30, z: 0 }, rotation: { x: 90, y: 0, z: 0 } })
152
+ const bottom = (a: typeof asset, part: string) => {
153
+ const g = compileV3Asset(a).evaluate().geometry.find(o => o.id === part)! as unknown as { pose: { t: number[] } }
154
+ return g.pose.t[1]!
155
+ }
156
+ const grow = (part: string, input: string, value: number) => {
157
+ const reach = gizmoReachOf(asset, part)
158
+ const changes = [{ kind: 'set-design', input, unit: 'mm', value } as never, ...keepBottomChanges(part, reach, input, value, asset.designInputs)]
159
+ return apply(asset, changes)
160
+ }
161
+ const postH = gizmoReachOf(asset, 'post').sizes![1]
162
+ const tall = grow('post', postH, 2200)
163
+ assert.equal(bottom(tall, 'post') - 2200 / 2, 0, '기둥 아랫면이 바닥에')
164
+ const drumH = gizmoReachOf(asset, 'drum').round!.along[0]!
165
+ assert.equal(bottom(grow('drum', drumH, 300), 'drum') - 300 / 2, 300 - 100 / 2, '원기둥도 아랫면이 제자리')
166
+ const rollerLength = gizmoReachOf(asset, 'roller').round!.along[0]!
167
+ assert.deepEqual(keepBottomChanges('roller', gizmoReachOf(asset, 'roller'), rollerLength, 800, asset.designInputs), [], '누운 롤러의 길이는 위아래가 아니다')
168
+ assert.deepEqual(keepBottomChanges('post', gizmoReachOf(asset, 'post'), gizmoReachOf(asset, 'post').sizes![0], 800, asset.designInputs), [], '폭은 가운데 그대로')
169
+ assert.ok(read('../client/modeller/v3-editor.ts').includes('keepBottomChanges('), '숫자 칸이 부른다')
170
+ })
171
+
172
+ /* 손잡이는 키를 몰라도 찾는다 — 도구 막대에 옮기기 · 돌리기 · 크기, 제 숫자로 놓인 부품에만 (09-26). */
173
+ test('도구 막대의 옮기기 · 돌리기 · 크기 — 키와 같은 길, 되쓸 수 있는 것만', () => {
174
+ const editor = read('../client/modeller/v3-editor.ts')
175
+ const bar = editor.slice(editor.indexOf('return html`<ox-canvas-bar'), editor.indexOf('</ox-canvas-bar>'))
176
+ assert.ok(bar.includes("this.renderGizmoTools(!unavailable && this.middle === 'edit')"), '편집 중에만')
177
+ const tools = editor.slice(editor.indexOf(' private renderGizmoTools('), editor.indexOf(" /** The modeller's keys"))
178
+ for (const [mode, key, can] of [['translate', 'W', 'translate'], ['rotate', 'E', 'rotate'], ['scale', 'R', 'scale']])
179
+ assert.ok(tools.includes(`mode: '${mode}'`) && tools.includes(`key: '${key}'`) && tools.includes(`can: !!reach?.${can}`), mode)
180
+ assert.ok(tools.includes('@click=${() => this.useGizmo(tool.mode)}'))
181
+ assert.match(editor, /action === 'scale'\) canvas && this\.useGizmo\(action\)/, '키도 같은 길로 — 눌린 단추가 맞다')
182
+ const ko = JSON.parse(read('../translations/ko.json'))
183
+ assert.deepEqual(['translate', 'rotate', 'scale'].map(m => ko['figure.button.gizmo-' + m]), ['옮기기', '돌리기', '크기 바꾸기'])
184
+ })
@@ -303,6 +303,38 @@ test('붙일 때 면 위 어디에 — 그 면을 가로지르는 두 축만,
303
303
  assert.deepEqual(leaf.pose.t.map(Math.round), [500, 1000, 55], '문짝 아래가 문틀 아래에, 앞이 문틀 앞에')
304
304
  })
305
305
 
306
+ test('붙여도 제자리 — 폼은 지금 서 있는 자리를 가운데서 거리로 채운다 (문짝이 문틀 앞 100 mm 에서 가운데로 옮겨졌다, 09-26)', async () => {
307
+ const { alignOf, placeKeptOf } = await import('../client/modeller/v3-links.ts')
308
+ const { compileV3Asset } = await import('@hatiolab/figure-model')
309
+ let asset = createV3Asset('Door')
310
+ asset = addV3Box(asset, { id: 'frame', size: { x: 1600, y: 100, z: 150 }, position: { x: 300, y: 50, z: 0 } })
311
+ asset = addV3Box(asset, { id: 'leaf', size: { x: 1400, y: 2200, z: 40 }, position: { x: 250, y: 1100, z: 100 } })
312
+ const where = (a: V3Asset) => compileV3Asset(a).evaluate().geometry.find(g => g.id === 'leaf')!.pose.t.map(v => Math.round(v))
313
+ for (const target of ['plane', { part: 'frame', face: 'top' }] as const) {
314
+ const kept = placeKeptOf(asset, 'leaf', target)
315
+ const align = alignOf(asset, 'leaf', target, kept.align, kept.alignMm)
316
+ const to = target === 'plane' ? { plane: 'mounting-plane' as const } : target
317
+ const fastened = applyV3Authoring(asset, { kind: 'attach', part: 'leaf', face: 'bottom', to, ...(align ? { align } : {}) } as never)
318
+ const [before, after] = [where(asset), where(fastened)]
319
+ assert.deepEqual([after[0], after[2]], [before[0], before[2]], `${JSON.stringify(target)}: 앞뒤·좌우는 그대로`)
320
+ }
321
+ assert.deepEqual(placeKeptOf(asset, 'leaf', { part: 'frame', face: 'top' }), { align: { x: 'mm', z: 'mm' }, alignMm: { x: -50, z: 100 } })
322
+ const centred = addV3Box(createV3Asset('C'), { id: 'a', size: { x: 10, y: 10, z: 10 }, position: { x: 0, y: 5, z: 0 } })
323
+ assert.deepEqual(placeKeptOf(centred, 'a', 'plane'), { align: {}, alignMm: {} }, '가운데 있던 것은 가운데로')
324
+
325
+ /* The form fills it in whenever the aim changes, and does not offer a part that hangs from this one. */
326
+ const { hangsFrom } = await import('../client/modeller/v3-links.ts')
327
+ let row = addV3Box(createV3Asset('Row'), { id: 'plate', size: { x: 1000, y: 50, z: 400 }, position: { x: 0, y: 700, z: 0 } })
328
+ row = addV3Box(row, { id: 'roller', size: { x: 60, y: 60, z: 400 }, position: { x: 0, y: 760, z: 0 } })
329
+ row = addV3Box(row, { id: 'cap', size: { x: 20, y: 20, z: 20 }, position: { x: 0, y: 800, z: 0 } })
330
+ row = applyV3Authoring(row, { kind: 'attach', part: 'roller', face: 'bottom', to: { part: 'plate', face: 'top' } })
331
+ row = applyV3Authoring(row, { kind: 'attach', part: 'cap', face: 'bottom', to: { part: 'roller', face: 'top' } })
332
+ assert.deepEqual([hangsFrom(row, 'roller', 'plate'), hangsFrom(row, 'cap', 'plate'), hangsFrom(row, 'plate', 'roller')], [true, true, false])
333
+ const form = read('../client/modeller/figure-v3-links.ts')
334
+ assert.equal(form.match(/this\.aimAt\(asset, \{ \.\.\.f, (toPlane|target|targetFace):/g)?.length, 3, '바닥 · 상대 부품 · 상대 면을 바꿀 때마다')
335
+ assert.ok(form.includes('!hangsFrom(asset, one, this.partId)'))
336
+ })
337
+
306
338
  test('따라 움직이는 관절 — 같은 방식으로 움직이고 제 조작값이 있는 관절만 고르고, 기록에는 배율과 더하기만 (집게 두 손가락, 09-25)', async () => {
307
339
  const { leadersFor, motionJoint } = await import('../client/modeller/v3-links.ts')
308
340
  const { compileV3Asset } = await import('@hatiolab/figure-model')
@@ -343,7 +375,7 @@ test('면은 도형이 정한다 — 문틀(portal)은 바깥 여섯에 개구
343
375
  asset = addV3Primitive(asset, {
344
376
  id: 'frame',
345
377
  kind: 'portal',
346
- dimensions: { left: -550, right: 550, bottom: 0, top: 2200, openingLeft: -450, openingRight: 450, openingTop: 2100, depth: 150 },
378
+ dimensions: { width: 1100, height: 2200, depth: 150, jamb: 100, head: 100 },
347
379
  position: { x: 0, y: 0, z: 0 }
348
380
  })
349
381
  asset = addV3Primitive(asset, { id: 'ball', kind: 'sphere', dimensions: { radiusX: 50, radiusY: 50, radiusZ: 50 }, position: { x: 0, y: 0, z: 0 } })
@@ -528,3 +560,61 @@ test('벽 배치 — 장착 평면은 Z = 0, 등으로 붙이고 가로질러 X
528
560
  const form = read('../client/modeller/figure-v3-links.ts')
529
561
  assert.ok(form.includes('(!f.toPlane || plane.faces.includes(face as V3Face))'), '평면에 붙일 때는 평면이 받는 면만')
530
562
  })
563
+
564
+ test('부품 칸의 두 제목이 같은 말이 아니다 — 자리 숫자는 「위치와 크기」, 붙이기 칸은 「붙이기」 (09-26)', () => {
565
+ const ko = JSON.parse(read('../translations/ko.json'))
566
+ assert.notEqual(ko['figure.label.placement'], ko['figure.label.group-placement-size'])
567
+ assert.equal(ko['figure.label.placement'], '붙이기')
568
+ })
569
+
570
+ /*
571
+ * 부품이 보이는 때 — 늘, 또는 조작값이 얼마 이상일 때(문이 열리면 켜지는 램프). figure-model 에 명령은 있었지만
572
+ * 고르는 칸이 없었다(09-26 기준 측정).
573
+ */
574
+ test('보이는 때 — 명령이 남긴 것을 읽고, 폼은 그 명령을 낸다', async () => {
575
+ const { shownWhenOf, visibilityControlsOf } = await import('../client/modeller/v3-links.ts')
576
+ const { editV3Part } = await import('@hatiolab/figure-model')
577
+ let asset = createV3Asset('Gate')
578
+ asset = addV3Box(asset, { id: 'frame', size: { x: 1200, y: 60, z: 200 }, position: { x: 0, y: 30, z: 0 } })
579
+ asset = addV3Box(asset, { id: 'door', size: { x: 1000, y: 2000, z: 40 }, position: { x: 0, y: 1060, z: 0 } })
580
+ asset = addV3Box(asset, { id: 'lamp', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 2200, z: 0 } })
581
+ asset = applyV3Authoring(asset, { kind: 'attach', part: 'frame', face: 'bottom', to: { plane: 'mounting-plane' } })
582
+ asset = applyV3Authoring(asset, { kind: 'attach', part: 'door', face: 'bottom', to: { part: 'frame', face: 'top' } })
583
+ assert.deepEqual(visibilityControlsOf(asset), [], '조작값이 없으면 고를 것도 없다')
584
+ asset = moving(asset, 'door', { kind: 'slide', axis: 'x', id: 'gate-open', min: 0, max: 900 })
585
+ assert.deepEqual(visibilityControlsOf(asset).map(c => [c.id, c.min, c.max, c.unit]), [['gate-open', 0, 900, 'mm']])
586
+ assert.equal(shownWhenOf(asset, 'lamp'), null, '늘 보인다')
587
+ const shown = editV3Part(asset, { kind: 'set-visibility', target: 'lamp', shownWhen: { input: 'gate-open', atLeast: 9 } })
588
+ assert.deepEqual(shownWhenOf(shown, 'lamp'), { input: 'gate-open', atLeast: 9 })
589
+ assert.equal(shownWhenOf(editV3Part(shown, { kind: 'set-visibility', target: 'lamp', shownWhen: null }), 'lamp'), null)
590
+ const form = read('../client/modeller/figure-v3-links.ts')
591
+ const section = form.slice(form.indexOf(' private renderShown('), form.indexOf(' private renderMotion('))
592
+ assert.ok(section.includes("kind: 'set-visibility'"), '폼은 그 명령을 낸다')
593
+ assert.ok(form.includes('${this.renderShown(asset)}'), '움직임 칸에')
594
+ })
595
+
596
+ /* 문틀은 폭 · 높이 · 깊이로 — 상자처럼 크기를 따르고, 손잡이로 키운다 (figure-model portal-profile@2, 09-26). */
597
+ test('팔레트의 문틀은 문 크기로 서고, 세 축 모두 크기를 따를 수 있고, 손잡이로 키울 수 있다', async () => {
598
+ const { paletteRecipe } = await import('../client/modeller/v3-palette.ts')
599
+ const { gizmoReachOf } = await import('../client/modeller/v3-gizmo.ts')
600
+ const { addV3Primitive: add } = await import('@hatiolab/figure-model')
601
+ const asset = add(createV3Asset('Frame'), paletteRecipe('portal', 'frame'))
602
+ assert.deepEqual(followableAxes(asset, 'frame'), ['x', 'y', 'z'])
603
+ const reach = gizmoReachOf(asset as never, 'frame')
604
+ assert.equal(reach.scale, true)
605
+ assert.deepEqual(reach.sizes, ['frame.dimension.width', 'frame.dimension.height', 'frame.dimension.depth'])
606
+ assert.deepEqual([asset.designInputs['frame.dimension.width'], asset.designInputs['frame.dimension.height']], [1000, 2200], '문 크기로')
607
+ })
608
+
609
+ /* 크기를 따라감 한 번 — 치수 칸의 단추 하나, 크기를 대신 정했으면 한 줄로 말한다 (ADR 판정 Q1, 09-26). */
610
+ test('치수 칸 — 「도형 크기를 따라감」 단추는 follow-size 한 번, 다 따르면 사라진다; 크기를 대신 정하면 한 줄', () => {
611
+ const form = read('../client/modeller/figure-v3-links.ts')
612
+ const dims = form.slice(form.indexOf(' private renderDimensions('), form.indexOf(' private renderDimensions(') + 1200)
613
+ assert.ok(dims.includes("this.command({ kind: 'follow-size', part: this.partId })"))
614
+ assert.ok(dims.includes("followOf(asset, this.partId, axis).of === 'instance'"), '다 따르면 단추가 없다')
615
+ const editor = read('../client/modeller/v3-editor.ts')
616
+ assert.ok(editor.includes("if (action.kind === 'follow-size' && !hadSize)"), '크기를 대신 정했을 때만')
617
+ assert.ok(editor.includes('<div role="status" class="notice">'))
618
+ const ko = JSON.parse(read('../translations/ko.json'))
619
+ assert.match(ko['figure.text.size-declared-to-follow'], /\{x\} × \{y\} × \{z\} mm/)
620
+ })
@@ -0,0 +1,60 @@
1
+ /*
2
+ * **조각 칸** (ADR-0095, 2026-09-26). 문 · 롤러 줄을 한 단계로 넣는다 — 부품을 고른 뒤에, 몇 개의 수와 선택으로.
3
+ */
4
+ import { test } from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import { readFileSync } from 'node:fs'
7
+ import { fileURLToPath } from 'node:url'
8
+
9
+ import { recipeFieldOf, recipeReady, recipesOf, recipeValueOf, recipeWord } from '../client/modeller/v3-recipes.ts'
10
+
11
+ const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
12
+ const share = { name: 'share', kind: 'ratio' as const, default: 0.35, min: 0.05, max: 0.95 }
13
+
14
+ test('비율은 백분율로 보이고 받는다; 빈 칸은 기본값; 선택은 선택지 중에서만', () => {
15
+ assert.equal(recipeFieldOf(share, 0.35), '35')
16
+ assert.equal(recipeValueOf(share, '50'), 0.5)
17
+ assert.equal(recipeValueOf(share, ''), 0.35)
18
+ assert.equal(recipeValueOf({ name: 'pitch', kind: 'mm', default: 200 }, '400'), 400)
19
+ const leaves = { name: 'leaves', kind: 'choice' as const, default: 2, options: [1, 2] }
20
+ assert.equal(recipeValueOf(leaves, '1'), 1, '선택지의 값 그대로 — 수는 수로')
21
+ assert.equal(recipeValueOf(leaves, '3'), 2)
22
+ assert.equal(recipeValueOf({ name: 'lamp', kind: 'flag', default: true }, false), false)
23
+ })
24
+
25
+ test('말은 화면의 언어로, 없으면 영어로; 부품용 조각은 부품을 고른 뒤에; 조각이 아닌 행은 빠진다', () => {
26
+ assert.equal(recipeWord({ ko: '미닫이 문', en: 'sliding door' }, 'ko'), '미닫이 문')
27
+ assert.equal(recipeWord({ ko: '미닫이 문', en: 'sliding door' }, 'ja'), 'sliding door')
28
+ assert.equal(recipeReady({ target: 'part' }, ''), false)
29
+ assert.equal(recipeReady({ target: 'part' }, 'room'), true)
30
+ assert.equal(recipeReady({ target: 'figure' }, ''), true)
31
+ assert.deepEqual(recipesOf([{ source: '{"name":"a","steps":[]}' }, { source: 'not json' }, { source: '{"x":1}' }]).map(r => r.name), ['a'])
32
+ })
33
+
34
+ test('편집기는 조각을 도형이 선 그대로 풀어 한 단계로 넣고, 거절은 폼에 커널의 말로', () => {
35
+ const editor = read('../client/modeller/v3-editor.ts')
36
+ const insert = editor.slice(editor.indexOf(' private insertRecipe('), editor.indexOf(' /** The design values of the asset being edited. */'))
37
+ assert.ok(insert.includes('v3RecipeIdFor(asset, checked)') && insert.includes('resolveV3Recipe(checked'), '다음 빈 이름으로 푼다')
38
+ assert.ok(insert.includes('this.session!.applyRecipe(steps)'), '한 단계')
39
+ assert.ok(insert.includes('this.recipeError = error instanceof Error ? error.message : String(error)'), '거절은 그대로')
40
+ const session = read('../client/modeller/v3-product-session.ts')
41
+ assert.ok(session.includes("workspace.applyProposal({ origin: 'ui', context: at.context, expectedRevision: at.expectedRevision, steps })"), '사람이 넣은 것')
42
+ const page = read('../client/pages/figure-v3-modeller-page.ts')
43
+ assert.ok(page.includes('.recipes=${this.recipes}') && page.includes('fetchFigureRecipes()'), '페이지가 목록을 읽어 건넨다')
44
+ })
45
+
46
+ test('관리 화면 — 주소에서 도메인을 읽고, 제 조각과 상위 도메인 조각을 가르고, 값이 받는 범위를 적는다', async () => {
47
+ const { subdomainOf, recipeIsOwn, paramRangeOf } = await import('../client/modeller/v3-recipes.ts')
48
+ assert.equal(subdomainOf('/domain/plant-1/figure-recipes'), 'plant-1')
49
+ assert.equal(subdomainOf('/figure-recipes'), '')
50
+ assert.equal(recipeIsOwn({ domain: { subdomain: 'plant-1' } }, 'plant-1'), true)
51
+ assert.equal(recipeIsOwn({ domain: { subdomain: 'system' } }, 'plant-1'), false)
52
+ assert.equal(recipeIsOwn({ domain: { subdomain: 'system' } }, ''), false, '주소가 도메인을 말하지 않으면 제 것이 아니다')
53
+ assert.equal(paramRangeOf(share), '5 – 95 %')
54
+ assert.equal(paramRangeOf({ name: 'leaves', kind: 'choice', default: 2, options: [1, 2] }), '1 / 2')
55
+ const page = read('../client/pages/figure-recipe-page.ts')
56
+ assert.ok(page.includes('applyV3Recipe(room.asset, parseV3Recipe(recipe)'), '미리보기는 검사하는 방에 넣은 모습')
57
+ assert.ok(page.includes("saveFigureRecipe(row.source, 'draft')"), '상위 도메인 것은 초안으로 복사해 고친다')
58
+ assert.ok(page.includes('OxPrompt.open('), '지우기 전에 앱의 확인 창으로 묻는다')
59
+ assert.ok(read('../things-factory.config.js').includes("{ tagname: 'figure-recipe-page', page: 'figure-recipes' }"))
60
+ })
@@ -5,6 +5,7 @@ export default {
5
5
  route,
6
6
  routes: [
7
7
  { tagname: 'figure-list-page', page: 'figure-list' },
8
+ { tagname: 'figure-recipe-page', page: 'figure-recipes' },
8
9
  /* One modeller since ADR-0087 step 5; the old V3 address is an alias. The shell builds the DOM from this table. */
9
10
  { tagname: 'figure-v3-modeller-page', page: 'figure-modeller' },
10
11
  { tagname: 'figure-v3-modeller-page', page: 'figure-v3-modeller' }
@@ -189,7 +189,44 @@
189
189
  "figure.label.part-position": "position (centre)",
190
190
  "figure.label.part-size": "size",
191
191
  "figure.label.parts": "parts",
192
- "figure.label.placement": "position and size",
192
+ "figure.label.gizmo-tools": "drag to change",
193
+ "figure.button.gizmo-translate": "move",
194
+ "figure.button.gizmo-rotate": "turn",
195
+ "figure.button.gizmo-scale": "resize",
196
+ "figure.text.gizmo-not-own": "this part is placed by another part or by the size, so it cannot be dragged",
197
+ "figure.label.shown-when": "shown when",
198
+ "figure.label.shown-always": "always",
199
+ "figure.label.shown-while": "while {control} is at least",
200
+ "figure.label.shown-at-least": "at least",
201
+ "figure.button.follow-size": "follow the figure size",
202
+ "figure.text.size-declared-to-follow": "the figure size to follow was set from its parts: {x} × {y} × {z} mm.",
203
+ "figure.label.recipes": "recipes",
204
+ "figure.text.recipe-needs-a-part": "choose the part to put it on first.",
205
+ "figure.text.recipe-onto": "onto {part}.",
206
+ "figure.button.insert-recipe": "put in",
207
+ "figure.text.recipes-intro": "a recipe is a group of parts made often -- a door, a roller row. In the modeller, choose a part and put it in at once.",
208
+ "figure.text.recipes-empty": "no recipes yet.",
209
+ "figure.label.recipe-draft": "draft",
210
+ "figure.label.recipe-inherited": "parent domain",
211
+ "figure.text.recipe-target-part": "put onto a chosen part",
212
+ "figure.text.recipe-target-figure": "put onto the whole figure",
213
+ "figure.text.recipe-own": "this domain's recipe",
214
+ "figure.text.recipe-inherited": "the {domain} domain's recipe; copy it here to change it",
215
+ "figure.label.recipe-param": "value",
216
+ "figure.label.recipe-default": "default",
217
+ "figure.label.recipe-range": "range",
218
+ "figure.label.yes": "yes",
219
+ "figure.label.no": "no",
220
+ "figure.button.recipe-release": "release",
221
+ "figure.button.recipe-to-draft": "back to draft",
222
+ "figure.button.recipe-delete": "delete",
223
+ "figure.button.recipe-copy-in": "copy to this domain",
224
+ "figure.button.recipe-edit-source": "source (JSON)",
225
+ "figure.text.recipe-edit-source": "saved, it is put into an empty room first and not saved if it does not go in. A new name makes a new recipe.",
226
+ "figure.text.recipe-preview": "put into a 4 x 3 x 6 m room with its defaults, shown with the room resized.",
227
+ "figure.text.recipe-delete-confirm": "delete the {name} recipe. figures already made with it do not change.",
228
+ "figure.title.recipes": "recipes",
229
+ "figure.label.placement": "fastening",
193
230
  "figure.label.placement-ceiling": "ceiling-based (ceiling)",
194
231
  "figure.label.placement-center": "center-based (center)",
195
232
  "figure.label.placement-floor": "floor-based (floor)",
@@ -523,13 +560,8 @@
523
560
  "figure.label.field-radius": "Radius",
524
561
  "figure.label.field-length": "Length",
525
562
  "figure.label.field-round": "Corner radius",
526
- "figure.label.field-left": "Left",
527
- "figure.label.field-right": "Right",
528
- "figure.label.field-bottom": "Bottom",
529
- "figure.label.field-top": "Top",
530
- "figure.label.field-openingLeft": "Opening left",
531
- "figure.label.field-openingRight": "Opening right",
532
- "figure.label.field-openingTop": "Opening top",
563
+ "figure.label.field-jamb": "jamb",
564
+ "figure.label.field-head": "head",
533
565
  "figure.label.field-radiusX": "Radius X",
534
566
  "figure.label.field-radiusY": "Radius Y",
535
567
  "figure.label.field-radiusZ": "Radius Z",