@things-factory/figure-ui 10.1.223 → 10.1.225

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 (80) hide show
  1. package/client/modeller/canvas.ts +2 -2
  2. package/client/modeller/consumer-capabilities.ts +66 -0
  3. package/client/modeller/data-interface.ts +230 -80
  4. package/client/modeller/editor-styles.ts +3 -2
  5. package/client/modeller/editor.ts +17 -8
  6. package/client/modeller/figure-capability-panel.ts +151 -1
  7. package/client/modeller/figure-clips.ts +1 -1
  8. package/client/modeller/figure-data-panel.ts +509 -199
  9. package/client/modeller/figure-links.ts +130 -12
  10. package/client/modeller/figure-preview.ts +65 -8
  11. package/client/modeller/figure-slot-layout-panel.ts +7 -3
  12. package/client/modeller/links.ts +58 -0
  13. package/client/modeller/product-session.ts +3 -2
  14. package/client/modeller/refusals.ts +12 -4
  15. package/client/modeller/slot-layout-host.ts +76 -5
  16. package/client/pages/figure-modeller-page.ts +2 -2
  17. package/dist-client/modeller/canvas.js +2 -2
  18. package/dist-client/modeller/canvas.js.map +1 -1
  19. package/dist-client/modeller/consumer-capabilities.d.ts +29 -0
  20. package/dist-client/modeller/consumer-capabilities.js +60 -0
  21. package/dist-client/modeller/consumer-capabilities.js.map +1 -0
  22. package/dist-client/modeller/data-interface.d.ts +83 -17
  23. package/dist-client/modeller/data-interface.js +194 -82
  24. package/dist-client/modeller/data-interface.js.map +1 -1
  25. package/dist-client/modeller/editor-styles.js +3 -2
  26. package/dist-client/modeller/editor-styles.js.map +1 -1
  27. package/dist-client/modeller/editor.d.ts +5 -2
  28. package/dist-client/modeller/editor.js +21 -8
  29. package/dist-client/modeller/editor.js.map +1 -1
  30. package/dist-client/modeller/figure-capability-panel.d.ts +12 -0
  31. package/dist-client/modeller/figure-capability-panel.js +129 -1
  32. package/dist-client/modeller/figure-capability-panel.js.map +1 -1
  33. package/dist-client/modeller/figure-clips.js +1 -1
  34. package/dist-client/modeller/figure-clips.js.map +1 -1
  35. package/dist-client/modeller/figure-data-panel.d.ts +57 -13
  36. package/dist-client/modeller/figure-data-panel.js +476 -191
  37. package/dist-client/modeller/figure-data-panel.js.map +1 -1
  38. package/dist-client/modeller/figure-links.d.ts +11 -0
  39. package/dist-client/modeller/figure-links.js +137 -16
  40. package/dist-client/modeller/figure-links.js.map +1 -1
  41. package/dist-client/modeller/figure-preview.d.ts +4 -0
  42. package/dist-client/modeller/figure-preview.js +65 -7
  43. package/dist-client/modeller/figure-preview.js.map +1 -1
  44. package/dist-client/modeller/figure-slot-layout-panel.js +6 -4
  45. package/dist-client/modeller/figure-slot-layout-panel.js.map +1 -1
  46. package/dist-client/modeller/links.d.ts +22 -0
  47. package/dist-client/modeller/links.js +48 -0
  48. package/dist-client/modeller/links.js.map +1 -1
  49. package/dist-client/modeller/product-session.d.ts +4 -2
  50. package/dist-client/modeller/product-session.js +3 -2
  51. package/dist-client/modeller/product-session.js.map +1 -1
  52. package/dist-client/modeller/refusals.js +14 -6
  53. package/dist-client/modeller/refusals.js.map +1 -1
  54. package/dist-client/modeller/slot-layout-host.d.ts +28 -3
  55. package/dist-client/modeller/slot-layout-host.js +68 -6
  56. package/dist-client/modeller/slot-layout-host.js.map +1 -1
  57. package/dist-client/pages/figure-modeller-page.js +2 -2
  58. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  59. package/dist-client/tsconfig.tsbuildinfo +1 -1
  60. package/package.json +8 -8
  61. package/test/ai-proposal.test.ts +2 -2
  62. package/test/browser/data-table-page.js +9 -0
  63. package/test/browser/links.check.mjs +25 -3
  64. package/test/browser/product-page.check.mjs +25 -0
  65. package/test/browser/side-panel-sweep.check.mjs +182 -0
  66. package/test/consumer-capabilities.test.ts +116 -0
  67. package/test/data-motion.test.ts +94 -27
  68. package/test/figure-assistant-surface.test.ts +1 -1
  69. package/test/first-save.test.ts +2 -2
  70. package/test/links.test.ts +15 -0
  71. package/test/product-session.test.ts +5 -5
  72. package/test/proposal-record.test.ts +3 -3
  73. package/test/revert-keeping.test.ts +1 -1
  74. package/test/slot-layout-host.test.ts +63 -5
  75. package/translations/en.json +82 -23
  76. package/translations/es.json +82 -23
  77. package/translations/ja.json +82 -23
  78. package/translations/ko.json +82 -23
  79. package/translations/ms.json +82 -23
  80. package/translations/zh.json +82 -23
@@ -7,7 +7,7 @@ import type { FigureRenderPart } from './renderer.js'
7
7
  import { DEFAULT_VIEW, sceneLook, WORKBENCH, type ViewSettings } from '@things-factory/figure-viewer/dist-client/figure-view.js'
8
8
  import { boardFor, changedKeys, componentOf, gizmoResultOf, needsRefit, type Board } from './stage-model.js'
9
9
  import { JOINT_FAN } from './joint-fan.js'
10
- import { SLOT_MARK } from './slot-layout-host.js'
10
+ import { SLOT_MARK, SLOT_MARK_CHOSEN } from './slot-layout-host.js'
11
11
  import { PREVIEW, TARGET_FACE } from './command-preview.js'
12
12
  import { ownerOf } from './fields.js'
13
13
 
@@ -256,7 +256,7 @@ export class FigureCanvas extends LitElement {
256
256
  /* The fan in the accent colour, translucent; `capturable: false` keeps clicks, the marquee and the gizmo off it. */
257
257
  const fan = this.fan ? componentOf(this.fan, { ...this.palette, [JOINT_FAN]: this.palette['palette.accent'] ?? FAN_COLOUR }, this.board!) : null
258
258
  const accent = this.palette['palette.accent'] ?? FAN_COLOUR
259
- const slots = this.marks.map(part => componentOf(part, { ...this.palette, [SLOT_MARK]: this.palette['palette.secondary'] ?? accent }, this.board!))
259
+ const slots = this.marks.map(part => componentOf(part, { ...this.palette, [SLOT_MARK]: this.palette['palette.secondary'] ?? accent, [SLOT_MARK_CHOSEN]: this.palette['palette.primary'] ?? accent }, this.board!))
260
260
  const shown = [...(fan ? [fan] : []), ...slots, ...this.ghost.map(part => componentOf(part, { ...this.palette, [PREVIEW]: accent, [TARGET_FACE]: accent }, this.board!)), ...(this.highlight ? [componentOf(this.highlight, { ...this.palette, [TARGET_FACE]: accent }, this.board!)] : [])]
261
261
  return [...parts, ...shown.filter((c): c is Record<string, unknown> => !!c).map(c => ({ ...c, capturable: false, locked: true }))]
262
262
  }
@@ -0,0 +1,66 @@
1
+ /*
2
+ * A product's capabilities in the modeller (ADR-0167 3, 9, 12): the descriptors registered with the scene, which ones
3
+ * the figure wears, what each still needs, and its settings on the asset as its descriptor draws them -- parts picked,
4
+ * an input and a value per fixed key. The descriptor's own check says what the settings lack. Kept apart from the
5
+ * element so it is tested without a browser.
6
+ */
7
+ import { isFigureConsumerCapability } from '@hatiolab/figure-model'
8
+ import type { FigureAsset } from '@hatiolab/figure-model'
9
+ import { figureCapabilityDescriptors, type FigureCapabilityDescriptor, type FigureCapabilitySetting } from '@hatiolab/things-scene'
10
+
11
+ /** The products' capabilities this modeller knows: registered descriptors with a namespaced name. */
12
+ export const consumerDescriptors = (): FigureCapabilityDescriptor[] => figureCapabilityDescriptors().filter(d => isFigureConsumerCapability(d.name))
13
+
14
+ /** The products' capabilities the figure declares that this modeller has no descriptor for: kept, said, removable. */
15
+ export function unknownConsumerNames(asset: FigureAsset): string[] {
16
+ const known = new Set(consumerDescriptors().map(d => d.name))
17
+ return (asset.document.capabilities.names ?? []).filter(n => isFigureConsumerCapability(n) && !known.has(n))
18
+ }
19
+
20
+ /** What a capability still needs on this figure: a shared or a product's name not declared, or the item mark. */
21
+ export function needsMissing(descriptor: FigureCapabilityDescriptor, asset: FigureAsset): string[] {
22
+ const names = new Set(asset.document.capabilities.names ?? [])
23
+ const has = (need: string) => (need === 'item' ? !!asset.item : names.has(need))
24
+ return descriptor.needs.filter(need => (Array.isArray(need) ? !need.some(has) : !has(need as string))).map(need => (Array.isArray(need) ? need.join(' | ') : (need as string)))
25
+ }
26
+
27
+ /** The names with a product's capability put on or taken off; the shared ones as they are. */
28
+ export function namesWith(asset: FigureAsset, name: string, on: boolean): string[] {
29
+ const names = asset.document.capabilities.names ?? []
30
+ return on ? [...new Set([...names, name])] : names.filter(n => n !== name)
31
+ }
32
+
33
+ /** A capability's settings on the asset, or an empty object. */
34
+ export const settingsOf = (asset: FigureAsset, name: string): Record<string, unknown> => ({ ...(asset.document.capabilities.extensions?.[name] ?? {}) })
35
+
36
+ /** The parts a parts setting holds. */
37
+ export function partsAt(settings: Record<string, unknown>, key: string): string[] {
38
+ const v = settings[key]
39
+ return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []
40
+ }
41
+
42
+ /** The settings with a parts setting changed; none is left out. */
43
+ export function withParts(settings: Record<string, unknown>, key: string, parts: string[]): Record<string, unknown> {
44
+ const next = { ...settings }
45
+ if (parts.length) next[key] = [...parts]
46
+ else delete next[key]
47
+ return next
48
+ }
49
+
50
+ /** An input table as held: the input, and a value per fixed key (the descriptor's start where none is given yet). */
51
+ export function inputTableAt(settings: Record<string, unknown>, setting: Extract<FigureCapabilitySetting, { kind: 'input-table' }>): { input?: string; values: Record<string, number> } {
52
+ const v = settings[setting.key] as { input?: unknown; values?: Record<string, unknown> } | undefined
53
+ const values = Object.fromEntries(setting.keys.map(k => [k, typeof v?.values?.[k] === 'number' ? (v.values[k] as number) : setting.start[k]!]))
54
+ return { ...(typeof v?.input === 'string' ? { input: v.input } : {}), values }
55
+ }
56
+
57
+ /** The settings with an input table changed: an input chosen writes every fixed key's value; none takes the table away. */
58
+ export function withInputTable(settings: Record<string, unknown>, setting: Extract<FigureCapabilitySetting, { kind: 'input-table' }>, input: string | undefined, values: Record<string, number>): Record<string, unknown> {
59
+ const next = { ...settings }
60
+ if (input) next[setting.key] = { input, values: Object.fromEntries(setting.keys.map(k => [k, values[k] ?? setting.start[k]!])) }
61
+ else delete next[setting.key]
62
+ return next
63
+ }
64
+
65
+ /** What the capability's own check says of the settings now; nothing when it has no check. */
66
+ export const settingsIssues = (descriptor: FigureCapabilityDescriptor, asset: FigureAsset): string[] => descriptor.check?.(asset.document.capabilities.extensions?.[descriptor.name], asset) ?? []
@@ -1,12 +1,26 @@
1
1
  /*
2
- * The figure's data and what it moves (ADR-0051 §8, data binding design appendix A): what the data tab offers and
3
- * shows. Every rule is figure-model's -- which accessor holds, which rule's results a behavior takes -- and the reading
2
+ * The figure's data and what it moves (ADR-0051 §8, ADR-0166): what the data tab offers and shows -- one binding, its
3
+ * method as the field's kind allows (as it is, scaled, a code table, a range table). Every rule is figure-model's -- which accessor holds, which rule's results a behavior takes -- and the reading
4
4
  * of data is things-scene's; this only lists what there is to choose, drafts a binding the kernel then checks, and edits
5
5
  * the schema as a tree. Kept apart from the element so it is tested without a browser.
6
6
  */
7
7
  import { FIGURE_PART_ID, FIGURE_MOTION_SPEED_MAX, FigureContractError, figureAxesOf, figureDataRows, motionStateIdOf, parseFigureAccessor } from '@hatiolab/figure-model'
8
- import type { FigureAsset, FigureDataBehavior, FigureDataBinding, FigureDataField, FigureDataNode, FigureDataRow, FigureDataValue, FigureDataValueIssue, FigureProposalStep, FigureStatusPort } from '@hatiolab/figure-model'
9
- import { FigureInputs, type FigureDataResult } from '@hatiolab/things-scene'
8
+ import type {
9
+ FigureAsset,
10
+ FigureDataBehavior,
11
+ FigureDataBinding,
12
+ FigureDataField,
13
+ FigureDataNode,
14
+ FigureDataRow,
15
+ FigureDataTableBinding,
16
+ FigureDataTableEntry,
17
+ FigureDataTargets,
18
+ FigureDataValue,
19
+ FigureDataValueBinding,
20
+ FigureDataValueIssue,
21
+ FigureProposalStep
22
+ } from '@hatiolab/figure-model'
23
+ import { FigureInputs, type FigureDataResult, type FigureRuntimeIssue, type FigureTableUse } from '@hatiolab/things-scene'
10
24
 
11
25
  /** Something a binding can move, with what the screen shows of it. */
12
26
  export interface BehaviorChoice {
@@ -15,11 +29,13 @@ export interface BehaviorChoice {
15
29
  behavior: FigureDataBehavior
16
30
  /** The screen's word for the kind: a control, a colour, a group's clip, … */
17
31
  kind: FigureDataBehavior['kind']
18
- /** The control's label, the colour token, the group's label, the port's label, the axis. */
32
+ /** The control's label, the colour token, the group's label, the axis. */
19
33
  name: string
20
34
  unit?: string
21
35
  min?: number
22
36
  max?: number
37
+ /** A motion group's clips. */
38
+ clips?: string[]
23
39
  }
24
40
 
25
41
  export const behaviorKeyOf = (b: FigureDataBehavior): string =>
@@ -27,17 +43,15 @@ export const behaviorKeyOf = (b: FigureDataBehavior): string =>
27
43
  ? `state:${b.input}`
28
44
  : b.kind === 'palette'
29
45
  ? `palette:${b.token}`
30
- : b.kind === 'status'
31
- ? `status:${b.port}`
32
- : b.kind === 'pose'
33
- ? `pose:${b.axis}`
34
- : b.kind === 'items'
35
- ? `items:${b.slot ?? ''}`
36
- : `${b.kind}:${b.group}`
46
+ : b.kind === 'pose'
47
+ ? `pose:${b.axis}`
48
+ : b.kind === 'items'
49
+ ? `items:${b.slot ?? ''}`
50
+ : `${b.kind}:${b.group}`
37
51
 
38
52
  /**
39
- * Everything the figure can do that data may drive: each control, each colour, each motion group's clip, speed and
40
- * pause, each status port, its position and heading, and -- when it has places -- the items in them.
53
+ * Everything a value binding may drive: each control, each colour, each motion group's clip, speed and pause, its
54
+ * position and heading, and -- when it has places -- the items in them.
41
55
  */
42
56
  export function behaviorsOf(asset: FigureAsset): BehaviorChoice[] {
43
57
  const out: BehaviorChoice[] = []
@@ -48,11 +62,10 @@ export function behaviorsOf(asset: FigureAsset): BehaviorChoice[] {
48
62
  }
49
63
  for (const token of Object.keys(asset.palette)) add({ kind: 'palette', token }, token)
50
64
  for (const group of asset.motionGroups ?? []) {
51
- add({ kind: 'motion-clip', group: group.id }, group.label)
65
+ add({ kind: 'motion-clip', group: group.id }, group.label, { clips: [...group.clips] })
52
66
  add({ kind: 'motion-speed', group: group.id }, group.label, { min: 0, max: FIGURE_MOTION_SPEED_MAX })
53
67
  add({ kind: 'motion-paused', group: group.id }, group.label)
54
68
  }
55
- for (const port of asset.dataInterface?.ports ?? []) add({ kind: 'status', port: port.id }, port.label)
56
69
  for (const axis of ['x', 'z', 'y', 'heading'] as const) add({ kind: 'pose', axis, seconds: 1 }, axis, { unit: axis === 'heading' ? 'deg' : 'mm' })
57
70
  if (asset.document.capabilities.slotLayout || asset.document.capabilities.names?.includes('Holdable')) add({ kind: 'items' }, asset.document.capabilities.slotLayout?.label ?? 'items')
58
71
  return out
@@ -60,17 +73,40 @@ export function behaviorsOf(asset: FigureAsset): BehaviorChoice[] {
60
73
 
61
74
  const isValue = (n: FigureDataNode): n is Extract<FigureDataNode, { kind: 'number' | 'boolean' | 'text' }> => n.kind === 'number' || n.kind === 'boolean' || n.kind === 'text'
62
75
 
63
- /** Whether a field of this kind can drive the behavior, by some rule -- the choices offered for it. */
76
+ /**
77
+ * Whether a field of this kind drives the behavior as it is or scaled -- the choices a value binding offers for it. A
78
+ * value that picks a look per code is a table (ADR-0166 2).
79
+ */
64
80
  export function fitsField(node: FigureDataNode, choice: BehaviorChoice): boolean {
65
81
  if (choice.kind === 'items') return node.kind === 'list' || node.kind === 'text' || node.kind === 'number'
66
82
  if (!isValue(node)) return false
67
- if (choice.kind === 'pose') return node.kind === 'number'
68
- if (choice.kind === 'motion-speed') return node.kind === 'number' || node.kind === 'boolean'
69
- if (choice.kind === 'motion-paused') return node.kind === 'boolean' || (node.kind === 'text' && !!node.values)
70
- if (choice.kind === 'status') return node.kind !== 'boolean'
71
- return true
83
+ if (choice.kind === 'motion-paused') return node.kind === 'boolean'
84
+ if (choice.kind === 'palette') return node.kind === 'text' && !!node.values?.every(v => /^#[0-9a-f]{6}$/i.test(v))
85
+ if (choice.kind === 'motion-clip') {
86
+ const clips = choice.behavior.kind === 'motion-clip' ? choice.clips : undefined
87
+ return node.kind === 'text' && !!node.values && (!clips || node.values.every(v => clips.includes(v)))
88
+ }
89
+ return node.kind === 'number'
90
+ }
91
+
92
+ /** How a binding turns a value into what the figure shows (ADR-0166 1): the screen's four methods. */
93
+ export type DataMethod = 'value' | 'scale' | 'code' | 'ranges'
94
+
95
+ /**
96
+ * The methods a field allows (ADR-0166 2): a number as it is, scaled or by a range table; a text or true/false by a code
97
+ * table, or as it is where a behavior takes it so; a list as it is (its items). A new field is made by a table.
98
+ */
99
+ export function methodsOf(node: FigureDataNode | null, choices: BehaviorChoice[]): DataMethod[] {
100
+ if (!node) return ['code', 'ranges']
101
+ const fitting = choices.some(c => fitsField(node, c))
102
+ if (node.kind === 'number') return [...(fitting ? (['value', 'scale'] as const) : []), 'ranges']
103
+ if (node.kind === 'text' || node.kind === 'boolean') return [...(fitting ? (['value'] as const) : []), 'code']
104
+ return fitting ? ['value'] : []
72
105
  }
73
106
 
107
+ /** The method a binding is shown by. */
108
+ export const methodOf = (b: FigureDataBinding): DataMethod => (b.rule === 'table' ? (b.lookup === 'code' ? 'code' : 'ranges') : b.rule)
109
+
74
110
  /** An id not taken among `taken`: `base`, then `base-2`, `base-3`, … made into the kernel's id shape. */
75
111
  export function freeIdOf(taken: Iterable<string>, base: string): string {
76
112
  const used = new Set(taken)
@@ -90,19 +126,15 @@ const TO_DEG: Record<string, number> = { deg: 1, rad: 180 / Math.PI }
90
126
  export const domainOf = (node: FigureDataNode): FigureDataValue[] => (node.kind === 'boolean' ? [false, true] : node.kind === 'text' && node.values ? [...node.values] : [])
91
127
 
92
128
  /**
93
- * A first binding of a field to a behavior, for the author to adjust: the value as it is when it already fits, a scale
94
- * when only its unit or range differs, one result per value (a map) for a closed field, a default for an open one.
95
- * Each result starts at what the behavior shows now -- the control's rest value, the colour, the group's default clip --
96
- * or, for a clip, the clip of the value's own name. The kernel says whether it holds.
129
+ * A first value binding of a field to a behavior, for the author to adjust: the value as it is when it already fits, a
130
+ * scale when only its unit or range differs -- or the method the author chose. The kernel says whether it holds.
97
131
  */
98
- export function bindingDraftOf(asset: FigureAsset, accessor: string, node: FigureDataNode, choice: BehaviorChoice): FigureDataBinding {
132
+ export function bindingDraftOf(asset: FigureAsset, accessor: string, node: FigureDataNode, choice: BehaviorChoice, method?: 'value' | 'scale'): FigureDataValueBinding {
99
133
  const taken = (asset.dataInterface?.bindings ?? []).map(b => b.id)
100
134
  const name = accessor.replace(/\[\d+\]/g, '').split('.').pop() ?? accessor
101
135
  const id = freeIdOf(taken, `${name}-${choice.kind === 'state' ? (choice.behavior as { input: string }).input : choice.kind}`)
102
136
  const behavior = choice.behavior
103
- const make = (rule: FigureDataBinding['rule'], param?: FigureDataBinding['param']): FigureDataBinding => ({ id, accessor, behavior, rule, ...(param ? { param } : {}) })
104
- const domain = domainOf(node)
105
- const caseMap = (each: (v: FigureDataValue) => FigureDataValue, fallback: FigureDataValue) => Object.fromEntries([...domain.map(v => [String(v), each(v)] as const), ...(domain.length ? [] : [['default', fallback] as const])])
137
+ const make = (rule: 'value' | 'scale', param?: FigureDataValueBinding['param']): FigureDataValueBinding => ({ id, accessor, behavior, rule, ...(param ? { param } : {}) })
106
138
 
107
139
  if (behavior.kind === 'items') {
108
140
  if (node.kind !== 'list') return make('value')
@@ -114,47 +146,152 @@ export function bindingDraftOf(asset: FigureAsset, accessor: string, node: Figur
114
146
  const stack = pick(/stack/i, /level/i)
115
147
  return { id, accessor, behavior: { kind: 'items', ...(itemId ? { id: itemId } : {}), ...(type ? { type } : {}), ...(place ? { place } : {}), ...(stack ? { stack } : {}) }, rule: 'value' }
116
148
  }
117
- if (behavior.kind === 'status') return make('value')
118
- if (behavior.kind === 'palette') {
119
- const now = asset.palette[behavior.token] ?? '#000000'
120
- if (node.kind === 'number') return make('range', { default: now })
121
- return make('map', caseMap(() => now, now))
122
- }
123
- if (behavior.kind === 'motion-clip') {
124
- const group = (asset.motionGroups ?? []).find(g => g.id === behavior.group)
125
- const clips = group?.clips ?? []
126
- if (node.kind === 'text' && node.values && node.values.every(v => clips.includes(v))) return make('value')
127
- return make('map', caseMap(v => (clips.includes(String(v)) ? String(v) : (group?.defaultClip ?? '')), group?.defaultClip ?? ''))
128
- }
129
- if (behavior.kind === 'motion-paused') {
130
- if (node.kind === 'boolean') return make('value')
131
- return make('map', caseMap(() => false, false))
132
- }
149
+ if (node.kind !== 'number') return make('value')
133
150
  if (behavior.kind === 'pose') {
134
- if (node.kind !== 'number') return make('value')
135
151
  const want = behavior.axis === 'heading' ? TO_DEG : TO_MM
136
152
  const unit = node.unit ?? ''
137
- if (unit === (behavior.axis === 'heading' ? 'deg' : 'mm')) return make('value')
138
- return unit in want ? make('scale', { scale: want[unit]!, offset: 0 }) : make('value')
153
+ if (method === 'value' || unit === (behavior.axis === 'heading' ? 'deg' : 'mm') || (method === undefined && !(unit in want))) return make('value')
154
+ return make('scale', { scale: want[unit] ?? 1, offset: 0 })
139
155
  }
140
156
  /* state and motion-speed: a number into a range. */
141
157
  const [lo, hi, unit] =
142
158
  behavior.kind === 'motion-speed' ? [0, FIGURE_MOTION_SPEED_MAX, 'ratio'] : [choice.min ?? 0, choice.max ?? 1, choice.unit ?? '']
143
- if (node.kind === 'number') {
144
- const sameUnit = behavior.kind === 'motion-speed' ? node.unit === undefined || node.unit === 'ratio' : node.unit === unit
145
- const inside = node.min !== undefined && node.max !== undefined && node.min >= lo && node.max <= hi
146
- if (sameUnit && inside) return make('value')
147
- if (node.min !== undefined && node.max !== undefined && node.max > node.min) {
148
- /* The field's range laid onto the behavior's. */
149
- const scale = (hi - lo) / (node.max - node.min)
150
- return make('scale', { scale: round(scale), offset: round(lo - scale * node.min) })
151
- }
152
- return make('value')
159
+ const sameUnit = behavior.kind === 'motion-speed' ? node.unit === undefined || node.unit === 'ratio' : node.unit === unit
160
+ const inside = node.min !== undefined && node.max !== undefined && node.min >= lo && node.max <= hi
161
+ if (method === 'value' || (method === undefined && sameUnit && inside)) return make('value')
162
+ if (node.min !== undefined && node.max !== undefined && node.max > node.min) {
163
+ /* The field's range laid onto the behavior's. */
164
+ const scale = (hi - lo) / (node.max - node.min)
165
+ return make('scale', { scale: round(scale), offset: round(lo - scale * node.min) })
153
166
  }
154
- const rest = behavior.kind === 'motion-speed' ? 1 : (asset.stateDefaults[(behavior as { input: string }).input] ?? lo)
155
- if (node.kind === 'boolean') return make('map', { false: behavior.kind === 'motion-speed' ? 0 : lo, true: behavior.kind === 'motion-speed' ? 1 : hi })
156
- return make('map', caseMap(() => rest, rest))
167
+ return method === 'scale' ? make('scale', { scale: 1, offset: 0 }) : make('value')
157
168
  }
169
+
170
+ /* ---------------------------------------------------------------- tables (ADR-0166 3) */
171
+
172
+ /** The first code a new code table names, and the next ones: `CODE`, `CODE2`, … */
173
+ export function freeCodeOf(taken: string[]): string {
174
+ if (!taken.includes('CODE')) return 'CODE'
175
+ let n = 2
176
+ while (taken.includes(`CODE${n}`)) n++
177
+ return `CODE${n}`
178
+ }
179
+
180
+ /** A legend role from a binding's id: lower case letters, digits and `-`. */
181
+ export const legendRoleOf = (id: string): string =>
182
+ id
183
+ .toLowerCase()
184
+ .replace(/[^a-z0-9-]+/g, '-')
185
+ .replace(/^-+|-+$/g, '') || 'legend'
186
+
187
+ /**
188
+ * A first table on a field (ADR-0166 10): a row per value of a closed field, an open field's rows typed later, one
189
+ * interval over the whole of a number. A new field is made by the table (P2): a code table's a text whose values are the
190
+ * table's keys, starting with one code; a range table's a number. Returns the binding and, for a new field, the schema
191
+ * with it.
192
+ */
193
+ export function tableDraftOf(
194
+ asset: FigureAsset,
195
+ field: { path: string; node: FigureDataNode } | { name: string },
196
+ method: 'code' | 'ranges',
197
+ targets: FigureDataTargets
198
+ ): { binding: FigureDataTableBinding; schema?: FigureDataField[] } {
199
+ const taken = (asset.dataInterface?.bindings ?? []).map(b => b.id)
200
+ const made = 'name' in field
201
+ const name = made ? field.name : (field.path.replace(/\[\]/g, '').split('.').pop() ?? field.path)
202
+ const id = freeIdOf(taken, name.toLowerCase())
203
+ const node: FigureDataNode = made ? (method === 'code' ? { kind: 'text', values: ['CODE'] } : { kind: 'number' }) : field.node
204
+ const accessor = made ? field.name : accessorOfRow(field.path)
205
+ const table: Record<string, FigureDataTableEntry> =
206
+ method === 'code' ? Object.fromEntries(domainOf(node).map(v => [String(v), {}])) : { [`${node.kind === 'number' ? (node.min ?? '') : ''}~`]: {} }
207
+ const binding: FigureDataTableBinding = { id, accessor, rule: 'table', lookup: method, targets: tidyTargets(targets), table }
208
+ if (!made) return { binding }
209
+ const schema = [...(asset.dataInterface?.schema ?? []), { name: field.name, ...node } as FigureDataField]
210
+ return { binding, schema }
211
+ }
212
+
213
+ /** Targets with empty lists left out, as the kernel keeps them. */
214
+ const tidyTargets = (t: FigureDataTargets): FigureDataTargets => ({
215
+ ...(t.parts?.length ? { parts: [...t.parts] } : {}),
216
+ ...(t.inputs?.length ? { inputs: [...t.inputs] } : {}),
217
+ ...(t.group ? { group: t.group } : {})
218
+ })
219
+
220
+ /** A look with only the columns its targets take: a target taken away takes its columns with it. */
221
+ function lookFor(look: FigureDataTableEntry, t: FigureDataTargets): FigureDataTableEntry {
222
+ const out: FigureDataTableEntry = {}
223
+ if (t.parts?.length) {
224
+ if (look.color !== undefined) out.color = look.color
225
+ if (look.effect !== undefined) out.effect = look.effect
226
+ }
227
+ if (t.inputs?.length && look.values) {
228
+ const values = Object.fromEntries(Object.entries(look.values).filter(([id]) => t.inputs!.includes(id)))
229
+ if (Object.keys(values).length) out.values = values
230
+ }
231
+ if (t.group) {
232
+ if (look.clip !== undefined) out.clip = look.clip
233
+ if (look.speed !== undefined) out.speed = look.speed
234
+ if (look.paused !== undefined) out.paused = look.paused
235
+ }
236
+ return out
237
+ }
238
+
239
+ /** The table with these targets: each look keeps only the columns they take. */
240
+ export function tableWithTargets(b: FigureDataTableBinding, targets: FigureDataTargets): FigureDataTableBinding {
241
+ const t = tidyTargets(targets)
242
+ return { ...b, targets: t, table: Object.fromEntries(Object.entries(b.table).map(([k, look]) => [k, lookFor(look, t)])) }
243
+ }
244
+
245
+ /** The table with one look set: the whole look given, a column left out cleared. */
246
+ export const tableWithLook = (b: FigureDataTableBinding, key: string, look: FigureDataTableEntry): FigureDataTableBinding => ({ ...b, table: { ...b.table, [key]: lookFor(look, b.targets) } })
247
+
248
+ /** The table's rows renamed, added or taken away, in order; `default` kept last. */
249
+ export function tableWithKeys(b: FigureDataTableBinding, rows: Array<[string, FigureDataTableEntry]>): FigureDataTableBinding {
250
+ const d = b.table.default
251
+ return { ...b, table: { ...Object.fromEntries(rows), ...(d !== undefined ? { default: d } : {}) } }
252
+ }
253
+
254
+ /**
255
+ * The columns a table's targets give its looks, as the row editor shows them: colour and effect for parts, a value per
256
+ * state input (its unit and range), the clip, speed and pause of the group.
257
+ */
258
+ export function tableColumnsOf(asset: FigureAsset, b: FigureDataTableBinding): TableColumn[] {
259
+ const out: TableColumn[] = []
260
+ if (b.targets.parts?.length) out.push({ kind: 'color' }, { kind: 'effect' })
261
+ for (const id of b.targets.inputs ?? []) {
262
+ const input = asset.document.model.inputs.find(i => i.id === id)
263
+ out.push({ kind: 'value', id, label: asset.stateInputs?.[id]?.label ?? id, unit: input?.unit, min: input?.min, max: input?.max })
264
+ }
265
+ if (b.targets.group) {
266
+ const group = (asset.motionGroups ?? []).find(g => g.id === b.targets.group)
267
+ out.push({ kind: 'clip', clips: group?.clips ?? [] }, { kind: 'speed' }, { kind: 'paused' })
268
+ }
269
+ return out
270
+ }
271
+ export type TableColumn =
272
+ | { kind: 'color' | 'effect' | 'speed' | 'paused' }
273
+ | { kind: 'value'; id: string; label: string; unit?: string; min?: number; max?: number }
274
+ | { kind: 'clip'; clips: string[] }
275
+
276
+ /** The state inputs a table may give values to: each control, with its label. */
277
+ export const stateInputsOf = (asset: FigureAsset): Array<{ id: string; label: string }> =>
278
+ asset.document.model.inputs.filter(i => i.role === 'state').map(i => ({ id: i.id, label: asset.stateInputs?.[i.id]?.label ?? i.id }))
279
+
280
+ /**
281
+ * A code table's field follows its keys (ADR-0166 risk 3): when the field is a closed text, its values are the table's
282
+ * codes. The schema with the field so, or undefined when the field is open or already so.
283
+ */
284
+ export function schemaFollowingCodes(asset: FigureAsset, b: FigureDataTableBinding, codes: string[]): FigureDataField[] | undefined {
285
+ const schema = asset.dataInterface?.schema ?? []
286
+ const path = b.accessor.replace(/\[\d+\]/g, '[]')
287
+ const field = fieldAt(schema, path)
288
+ if (field?.kind !== 'text' || !field.values || !codes.length) return undefined
289
+ if (field.values.length === codes.length && field.values.every((v, i) => v === codes[i])) return undefined
290
+ const next = { ...field, values: [...codes] } as FigureDataField & { default?: string }
291
+ if (next.default !== undefined && !codes.includes(next.default)) delete next.default
292
+ return schemaWithFieldReplaced(schema, path, next)
293
+ }
294
+
158
295
  const round = (v: number) => Math.round(v * 1e6) / 1e6
159
296
 
160
297
  /* ---------------------------------------------------------------- the schema as a tree */
@@ -231,12 +368,23 @@ export const schemaRowsOf = (asset: FigureAsset): FigureDataRow[] => (asset.data
231
368
 
232
369
  /**
233
370
  * What a sample of data would do on this figure, read the one way a placed figure reads it (things-scene's figure
234
- * inputs): what it moves, or why it is refused, each value's reason. Nothing here reads data itself.
371
+ * inputs): what it moves, or why it is refused, each value's reason; each table's look and where it came from, and what
372
+ * of a legend was left out. `legends`: sample legends by role, as a scene's legend of the role's name would be.
235
373
  */
236
- export function sampleOf(asset: FigureAsset, sample: unknown): { issues: FigureDataValueIssue[]; result: FigureDataResult } {
374
+ export function sampleOf(
375
+ asset: FigureAsset,
376
+ sample: unknown,
377
+ legends: Record<string, unknown> = {}
378
+ ): { issues: FigureDataValueIssue[]; result: FigureDataResult; uses: Readonly<Record<string, FigureTableUse>>; notes: FigureRuntimeIssue[] } {
237
379
  const inputs = new FigureInputs(asset)
380
+ inputs.setThemes({ legends: Object.fromEntries(Object.entries(legends).map(([role, legend]) => [role, { legend, source: 'domain' as const, name: role }])) })
238
381
  const taken = inputs.setData(sample)
239
- return { issues: taken.accepted ? [] : (taken.issues[0]?.issues ?? []), result: inputs.data }
382
+ return {
383
+ issues: taken.accepted ? [] : (taken.issues[0]?.issues ?? []),
384
+ result: inputs.data,
385
+ uses: inputs.tableUses,
386
+ notes: inputs.diagnostics().filter(i => i.severity === 'note')
387
+ }
240
388
  }
241
389
 
242
390
  /** The rows a person gives a value by hand, as a board's saved values: one value, read by a binding, not inside a list. */
@@ -249,16 +397,9 @@ export function typedValueOf(node: FigureDataNode, typed: string): FigureDataVal
249
397
  return typed
250
398
  }
251
399
 
252
- /** The parts a status port may show on: the parts the figure's appearance names. */
400
+ /** The parts a table may show on: the parts the figure's appearance names. */
253
401
  export const partsOf = (asset: FigureAsset): string[] => [...new Set(asset.appearance.map(a => a.target))]
254
402
 
255
- /** A first status port: the label as given, on the first colour token, no part yet. The kernel says whether it holds. */
256
- export function portDraftOf(asset: FigureAsset, label: string): FigureStatusPort {
257
- const taken = (asset.dataInterface?.ports ?? []).map(p => p.id)
258
- const token = Object.keys(asset.palette)[0]
259
- return { id: freeIdOf(taken, label.trim() || 'status'), label: label.trim(), tokens: token ? [token] : [], parts: [] }
260
- }
261
-
262
403
  /** Whether a text is an accessor the kernel takes. */
263
404
  export const accessorTaken = (accessor: string): boolean => parseFigureAccessor(accessor) !== null
264
405
 
@@ -269,6 +410,14 @@ export const accessorTaken = (accessor: string): boolean => parseFigureAccessor(
269
410
  export function motionWrittenByData(asset: FigureAsset): Record<string, { clip: boolean; speed: boolean }> {
270
411
  const out: Record<string, { clip: boolean; speed: boolean }> = {}
271
412
  for (const b of asset.dataInterface?.bindings ?? []) {
413
+ if (b.rule === 'table') {
414
+ if (!b.targets.group) continue
415
+ const one = (out[b.targets.group] ??= { clip: false, speed: false })
416
+ const looks = Object.values(b.table)
417
+ if (looks.some(l => l.clip !== undefined)) one.clip = true
418
+ if (looks.some(l => l.speed !== undefined)) one.speed = true
419
+ continue
420
+ }
272
421
  if (b.behavior.kind !== 'motion-clip' && b.behavior.kind !== 'motion-speed') continue
273
422
  const one = (out[b.behavior.group] ??= { clip: false, speed: false })
274
423
  if (b.behavior.kind === 'motion-clip') one.clip = true
@@ -315,7 +464,7 @@ export interface RotationChoice {
315
464
  /**
316
465
  * The rotation card's commands (data binding design decision 1, 「회전」): a clip that turns the joint once around and
317
466
  * keeps adding the turn (wrap, accumulating), a reverse one when a direction is given, the motion group holding them,
318
- * and the bindings -- on to the group's pause, speed to its speed, direction to its clip. Nothing new runs them: they
467
+ * and the bindings -- on, by a table, to the group's pause, speed to its speed, direction, by a table, to its clip. Nothing new runs them: they
319
468
  * are the drivers, the group and the bindings any author could write, and stay editable one by one.
320
469
  *
321
470
  * The clip lasts `T = 3 × 60 / max rpm` seconds, so the most the field gives plays at the group's top speed, 3 -- the
@@ -359,12 +508,12 @@ export function rotationStepsOf(asset: FigureAsset, c: RotationChoice): FigurePr
359
508
  steps.push({ kind: 'set-motion-group', group: { id: group, label: c.label, clips, defaultClip: forward, transitionSeconds: 0 } })
360
509
  }
361
510
  const taken = (asset.dataInterface?.bindings ?? []).map(b => b.id)
362
- const bind = (base: string, binding: Omit<FigureDataBinding, 'id'>): FigureProposalStep => {
511
+ const bind = (base: string, binding: Omit<FigureDataValueBinding, 'id'> | Omit<FigureDataTableBinding, 'id'>): FigureProposalStep => {
363
512
  const id = freeIdOf(taken, base)
364
513
  taken.push(id)
365
- return { kind: 'set-data-binding', binding: { id, ...binding } }
514
+ return { kind: 'set-data-binding', binding: { id, ...binding } as FigureDataBinding }
366
515
  }
367
- if (c.on) steps.push(bind(`${group}-on`, { accessor: c.on, behavior: { kind: 'motion-paused', group }, rule: 'map', param: { true: false, false: true } }))
516
+ if (c.on) steps.push(bind(`${group}-on`, { accessor: c.on, rule: 'table', lookup: 'code', targets: { group }, table: { true: { paused: false }, false: { paused: true } } }))
368
517
  if (c.speed) steps.push(bind(`${group}-speed`, { accessor: c.speed.path, behavior: { kind: 'motion-speed', group }, rule: 'scale', param: { scale: round(seconds / 60), offset: 0 } }))
369
518
  if (c.direction) {
370
519
  const values = domainOf(fieldAt(schema, c.direction.path) ?? { kind: 'text' }).map(String)
@@ -372,9 +521,10 @@ export function rotationStepsOf(asset: FigureAsset, c: RotationChoice): FigurePr
372
521
  steps.push(
373
522
  bind(`${group}-direction`, {
374
523
  accessor: c.direction.path,
375
- behavior: { kind: 'motion-clip', group },
376
- rule: 'map',
377
- param: { [c.direction.forward]: forward, [c.direction.reverse]: reverse, ...(covered ? {} : { default: forward }) }
524
+ rule: 'table',
525
+ lookup: 'code',
526
+ targets: { group },
527
+ table: { [c.direction.forward]: { clip: forward }, [c.direction.reverse]: { clip: reverse }, ...(covered ? {} : { default: { clip: forward } }) }
378
528
  })
379
529
  )
380
530
  }
@@ -339,11 +339,12 @@ export const EditorStyles = css`
339
339
  min-height: 0;
340
340
  overflow: auto;
341
341
  }
342
- /* Which part the panel speaks of. */
342
+ /* Which part the panel speaks of. Above z-index 1: a chip's label (md-filter-chip) sits at 1 and, coming later, was
343
+ drawn over it when the panel scrolled (sweep 2026-10-27 U2). */
343
344
  div[subject] {
344
345
  position: sticky;
345
346
  top: 0;
346
- z-index: 1;
347
+ z-index: 2;
347
348
  display: grid;
348
349
  gap: 4px;
349
350
  padding: 10px 12px;
@@ -47,7 +47,7 @@ import { paletteRecipe } from './palette.js'
47
47
  import { meshChoicesOf, meshPartOf } from './mesh-import.js'
48
48
  import './figure-mesh-choice.js'
49
49
  import { uploadFigureMesh } from '../graphql/index.js'
50
- import { slotMarksOf } from './slot-layout-host.js'
50
+ import { placeMarksOf, slotMarksOf } from './slot-layout-host.js'
51
51
 
52
52
  const NO_MARKS: FigureRenderPart[] = []
53
53
  import { apexOver, drawnOf } from './section-drawing.js'
@@ -950,6 +950,8 @@ export class FigureEditor extends LitElement {
950
950
  }
951
951
  /** Data tried in the data tab, given to the preview's figures as their `data`; null when none is (ADR-0051 §8). */
952
952
  @state() private dataSample: unknown = null
953
+ /** Sample legends by role, pasted in the data tab: the preview shows the figure's tables by them. Not saved. */
954
+ @state() private legendSample: Record<string, unknown> = {}
953
955
  /** The figure's controls: try a value, name it, set its range and travel time, keep the pose. */
954
956
  private renderControls(view: { fields: Array<{ id: string; role?: string; value: number }> }, unavailable: boolean) {
955
957
  const asset = this.shown()?.asset
@@ -1574,18 +1576,20 @@ export class FigureEditor extends LitElement {
1574
1576
  return { ...(this.selected ? { part: this.selected } : {}), ...(trying ? { trying } : {}) }
1575
1577
  }
1576
1578
  /**
1577
- * The slots the figure's slot layout unfolds to, where they fall now (ADR-0118): drawn while the places are being
1578
- * edited. Made again only when the evaluation is new, so the canvas is not stood up again for nothing.
1579
+ * The places things are held, where they fall now (ADR-0118): the slots the slot layout unfolds to and the places held
1580
+ * one by one, drawn while the places are being edited, the selected part's in the primary colour. Made again only when the
1581
+ * evaluation or the selection is new, so the canvas is not stood up again for nothing.
1579
1582
  */
1580
- private slotMarks(view: { bindings?: readonly unknown[] }, asset: unknown) {
1583
+ private slotMarks(view: { bindings?: readonly unknown[]; geometry: readonly unknown[] }, asset: unknown) {
1581
1584
  const places = view.bindings ?? []
1582
- if (this.slotMarksDrawn?.of !== places) {
1585
+ if (this.slotMarksDrawn?.of !== places || this.slotMarksDrawn.selected !== this.selected) {
1583
1586
  const layout = (asset as { document?: { capabilities?: { slotLayout?: { layout: never } } } })?.document?.capabilities?.slotLayout?.layout
1584
- this.slotMarksDrawn = { of: places, marks: slotMarksOf(places as never, layout) as never }
1587
+ const marks = [...slotMarksOf(places as never, layout), ...(asset ? placeMarksOf(places as never, asset as never, view.geometry as never, this.selected) : [])]
1588
+ this.slotMarksDrawn = { of: places, selected: this.selected, marks: marks as never }
1585
1589
  }
1586
1590
  return this.slotMarksDrawn.marks
1587
1591
  }
1588
- private slotMarksDrawn?: { of: readonly unknown[]; marks: FigureRenderPart[] }
1592
+ private slotMarksDrawn?: { of: readonly unknown[]; selected: string; marks: FigureRenderPart[] }
1589
1593
  private choose(id: string) {
1590
1594
  /* A copy on the canvas selects its repeat: the part the author made. */
1591
1595
  this.selected = id ? ownerOf(id) : id
@@ -1868,6 +1872,7 @@ export class FigureEditor extends LitElement {
1868
1872
  .view=${this.view}
1869
1873
  .selected=${this.selected}
1870
1874
  .sample=${this.dataSample}
1875
+ .legends=${this.legendSample}
1871
1876
  @configure-size=${async (e: CustomEvent<{ part: string }>) => {
1872
1877
  this.middle = 'edit'
1873
1878
  this.selected = e.detail.part
@@ -1938,7 +1943,9 @@ export class FigureEditor extends LitElement {
1938
1943
  @part-live=${(e: CustomEvent) => this.hold({}, e.detail.edit)}
1939
1944
  ></figure-material-panel>
1940
1945
  ${this.renderMore(part, view.palette ?? {}, unavailable)}`
1941
- : view.geometry.length === 0
1946
+ : view.loading || !view.context
1947
+ ? nothing
1948
+ : view.geometry.length === 0
1942
1949
  ? html`${this.renderSubject()}<section empty-figure>
1943
1950
  <p>${i18next.t('figure.text.empty-figure-next')}</p>
1944
1951
  <details>
@@ -1976,8 +1983,10 @@ export class FigureEditor extends LitElement {
1976
1983
  ${this.renderSubject()}
1977
1984
  <figure-data-panel
1978
1985
  .asset=${asset}
1986
+ .selected=${this.selected}
1979
1987
  ?locked=${unavailable}
1980
1988
  @data-sample=${(e: CustomEvent) => (this.dataSample = e.detail.data)}
1989
+ @legend-sample=${(e: CustomEvent) => (this.legendSample = e.detail.legends)}
1981
1990
  @part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
1982
1991
  @part-edits=${(e: CustomEvent) => this.run(() => this.session!.applyRecipe(e.detail.edits))}
1983
1992
  ></figure-data-panel>