@things-factory/figure-ui 10.1.49 → 10.1.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/client/graphql/v3-store.ts +2 -1
  2. package/client/modeller/figure-history-panel.ts +9 -1
  3. package/client/modeller/figure-v3-first-save.ts +98 -0
  4. package/client/modeller/figure-v3-links.ts +89 -26
  5. package/client/modeller/figure-v3-parts.ts +1 -0
  6. package/client/modeller/figure-v3-properties.ts +2 -1
  7. package/client/modeller/v3-canvas.ts +61 -7
  8. package/client/modeller/v3-editor.ts +91 -21
  9. package/client/modeller/v3-first-save.ts +34 -0
  10. package/client/modeller/v3-gizmo.ts +52 -3
  11. package/client/modeller/v3-joint-drag.ts +116 -0
  12. package/client/modeller/v3-joint-fan.ts +24 -11
  13. package/client/modeller/v3-keys.ts +12 -1
  14. package/client/modeller/v3-links.ts +72 -5
  15. package/client/modeller/v3-live.ts +11 -0
  16. package/client/modeller/v3-product-session.ts +4 -2
  17. package/client/modeller/v3-refusals.ts +21 -0
  18. package/client/pages/figure-v3-modeller-page.ts +46 -7
  19. package/dist-client/graphql/v3-store.js +2 -1
  20. package/dist-client/graphql/v3-store.js.map +1 -1
  21. package/dist-client/modeller/figure-history-panel.js +9 -1
  22. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  23. package/dist-client/modeller/figure-v3-first-save.d.ts +20 -0
  24. package/dist-client/modeller/figure-v3-first-save.js +118 -0
  25. package/dist-client/modeller/figure-v3-first-save.js.map +1 -0
  26. package/dist-client/modeller/figure-v3-links.d.ts +5 -0
  27. package/dist-client/modeller/figure-v3-links.js +81 -23
  28. package/dist-client/modeller/figure-v3-links.js.map +1 -1
  29. package/dist-client/modeller/figure-v3-parts.js +1 -0
  30. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  31. package/dist-client/modeller/figure-v3-properties.js +2 -1
  32. package/dist-client/modeller/figure-v3-properties.js.map +1 -1
  33. package/dist-client/modeller/v3-canvas.d.ts +11 -0
  34. package/dist-client/modeller/v3-canvas.js +61 -7
  35. package/dist-client/modeller/v3-canvas.js.map +1 -1
  36. package/dist-client/modeller/v3-editor.d.ts +13 -1
  37. package/dist-client/modeller/v3-editor.js +91 -21
  38. package/dist-client/modeller/v3-editor.js.map +1 -1
  39. package/dist-client/modeller/v3-first-save.d.ts +17 -0
  40. package/dist-client/modeller/v3-first-save.js +27 -0
  41. package/dist-client/modeller/v3-first-save.js.map +1 -0
  42. package/dist-client/modeller/v3-gizmo.d.ts +18 -0
  43. package/dist-client/modeller/v3-gizmo.js +45 -2
  44. package/dist-client/modeller/v3-gizmo.js.map +1 -1
  45. package/dist-client/modeller/v3-joint-drag.d.ts +19 -0
  46. package/dist-client/modeller/v3-joint-drag.js +97 -0
  47. package/dist-client/modeller/v3-joint-drag.js.map +1 -0
  48. package/dist-client/modeller/v3-joint-fan.js +24 -11
  49. package/dist-client/modeller/v3-joint-fan.js.map +1 -1
  50. package/dist-client/modeller/v3-keys.d.ts +9 -1
  51. package/dist-client/modeller/v3-keys.js +3 -0
  52. package/dist-client/modeller/v3-keys.js.map +1 -1
  53. package/dist-client/modeller/v3-links.d.ts +45 -2
  54. package/dist-client/modeller/v3-links.js +52 -3
  55. package/dist-client/modeller/v3-links.js.map +1 -1
  56. package/dist-client/modeller/v3-live.d.ts +6 -0
  57. package/dist-client/modeller/v3-live.js +10 -0
  58. package/dist-client/modeller/v3-live.js.map +1 -1
  59. package/dist-client/modeller/v3-product-session.d.ts +4 -1
  60. package/dist-client/modeller/v3-product-session.js +2 -2
  61. package/dist-client/modeller/v3-product-session.js.map +1 -1
  62. package/dist-client/modeller/v3-refusals.d.ts +4 -0
  63. package/dist-client/modeller/v3-refusals.js +21 -0
  64. package/dist-client/modeller/v3-refusals.js.map +1 -0
  65. package/dist-client/pages/figure-v3-modeller-page.d.ts +4 -0
  66. package/dist-client/pages/figure-v3-modeller-page.js +45 -7
  67. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  68. package/dist-client/tsconfig.tsbuildinfo +1 -1
  69. package/package.json +7 -5
  70. package/test/browser/build.mjs +62 -0
  71. package/test/browser/modeller-page.js +198 -0
  72. package/test/browser/modeller.check.mjs +334 -0
  73. package/test/browser/serve.mjs +43 -0
  74. package/test/joints.ts +33 -0
  75. package/test/v3-clips.test.ts +3 -2
  76. package/test/v3-controls.test.ts +3 -2
  77. package/test/v3-fields.test.ts +3 -2
  78. package/test/v3-first-save.test.ts +75 -0
  79. package/test/v3-gizmo.test.ts +38 -5
  80. package/test/v3-joint-drag.test.ts +93 -0
  81. package/test/v3-joint-fan.test.ts +16 -6
  82. package/test/v3-keys.test.ts +15 -2
  83. package/test/v3-links.test.ts +62 -14
  84. package/test/v3-live.test.ts +23 -4
  85. package/test/v3-maturity.test.ts +2 -6
  86. package/test/v3-refusals.test.ts +28 -0
  87. package/translations/en.json +33 -5
  88. package/translations/ja.json +32 -4
  89. package/translations/ko.json +32 -4
  90. package/translations/ms.json +32 -4
  91. package/translations/zh.json +32 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.49",
3
+ "version": "10.1.50",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -18,6 +18,7 @@
18
18
  "scripts": {
19
19
  "build": "npm run build:server && npm run build:client",
20
20
  "test": "node --import ./test/resolve-ts-siblings.mjs --test --test-force-exit test/*.test.ts",
21
+ "test:browser": "node --test --test-force-exit test/browser/modeller.check.mjs",
21
22
  "copy:files": "copyfiles -e \"./client/**/*.{ts,js,json}\" -u 1 \"./client/**/*\" dist-client",
22
23
  "build:client": "npm run copy:files && npx tsc --p ./client/tsconfig.json",
23
24
  "build:server": "npx tsc --p ./server/tsconfig.json",
@@ -26,19 +27,20 @@
26
27
  "clean": "npm run clean:server && npm run clean:client"
27
28
  },
28
29
  "dependencies": {
29
- "@hatiolab/figure-model": "^0.1.55",
30
- "@hatiolab/things-scene": "^10.1.33",
30
+ "@hatiolab/figure-model": "^0.1.57",
31
+ "@hatiolab/things-scene": "^10.1.34",
31
32
  "@material/web": "^2.0.0",
32
33
  "@operato/data-grist": "^10.0.0",
33
34
  "@operato/graphql": "^10.0.0",
34
35
  "@operato/i18n": "^10.0.0",
35
36
  "@operato/layout": "^10.28.0",
36
37
  "@operato/p13n": "^10.0.0",
38
+ "@operato/popup": "^10.23.0",
37
39
  "@operato/shell": "^10.0.0",
38
40
  "@operato/styles": "^10.0.0",
39
41
  "@operato/utils": "^10.0.0",
40
- "@things-factory/figure-service": "^10.1.49",
42
+ "@things-factory/figure-service": "^10.1.50",
41
43
  "three": "^0.185.1"
42
44
  },
43
- "gitHead": "f9b29dcbdfecbfdd72e33bbcf74aea05331da75f"
45
+ "gitHead": "4700d39d70eb5fae4ad56cf47ea0b966c8516ae2"
44
46
  }
@@ -0,0 +1,62 @@
1
+ /*
2
+ * Builds the page the browser check drives: emits `client/` fresh (never a dist-client left from an earlier build --
3
+ * a stale bundle passes a check the source fails) and bundles it with the page into one file.
4
+ *
5
+ * FIGURE_MODEL=<dir of a figure-model build> bundles that build instead of the installed one -- for a change to
6
+ * figure-model that is not published yet.
7
+ */
8
+ import { execFileSync } from 'node:child_process'
9
+ import { mkdirSync, rmSync, existsSync } from 'node:fs'
10
+ import { tmpdir } from 'node:os'
11
+ import { dirname, join, resolve } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+ import { rollup } from 'rollup'
14
+ import nodeResolve from '@rollup/plugin-node-resolve'
15
+ import commonjs from '@rollup/plugin-commonjs'
16
+ import replace from '@rollup/plugin-replace'
17
+
18
+ const HERE = dirname(fileURLToPath(import.meta.url))
19
+ const PACKAGE = resolve(HERE, '../..')
20
+ const ROOT = resolve(PACKAGE, '../..')
21
+
22
+ export async function buildPage() {
23
+ const out = join(tmpdir(), 'figure-ui-browser-check')
24
+ const emitted = join(out, 'client')
25
+ rmSync(out, { recursive: true, force: true })
26
+ mkdirSync(out, { recursive: true })
27
+
28
+ /* Types are the unit suite's and tsc's business; here only what runs. */
29
+ execFileSync(join(ROOT, 'node_modules/.bin/tsc'), ['-p', join(PACKAGE, 'client/tsconfig.json'), '--outDir', emitted, '--noCheck', '--declaration', 'false', '--incremental', 'false'], {
30
+ stdio: 'inherit'
31
+ })
32
+
33
+ const figureModel = process.env.FIGURE_MODEL ? resolve(process.env.FIGURE_MODEL, 'dist/index.js') : undefined
34
+ if (figureModel && !existsSync(figureModel)) throw Error(`FIGURE_MODEL has no build: ${figureModel}`)
35
+
36
+ const bundle = await rollup({
37
+ input: join(HERE, 'modeller-page.js'),
38
+ onwarn(warning, warn) {
39
+ if (warning.code === 'CIRCULAR_DEPENDENCY' || warning.code === 'THIS_IS_UNDEFINED' || warning.code === 'EVAL') return
40
+ warn(warning)
41
+ },
42
+ plugins: [
43
+ {
44
+ name: 'figure-ui-client',
45
+ resolveId(id) {
46
+ if (id.startsWith('figure-ui-client/')) return join(emitted, id.slice('figure-ui-client/'.length))
47
+ if (figureModel && id === '@hatiolab/figure-model') return figureModel
48
+ return null
49
+ }
50
+ },
51
+ replace({ preventAssignment: true, values: { 'process.env.NODE_ENV': JSON.stringify('production') } }),
52
+ nodeResolve({ browser: true, modulePaths: [join(PACKAGE, 'node_modules'), join(ROOT, 'node_modules')] }),
53
+ commonjs()
54
+ ]
55
+ })
56
+ const file = join(out, 'page.js')
57
+ await bundle.write({ file, format: 'esm', inlineDynamicImports: true })
58
+ await bundle.close()
59
+ return file
60
+ }
61
+
62
+ if (process.argv[1] === fileURLToPath(import.meta.url)) console.log(await buildPage())
@@ -0,0 +1,198 @@
1
+ /*
2
+ * The page the browser check drives: the V3 editor as the modeller page mounts it, over a store in memory, so the check
3
+ * needs no server and no sign-in. Everything the editor draws and does is the product's own code, emitted fresh from
4
+ * `client/` by the runner.
5
+ */
6
+ import 'figure-ui-client/modeller/v3-editor.js'
7
+ import { createV3ProductSession } from 'figure-ui-client/modeller/v3-product-session.js'
8
+ import { addV3Box, convertV2ToV3, createV3Asset, robotArmV3, serializeV3Asset } from '@hatiolab/figure-model'
9
+
10
+ const PALETTE = {
11
+ 'palette.dark': '#2b2b2b',
12
+ 'palette.neutral': '#9a9a9a',
13
+ 'palette.primary': '#477b9e',
14
+ 'palette.accent': '#f79c40',
15
+ 'palette.light': '#dddddd'
16
+ }
17
+
18
+ function sourceOf(kind, v2) {
19
+ if (kind === 'arm') return serializeV3Asset(robotArmV3())
20
+ if (kind === 'box') {
21
+ const asset = createV3Asset('Probe')
22
+ asset.palette = { ...asset.palette, ...PALETTE }
23
+ return serializeV3Asset(addV3Box(asset, { id: 'box', size: { x: 400, y: 400, z: 400 }, position: { x: 0, y: 200, z: 0 } }))
24
+ }
25
+ const result = convertV2ToV3(v2, { palette: PALETTE })
26
+ if (result.status !== 'converted') throw Error(`not converted: ${JSON.stringify(result.refusals)}`)
27
+ return serializeV3Asset(result.asset)
28
+ }
29
+
30
+ function deep(selector, root = document) {
31
+ const found = [...root.querySelectorAll(selector)]
32
+ for (const element of root.querySelectorAll('*')) if (element.shadowRoot) found.push(...deep(selector, element.shadowRoot))
33
+ return found
34
+ }
35
+
36
+ const editor = () => document.querySelector('figure-v3-editor')
37
+ const canvas = () => deep('figure-v3-canvas')[0]
38
+ const three = () => canvas().scene.root._threeCapability
39
+ const controls = () => three().gizmoManager.controls
40
+
41
+ /** A world point on the page, in CSS pixels. */
42
+ function onPage(world) {
43
+ const camera = three().cameraManager.activeCamera
44
+ const box = three().rendererManager.domElement.getBoundingClientRect()
45
+ const v = world.clone().project(camera)
46
+ return { x: box.left + ((v.x + 1) / 2) * box.width, y: box.top + ((1 - v.y) / 2) * box.height, inFront: v.z < 1 }
47
+ }
48
+
49
+ function centreOf(element) {
50
+ element.scrollIntoView({ block: 'center' })
51
+ const box = element.getBoundingClientRect()
52
+ return { x: box.left + box.width / 2, y: box.top + box.height / 2 }
53
+ }
54
+
55
+ /*
56
+ * What the check reads. Only reading: every change the check makes goes through the mouse and the keyboard, as a
57
+ * person's would.
58
+ */
59
+ window.probe = {
60
+ state() {
61
+ const view = editor().session.read()
62
+ return {
63
+ revision: view.revision,
64
+ selected: editor().selected,
65
+ error: editor().error,
66
+ mode: three().gizmoManager.mode,
67
+ attached: three().gizmoManager.attached?.name ?? (three().gizmoManager.attached ? '(object)' : null),
68
+ axis: controls()?.axis ?? null,
69
+ dragging: three().gizmoManager.isDragging
70
+ }
71
+ },
72
+ /** The evaluated body of a part: where it is and its size, as the session holds it. */
73
+ body(id) {
74
+ const g = editor().session.read().geometry.find(g => g.id === id)
75
+ return g ? { t: g.pose.t, r: g.pose.r, dimensions: g.shape.dimensions } : null
76
+ },
77
+ fields(prefix) {
78
+ return Object.fromEntries(editor().session.read().fields.filter(f => f.id.startsWith(prefix)).map(f => [f.id, f.value]))
79
+ },
80
+ /** The page point of a drawn part's centre, for a click that picks it. */
81
+ partOnPage(id) {
82
+ const component = canvas().scene.root.components.find(c => c.state.name === id)
83
+ const object = component?._realObject?.object3d
84
+ if (!object) return null
85
+ object.updateWorldMatrix(true, true)
86
+ const centre = object.getWorldPosition(object.position.clone())
87
+ return onPage(centre)
88
+ },
89
+ /**
90
+ * Page points on the gizmo's handle for an axis in the current mode: vertices of the handle's picker (the invisible
91
+ * shape the gizmo hit-tests), nearest the viewer first. The check hovers them until the gizmo says it holds that axis.
92
+ */
93
+ handlePoints(axis) {
94
+ const c = controls()
95
+ const picker = c?._gizmo?.picker?.[c.mode]
96
+ if (!picker) return []
97
+ picker.updateMatrixWorld(true)
98
+ const points = []
99
+ for (const handle of picker.children.filter(h => h.name === axis)) {
100
+ const position = handle.geometry.attributes.position
101
+ const v = handle.position.clone()
102
+ for (let i = 0; i < position.count; i += Math.max(1, Math.floor(position.count / 24))) {
103
+ v.fromBufferAttribute(position, i).applyMatrix4(handle.matrixWorld)
104
+ const p = onPage(v)
105
+ if (p.inFront) points.push(p)
106
+ }
107
+ }
108
+ return points
109
+ },
110
+ /**
111
+ * Which way on the page to pull a rotate ring so it turns: the gizmo turns by the pull along (axis × eye), the
112
+ * direction to the camera (three's TransformControls). A unit vector in page pixels.
113
+ */
114
+ turnDirection(axis) {
115
+ const c = controls()
116
+ const object = three().gizmoManager.attached
117
+ if (!c || !object) return null
118
+ object.updateWorldMatrix(true, false)
119
+ const at = object.getWorldPosition(object.position.clone())
120
+ const camera = three().cameraManager.activeCamera
121
+ const eye = camera.getWorldPosition(at.clone()).sub(at).normalize()
122
+ const unit = at.clone().set(axis === 'X' ? 1 : 0, axis === 'Y' ? 1 : 0, axis === 'Z' ? 1 : 0)
123
+ if (c.space === 'local') unit.applyQuaternion(object.getWorldQuaternion(object.quaternion.clone()))
124
+ const tangent = unit.cross(eye).normalize()
125
+ const size = at.distanceTo(camera.getWorldPosition(at.clone())) * 0.05
126
+ const a = onPage(at), b = onPage(at.clone().add(tangent.multiplyScalar(size)))
127
+ const l = Math.hypot(b.x - a.x, b.y - a.y) || 1
128
+ return { x: (b.x - a.x) / l, y: (b.y - a.y) / l }
129
+ },
130
+ /** The page point of a part's row in the parts list. */
131
+ row(id) {
132
+ const label = deep('span[label]').find(s => s.getAttribute('title') === id)
133
+ return label ? centreOf(label) : null
134
+ },
135
+ /** The page point of a side tab, by its label (the i18n key: the check loads no translations). */
136
+ tab(label) {
137
+ const button = deep('button[tab]').find(b => b.getAttribute('aria-label') === label)
138
+ return button ? centreOf(button) : null
139
+ },
140
+ /** Where a slider's thumb is and how many pixels one unit of it spans. */
141
+ slider(label) {
142
+ const input = deep('input[type=range]').find(r => r.getAttribute('aria-label') === label)
143
+ if (!input) return null
144
+ input.scrollIntoView({ block: 'center' })
145
+ const box = input.getBoundingClientRect()
146
+ const min = Number(input.min),
147
+ max = Number(input.max),
148
+ value = Number(input.value)
149
+ const thumb = 8
150
+ const x = box.left + thumb + ((value - min) / (max - min)) * (box.width - 2 * thumb)
151
+ return { x, y: box.top + box.height / 2, pxPerUnit: (box.width - 2 * thumb) / (max - min), value }
152
+ },
153
+ /** A joint record as the session holds it. */
154
+ joint(id) {
155
+ return (JSON.parse(editor().session.source()).joints ?? []).find(j => j.id === id) ?? null
156
+ },
157
+ /** A state or design value as the session holds it. */
158
+ value(id) {
159
+ return editor().session.read().fields.find(f => f.id === id)?.value ?? null
160
+ },
161
+ /** The world centre of a part's top or bottom face, from the session's evaluation. */
162
+ face(id, which) {
163
+ const g = editor().session.read().geometry.find(g => g.id === id)
164
+ if (!g) return null
165
+ const d = g.shape.dimensions
166
+ const half = (d.height ?? d.length) / 2
167
+ const s = which === 'top' ? half : -half
168
+ return [0, 1, 2].map(i => g.pose.t[i] + g.pose.r[i][1] * s)
169
+ },
170
+ /** Whether the joint fan stands on the board now. */
171
+ fan() {
172
+ return canvas().scene.root.components.some(c => c.state.name === '__joint-fan')
173
+ },
174
+ /** The drawn position of a part's component, as the canvas shows it now. */
175
+ drawn(id) {
176
+ const c = canvas().scene.root.components.find(c => c.state.name === id)
177
+ return c ? [c.state.left, c.state.top, c.state.zPos].map(Math.round) : null
178
+ }
179
+ }
180
+
181
+ window.harness = {
182
+ async mount(kind, v2) {
183
+ const source = sourceOf(kind, v2)
184
+ const session = createV3ProductSession({
185
+ load: async id => ({ id, source }),
186
+ save: async request => ({ id: request.id ?? 'probe', source: request.source })
187
+ })
188
+ await session.open('probe')
189
+ const editor = document.createElement('figure-v3-editor')
190
+ editor.session = session
191
+ editor.revision = 0
192
+ /* The modeller page counts a change the same way: the editor re-reads the session on a new revision. */
193
+ editor.addEventListener('v3-changed', () => (editor.revision = editor.revision + 1))
194
+ document.body.replaceChildren(editor)
195
+ await editor.updateComplete
196
+ return session.read().revision
197
+ }
198
+ }
@@ -0,0 +1,334 @@
1
+ /*
2
+ * The V3 modeller driven with the mouse, in a real browser.
3
+ *
4
+ * The unit suite proves the rules; it cannot prove what happens between a pointer going down on a gizmo handle and
5
+ * coming up -- the scene writes the component on every move, the editor must not fight it, and the drag must land as
6
+ * one step that stays. Every failure the user found in the gizmo (2026-09-24: selection lost, a converted part
7
+ * springing back, scale growing then snapping back) lived there, and each had been declared working from code. So
8
+ * here the check does what a person does: clicks the part, presses the mode key, hovers until the gizmo holds the
9
+ * handle, drags, lets go -- and then reads the session.
10
+ *
11
+ * Needs the sibling figure-model checkout for the V2 robot arm it converts (as things-scene's browser checks do).
12
+ *
13
+ * Run: npm run test:browser
14
+ * FIGURE_MODEL=../../../figure-model npm run test:browser (a figure-model build not published yet)
15
+ */
16
+ import { test, describe, before, after } from 'node:test'
17
+ import assert from 'node:assert/strict'
18
+ import { readFileSync } from 'node:fs'
19
+ import { dirname, resolve } from 'node:path'
20
+ import { fileURLToPath } from 'node:url'
21
+
22
+ import { buildPage } from './build.mjs'
23
+ import { openPage } from './serve.mjs'
24
+
25
+ const HERE = dirname(fileURLToPath(import.meta.url))
26
+ const FIXTURE = resolve(HERE, '../../../../../figure-model/src/fixtures/robot-arm-6dof.json')
27
+
28
+ const sleep = ms => new Promise(done => setTimeout(done, ms))
29
+
30
+ /** The V2 six-axis arm, without the tool-seat marker (a slot anchor with no material, which V3 carries as a place). */
31
+ function robotArm() {
32
+ const source = JSON.parse(readFileSync(FIXTURE, 'utf-8'))
33
+ source.parts = source.parts.filter(part => part.material)
34
+ delete source.slots
35
+ return source
36
+ }
37
+
38
+ let opened
39
+ before(async () => {
40
+ opened = await openPage(await buildPage())
41
+ })
42
+ after(async () => {
43
+ await opened?.close()
44
+ })
45
+
46
+ const page = () => opened.page
47
+ const read = (fn, ...args) => page().evaluate(fn, ...args)
48
+ const state = () => read(() => probe.state())
49
+ const body = id => read(id => probe.body(id), id)
50
+ const fields = id => read(id => probe.fields(id + '.'), id)
51
+
52
+ async function mount(kind) {
53
+ await read((kind, v2) => harness.mount(kind, v2), kind, kind === 'box' ? null : robotArm())
54
+ /* The board is built and the camera fitted after the first frames. */
55
+ await sleep(2500)
56
+ }
57
+
58
+ async function click(point) {
59
+ assert.ok(point, 'nothing on the page to click')
60
+ await page().mouse.click(point.x, point.y)
61
+ await sleep(400)
62
+ }
63
+
64
+ const KEYS = { translate: 'KeyW', rotate: 'KeyE', scale: 'KeyR' }
65
+
66
+ /**
67
+ * Drags a gizmo handle `pixels` outward along it, as a hand would: the mode key (the scene's toggle -- pressed only
68
+ * when the gizmo is not already on in that mode), a hover until the gizmo says it holds the handle, ten moves, release.
69
+ */
70
+ async function dragHandle(part, mode, axis, pixels) {
71
+ const s = await state()
72
+ if (s.mode !== mode || !s.attached) {
73
+ await page().keyboard.press(KEYS[mode])
74
+ await sleep(300)
75
+ }
76
+ assert.ok((await state()).attached, `the ${mode} gizmo is on the selected part`)
77
+ const centre = await read(id => probe.partOnPage(id), part)
78
+ let hold
79
+ for (const point of await read(axis => probe.handlePoints(axis), axis)) {
80
+ await page().mouse.move(point.x, point.y)
81
+ await sleep(20)
82
+ if ((await state()).axis === axis) {
83
+ hold = point
84
+ break
85
+ }
86
+ }
87
+ assert.ok(hold, `the mouse finds the ${mode} ${axis} handle`)
88
+ let dx = hold.x - centre.x,
89
+ dy = hold.y - centre.y
90
+ const length = Math.hypot(dx, dy) || 1
91
+ dx /= length
92
+ dy /= length
93
+ /* A rotate ring turns by the pull along (axis × eye): pull that way. */
94
+ if (mode === 'rotate') ({ x: dx, y: dy } = await read(axis => probe.turnDirection(axis), axis))
95
+ await page().mouse.down()
96
+ for (let i = 1; i <= 10; i++) {
97
+ await page().mouse.move(hold.x + (dx * pixels * i) / 10, hold.y + (dy * pixels * i) / 10)
98
+ await sleep(30)
99
+ }
100
+ await page().mouse.up()
101
+ await sleep(600)
102
+ }
103
+
104
+ /** After the release, and again a second later: a value that springs back shows only in the second reading. */
105
+ async function settled(part) {
106
+ const now = await body(part)
107
+ await sleep(1000)
108
+ assert.deepEqual(await body(part), now, `${part} stays where the drag left it`)
109
+ return now
110
+ }
111
+
112
+ const moved = (a, b) => a.map((v, i) => Math.round(b[i] - v))
113
+
114
+ describe('the gizmo on a part the modeller made (own numbers)', () => {
115
+ before(async () => {
116
+ await mount('box')
117
+ await click(await read(() => probe.partOnPage('box')))
118
+ assert.equal((await state()).selected, 'box', 'a click on the part selects it')
119
+ })
120
+
121
+ test('translate: one step, the part moves along the handle and stays, still selected', async () => {
122
+ const start = await state(),
123
+ was = await body('box')
124
+ await dragHandle('box', 'translate', 'X', 80)
125
+ const now = await settled('box')
126
+ const after = await state()
127
+ assert.equal(after.revision, start.revision + 1, 'one step of the history')
128
+ assert.equal(after.selected, 'box')
129
+ assert.ok(after.attached, 'the gizmo is still on it')
130
+ const [x, y, z] = moved(was.t, now.t)
131
+ assert.ok(Math.abs(x) > 20 && y === 0 && z === 0, `moved along X only: ${[x, y, z]}`)
132
+ assert.deepEqual(now.dimensions, was.dimensions)
133
+ })
134
+
135
+ test('scale: one step, the part grows along the handle and keeps the size', async () => {
136
+ const start = await state(),
137
+ was = await body('box')
138
+ await dragHandle('box', 'scale', 'X', 80)
139
+ const now = await settled('box')
140
+ const after = await state()
141
+ assert.equal(after.revision, start.revision + 1, 'one step of the history')
142
+ assert.equal(after.selected, 'box')
143
+ assert.ok(now.dimensions.width > was.dimensions.width * 1.2, `grew: ${was.dimensions.width} → ${now.dimensions.width}`)
144
+ assert.equal(now.dimensions.height, was.dimensions.height)
145
+ assert.equal(now.dimensions.depth, was.dimensions.depth)
146
+ })
147
+
148
+ test('rotate: one step, the part turns about the handle axis and keeps the turn', async () => {
149
+ const start = await state(),
150
+ was = await fields('box')
151
+ await dragHandle('box', 'rotate', 'Y', 80)
152
+ await settled('box')
153
+ const now = await fields('box')
154
+ const after = await state()
155
+ assert.equal(after.revision, start.revision + 1, 'one step of the history')
156
+ assert.equal(after.selected, 'box')
157
+ assert.ok(Math.abs(now['box.rotation.y'] - was['box.rotation.y']) > 10, `turned about Y: ${was['box.rotation.y']} → ${now['box.rotation.y']}`)
158
+ assert.equal(now['box.rotation.x'], was['box.rotation.x'])
159
+ assert.equal(now['box.rotation.z'], was['box.rotation.z'])
160
+ })
161
+ })
162
+
163
+ describe('the gizmo on a converted part (worked-out rules)', () => {
164
+ before(async () => {
165
+ await mount('v2')
166
+ await click(await read(() => probe.row('pedestal')))
167
+ assert.equal((await state()).selected, 'pedestal', 'a click on the row selects it')
168
+ })
169
+
170
+ test('translate: one step (nudge-part), the part moves and stays, still selected', async () => {
171
+ const start = await state(),
172
+ was = await body('pedestal')
173
+ await dragHandle('pedestal', 'translate', 'X', 80)
174
+ const now = await settled('pedestal')
175
+ const after = await state()
176
+ assert.equal(after.error, '', 'not refused')
177
+ assert.equal(after.revision, start.revision + 1, 'one step of the history')
178
+ assert.equal(after.selected, 'pedestal')
179
+ const [x, y, z] = moved(was.t, now.t)
180
+ assert.ok(Math.abs(x) > 20 && y === 0 && z === 0, `moved along X only: ${[x, y, z]}`)
181
+ })
182
+
183
+ test('scale: one step (nudge-part grow), the cylinder widens and keeps it', async () => {
184
+ const start = await state(),
185
+ was = await body('pedestal')
186
+ await dragHandle('pedestal', 'scale', 'X', 80)
187
+ const now = await settled('pedestal')
188
+ const after = await state()
189
+ assert.equal(after.error, '', 'not refused')
190
+ assert.equal(after.revision, start.revision + 1, 'one step of the history')
191
+ assert.equal(after.selected, 'pedestal')
192
+ assert.ok(now.dimensions.radius > was.dimensions.radius * 1.1, `wider: ${was.dimensions.radius} → ${now.dimensions.radius}`)
193
+ assert.equal(now.dimensions.length, was.dimensions.length)
194
+ })
195
+
196
+ test('undo with the canvas focused takes the whole drag back, and nothing writes it again', async () => {
197
+ const was = await body('pedestal')
198
+ await dragHandle('pedestal', 'translate', 'X', 80)
199
+ const dragged = await state()
200
+ const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'
201
+ await page().keyboard.down(modifier)
202
+ await page().keyboard.press('KeyZ')
203
+ await page().keyboard.up(modifier)
204
+ await sleep(1200)
205
+ assert.deepEqual(await body('pedestal'), was, 'back where it was before the drag')
206
+ assert.equal((await state()).revision, dragged.revision + 1, 'the undo, and no write after it')
207
+ })
208
+
209
+ test('a part a joint places is refused, said, and drawn back where the joint puts it', async () => {
210
+ await click(await read(() => probe.row('upper-arm')))
211
+ assert.equal((await state()).selected, 'upper-arm')
212
+ const start = await state(),
213
+ was = await body('upper-arm'),
214
+ drawn = await read(() => probe.drawn('upper-arm'))
215
+ await dragHandle('upper-arm', 'translate', 'X', 80)
216
+ await sleep(1000)
217
+ const after = await state()
218
+ assert.equal(after.revision, start.revision, 'nothing written')
219
+ assert.notEqual(after.error, '', 'the refusal is said')
220
+ assert.equal(after.selected, 'upper-arm', 'the selection stays')
221
+ assert.deepEqual(await body('upper-arm'), was)
222
+ assert.deepEqual(await read(() => probe.drawn('upper-arm')), drawn, 'the canvas draws it back')
223
+ })
224
+ })
225
+
226
+ describe('a pose slider dragged with the mouse (user 2026-09-24: the arm jumped on release)', () => {
227
+ before(async () => {
228
+ await mount('v2')
229
+ await click(await read(() => probe.tab('figure.label.motion')))
230
+ })
231
+
232
+ /** Presses a slider's thumb, moves it `units`, reads the canvas while still held, then releases. */
233
+ async function dragSlider(label, units) {
234
+ const thumb = await read(label => probe.slider(label), label)
235
+ assert.ok(thumb, `the ${label} slider is on the page`)
236
+ await page().mouse.move(thumb.x, thumb.y)
237
+ await page().mouse.down()
238
+ for (let i = 1; i <= 10; i++) {
239
+ await page().mouse.move(thumb.x + (thumb.pxPerUnit * units * i) / 10, thumb.y)
240
+ await sleep(40)
241
+ }
242
+ await sleep(300)
243
+ const held = await read(() => probe.drawn('finger-left'))
244
+ await page().mouse.up()
245
+ await sleep(700)
246
+ return { held, released: await read(() => probe.drawn('finger-left')) }
247
+ }
248
+
249
+ test('while one joint is dragged the others keep the pose already set, so the release lands where the drag showed', async () => {
250
+ const base = await dragSlider('Base pan (J1)', 60)
251
+ assert.deepEqual(base.held, base.released, 'the first joint: held and released agree')
252
+ const pan = (await read(() => probe.slider('Base pan (J1)'))).value
253
+ assert.ok(Math.abs(pan) > 20, `the base turned: ${pan}`)
254
+
255
+ const shoulder = await dragSlider('Shoulder lift (J2)', 30)
256
+ assert.deepEqual(shoulder.held, shoulder.released, `drawn while held ${shoulder.held}, after release ${shoulder.released}`)
257
+ assert.equal((await read(() => probe.slider('Base pan (J1)'))).value, pan, 'the base keeps its turn')
258
+ assert.notDeepEqual(shoulder.released, base.released, 'the shoulder moved the tip')
259
+ })
260
+ })
261
+
262
+ describe('the V3 robot arm: a drag on a part edits its joint (ADR-0093)', () => {
263
+ before(async () => {
264
+ await mount('arm')
265
+ await click(await read(() => probe.row('upper-arm')))
266
+ assert.equal((await state()).selected, 'upper-arm')
267
+ })
268
+
269
+ test('the upper arm shows the shoulder joint\'s fan', async () => {
270
+ assert.equal(await read(() => probe.fan()), true)
271
+ })
272
+
273
+ test('turning the upper arm with the rotate handle turns J2: one step, still on the shoulder, undone whole', async () => {
274
+ const start = await state()
275
+ const was = await read(() => probe.value('j2-shoulder'))
276
+ await dragHandle('upper-arm', 'rotate', 'Z', 120)
277
+ await sleep(600)
278
+ const after = await state()
279
+ const now = await read(() => probe.value('j2-shoulder'))
280
+ assert.equal(after.error, '', 'not refused')
281
+ assert.equal(after.revision, start.revision + 1, 'one step')
282
+ assert.equal(after.selected, 'upper-arm')
283
+ assert.ok(Math.abs(now - was) > 5, `J2 turned: ${was} -> ${now}`)
284
+ const gap = (await read(() => probe.face('upper-arm', 'bottom'))).map((v, i) => v - 0)
285
+ const top = await read(() => probe.face('shoulder', 'top'))
286
+ assert.ok(Math.hypot(...gap.map((v, i) => v - top[i])) < 1e-6, 'the upper arm still starts on the shoulder top')
287
+ const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'
288
+ await page().keyboard.down(modifier)
289
+ await page().keyboard.press('KeyZ')
290
+ await page().keyboard.up(modifier)
291
+ await sleep(900)
292
+ assert.equal(await read(() => probe.value('j2-shoulder')), was, 'undo takes the turn back')
293
+ })
294
+
295
+ test('scaling the upper arm with the R handle makes it longer: one step, still on the shoulder, the elbow carried', async () => {
296
+ await click(await read(() => probe.row('upper-arm')))
297
+ const start = await state()
298
+ const was = await read(() => probe.value('upper-arm.dimension.height'))
299
+ await dragHandle('upper-arm', 'scale', 'Y', 80)
300
+ await sleep(600)
301
+ const after = await state()
302
+ const now = await read(() => probe.value('upper-arm.dimension.height'))
303
+ assert.equal(after.error, '', 'not refused')
304
+ assert.equal(after.revision, start.revision + 1, 'one step')
305
+ assert.ok(now > was * 1.1, `longer: ${was} -> ${now}`)
306
+ const bottom = await read(() => probe.face('upper-arm', 'bottom'))
307
+ const top = await read(() => probe.face('shoulder', 'top'))
308
+ assert.ok(Math.hypot(...bottom.map((v, i) => v - top[i])) < 1e-6, 'still starts on the shoulder top')
309
+ const elbow = await read(() => probe.face('elbow', 'bottom'))
310
+ const end = await read(() => probe.face('upper-arm', 'top'))
311
+ assert.ok(Math.hypot(...elbow.map((v, i) => v - end[i])) < 1e-6, 'the elbow sits on the longer arm\'s end')
312
+ })
313
+
314
+ test('moving the elbow block moves where its joint sits; the forearm comes with it', async () => {
315
+ await click(await read(() => probe.row('elbow')))
316
+ const start = await state()
317
+ const forearm = await read(() => probe.face('forearm', 'bottom'))
318
+ await dragHandle('elbow', 'translate', 'X', 60)
319
+ await sleep(600)
320
+ const after = await state()
321
+ assert.equal(after.error, '', 'not refused')
322
+ assert.equal(after.revision, start.revision + 1, 'one step')
323
+ const joint = await read(() => probe.joint('elbow'))
324
+ assert.ok(joint?.origin?.align?.x?.mm, `the joint's place across the face: ${JSON.stringify(joint?.origin)}`)
325
+ const moved = await read(() => probe.face('forearm', 'bottom'))
326
+ const top = await read(() => probe.face('elbow', 'top'))
327
+ assert.ok(Math.abs(moved[0] - forearm[0]) > 5, 'the forearm moved with it')
328
+ assert.ok(Math.hypot(...moved.map((v, i) => v - top[i])) < 1e-6, 'and still starts on the elbow top')
329
+ })
330
+ })
331
+
332
+ test('the page raised no error', () => {
333
+ assert.deepEqual(opened.errors, [])
334
+ })
@@ -0,0 +1,43 @@
1
+ /*
2
+ * Opens the built page in a headless browser: a tiny server for page.js, and puppeteer on it at a desktop size.
3
+ */
4
+ import { createServer } from 'node:http'
5
+ import { readFileSync } from 'node:fs'
6
+ import puppeteer from 'puppeteer'
7
+
8
+ const HTML = `<!DOCTYPE html>
9
+ <html><head><meta charset="utf-8"><style>
10
+ html, body { margin: 0; height: 100%; }
11
+ body { display: flex; font-family: sans-serif; }
12
+ figure-v3-editor { flex: 1; min-width: 0; min-height: 0; }
13
+ </style></head>
14
+ <body><script type="module" src="/page.js"></script></body></html>`
15
+
16
+ export async function openPage(bundle, { width = 1400, height = 900 } = {}) {
17
+ const js = readFileSync(bundle)
18
+ const server = createServer((req, res) => {
19
+ if (req.url === '/page.js') {
20
+ res.writeHead(200, { 'Content-Type': 'application/javascript' })
21
+ res.end(js)
22
+ } else {
23
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
24
+ res.end(HTML)
25
+ }
26
+ })
27
+ await new Promise(done => server.listen(0, done))
28
+ const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] })
29
+ const page = await browser.newPage()
30
+ await page.setViewport({ width, height })
31
+ const errors = []
32
+ page.on('pageerror', e => errors.push(String(e)))
33
+ await page.goto(`http://localhost:${server.address().port}/`)
34
+ await page.waitForFunction(() => !!window.harness, { timeout: 30000 })
35
+ return {
36
+ page,
37
+ errors,
38
+ async close() {
39
+ await browser.close()
40
+ server.close()
41
+ }
42
+ }
43
+ }