@things-factory/figure-ui 10.1.45 → 10.1.47

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 (138) 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 +150 -79
  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 +221 -263
  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 +532 -223
  18. package/client/modeller/v3-fields.ts +62 -0
  19. package/client/modeller/v3-help.ts +41 -0
  20. package/client/modeller/v3-keys.ts +34 -0
  21. package/client/modeller/v3-links.ts +126 -10
  22. package/client/modeller/v3-live.ts +79 -0
  23. package/client/modeller/v3-maturity.ts +415 -0
  24. package/client/modeller/v3-palette.ts +45 -0
  25. package/client/modeller/v3-preview-model.ts +54 -0
  26. package/client/modeller/v3-stage-model.ts +159 -0
  27. package/client/pages/figure-list-page.ts +4 -4
  28. package/client/pages/figure-v3-modeller-page.ts +66 -134
  29. package/dist-client/modeller/angle-dial.d.ts +32 -0
  30. package/dist-client/modeller/angle-dial.js +41 -0
  31. package/dist-client/modeller/angle-dial.js.map +1 -0
  32. package/dist-client/modeller/figure-settings.d.ts +39 -0
  33. package/dist-client/modeller/figure-settings.js +317 -0
  34. package/dist-client/modeller/figure-settings.js.map +1 -0
  35. package/dist-client/modeller/figure-v3-capability.d.ts +22 -0
  36. package/dist-client/modeller/figure-v3-capability.js +141 -0
  37. package/dist-client/modeller/figure-v3-capability.js.map +1 -0
  38. package/dist-client/modeller/figure-v3-clips.d.ts +31 -0
  39. package/dist-client/modeller/figure-v3-clips.js +268 -0
  40. package/dist-client/modeller/figure-v3-clips.js.map +1 -0
  41. package/dist-client/modeller/figure-v3-controls.d.ts +36 -0
  42. package/dist-client/modeller/figure-v3-controls.js +226 -0
  43. package/dist-client/modeller/figure-v3-controls.js.map +1 -0
  44. package/dist-client/modeller/figure-v3-links.d.ts +10 -2
  45. package/dist-client/modeller/figure-v3-links.js +134 -79
  46. package/dist-client/modeller/figure-v3-links.js.map +1 -1
  47. package/dist-client/modeller/figure-v3-material.d.ts +5 -2
  48. package/dist-client/modeller/figure-v3-material.js +109 -35
  49. package/dist-client/modeller/figure-v3-material.js.map +1 -1
  50. package/dist-client/modeller/figure-v3-parts.d.ts +10 -0
  51. package/dist-client/modeller/figure-v3-parts.js +88 -3
  52. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  53. package/dist-client/modeller/figure-v3-preview.d.ts +34 -0
  54. package/dist-client/modeller/figure-v3-preview.js +322 -0
  55. package/dist-client/modeller/figure-v3-preview.js.map +1 -0
  56. package/dist-client/modeller/figure-v3-properties.d.ts +2 -2
  57. package/dist-client/modeller/figure-v3-properties.js +29 -57
  58. package/dist-client/modeller/figure-v3-properties.js.map +1 -1
  59. package/dist-client/modeller/figure-v3-verdict.d.ts +31 -17
  60. package/dist-client/modeller/figure-v3-verdict.js +579 -59
  61. package/dist-client/modeller/figure-v3-verdict.js.map +1 -1
  62. package/dist-client/modeller/figure-view.d.ts +118 -0
  63. package/dist-client/modeller/figure-view.js +78 -0
  64. package/dist-client/modeller/figure-view.js.map +1 -0
  65. package/dist-client/modeller/side-panel-styles.d.ts +23 -0
  66. package/dist-client/modeller/side-panel-styles.js +326 -0
  67. package/dist-client/modeller/side-panel-styles.js.map +1 -0
  68. package/dist-client/modeller/v3-canvas.d.ts +29 -37
  69. package/dist-client/modeller/v3-canvas.js +188 -260
  70. package/dist-client/modeller/v3-canvas.js.map +1 -1
  71. package/dist-client/modeller/v3-clips.d.ts +39 -0
  72. package/dist-client/modeller/v3-clips.js +58 -0
  73. package/dist-client/modeller/v3-clips.js.map +1 -0
  74. package/dist-client/modeller/v3-controls.d.ts +28 -0
  75. package/dist-client/modeller/v3-controls.js +28 -0
  76. package/dist-client/modeller/v3-controls.js.map +1 -0
  77. package/dist-client/modeller/v3-editor.d.ts +58 -1
  78. package/dist-client/modeller/v3-editor.js +556 -218
  79. package/dist-client/modeller/v3-editor.js.map +1 -1
  80. package/dist-client/modeller/v3-fields.d.ts +30 -0
  81. package/dist-client/modeller/v3-fields.js +46 -0
  82. package/dist-client/modeller/v3-fields.js.map +1 -0
  83. package/dist-client/modeller/v3-help.d.ts +18 -0
  84. package/dist-client/modeller/v3-help.js +37 -0
  85. package/dist-client/modeller/v3-help.js.map +1 -0
  86. package/dist-client/modeller/v3-keys.d.ts +14 -0
  87. package/dist-client/modeller/v3-keys.js +23 -0
  88. package/dist-client/modeller/v3-keys.js.map +1 -0
  89. package/dist-client/modeller/v3-links.d.ts +44 -2
  90. package/dist-client/modeller/v3-links.js +125 -8
  91. package/dist-client/modeller/v3-links.js.map +1 -1
  92. package/dist-client/modeller/v3-live.d.ts +31 -0
  93. package/dist-client/modeller/v3-live.js +58 -0
  94. package/dist-client/modeller/v3-live.js.map +1 -0
  95. package/dist-client/modeller/v3-maturity.d.ts +120 -0
  96. package/dist-client/modeller/v3-maturity.js +317 -0
  97. package/dist-client/modeller/v3-maturity.js.map +1 -0
  98. package/dist-client/modeller/v3-palette.d.ts +7 -0
  99. package/dist-client/modeller/v3-palette.js +42 -0
  100. package/dist-client/modeller/v3-palette.js.map +1 -0
  101. package/dist-client/modeller/v3-preview-model.d.ts +32 -0
  102. package/dist-client/modeller/v3-preview-model.js +48 -0
  103. package/dist-client/modeller/v3-preview-model.js.map +1 -0
  104. package/dist-client/modeller/v3-stage-model.d.ts +31 -0
  105. package/dist-client/modeller/v3-stage-model.js +121 -0
  106. package/dist-client/modeller/v3-stage-model.js.map +1 -0
  107. package/dist-client/pages/figure-list-page.js +4 -4
  108. package/dist-client/pages/figure-list-page.js.map +1 -1
  109. package/dist-client/pages/figure-v3-modeller-page.d.ts +2 -6
  110. package/dist-client/pages/figure-v3-modeller-page.js +64 -136
  111. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  112. package/dist-client/tsconfig.chk.tsbuildinfo +1 -0
  113. package/dist-client/tsconfig.scratch-fm.tsbuildinfo +1 -0
  114. package/dist-client/tsconfig.tsbuildinfo +1 -1
  115. package/package.json +6 -6
  116. package/test/v3-clips.test.ts +70 -0
  117. package/test/v3-controls.test.ts +68 -0
  118. package/test/v3-fields.test.ts +62 -0
  119. package/test/v3-gizmo.test.ts +3 -5
  120. package/test/v3-keys.test.ts +37 -0
  121. package/test/v3-layout.test.ts +78 -0
  122. package/test/v3-links.test.ts +118 -7
  123. package/test/v3-live.test.ts +99 -0
  124. package/test/v3-material.test.ts +6 -0
  125. package/test/v3-maturity.test.ts +268 -0
  126. package/test/v3-parts-wiring.test.ts +3 -1
  127. package/test/v3-preview.test.ts +42 -0
  128. package/test/v3-properties.test.ts +4 -2
  129. package/test/v3-scene-canvas.test.ts +85 -0
  130. package/test/v3-stage-model.test.ts +25 -0
  131. package/test/v3-theme.test.ts +16 -25
  132. package/test/v3-verdict.test.ts +12 -6
  133. package/test/v3-view.test.ts +20 -0
  134. package/translations/en.json +98 -5
  135. package/translations/ja.json +100 -7
  136. package/translations/ko.json +98 -5
  137. package/translations/ms.json +100 -7
  138. package/translations/zh.json +100 -7
@@ -0,0 +1,62 @@
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
+
@@ -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,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,5 +1,6 @@
1
- import { v3AttachmentsOf } from '@hatiolab/figure-model'
2
- import type { V3Asset } from '@hatiolab/figure-model'
1
+ import { compileV3Graph, defaultInstanceSizeRange, foldInstanceSizeRange, v3AttachmentsOf, v3BindingsOf, v3InstanceSizeLimitsOf } from '@hatiolab/figure-model'
2
+ import type { V3SizeRange } from '@hatiolab/figure-model'
3
+ import type { V3Asset, V3BindingPlace, V3Face } from '@hatiolab/figure-model'
3
4
 
4
5
  /*
5
6
  * What a V3 part is tied to — the readers behind the parts' size response, fastening and motion sections (parity rows
@@ -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':
@@ -136,3 +192,63 @@ export function choiceOf(follow: Follow): string {
136
192
  return 'expression'
137
193
  }
138
194
  }
195
+
196
+ /** The faces a place can sit on for this part: a box's six, a rounded box's top and bottom, none for a round body. */
197
+ export function placeFacesOf(asset: V3Asset, part: string): V3Face[] {
198
+ const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
199
+ if (shape?.op === 'box-shape@1') return ['left', 'right', 'bottom', 'top', 'back', 'front']
200
+ if (shape?.op === 'rounded-box@1') return ['bottom', 'top']
201
+ return []
202
+ }
203
+
204
+ /** The places things are held that sit on this part. */
205
+ export function placesOn(asset: V3Asset, part: string): V3BindingPlace[] {
206
+ return v3BindingsOf(asset).filter(one => one.part === part)
207
+ }
208
+
209
+ /** An id for a new place on this part, not yet taken: `<part>-<role>`, then `-2`, `-3`. */
210
+ export function placeIdFor(asset: V3Asset, part: string, role: 'seat' | 'grip'): string {
211
+ const taken = new Set(asset.document.capabilities.bindings.map(b => b.id))
212
+ const base = `${part}-${role}`
213
+ if (!taken.has(base)) return base
214
+ let n = 2
215
+ while (taken.has(`${base}-${n}`)) n++
216
+ return `${base}-${n}`
217
+ }
218
+
219
+ /**
220
+ * Whether a board will hand this figure anything: a place is a slot only on a figure that declares it holds things
221
+ * (`Capacity`, or `FlowNode`, which includes it). Without it the places are declared and nothing arrives.
222
+ */
223
+ export const holdsThings = (asset: V3Asset) => (asset.document.capabilities.names ?? []).some(n => n === 'Capacity' || n === 'FlowNode')
224
+
225
+ /** What `accepts` a person typed: names split on commas, trimmed, blanks and repeats dropped. */
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
+
@@ -0,0 +1,79 @@
1
+ import { compileV3Asset, editV3Part } from '@hatiolab/figure-model'
2
+ import type { V3Asset, V3PartEdit } from '@hatiolab/figure-model'
3
+
4
+ import { drawableV3Parts, type V3RenderPart } from './v3-renderer.js'
5
+
6
+ /*
7
+ * The figure while a value is in hand: evaluated with the values being typed or dragged, drawn at once and written
8
+ * nowhere. The panels committed on release only, so the canvas stood still while a number was typed, a spinner held,
9
+ * a label scrubbed, a slider or the glow dragged (user, 2026-09-24: "make the editor move naturally while the value
10
+ * changes"). The value in hand shows here; the release is the one step of the history.
11
+ *
12
+ * What is in hand is either input values -- a design value (a size, a position) or a pose -- or one part edit (the
13
+ * glow of a material). A design value is a change of the figure's design, so it is shown by evaluating a copy with
14
+ * that value; a pose is a state override, as the preview gives it.
15
+ */
16
+
17
+ export interface LiveValues {
18
+ /** Input values in hand, design or state, by input id. */
19
+ values: Record<string, number>
20
+ /** A part edit in hand, not yet committed. */
21
+ edit?: V3PartEdit
22
+ }
23
+
24
+ export const NOTHING_LIVE: LiveValues = { values: {} }
25
+
26
+ /** Known inputs with a number, split by what they are. An id the figure does not declare is dropped. */
27
+ export function liveOverridesOf(asset: V3Asset, values: Record<string, number>): { design: Record<string, number>; state: Record<string, number> } {
28
+ const roles = new Map(asset.document.model.inputs.map(i => [i.id, i.role]))
29
+ const design: Record<string, number> = {},
30
+ state: Record<string, number> = {}
31
+ for (const [id, value] of Object.entries(values)) {
32
+ if (!Number.isFinite(value)) continue
33
+ const role = roles.get(id)
34
+ if (role === 'state') state[id] = value
35
+ else if (role !== undefined) design[id] = value
36
+ }
37
+ return { design, state }
38
+ }
39
+
40
+ export function isLive(live: LiveValues): boolean {
41
+ return !!live.edit || Object.keys(live.values).length > 0
42
+ }
43
+
44
+ /**
45
+ * The bodies to draw with what is in hand, or null when nothing is -- then the evaluation as it stands is drawn.
46
+ *
47
+ * Also null when the figure cannot take the value in hand (a size of 0 on the way to typing 0.5, a glow the material
48
+ * refuses): the canvas keeps the figure as it stands until the release, and the release reports why through the same
49
+ * refusal every committed edit gets. A preview never invents a figure the commit would not make.
50
+ */
51
+ export function livePartsOf(asset: V3Asset, live: LiveValues): V3RenderPart[] | null {
52
+ if (!isLive(live)) return null
53
+ const { design, state } = liveOverridesOf(asset, live.values)
54
+ try {
55
+ let next = live.edit ? editV3Part(asset, live.edit) : asset
56
+ if (Object.keys(design).length) next = { ...next, designInputs: { ...next.designInputs, ...design } }
57
+ return drawableV3Parts(compileV3Asset(next).evaluate(state).geometry)
58
+ } catch {
59
+ return null
60
+ }
61
+ }
62
+
63
+ /**
64
+ * How much one pixel of a label drag moves a value: a degree for an angle, a hundredth for a ratio, and for a length a
65
+ * step that suits its size -- a millimetre under 100, ten over 1,000 -- so a 12 m wall and a 5 mm pin both scrub at a
66
+ * usable pace. Shift makes it ten times finer.
67
+ */
68
+ export function scrubStep(unit: string, value: number, fine = false): number {
69
+ const base =
70
+ unit === 'deg' ? 1 : unit === 'ratio' ? 0.01 : Math.max(1, Math.pow(10, Math.floor(Math.log10(Math.max(1, Math.abs(value)))) - 2))
71
+ return fine ? base / 10 : base
72
+ }
73
+
74
+ /** A scrubbed value on the step's grid, so a drag does not leave 1203.0000000002 behind. */
75
+ export function scrubbed(start: number, pixels: number, step: number): number {
76
+ const value = start + pixels * step
77
+ const digits = Math.max(0, -Math.floor(Math.log10(step)))
78
+ return Number(value.toFixed(digits))
79
+ }