@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.
- package/client/graphql/index.ts +28 -69
- package/client/modeller/figure-v3-parts.ts +16 -136
- package/client/modeller/figure-v3-recipe-catalog.ts +348 -0
- package/client/modeller/v3-editor.ts +25 -5
- package/client/modeller/v3-fields.ts +21 -16
- package/client/modeller/v3-gizmo.ts +1 -1
- package/client/modeller/v3-links.ts +7 -66
- package/client/modeller/v3-palette.ts +3 -0
- package/client/modeller/v3-recipe-source.ts +15 -0
- package/client/modeller/v3-recipes.ts +50 -27
- package/client/pages/figure-v3-modeller-page.ts +16 -13
- package/client/route.ts +0 -4
- package/dist-client/graphql/index.d.ts +11 -18
- package/dist-client/graphql/index.js +18 -59
- package/dist-client/graphql/index.js.map +1 -1
- package/dist-client/modeller/figure-v3-parts.d.ts +5 -12
- package/dist-client/modeller/figure-v3-parts.js +17 -147
- package/dist-client/modeller/figure-v3-parts.js.map +1 -1
- package/dist-client/modeller/figure-v3-recipe-catalog.d.ts +36 -0
- package/dist-client/modeller/figure-v3-recipe-catalog.js +382 -0
- package/dist-client/modeller/figure-v3-recipe-catalog.js.map +1 -0
- package/dist-client/modeller/v3-editor.d.ts +8 -3
- package/dist-client/modeller/v3-editor.js +35 -6
- package/dist-client/modeller/v3-editor.js.map +1 -1
- package/dist-client/modeller/v3-fields.d.ts +2 -2
- package/dist-client/modeller/v3-fields.js +20 -18
- package/dist-client/modeller/v3-fields.js.map +1 -1
- package/dist-client/modeller/v3-gizmo.js +1 -1
- package/dist-client/modeller/v3-gizmo.js.map +1 -1
- package/dist-client/modeller/v3-links.d.ts +8 -25
- package/dist-client/modeller/v3-links.js +4 -66
- package/dist-client/modeller/v3-links.js.map +1 -1
- package/dist-client/modeller/v3-palette.js +3 -0
- package/dist-client/modeller/v3-palette.js.map +1 -1
- package/dist-client/modeller/v3-recipe-source.d.ts +8 -0
- package/dist-client/modeller/v3-recipe-source.js +14 -0
- package/dist-client/modeller/v3-recipe-source.js.map +1 -0
- package/dist-client/modeller/v3-recipes.d.ts +25 -14
- package/dist-client/modeller/v3-recipes.js +42 -28
- package/dist-client/modeller/v3-recipes.js.map +1 -1
- package/dist-client/pages/figure-v3-modeller-page.d.ts +5 -3
- package/dist-client/pages/figure-v3-modeller-page.js +16 -13
- package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
- package/dist-client/route.d.ts +1 -1
- package/dist-client/route.js +0 -3
- package/dist-client/route.js.map +1 -1
- package/dist-client/tsconfig.fui.tsbuildinfo +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -4
- package/scripts/template-thumbnails.mjs +53 -0
- package/test/browser/build.mjs +3 -3
- package/test/browser/modeller-page.js +25 -3
- package/test/browser/modeller.check.mjs +57 -47
- package/test/browser/thumbnail-page.js +14 -0
- package/test/v3-fields.test.ts +5 -1
- package/test/v3-links.test.ts +12 -0
- package/test/v3-recipes.test.ts +42 -25
- package/things-factory.config.js +0 -1
- package/translations/en.json +38 -23
- package/translations/ja.json +38 -23
- package/translations/ko.json +38 -23
- package/translations/ms.json +38 -23
- package/translations/zh.json +38 -23
- package/client/pages/figure-recipe-page.ts +0 -338
- package/dist-client/pages/figure-recipe-page.d.ts +0 -41
- package/dist-client/pages/figure-recipe-page.js +0 -352
- 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.
|
|
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.
|
|
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.
|
|
44
|
+
"@things-factory/figure-service": "^10.1.71",
|
|
44
45
|
"three": "^0.185.1"
|
|
45
46
|
},
|
|
46
|
-
"gitHead": "
|
|
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
|
+
}
|
package/test/browser/build.mjs
CHANGED
|
@@ -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-
|
|
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,
|
|
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,
|
|
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.
|
|
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 {
|
|
17
|
-
import {
|
|
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('
|
|
678
|
-
/* The
|
|
679
|
-
const
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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('
|
|
696
|
-
await read(
|
|
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
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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('
|
|
714
|
-
|
|
715
|
-
assert.equal(
|
|
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,
|
|
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
|
|
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
|
-
|
|
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('
|
|
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
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
+
}
|
package/test/v3-fields.test.ts
CHANGED
|
@@ -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('["
|
|
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')
|
package/test/v3-links.test.ts
CHANGED
|
@@ -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
|
+
})
|
package/test/v3-recipes.test.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* **조각
|
|
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,
|
|
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
|
-
|
|
31
|
-
assert.deepEqual(
|
|
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('
|
|
38
|
-
assert.ok(insert.includes('this.
|
|
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(
|
|
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('
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
assert.
|
|
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
|
})
|
package/things-factory.config.js
CHANGED
|
@@ -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' }
|
package/translations/en.json
CHANGED
|
@@ -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.
|
|
208
|
-
"figure.text.
|
|
209
|
-
"figure.label.recipe-
|
|
210
|
-
"figure.label.recipe-
|
|
211
|
-
"figure.
|
|
212
|
-
"figure.
|
|
213
|
-
"figure.
|
|
214
|
-
"figure.
|
|
215
|
-
"figure.label.recipe-
|
|
216
|
-
"figure.label.recipe-
|
|
217
|
-
"figure.label.recipe-
|
|
218
|
-
"figure.label.
|
|
219
|
-
"figure.label.
|
|
220
|
-
"figure.
|
|
221
|
-
"figure.
|
|
222
|
-
"figure.
|
|
223
|
-
"figure.
|
|
224
|
-
"figure.
|
|
225
|
-
"figure.text.recipe-
|
|
226
|
-
"figure.text.recipe-
|
|
227
|
-
"figure.text.recipe-
|
|
228
|
-
"figure.
|
|
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)",
|