@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.
- package/client/modeller/angle-dial.ts +70 -0
- package/client/modeller/figure-settings.ts +328 -0
- package/client/modeller/figure-v3-capability.ts +137 -0
- package/client/modeller/figure-v3-clips.ts +253 -0
- package/client/modeller/figure-v3-controls.ts +212 -0
- package/client/modeller/figure-v3-links.ts +150 -79
- package/client/modeller/figure-v3-material.ts +108 -35
- package/client/modeller/figure-v3-parts.ts +96 -3
- package/client/modeller/figure-v3-preview.ts +326 -0
- package/client/modeller/figure-v3-properties.ts +29 -57
- package/client/modeller/figure-v3-verdict.ts +598 -60
- package/client/modeller/figure-view.ts +152 -0
- package/client/modeller/side-panel-styles.ts +326 -0
- package/client/modeller/v3-canvas.ts +221 -263
- package/client/modeller/v3-clips.ts +89 -0
- package/client/modeller/v3-controls.ts +54 -0
- package/client/modeller/v3-editor.ts +532 -223
- package/client/modeller/v3-fields.ts +62 -0
- package/client/modeller/v3-help.ts +41 -0
- package/client/modeller/v3-keys.ts +34 -0
- package/client/modeller/v3-links.ts +126 -10
- package/client/modeller/v3-live.ts +79 -0
- package/client/modeller/v3-maturity.ts +415 -0
- package/client/modeller/v3-palette.ts +45 -0
- package/client/modeller/v3-preview-model.ts +54 -0
- package/client/modeller/v3-stage-model.ts +159 -0
- package/client/pages/figure-list-page.ts +4 -4
- package/client/pages/figure-v3-modeller-page.ts +66 -134
- package/dist-client/modeller/angle-dial.d.ts +32 -0
- package/dist-client/modeller/angle-dial.js +41 -0
- package/dist-client/modeller/angle-dial.js.map +1 -0
- package/dist-client/modeller/figure-settings.d.ts +39 -0
- package/dist-client/modeller/figure-settings.js +317 -0
- package/dist-client/modeller/figure-settings.js.map +1 -0
- package/dist-client/modeller/figure-v3-capability.d.ts +22 -0
- package/dist-client/modeller/figure-v3-capability.js +141 -0
- package/dist-client/modeller/figure-v3-capability.js.map +1 -0
- package/dist-client/modeller/figure-v3-clips.d.ts +31 -0
- package/dist-client/modeller/figure-v3-clips.js +268 -0
- package/dist-client/modeller/figure-v3-clips.js.map +1 -0
- package/dist-client/modeller/figure-v3-controls.d.ts +36 -0
- package/dist-client/modeller/figure-v3-controls.js +226 -0
- package/dist-client/modeller/figure-v3-controls.js.map +1 -0
- package/dist-client/modeller/figure-v3-links.d.ts +10 -2
- package/dist-client/modeller/figure-v3-links.js +134 -79
- package/dist-client/modeller/figure-v3-links.js.map +1 -1
- package/dist-client/modeller/figure-v3-material.d.ts +5 -2
- package/dist-client/modeller/figure-v3-material.js +109 -35
- package/dist-client/modeller/figure-v3-material.js.map +1 -1
- package/dist-client/modeller/figure-v3-parts.d.ts +10 -0
- package/dist-client/modeller/figure-v3-parts.js +88 -3
- package/dist-client/modeller/figure-v3-parts.js.map +1 -1
- package/dist-client/modeller/figure-v3-preview.d.ts +34 -0
- package/dist-client/modeller/figure-v3-preview.js +322 -0
- package/dist-client/modeller/figure-v3-preview.js.map +1 -0
- package/dist-client/modeller/figure-v3-properties.d.ts +2 -2
- package/dist-client/modeller/figure-v3-properties.js +29 -57
- package/dist-client/modeller/figure-v3-properties.js.map +1 -1
- package/dist-client/modeller/figure-v3-verdict.d.ts +31 -17
- package/dist-client/modeller/figure-v3-verdict.js +579 -59
- package/dist-client/modeller/figure-v3-verdict.js.map +1 -1
- package/dist-client/modeller/figure-view.d.ts +118 -0
- package/dist-client/modeller/figure-view.js +78 -0
- package/dist-client/modeller/figure-view.js.map +1 -0
- package/dist-client/modeller/side-panel-styles.d.ts +23 -0
- package/dist-client/modeller/side-panel-styles.js +326 -0
- package/dist-client/modeller/side-panel-styles.js.map +1 -0
- package/dist-client/modeller/v3-canvas.d.ts +29 -37
- package/dist-client/modeller/v3-canvas.js +188 -260
- package/dist-client/modeller/v3-canvas.js.map +1 -1
- package/dist-client/modeller/v3-clips.d.ts +39 -0
- package/dist-client/modeller/v3-clips.js +58 -0
- package/dist-client/modeller/v3-clips.js.map +1 -0
- package/dist-client/modeller/v3-controls.d.ts +28 -0
- package/dist-client/modeller/v3-controls.js +28 -0
- package/dist-client/modeller/v3-controls.js.map +1 -0
- package/dist-client/modeller/v3-editor.d.ts +58 -1
- package/dist-client/modeller/v3-editor.js +556 -218
- package/dist-client/modeller/v3-editor.js.map +1 -1
- package/dist-client/modeller/v3-fields.d.ts +30 -0
- package/dist-client/modeller/v3-fields.js +46 -0
- package/dist-client/modeller/v3-fields.js.map +1 -0
- package/dist-client/modeller/v3-help.d.ts +18 -0
- package/dist-client/modeller/v3-help.js +37 -0
- package/dist-client/modeller/v3-help.js.map +1 -0
- package/dist-client/modeller/v3-keys.d.ts +14 -0
- package/dist-client/modeller/v3-keys.js +23 -0
- package/dist-client/modeller/v3-keys.js.map +1 -0
- package/dist-client/modeller/v3-links.d.ts +44 -2
- package/dist-client/modeller/v3-links.js +125 -8
- package/dist-client/modeller/v3-links.js.map +1 -1
- package/dist-client/modeller/v3-live.d.ts +31 -0
- package/dist-client/modeller/v3-live.js +58 -0
- package/dist-client/modeller/v3-live.js.map +1 -0
- package/dist-client/modeller/v3-maturity.d.ts +120 -0
- package/dist-client/modeller/v3-maturity.js +317 -0
- package/dist-client/modeller/v3-maturity.js.map +1 -0
- package/dist-client/modeller/v3-palette.d.ts +7 -0
- package/dist-client/modeller/v3-palette.js +42 -0
- package/dist-client/modeller/v3-palette.js.map +1 -0
- package/dist-client/modeller/v3-preview-model.d.ts +32 -0
- package/dist-client/modeller/v3-preview-model.js +48 -0
- package/dist-client/modeller/v3-preview-model.js.map +1 -0
- package/dist-client/modeller/v3-stage-model.d.ts +31 -0
- package/dist-client/modeller/v3-stage-model.js +121 -0
- package/dist-client/modeller/v3-stage-model.js.map +1 -0
- package/dist-client/pages/figure-list-page.js +4 -4
- package/dist-client/pages/figure-list-page.js.map +1 -1
- package/dist-client/pages/figure-v3-modeller-page.d.ts +2 -6
- package/dist-client/pages/figure-v3-modeller-page.js +64 -136
- package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
- package/dist-client/tsconfig.chk.tsbuildinfo +1 -0
- package/dist-client/tsconfig.scratch-fm.tsbuildinfo +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/test/v3-clips.test.ts +70 -0
- package/test/v3-controls.test.ts +68 -0
- package/test/v3-fields.test.ts +62 -0
- package/test/v3-gizmo.test.ts +3 -5
- package/test/v3-keys.test.ts +37 -0
- package/test/v3-layout.test.ts +78 -0
- package/test/v3-links.test.ts +118 -7
- package/test/v3-live.test.ts +99 -0
- package/test/v3-material.test.ts +6 -0
- package/test/v3-maturity.test.ts +268 -0
- package/test/v3-parts-wiring.test.ts +3 -1
- package/test/v3-preview.test.ts +42 -0
- package/test/v3-properties.test.ts +4 -2
- package/test/v3-scene-canvas.test.ts +85 -0
- package/test/v3-stage-model.test.ts +25 -0
- package/test/v3-theme.test.ts +16 -25
- package/test/v3-verdict.test.ts +12 -6
- package/test/v3-view.test.ts +20 -0
- package/translations/en.json +98 -5
- package/translations/ja.json +100 -7
- package/translations/ko.json +98 -5
- package/translations/ms.json +100 -7
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 => !
|
|
54
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|