@things-factory/figure-ui 10.1.44 → 10.1.46

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 (129) hide show
  1. package/client/modeller/figure-v3-links.ts +394 -0
  2. package/client/modeller/modeller-route.ts +6 -21
  3. package/client/modeller/v3-editor.ts +47 -5
  4. package/client/modeller/v3-gizmo.ts +13 -2
  5. package/client/modeller/v3-links.ts +170 -0
  6. package/client/pages/figure-v3-modeller-page.ts +2 -5
  7. package/client/route.ts +2 -6
  8. package/dist-client/modeller/figure-v3-links.d.ts +47 -0
  9. package/dist-client/modeller/figure-v3-links.js +379 -0
  10. package/dist-client/modeller/figure-v3-links.js.map +1 -0
  11. package/dist-client/modeller/modeller-route.d.ts +4 -11
  12. package/dist-client/modeller/modeller-route.js +5 -22
  13. package/dist-client/modeller/modeller-route.js.map +1 -1
  14. package/dist-client/modeller/v3-editor.d.ts +5 -0
  15. package/dist-client/modeller/v3-editor.js +49 -4
  16. package/dist-client/modeller/v3-editor.js.map +1 -1
  17. package/dist-client/modeller/v3-gizmo.js +11 -1
  18. package/dist-client/modeller/v3-gizmo.js.map +1 -1
  19. package/dist-client/modeller/v3-links.d.ts +67 -0
  20. package/dist-client/modeller/v3-links.js +141 -0
  21. package/dist-client/modeller/v3-links.js.map +1 -0
  22. package/dist-client/pages/figure-v3-modeller-page.d.ts +2 -5
  23. package/dist-client/pages/figure-v3-modeller-page.js +2 -5
  24. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  25. package/dist-client/route.d.ts +1 -1
  26. package/dist-client/route.js +2 -5
  27. package/dist-client/route.js.map +1 -1
  28. package/dist-client/tsconfig.scratch-fm.tsbuildinfo +1 -0
  29. package/dist-client/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +4 -4
  31. package/test/ai-proposal-contract.test.ts +0 -88
  32. package/test/figure-source.test.ts +0 -102
  33. package/test/i18n-prefix-guard.test.ts +0 -24
  34. package/test/modeller-route.test.ts +14 -25
  35. package/test/part-edits.test.ts +0 -7
  36. package/test/scene-budget.test.ts +0 -7
  37. package/test/v3-gizmo.test.ts +10 -0
  38. package/test/v3-links.test.ts +163 -0
  39. package/things-factory.config.js +2 -5
  40. package/translations/en.json +21 -1
  41. package/translations/ja.json +21 -1
  42. package/translations/ko.json +21 -1
  43. package/translations/ms.json +21 -1
  44. package/translations/zh.json +21 -1
  45. package/client/modeller/angle-dial.ts +0 -70
  46. package/client/modeller/base-box.ts +0 -175
  47. package/client/modeller/figure-animations.ts +0 -1249
  48. package/client/modeller/figure-canvas.ts +0 -567
  49. package/client/modeller/figure-capabilities.ts +0 -379
  50. package/client/modeller/figure-inspector.ts +0 -2208
  51. package/client/modeller/figure-parts.ts +0 -479
  52. package/client/modeller/figure-preview.ts +0 -598
  53. package/client/modeller/figure-report.ts +0 -966
  54. package/client/modeller/figure-settings.ts +0 -328
  55. package/client/modeller/figure-side.ts +0 -272
  56. package/client/modeller/figure-thumbnail.ts +0 -123
  57. package/client/modeller/figure-view.ts +0 -152
  58. package/client/modeller/joint-edits.ts +0 -262
  59. package/client/modeller/joint-gizmo.ts +0 -180
  60. package/client/modeller/maturity.ts +0 -288
  61. package/client/modeller/new-figure-entry.ts +0 -4
  62. package/client/modeller/pose-defaults.ts +0 -67
  63. package/client/pages/figure-modeller-page.ts +0 -2082
  64. package/dist-client/modeller/angle-dial.d.ts +0 -32
  65. package/dist-client/modeller/angle-dial.js +0 -41
  66. package/dist-client/modeller/angle-dial.js.map +0 -1
  67. package/dist-client/modeller/base-box.d.ts +0 -71
  68. package/dist-client/modeller/base-box.js +0 -110
  69. package/dist-client/modeller/base-box.js.map +0 -1
  70. package/dist-client/modeller/figure-animations.d.ts +0 -159
  71. package/dist-client/modeller/figure-animations.js +0 -1132
  72. package/dist-client/modeller/figure-animations.js.map +0 -1
  73. package/dist-client/modeller/figure-canvas.d.ts +0 -150
  74. package/dist-client/modeller/figure-canvas.js +0 -506
  75. package/dist-client/modeller/figure-canvas.js.map +0 -1
  76. package/dist-client/modeller/figure-capabilities.d.ts +0 -42
  77. package/dist-client/modeller/figure-capabilities.js +0 -349
  78. package/dist-client/modeller/figure-capabilities.js.map +0 -1
  79. package/dist-client/modeller/figure-inspector.d.ts +0 -329
  80. package/dist-client/modeller/figure-inspector.js +0 -2095
  81. package/dist-client/modeller/figure-inspector.js.map +0 -1
  82. package/dist-client/modeller/figure-parts.d.ts +0 -64
  83. package/dist-client/modeller/figure-parts.js +0 -478
  84. package/dist-client/modeller/figure-parts.js.map +0 -1
  85. package/dist-client/modeller/figure-preview.d.ts +0 -138
  86. package/dist-client/modeller/figure-preview.js +0 -549
  87. package/dist-client/modeller/figure-preview.js.map +0 -1
  88. package/dist-client/modeller/figure-report.d.ts +0 -165
  89. package/dist-client/modeller/figure-report.js +0 -952
  90. package/dist-client/modeller/figure-report.js.map +0 -1
  91. package/dist-client/modeller/figure-settings.d.ts +0 -39
  92. package/dist-client/modeller/figure-settings.js +0 -317
  93. package/dist-client/modeller/figure-settings.js.map +0 -1
  94. package/dist-client/modeller/figure-side.d.ts +0 -78
  95. package/dist-client/modeller/figure-side.js +0 -317
  96. package/dist-client/modeller/figure-side.js.map +0 -1
  97. package/dist-client/modeller/figure-thumbnail.d.ts +0 -3
  98. package/dist-client/modeller/figure-thumbnail.js +0 -95
  99. package/dist-client/modeller/figure-thumbnail.js.map +0 -1
  100. package/dist-client/modeller/figure-view.d.ts +0 -118
  101. package/dist-client/modeller/figure-view.js +0 -78
  102. package/dist-client/modeller/figure-view.js.map +0 -1
  103. package/dist-client/modeller/joint-edits.d.ts +0 -49
  104. package/dist-client/modeller/joint-edits.js +0 -223
  105. package/dist-client/modeller/joint-edits.js.map +0 -1
  106. package/dist-client/modeller/joint-gizmo.d.ts +0 -34
  107. package/dist-client/modeller/joint-gizmo.js +0 -158
  108. package/dist-client/modeller/joint-gizmo.js.map +0 -1
  109. package/dist-client/modeller/maturity.d.ts +0 -55
  110. package/dist-client/modeller/maturity.js +0 -212
  111. package/dist-client/modeller/maturity.js.map +0 -1
  112. package/dist-client/modeller/new-figure-entry.d.ts +0 -2
  113. package/dist-client/modeller/new-figure-entry.js +0 -5
  114. package/dist-client/modeller/new-figure-entry.js.map +0 -1
  115. package/dist-client/modeller/pose-defaults.d.ts +0 -9
  116. package/dist-client/modeller/pose-defaults.js +0 -35
  117. package/dist-client/modeller/pose-defaults.js.map +0 -1
  118. package/dist-client/pages/figure-modeller-page.d.ts +0 -419
  119. package/dist-client/pages/figure-modeller-page.js +0 -2060
  120. package/dist-client/pages/figure-modeller-page.js.map +0 -1
  121. package/test/angle-dial.test.ts +0 -78
  122. package/test/base-box.test.ts +0 -235
  123. package/test/joint-edits.test.ts +0 -165
  124. package/test/joint-gizmo.test.ts +0 -174
  125. package/test/joint-needle.test.ts +0 -162
  126. package/test/modeller-undo-redo.test.ts +0 -33
  127. package/test/new-figure-entry.test.ts +0 -15
  128. package/test/pose-defaults.test.ts +0 -71
  129. package/test/preview-board-depth.test.ts +0 -27
@@ -0,0 +1,170 @@
1
+ import { v3AttachmentsOf, v3BindingsOf } from '@hatiolab/figure-model'
2
+ import type { V3Asset, V3BindingPlace, V3Face } from '@hatiolab/figure-model'
3
+
4
+ /*
5
+ * What a V3 part is tied to — the readers behind the parts' size response, fastening and motion sections (parity rows
6
+ * ④ ⑤ ⑥). Kept apart from the element so they can be tested without a browser.
7
+ */
8
+
9
+ export type Axis = 'x' | 'y' | 'z'
10
+ export const AXES: readonly Axis[] = ['x', 'y', 'z']
11
+
12
+ interface Node {
13
+ id: string
14
+ op: string
15
+ args: string[]
16
+ outputs: Record<string, string>
17
+ }
18
+ const modelOf = (asset: V3Asset) =>
19
+ asset.document.model as unknown as { nodes: Node[]; inputs: Array<{ id: string; role?: string; unit?: string; min?: number; max?: number }>; constants: Array<{ id: string; value: number }> }
20
+
21
+ /** What one dimension of a part is worked out from. */
22
+ export type Follow =
23
+ | { of: 'own' }
24
+ | { of: 'instance'; axis: Axis }
25
+ | { of: 'part'; part: string; axis: Axis }
26
+ | { of: 'input'; input: string }
27
+ /** Worked out some other way (the converter writes expressions) — shown, not offered as a choice. */
28
+ | { of: 'expression' }
29
+
30
+ /**
31
+ * `link-dimension` routes a part's dimension through `<part>.dimension.<axis>` and points its first argument at the
32
+ * source, directly or through the nodes that scale it. Walking back through those nodes finds the source: the
33
+ * instance size (`size.<axis>`), another part's dimension (`<part>.dimension.<axis>.value`) or a declared input.
34
+ */
35
+ export function followOf(asset: V3Asset, part: string, axis: Axis): Follow {
36
+ const m = modelOf(asset)
37
+ const node = m.nodes.find(n => n.id === `${part}.dimension.${axis}`)
38
+ if (!node) return { of: 'own' }
39
+ const writerOf = (ref: string) => m.nodes.find(n => Object.values(n.outputs).includes(ref))
40
+ const seen = new Set<string>()
41
+ const walk = (ref: string): Follow => {
42
+ if (seen.has(ref)) return { of: 'expression' }
43
+ seen.add(ref)
44
+ const instance = /^size\.([xyz])$/.exec(ref)
45
+ if (instance) return { of: 'instance', axis: instance[1] as Axis }
46
+ const other = /^(.+)\.dimension\.([xyz])\.value$/.exec(ref)
47
+ if (other) return { of: 'part', part: other[1]!, axis: other[2] as Axis }
48
+ const input = m.inputs.find(i => i.id === ref)
49
+ if (input) return ref.startsWith(`${part}.`) ? { of: 'own' } : { of: 'input', input: ref }
50
+ const writer = writerOf(ref)
51
+ if (!writer) return { of: 'expression' }
52
+ /* 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' }
55
+ }
56
+ return walk(node.args[0]!)
57
+ }
58
+
59
+ /** Whether the asset declares an instance size on an axis — only then can a dimension follow it. */
60
+ export const hasInstanceSize = (asset: V3Asset, axis: Axis) => modelOf(asset).inputs.some(i => i.id === `size.${axis}` && i.role === 'design')
61
+
62
+ /** The part this one is fastened to, or `null`. The mounting plane counts as fastened (the model names no part). */
63
+ export function fastenedTo(asset: V3Asset, part: string): string | null {
64
+ return v3AttachmentsOf(asset)[part] ?? null
65
+ }
66
+
67
+ /** Whether a dimension of this part can follow something — box-shaped, whose three sizes are X, Y and Z. */
68
+ export function canFollow(asset: V3Asset, part: string): boolean {
69
+ const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
70
+ return !!shape && (shape.op === 'box-shape@1' || shape.op === 'rounded-box@1')
71
+ }
72
+
73
+ /** Whether `attach` can take this part: a box, or a cylinder (figure-model measures its faces since 0.1.47). */
74
+ export function canFasten(asset: V3Asset, part: string): boolean {
75
+ const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
76
+ return canFollow(asset, part) || shape?.op === 'cylinder-shape@1'
77
+ }
78
+
79
+ /**
80
+ * The axes on which the part's position is worked out from other values rather than being its own number — the
81
+ * converter writes V2's anchor rules that way. `fix-position` makes one its own number where it is now.
82
+ */
83
+ export function positionsWorkedOut(asset: V3Asset, part: string): Axis[] {
84
+ const m = modelOf(asset)
85
+ const pose = m.nodes.find(n => n.id === `${part}.pose`)
86
+ if (!pose || pose.op !== 'rigid@1' || fastenedTo(asset, part)) return []
87
+ const own = (ref: string) => ref.startsWith(`${part}.`) && m.inputs.some(i => i.id === ref && i.role === 'design')
88
+ const constant = (ref: string) => m.constants.some(c => c.id === ref)
89
+ return AXES.filter((_axis, i) => !own(pose.args[i]!) && !constant(pose.args[i]!))
90
+ }
91
+
92
+ /** How a part moves, if it does: slide or turn, along which axis, driven by which control. */
93
+ export interface Motion {
94
+ kind: 'slide' | 'turn'
95
+ axis: Axis | '?'
96
+ control?: { id: string; unit?: string; min?: number; max?: number }
97
+ }
98
+
99
+ export function motionOf(asset: V3Asset, part: string): Motion | undefined {
100
+ const m = modelOf(asset)
101
+ const node = m.nodes.find(n => n.id === `${part}.motion`)
102
+ if (!node) return undefined
103
+ const kind = node.op === 'axis-turn@1' ? 'turn' : 'slide'
104
+ const unit = node.args.slice(0, 3).map(ref => m.constants.find(c => c.id === ref)?.value)
105
+ const axis = AXES[unit.findIndex(v => v !== undefined && v !== 0)] ?? '?'
106
+ /* The quantity is the fourth argument, a state input directly or through the nodes that scale it. */
107
+ const quantity = node.args[3]
108
+ const seen = new Set<string>()
109
+ const control = (ref?: string): Motion['control'] => {
110
+ if (!ref || seen.has(ref)) return undefined
111
+ seen.add(ref)
112
+ const input = m.inputs.find(i => i.id === ref && i.role === 'state')
113
+ if (input) return { id: input.id, unit: input.unit, min: input.min, max: input.max }
114
+ const writer = m.nodes.find(n => Object.values(n.outputs).includes(ref))
115
+ for (const arg of writer?.args ?? []) {
116
+ const found = control(arg)
117
+ if (found) return found
118
+ }
119
+ return undefined
120
+ }
121
+ return { kind, axis, control: control(quantity) }
122
+ }
123
+
124
+ /** The select's value for what a dimension follows now. */
125
+ export function choiceOf(follow: Follow): string {
126
+ switch (follow.of) {
127
+ case 'own':
128
+ return 'own'
129
+ case 'instance':
130
+ return `instance:${follow.axis}`
131
+ case 'part':
132
+ return `part:${follow.part}:${follow.axis}`
133
+ case 'input':
134
+ return `input:${follow.input}`
135
+ case 'expression':
136
+ return 'expression'
137
+ }
138
+ }
139
+
140
+ /** 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. */
141
+ export function placeFacesOf(asset: V3Asset, part: string): V3Face[] {
142
+ const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
143
+ if (shape?.op === 'box-shape@1') return ['left', 'right', 'bottom', 'top', 'back', 'front']
144
+ if (shape?.op === 'rounded-box@1') return ['bottom', 'top']
145
+ return []
146
+ }
147
+
148
+ /** The places things are held that sit on this part. */
149
+ export function placesOn(asset: V3Asset, part: string): V3BindingPlace[] {
150
+ return v3BindingsOf(asset).filter(one => one.part === part)
151
+ }
152
+
153
+ /** An id for a new place on this part, not yet taken: `<part>-<role>`, then `-2`, `-3`. */
154
+ export function placeIdFor(asset: V3Asset, part: string, role: 'seat' | 'grip'): string {
155
+ const taken = new Set(asset.document.capabilities.bindings.map(b => b.id))
156
+ const base = `${part}-${role}`
157
+ if (!taken.has(base)) return base
158
+ let n = 2
159
+ while (taken.has(`${base}-${n}`)) n++
160
+ return `${base}-${n}`
161
+ }
162
+
163
+ /**
164
+ * Whether a board will hand this figure anything: a place is a slot only on a figure that declares it holds things
165
+ * (`Capacity`, or `FlowNode`, which includes it). Without it the places are declared and nothing arrives.
166
+ */
167
+ export const holdsThings = (asset: V3Asset) => (asset.document.capabilities.names ?? []).some(n => n === 'Capacity' || n === 'FlowNode')
168
+
169
+ /** What `accepts` a person typed: names split on commas, trimmed, blanks and repeats dropped. */
170
+ export const acceptsOf = (typed: string) => [...new Set(typed.split(',').map(one => one.trim()).filter(Boolean))]
@@ -16,11 +16,8 @@ import '../modeller/figure-v3-properties.js'
16
16
  /**
17
17
  * The V3 product page has exactly one authoritative workspace for UI and AI input transactions.
18
18
  *
19
- * It answers to `figure-v3-modeller` while the catalogue's assets are still V2. The designer's order switches
20
- * the production data and the consuming paths together (ADR-0087, step 5): until the assets are converted, a
21
- * `figure-modeller` that opened this page would fail on every figure in the catalogue. Having its own route
22
- * is what makes steps 3 and 4 — the conversion on a recoverable copy, and the canvas check — possible at all.
23
- * At step 5 the V2 page goes, and this one takes the `figure-modeller` name and route.
19
+ * The one modeller since ADR-0087 step 5 (2026-09-24): it answers to `figure-modeller`, and to `figure-v3-modeller`
20
+ * as an alias for links made before the switch.
24
21
  */
25
22
  @customElement('figure-v3-modeller-page')
26
23
  export class FigureV3ModellerPage extends PageView {
package/client/route.ts CHANGED
@@ -4,14 +4,10 @@ export default function route(page: string) {
4
4
  import('./pages/figure-list-page')
5
5
  return page
6
6
 
7
- case 'figure-modeller':
8
- import('./pages/figure-modeller-page')
9
- return page
10
-
11
7
  /*
12
- The V3 modeller, under its own name until the catalogue's assets are V3 (ADR-0087, step 5). Both pages
13
- define a custom element, and an element name may be registered once, so they cannot share one.
8
+ One modeller since ADR-0087 step 5. `figure-v3-modeller` is kept as an alias for links made before the switch.
14
9
  */
10
+ case 'figure-modeller':
15
11
  case 'figure-v3-modeller':
16
12
  import('./pages/figure-v3-modeller-page')
17
13
  return page
@@ -0,0 +1,47 @@
1
+ import '@material/web/icon/icon.js';
2
+ import { LitElement, nothing } from 'lit';
3
+ import type { V3Asset } from '@hatiolab/figure-model';
4
+ declare const FigureV3Links_base: (new (...args: any[]) => LitElement) & typeof LitElement;
5
+ /**
6
+ * What a part is tied to — its size response, what it is fastened to, how it moves (parity rows ④ ⑤ ⑥).
7
+ *
8
+ * 「Build Once, Fit Anywhere」 lives here: a dimension that follows the instance size or another part is what lets
9
+ * a figure be placed at any size, and a part fastened to the face it rests on is what keeps it there when that
10
+ * happens. The release gate refuses a figure that falls apart when resized, and tells the author to fasten the
11
+ * parts that come away — this is where they do it. Motion is the V2 joint: a slide or a turn driven by a control.
12
+ *
13
+ * The V2 inspector has these as its dimensions, placement and joint sections, in that order; so does this. Every
14
+ * change is one figure-model authoring command, sent as `v3-command` and run by the editor through the session — one
15
+ * step of the undo history. Choices the command would refuse (a round body fastened, a size following an instance
16
+ * size the figure does not declare) are not offered, so the author is not sent into a refusal.
17
+ */
18
+ export declare class FigureV3Links extends FigureV3Links_base {
19
+ static styles: import("lit").CSSResult;
20
+ /** The asset being edited. */
21
+ asset?: V3Asset;
22
+ /** The selected part. */
23
+ partId: string;
24
+ /** The part's own extent along its axes now, in mm — what a dimension is fixed at when it stops following. */
25
+ dims?: [number, number, number];
26
+ locked: boolean;
27
+ private fasten;
28
+ private motion;
29
+ private place;
30
+ render(): import("lit-html").TemplateResult<1> | typeof nothing;
31
+ /** Where things are held on this part: a seat things rest on, or a grip things are held in (parity row ⑤). */
32
+ private renderPlaces;
33
+ /** The figure holds things: `Capacity` and `Holdable`, the ladder's middle rung. One part edit, one undo step. */
34
+ private declareHolding;
35
+ private renderDimensions;
36
+ private follow;
37
+ /**
38
+ * Positions worked out from other values — V2's anchor rules, as the converter wrote them — each with the way to
39
+ * keep it where it is. This is the way out the release gate names when a resize leaves a part in the air.
40
+ */
41
+ private renderPositions;
42
+ private renderFastening;
43
+ private renderMotion;
44
+ private setKind;
45
+ private command;
46
+ }
47
+ export {};
@@ -0,0 +1,379 @@
1
+ import { __decorate, __metadata } from "tslib";
2
+ import '@material/web/icon/icon.js';
3
+ import { css, html, LitElement, nothing } from 'lit';
4
+ import { customElement, property, state } from 'lit/decorators.js';
5
+ import { i18next, localize } from '@operato/i18n';
6
+ import { V3_FACES } from '@hatiolab/figure-model';
7
+ import { acceptsOf, AXES, canFasten, canFollow, choiceOf, fastenedTo, followOf, hasInstanceSize, holdsThings, motionOf, placeFacesOf, placeIdFor, placesOn, positionsWorkedOut } from './v3-links.js';
8
+ const FACES = Object.keys(V3_FACES);
9
+ /**
10
+ * What a part is tied to — its size response, what it is fastened to, how it moves (parity rows ④ ⑤ ⑥).
11
+ *
12
+ * 「Build Once, Fit Anywhere」 lives here: a dimension that follows the instance size or another part is what lets
13
+ * a figure be placed at any size, and a part fastened to the face it rests on is what keeps it there when that
14
+ * happens. The release gate refuses a figure that falls apart when resized, and tells the author to fasten the
15
+ * parts that come away — this is where they do it. Motion is the V2 joint: a slide or a turn driven by a control.
16
+ *
17
+ * The V2 inspector has these as its dimensions, placement and joint sections, in that order; so does this. Every
18
+ * change is one figure-model authoring command, sent as `v3-command` and run by the editor through the session — one
19
+ * step of the undo history. Choices the command would refuse (a round body fastened, a size following an instance
20
+ * size the figure does not declare) are not offered, so the author is not sent into a refusal.
21
+ */
22
+ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
23
+ constructor() {
24
+ super(...arguments);
25
+ /** The selected part. */
26
+ this.partId = '';
27
+ this.locked = false;
28
+ this.fasten = { face: 'bottom', toPlane: false, target: '', targetFace: 'top', facing: 'meet', gap: 0 };
29
+ this.motion = { kind: 'slide', axis: 'x', id: '', min: 0, max: 500 };
30
+ this.place = { role: 'grip', face: 'top', label: '', accepts: '', capacity: 1 };
31
+ }
32
+ static { this.styles = css `
33
+ :host {
34
+ display: grid;
35
+ gap: 10px;
36
+ }
37
+ h3 {
38
+ font-size: var(--ui-t-body, 13px);
39
+ margin: 12px 0 0;
40
+ }
41
+ div[row] {
42
+ display: grid;
43
+ grid-template-columns: 24px minmax(0, 1fr);
44
+ gap: 6px;
45
+ align-items: center;
46
+ }
47
+ div[form] {
48
+ display: grid;
49
+ grid-template-columns: 96px minmax(0, 1fr);
50
+ gap: 6px;
51
+ align-items: center;
52
+ }
53
+ div[pair] {
54
+ display: flex;
55
+ gap: 6px;
56
+ }
57
+ div[pair] > * {
58
+ flex: 1;
59
+ min-width: 0;
60
+ }
61
+ select,
62
+ input,
63
+ button {
64
+ font: inherit;
65
+ min-width: 0;
66
+ }
67
+ button[act] {
68
+ justify-self: start;
69
+ display: inline-flex;
70
+ align-items: center;
71
+ gap: 4px;
72
+ padding: 4px 10px;
73
+ border: none;
74
+ border-radius: 999em;
75
+ cursor: pointer;
76
+ color: var(--md-sys-color-on-secondary-container);
77
+ background: var(--md-sys-color-secondary-container);
78
+ }
79
+ button[act]:disabled {
80
+ opacity: 0.5;
81
+ cursor: default;
82
+ }
83
+ button[act] md-icon {
84
+ font-size: 16px;
85
+ width: 16px;
86
+ height: 16px;
87
+ }
88
+ small,
89
+ p[hint] {
90
+ margin: 0;
91
+ color: var(--md-sys-color-on-surface-variant);
92
+ }
93
+ b[bound] {
94
+ font-weight: 600;
95
+ }
96
+ label[toggle] {
97
+ display: flex;
98
+ align-items: center;
99
+ gap: 6px;
100
+ }
101
+ `; }
102
+ render() {
103
+ const asset = this.asset;
104
+ if (!asset || !this.partId)
105
+ return nothing;
106
+ return html `
107
+ <h3>${i18next.t('figure.label.dimensions')}</h3>
108
+ ${canFollow(asset, this.partId) ? this.renderDimensions(asset) : html `<p hint>${i18next.t('figure.text.links-need-a-box')}</p>`}
109
+ <h3>${i18next.t('figure.label.placement')}</h3>
110
+ ${this.renderPositions(asset)}
111
+ ${canFasten(asset, this.partId) ? this.renderFastening(asset) : html `<p hint>${i18next.t('figure.text.links-need-a-box')}</p>`}
112
+ <h3>${i18next.t('figure.label.motion')}</h3>
113
+ ${this.renderMotion(asset)}
114
+ <h3>${i18next.t('figure.label.places')}</h3>
115
+ ${placeFacesOf(asset, this.partId).length ? this.renderPlaces(asset) : html `<p hint>${i18next.t('figure.text.places-need-a-box')}</p>`}
116
+ `;
117
+ }
118
+ /** Where things are held on this part: a seat things rest on, or a grip things are held in (parity row ⑤). */
119
+ renderPlaces(asset) {
120
+ const faces = placeFacesOf(asset, this.partId);
121
+ const p = this.place;
122
+ const face = faces.includes(p.face) ? p.face : faces[0];
123
+ const accepts = acceptsOf(p.accepts);
124
+ const capacityOk = Number.isSafeInteger(p.capacity) && p.capacity >= 1;
125
+ const roleName = (role) => i18next.t('figure.label.place-' + role);
126
+ return html `
127
+ ${placesOn(asset, this.partId).map(one => html `<div pair>
128
+ <span>
129
+ <b bound>${one.label}</b>
130
+ <small>${roleName(one.role)} · ${one.face ? i18next.t('figure.label.face-' + one.face) : ''} · ${one.accepts.join(', ')} · ${i18next.t('figure.label.place-holds', { count: one.capacity })}</small>
131
+ </span>
132
+ <button act ?disabled=${this.locked} @click=${() => this.command({ kind: 'remove-binding', id: one.id })}>
133
+ <md-icon>close</md-icon>${i18next.t('figure.button.remove-place')}
134
+ </button>
135
+ </div>`)}
136
+ <p hint>${i18next.t('figure.text.place-explained')}</p>
137
+ ${holdsThings(asset)
138
+ ? nothing
139
+ : html `<p hint>${i18next.t('figure.text.place-needs-holding')}</p>
140
+ <button act ?disabled=${this.locked} @click=${() => this.declareHolding(asset)}>
141
+ <md-icon>inventory_2</md-icon>${i18next.t('figure.button.declare-holding')}
142
+ </button>`}
143
+ <div pair>
144
+ <select @change=${(e) => (this.place = { ...p, role: e.target.value })}>
145
+ <option value="grip" ?selected=${p.role === 'grip'}>${roleName('grip')}</option>
146
+ <option value="seat" ?selected=${p.role === 'seat'}>${roleName('seat')}</option>
147
+ </select>
148
+ <select @change=${(e) => (this.place = { ...p, face: e.target.value })}>
149
+ ${faces.map(one => html `<option value=${one} ?selected=${one === face}>${i18next.t('figure.label.face-' + one)}</option>`)}
150
+ </select>
151
+ </div>
152
+ <div form>
153
+ <span>${i18next.t('figure.label.place-name')}</span>
154
+ <input .value=${p.label} placeholder=${roleName(p.role)} @change=${(e) => (this.place = { ...p, label: e.target.value })} />
155
+ <span>${i18next.t('figure.label.place-accepts')}</span>
156
+ <input .value=${p.accepts} placeholder="BOX, TOTE" @input=${(e) => (this.place = { ...p, accepts: e.target.value })} />
157
+ <span>${i18next.t('figure.label.place-capacity')}</span>
158
+ <input type="number" min="1" step="1" .value=${String(p.capacity)} @change=${(e) => (this.place = { ...p, capacity: e.target.valueAsNumber })} />
159
+ </div>
160
+ ${accepts.length ? nothing : html `<small>${i18next.t('figure.text.place-accepts-needed')}</small>`}
161
+ <button
162
+ act
163
+ ?disabled=${this.locked || !accepts.length || !capacityOk}
164
+ @click=${() => this.command({
165
+ kind: 'add-binding',
166
+ id: placeIdFor(asset, this.partId, p.role),
167
+ label: p.label.trim() || roleName(p.role),
168
+ role: p.role,
169
+ part: this.partId,
170
+ face,
171
+ accepts,
172
+ capacity: p.capacity
173
+ })}
174
+ >
175
+ <md-icon>back_hand</md-icon>${i18next.t('figure.button.add-place')}
176
+ </button>
177
+ `;
178
+ }
179
+ /** The figure holds things: `Capacity` and `Holdable`, the ladder's middle rung. One part edit, one undo step. */
180
+ declareHolding(asset) {
181
+ const names = [...new Set([...(asset.document.capabilities.names ?? []), 'Capacity', 'Holdable'])];
182
+ this.dispatchEvent(new CustomEvent('v3-part-edit', { detail: { edit: { kind: 'set-capabilities', names } }, bubbles: true, composed: true }));
183
+ }
184
+ renderDimensions(asset) {
185
+ const others = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFollow(asset, one));
186
+ return AXES.map(axis => {
187
+ const now = followOf(asset, this.partId, axis);
188
+ const value = choiceOf(now);
189
+ return html `<div row>
190
+ <b>${axis.toUpperCase()}</b>
191
+ <select ?disabled=${this.locked || now.of === 'expression'} @change=${(e) => this.follow(axis, e.target.value, now)}>
192
+ <option value="own" ?selected=${value === 'own'}>${i18next.t('figure.label.follows-nothing')}</option>
193
+ ${AXES.filter(one => hasInstanceSize(asset, one)).map(one => html `<option value=${`instance:${one}`} ?selected=${value === `instance:${one}`}>
194
+ ${i18next.t('figure.label.follows-instance')} · ${one.toUpperCase()}
195
+ </option>`)}
196
+ ${others.flatMap(other => AXES.map(one => html `<option value=${`part:${other}:${one}`} ?selected=${value === `part:${other}:${one}`}>${other} · ${one.toUpperCase()}</option>`))}
197
+ ${now.of === 'expression' || now.of === 'input'
198
+ ? html `<option value=${value} selected>${now.of === 'input' ? now.input : i18next.t('figure.label.follows-expression')}</option>`
199
+ : nothing}
200
+ </select>
201
+ </div>`;
202
+ });
203
+ }
204
+ follow(axis, choice, now) {
205
+ const following = now.of !== 'own';
206
+ if (choice === 'own') {
207
+ if (!following)
208
+ return;
209
+ const mm = Math.max(1, Math.round(this.dims?.[AXES.indexOf(axis)] ?? 100));
210
+ this.command({ kind: 'unlink-dimension', part: this.partId, axis, mm });
211
+ return;
212
+ }
213
+ const [of, a, b] = choice.split(':');
214
+ const source = of === 'instance' ? { of: 'instance', axis: a } : { of: 'part', part: a, axis: b };
215
+ this.command({ kind: 'link-dimension', part: this.partId, axis, source, replace: following });
216
+ }
217
+ /**
218
+ * Positions worked out from other values — V2's anchor rules, as the converter wrote them — each with the way to
219
+ * keep it where it is. This is the way out the release gate names when a resize leaves a part in the air.
220
+ */
221
+ renderPositions(asset) {
222
+ return positionsWorkedOut(asset, this.partId).map(axis => html `<div pair>
223
+ <span>${axis.toUpperCase()} · ${i18next.t('figure.label.position-worked-out')}</span>
224
+ <button act ?disabled=${this.locked} @click=${() => this.command({ kind: 'fix-position', part: this.partId, axis })}>
225
+ <md-icon>push_pin</md-icon>${i18next.t('figure.button.fix-position')}
226
+ </button>
227
+ </div>`);
228
+ }
229
+ renderFastening(asset) {
230
+ const to = fastenedTo(asset, this.partId);
231
+ const f = this.fasten;
232
+ const targets = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFasten(asset, one));
233
+ return html `
234
+ ${to
235
+ ? html `<div pair>
236
+ <span>${i18next.t('figure.label.fastened-to')}: <b bound>${to}</b></span>
237
+ <button act ?disabled=${this.locked} @click=${() => this.command({ kind: 'detach', part: this.partId })}>
238
+ <md-icon>link_off</md-icon>${i18next.t('figure.button.detach')}
239
+ </button>
240
+ </div>`
241
+ : html `<p hint>${i18next.t('figure.text.fastening-explained')}</p>`}
242
+ <div form>
243
+ <span>${i18next.t('figure.label.my-face')}</span>
244
+ <select @change=${(e) => (this.fasten = { ...f, face: e.target.value })}>
245
+ ${FACES.map(face => html `<option value=${face} ?selected=${face === f.face}>${i18next.t('figure.label.face-' + face)}</option>`)}
246
+ </select>
247
+ </div>
248
+ <label toggle>
249
+ <input type="checkbox" .checked=${f.toPlane} @change=${(e) => (this.fasten = { ...f, toPlane: e.target.checked })} />
250
+ ${i18next.t('figure.label.stands-on-plane')}
251
+ </label>
252
+ ${f.toPlane
253
+ ? nothing
254
+ : html `<div form>
255
+ <span>${i18next.t('figure.label.target-part')}</span>
256
+ <select @change=${(e) => (this.fasten = { ...f, target: e.target.value })}>
257
+ <option value="" ?selected=${!f.target}>—</option>
258
+ ${targets.map(one => html `<option value=${one} ?selected=${one === f.target}>${one}</option>`)}
259
+ </select>
260
+ <span>${i18next.t('figure.label.target-face')}</span>
261
+ <select @change=${(e) => (this.fasten = { ...f, targetFace: e.target.value })}>
262
+ ${FACES.map(face => html `<option value=${face} ?selected=${face === f.targetFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
263
+ </select>
264
+ <span>${i18next.t('figure.label.facing-meet')}</span>
265
+ <select @change=${(e) => (this.fasten = { ...f, facing: e.target.value })}>
266
+ <option value="meet" ?selected=${f.facing === 'meet'}>${i18next.t('figure.label.facing-meet')}</option>
267
+ <option value="flush" ?selected=${f.facing === 'flush'}>${i18next.t('figure.label.facing-flush')}</option>
268
+ </select>
269
+ </div>`}
270
+ <div form>
271
+ <span>${i18next.t('figure.label.gap')}</span>
272
+ <input type="number" step="any" .value=${String(f.gap)} @change=${(e) => (this.fasten = { ...f, gap: Number(e.target.value) || 0 })} />
273
+ </div>
274
+ <button
275
+ act
276
+ ?disabled=${this.locked || (!f.toPlane && !f.target)}
277
+ @click=${() => this.command({
278
+ kind: 'attach',
279
+ part: this.partId,
280
+ face: f.face,
281
+ to: f.toPlane ? { plane: 'mounting-plane' } : { part: f.target, face: f.targetFace },
282
+ ...(f.toPlane ? {} : { facing: f.facing }),
283
+ ...(f.gap ? { gap: f.gap } : {}),
284
+ replace: !!to
285
+ })}
286
+ >
287
+ <md-icon>link</md-icon>${i18next.t('figure.button.fasten')}
288
+ </button>
289
+ `;
290
+ }
291
+ renderMotion(asset) {
292
+ const now = motionOf(asset, this.partId);
293
+ if (now) {
294
+ return html `<div pair>
295
+ <span>
296
+ ${i18next.t('figure.label.motion-' + now.kind)} · ${String(now.axis).toUpperCase()}
297
+ ${now.control ? html `<small>${now.control.id} (${now.control.unit}) ${now.control.min} … ${now.control.max}</small>` : nothing}
298
+ </span>
299
+ <button act ?disabled=${this.locked} @click=${() => this.command({ kind: 'remove-motion', part: this.partId })}>
300
+ <md-icon>close</md-icon>${i18next.t('figure.button.remove-motion')}
301
+ </button>
302
+ </div>`;
303
+ }
304
+ const m = this.motion;
305
+ const unit = m.kind === 'turn' ? 'deg' : 'mm';
306
+ return html `
307
+ <p hint>${i18next.t('figure.text.motion-explained')}</p>
308
+ <div pair>
309
+ <select @change=${(e) => this.setKind(e.target.value)}>
310
+ <option value="slide" ?selected=${m.kind === 'slide'}>${i18next.t('figure.label.motion-slide')}</option>
311
+ <option value="turn" ?selected=${m.kind === 'turn'}>${i18next.t('figure.label.motion-turn')}</option>
312
+ </select>
313
+ <select @change=${(e) => (this.motion = { ...m, axis: e.target.value })}>
314
+ ${AXES.map(a => html `<option value=${a} ?selected=${a === m.axis}>${i18next.t('figure.label.motion-axis')} ${a.toUpperCase()}</option>`)}
315
+ </select>
316
+ </div>
317
+ <div form>
318
+ <span>${i18next.t('figure.label.control-id')}</span>
319
+ <input .value=${m.id} placeholder=${`${this.partId}-${m.kind}`} @change=${(e) => (this.motion = { ...m, id: e.target.value.trim() })} />
320
+ <span>${i18next.t('figure.label.control-range')} (${unit})</span>
321
+ <div pair>
322
+ <input type="number" step="any" .value=${String(m.min)} @change=${(e) => (this.motion = { ...m, min: Number(e.target.value) })} />
323
+ <input type="number" step="any" .value=${String(m.max)} @change=${(e) => (this.motion = { ...m, max: Number(e.target.value) })} />
324
+ </div>
325
+ </div>
326
+ <button
327
+ act
328
+ ?disabled=${this.locked || !(m.max > m.min)}
329
+ @click=${() => this.command({
330
+ kind: 'add-motion',
331
+ part: this.partId,
332
+ motion: { kind: m.kind, axis: m.axis },
333
+ state: { id: m.id || `${this.partId}-${m.kind}`, unit, min: m.min, max: m.max, start: m.min }
334
+ })}
335
+ >
336
+ <md-icon>animation</md-icon>${i18next.t('figure.button.add-motion')}
337
+ </button>
338
+ `;
339
+ }
340
+ setKind(kind) {
341
+ this.motion = kind === 'turn' ? { ...this.motion, kind, min: -90, max: 90 } : { ...this.motion, kind, min: 0, max: 500 };
342
+ }
343
+ command(action) {
344
+ this.dispatchEvent(new CustomEvent('v3-command', { detail: { action }, bubbles: true, composed: true }));
345
+ }
346
+ };
347
+ __decorate([
348
+ property({ attribute: false }),
349
+ __metadata("design:type", Object)
350
+ ], FigureV3Links.prototype, "asset", void 0);
351
+ __decorate([
352
+ property(),
353
+ __metadata("design:type", Object)
354
+ ], FigureV3Links.prototype, "partId", void 0);
355
+ __decorate([
356
+ property({ attribute: false }),
357
+ __metadata("design:type", Array)
358
+ ], FigureV3Links.prototype, "dims", void 0);
359
+ __decorate([
360
+ property({ type: Boolean }),
361
+ __metadata("design:type", Object)
362
+ ], FigureV3Links.prototype, "locked", void 0);
363
+ __decorate([
364
+ state(),
365
+ __metadata("design:type", Object)
366
+ ], FigureV3Links.prototype, "fasten", void 0);
367
+ __decorate([
368
+ state(),
369
+ __metadata("design:type", Object)
370
+ ], FigureV3Links.prototype, "motion", void 0);
371
+ __decorate([
372
+ state(),
373
+ __metadata("design:type", Object)
374
+ ], FigureV3Links.prototype, "place", void 0);
375
+ FigureV3Links = __decorate([
376
+ customElement('figure-v3-links')
377
+ ], FigureV3Links);
378
+ export { FigureV3Links };
379
+ //# sourceMappingURL=figure-v3-links.js.map