@things-factory/figure-ui 10.1.46 → 10.1.48

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 (148) hide show
  1. package/client/modeller/angle-dial.ts +70 -0
  2. package/client/modeller/figure-settings.ts +328 -0
  3. package/client/modeller/figure-v3-capability.ts +137 -0
  4. package/client/modeller/figure-v3-clips.ts +253 -0
  5. package/client/modeller/figure-v3-controls.ts +212 -0
  6. package/client/modeller/figure-v3-links.ts +61 -81
  7. package/client/modeller/figure-v3-material.ts +108 -35
  8. package/client/modeller/figure-v3-parts.ts +96 -3
  9. package/client/modeller/figure-v3-preview.ts +326 -0
  10. package/client/modeller/figure-v3-properties.ts +29 -57
  11. package/client/modeller/figure-v3-verdict.ts +598 -60
  12. package/client/modeller/figure-view.ts +152 -0
  13. package/client/modeller/side-panel-styles.ts +326 -0
  14. package/client/modeller/v3-canvas.ts +254 -262
  15. package/client/modeller/v3-clips.ts +89 -0
  16. package/client/modeller/v3-controls.ts +54 -0
  17. package/client/modeller/v3-editor.ts +566 -223
  18. package/client/modeller/v3-fields.ts +84 -0
  19. package/client/modeller/v3-help.ts +41 -0
  20. package/client/modeller/v3-joint-fan.ts +292 -0
  21. package/client/modeller/v3-keys.ts +34 -0
  22. package/client/modeller/v3-links.ts +101 -9
  23. package/client/modeller/v3-live.ts +79 -0
  24. package/client/modeller/v3-maturity.ts +415 -0
  25. package/client/modeller/v3-palette.ts +45 -0
  26. package/client/modeller/v3-preview-model.ts +54 -0
  27. package/client/modeller/v3-stage-model.ts +159 -0
  28. package/client/modeller/v3-thumbnail.ts +8 -4
  29. package/client/pages/figure-list-page.ts +4 -4
  30. package/client/pages/figure-v3-modeller-page.ts +66 -134
  31. package/dist-client/modeller/angle-dial.d.ts +32 -0
  32. package/dist-client/modeller/angle-dial.js +41 -0
  33. package/dist-client/modeller/angle-dial.js.map +1 -0
  34. package/dist-client/modeller/figure-settings.d.ts +39 -0
  35. package/dist-client/modeller/figure-settings.js +317 -0
  36. package/dist-client/modeller/figure-settings.js.map +1 -0
  37. package/dist-client/modeller/figure-v3-capability.d.ts +22 -0
  38. package/dist-client/modeller/figure-v3-capability.js +141 -0
  39. package/dist-client/modeller/figure-v3-capability.js.map +1 -0
  40. package/dist-client/modeller/figure-v3-clips.d.ts +31 -0
  41. package/dist-client/modeller/figure-v3-clips.js +268 -0
  42. package/dist-client/modeller/figure-v3-clips.js.map +1 -0
  43. package/dist-client/modeller/figure-v3-controls.d.ts +36 -0
  44. package/dist-client/modeller/figure-v3-controls.js +226 -0
  45. package/dist-client/modeller/figure-v3-controls.js.map +1 -0
  46. package/dist-client/modeller/figure-v3-links.d.ts +4 -1
  47. package/dist-client/modeller/figure-v3-links.js +64 -82
  48. package/dist-client/modeller/figure-v3-links.js.map +1 -1
  49. package/dist-client/modeller/figure-v3-material.d.ts +4 -1
  50. package/dist-client/modeller/figure-v3-material.js +109 -35
  51. package/dist-client/modeller/figure-v3-material.js.map +1 -1
  52. package/dist-client/modeller/figure-v3-parts.d.ts +10 -0
  53. package/dist-client/modeller/figure-v3-parts.js +88 -3
  54. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  55. package/dist-client/modeller/figure-v3-preview.d.ts +34 -0
  56. package/dist-client/modeller/figure-v3-preview.js +322 -0
  57. package/dist-client/modeller/figure-v3-preview.js.map +1 -0
  58. package/dist-client/modeller/figure-v3-properties.d.ts +1 -1
  59. package/dist-client/modeller/figure-v3-properties.js +29 -57
  60. package/dist-client/modeller/figure-v3-properties.js.map +1 -1
  61. package/dist-client/modeller/figure-v3-verdict.d.ts +31 -17
  62. package/dist-client/modeller/figure-v3-verdict.js +579 -59
  63. package/dist-client/modeller/figure-v3-verdict.js.map +1 -1
  64. package/dist-client/modeller/figure-view.d.ts +118 -0
  65. package/dist-client/modeller/figure-view.js +78 -0
  66. package/dist-client/modeller/figure-view.js.map +1 -0
  67. package/dist-client/modeller/side-panel-styles.d.ts +23 -0
  68. package/dist-client/modeller/side-panel-styles.js +326 -0
  69. package/dist-client/modeller/side-panel-styles.js.map +1 -0
  70. package/dist-client/modeller/v3-canvas.d.ts +44 -37
  71. package/dist-client/modeller/v3-canvas.js +230 -260
  72. package/dist-client/modeller/v3-canvas.js.map +1 -1
  73. package/dist-client/modeller/v3-clips.d.ts +39 -0
  74. package/dist-client/modeller/v3-clips.js +58 -0
  75. package/dist-client/modeller/v3-clips.js.map +1 -0
  76. package/dist-client/modeller/v3-controls.d.ts +28 -0
  77. package/dist-client/modeller/v3-controls.js +28 -0
  78. package/dist-client/modeller/v3-controls.js.map +1 -0
  79. package/dist-client/modeller/v3-editor.d.ts +73 -1
  80. package/dist-client/modeller/v3-editor.js +593 -218
  81. package/dist-client/modeller/v3-editor.js.map +1 -1
  82. package/dist-client/modeller/v3-fields.d.ts +39 -0
  83. package/dist-client/modeller/v3-fields.js +69 -0
  84. package/dist-client/modeller/v3-fields.js.map +1 -0
  85. package/dist-client/modeller/v3-help.d.ts +18 -0
  86. package/dist-client/modeller/v3-help.js +37 -0
  87. package/dist-client/modeller/v3-help.js.map +1 -0
  88. package/dist-client/modeller/v3-joint-fan.d.ts +9 -0
  89. package/dist-client/modeller/v3-joint-fan.js +264 -0
  90. package/dist-client/modeller/v3-joint-fan.js.map +1 -0
  91. package/dist-client/modeller/v3-keys.d.ts +14 -0
  92. package/dist-client/modeller/v3-keys.js +23 -0
  93. package/dist-client/modeller/v3-keys.js.map +1 -0
  94. package/dist-client/modeller/v3-links.d.ts +35 -1
  95. package/dist-client/modeller/v3-links.js +101 -8
  96. package/dist-client/modeller/v3-links.js.map +1 -1
  97. package/dist-client/modeller/v3-live.d.ts +31 -0
  98. package/dist-client/modeller/v3-live.js +58 -0
  99. package/dist-client/modeller/v3-live.js.map +1 -0
  100. package/dist-client/modeller/v3-maturity.d.ts +120 -0
  101. package/dist-client/modeller/v3-maturity.js +317 -0
  102. package/dist-client/modeller/v3-maturity.js.map +1 -0
  103. package/dist-client/modeller/v3-palette.d.ts +7 -0
  104. package/dist-client/modeller/v3-palette.js +42 -0
  105. package/dist-client/modeller/v3-palette.js.map +1 -0
  106. package/dist-client/modeller/v3-preview-model.d.ts +32 -0
  107. package/dist-client/modeller/v3-preview-model.js +48 -0
  108. package/dist-client/modeller/v3-preview-model.js.map +1 -0
  109. package/dist-client/modeller/v3-stage-model.d.ts +31 -0
  110. package/dist-client/modeller/v3-stage-model.js +121 -0
  111. package/dist-client/modeller/v3-stage-model.js.map +1 -0
  112. package/dist-client/modeller/v3-thumbnail.js +6 -3
  113. package/dist-client/modeller/v3-thumbnail.js.map +1 -1
  114. package/dist-client/pages/figure-list-page.js +4 -4
  115. package/dist-client/pages/figure-list-page.js.map +1 -1
  116. package/dist-client/pages/figure-v3-modeller-page.d.ts +2 -6
  117. package/dist-client/pages/figure-v3-modeller-page.js +64 -136
  118. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  119. package/dist-client/tsconfig.chk.tsbuildinfo +1 -0
  120. package/dist-client/tsconfig.scratch-fm.tsbuildinfo +1 -1
  121. package/dist-client/tsconfig.tsbuildinfo +1 -1
  122. package/package.json +6 -6
  123. package/test/v3-clips.test.ts +70 -0
  124. package/test/v3-controls.test.ts +68 -0
  125. package/test/v3-fields.test.ts +71 -0
  126. package/test/v3-gizmo.test.ts +3 -5
  127. package/test/v3-joint-fan.test.ts +210 -0
  128. package/test/v3-keys.test.ts +37 -0
  129. package/test/v3-layout.test.ts +78 -0
  130. package/test/v3-links.test.ts +89 -6
  131. package/test/v3-live.test.ts +99 -0
  132. package/test/v3-material.test.ts +6 -0
  133. package/test/v3-maturity.test.ts +268 -0
  134. package/test/v3-palette-icons.test.ts +22 -0
  135. package/test/v3-parts-wiring.test.ts +3 -1
  136. package/test/v3-preview.test.ts +42 -0
  137. package/test/v3-properties.test.ts +4 -2
  138. package/test/v3-scene-canvas.test.ts +85 -0
  139. package/test/v3-stage-model.test.ts +25 -0
  140. package/test/v3-theme.test.ts +16 -25
  141. package/test/v3-thumbnail.test.ts +29 -0
  142. package/test/v3-verdict.test.ts +12 -6
  143. package/test/v3-view.test.ts +20 -0
  144. package/translations/en.json +90 -6
  145. package/translations/ja.json +92 -8
  146. package/translations/ko.json +90 -6
  147. package/translations/ms.json +92 -8
  148. package/translations/zh.json +92 -8
@@ -0,0 +1,84 @@
1
+ /*
2
+ * The values a part or the figure is edited by, grouped as V2's inspector showed them: position, rotation and size
3
+ * as one row of three cells each (X · Y · Z read together), and the shape's own measures one to a line under their
4
+ * own title. Kept apart from the editor so the grouping is tested without a browser.
5
+ */
6
+ export interface FieldLike {
7
+ id: string
8
+ unit: string
9
+ role?: string
10
+ value: number
11
+ }
12
+
13
+ export type Axis3 = 'x' | 'y' | 'z'
14
+ const AXES: readonly Axis3[] = ['x', 'y', 'z']
15
+
16
+ export type FieldGroup =
17
+ | { kind: 'vector'; name: 'position' | 'rotation' | 'size'; cells: Record<Axis3, FieldLike> }
18
+ | { kind: 'single'; name: string; field: FieldLike }
19
+
20
+ /** The name a single value goes by: its id without the part, `dimension.` and corner numbers read as words. */
21
+ function nameOf(rest: string): string {
22
+ return rest.replace(/^dimension\./, '')
23
+ }
24
+
25
+ /**
26
+ * The part's own values (`<part>.…`), or with no part the figure's (`size.x` …). Position, rotation and size come
27
+ * first, each only when all three of its axes are there; the rest follow in the order they are declared.
28
+ */
29
+ export function groupFields(fields: readonly FieldLike[], part?: string): { vectors: FieldGroup[]; singles: FieldGroup[] } {
30
+ const prefix = part ? `${part}.` : ''
31
+ const own = fields.filter(f => (part ? f.id.startsWith(prefix) : !f.id.includes('.') || /^size\.[xyz]$/.test(f.id)))
32
+ const byId = new Map(own.map(f => [f.id, f]))
33
+ const used = new Set<string>()
34
+ const vectors: FieldGroup[] = []
35
+ for (const name of ['position', 'rotation', 'size'] as const) {
36
+ const ids = AXES.map(axis => `${prefix}${name}.${axis}`)
37
+ if (!ids.every(id => byId.has(id))) continue
38
+ ids.forEach(id => used.add(id))
39
+ vectors.push({ kind: 'vector', name, cells: { x: byId.get(ids[0]!)!, y: byId.get(ids[1]!)!, z: byId.get(ids[2]!)! } })
40
+ }
41
+ const singles: FieldGroup[] = own.filter(f => !used.has(f.id)).map(field => ({ kind: 'single', name: nameOf(field.id.slice(prefix.length)), field }))
42
+ return { vectors, singles }
43
+ }
44
+
45
+ /**
46
+ * The name a person reads for a body. A repeated body's id is its path through the repeats (`["roller.repeat",0,
47
+ * "roller.value"]`), which the list showed as it was; it reads as the repeat's name and the member's place counted
48
+ * from 1 -- `roller 1`, and `rack 2·3` inside two repeats. Any other id is its own name.
49
+ */
50
+ export function partLabelOf(id: string): string {
51
+ if (!id.startsWith('[')) return id
52
+ let path: unknown
53
+ try {
54
+ path = JSON.parse(id)
55
+ } catch {
56
+ return id
57
+ }
58
+ if (!Array.isArray(path) || typeof path[0] !== 'string') return id
59
+ const places = path.filter((step): step is number => typeof step === 'number').map(n => n + 1)
60
+ return places.length ? `${path[0].replace(/\.repeat$/, '')} ${places.join('·')}` : id
61
+ }
62
+
63
+ /**
64
+ * The repeat a body is a copy of: its name, how many copies it makes, and which copy this is (counted from 1). Nothing
65
+ * for a body that is not a copy.
66
+ */
67
+ export function repeatOf(id: string, geometry: readonly unknown[] | undefined): { name: string; count: number; index: number } | null {
68
+ const path = pathOf(id)
69
+ if (!path) return null
70
+ const count = (geometry ?? []).filter(g => pathOf((g as { id?: string }).id ?? '')?.[0] === path[0]).length
71
+ const index = path.find((step): step is number => typeof step === 'number')
72
+ return { name: String(path[0]).replace(/\.repeat$/, ''), count, index: (index ?? 0) + 1 }
73
+ }
74
+
75
+ function pathOf(id: string): unknown[] | null {
76
+ if (!id.startsWith('[')) return null
77
+ try {
78
+ const path = JSON.parse(id)
79
+ return Array.isArray(path) && typeof path[0] === 'string' ? path : null
80
+ } catch {
81
+ return null
82
+ }
83
+ }
84
+
@@ -0,0 +1,41 @@
1
+ import '@material/web/icon/icon.js'
2
+
3
+ import { html, nothing, type ReactiveController, type ReactiveControllerHost } from 'lit'
4
+ import { i18next } from '@operato/i18n'
5
+
6
+ /**
7
+ * V2's help toggles: a small 「?」 beside a title opens the longer explanation under it. Explanations left open on every
8
+ * section made the side read as clutter (user, 2026-09-24); the author opens the one they want. What was opened stays
9
+ * open while the author moves between parts.
10
+ */
11
+ export class HelpToggles implements ReactiveController {
12
+ private opened = new Set<string>()
13
+
14
+ constructor(private readonly host: ReactiveControllerHost) {
15
+ host.addController(this)
16
+ }
17
+
18
+ hostConnected() {}
19
+
20
+ isOpen(key: string) {
21
+ return this.opened.has(key)
22
+ }
23
+
24
+ toggle(key: string) {
25
+ if (this.opened.has(key)) this.opened.delete(key)
26
+ else this.opened.add(key)
27
+ this.host.requestUpdate()
28
+ }
29
+
30
+ /** The 「?」: it takes no line of its own, it sits in the title's spare width. */
31
+ button(key: string) {
32
+ const shown = this.opened.has(key)
33
+ return html`<button help aria-label=${i18next.t('figure.label.help')} aria-expanded=${shown ? 'true' : 'false'} @click=${() => this.toggle(key)}>
34
+ <md-icon>${shown ? 'help' : 'help_outline'}</md-icon>
35
+ </button>`
36
+ }
37
+
38
+ hint(key: string) {
39
+ return this.opened.has(key) ? html`<p hint>${i18next.t(key)}</p>` : nothing
40
+ }
41
+ }
@@ -0,0 +1,292 @@
1
+ import { compileV3Asset, compileV3Graph, v3WorldBoxesOf, v3WritersOf } from '@hatiolab/figure-model'
2
+ import type { V3Asset } from '@hatiolab/figure-model'
3
+
4
+ import type { V3RenderPart } from './v3-renderer.js'
5
+
6
+ /*
7
+ * Where the selected part turns, and how far -- a translucent fan on the editing board (V2's joint gizmo, inventory
8
+ * 3.38). An axis name says nothing about which way a part swings; on a six-axis arm the author went to the preview and
9
+ * pushed each slider to its end to find out. The fan lies in the plane the part turns in, at the pivot, open from the
10
+ * control's lowest value to its highest; the edge at the value the figure holds now is where the part is.
11
+ *
12
+ * It is found by evaluating, not by reading the graph's shape: the frame that carries the part is sampled across the
13
+ * control's range, and a point at a fixed distance from the pivot traces the fan. So the direction is right whatever
14
+ * the slope of the control, and a joint inside other joints is drawn where its parents put it.
15
+ *
16
+ * Two ways a part turns are read:
17
+ * - a motion made in the modeller (`add-motion`): `<part>.motion`, the frame it moves is its seat, the pivot the
18
+ * seat's origin;
19
+ * - a joint the converter wrote from V2 (`<joint>.motion` · `<joint>.chain`): the part is composed on the joint's
20
+ * chain, the pivot is the joint's origin (`<joint>.frameOnParent`).
21
+ * A slide has no fan. Nothing is drawn when the turn cannot be found -- a fan in some guessed plane would point the
22
+ * wrong way.
23
+ */
24
+
25
+ /** The fan's name on the board. It is not a part: not in the list, not saved, not picked. */
26
+ export const JOINT_FAN = '__joint-fan'
27
+
28
+ /** How thick the fan is: thin enough not to hide the part, thick enough to be seen edge-on. */
29
+ const THICKNESS = 6
30
+ /** About one segment every six degrees, so a full turn reads as a circle. */
31
+ const STEP_DEG = 6
32
+
33
+ type Pose = { r: number[][]; t: number[] }
34
+ interface Node {
35
+ id: string
36
+ op: string
37
+ args: string[]
38
+ outputs: Record<string, string>
39
+ params?: Record<string, string>
40
+ }
41
+
42
+ const apply = (p: Pose, v: number[]) => [0, 1, 2].map(i => p.r[i]![0]! * v[0]! + p.r[i]![1]! * v[1]! + p.r[i]![2]! * v[2]! + p.t[i]!)
43
+ const sub = (a: number[], b: number[]) => a.map((v, i) => v - b[i]!)
44
+ const dot = (a: number[], b: number[]) => a.reduce((s, v, i) => s + v * b[i]!, 0)
45
+ const cross = (a: number[], b: number[]) => [a[1]! * b[2]! - a[2]! * b[1]!, a[2]! * b[0]! - a[0]! * b[2]!, a[0]! * b[1]! - a[1]! * b[0]!]
46
+ const norm = (a: number[]) => {
47
+ const l = Math.hypot(...a)
48
+ return l > 1e-9 ? a.map(v => v / l) : null
49
+ }
50
+
51
+ /** How far outside a part's box a pivot may lie and still be on the part: the kernel's contact skin. */
52
+ const SEAM = 1
53
+ /** An origin this deep in a part's box (a share of its half-measure, on every axis) is housed by the part. */
54
+ const HOUSED = 0.5
55
+ const AXIS_NAMES = ['x', 'y', 'z'] as const
56
+ type WorldBox = { centre: Record<'x' | 'y' | 'z', number>; extent: Record<'x' | 'y' | 'z', number> }
57
+
58
+ interface Turn {
59
+ /** The node whose value carries the moving frame into the figure's frame. */
60
+ carrier: string
61
+ /** The pivot, in the moving frame. */
62
+ pivot: number[]
63
+ /** The axis, in the moving frame. */
64
+ axis: number[]
65
+ /** Where the part sits, in the moving frame -- which way the fan's reference edge points. */
66
+ toward: number[]
67
+ /** The part the joint turns -- the fan is sized from it, so a joint shows one fan whichever part is selected. */
68
+ turnedPart: string
69
+ /** The state input that turns it. */
70
+ control: { id: string; min: number; max: number }
71
+ }
72
+
73
+ function turnOf(asset: V3Asset, part: string, values: Record<string, unknown>, box: WorldBox | undefined): Turn | null {
74
+ const m = asset.document.model as unknown as { nodes: Node[]; constants: { id: string; value: number }[]; inputs: { id: string; role?: string; min?: number; max?: number }[] }
75
+ const node = (id: string) => m.nodes.find(n => n.id === id)
76
+ const constant = (ref: string) => m.constants.find(c => c.id === ref)?.value
77
+ const valueOf = (n: Node | undefined) => (n ? values[Object.values(n.outputs)[0]!] : undefined) as Pose | undefined
78
+ const controlOf = (ref: string | undefined, seen = new Set<string>()): Turn['control'] | null => {
79
+ if (!ref || seen.has(ref)) return null
80
+ seen.add(ref)
81
+ const input = m.inputs.find(i => i.id === ref && i.role === 'state')
82
+ if (input) return { id: input.id, min: input.min ?? 0, max: input.max ?? 0 }
83
+ const writer = m.nodes.find(n => Object.values(n.outputs).includes(ref))
84
+ for (const arg of writer?.args ?? []) {
85
+ const found = controlOf(arg, seen)
86
+ if (found) return found
87
+ }
88
+ return null
89
+ }
90
+ const axisOf = (motion: Node) => {
91
+ const axis = motion.args.slice(0, 3).map(constant)
92
+ return axis.every(v => typeof v === 'number') ? norm(axis as number[]) : null
93
+ }
94
+
95
+ /* Made in the modeller: the part's own motion turns its seat about the seat's origin. */
96
+ const own = node(`${part}.motion`)
97
+ if (own) {
98
+ if (own.op !== 'axis-turn@1') return null
99
+ const axis = axisOf(own)
100
+ const control = controlOf(own.args[3])
101
+ const at = valueOf(node(`${part}.pose`))
102
+ if (!axis || !control || !at) return null
103
+ return { carrier: own.id, pivot: [0, 0, 0], axis, toward: at.t, control, turnedPart: part }
104
+ }
105
+
106
+ /*
107
+ Written by the converter: a joint (`<joint>.motion`, its origin `<joint>.frameOnParent`) carries its own part and
108
+ everything fastened below it, through its chain (the first joint of a chain has only its attachment). A part shows
109
+ the joint it is to that part of the mechanism (user, 2026-09-24: 「elbow-housing itself is a axis」 · 「please check
110
+ all that logic and concept」), tested in this order on the joints as they stand now:
111
+ 1. housing -- a joint's origin lies deep inside the part, which is centred on it: the shoulder housing shows the
112
+ shoulder and the elbow housing the elbow, though another joint carries them;
113
+ 2. turned -- the joint that carries the part, when its axis passes through the part: through its middle for a
114
+ body that rolls on it (the wrist roll, the tool flange, the gripper body), through its root for a link (the
115
+ upper arm, the forearm);
116
+ 3. otherwise none: the pedestal (the base's axis runs through it but turns it not), the fingers.
117
+ An axis passing through a part is not enough by itself: the wrist roll's axis runs the forearm's length and the
118
+ tool roll's through the wrist pitch, and neither turns them (an earlier rule said so, and was wrong).
119
+ */
120
+ if (!box) return null
121
+ const carrierOf = (joint: string) => (node(`${joint}.chain`) ? `${joint}.chain` : `${joint}.attach`)
122
+ const mine = node(`${part}.moved`)?.args.find(arg => /\.(chain|attach)\.value$/.test(arg))?.replace(/\.value$/, '')
123
+ const centre = AXIS_NAMES.map(a => box.centre[a])
124
+ const half = AXIS_NAMES.map(a => box.extent[a] / 2)
125
+ const joints = m.nodes
126
+ .filter(n => n.op === 'axis-turn@1' && n.id.endsWith('.motion') && node(n.id.replace(/\.motion$/, '.frameOnParent')))
127
+ .map(motion => {
128
+ const joint = motion.id.replace(/\.motion$/, '')
129
+ const carrier = carrierOf(joint)
130
+ const frame = valueOf(node(carrier))
131
+ const origin = valueOf(node(`${joint}.frameOnParent`))
132
+ const local = axisOf(motion)
133
+ if (!frame || !origin || !local) return null
134
+ const at = apply(frame, origin.t)
135
+ const along = norm([0, 1, 2].map(i => dot(frame.r[i]!, local)))
136
+ if (!along) return null
137
+ const off = sub(centre, at)
138
+ return {
139
+ joint,
140
+ motion,
141
+ carrier,
142
+ origin,
143
+ /* How deep the origin lies: 0 at the centre, 1 on the box's face, on the axis where it is shallowest. */
144
+ depth: Math.max(...[0, 1, 2].map(i => Math.abs(off[i]!) / (half[i]! + SEAM))),
145
+ fromCentre: Math.hypot(...off),
146
+ through: lineMeetsBox(at, along, centre, half)
147
+ }
148
+ })
149
+ .filter((c): c is NonNullable<typeof c> => !!c)
150
+ const housing = joints.filter(c => c.depth <= HOUSED).sort((a, b) => a.fromCentre - b.fromCentre)[0]
151
+ const carrying = joints.find(c => c.carrier === mine)
152
+ const chosen = housing ?? (carrying && carrying.through ? carrying : undefined)
153
+ if (!chosen) return null
154
+ const axis = axisOf(chosen.motion)
155
+ const control = controlOf(chosen.motion.args[3])
156
+ if (!axis || !control) return null
157
+ /* The reference edge points at what the joint turns: of the parts it carries, the one farthest off its axis. */
158
+ const flatOff = (t: number[]) => {
159
+ const rel = sub(t, chosen.origin.t)
160
+ return Math.hypot(...sub(rel, axis.map(v => v * dot(rel, axis))))
161
+ }
162
+ const turned = m.nodes
163
+ .filter(n => n.id.endsWith('.moved') && n.args.includes(`${chosen.carrier}.value`))
164
+ .map(n => ({ id: n.id.replace(/\.moved$/, ''), t: valueOf(node(n.id.replace(/\.moved$/, '.pose')))?.t }))
165
+ .filter((one): one is { id: string; t: number[] } => !!one.t)
166
+ .sort((a, b) => flatOff(b.t) - flatOff(a.t))[0]
167
+ return { carrier: chosen.carrier, pivot: chosen.origin.t, axis, toward: turned?.t ?? chosen.origin.t, control, turnedPart: turned?.id ?? part }
168
+ }
169
+
170
+ /**
171
+ * The fan for the part, as a body to stand on the board, or null when the part does not turn. `values` are the
172
+ * inputs the figure is drawn with now (a pose in hand included), so the fan follows the arm as it is posed.
173
+ */
174
+ export function jointFanOf(asset: V3Asset, part: string, values: Record<string, number> = {}): V3RenderPart | null {
175
+ if (!part) return null
176
+ let graph: ReturnType<typeof compileV3Graph>
177
+ try {
178
+ graph = compileV3Graph(asset.document.model as never)
179
+ } catch {
180
+ return null
181
+ }
182
+ const inputs = { ...asset.designInputs, ...asset.stateDefaults, ...values }
183
+ const at = (over: Record<string, number>) => graph.evaluate({ ...inputs, ...over }).values as Record<string, unknown>
184
+ let now: Record<string, unknown>
185
+ try {
186
+ now = at({})
187
+ } catch {
188
+ return null
189
+ }
190
+ let box: WorldBox | undefined
191
+ try {
192
+ box = v3WorldBoxesOf(
193
+ compileV3Asset({ ...asset, designInputs: { ...asset.designInputs, ...pick(values, asset.designInputs) }, stateDefaults: { ...asset.stateDefaults, ...pick(values, asset.stateDefaults) } })
194
+ .evaluate()
195
+ .geometry.filter(g => g.id === part),
196
+ v3WritersOf(asset)
197
+ )[0]
198
+ } catch {
199
+ box = undefined
200
+ }
201
+ const turn = turnOf(asset, part, now, box)
202
+ if (!turn) return null
203
+ const carrierRef = Object.values(asset.document.model.nodes.find(n => n.id === turn.carrier)!.outputs)[0]!
204
+ const carrierNow = now[carrierRef] as Pose | undefined
205
+ if (!carrierNow) return null
206
+
207
+ /* The reference edge points from the pivot toward the part, in the plane it turns in; any perpendicular if it sits on the axis. */
208
+ const rel = sub(turn.toward, turn.pivot)
209
+ const inPlane = sub(rel, turn.axis.map(v => v * dot(rel, turn.axis)))
210
+ const d = norm(inPlane) ?? norm(cross(turn.axis, Math.abs(turn.axis[0]!) < 0.9 ? [1, 0, 0] : [0, 1, 0]))!
211
+ /*
212
+ Sized from what the joint turns, as V2 sized it from the joint's child: selecting the elbow housing or the forearm
213
+ shows the elbow's one fan, not a smaller one for the smaller part (user, 2026-09-24).
214
+ */
215
+ const radius = Math.max(60, Math.round(Math.max(largestMeasureOf(asset, turn.turnedPart, inputs), Math.hypot(...inPlane)) * 0.6))
216
+ const edge = turn.pivot.map((v, i) => v + d[i]! * radius)
217
+
218
+ const { min, max } = turn.control
219
+ if (!(max > min)) return null
220
+ const steps = Math.max(2, Math.ceil((max - min) / STEP_DEG))
221
+ const pivot = apply(carrierNow, turn.pivot)
222
+ const traced: number[][] = []
223
+ try {
224
+ for (let i = 0; i <= steps; i++) {
225
+ const s = min + ((max - min) * i) / steps
226
+ traced.push(apply(at({ [turn.control.id]: s })[carrierRef] as Pose, edge))
227
+ }
228
+ } catch {
229
+ return null
230
+ }
231
+
232
+ /* The fan's own frame: x toward the first traced point, y along the axis as it stands now, z completing them. */
233
+ const axisNow = norm([0, 1, 2].map(i => dot(carrierNow.r[i]!, turn.axis)))
234
+ const first = norm(sub(traced[0]!, pivot))
235
+ if (!axisNow || !first) return null
236
+ const zAxis = cross(first, axisNow)
237
+ const r = [0, 1, 2].map(i => [first[i]!, axisNow[i]!, zAxis[i]!])
238
+ const local = (p: number[]) => {
239
+ const v = sub(p, pivot)
240
+ return { x: dot(v, first), y: dot(v, zAxis) }
241
+ }
242
+ const full = max - min >= 359.999
243
+ const profile = [...(full ? [] : [{ x: 0, y: 0 }]), ...traced.map(local)].map(p => ({ x: Math.round(p.x * 1000) / 1000, y: Math.round(p.y * 1000) / 1000 }))
244
+
245
+ return {
246
+ id: JOINT_FAN,
247
+ shape: { provider: 'polygon', dimensions: { height: THICKNESS }, profile },
248
+ pose: { t: pivot, r },
249
+ material: { token: '__joint-fan', transparent: true },
250
+ /** Which control turns it: the joint the fan shows. */
251
+ turns: turn.control.id
252
+ } as unknown as V3RenderPart
253
+ }
254
+
255
+ /** The values in hand that belong to a record's keys (design inputs, or state defaults). */
256
+ function pick(values: Record<string, number>, of: Record<string, number>): Record<string, number> {
257
+ return Object.fromEntries(Object.entries(values).filter(([id]) => id in of))
258
+ }
259
+
260
+ /** A part's largest own measure (a box side, a diameter, a length) at the inputs given; 0 when it cannot be read. */
261
+ function largestMeasureOf(asset: V3Asset, part: string, inputs: Record<string, number>): number {
262
+ try {
263
+ const body = compileV3Asset({ ...asset, designInputs: pick(inputs, asset.designInputs), stateDefaults: pick(inputs, asset.stateDefaults) })
264
+ .evaluate()
265
+ .geometry.find(g => g.id === part) as { shape?: { dimensions?: Record<string, number> } } | undefined
266
+ const dims = Object.entries(body?.shape?.dimensions ?? {}).map(([k, v]) => (k.startsWith('radius') ? v * 2 : v))
267
+ return dims.length ? Math.max(...dims) : 0
268
+ } catch {
269
+ return 0
270
+ }
271
+ }
272
+
273
+ /** Whether the line through `at` along `along` passes through the box (centre, half-measures), within the skin. */
274
+ function lineMeetsBox(at: number[], along: number[], centre: number[], half: number[]): boolean {
275
+ let lo = -Infinity,
276
+ hi = Infinity
277
+ for (let i = 0; i < 3; i++) {
278
+ const min = centre[i]! - half[i]! - SEAM,
279
+ max = centre[i]! + half[i]! + SEAM
280
+ if (Math.abs(along[i]!) < 1e-12) {
281
+ if (at[i]! < min || at[i]! > max) return false
282
+ continue
283
+ }
284
+ const t1 = (min - at[i]!) / along[i]!,
285
+ t2 = (max - at[i]!) / along[i]!
286
+ lo = Math.max(lo, Math.min(t1, t2))
287
+ hi = Math.min(hi, Math.max(t1, t2))
288
+ if (lo > hi) return false
289
+ }
290
+ return true
291
+ }
292
+
@@ -0,0 +1,34 @@
1
+ /*
2
+ * The modeller's keys (V2 had undo and redo on Ctrl+Z / Ctrl+Y; these add what a person reaches for next). Read apart
3
+ * from the element so the rule is tested without a browser.
4
+ *
5
+ * Typing is never taken: a key pressed in a field belongs to the field -- Backspace in a name box deletes a letter,
6
+ * not the part.
7
+ */
8
+ export type KeyAction = 'undo' | 'redo' | 'duplicate' | 'remove' | 'deselect'
9
+
10
+ export interface KeyLike {
11
+ key: string
12
+ ctrlKey?: boolean
13
+ metaKey?: boolean
14
+ shiftKey?: boolean
15
+ altKey?: boolean
16
+ /** The element the key was pressed in -- the first of the event's composed path. */
17
+ target?: { tagName?: string; isContentEditable?: boolean } | null
18
+ }
19
+
20
+ const TYPING = new Set(['INPUT', 'SELECT', 'TEXTAREA'])
21
+
22
+ export function keyActionOf(e: KeyLike, selected: string): KeyAction | null {
23
+ if (e.target && (TYPING.has(String(e.target.tagName).toUpperCase()) || e.target.isContentEditable)) return null
24
+ if (e.altKey) return null
25
+ const mod = !!(e.ctrlKey || e.metaKey)
26
+ const key = e.key.toLowerCase()
27
+ if (mod && key === 'z') return e.shiftKey ? 'redo' : 'undo'
28
+ if (mod && key === 'y') return 'redo'
29
+ if (!selected) return null
30
+ if (mod && key === 'd') return 'duplicate'
31
+ if (!mod && (key === 'delete' || key === 'backspace')) return 'remove'
32
+ if (!mod && key === 'escape') return 'deselect'
33
+ return null
34
+ }
@@ -1,4 +1,5 @@
1
- import { v3AttachmentsOf, v3BindingsOf } from '@hatiolab/figure-model'
1
+ import { compileV3Graph, defaultInstanceSizeRange, foldInstanceSizeRange, v3AttachmentsOf, v3BindingsOf, v3InstanceSizeLimitsOf } from '@hatiolab/figure-model'
2
+ import type { V3SizeRange } from '@hatiolab/figure-model'
2
3
  import type { V3Asset, V3BindingPlace, V3Face } from '@hatiolab/figure-model'
3
4
 
4
5
  /*
@@ -21,7 +22,8 @@ const modelOf = (asset: V3Asset) =>
21
22
  /** What one dimension of a part is worked out from. */
22
23
  export type Follow =
23
24
  | { of: 'own' }
24
- | { of: 'instance'; axis: Axis }
25
+ /** `times` 1 is the instance size itself; any other is a share of it -- V2's proportional rule. */
26
+ | { of: 'instance'; axis: Axis; times: number }
25
27
  | { of: 'part'; part: string; axis: Axis }
26
28
  | { of: 'input'; input: string }
27
29
  /** Worked out some other way (the converter writes expressions) — shown, not offered as a choice. */
@@ -34,15 +36,23 @@ export type Follow =
34
36
  */
35
37
  export function followOf(asset: V3Asset, part: string, axis: Axis): Follow {
36
38
  const m = modelOf(asset)
39
+ /*
40
+ A link routes the dimension through `<part>.dimension.<axis>`; before any link the shape reads its measure directly,
41
+ and that may already be worked out -- the converter writes V2's rules there (ROBOT_ARM's flange filled what was left).
42
+ */
37
43
  const node = m.nodes.find(n => n.id === `${part}.dimension.${axis}`)
38
- if (!node) return { of: 'own' }
44
+ const start = node ? node.args[0] : m.nodes.find(n => n.id === `${part}.shape`)?.args[AXES.indexOf(axis)]
45
+ if (!start || m.constants.some(c => c.id === start)) return { of: 'own' }
39
46
  const writerOf = (ref: string) => m.nodes.find(n => Object.values(n.outputs).includes(ref))
40
47
  const seen = new Set<string>()
48
+ const constant = (ref: string) => m.constants.find(c => c.id === ref)
49
+ /* The constant factors met on the way back: a share of the instance size is `size × share`. */
50
+ let times = 1
41
51
  const walk = (ref: string): Follow => {
42
52
  if (seen.has(ref)) return { of: 'expression' }
43
53
  seen.add(ref)
44
54
  const instance = /^size\.([xyz])$/.exec(ref)
45
- if (instance) return { of: 'instance', axis: instance[1] as Axis }
55
+ if (instance) return { of: 'instance', axis: instance[1] as Axis, times: Math.round(times * 1e6) / 1e6 }
46
56
  const other = /^(.+)\.dimension\.([xyz])\.value$/.exec(ref)
47
57
  if (other) return { of: 'part', part: other[1]!, axis: other[2] as Axis }
48
58
  const input = m.inputs.find(i => i.id === ref)
@@ -50,10 +60,19 @@ export function followOf(asset: V3Asset, part: string, axis: Axis): Follow {
50
60
  const writer = writerOf(ref)
51
61
  if (!writer) return { of: 'expression' }
52
62
  /* Follow the one argument that is not a constant factor. */
53
- const onward = writer.args.filter(arg => !m.constants.some(c => c.id === arg))
54
- return onward.length === 1 ? walk(onward[0]!) : { of: 'expression' }
63
+ const onward = writer.args.filter(arg => !constant(arg))
64
+ if (onward.length !== 1) return { of: 'expression' }
65
+ /*
66
+ Only a product is a share of its source. A sum with a number is not (V2's 「fill what is left」 is the size plus a
67
+ negative gap -- ROBOT_ARM's tool flange), nor anything else; those read as worked out some other way.
68
+ */
69
+ const factors = writer.args.filter(arg => constant(arg)).map(arg => constant(arg)!.value)
70
+ if (writer.op === 'mul@1') times *= factors.reduce((a, b) => a * b, 1)
71
+ else if (writer.op === 'div@1' && writer.args[0] === onward[0] && factors.length === 1 && factors[0] !== 0) times /= factors[0]!
72
+ else if (factors.some(v => v !== 0) || !['mul@1', 'div@1', 'add@1', 'sub@1'].includes(writer.op)) return { of: 'expression' }
73
+ return walk(onward[0]!)
55
74
  }
56
- return walk(node.args[0]!)
75
+ return walk(start)
57
76
  }
58
77
 
59
78
  /** Whether the asset declares an instance size on an axis — only then can a dimension follow it. */
@@ -66,8 +85,34 @@ export function fastenedTo(asset: V3Asset, part: string): string | null {
66
85
 
67
86
  /** Whether a dimension of this part can follow something — box-shaped, whose three sizes are X, Y and Z. */
68
87
  export function canFollow(asset: V3Asset, part: string): boolean {
88
+ return followableAxes(asset, part).length > 0
89
+ }
90
+
91
+ /**
92
+ * The axes whose size can follow something, by the shape's own measures: a box's X · Y · Z; a cylinder's radius (read
93
+ * as X) and length (Y) -- its shape takes those two, so it has no Z of its own (V2's sizing cards had the cylinder's
94
+ * two as well).
95
+ */
96
+ export function followableAxes(asset: V3Asset, part: string): Axis[] {
69
97
  const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
70
- return !!shape && (shape.op === 'box-shape@1' || shape.op === 'rounded-box@1')
98
+ if (!shape) return []
99
+ if (shape.op === 'box-shape@1' || shape.op === 'rounded-box@1') return [...AXES]
100
+ if (shape.op === 'cylinder-shape@1') return ['x', 'y']
101
+ return []
102
+ }
103
+
104
+ /** What a part's own measure on an axis is now (a box's side, a cylinder's radius or length), at the design as it stands. */
105
+ export function dimensionNow(asset: V3Asset, part: string, axis: Axis): number | undefined {
106
+ const m = modelOf(asset)
107
+ const ref = m.nodes.find(n => n.id === `${part}.shape`)?.args[AXES.indexOf(axis)]
108
+ if (!ref) return undefined
109
+ try {
110
+ const values = compileV3Graph(asset.document.model as never).evaluate({ ...asset.designInputs, ...asset.stateDefaults }).values as Record<string, number>
111
+ const value = values[ref]
112
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
113
+ } catch {
114
+ return undefined
115
+ }
71
116
  }
72
117
 
73
118
  /** Whether `attach` can take this part: a box, or a cylinder (figure-model measures its faces since 0.1.47). */
@@ -89,6 +134,17 @@ export function positionsWorkedOut(asset: V3Asset, part: string): Axis[] {
89
134
  return AXES.filter((_axis, i) => !own(pose.args[i]!) && !constant(pose.args[i]!))
90
135
  }
91
136
 
137
+ /**
138
+ * Whether the part's position on an axis is a share of the instance size (`scale-position`): `size.<axis> × share`.
139
+ * Such a position is worked out too, but it already grows with the figure, so it is not offered again.
140
+ */
141
+ export function positionIsShare(asset: V3Asset, part: string, axis: Axis): boolean {
142
+ const m = modelOf(asset)
143
+ const ref = m.nodes.find(n => n.id === `${part}.pose`)?.args[AXES.indexOf(axis)]
144
+ const writer = ref ? m.nodes.find(n => Object.values(n.outputs).includes(ref)) : undefined
145
+ return !!writer && writer.op === 'mul@1' && writer.args[0] === `size.${axis}` && m.constants.some(c => c.id === writer.args[1])
146
+ }
147
+
92
148
  /** How a part moves, if it does: slide or turn, along which axis, driven by which control. */
93
149
  export interface Motion {
94
150
  kind: 'slide' | 'turn'
@@ -127,7 +183,7 @@ export function choiceOf(follow: Follow): string {
127
183
  case 'own':
128
184
  return 'own'
129
185
  case 'instance':
130
- return `instance:${follow.axis}`
186
+ return follow.times === 1 ? `instance:${follow.axis}` : `share:${follow.axis}`
131
187
  case 'part':
132
188
  return `part:${follow.part}:${follow.axis}`
133
189
  case 'input':
@@ -168,3 +224,39 @@ export const holdsThings = (asset: V3Asset) => (asset.document.capabilities.name
168
224
 
169
225
  /** What `accepts` a person typed: names split on commas, trimmed, blanks and repeats dropped. */
170
226
  export const acceptsOf = (typed: string) => [...new Set(typed.split(',').map(one => one.trim()).filter(Boolean))]
227
+
228
+ /**
229
+ * The sizes a board may give this figure: the range it declares now, and the range measured afresh -- the default
230
+ * (1 mm to ten times the authored size) narrowed only where the figure cannot follow, by the converter's own functions.
231
+ * A range measured when a part filled what the others left (ROBOT_ARM: Y from 1,854 mm) stays narrow after the part is
232
+ * made to grow with the figure; measuring again gives the figure its whole range back. Nothing when no size is declared.
233
+ */
234
+ export function instanceRangeOf(asset: V3Asset): { declared: V3SizeRange; measured: V3SizeRange } | undefined {
235
+ const inputs = modelOf(asset).inputs
236
+ const declared = {} as V3SizeRange
237
+ const authored = {} as Record<Axis, number>
238
+ for (const axis of AXES) {
239
+ const input = inputs.find(i => i.id === `size.${axis}` && i.role === 'design')
240
+ const at = asset.designInputs[`size.${axis}`]
241
+ if (!input || typeof at !== 'number') return undefined
242
+ declared[axis] = { min: input.min ?? 0, max: input.max ?? Infinity }
243
+ authored[axis] = at
244
+ }
245
+ const start = defaultInstanceSizeRange(authored)
246
+ const measured = foldInstanceSizeRange(start, v3InstanceSizeLimitsOf(asset, start)).range
247
+ return { declared, measured }
248
+ }
249
+
250
+ /** Whether measuring again would change the declared range (to a micrometre). */
251
+ export function rangeDiffers(a: V3SizeRange, b: V3SizeRange): boolean {
252
+ return AXES.some(axis => Math.abs(a[axis].min - b[axis].min) > 1e-3 || Math.abs(a[axis].max - b[axis].max) > 1e-3)
253
+ }
254
+
255
+ /**
256
+ * Whether the figure is a mechanism -- a part turns about a joint. Such a figure should grow by one ratio (chief
257
+ * architect's ruling 2026-09-24); the modeller proposes it, the author declares it.
258
+ */
259
+ export function turnsAboutAJoint(asset: V3Asset): boolean {
260
+ return modelOf(asset).nodes.some(n => n.op === 'axis-turn@1')
261
+ }
262
+