@things-factory/figure-ui 10.1.71 → 10.1.73
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/modeller/figure-v3-links.ts +12 -5
- package/client/modeller/figure-v3-parts.ts +1 -0
- package/client/modeller/figure-v3-properties.ts +3 -2
- package/client/modeller/figure-v3-recipe-catalog.ts +22 -4
- package/client/modeller/v3-editor.ts +1 -1
- package/client/modeller/v3-figure-facts.ts +26 -2
- package/client/modeller/v3-links.ts +50 -0
- package/client/modeller/v3-recipes.ts +16 -1
- package/dist-client/modeller/figure-v3-links.js +9 -3
- package/dist-client/modeller/figure-v3-links.js.map +1 -1
- package/dist-client/modeller/figure-v3-parts.js +1 -0
- package/dist-client/modeller/figure-v3-parts.js.map +1 -1
- package/dist-client/modeller/figure-v3-properties.js +3 -2
- package/dist-client/modeller/figure-v3-properties.js.map +1 -1
- package/dist-client/modeller/figure-v3-recipe-catalog.d.ts +2 -0
- package/dist-client/modeller/figure-v3-recipe-catalog.js +21 -4
- package/dist-client/modeller/figure-v3-recipe-catalog.js.map +1 -1
- package/dist-client/modeller/v3-editor.js +1 -1
- package/dist-client/modeller/v3-editor.js.map +1 -1
- package/dist-client/modeller/v3-figure-facts.d.ts +9 -0
- package/dist-client/modeller/v3-figure-facts.js +23 -2
- package/dist-client/modeller/v3-figure-facts.js.map +1 -1
- package/dist-client/modeller/v3-links.d.ts +17 -0
- package/dist-client/modeller/v3-links.js +38 -0
- package/dist-client/modeller/v3-links.js.map +1 -1
- package/dist-client/modeller/v3-recipes.js +21 -1
- package/dist-client/modeller/v3-recipes.js.map +1 -1
- package/dist-client/route.d.ts +1 -1
- package/dist-client/tsconfig.fui.tsbuildinfo +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/test/browser/build.mjs +12 -0
- package/test/browser/modeller-page.js +28 -2
- package/test/browser/modeller.check.mjs +47 -5
- package/test/browser/serve.mjs +7 -1
- package/test/v3-fields.test.ts +2 -1
- package/test/v3-links.test.ts +25 -0
- package/test/v3-properties.test.ts +13 -0
- package/test/v3-recipes.test.ts +13 -1
- package/translations/en.json +5 -2
- package/translations/ja.json +4 -1
- package/translations/ko.json +5 -2
- package/translations/ms.json +4 -1
- package/translations/zh.json +4 -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.73",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"@things-factory:registry": "https://registry.npmjs.org"
|
|
@@ -27,7 +27,7 @@
|
|
|
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.89",
|
|
31
31
|
"@hatiolab/things-scene": "^10.1.56",
|
|
32
32
|
"@material/web": "^2.0.0",
|
|
33
33
|
"@operato/data-grist": "^10.0.0",
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
"@operato/styles": "^10.0.0",
|
|
42
42
|
"@operato/utils": "^10.0.0",
|
|
43
43
|
"@things-factory/ai-assistant": "^10.1.58",
|
|
44
|
-
"@things-factory/figure-service": "^10.1.
|
|
44
|
+
"@things-factory/figure-service": "^10.1.73",
|
|
45
45
|
"three": "^0.185.1"
|
|
46
46
|
},
|
|
47
|
-
"gitHead": "
|
|
47
|
+
"gitHead": "5b5ef6baa7709c2fcbd2f08b8f9c6a750393dfa6"
|
|
48
48
|
}
|
package/test/browser/build.mjs
CHANGED
|
@@ -56,6 +56,18 @@ export async function buildPage(page = 'modeller-page.js') {
|
|
|
56
56
|
const file = join(out, 'page.js')
|
|
57
57
|
await bundle.write({ file, format: 'esm', inlineDynamicImports: true })
|
|
58
58
|
await bundle.close()
|
|
59
|
+
|
|
60
|
+
/*
|
|
61
|
+
ox-markdown renders in a worker it loads from beside its module (\`../../workers/markdown-webworker.js\`); from
|
|
62
|
+
/page.js that is /workers/. Bundled here and served there, the guides render as in the app -- without it the worker
|
|
63
|
+
never answers and a guide is drawn empty.
|
|
64
|
+
*/
|
|
65
|
+
const worker = await rollup({
|
|
66
|
+
input: join(ROOT, 'node_modules/@operato/markdown/workers/markdown-webworker.js'),
|
|
67
|
+
plugins: [nodeResolve({ browser: true, modulePaths: [join(PACKAGE, 'node_modules'), join(ROOT, 'node_modules')] }), commonjs()]
|
|
68
|
+
})
|
|
69
|
+
await worker.write({ file: join(out, 'workers/markdown-webworker.js'), format: 'esm', inlineDynamicImports: true })
|
|
70
|
+
await worker.close()
|
|
59
71
|
return file
|
|
60
72
|
}
|
|
61
73
|
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* `client/` by the runner.
|
|
5
5
|
*/
|
|
6
6
|
import 'figure-ui-client/modeller/v3-editor.js'
|
|
7
|
+
import { budgetOf, v3ReportOf } from 'figure-ui-client/modeller/v3-maturity.js'
|
|
7
8
|
import { createV3ProductSession } from 'figure-ui-client/modeller/v3-product-session.js'
|
|
8
|
-
import { addV3Box, addV3Primitive, applyV3Authoring, createV3Asset, robotArmV3, serializeV3Asset } from '@hatiolab/figure-model'
|
|
9
|
+
import { addV3Box, addV3Primitive, applyV3Authoring, createV3Asset, parseV3Asset, robotArmV3, serializeV3Asset } from '@hatiolab/figure-model'
|
|
9
10
|
|
|
10
11
|
const PALETTE = {
|
|
11
12
|
'palette.dark': '#2b2b2b',
|
|
@@ -157,6 +158,20 @@ window.probe = {
|
|
|
157
158
|
ids() {
|
|
158
159
|
return editor().session.read().geometry.map(g => g.id)
|
|
159
160
|
},
|
|
161
|
+
/** The value of the selects that offer this option, as they stand. */
|
|
162
|
+
selectsOffering(value) {
|
|
163
|
+
return deep('select').filter(s => [...s.options].some(o => o.value === value)).map(s => s.value)
|
|
164
|
+
},
|
|
165
|
+
/** The page point of an input by its aria-label, or of the part name field. */
|
|
166
|
+
nameField() {
|
|
167
|
+
const input = deep('input[name-field]')[0]
|
|
168
|
+
return input ? { ...centreOf(input), disabled: input.disabled } : null
|
|
169
|
+
},
|
|
170
|
+
/** The cost line's grade and words. */
|
|
171
|
+
budgetLine() {
|
|
172
|
+
const b = deep('button[budget]')[0]
|
|
173
|
+
return b ? { grade: b.getAttribute('grade'), text: b.textContent.replace(/\s+/g, ' ').trim() } : null
|
|
174
|
+
},
|
|
160
175
|
/** Whether an input with this label is on the page. */
|
|
161
176
|
hasInput(label) {
|
|
162
177
|
return deep('input').some(i => i.getAttribute('aria-label') === label)
|
|
@@ -213,7 +228,7 @@ window.probe = {
|
|
|
213
228
|
}
|
|
214
229
|
|
|
215
230
|
window.harness = {
|
|
216
|
-
async mount(kind, text, templates = []) {
|
|
231
|
+
async mount(kind, text, templates = [], guides = {}) {
|
|
217
232
|
const source = sourceOf(kind, text)
|
|
218
233
|
const session = createV3ProductSession({
|
|
219
234
|
load: async id => ({ id, source }),
|
|
@@ -222,7 +237,18 @@ window.harness = {
|
|
|
222
237
|
await session.open('probe')
|
|
223
238
|
const editor = document.createElement('figure-v3-editor')
|
|
224
239
|
editor.session = session
|
|
240
|
+
/* The cost line as the modeller page gives it: the maturity report of what is authored (figure-v3-verdict). */
|
|
241
|
+
const report = () => {
|
|
242
|
+
try {
|
|
243
|
+
return budgetOf(v3ReportOf(parseV3Asset(session.source())))
|
|
244
|
+
} catch {
|
|
245
|
+
return undefined
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
editor.budget = report()
|
|
249
|
+
editor.addEventListener('v3-changed', () => (editor.budget = report()))
|
|
225
250
|
editor.templates = templates
|
|
251
|
+
editor.guideOf = async name => guides[name] ?? ''
|
|
226
252
|
editor.revision = 0
|
|
227
253
|
/* The modeller page counts a change the same way: the editor re-reads the session on a new revision. */
|
|
228
254
|
editor.addEventListener('v3-changed', () => (editor.revision = editor.revision + 1))
|
|
@@ -334,7 +334,7 @@ describe('the fastening form belongs to the part it is for', () => {
|
|
|
334
334
|
return deep().find(s => s.previousElementSibling?.textContent?.trim() === label) ?? null
|
|
335
335
|
}, label)
|
|
336
336
|
|
|
337
|
-
test('a choice made for one part is not carried to the next part picked (셔터
|
|
337
|
+
test('a choice made for one part is not carried to the next part picked; the next opens on its own fastening (셔터 커튼 2026-09-25, walk 1 2026-09-26)', async () => {
|
|
338
338
|
/* The placing across the target face is asked once there is a target: the form starts with none. */
|
|
339
339
|
await click(await read(() => probe.row('forearm')))
|
|
340
340
|
const target = await alignSelect('figure.label.target-part')
|
|
@@ -346,10 +346,15 @@ describe('the fastening form belongs to the part it is for', () => {
|
|
|
346
346
|
assert.equal(await select.evaluate(s => s.value), 'max', 'chosen for the forearm')
|
|
347
347
|
|
|
348
348
|
await click(await read(() => probe.row('elbow')))
|
|
349
|
+
/* The elbow opens on its own fastening (walk 1, 2026-09-26), not on the forearm's choices. */
|
|
350
|
+
const own = await read(() => {
|
|
351
|
+
const joint = JSON.parse(document.querySelector('figure-v3-editor').session.source()).joints.find(j => j.body1.part === 'elbow')
|
|
352
|
+
return { target: joint.body0.part, x: joint.origin?.align?.x ?? 'centre' }
|
|
353
|
+
})
|
|
349
354
|
const nextTarget = await alignSelect('figure.label.target-part')
|
|
350
|
-
assert.equal(await nextTarget.evaluate(s => s.value),
|
|
355
|
+
assert.equal(await nextTarget.evaluate(s => s.value), own.target, 'the elbow opens on what it is fastened to')
|
|
351
356
|
const next = await alignSelect('figure.label.align-x')
|
|
352
|
-
assert.ok(await next.evaluate(s => !s || s.value === 'centre'), '
|
|
357
|
+
assert.ok(await next.evaluate((s, x) => !s || s.value === (typeof x === 'string' ? x : 'mm') || s.value === 'centre', own.x), 'and on its own placing, not the forearm\'s edge')
|
|
353
358
|
})
|
|
354
359
|
})
|
|
355
360
|
|
|
@@ -702,13 +707,30 @@ describe('the recipe catalog: a template chosen, filled in, put onto the chosen
|
|
|
702
707
|
}
|
|
703
708
|
|
|
704
709
|
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
|
-
|
|
710
|
+
const guides = { 'sliding-door': readFileSync(resolve(HERE, '../../../figure-service/recipes/sliding-door/guide.en.md'), 'utf-8') }
|
|
711
|
+
await read((list, guides) => harness.mount('room', null, list, guides), templates(), guides)
|
|
706
712
|
await sleep(2500)
|
|
707
713
|
await click(await read(() => probe.row('room')))
|
|
708
714
|
await click(await at('button[recipe-catalog]'))
|
|
709
715
|
assert.ok(await at('figure-v3-recipe-catalog'), 'the catalog is open')
|
|
710
|
-
await click(await at('button[card="
|
|
716
|
+
await click(await at('button[card="hinged-door"]'))
|
|
717
|
+
/* A search that takes the chosen card away tells the first card it leaves (:3300, 2026-09-26). */
|
|
718
|
+
await type('input[search]', 'sliding')
|
|
719
|
+
assert.equal(await read(() => {
|
|
720
|
+
const deep = (root = document) => [...root.querySelectorAll('button[card][on]')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
|
|
721
|
+
return deep().map(b => b.getAttribute('card')).join()
|
|
722
|
+
}), 'sliding-door', 'the card told is one the search shows')
|
|
711
723
|
assert.ok(await at('li[parts="2–4"]'), 'the parts it adds, worked out from its choices: 2 to 4')
|
|
724
|
+
/* The guide is drawn as its text, not as the page its words would name (seen on :3300, 2026-09-26). */
|
|
725
|
+
await sleep(1500)
|
|
726
|
+
const guide = await read(() => {
|
|
727
|
+
const deep = (root = document) => [...root.querySelectorAll('ox-markdown')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
|
|
728
|
+
const shown = deep()[0]
|
|
729
|
+
return shown ? { headings: [...shown.shadowRoot.querySelectorAll('h2')].map(h => h.textContent.trim()), app: !!shown.shadowRoot.querySelector('ox-app, things-app, body, html') } : null
|
|
730
|
+
})
|
|
731
|
+
assert.ok(guide, 'the guide is shown')
|
|
732
|
+
assert.ok(guide.headings.includes('When to use it'), `the guide's own headings: ${JSON.stringify(guide.headings)}`)
|
|
733
|
+
assert.equal(guide.app, false, 'no page drawn inside it')
|
|
712
734
|
await type('input[param="share"]', '50')
|
|
713
735
|
const start = await state()
|
|
714
736
|
await click(await at('button[insert]'))
|
|
@@ -741,6 +763,13 @@ describe('a row that carries a part (ADR-0093, 2026-09-26)', () => {
|
|
|
741
763
|
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
764
|
await mount('rollers')
|
|
743
765
|
assert.ok(await read(() => probe.row('roller')), 'the roller, once')
|
|
766
|
+
/*
|
|
767
|
+
The grade counts what the renderer draws (ADR-0087 amended, 2026-09-26): ten turning rollers and their marks are
|
|
768
|
+
two batches and twenty moving bodies -- within the budgets -- and the line names it a performance cost.
|
|
769
|
+
*/
|
|
770
|
+
const line = await read(() => probe.budgetLine())
|
|
771
|
+
assert.ok(line.text.includes('figure.label.performance-cost'), `the line says performance cost: ${line.text}`)
|
|
772
|
+
assert.notEqual(line.grade, 'D', `a turning roller row is not over its budgets: ${line.text}`)
|
|
744
773
|
assert.ok(await read(() => probe.row('mark')), 'the mark it carries, once')
|
|
745
774
|
const copy = (await read(() => probe.ids())).find(id => id.includes('mark.') && id.includes(',3,'))
|
|
746
775
|
assert.ok(copy, 'a mark on the fourth roller is drawn')
|
|
@@ -750,6 +779,19 @@ describe('a row that carries a part (ADR-0093, 2026-09-26)', () => {
|
|
|
750
779
|
await click(await read(() => probe.row('roller')))
|
|
751
780
|
assert.equal((await state()).selected, 'roller')
|
|
752
781
|
assert.ok(await read(() => probe.hasInput('roller.repeat.pitch')), 'the roller offers its row')
|
|
782
|
+
/* Walk 1 (2026-09-26): the fastening form opens on the roller's own fastening -- by its centre. */
|
|
783
|
+
assert.ok((await read(() => probe.selectsOffering('centre'))).includes('centre'), 'my face reads centre')
|
|
784
|
+
/* And the row's part keeps a name of its own to change. */
|
|
785
|
+
const name = await read(() => probe.nameField())
|
|
786
|
+
assert.equal(name.disabled, false, 'the roller can be renamed')
|
|
787
|
+
await page().mouse.click(name.x, name.y, { clickCount: 3 })
|
|
788
|
+
await page().keyboard.type('idler')
|
|
789
|
+
await page().keyboard.press('Tab')
|
|
790
|
+
await sleep(600)
|
|
791
|
+
assert.ok(await read(() => probe.row('idler')), 'renamed in the list')
|
|
792
|
+
const ids = await read(() => probe.ids())
|
|
793
|
+
assert.equal(ids.filter(id => id.includes('"idler.placed"')).length, 10, 'still a row of ten')
|
|
794
|
+
assert.equal(ids.filter(id => id.includes('"mark.placed"')).length, 10, 'each still carries its mark')
|
|
753
795
|
})
|
|
754
796
|
})
|
|
755
797
|
|
package/test/browser/serve.mjs
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Opens the built page in a headless browser: a tiny server for page.js, and puppeteer on it at a desktop size.
|
|
3
3
|
*/
|
|
4
4
|
import { createServer } from 'node:http'
|
|
5
|
-
import { readFileSync } from 'node:fs'
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
6
|
+
import { dirname, join, normalize } from 'node:path'
|
|
6
7
|
import puppeteer from 'puppeteer'
|
|
7
8
|
|
|
8
9
|
const HTML = `<!DOCTYPE html>
|
|
@@ -16,9 +17,14 @@ const HTML = `<!DOCTYPE html>
|
|
|
16
17
|
export async function openPage(bundle, { width = 1400, height = 900 } = {}) {
|
|
17
18
|
const js = readFileSync(bundle)
|
|
18
19
|
const server = createServer((req, res) => {
|
|
20
|
+
/* Scripts built beside the page (the markdown worker) are served as they are. */
|
|
21
|
+
const beside = join(dirname(bundle), normalize(req.url.split('?')[0]).replace(/^([/\\])+/, ''))
|
|
19
22
|
if (req.url === '/page.js') {
|
|
20
23
|
res.writeHead(200, { 'Content-Type': 'application/javascript' })
|
|
21
24
|
res.end(js)
|
|
25
|
+
} else if (req.url.endsWith('.js') && existsSync(beside)) {
|
|
26
|
+
res.writeHead(200, { 'Content-Type': 'application/javascript' })
|
|
27
|
+
res.end(readFileSync(beside))
|
|
22
28
|
} else {
|
|
23
29
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
24
30
|
res.end(HTML)
|
package/test/v3-fields.test.ts
CHANGED
|
@@ -69,7 +69,8 @@ test('되풀이한 부품의 속성 이름 칸도 사람의 이름이고, 몇
|
|
|
69
69
|
assert.deepEqual(repeatOf('["roller.repeat",2,"mark.placed"]', carried), { name: 'mark', count: 16, index: 3 })
|
|
70
70
|
const editor = readFileSync(fileURLToPath(new URL('../client/modeller/v3-editor.ts', import.meta.url)), 'utf8')
|
|
71
71
|
assert.ok(editor.includes('.value=${partLabelOf(this.selected)}'), '사용자 2026-09-24: 「부품 이름 ???」')
|
|
72
|
-
assert.ok(editor.includes('?disabled=${unavailable || !!repeatOf(this.selected, view.geometry)}'), '복사본에는 바꿀 제 이름이
|
|
72
|
+
assert.ok(editor.includes('?disabled=${unavailable || !!repeatOf(this.selected, view.geometry)?.index}'), '복사본에는 바꿀 제 이름이 없고, 줄의 부품 자신은 이름을 바꿀 수 있다(walk 1, 2026-09-26)')
|
|
73
|
+
assert.equal(repeatOf('roller', geometry)?.index, 0, '줄의 부품 자신은 몇 번째가 아니다')
|
|
73
74
|
})
|
|
74
75
|
|
|
75
76
|
test('편집기는 저작한 부품 단위로 고르고 보인다 — 되풀이는 한 부품, 캔버스에서는 복사본 모두가 골라진다', () => {
|
package/test/v3-links.test.ts
CHANGED
|
@@ -630,3 +630,28 @@ test('팔레트의 통은 통의 크기로 서고, 세 축 모두 크기를 따
|
|
|
630
630
|
const ko = JSON.parse(read('../translations/ko.json'))
|
|
631
631
|
assert.equal(ko['figure.label.face-inside-bottom'], '안쪽 바닥')
|
|
632
632
|
})
|
|
633
|
+
|
|
634
|
+
/*
|
|
635
|
+
* The fastening form opens on the part's own fastening (walk 1, 2026-09-26): a roller fastened by its centre was shown
|
|
636
|
+
* as fastened by its bottom, and fastening again from there broke its turning in place.
|
|
637
|
+
*/
|
|
638
|
+
import { fasteningOf } from '../client/modeller/v3-links.ts'
|
|
639
|
+
|
|
640
|
+
test('붙이기 칸은 부품 제 붙임으로 열린다 — 가운데로 붙은 롤러는 가운데 · 상대 · 간격 그대로', () => {
|
|
641
|
+
let asset = addV3Box(createV3Asset('Rollers'), { id: 'bed', size: { x: 2000, y: 400, z: 600 }, position: { x: 0, y: 200, z: 0 } })
|
|
642
|
+
asset = applyV3Authoring(asset, { kind: 'attach', part: 'bed', face: 'bottom', to: { plane: 'mounting-plane' } })
|
|
643
|
+
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 } })
|
|
644
|
+
asset = applyV3Authoring(asset, { kind: 'attach', part: 'roller', face: 'centre', to: { part: 'bed', face: 'top' }, gap: 40, align: { x: 'min' } })
|
|
645
|
+
const roller = fasteningOf(asset, 'roller')!
|
|
646
|
+
assert.equal(roller.face, 'centre')
|
|
647
|
+
assert.equal(roller.target, 'bed')
|
|
648
|
+
assert.equal(roller.targetFace, 'top')
|
|
649
|
+
assert.equal(roller.gap, 40)
|
|
650
|
+
assert.equal(roller.align.x, 'min', '기록의 정렬')
|
|
651
|
+
const bed = fasteningOf(asset, 'bed')!
|
|
652
|
+
assert.deepEqual([bed.face, bed.toPlane], ['bottom', true])
|
|
653
|
+
assert.equal(fasteningOf(addV3Box(createV3Asset('Loose'), { id: 'b', size: { x: 1, y: 1, z: 1 }, position: { x: 0, y: 0, z: 0 } }), 'b'), null, '안 붙은 부품은 기본값으로')
|
|
654
|
+
const panel = readFileSync(fileURLToPath(new URL('../client/modeller/figure-v3-links.ts', import.meta.url)), 'utf8')
|
|
655
|
+
assert.ok(panel.includes('fasteningOf(this.asset, this.partId)'), '부품을 고르면 제 붙임으로 연다')
|
|
656
|
+
assert.ok(panel.includes("...(f.toPlane ? [] : (['centre'] as const))"), '내 면에 가운데가 있다')
|
|
657
|
+
})
|
|
@@ -93,3 +93,16 @@ test('속성 탭의 말은 다섯 말 모두에 있다', () => {
|
|
|
93
93
|
for (const key of keys) assert.ok(table[key], `${lang}: ${key}`)
|
|
94
94
|
}
|
|
95
95
|
})
|
|
96
|
+
|
|
97
|
+
/* Walk 1 (2026-09-26): the mounting face offered is where the figure meets its plane -- a conveyor's legs, not its bed. */
|
|
98
|
+
test('장착면은 도형이 평면에 닿는 부품으로 권한다 — 다리 위 바닥이 아니라 다리', async () => {
|
|
99
|
+
const fm = await import('@hatiolab/figure-model')
|
|
100
|
+
let asset = fm.addV3Box(fm.createV3Asset('Conveyor'), { id: 'bed', size: { x: 3000, y: 700, z: 800 }, position: { x: 0, y: 1050, z: 0 } })
|
|
101
|
+
asset = fm.addV3Box(asset, { id: 'leg', size: { x: 60, y: 700, z: 60 }, position: { x: -1400, y: 350, z: -300 } })
|
|
102
|
+
asset = fm.applyV3Authoring(asset, { kind: 'attach', part: 'leg', face: 'top', to: { part: 'bed', face: 'bottom' }, align: { x: 'min', z: 'min' } })
|
|
103
|
+
const facts = v3FigureFacts(fm.serializeV3Asset(asset))
|
|
104
|
+
assert.deepEqual(facts.suggested, { part: 'leg', face: 'bottom' })
|
|
105
|
+
assert.equal(facts.parts[0], 'bed', '목록의 첫 부품은 바닥이다')
|
|
106
|
+
const panel = readFileSync(fileURLToPath(new URL('../client/modeller/figure-v3-properties.ts', import.meta.url)), 'utf8')
|
|
107
|
+
assert.ok(panel.includes('[this.pickedPart, mounting?.part, facts.suggested?.part, facts.parts[0]]'), '고른 것 · 정한 것 다음이 권하는 것')
|
|
108
|
+
})
|
package/test/v3-recipes.test.ts
CHANGED
|
@@ -53,7 +53,12 @@ test('작동 방식은 레시피에서 셈한다 — 미닫이 문: 두 문짝
|
|
|
53
53
|
{ key: 'figure.text.recipe-controls', words: { count: 1 } },
|
|
54
54
|
{ key: 'figure.text.recipe-shown-by-control', words: { parts: 'lamp' } }
|
|
55
55
|
])
|
|
56
|
-
|
|
56
|
+
/* The rollers spin on one control, every copy (ADR-0093, 2026-09-26). */
|
|
57
|
+
assert.deepEqual(recipeWorkings(JSON.parse(template('roller-row'))), [
|
|
58
|
+
{ key: 'figure.text.recipe-turns', words: { part: 'roller' } },
|
|
59
|
+
{ key: 'figure.text.recipe-controls', words: { count: 1 } },
|
|
60
|
+
{ key: 'figure.text.recipe-row', words: { part: 'roller' } }
|
|
61
|
+
])
|
|
57
62
|
})
|
|
58
63
|
|
|
59
64
|
test('모델러 — 부품 칸의 단추가 카탈로그를 열고, 넣으면 한 단계로 들어가고 창이 닫힌다; 목록은 페이지가 서버에서 한 번 읽는다', () => {
|
|
@@ -75,3 +80,10 @@ test('넣기는 카탈로그에서 온다 — 편집기는 카탈로그 요소
|
|
|
75
80
|
const catalog = editor.slice(editor.indexOf('<figure-v3-recipe-catalog'), editor.indexOf('</figure-v3-recipe-catalog>'))
|
|
76
81
|
assert.ok(catalog.includes('@insert-recipe=${(e: CustomEvent) => this.insertRecipe(e.detail.recipe, e.detail.values)}'))
|
|
77
82
|
})
|
|
83
|
+
|
|
84
|
+
test('검색은 제목이 찾는 말인 것부터 — 「다리」에 다리 넷이 사다리보다 먼저 (walk 1, 2026-09-26)', () => {
|
|
85
|
+
const t = (name: string, title: string, description = '') => ({ name, category: 'support', title, description, tags: [] as string[] })
|
|
86
|
+
const list = [t('ladder', '사다리', '벽에 세우는 사다리'), t('platform', '발판', '다리 위 발판'), t('legs-four', '다리 넷', '상판 아래 다리')]
|
|
87
|
+
assert.deepEqual(recipesMatching(list, '', '다리').map(x => x.name), ['legs-four', 'ladder', 'platform'], '제목이 그 말로 시작 → 제목에 든 것 → 설명에만 있는 것')
|
|
88
|
+
assert.deepEqual(recipesMatching(list, '', '').map(x => x.name), ['ladder', 'platform', 'legs-four'], '찾는 말이 없으면 원래 순서')
|
|
89
|
+
})
|
package/translations/en.json
CHANGED
|
@@ -359,7 +359,7 @@
|
|
|
359
359
|
"figure.text.floor-explained": "overlaps the coordinate ground, so it is usually off",
|
|
360
360
|
"figure.text.follow-camera-explained": "turn off to fix the shadow direction",
|
|
361
361
|
"figure.text.grade-decided-by": "what decided the grade",
|
|
362
|
-
"figure.text.grade-explained": "the grade is the headroom against placing {target} of this figure on one screen. the item closest to its limit decides it.",
|
|
362
|
+
"figure.text.grade-explained": "the performance cost grade is the headroom against placing {target} of this figure on one screen. the item closest to its limit decides it. it does not judge what the figure does or how good it is.",
|
|
363
363
|
"figure.text.grade-scale-lead": "grade scale (limit used)",
|
|
364
364
|
"figure.text.grade-target": "target: {target} on one screen — up to {groups} groups · {triangles} triangles each",
|
|
365
365
|
"figure.text.parts-budget": "{triangles} triangles · {groups} groups · {instances} on one screen",
|
|
@@ -646,5 +646,8 @@
|
|
|
646
646
|
"figure.button.remove-and-refit-room": "delete {part} and fit the space to the parts left",
|
|
647
647
|
"figure.text.preview-clamped": "{axis} {asked} → {used}",
|
|
648
648
|
"figure.label.placement-wall": "wall (wall)",
|
|
649
|
-
"figure.text.placement-plane-refused": "{parts} cannot go on a {placement} by the face fastened to the mounting plane. detach it or fasten it by its {faces} first."
|
|
649
|
+
"figure.text.placement-plane-refused": "{parts} cannot go on a {placement} by the face fastened to the mounting plane. detach it or fasten it by its {faces} first.",
|
|
650
|
+
"figure.label.face-centre": "centre",
|
|
651
|
+
"figure.label.budget-moving": "parts recomputed every frame",
|
|
652
|
+
"figure.label.performance-cost": "performance cost"
|
|
650
653
|
}
|
package/translations/ja.json
CHANGED
|
@@ -646,5 +646,8 @@
|
|
|
646
646
|
"figure.button.remove-and-refit-room": "「{part}」を削除し、空間を残りの部品に合わせる",
|
|
647
647
|
"figure.text.preview-clamped": "{axis} {asked} → {used}",
|
|
648
648
|
"figure.label.placement-wall": "壁基準 (wall)",
|
|
649
|
-
"figure.text.placement-plane-refused": "{parts} は取付平面に付いている面では {placement} に付けられません。外すか {faces} 面で付け直してから変更してください。"
|
|
649
|
+
"figure.text.placement-plane-refused": "{parts} は取付平面に付いている面では {placement} に付けられません。外すか {faces} 面で付け直してから変更してください。",
|
|
650
|
+
"figure.label.face-centre": "中心",
|
|
651
|
+
"figure.label.budget-moving": "毎フレーム再計算する部品",
|
|
652
|
+
"figure.label.performance-cost": "性能コスト"
|
|
650
653
|
}
|
package/translations/ko.json
CHANGED
|
@@ -359,7 +359,7 @@
|
|
|
359
359
|
"figure.text.floor-explained": "좌표 바탕과 겹쳐 보통 끕니다",
|
|
360
360
|
"figure.text.follow-camera-explained": "끄면 그림자 방향이 고정됩니다",
|
|
361
361
|
"figure.text.grade-decided-by": "등급 결정에 영향을 준 항목",
|
|
362
|
-
"figure.text.grade-explained": "등급은 한 화면에 이 도형 {target} 대를 놓는 목표에 견준 여유입니다. 한도에 가장 가까운 항목 하나가 정합니다.",
|
|
362
|
+
"figure.text.grade-explained": "성능 비용 등급은 한 화면에 이 도형 {target} 대를 놓는 목표에 견준 여유입니다. 한도에 가장 가까운 항목 하나가 정합니다. 도형의 기능이나 품질에 대한 평가가 아닙니다.",
|
|
363
363
|
"figure.text.grade-scale-lead": "등급 눈금 (한도 사용률)",
|
|
364
364
|
"figure.text.grade-target": "한 화면 {target} 대 목표 — 한 대에 묶음 {groups} · 삼각형 {triangles} 까지",
|
|
365
365
|
"figure.text.parts-budget": "삼각형 {triangles} · 묶음 {groups} · 한 화면 {instances} 대",
|
|
@@ -646,5 +646,8 @@
|
|
|
646
646
|
"figure.button.remove-and-refit-room": "'{part}' 지우고 공간을 남은 부품에 맞추기",
|
|
647
647
|
"figure.text.preview-clamped": "{axis} {asked} → {used}",
|
|
648
648
|
"figure.label.placement-wall": "벽 기반 (wall)",
|
|
649
|
-
"figure.text.placement-plane-refused": "{parts} 가 장착 평면에 붙은 면으로는 {placement} 에 붙일 수 없습니다. 떼거나 {faces} 면으로 다시 붙인 뒤 바꾸세요."
|
|
649
|
+
"figure.text.placement-plane-refused": "{parts} 가 장착 평면에 붙은 면으로는 {placement} 에 붙일 수 없습니다. 떼거나 {faces} 면으로 다시 붙인 뒤 바꾸세요.",
|
|
650
|
+
"figure.label.face-centre": "가운데",
|
|
651
|
+
"figure.label.budget-moving": "매 프레임 다시 계산하는 부품",
|
|
652
|
+
"figure.label.performance-cost": "성능 비용"
|
|
650
653
|
}
|
package/translations/ms.json
CHANGED
|
@@ -646,5 +646,8 @@
|
|
|
646
646
|
"figure.button.remove-and-refit-room": "padam '{part}' dan muatkan ruang pada bahagian yang tinggal",
|
|
647
647
|
"figure.text.preview-clamped": "{axis} {asked} → {used}",
|
|
648
648
|
"figure.label.placement-wall": "dinding (wall)",
|
|
649
|
-
"figure.text.placement-plane-refused": "{parts} tidak boleh dipasang pada {placement} dengan muka yang dipasang pada satah pemasangan. tanggalkan atau pasang semula dengan muka {faces} dahulu."
|
|
649
|
+
"figure.text.placement-plane-refused": "{parts} tidak boleh dipasang pada {placement} dengan muka yang dipasang pada satah pemasangan. tanggalkan atau pasang semula dengan muka {faces} dahulu.",
|
|
650
|
+
"figure.label.face-centre": "tengah",
|
|
651
|
+
"figure.label.budget-moving": "bahagian dikira semula setiap bingkai",
|
|
652
|
+
"figure.label.performance-cost": "kos prestasi"
|
|
650
653
|
}
|
package/translations/zh.json
CHANGED
|
@@ -646,5 +646,8 @@
|
|
|
646
646
|
"figure.button.remove-and-refit-room": "删除 '{part}' 并使空间适配剩余部件",
|
|
647
647
|
"figure.text.preview-clamped": "{axis} {asked} → {used}",
|
|
648
648
|
"figure.label.placement-wall": "墙面基准 (wall)",
|
|
649
|
-
"figure.text.placement-plane-refused": "{parts} 贴在安装平面上的面不能用于 {placement}。请先拆下或改用 {faces} 面贴合后再更改。"
|
|
649
|
+
"figure.text.placement-plane-refused": "{parts} 贴在安装平面上的面不能用于 {placement}。请先拆下或改用 {faces} 面贴合后再更改。",
|
|
650
|
+
"figure.label.face-centre": "中心",
|
|
651
|
+
"figure.label.budget-moving": "每帧重新计算的部件",
|
|
652
|
+
"figure.label.performance-cost": "性能成本"
|
|
650
653
|
}
|