@things-factory/figure-ui 10.1.70 → 10.1.71

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 (67) hide show
  1. package/client/graphql/index.ts +28 -69
  2. package/client/modeller/figure-v3-parts.ts +16 -136
  3. package/client/modeller/figure-v3-recipe-catalog.ts +348 -0
  4. package/client/modeller/v3-editor.ts +25 -5
  5. package/client/modeller/v3-fields.ts +21 -16
  6. package/client/modeller/v3-gizmo.ts +1 -1
  7. package/client/modeller/v3-links.ts +7 -66
  8. package/client/modeller/v3-palette.ts +3 -0
  9. package/client/modeller/v3-recipe-source.ts +15 -0
  10. package/client/modeller/v3-recipes.ts +50 -27
  11. package/client/pages/figure-v3-modeller-page.ts +16 -13
  12. package/client/route.ts +0 -4
  13. package/dist-client/graphql/index.d.ts +11 -18
  14. package/dist-client/graphql/index.js +18 -59
  15. package/dist-client/graphql/index.js.map +1 -1
  16. package/dist-client/modeller/figure-v3-parts.d.ts +5 -12
  17. package/dist-client/modeller/figure-v3-parts.js +17 -147
  18. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  19. package/dist-client/modeller/figure-v3-recipe-catalog.d.ts +36 -0
  20. package/dist-client/modeller/figure-v3-recipe-catalog.js +382 -0
  21. package/dist-client/modeller/figure-v3-recipe-catalog.js.map +1 -0
  22. package/dist-client/modeller/v3-editor.d.ts +8 -3
  23. package/dist-client/modeller/v3-editor.js +35 -6
  24. package/dist-client/modeller/v3-editor.js.map +1 -1
  25. package/dist-client/modeller/v3-fields.d.ts +2 -2
  26. package/dist-client/modeller/v3-fields.js +20 -18
  27. package/dist-client/modeller/v3-fields.js.map +1 -1
  28. package/dist-client/modeller/v3-gizmo.js +1 -1
  29. package/dist-client/modeller/v3-gizmo.js.map +1 -1
  30. package/dist-client/modeller/v3-links.d.ts +8 -25
  31. package/dist-client/modeller/v3-links.js +4 -66
  32. package/dist-client/modeller/v3-links.js.map +1 -1
  33. package/dist-client/modeller/v3-palette.js +3 -0
  34. package/dist-client/modeller/v3-palette.js.map +1 -1
  35. package/dist-client/modeller/v3-recipe-source.d.ts +8 -0
  36. package/dist-client/modeller/v3-recipe-source.js +14 -0
  37. package/dist-client/modeller/v3-recipe-source.js.map +1 -0
  38. package/dist-client/modeller/v3-recipes.d.ts +25 -14
  39. package/dist-client/modeller/v3-recipes.js +42 -28
  40. package/dist-client/modeller/v3-recipes.js.map +1 -1
  41. package/dist-client/pages/figure-v3-modeller-page.d.ts +5 -3
  42. package/dist-client/pages/figure-v3-modeller-page.js +16 -13
  43. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  44. package/dist-client/route.d.ts +1 -1
  45. package/dist-client/route.js +0 -3
  46. package/dist-client/route.js.map +1 -1
  47. package/dist-client/tsconfig.fui.tsbuildinfo +1 -1
  48. package/dist-client/tsconfig.tsbuildinfo +1 -1
  49. package/package.json +5 -4
  50. package/scripts/template-thumbnails.mjs +53 -0
  51. package/test/browser/build.mjs +3 -3
  52. package/test/browser/modeller-page.js +25 -3
  53. package/test/browser/modeller.check.mjs +57 -47
  54. package/test/browser/thumbnail-page.js +14 -0
  55. package/test/v3-fields.test.ts +5 -1
  56. package/test/v3-links.test.ts +12 -0
  57. package/test/v3-recipes.test.ts +42 -25
  58. package/things-factory.config.js +0 -1
  59. package/translations/en.json +38 -23
  60. package/translations/ja.json +38 -23
  61. package/translations/ko.json +38 -23
  62. package/translations/ms.json +38 -23
  63. package/translations/zh.json +38 -23
  64. package/client/pages/figure-recipe-page.ts +0 -338
  65. package/dist-client/pages/figure-recipe-page.d.ts +0 -41
  66. package/dist-client/pages/figure-recipe-page.js +0 -352
  67. package/dist-client/pages/figure-recipe-page.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.70",
3
+ "version": "10.1.71",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -27,21 +27,22 @@
27
27
  "clean": "npm run clean:server && npm run clean:client"
28
28
  },
29
29
  "dependencies": {
30
- "@hatiolab/figure-model": "^0.1.82",
30
+ "@hatiolab/figure-model": "^0.1.86",
31
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",
35
35
  "@operato/i18n": "^10.0.0",
36
36
  "@operato/layout": "^10.28.0",
37
+ "@operato/markdown": "^10.23.0",
37
38
  "@operato/p13n": "^10.0.0",
38
39
  "@operato/popup": "^10.23.0",
39
40
  "@operato/shell": "^10.0.0",
40
41
  "@operato/styles": "^10.0.0",
41
42
  "@operato/utils": "^10.0.0",
42
43
  "@things-factory/ai-assistant": "^10.1.58",
43
- "@things-factory/figure-service": "^10.1.70",
44
+ "@things-factory/figure-service": "^10.1.71",
44
45
  "three": "^0.185.1"
45
46
  },
46
- "gitHead": "7c9c614b0cb1d02d401211db76ec0ca0ead344af"
47
+ "gitHead": "4ee13a8b66a34e889994eff3ad31af6ebb8d7ca5"
47
48
  }
@@ -0,0 +1,53 @@
1
+ /*
2
+ * Draws each recipe template's thumbnail from its model (ADR-0095 amended: model first, verified, then the recipe).
3
+ *
4
+ * node scripts/template-thumbnails.mjs [name …]
5
+ * FIGURE_MODEL=../../../figure-model node scripts/template-thumbnails.mjs (a figure-model not published yet)
6
+ *
7
+ * Reads figure-service/recipes/<name>/models/default.json (the model at the template's defaults) and writes
8
+ * figure-service/recipes/<name>/thumbnail.png, drawn open when the model has a control, so the card shows the motion.
9
+ */
10
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
11
+ import { dirname, join, resolve } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+
14
+ import { buildPage } from '../test/browser/build.mjs'
15
+ import { openPage } from '../test/browser/serve.mjs'
16
+
17
+ const HERE = dirname(fileURLToPath(import.meta.url))
18
+ const RECIPES = resolve(HERE, '../../figure-service/recipes')
19
+ const only = process.argv.slice(2)
20
+
21
+ const opened = await openPage(await buildPage('thumbnail-page.js'))
22
+ try {
23
+ for (const name of readdirSync(RECIPES).filter(n => !only.length || only.includes(n)).sort()) {
24
+ const model = join(RECIPES, name, 'models', 'default.json')
25
+ if (!existsSync(model)) continue
26
+ const text = readFileSync(model, 'utf-8')
27
+ const asset = JSON.parse(text)
28
+ /*
29
+ Each control most of the way from where it rests toward its far end: a door partly open (a left hinge opens toward
30
+ -90), so the card shows what moves -- a sliding leaf opened all the way hides behind its jamb.
31
+ */
32
+ const rest = asset.stateDefaults ?? {}
33
+ const state = Object.fromEntries(
34
+ asset.document.model.inputs
35
+ .filter(i => i.role === 'state')
36
+ .map(i => {
37
+ const from = rest[i.id] ?? 0
38
+ const far = Math.abs(i.max - from) >= Math.abs(i.min - from) ? i.max : i.min
39
+ return [i.id, from + (far - from) * 0.6]
40
+ })
41
+ )
42
+ const url = await opened.page.evaluate((text, state) => window.shoot(text, state), text, state)
43
+ if (!url) {
44
+ console.error(`${name}: not drawn`)
45
+ process.exitCode = 1
46
+ continue
47
+ }
48
+ writeFileSync(join(RECIPES, name, 'thumbnail.png'), Buffer.from(url.split(',')[1], 'base64'))
49
+ console.log(`${name}: thumbnail.png`)
50
+ }
51
+ } finally {
52
+ await opened.close()
53
+ }
@@ -19,8 +19,8 @@ const HERE = dirname(fileURLToPath(import.meta.url))
19
19
  const PACKAGE = resolve(HERE, '../..')
20
20
  const ROOT = resolve(PACKAGE, '../..')
21
21
 
22
- export async function buildPage() {
23
- const out = join(tmpdir(), 'figure-ui-browser-check')
22
+ export async function buildPage(page = 'modeller-page.js') {
23
+ const out = join(tmpdir(), 'figure-ui-browser-' + page.replace(/\W+/g, '-'))
24
24
  const emitted = join(out, 'client')
25
25
  rmSync(out, { recursive: true, force: true })
26
26
  mkdirSync(out, { recursive: true })
@@ -34,7 +34,7 @@ export async function buildPage() {
34
34
  if (figureModel && !existsSync(figureModel)) throw Error(`FIGURE_MODEL has no build: ${figureModel}`)
35
35
 
36
36
  const bundle = await rollup({
37
- input: join(HERE, 'modeller-page.js'),
37
+ input: join(HERE, page),
38
38
  onwarn(warning, warn) {
39
39
  if (warning.code === 'CIRCULAR_DEPENDENCY' || warning.code === 'THIS_IS_UNDEFINED' || warning.code === 'EVAL') return
40
40
  warn(warning)
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import 'figure-ui-client/modeller/v3-editor.js'
7
7
  import { createV3ProductSession } from 'figure-ui-client/modeller/v3-product-session.js'
8
- import { addV3Box, createV3Asset, robotArmV3, serializeV3Asset } from '@hatiolab/figure-model'
8
+ import { addV3Box, addV3Primitive, applyV3Authoring, createV3Asset, robotArmV3, serializeV3Asset } from '@hatiolab/figure-model'
9
9
 
10
10
  const PALETTE = {
11
11
  'palette.dark': '#2b2b2b',
@@ -36,6 +36,20 @@ function sourceOf(kind, text) {
36
36
  asset.palette = { ...asset.palette, ...PALETTE }
37
37
  return serializeV3Asset(addV3Box(asset, { id: 'room', size: { x: 4000, y: 3000, z: 6000 }, position: { x: 0, y: 1500, z: 0 } }))
38
38
  }
39
+ if (kind === 'rollers') {
40
+ /* A roller row whose rollers spin in place, each carrying a mark (ADR-0093, 2026-09-26). */
41
+ let asset = createV3Asset('Rollers')
42
+ asset.palette = { ...asset.palette, ...PALETTE }
43
+ asset = addV3Box(asset, { id: 'bed', size: { x: 2000, y: 400, z: 600 }, position: { x: 0, y: 200, z: 0 } })
44
+ asset = applyV3Authoring(asset, { kind: 'attach', part: 'bed', face: 'bottom', to: { plane: 'mounting-plane' } })
45
+ asset = addV3Primitive(asset, { id: 'roller', kind: 'cylinder', dimensions: { radius: 40, length: 560 }, position: { x: 0, y: 0, z: 0 }, rotation: { x: 90, y: 0, z: 0 } })
46
+ asset = applyV3Authoring(asset, { kind: 'attach', part: 'roller', face: 'centre', to: { part: 'bed', face: 'top' }, gap: 40 })
47
+ asset = addV3Box(asset, { id: 'mark', size: { x: 30, y: 30, z: 560 }, position: { x: 0, y: 0, z: 0 } })
48
+ asset = applyV3Authoring(asset, { kind: 'attach', part: 'mark', face: 'left', to: { part: 'roller', face: 'right' } })
49
+ const j = asset.joints.find(o => o.body1.part === 'roller')
50
+ asset = applyV3Authoring(asset, { kind: 'set-joint', joint: { ...j, type: 'revolute', axis: 'Z', lowerLimit: -360, upperLimit: 360, state: { id: 'spin' } } })
51
+ return serializeV3Asset(applyV3Authoring(asset, { kind: 'make-repeat', part: 'roller', axis: 'x', pitch: 200 }))
52
+ }
39
53
  throw Error(`no figure for ${kind}`)
40
54
  }
41
55
 
@@ -139,6 +153,14 @@ window.probe = {
139
153
  const l = Math.hypot(b.x - a.x, b.y - a.y) || 1
140
154
  return { x: (b.x - a.x) / l, y: (b.y - a.y) / l }
141
155
  },
156
+ /** The ids of every body drawn. */
157
+ ids() {
158
+ return editor().session.read().geometry.map(g => g.id)
159
+ },
160
+ /** Whether an input with this label is on the page. */
161
+ hasInput(label) {
162
+ return deep('input').some(i => i.getAttribute('aria-label') === label)
163
+ },
142
164
  /** The page point of a part's row in the parts list. */
143
165
  row(id) {
144
166
  const label = deep('span[label]').find(s => s.getAttribute('title') === id)
@@ -191,7 +213,7 @@ window.probe = {
191
213
  }
192
214
 
193
215
  window.harness = {
194
- async mount(kind, text, recipes = []) {
216
+ async mount(kind, text, templates = []) {
195
217
  const source = sourceOf(kind, text)
196
218
  const session = createV3ProductSession({
197
219
  load: async id => ({ id, source }),
@@ -200,7 +222,7 @@ window.harness = {
200
222
  await session.open('probe')
201
223
  const editor = document.createElement('figure-v3-editor')
202
224
  editor.session = session
203
- editor.recipes = recipes
225
+ editor.templates = templates
204
226
  editor.revision = 0
205
227
  /* The modeller page counts a change the same way: the editor re-reads the session on a new revision. */
206
228
  editor.addEventListener('v3-changed', () => (editor.revision = editor.revision + 1))
@@ -13,9 +13,8 @@
13
13
  */
14
14
  import { test, describe, before, after } from 'node:test'
15
15
  import assert from 'node:assert/strict'
16
- import { readdirSync, readFileSync } from 'node:fs'
17
- import { createRequire } from 'node:module'
18
- import { dirname, join, resolve } from 'node:path'
16
+ import { readFileSync } from 'node:fs'
17
+ import { dirname, resolve } from 'node:path'
19
18
  import { fileURLToPath } from 'node:url'
20
19
 
21
20
  import { buildPage } from './build.mjs'
@@ -674,13 +673,13 @@ describe('a part follows the figure size in one step (follow-size, 2026-09-26)',
674
673
  })
675
674
  })
676
675
 
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')))
676
+ describe('the recipe catalog: a template chosen, filled in, put onto the chosen part in one step (ADR-0095)', () => {
677
+ /* The product's templates, as the server reads them, as catalog rows. */
678
+ const templates = () =>
679
+ ['sliding-door', 'hinged-door', 'roller-row'].map(name => {
680
+ const recipe = JSON.parse(readFileSync(resolve(HERE, `../../../figure-service/recipes/${name}/recipe.json`), 'utf-8'))
681
+ return { name, category: recipe.category, tags: recipe.tags, target: recipe.target, title: recipe.title.en, description: recipe.description.en, hasThumbnail: false, recipe }
682
+ })
684
683
  const at = selector =>
685
684
  page().evaluate(selector => {
686
685
  const deep = (root = document) => [...root.querySelectorAll(selector)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
@@ -688,58 +687,69 @@ describe('a recipe put onto a part: chosen, filled in, one step (ADR-0095, 2026-
688
687
  if (!e) return null
689
688
  e.scrollIntoView({ block: 'center' })
690
689
  const r = e.getBoundingClientRect()
691
- return { x: r.left + r.width / 2, y: r.top + r.height / 2, disabled: !!e.disabled }
690
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2, disabled: !!e.disabled, text: e.textContent.trim() }
692
691
  }, selector)
693
692
  const parts = () => read(() => document.querySelector('figure-v3-editor').session.read().geometry.map(g => g.id))
693
+ const type = async (selector, text) => {
694
+ const field = await page().evaluateHandle(selector => {
695
+ const deep = (root = document) => [...root.querySelectorAll(selector)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
696
+ return deep()[0] ?? null
697
+ }, selector)
698
+ await field.evaluate(i => i.scrollIntoView({ block: 'center' }))
699
+ await field.click({ clickCount: 3 })
700
+ await page().keyboard.type(text)
701
+ await page().keyboard.press('Tab')
702
+ }
694
703
 
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())
704
+ test('the room chosen, the catalog opened: the door told in full, a share typed, put in -- the frame, the leaves and the lamp, and the catalog closes', async () => {
705
+ await read(list => harness.mount('room', null, list), templates())
697
706
  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
707
  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')
708
+ await click(await at('button[recipe-catalog]'))
709
+ assert.ok(await at('figure-v3-recipe-catalog'), 'the catalog is open')
710
+ await click(await at('button[card="sliding-door"]'))
711
+ assert.ok(await at('li[parts="2–4"]'), 'the parts it adds, worked out from its choices: 2 to 4')
712
+ await type('input[param="share"]', '50')
712
713
  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')
714
+ await click(await at('button[insert]'))
715
+ assert.equal(await read(() => document.querySelector('figure-v3-editor').recipeError), '', 'not refused')
716
+ assert.equal((await state()).revision, start.revision + 1, 'one step')
718
717
  const ids = await parts()
719
718
  for (const part of ['frame', 'left', 'right', 'lamp']) assert.ok(ids.includes(`sliding-door-1-${part}`), `${part} is in`)
720
719
  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')
720
+ assert.equal(Math.round(frame.dimensions.right - frame.dimensions.left), 2000, "half the room's 4000 mm")
721
+ assert.equal(await at('figure-v3-recipe-catalog'), null, 'the catalog closes')
722
722
  })
723
723
 
724
- test('a door the room cannot take is refused in the form, in the kernel\'s words, and nothing is added', async () => {
724
+ test('a door the room cannot take is refused in the catalog, in the kernel\'s words, and nothing is added', async () => {
725
+ await click(await read(() => probe.row('room')))
726
+ await click(await at('button[recipe-catalog]'))
727
+ await click(await at('button[card="sliding-door"]'))
725
728
  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')
729
+ await type('input[param="share"]', '5')
734
730
  const start = await state()
735
- await click(await at('div[recipe-actions] button[insert]'))
731
+ await click(await at('button[insert]'))
736
732
  assert.equal((await state()).revision, start.revision, 'no step')
737
733
  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}`)
734
+ const said = await at('section[detail] p[role="alert"]')
735
+ assert.ok(said && /steps\[\d+\]/.test(said.text), `the kernel's sentence, with the step: ${said?.text}`)
736
+ await click(await at('button[close]'))
737
+ })
738
+ })
739
+
740
+ describe('a row that carries a part (ADR-0093, 2026-09-26)', () => {
741
+ test('the list has the roller and its mark, each once; a click on a mark on the canvas picks the mark, and only the roller offers the row', async () => {
742
+ await mount('rollers')
743
+ assert.ok(await read(() => probe.row('roller')), 'the roller, once')
744
+ assert.ok(await read(() => probe.row('mark')), 'the mark it carries, once')
745
+ const copy = (await read(() => probe.ids())).find(id => id.includes('mark.') && id.includes(',3,'))
746
+ assert.ok(copy, 'a mark on the fourth roller is drawn')
747
+ await click(await read(id => probe.partOnPage(id), copy))
748
+ assert.equal((await state()).selected, 'mark', 'picked by the part it copies')
749
+ assert.equal(await read(() => probe.hasInput('mark.repeat.pitch')), false, 'the mark has no row of its own to set')
750
+ await click(await read(() => probe.row('roller')))
751
+ assert.equal((await state()).selected, 'roller')
752
+ assert.ok(await read(() => probe.hasInput('roller.repeat.pitch')), 'the roller offers its row')
743
753
  })
744
754
  })
745
755
 
@@ -0,0 +1,14 @@
1
+ /*
2
+ * The page that draws recipe template thumbnails (ADR-0095 amended): a model -- the host and the template's parts -- drawn
3
+ * the way the modeller draws a figure's card, by the same code (`captureV3Thumbnail`).
4
+ */
5
+ import { captureV3Thumbnail } from 'figure-ui-client/modeller/v3-thumbnail.js'
6
+ import { drawableV3Parts } from 'figure-ui-client/modeller/v3-renderer.js'
7
+ import { compileV3Asset } from '@hatiolab/figure-model'
8
+
9
+ window.harness = { ready: true }
10
+ window.shoot = async (text, state = {}) => {
11
+ const asset = JSON.parse(text)
12
+ const evaluation = compileV3Asset(asset).evaluate({ ...asset.stateDefaults, ...state })
13
+ return await captureV3Thumbnail(drawableV3Parts(evaluation.geometry), evaluation.palette ?? asset.palette ?? {})
14
+ }
@@ -53,7 +53,7 @@ test('긴 설명은 제목 옆 「?」 로 연다 — 늘 펼쳐 두지 않는
53
53
 
54
54
  test('되풀이한 부품은 되풀이의 이름과 몇 번째인지로 읽는다 — 날 id 를 보이지 않는다', () => {
55
55
  assert.equal(partLabelOf('["roller.repeat",0,"roller.value"]'), 'roller 1')
56
- assert.equal(partLabelOf('["rack.repeat",1,"shelf.repeat",2,"shelf.value"]'), 'rack 2·3')
56
+ assert.equal(partLabelOf('["roller.repeat",2,"mark.placed"]'), 'mark 3', '줄의 부품에 붙은 것은 제 이름으로 (ADR-0093, 2026-09-26)')
57
57
  assert.equal(partLabelOf('deck'), 'deck', '보통 부품은 제 이름')
58
58
  assert.equal(partLabelOf('[not json'), '[not json')
59
59
  const parts = readFileSync(fileURLToPath(new URL('../client/modeller/figure-v3-parts.ts', import.meta.url)), 'utf8')
@@ -64,6 +64,9 @@ test('되풀이한 부품의 속성 이름 칸도 사람의 이름이고, 몇
64
64
  const geometry = Array.from({ length: 16 }, (_, i) => ({ id: JSON.stringify(['roller.repeat', i, 'roller.value']) })).concat([{ id: 'deck' }])
65
65
  assert.deepEqual(repeatOf('["roller.repeat",14,"roller.value"]', geometry), { name: 'roller', count: 16, index: 15 })
66
66
  assert.equal(repeatOf('deck', geometry), null)
67
+ const carried = geometry.concat(Array.from({ length: 16 }, (_, i) => ({ id: JSON.stringify(['roller.repeat', i, 'mark.placed']) })))
68
+ assert.deepEqual(repeatOf('["roller.repeat",14,"roller.value"]', carried), { name: 'roller', count: 16, index: 15 }, '롤러에 붙은 것은 롤러 수에 안 든다')
69
+ assert.deepEqual(repeatOf('["roller.repeat",2,"mark.placed"]', carried), { name: 'mark', count: 16, index: 3 })
67
70
  const editor = readFileSync(fileURLToPath(new URL('../client/modeller/v3-editor.ts', import.meta.url)), 'utf8')
68
71
  assert.ok(editor.includes('.value=${partLabelOf(this.selected)}'), '사용자 2026-09-24: 「부품 이름 ???」')
69
72
  assert.ok(editor.includes('?disabled=${unavailable || !!repeatOf(this.selected, view.geometry)}'), '복사본에는 바꿀 제 이름이 없다')
@@ -72,6 +75,7 @@ test('되풀이한 부품의 속성 이름 칸도 사람의 이름이고, 몇
72
75
  test('편집기는 저작한 부품 단위로 고르고 보인다 — 되풀이는 한 부품, 캔버스에서는 복사본 모두가 골라진다', () => {
73
76
  assert.equal(ownerOf('["roller.repeat",14,"roller.value"]'), 'roller')
74
77
  assert.equal(ownerOf('deck'), 'deck')
78
+ assert.equal(ownerOf('["roller.repeat",2,"mark.placed"]'), 'mark', '롤러에 붙은 표시의 복사본을 누르면 그 표시를 고른다')
75
79
  assert.deepEqual(repeatOf('roller', [{ id: '["roller.repeat",0,"roller.value"]' }, { id: '["roller.repeat",1,"roller.value"]' }, { id: 'deck' }]), { name: 'roller', count: 2, index: 0 })
76
80
  const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
77
81
  const canvas = read('../client/modeller/v3-canvas.ts')
@@ -618,3 +618,15 @@ test('치수 칸 — 「도형 크기를 따라감」 단추는 follow-size 한
618
618
  const ko = JSON.parse(read('../translations/ko.json'))
619
619
  assert.match(ko['figure.text.size-declared-to-follow'], /\{x\} × \{y\} × \{z\} mm/)
620
620
  })
621
+
622
+ /* 통 — 속 빈 상자를 팔레트에서, 세 축 모두 크기를 따르고, 손잡이로 키우고, 안쪽 면에 붙인다 (G4, 09-26). */
623
+ test('팔레트의 통은 통의 크기로 서고, 세 축 모두 크기를 따를 수 있고, 손잡이로 키울 수 있다', async () => {
624
+ const { paletteRecipe } = await import('../client/modeller/v3-palette.ts')
625
+ const { gizmoReachOf } = await import('../client/modeller/v3-gizmo.ts')
626
+ const { addV3Primitive: add } = await import('@hatiolab/figure-model')
627
+ const asset = add(createV3Asset('Tote'), paletteRecipe('hollow-box' as never, 'tote'))
628
+ assert.deepEqual(followableAxes(asset, 'tote'), ['x', 'y', 'z'])
629
+ assert.deepEqual(gizmoReachOf(asset as never, 'tote').sizes, ['tote.dimension.width', 'tote.dimension.height', 'tote.dimension.depth'])
630
+ const ko = JSON.parse(read('../translations/ko.json'))
631
+ assert.equal(ko['figure.label.face-inside-bottom'], '안쪽 바닥')
632
+ })
@@ -1,14 +1,17 @@
1
1
  /*
2
- * **조각 칸** (ADR-0095, 2026-09-26). 문 · 롤러 줄을 한 단계로 넣는다 — 부품을 고른 뒤에, 몇 개의 수와 선택으로.
2
+ * **조각 카탈로그** (ADR-0095, 2026-09-26). 템플릿은 서버의 정적 파일이고, 모델러는 분류 · 찾기 · 설명 · 넣기를 한 창에서 한다.
3
+ * 부품 수와 작동 방식은 레시피에서 셈한다 — 글로 적지 않는다.
3
4
  */
4
5
  import { test } from 'node:test'
5
6
  import assert from 'node:assert/strict'
6
7
  import { readFileSync } from 'node:fs'
7
8
  import { fileURLToPath } from 'node:url'
8
9
 
9
- import { recipeFieldOf, recipeReady, recipesOf, recipeValueOf, recipeWord } from '../client/modeller/v3-recipes.ts'
10
+ import { recipeFieldOf, recipeReady, recipesMatching, recipeValueOf, recipeWord, recipeWorkings, templatesOf } from '../client/modeller/v3-recipes.ts'
10
11
 
11
12
  const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
13
+ /* The product's templates, as the server reads them. */
14
+ const template = (name: string) => read(`../../figure-service/recipes/${name}/recipe.json`)
12
15
  const share = { name: 'share', kind: 'ratio' as const, default: 0.35, min: 0.05, max: 0.95 }
13
16
 
14
17
  test('비율은 백분율로 보이고 받는다; 빈 칸은 기본값; 선택은 선택지 중에서만', () => {
@@ -27,34 +30,48 @@ test('말은 화면의 언어로, 없으면 영어로; 부품용 조각은 부
27
30
  assert.equal(recipeWord({ ko: '미닫이 문', en: 'sliding door' }, 'ja'), 'sliding door')
28
31
  assert.equal(recipeReady({ target: 'part' }, ''), false)
29
32
  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'])
33
+ const row = { category: 'door', tags: [], target: 'part' as const, title: 't', description: 'd', hasThumbnail: false }
34
+ assert.deepEqual(templatesOf([{ ...row, name: 'a', source: template('sliding-door') }, { ...row, name: 'b', source: 'not json' }]).map(t => t.name), ['a'])
32
35
  })
33
36
 
34
- test('편집기는 조각을 도형이 선 그대로 풀어 한 단계로 넣고, 거절은 폼에 커널의 말로', () => {
37
+ test('분류와 낱말로 찾는다 — 제목 · 설명 · 이름 · 태그 어디에 있든, 낱말이 모두 있어야', () => {
38
+ const list = [
39
+ { name: 'sliding-door', category: 'door', title: '미닫이 문', description: '앞면에 문틀과 문짝', tags: ['door', 'lamp'] },
40
+ { name: 'roller-row', category: 'conveyor', title: '롤러 줄', description: '윗면에 롤러', tags: ['roller'] }
41
+ ]
42
+ assert.deepEqual(recipesMatching(list, '', '').map(t => t.name), ['sliding-door', 'roller-row'])
43
+ assert.deepEqual(recipesMatching(list, 'conveyor', '').map(t => t.name), ['roller-row'])
44
+ assert.deepEqual(recipesMatching(list, '', 'lamp 문').map(t => t.name), ['sliding-door'])
45
+ assert.deepEqual(recipesMatching(list, 'door', 'roller'), [])
46
+ })
47
+
48
+ test('작동 방식은 레시피에서 셈한다 — 미닫이 문: 두 문짝이 미끄러지고 하나는 따라가며, 조작값 하나, 램프는 조작값에 따라 보임', () => {
49
+ const door = JSON.parse(template('sliding-door'))
50
+ assert.deepEqual(recipeWorkings(door), [
51
+ { key: 'figure.text.recipe-slides', words: { part: 'left' } },
52
+ { key: 'figure.text.recipe-moves-with', words: { part: 'right', leader: 'left' } },
53
+ { key: 'figure.text.recipe-controls', words: { count: 1 } },
54
+ { key: 'figure.text.recipe-shown-by-control', words: { parts: 'lamp' } }
55
+ ])
56
+ assert.deepEqual(recipeWorkings(JSON.parse(template('roller-row'))), [{ key: 'figure.text.recipe-row', words: { part: 'roller' } }])
57
+ })
58
+
59
+ test('모델러 — 부품 칸의 단추가 카탈로그를 열고, 넣으면 한 단계로 들어가고 창이 닫힌다; 목록은 페이지가 서버에서 한 번 읽는다', () => {
60
+ const parts = read('../client/modeller/figure-v3-parts.ts')
61
+ assert.ok(parts.includes("this.ask('open-recipe-catalog', {})"))
35
62
  const editor = read('../client/modeller/v3-editor.ts')
36
63
  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 })"), '사람이 넣은 것')
64
+ assert.ok(insert.includes('v3RecipeIdFor(asset, checked)') && insert.includes('this.session!.applyRecipe(steps)') && insert.includes('this.catalogOpen = false'))
65
+ assert.ok(insert.includes('this.recipeError = error instanceof Error ? error.message : String(error)'), '거절은 커널의 말로')
42
66
  const page = read('../client/pages/figure-v3-modeller-page.ts')
43
- assert.ok(page.includes('.recipes=${this.recipes}') && page.includes('fetchFigureRecipes()'), '페이지가 목록을 읽어 건넨다')
67
+ assert.ok(page.includes("availableRecipes(i18next.language || 'en')") && page.includes('.templates=${this.templates}'))
68
+ const catalog = read('../client/modeller/figure-v3-recipe-catalog.ts')
69
+ assert.ok(catalog.includes('describeV3Recipe(t.recipe)'), '부품 수는 셈한다')
70
+ assert.ok(catalog.includes("${this.guide ? html`<ox-markdown"), '설명이 없으면 아무것도 두지 않는다 — 「준비 중」 없음')
44
71
  })
45
72
 
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' }"))
73
+ test('넣기는 카탈로그에서 온다 — 편집기는 카탈로그 요소에서 insert-recipe 를 듣는다 (잘못 옮겨진 연결, 09-26)', () => {
74
+ const editor = read('../client/modeller/v3-editor.ts')
75
+ const catalog = editor.slice(editor.indexOf('<figure-v3-recipe-catalog'), editor.indexOf('</figure-v3-recipe-catalog>'))
76
+ assert.ok(catalog.includes('@insert-recipe=${(e: CustomEvent) => this.insertRecipe(e.detail.recipe, e.detail.values)}'))
60
77
  })
@@ -5,7 +5,6 @@ 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' },
9
8
  /* One modeller since ADR-0087 step 5; the old V3 address is an alias. The shell builds the DOM from this table. */
10
9
  { tagname: 'figure-v3-modeller-page', page: 'figure-modeller' },
11
10
  { tagname: 'figure-v3-modeller-page', page: 'figure-v3-modeller' }
@@ -202,30 +202,45 @@
202
202
  "figure.text.size-declared-to-follow": "the figure size to follow was set from its parts: {x} × {y} × {z} mm.",
203
203
  "figure.label.recipes": "recipes",
204
204
  "figure.text.recipe-needs-a-part": "choose the part to put it on first.",
205
- "figure.text.recipe-onto": "onto {part}.",
206
205
  "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",
206
+ "figure.label.recipe-catalog": "recipe catalog",
207
+ "figure.text.recipe-search": "find by name, description or tag",
208
+ "figure.label.recipe-all": "all",
209
+ "figure.label.recipe-category-door": "doors and openings",
210
+ "figure.label.recipe-category-conveyor": "conveying",
211
+ "figure.label.recipe-category-support": "supports and structure",
212
+ "figure.label.recipe-category-safety": "safety",
213
+ "figure.label.recipe-category-signal": "signals and lights",
214
+ "figure.label.recipe-category-control": "controls and electrical",
215
+ "figure.label.recipe-category-handling": "handling",
216
+ "figure.label.recipe-category-storage": "storage and containers",
217
+ "figure.label.recipe-category-utility": "piping and utilities",
218
+ "figure.label.recipe-category-space": "space and building",
219
+ "figure.label.recipe-category-other": "other",
220
+ "figure.label.recipe-adds": "what it puts in",
221
+ "figure.text.recipe-parts": "{parts} parts",
222
+ "figure.text.recipe-slides": "{part}: slides",
223
+ "figure.text.recipe-turns": "{part}: turns",
224
+ "figure.text.recipe-moves-with": "{part}: moves with {leader}",
225
+ "figure.text.recipe-controls": "moved by {count} control(s)",
226
+ "figure.text.recipe-shown-by-control": "{parts}: shown by a control",
227
+ "figure.text.recipe-row": "{part}: a row along the part's length",
228
+ "figure.text.recipe-onto-chosen": "fastened to the chosen part, following its size",
229
+ "figure.text.recipe-onto-figure": "put onto the whole figure",
230
+ "figure.label.recipe-values": "values",
231
+ "figure.button.insert-recipe-onto": "put onto {part}",
232
+ "figure.button.open-recipe-catalog": "recipe catalog ({count})",
233
+ "figure.label.primitive-hollow-box": "tote",
234
+ "figure.label.field-roundX": "corner X",
235
+ "figure.label.field-roundZ": "corner Z",
236
+ "figure.label.field-wallX": "wall X",
237
+ "figure.label.field-wallZ": "wall Z",
238
+ "figure.label.field-floor": "floor",
239
+ "figure.label.face-inside-bottom": "inside bottom",
240
+ "figure.label.face-inside-left": "inside left",
241
+ "figure.label.face-inside-right": "inside right",
242
+ "figure.label.face-inside-back": "inside back",
243
+ "figure.label.face-inside-front": "inside front",
229
244
  "figure.label.placement": "fastening",
230
245
  "figure.label.placement-ceiling": "ceiling-based (ceiling)",
231
246
  "figure.label.placement-center": "center-based (center)",