@things-factory/figure-ui 10.1.151 → 10.1.153
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/ai-proposal.ts +14 -13
- package/client/modeller/command-preview-controller.ts +210 -0
- package/client/modeller/editor-styles.ts +307 -0
- package/client/modeller/editor.ts +65 -634
- package/client/modeller/fasten-geometry.ts +32 -0
- package/client/modeller/figure-first-save.ts +9 -1
- package/client/modeller/figure-links.ts +32 -14
- package/client/modeller/mesh-import.ts +0 -10
- package/client/modeller/modeller-keys.ts +60 -0
- package/client/modeller/pane-fold.ts +61 -0
- package/client/modeller/panel-memory.ts +53 -0
- package/client/modeller/refusal-teller.ts +93 -0
- package/client/modeller/refusals.ts +32 -2
- package/dist-client/modeller/ai-proposal.d.ts +6 -5
- package/dist-client/modeller/ai-proposal.js +9 -9
- package/dist-client/modeller/ai-proposal.js.map +1 -1
- package/dist-client/modeller/clips.d.ts +1 -1
- package/dist-client/modeller/command-preview-controller.d.ts +75 -0
- package/dist-client/modeller/command-preview-controller.js +178 -0
- package/dist-client/modeller/command-preview-controller.js.map +1 -0
- package/dist-client/modeller/editor-styles.d.ts +2 -0
- package/dist-client/modeller/editor-styles.js +307 -0
- package/dist-client/modeller/editor-styles.js.map +1 -0
- package/dist-client/modeller/editor.d.ts +5 -56
- package/dist-client/modeller/editor.js +76 -656
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/fasten-geometry.d.ts +13 -0
- package/dist-client/modeller/fasten-geometry.js +29 -0
- package/dist-client/modeller/fasten-geometry.js.map +1 -1
- package/dist-client/modeller/figure-first-save.d.ts +1 -0
- package/dist-client/modeller/figure-first-save.js +8 -1
- package/dist-client/modeller/figure-first-save.js.map +1 -1
- package/dist-client/modeller/figure-links.d.ts +9 -2
- package/dist-client/modeller/figure-links.js +35 -14
- package/dist-client/modeller/figure-links.js.map +1 -1
- package/dist-client/modeller/mesh-import.d.ts +0 -2
- package/dist-client/modeller/mesh-import.js +0 -11
- package/dist-client/modeller/mesh-import.js.map +1 -1
- package/dist-client/modeller/modeller-keys.d.ts +22 -0
- package/dist-client/modeller/modeller-keys.js +43 -0
- package/dist-client/modeller/modeller-keys.js.map +1 -0
- package/dist-client/modeller/pane-fold.d.ts +15 -0
- package/dist-client/modeller/pane-fold.js +60 -0
- package/dist-client/modeller/pane-fold.js.map +1 -0
- package/dist-client/modeller/panel-memory.d.ts +15 -0
- package/dist-client/modeller/panel-memory.js +54 -0
- package/dist-client/modeller/panel-memory.js.map +1 -0
- package/dist-client/modeller/product-session.d.ts +4 -28
- package/dist-client/modeller/refusal-teller.d.ts +36 -0
- package/dist-client/modeller/refusal-teller.js +81 -0
- package/dist-client/modeller/refusal-teller.js.map +1 -0
- package/dist-client/modeller/refusals.d.ts +12 -1
- package/dist-client/modeller/refusals.js +26 -1
- package/dist-client/modeller/refusals.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/test/browser/build.mjs +7 -2
- package/test/browser/editing.check.mjs +14 -0
- package/test/browser/fix-alignment.check.mjs +112 -0
- package/test/browser/links.check.mjs +69 -13
- package/test/browser/mesh-select.check.mjs +91 -0
- package/test/browser/modeller-page.js +36 -0
- package/test/browser/modeller-revision.check.mjs +18 -6
- package/test/browser/modeller.check.mjs +25 -0
- package/test/browser/part-ids.check.mjs +24 -6
- package/test/browser/parts-page.js +9 -1
- package/test/browser/parts.check.mjs +61 -2
- package/test/browser/places-mesh.check.mjs +25 -9
- package/test/browser/product-page.check.mjs +42 -2
- package/test/browser/row-preview.check.mjs +81 -0
- package/test/browser/screen.check.mjs +35 -1
- package/test/clip-ai-proposal.test.ts +4 -1
- package/test/clip-consent.test.ts +9 -2
- package/test/fields.test.ts +5 -9
- package/test/fix-alignment.test.ts +32 -0
- package/test/layout.test.ts +3 -1
- package/test/mesh-import.test.ts +1 -8
- package/test/parts-wiring.test.ts +1 -5
- package/test/refusals.test.ts +46 -10
- package/test/theme.test.ts +5 -15
- package/translations/en.json +8 -0
- package/translations/es.json +8 -0
- package/translations/ja.json +8 -0
- package/translations/ko.json +8 -0
- package/translations/ms.json +8 -0
- package/translations/zh.json +8 -0
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.153",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"@things-factory:registry": "https://registry.npmjs.org"
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"scripts": {
|
|
19
19
|
"build": "npm run build:server && npm run build:client",
|
|
20
20
|
"test": "FIGURE_FREEZE_COMPILED=1 node --import ./test/resolve-ts-siblings.mjs --test test/*.test.ts",
|
|
21
|
-
"test:browser": "node --test test/browser/build.check.mjs test/browser/modeller.check.mjs test/browser/modeller-revision.check.mjs test/browser/editing.check.mjs test/browser/links.check.mjs test/browser/section.check.mjs test/browser/screen.check.mjs test/browser/product-page.check.mjs test/browser/parts.check.mjs test/browser/places-mesh.check.mjs test/browser/part-ids.check.mjs",
|
|
21
|
+
"test:browser": "node --test test/browser/build.check.mjs test/browser/modeller.check.mjs test/browser/modeller-revision.check.mjs test/browser/editing.check.mjs test/browser/links.check.mjs test/browser/section.check.mjs test/browser/screen.check.mjs test/browser/product-page.check.mjs test/browser/parts.check.mjs test/browser/places-mesh.check.mjs test/browser/part-ids.check.mjs test/browser/fix-alignment.check.mjs test/browser/mesh-select.check.mjs test/browser/row-preview.check.mjs",
|
|
22
22
|
"copy:files": "copyfiles -e \"./client/**/*.{ts,js,json}\" -u 1 \"./client/**/*\" dist-client",
|
|
23
23
|
"build:client": "npm run copy:files && npx tsc --p ./client/tsconfig.json",
|
|
24
24
|
"build:server": "npx tsc --p ./server/tsconfig.json",
|
|
@@ -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.6.
|
|
30
|
+
"@hatiolab/figure-model": "^0.6.3",
|
|
31
31
|
"@hatiolab/things-scene": "^10.1.83",
|
|
32
32
|
"@material/web": "^2.0.0",
|
|
33
33
|
"@operato/data-grist": "^10.0.0",
|
|
@@ -42,8 +42,8 @@
|
|
|
42
42
|
"@operato/styles": "^10.0.0",
|
|
43
43
|
"@operato/utils": "^10.0.0",
|
|
44
44
|
"@things-factory/ai-assistant": "^10.1.150",
|
|
45
|
-
"@things-factory/figure-service": "^10.1.
|
|
45
|
+
"@things-factory/figure-service": "^10.1.153",
|
|
46
46
|
"three": "^0.185.1"
|
|
47
47
|
},
|
|
48
|
-
"gitHead": "
|
|
48
|
+
"gitHead": "54b3e985a55d5d0b01622d87f385f07518fb5d16"
|
|
49
49
|
}
|
package/test/browser/build.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* (its browser bundle, `things-scene.mjs`).
|
|
8
8
|
*/
|
|
9
9
|
import { execFileSync } from 'node:child_process'
|
|
10
|
-
import { mkdtempSync, rmSync, existsSync } from 'node:fs'
|
|
10
|
+
import { mkdtempSync, realpathSync, rmSync, existsSync } from 'node:fs'
|
|
11
11
|
import { tmpdir } from 'node:os'
|
|
12
12
|
import { dirname, join, resolve } from 'node:path'
|
|
13
13
|
import { fileURLToPath } from 'node:url'
|
|
@@ -41,7 +41,12 @@ for (const signal of ['SIGINT', 'SIGTERM'])
|
|
|
41
41
|
* assistant dock needs the whole app's tool bar), a stand-in that records what the product asked of it.
|
|
42
42
|
*/
|
|
43
43
|
export async function buildPage(page = 'modeller-page.js', { standIns = {} } = {}) {
|
|
44
|
-
|
|
44
|
+
/*
|
|
45
|
+
The real path: on macOS the temp dir is a link (/var -> /private/var). A module named both through
|
|
46
|
+
`figure-ui-client/...` and by a relative import was bundled twice, once under each path, and a page that imports one
|
|
47
|
+
both ways (product-page.js: the modeller page and the history panel) defined its element twice and never came up.
|
|
48
|
+
*/
|
|
49
|
+
const out = mkdtempSync(join(realpathSync(tmpdir()), 'figure-ui-browser-' + page.replace(/\W+/g, '-') + '-'))
|
|
45
50
|
made.push(out)
|
|
46
51
|
const emitted = join(out, 'client')
|
|
47
52
|
|
|
@@ -218,6 +218,20 @@ describe('a part a joint places is not moved by the bar, nor put on the plane',
|
|
|
218
218
|
})
|
|
219
219
|
})
|
|
220
220
|
|
|
221
|
+
describe('a part a joint places, dragged with the move handle all the same (the six-axis arm, a saved asset)', () => {
|
|
222
|
+
before(() => mount('six-axis'))
|
|
223
|
+
|
|
224
|
+
test('nothing written, and the refusal says the joint places it', async () => {
|
|
225
|
+
await pick('upper-arm')
|
|
226
|
+
const start = await revision()
|
|
227
|
+
await dragHandle('upper-arm', 'translate', 'X', 80)
|
|
228
|
+
await until(async () => (await state()).error !== '', 'the refusal said')
|
|
229
|
+
assert.equal(await revision(), start, 'nothing written')
|
|
230
|
+
/* Said from the kernel's details (ADR-0127): the page loads no translations, so the words are the key. */
|
|
231
|
+
assert.equal((await state()).error, 'figure.text.gizmo-joint-places')
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
|
|
221
235
|
describe('put on the plane by the figure\'s placement: once, not fastened (designer 2026-09-27)', () => {
|
|
222
236
|
test('a box 500 mm above the floor is put on it by its bottom, in one step', async () => {
|
|
223
237
|
await mount('box', 'raised')
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 정렬·간격 고치기, in a real browser: a part fastened past the edge of the face it stands on is moved inside by the
|
|
3
|
+
* button, as one step; when nothing of the part itself is out, the button says why instead of doing nothing (on :3300,
|
|
4
|
+
* 2026-10-11, it only scrolled to the drawing, which was already in view).
|
|
5
|
+
*
|
|
6
|
+
* Run: node --test test/browser/fix-alignment.check.mjs
|
|
7
|
+
*/
|
|
8
|
+
import { test, describe, before, after } from 'node:test'
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
|
|
11
|
+
import { addFigureBox, applyFigureAuthoring, createFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
|
|
12
|
+
|
|
13
|
+
import { buildPage } from './build.mjs'
|
|
14
|
+
import { openPage } from './serve.mjs'
|
|
15
|
+
|
|
16
|
+
const sleep = ms => new Promise(done => setTimeout(done, ms))
|
|
17
|
+
|
|
18
|
+
let opened
|
|
19
|
+
before(async () => {
|
|
20
|
+
opened = await openPage(await buildPage('places-mesh-page.js'))
|
|
21
|
+
})
|
|
22
|
+
after(async () => {
|
|
23
|
+
await opened?.close()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const page = () => opened.page
|
|
27
|
+
const read = (fn, ...args) => page().evaluate(fn, ...args)
|
|
28
|
+
const state = () => read(() => probe.state())
|
|
29
|
+
|
|
30
|
+
async function until(fn, args, what, ms = 8000) {
|
|
31
|
+
const deadline = Date.now() + ms
|
|
32
|
+
for (;;) {
|
|
33
|
+
if (await read(fn, ...args)) return
|
|
34
|
+
if (Date.now() > deadline) assert.fail(`waited for ${what}`)
|
|
35
|
+
await sleep(50)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/* The editor and the part's panels have drawn what they were last told: a read never meets the draw before. */
|
|
39
|
+
const settled = () =>
|
|
40
|
+
read(async () => {
|
|
41
|
+
const deep = (s, root = document) => [...root.querySelectorAll(s)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(s, e.shadowRoot)))
|
|
42
|
+
const drawing = () => [document.querySelector('figure-editor'), ...deep('figure-links')].filter(Boolean)
|
|
43
|
+
for (let round = 0; round < 20; round++) {
|
|
44
|
+
await Promise.all(drawing().map(e => e.updateComplete))
|
|
45
|
+
if (!drawing().some(e => e.isUpdatePending)) return
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
/** Pressed once it is on the page and the panels have drawn -- waited for, not looked up once. */
|
|
49
|
+
async function click(selector, options = {}, what = selector) {
|
|
50
|
+
await settled()
|
|
51
|
+
await until((s, o) => !!findDeep(s, o), [selector, options], `${what} on the page`)
|
|
52
|
+
const element = await page().evaluateHandle((s, o) => findDeep(s, o), selector, options)
|
|
53
|
+
await element.evaluate(e => e.scrollIntoView({ block: 'center' }))
|
|
54
|
+
await element.click()
|
|
55
|
+
await settled()
|
|
56
|
+
}
|
|
57
|
+
async function pick(part) {
|
|
58
|
+
const at = await read(id => probe.row(id), part)
|
|
59
|
+
assert.ok(at, `the row of ${part}`)
|
|
60
|
+
await page().mouse.click(at.x, at.y)
|
|
61
|
+
await until(id => probe.state().selected === id, [part], `${part} selected`)
|
|
62
|
+
}
|
|
63
|
+
const jointOf = part => read(p => probe.source().joints.find(j => j.body1.part === p), part)
|
|
64
|
+
const words = () => read(() => [...(findDeep('div[overhang]')?.querySelectorAll('p') ?? [])].map(p => p.textContent.trim()))
|
|
65
|
+
|
|
66
|
+
/* A 400 × 1000 deck; a 100 mm block on it, fastened 1000 mm off its centre along X -- past its low edge. */
|
|
67
|
+
function figure(withChild = false) {
|
|
68
|
+
let a = addFigureBox(createFigureAsset('Deck'), { id: 'deck', size: { x: 400, y: 50, z: 1000 }, position: { x: 0, y: 25, z: 0 } })
|
|
69
|
+
a = applyFigureAuthoring(a, { kind: 'attach', part: 'deck', face: 'bottom', to: { plane: 'mounting-plane' } })
|
|
70
|
+
a = addFigureBox(a, { id: 'block', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 100, z: 0 } })
|
|
71
|
+
a = applyFigureAuthoring(a, { kind: 'attach', part: 'block', face: 'bottom', to: { part: 'deck', face: 'top' }, ...(withChild ? {} : { align: { x: { mm: -1000 } } }) })
|
|
72
|
+
if (!withChild) return a
|
|
73
|
+
/* The block inside; a cap on it, past the block's edge. */
|
|
74
|
+
a = addFigureBox(a, { id: 'cap', size: { x: 40, y: 40, z: 40 }, position: { x: 0, y: 170, z: 0 } })
|
|
75
|
+
return applyFigureAuthoring(a, { kind: 'attach', part: 'cap', face: 'bottom', to: { part: 'block', face: 'top' }, align: { x: { mm: 300 } } })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('정렬·간격 고치기 moves a part inside the face it is fastened on', () => {
|
|
79
|
+
before(async () => {
|
|
80
|
+
await read(text => harness.mount('given', text), serializeFigureAsset(figure()))
|
|
81
|
+
await until(() => look.drawn().length > 0, [], 'the board is drawn')
|
|
82
|
+
await pick('block')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('out past the low edge: said, then moved to that edge as one step, and no longer said', async () => {
|
|
86
|
+
await until(() => !!findDeep('button[fix-alignment]'), [], 'the button')
|
|
87
|
+
assert.ok((await words()).length > 0, 'the overhang is said')
|
|
88
|
+
const start = await state()
|
|
89
|
+
await click('button[fix-alignment]')
|
|
90
|
+
await until(r => probe.state().revision === r + 1, [start.revision], 'one step')
|
|
91
|
+
assert.deepEqual((await jointOf('block')).origin.align?.x, 'min')
|
|
92
|
+
await until(() => !findDeep('div[overhang]'), [], 'nothing said of an overhang')
|
|
93
|
+
assert.equal((await state()).error, '')
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('when nothing of the part is out, the button says why', () => {
|
|
98
|
+
before(async () => {
|
|
99
|
+
await read(text => harness.mount('given', text), serializeFigureAsset(figure(true)))
|
|
100
|
+
await until(() => look.drawn().length > 0, [], 'the board is drawn')
|
|
101
|
+
await pick('block')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('what is out is the cap fastened on it: said under the button, nothing written', async () => {
|
|
105
|
+
await until(() => !!findDeep('button[fix-alignment]'), [], 'the button')
|
|
106
|
+
const start = await state()
|
|
107
|
+
await click('button[fix-alignment]')
|
|
108
|
+
await until(() => !!findDeep('p[fix-note]'), [], 'the note')
|
|
109
|
+
assert.match(await read(() => findDeep('p[fix-note]').textContent.trim()), /^figure\.text\.fix-alignment-inside/)
|
|
110
|
+
assert.equal((await state()).revision, start.revision)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -101,6 +101,28 @@ const handle = (selector, test) =>
|
|
|
101
101
|
selector,
|
|
102
102
|
test
|
|
103
103
|
)
|
|
104
|
+
/**
|
|
105
|
+
* Pressed as the panels stand once drawn: no fastening panel with an update still to draw, then the element itself, not a
|
|
106
|
+
* point read before. A point read too early is pressed after the panel has moved under it (2026-10-11: the door leaf's
|
|
107
|
+
* "fasten" read, then its scroll put back a frame later, and the click fell on another button).
|
|
108
|
+
*/
|
|
109
|
+
async function press(selector, text, what = text ?? selector) {
|
|
110
|
+
await read(async () => {
|
|
111
|
+
const deep = (s, root = document) => [...root.querySelectorAll(s)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(s, e.shadowRoot)))
|
|
112
|
+
for (let round = 0; round < 20 && deep('figure-links').some(e => e.isUpdatePending); round++) await Promise.all(deep('figure-links').map(e => e.updateComplete))
|
|
113
|
+
})
|
|
114
|
+
const element = await page().evaluateHandle(
|
|
115
|
+
(selector, text) => {
|
|
116
|
+
const deep = (s, root = document) => [...root.querySelectorAll(s)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(s, e.shadowRoot)))
|
|
117
|
+
return deep(selector).find(e => text === undefined || e.textContent.replace(/\s+/g, ' ').trim().includes(text)) ?? null
|
|
118
|
+
},
|
|
119
|
+
selector,
|
|
120
|
+
text
|
|
121
|
+
)
|
|
122
|
+
assert.ok(await element.evaluate(e => !!e), `${what} is on the page`)
|
|
123
|
+
await element.click()
|
|
124
|
+
await settled()
|
|
125
|
+
}
|
|
104
126
|
/** Chooses an option of the select labelled `label`, as a person does: the select changes, once. */
|
|
105
127
|
async function choose(label, value, selector = 'select') {
|
|
106
128
|
const select = await handle(selector, label)
|
|
@@ -113,7 +135,24 @@ async function choose(label, value, selector = 'select') {
|
|
|
113
135
|
async function type(field, text) {
|
|
114
136
|
assert.ok(await field.evaluate(i => !!i), 'the field is on the page')
|
|
115
137
|
await field.evaluate(i => i.scrollIntoView({ block: 'center' }))
|
|
138
|
+
/*
|
|
139
|
+
Pressed where it stands once the panel has stopped moving: what a fold opening or a preview's words bring is drawn
|
|
140
|
+
after the click that brought it, and a press aimed before that lands on whatever moved under it.
|
|
141
|
+
*/
|
|
142
|
+
const where = () => field.evaluate(i => JSON.stringify(i.getBoundingClientRect()))
|
|
143
|
+
for (let last = await where(), i = 0; i < 40; i++) {
|
|
144
|
+
await sleep(100)
|
|
145
|
+
const now = await where()
|
|
146
|
+
if (now === last) break
|
|
147
|
+
last = now
|
|
148
|
+
}
|
|
116
149
|
await field.click({ clickCount: 3 })
|
|
150
|
+
const focused = await field.evaluate(i => {
|
|
151
|
+
let at = document.activeElement
|
|
152
|
+
while (at?.shadowRoot?.activeElement) at = at.shadowRoot.activeElement
|
|
153
|
+
return at === i
|
|
154
|
+
})
|
|
155
|
+
assert.ok(focused, 'the press lands in the field')
|
|
117
156
|
await page().keyboard.type(text)
|
|
118
157
|
await page().keyboard.press('Tab')
|
|
119
158
|
await settled()
|
|
@@ -305,7 +344,7 @@ describe('a new fastening for a door leaf: seen before it is done', () => {
|
|
|
305
344
|
|
|
306
345
|
test('started, it asks what to fasten to, with nothing chosen', async () => {
|
|
307
346
|
await pick('leaf')
|
|
308
|
-
await
|
|
347
|
+
await press('button[act]', 'figure.button.fasten-start')
|
|
309
348
|
/* The form is the panel's, drawn after the editor's update. */
|
|
310
349
|
const target = await until(() => look.select('figure.label.target-part'), [], 'the fastening form')
|
|
311
350
|
assert.deepEqual([target.value, target.options[0].text], ['', 'figure.label.target-part-placeholder'])
|
|
@@ -351,7 +390,7 @@ describe('a new fastening for a door leaf: seen before it is done', () => {
|
|
|
351
390
|
|
|
352
391
|
test('"fasten it first" from the motion tab opens this part\'s fastening, as it is now, in view, on the properties tab', async () => {
|
|
353
392
|
await pick('leaf')
|
|
354
|
-
await
|
|
393
|
+
await press('button[act]', 'figure.button.fasten-start')
|
|
355
394
|
await choose('figure.label.target-part', 'frame')
|
|
356
395
|
/* The panel scrolled back to its head, the fastening below the fold. */
|
|
357
396
|
const panel = await at('section[fastening]')
|
|
@@ -369,8 +408,14 @@ describe('a new fastening for a door leaf: seen before it is done', () => {
|
|
|
369
408
|
test('the frame\'s front chosen in the drawing, fastened: the leaf keeps its place across that face', async () => {
|
|
370
409
|
const was = (await read(() => probe.body('leaf'))).t.map(Math.round)
|
|
371
410
|
await choose('figure.label.target-part', 'frame')
|
|
372
|
-
/*
|
|
373
|
-
|
|
411
|
+
/*
|
|
412
|
+
The frame's faces are offered once the drawing has the frame: a press before that is not a choice. And the press is
|
|
413
|
+
aimed once the form has drawn what the choice brings -- the leaf where it would go, and the words above the drawing
|
|
414
|
+
that move it down -- or it lands where the face was.
|
|
415
|
+
*/
|
|
416
|
+
await until(() => look.face('front')?.pickable && look.drawn().includes('__preview:leaf'), [], 'the front face offered, the leaf drawn where it would go')
|
|
417
|
+
await settled()
|
|
418
|
+
await sleep(200)
|
|
374
419
|
await click(await at('path[face="front"]'), 'the front face in the drawing')
|
|
375
420
|
await until(() => look.face('front').chosen, [], 'the front face drawn as the one chosen')
|
|
376
421
|
const start = await state()
|
|
@@ -383,6 +428,14 @@ describe('a new fastening for a door leaf: seen before it is done', () => {
|
|
|
383
428
|
})
|
|
384
429
|
})
|
|
385
430
|
|
|
431
|
+
/*
|
|
432
|
+
* 정렬·간격 고치기 pressed as the panel stands once it has drawn: a point read before it has, then clicked, can land where
|
|
433
|
+
* the button was, and nothing happens.
|
|
434
|
+
*/
|
|
435
|
+
async function pressFixAlignment() {
|
|
436
|
+
await press('div[overhang] button[fix-alignment]', undefined, 'fix alignment')
|
|
437
|
+
}
|
|
438
|
+
|
|
386
439
|
describe('past the edge of the face a part is fastened to', () => {
|
|
387
440
|
before(async () => {
|
|
388
441
|
let a = fasten(box(createFigureAsset('Overhangs'), 'frame', [1000, 100, 400], [0, 50, 0]), 'frame', 'plane')
|
|
@@ -395,11 +448,13 @@ describe('past the edge of the face a part is fastened to', () => {
|
|
|
395
448
|
})
|
|
396
449
|
const overhang = () => read(() => look.all('div[overhang] p').map(p => p.text))
|
|
397
450
|
|
|
398
|
-
test('past it: said, and "fix alignment"
|
|
451
|
+
test('past it: said, and "fix alignment" moves it inside the face as one step', async () => {
|
|
399
452
|
await pick('out')
|
|
400
453
|
await until(() => look.all('div[overhang] p', 'figure.text.overhang-out').length === 1, [], 'said past the edge')
|
|
401
|
-
|
|
402
|
-
await
|
|
454
|
+
const start = await state()
|
|
455
|
+
await pressFixAlignment()
|
|
456
|
+
await until(r => probe.state().revision === r + 1, [start.revision], 'one step')
|
|
457
|
+
await until(() => look.all('div[overhang] p', 'figure.text.overhang-out').length === 0, [], 'no longer past the edge')
|
|
403
458
|
})
|
|
404
459
|
|
|
405
460
|
test('turned, it may be past it: said so', async () => {
|
|
@@ -407,9 +462,13 @@ describe('past the edge of the face a part is fastened to', () => {
|
|
|
407
462
|
await until(() => look.all('div[overhang] p', 'figure.text.overhang-possible').length === 1, [], 'said it may be')
|
|
408
463
|
})
|
|
409
464
|
|
|
410
|
-
test('on a round face it is not known: said so', async () => {
|
|
465
|
+
test('on a round face it is not known: said so, and "fix alignment" says why it cannot', async () => {
|
|
411
466
|
await pick('cap')
|
|
412
467
|
await until(() => look.all('div[overhang] p', 'figure.text.overhang-unknown').length === 1, [], 'said not known')
|
|
468
|
+
const start = await state()
|
|
469
|
+
await pressFixAlignment()
|
|
470
|
+
await until(() => look.all('p[fix-note]', 'figure.text.fix-alignment-unmeasured').length === 1, [], 'why it cannot')
|
|
471
|
+
assert.equal((await state()).revision, start.revision, 'nothing written')
|
|
413
472
|
})
|
|
414
473
|
|
|
415
474
|
test('wider than the face: said in the relation with which side, not as a warning', async () => {
|
|
@@ -460,11 +519,8 @@ describe('a bench: what each part is fastened to, what detaching would do, a row
|
|
|
460
519
|
await pick('lamp')
|
|
461
520
|
assert.deepEqual((await all('details[row-fold]')).map(d => d.open), [false], 'folded')
|
|
462
521
|
await read(() => look.hear('preview-command'))
|
|
463
|
-
/*
|
|
464
|
-
|
|
465
|
-
toggle then closes it again -- two clicks open it (seen 2026-10-11).
|
|
466
|
-
*/
|
|
467
|
-
for (let i = 0; i < 2 && !(await all('details[row-fold]'))[0].open; i++) await click(await at('details[row-fold] > summary'), 'the row')
|
|
522
|
+
/* One click opens it, and it stays open while the row is worked. */
|
|
523
|
+
await click(await at('details[row-fold] > summary'), 'the row')
|
|
468
524
|
assert.deepEqual((await all('details[row-fold]')).map(d => d.open), [true], 'open while it is worked')
|
|
469
525
|
await until(() => look.all('section[row] input[type=number]').length > 0, [], 'the pitch field in the open row')
|
|
470
526
|
await type(await handle('section[row] input[type=number]'), '250')
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A GLB imported on the modeller page as the shell mounts it (product-page.js), with the mouse as a person uses it: the
|
|
3
|
+
* palette's file chooser, the question drawn as the layout draws popups, a choice clicked in it. The part made is the
|
|
4
|
+
* one selected (on :3300, 2026-10-11, the part selected before the import stayed selected; the editor alone said
|
|
5
|
+
* otherwise, so this follows the page's own path).
|
|
6
|
+
*
|
|
7
|
+
* Run: node --test test/browser/mesh-select.check.mjs
|
|
8
|
+
*/
|
|
9
|
+
import { test, describe, before, after } from 'node:test'
|
|
10
|
+
import assert from 'node:assert/strict'
|
|
11
|
+
import { writeFileSync, mkdtempSync } from 'node:fs'
|
|
12
|
+
import { tmpdir } from 'node:os'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
|
|
15
|
+
import { addFigureBox, createFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
|
|
16
|
+
|
|
17
|
+
import { buildPage } from './build.mjs'
|
|
18
|
+
import { openPage } from './serve.mjs'
|
|
19
|
+
|
|
20
|
+
const STAND_INS = {
|
|
21
|
+
'@things-factory/ai-assistant/dist-client/index.js': 'assistant-stand-in.js',
|
|
22
|
+
'@things-factory/ai-assistant/dist-client/components/assistant-dock.js': 'assistant-stand-in.js'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let opened
|
|
26
|
+
before(async () => {
|
|
27
|
+
opened = await openPage(await buildPage('product-page.js', { standIns: STAND_INS }))
|
|
28
|
+
})
|
|
29
|
+
after(async () => {
|
|
30
|
+
await opened?.close()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const page = () => opened.page
|
|
34
|
+
const read = (fn, ...args) => page().evaluate(fn, ...args)
|
|
35
|
+
async function until(fn, label, ...args) {
|
|
36
|
+
try {
|
|
37
|
+
return await (await page().waitForFunction(fn, { timeout: 15000, polling: 50 }, ...args)).jsonValue()
|
|
38
|
+
} catch (error) {
|
|
39
|
+
throw Error(`waited in vain: ${label} (${error.message})`)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const editor = () => read(() => ({ selected: product.deep('figure-editor')[0]?.selected, revision: product.view()?.revision }))
|
|
43
|
+
/** The page point of the first element the selector finds, scrolled into view. */
|
|
44
|
+
const pointOf = selector =>
|
|
45
|
+
read(s => {
|
|
46
|
+
const e = product.deep(s)[0]
|
|
47
|
+
if (!e) return null
|
|
48
|
+
e.scrollIntoView({ block: 'center' })
|
|
49
|
+
const r = e.getBoundingClientRect()
|
|
50
|
+
return { x: r.left + r.width / 2, y: r.top + r.height / 2 }
|
|
51
|
+
}, selector)
|
|
52
|
+
async function clickAt(selector, label = selector) {
|
|
53
|
+
await until(s => !!product.deep(s)[0], label, selector)
|
|
54
|
+
const at = await pointOf(selector)
|
|
55
|
+
await page().mouse.click(at.x, at.y)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const HASH = 'c'.repeat(64)
|
|
59
|
+
const bounds = (x0, y0, z0, x1, y1, z1) => ({ min: { x: x0, y: y0, z: z0 }, max: { x: x1, y: y1, z: z1 } })
|
|
60
|
+
const node = (index, name, b, triangles) => ({ index, name, parent: index ? 0 : null, bounds: b, triangles, primitives: 1, position: { x: 0, y: 0, z: 0 }, rotation: { x: 0, y: 0, z: 0 } })
|
|
61
|
+
const girder = bounds(-1000, -150, -200, 1000, 150, 200)
|
|
62
|
+
const UPLOAD = { hash: HASH, summary: { bounds: girder, triangles: 24, primitives: 2, unit: 'm', nodes: [node(0, 'Girder', girder, 24), node(1, 'Hoist', bounds(-150, -200, -150, 150, 200, 150), 12)] } }
|
|
63
|
+
|
|
64
|
+
/* A wide slab filling the middle of the view, selected: what lies under the question when it is answered. */
|
|
65
|
+
const slab = () => serializeFigureAsset(addFigureBox(createFigureAsset('Slab'), { id: 'slab', size: { x: 4000, y: 100, z: 4000 }, position: { x: 0, y: 50, z: 0 } }))
|
|
66
|
+
|
|
67
|
+
describe('a GLB imported on the modeller page: the part made is the one selected', () => {
|
|
68
|
+
before(async () => {
|
|
69
|
+
await page().waitForFunction(() => !!window.product, { timeout: 30000 })
|
|
70
|
+
await read(s => product.seed({ id: 'f', type: 'slab', source: s }), slab())
|
|
71
|
+
await read(u => product.answer('uploadFigureMesh', () => u), UPLOAD)
|
|
72
|
+
await read(() => product.open('f'))
|
|
73
|
+
await until(() => { const v = product.view(); return v && v.context && !product.page().session.read().loading }, 'the figure open')
|
|
74
|
+
await clickAt('figure-parts >>> span[label][title="slab"]', 'the slab\'s row')
|
|
75
|
+
await until(() => product.deep('figure-editor')[0]?.selected === 'slab', 'the slab selected')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('the whole file chosen in the question: added as one step, and selected', async () => {
|
|
79
|
+
const file = join(mkdtempSync(join(tmpdir(), 'figure-glb-')), 'crane.glb')
|
|
80
|
+
writeFileSync(file, Buffer.from('glTF'))
|
|
81
|
+
const start = await editor()
|
|
82
|
+
const [chooser] = await Promise.all([page().waitForFileChooser({ timeout: 4000 }), clickAt('figure-parts >>> button[shape][title="figure.text.import-mesh-hint"]', 'the import button')])
|
|
83
|
+
await chooser.accept([file])
|
|
84
|
+
await until(() => !!product.deep('figure-mesh-choice')[0], 'the question')
|
|
85
|
+
await clickAt('figure-mesh-choice >>> li button', 'the whole file')
|
|
86
|
+
await until(r => product.view().revision === r + 1, 'one step', start.revision)
|
|
87
|
+
/* Read a second later too: anything that takes the selection back after the step would have done so by then. */
|
|
88
|
+
await new Promise(done => setTimeout(done, 1000))
|
|
89
|
+
assert.equal((await editor()).selected, 'crane', 'the part made is the one selected')
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -8,6 +8,30 @@ import { budgetOf, figureReportOf } from 'figure-ui-client/modeller/maturity.js'
|
|
|
8
8
|
import { createFigureProductSession } from 'figure-ui-client/modeller/product-session.js'
|
|
9
9
|
import { addFigureBox, addFigurePrimitive, applyFigureAuthoring, createFigureAsset, figureWork, robotArmFigure, serializeFigureAsset } from '@hatiolab/figure-model'
|
|
10
10
|
|
|
11
|
+
/*
|
|
12
|
+
The work the modeller puts off until the screen rests (a timer of 100 ms or more: 150 ms for a pick's previews, 200 ms
|
|
13
|
+
for the verdict) can be held while a check counts what an event and its render did, and let run after (holdRest,
|
|
14
|
+
releaseRest). The count then does not lean on time: on a busy machine the put-off work ran before the check read the
|
|
15
|
+
count, and a pick counted 3 compiles and 8 evaluations (modeller-revision, 2026-10-11, the full test:browser only).
|
|
16
|
+
Shorter timers run as ever, so work moved off the event into one is still counted as the event's.
|
|
17
|
+
*/
|
|
18
|
+
const REST_MS = 100
|
|
19
|
+
const setTimer = window.setTimeout.bind(window)
|
|
20
|
+
const clearTimer = window.clearTimeout.bind(window)
|
|
21
|
+
let holding = false
|
|
22
|
+
let held = []
|
|
23
|
+
let heldIds = 0
|
|
24
|
+
window.setTimeout = (callback, ms, ...args) => {
|
|
25
|
+
if (!holding || typeof callback !== 'function' || !(Number(ms) >= REST_MS)) return setTimer(callback, ms, ...args)
|
|
26
|
+
const id = -++heldIds
|
|
27
|
+
held.push({ id, callback, ms, args })
|
|
28
|
+
return id
|
|
29
|
+
}
|
|
30
|
+
window.clearTimeout = id => {
|
|
31
|
+
if (typeof id === 'number' && id < 0) held = held.filter(h => h.id !== id)
|
|
32
|
+
else clearTimer(id)
|
|
33
|
+
}
|
|
34
|
+
|
|
11
35
|
const PALETTE = {
|
|
12
36
|
'palette.dark': '#2b2b2b',
|
|
13
37
|
'palette.neutral': '#9a9a9a',
|
|
@@ -248,6 +272,18 @@ window.probe = {
|
|
|
248
272
|
work() {
|
|
249
273
|
return { ...figureWork }
|
|
250
274
|
},
|
|
275
|
+
/** From now the work put off until the screen rests waits (see REST_MS). */
|
|
276
|
+
holdRest() {
|
|
277
|
+
holding = true
|
|
278
|
+
},
|
|
279
|
+
/** The work held runs, each after its own delay from now; how many were held. */
|
|
280
|
+
releaseRest() {
|
|
281
|
+
holding = false
|
|
282
|
+
const due = held
|
|
283
|
+
held = []
|
|
284
|
+
for (const h of due) setTimer(h.callback, h.ms, ...h.args)
|
|
285
|
+
return due.length
|
|
286
|
+
},
|
|
251
287
|
/**
|
|
252
288
|
* The two middles -- the edit canvas and the preview -- marked, so a later look says whether each is the same element
|
|
253
289
|
* with the same scene, or was made again.
|
|
@@ -16,6 +16,21 @@ import { openPage } from './serve.mjs'
|
|
|
16
16
|
const sleep = ms => new Promise(done => setTimeout(done, ms))
|
|
17
17
|
/* The event handled and the render it asked for done -- not the work told to wait until the screen rests (150 ms). */
|
|
18
18
|
const drawn = () => page().evaluate(() => document.querySelector('figure-editor').updateComplete.then(() => probe.work()))
|
|
19
|
+
/*
|
|
20
|
+
What an event and its render cost, counted with the work put off until the screen rests held (probe.holdRest), so the
|
|
21
|
+
count does not lean on time; then that work is let run, and what it cost is the screen at rest's (2026-10-11: on a
|
|
22
|
+
busy machine it ran before the count was read, and a pick counted 3 compiles and 8 evaluations).
|
|
23
|
+
*/
|
|
24
|
+
async function counted(event) {
|
|
25
|
+
const before = await work()
|
|
26
|
+
await read(() => probe.holdRest())
|
|
27
|
+
try {
|
|
28
|
+
await event()
|
|
29
|
+
return { before, spent: since(before, await drawn()) }
|
|
30
|
+
} finally {
|
|
31
|
+
await read(() => probe.releaseRest())
|
|
32
|
+
}
|
|
33
|
+
}
|
|
19
34
|
|
|
20
35
|
let opened
|
|
21
36
|
before(async () => {
|
|
@@ -60,9 +75,8 @@ describe('work counted on the robot arm', () => {
|
|
|
60
75
|
/* The motion tab is drawn out of sight with the rest: the selected part's motion is evaluated at its two ends. */
|
|
61
76
|
test('picking a part on the canvas compiles nothing; it evaluates only the two ends of the part\'s motion', async () => {
|
|
62
77
|
const part = 'upper-arm'
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
const spent = since(before, await drawn())
|
|
78
|
+
const point = await read(id => probe.partOnPage(id), part)
|
|
79
|
+
const { before, spent } = await counted(() => click(point))
|
|
66
80
|
assert.equal((await read(() => probe.state())).selected, part)
|
|
67
81
|
assert.deepEqual(spent, { compiles: 0, evaluations: 2 })
|
|
68
82
|
/*
|
|
@@ -89,9 +103,7 @@ describe('work counted on the robot arm', () => {
|
|
|
89
103
|
const revision = (await read(() => probe.state())).revision
|
|
90
104
|
await page().mouse.click(field.x, field.y, { clickCount: 3 })
|
|
91
105
|
await page().keyboard.type(String(field.value + 10))
|
|
92
|
-
const
|
|
93
|
-
await page().keyboard.press('Enter')
|
|
94
|
-
const spent = since(before, await drawn())
|
|
106
|
+
const { spent } = await counted(() => page().keyboard.press('Enter'))
|
|
95
107
|
assert.equal((await read(() => probe.state())).revision, revision + 1, `${field.label} is one step`)
|
|
96
108
|
assert.ok(spent.compiles <= 2 && spent.evaluations <= 5, JSON.stringify(spent))
|
|
97
109
|
})
|
|
@@ -900,6 +900,31 @@ describe('the row section opens on one click', () => {
|
|
|
900
900
|
await click(at)
|
|
901
901
|
assert.equal(await summary.evaluate(s => s.parentElement.open), false, 'folded after a second')
|
|
902
902
|
})
|
|
903
|
+
|
|
904
|
+
test('a part picked again: a fold opened at once stays open -- the folds kept for the part are put back first', async () => {
|
|
905
|
+
await mount('rollers')
|
|
906
|
+
await click(await read(() => probe.row('mark')))
|
|
907
|
+
await click(await read(() => probe.row('roller')))
|
|
908
|
+
const open = await read(async () => {
|
|
909
|
+
const deep = (selector, root = document) => [...root.querySelectorAll(selector)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(selector, e.shadowRoot)))
|
|
910
|
+
const editor = deep('figure-editor')[0]
|
|
911
|
+
/*
|
|
912
|
+
The panel is drawn for the part once the editor and its links have updated; a click can come any time after
|
|
913
|
+
that, before a frame is drawn. Here it comes right then.
|
|
914
|
+
*/
|
|
915
|
+
editor.selected = 'mark'
|
|
916
|
+
await editor.updateComplete
|
|
917
|
+
await Promise.all(deep('figure-links').map(links => links.updateComplete))
|
|
918
|
+
await null
|
|
919
|
+
const summary = deep('details[row-fold] > summary')[0]
|
|
920
|
+
summary.click()
|
|
921
|
+
const fold = summary.parentElement
|
|
922
|
+
const opened = fold.open
|
|
923
|
+
await new Promise(done => requestAnimationFrame(() => requestAnimationFrame(done)))
|
|
924
|
+
return [opened, fold.open]
|
|
925
|
+
})
|
|
926
|
+
assert.deepEqual(open, [true, true], 'opened by the click, and still open a frame later')
|
|
927
|
+
})
|
|
903
928
|
})
|
|
904
929
|
|
|
905
930
|
test('the page raised no error', () => {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* A new part's id, in a real browser, on a figure with a row: the palette's box, a copy from the parts list. A row's
|
|
3
3
|
* copies are drawn under ids of their own; the part made into the row still holds its id, and the next part is not
|
|
4
|
-
* offered it (on :3300, 2026-10-11, a box added after a row was refused with DUPLICATE_PART).
|
|
4
|
+
* offered it (on :3300, 2026-10-11, a box added after a row was refused with DUPLICATE_PART). The id is figure-model's
|
|
5
|
+
* to choose (figurePartIdFor): read here as whatever new part there is.
|
|
5
6
|
*
|
|
6
7
|
* Run: node --test test/browser/part-ids.check.mjs
|
|
7
8
|
*/
|
|
@@ -52,7 +53,9 @@ function rowed() {
|
|
|
52
53
|
a = applyFigureAuthoring(a, { kind: 'attach', part: 'box-1', face: 'bottom', to: { plane: 'mounting-plane' } })
|
|
53
54
|
a = addFigureBox(a, { id: 'box-2', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 150, z: 0 } })
|
|
54
55
|
a = applyFigureAuthoring(a, { kind: 'attach', part: 'box-2', face: 'bottom', to: { part: 'box-1', face: 'top' } })
|
|
55
|
-
|
|
56
|
+
a = applyFigureAuthoring(a, { kind: 'make-repeat', part: 'box-2', axis: 'x', pitch: 300 })
|
|
57
|
+
/* A part off to one side, free: its mirrored copy lands on the other side. */
|
|
58
|
+
return addFigureBox(a, { id: 'side', size: { x: 100, y: 100, z: 100 }, position: { x: 900, y: 50, z: 0 } })
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
describe('a new part on a figure with a row takes an id no part holds', () => {
|
|
@@ -62,14 +65,17 @@ describe('a new part on a figure with a row takes an id no part holds', () => {
|
|
|
62
65
|
assert.equal((await read(() => probe.ids())).includes('box-2'), false, 'the row is drawn under its copies\' ids')
|
|
63
66
|
})
|
|
64
67
|
|
|
65
|
-
test('a box from the palette: added
|
|
68
|
+
test('a box from the palette: added under an id no part holds, one step, selected', async () => {
|
|
66
69
|
const start = await state()
|
|
67
70
|
await click('button[shape]', { has: 'figure.label.primitive-cube' }, 'the palette\'s box')
|
|
68
|
-
await until(
|
|
71
|
+
await until(r => probe.state().revision === r + 1, [start.revision], 'one step')
|
|
69
72
|
const after = await state()
|
|
70
73
|
assert.equal(after.error, '')
|
|
71
|
-
|
|
72
|
-
assert.
|
|
74
|
+
const now = await parts()
|
|
75
|
+
assert.equal(now.length, 4)
|
|
76
|
+
const added = now.find(id => !['box-1', 'box-2', 'side'].includes(id))
|
|
77
|
+
assert.ok(added, now.join())
|
|
78
|
+
assert.equal(after.selected, added)
|
|
73
79
|
})
|
|
74
80
|
|
|
75
81
|
test('the row\'s part copied from the list: the copy is a part, and it is what is selected', async () => {
|
|
@@ -83,4 +89,16 @@ describe('a new part on a figure with a row takes an id no part holds', () => {
|
|
|
83
89
|
assert.equal((await state()).selected, copy)
|
|
84
90
|
assert.equal((await state()).error, '')
|
|
85
91
|
})
|
|
92
|
+
|
|
93
|
+
test('a part mirrored: the copy, on the other side, is what is selected', async () => {
|
|
94
|
+
const at = await read(() => probe.row('side'))
|
|
95
|
+
await page().mouse.click(at.x, at.y)
|
|
96
|
+
await until(() => probe.state().selected === 'side', [], 'side selected')
|
|
97
|
+
const before = await parts()
|
|
98
|
+
await click('button', { text: 'figure.button.mirror-x' }, 'mirror across X')
|
|
99
|
+
await until(n => probe.source().appearance.length === n, [before.length + 1], 'the copy')
|
|
100
|
+
const copy = (await parts()).find(id => !before.includes(id))
|
|
101
|
+
assert.equal((await state()).selected, copy)
|
|
102
|
+
assert.equal(Math.round((await read(id => probe.body(id), copy)).t[0]), -900, 'on the other side')
|
|
103
|
+
})
|
|
86
104
|
})
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Only reading: every change the check makes goes through the mouse and the keyboard.
|
|
6
6
|
*/
|
|
7
7
|
import './editing-page.js'
|
|
8
|
-
import { addFigureBox, applyFigureAuthoring, createFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
|
|
8
|
+
import { addFigureBox, applyFigureAuthoring, createFigureAsset, proposeFigureOccupancy, serializeFigureAsset } from '@hatiolab/figure-model'
|
|
9
9
|
|
|
10
10
|
const PALETTE = {
|
|
11
11
|
'palette.dark': '#2b2b2b',
|
|
@@ -36,6 +36,14 @@ Object.assign(window.figures, {
|
|
|
36
36
|
return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'attach', part: 'leaf', face: 'bottom', to: { part: 'frame', face: 'top' } }))
|
|
37
37
|
},
|
|
38
38
|
/* A box with rounded corners: made from the box, still called what it is. */
|
|
39
|
+
/* A housing and a stray part on the floor, the space the figure takes declared from both. */
|
|
40
|
+
gate: () => {
|
|
41
|
+
let a = figure('Gate')
|
|
42
|
+
a = addFigureBox(a, { id: 'housing', size: { x: 300, y: 1000, z: 300 }, position: { x: 0, y: 500, z: 0 } })
|
|
43
|
+
a = addFigureBox(a, { id: 'stray', size: { x: 100, y: 100, z: 100 }, position: { x: 600, y: 50, z: 0 } })
|
|
44
|
+
for (const part of ['housing', 'stray']) a = applyFigureAuthoring(a, { kind: 'attach', part, face: 'bottom', to: { plane: 'mounting-plane' } })
|
|
45
|
+
return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a), placement: 'floor' }))
|
|
46
|
+
},
|
|
39
47
|
rounded: () => {
|
|
40
48
|
let a = figure('Rounded')
|
|
41
49
|
a = addFigureBox(a, { id: 'tray', size: { x: 600, y: 200, z: 400 }, position: { x: 0, y: 100, z: 0 } })
|