@things-factory/figure-ui 10.1.52 → 10.1.53

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 (49) hide show
  1. package/client/figure-assistant.ts +20 -0
  2. package/client/index.ts +1 -0
  3. package/client/modeller/figure-v3-links.ts +56 -4
  4. package/client/modeller/v3-editor.ts +18 -2
  5. package/client/modeller/v3-links.ts +42 -9
  6. package/client/modeller/v3-refusals.ts +19 -0
  7. package/client/pages/figure-v3-modeller-page.ts +46 -16
  8. package/dist-client/figure-assistant.d.ts +1 -0
  9. package/dist-client/figure-assistant.js +19 -0
  10. package/dist-client/figure-assistant.js.map +1 -0
  11. package/dist-client/index.d.ts +1 -0
  12. package/dist-client/index.js +1 -0
  13. package/dist-client/index.js.map +1 -1
  14. package/dist-client/modeller/figure-v3-capability.d.ts +1 -1
  15. package/dist-client/modeller/figure-v3-clips.d.ts +1 -1
  16. package/dist-client/modeller/figure-v3-controls.d.ts +1 -1
  17. package/dist-client/modeller/figure-v3-links.d.ts +2 -0
  18. package/dist-client/modeller/figure-v3-links.js +57 -4
  19. package/dist-client/modeller/figure-v3-links.js.map +1 -1
  20. package/dist-client/modeller/figure-v3-material.d.ts +1 -1
  21. package/dist-client/modeller/figure-v3-properties.d.ts +1 -1
  22. package/dist-client/modeller/v3-editor.d.ts +2 -0
  23. package/dist-client/modeller/v3-editor.js +20 -2
  24. package/dist-client/modeller/v3-editor.js.map +1 -1
  25. package/dist-client/modeller/v3-links.d.ts +24 -2
  26. package/dist-client/modeller/v3-links.js +29 -5
  27. package/dist-client/modeller/v3-links.js.map +1 -1
  28. package/dist-client/modeller/v3-refusals.d.ts +13 -0
  29. package/dist-client/modeller/v3-refusals.js +20 -0
  30. package/dist-client/modeller/v3-refusals.js.map +1 -1
  31. package/dist-client/pages/figure-v3-modeller-page.d.ts +4 -1
  32. package/dist-client/pages/figure-v3-modeller-page.js +41 -15
  33. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  34. package/dist-client/tsconfig.tsbuildinfo +1 -1
  35. package/package.json +6 -5
  36. package/test/browser/modeller.check.mjs +39 -0
  37. package/test/figure-assistant-surface.test.ts +52 -0
  38. package/test/v3-links.test.ts +62 -2
  39. package/test/v3-refusals.test.ts +21 -0
  40. package/translations/en.json +11 -1
  41. package/translations/ja.json +11 -1
  42. package/translations/ko.json +11 -1
  43. package/translations/ms.json +11 -1
  44. package/translations/zh.json +11 -1
  45. package/client/modeller/figure-ai-target.ts +0 -23
  46. package/dist-client/modeller/figure-ai-target.d.ts +0 -18
  47. package/dist-client/modeller/figure-ai-target.js +0 -22
  48. package/dist-client/modeller/figure-ai-target.js.map +0 -1
  49. package/test/ai-target.test.ts +0 -25
@@ -0,0 +1,20 @@
1
+ /*
2
+ * What the AI dock does with a figure candidate - figure-ui declares it because figure-service declares the tool
3
+ * (`proposeFigureCandidate`, design/ai/shared-dock.md §4).
4
+ *
5
+ * Today a candidate cannot be taken anywhere. The proposer writes V2 figures and the modeller opens V3 only; the V2
6
+ * modeller that reviewed candidates was removed with its listeners (`figure-ai-review`, `figure-ai-stage`), and the
7
+ * server already refuses to propose for a V3 figure (`V3_PROPOSAL_UNAVAILABLE`). So the outcome is `unavailable`,
8
+ * said to the person in words, not a silent pass - until a proposer writes V3 changes, which the modeller takes
9
+ * through `figure-v3-apply`.
10
+ */
11
+ import { i18next } from '@operato/i18n'
12
+ import { registerProposalHandler } from '@things-factory/ai-assistant/dist-client/index.js'
13
+
14
+ export const FIGURE_CANDIDATE_TOOL = 'proposeFigureCandidate'
15
+
16
+ registerProposalHandler(FIGURE_CANDIDATE_TOOL, async (_proposal, context) => {
17
+ const note = i18next.t('figure.text.candidate-cannot-open')
18
+ context.notify('info', note)
19
+ return { verdict: 'unavailable', errorCode: 'v3-review-missing', note }
20
+ })
package/client/index.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export * from './types.js'
2
2
  export * from './graphql/index.js'
3
+ export * from './figure-assistant.js'
@@ -52,7 +52,7 @@ const freshFasten = () => ({
52
52
  align: {} as Partial<Record<Axis, Alignment>>,
53
53
  alignMm: {} as Partial<Record<Axis, number>>
54
54
  })
55
- const freshMotion = (): MotionForm => ({ kind: 'slide', axis: 'x', id: '', min: 0, max: 500, follow: '', multiplier: 1, offset: 0 })
55
+ const freshMotion = (): MotionForm => ({ kind: 'slide', axis: 'x', id: '', min: 0, max: 500, follow: '', multiplier: 1, offset: 0, travel: '', travelPlus: 0 })
56
56
  const freshRow = () => ({ axis: '' as Axis | '', pitch: 200, fills: true, count: 5 })
57
57
  const freshPlace = () => ({ role: 'grip' as 'seat' | 'grip', face: 'top' as V3Face, label: '', accepts: '', capacity: 1 })
58
58
 
@@ -89,6 +89,16 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
89
89
  b[bound] {
90
90
  font-weight: 600;
91
91
  }
92
+ /* The difference a following measure keeps: under its choice, on the same line as its words. */
93
+ label[inline-plus] {
94
+ display: flex;
95
+ align-items: center;
96
+ gap: 6px;
97
+ }
98
+ label[inline-plus] input {
99
+ flex: 1;
100
+ min-width: 0;
101
+ }
92
102
  `]
93
103
 
94
104
  /** The asset being edited. */
@@ -241,10 +251,32 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
241
251
  ? html`<option value=${value} selected>${now.of === 'input' ? now.input : i18next.t('figure.label.follows-expression')}</option>`
242
252
  : nothing}
243
253
  </select>
254
+ ${now.of === 'instance' || now.of === 'part'
255
+ ? html`<span></span>
256
+ <label inline-plus>
257
+ ${i18next.t('figure.label.follows-plus')}
258
+ <input
259
+ type="number"
260
+ step="any"
261
+ ?disabled=${this.locked}
262
+ .value=${String(now.plus ?? 0)}
263
+ @change=${(e: Event) => this.followPlus(axis, now, Number((e.target as HTMLInputElement).value) || 0)}
264
+ />
265
+ </label>`
266
+ : nothing}
244
267
  </div>`
245
268
  })
246
269
  }
247
270
 
271
+ /** The same source, the same share, a new difference: a leaf the opening less its gaps (link-dimension's `plus`). */
272
+ private followPlus(axis: Axis, now: Follow, plus: number) {
273
+ if (now.of !== 'instance' && now.of !== 'part') return
274
+ if ((now.plus ?? 0) === plus) return
275
+ const source: V3DimensionSource = now.of === 'instance' ? { of: 'instance', axis: now.axis } : { of: 'part', part: now.part, axis: now.axis }
276
+ const times = now.of === 'instance' ? now.times : (now.times ?? 1)
277
+ this.command({ kind: 'link-dimension', part: this.partId, axis, source, ...(times !== 1 ? { times } : {}), ...(plus ? { plus } : {}), replace: true })
278
+ }
279
+
248
280
  private follow(asset: V3Asset, axis: Axis, choice: string, now: Follow) {
249
281
  const following = now.of !== 'own'
250
282
  /* The measure as it stands: a dimension stops following at it, and a share is taken of it. */
@@ -382,8 +414,11 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
382
414
  <option value="max" ?selected=${how === 'max'}>${i18next.t(`figure.label.align-${axis}-max`)}</option>`
383
415
  : nothing}
384
416
  <option value="mm" ?selected=${how === 'mm'}>${i18next.t('figure.label.align-mm')}</option>
417
+ ${this.asset && hasInstanceSize(this.asset, axis)
418
+ ? html`<option value="share" ?selected=${how === 'share'}>${i18next.t('figure.label.align-share')}</option>`
419
+ : nothing}
385
420
  </select>
386
- ${how === 'mm'
421
+ ${how === 'mm' || how === 'share'
387
422
  ? html`<span></span><input type="number" step="any" aria-label=${i18next.t('figure.label.align-mm')} .value=${String(f.alignMm[axis] ?? 0)} @change=${(e: Event) => setMm(axis, Number((e.target as HTMLInputElement).value) || 0)} />`
388
423
  : nothing}`
389
424
  })}
@@ -438,6 +473,9 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
438
473
  return html`<div pair>
439
474
  <span>
440
475
  ${i18next.t('figure.label.motion-' + now.kind)} · ${String(now.axis).toUpperCase()}
476
+ ${now.travel
477
+ ? html`<small>${i18next.t('figure.label.motion-travel-is', { part: now.travel.part, axis: now.travel.axis.toUpperCase(), plus: now.travel.plus ?? 0 })}</small>`
478
+ : nothing}
441
479
  ${now.follows
442
480
  ? html`<small>${i18next.t('figure.label.motion-follows', { joint: now.follows.joint, multiplier: now.follows.multiplier, offset: now.follows.offset ?? 0 })}</small>`
443
481
  : now.control
@@ -488,9 +526,23 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
488
526
  <input type="number" step="any" .value=${String(m.offset)} @change=${(e: Event) => (this.motion = { ...m, offset: Number((e.target as HTMLInputElement).value) || 0 })} />
489
527
  </div>`
490
528
  : html`<div form>
529
+ ${m.kind === 'slide'
530
+ ? html`<span>${i18next.t('figure.label.motion-travel')}</span>
531
+ <select @change=${(e: Event) => (this.motion = { ...m, travel: (e.target as HTMLSelectElement).value })}>
532
+ <option value="" ?selected=${!m.travel}>${i18next.t('figure.label.motion-travel-mm')}</option>
533
+ ${asset.appearance
534
+ .map(look => look.target)
535
+ .filter(one => canFollow(asset, one))
536
+ .flatMap(one => AXES.map(a => html`<option value=${`${one}:${a}`} ?selected=${m.travel === `${one}:${a}`}>${one} · ${a.toUpperCase()}</option>`))}
537
+ </select>
538
+ ${m.travel
539
+ ? html`<span>${i18next.t('figure.label.follows-plus')}</span>
540
+ <input type="number" step="any" .value=${String(m.travelPlus ?? 0)} @change=${(e: Event) => (this.motion = { ...m, travelPlus: Number((e.target as HTMLInputElement).value) || 0 })} />`
541
+ : nothing}`
542
+ : nothing}
491
543
  <span>${i18next.t('figure.label.control-id')}</span>
492
544
  <input .value=${m.id} placeholder=${`${this.partId}-${m.kind}`} @change=${(e: Event) => (this.motion = { ...m, id: (e.target as HTMLInputElement).value.trim() })} />
493
- ${m.kind === 'continuous'
545
+ ${m.kind === 'continuous' || (m.kind === 'slide' && m.travel)
494
546
  ? nothing
495
547
  : html`<span>${i18next.t('figure.label.control-range')} (${unit})</span>
496
548
  <div pair>
@@ -500,7 +552,7 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
500
552
  </div>`}
501
553
  <button
502
554
  act
503
- ?disabled=${this.locked || (follow ? !(Number.isFinite(m.multiplier) && m.multiplier !== 0) : m.kind !== 'continuous' && !(m.max > m.min && m.min <= 0 && 0 <= m.max))}
555
+ ?disabled=${this.locked || (follow ? !(Number.isFinite(m.multiplier) && m.multiplier !== 0) : m.kind !== 'continuous' && !(m.kind === 'slide' && m.travel) && !(m.max > m.min && m.min <= 0 && 0 <= m.max))}
504
556
  title=${!follow && m.kind !== 'continuous' && !(m.min <= 0 && 0 <= m.max) ? i18next.t('figure.text.joint-range-holds-zero') : ''}
505
557
  @click=${() => this.command({ kind: 'set-joint', joint: motionJoint(joint, { ...m, follow }, this.partId) })}
506
558
  >
@@ -28,7 +28,7 @@ import type { V3PaletteKind, V3PartRow } from './figure-v3-parts.js'
28
28
  import { paletteRecipe } from './v3-palette.js'
29
29
  import type { FigureV3Canvas } from './v3-canvas.js'
30
30
  import { nudgeOf } from './v3-stage-model.js'
31
- import { sizeRangeRefusalOf } from './v3-refusals.js'
31
+ import { narrowingRetryOf, sizeRangeRefusalOf } from './v3-refusals.js'
32
32
  import type { V3RenderPart } from './v3-renderer.js'
33
33
  import type { V3BudgetLine } from './v3-maturity.js'
34
34
  import { instanceRangeOf, instanceRangeToDeclare, rangeDiffers, turnsAboutAJoint } from './v3-links.js'
@@ -238,6 +238,8 @@ export class FigureV3Editor extends LitElement {
238
238
  @property({ attribute: false }) firstSave?: (name: string) => Promise<FirstSave | undefined>
239
239
  @state() private selected = ''
240
240
  @state() private error = ''
241
+ /** The same link offered again with the size range narrowed, after a link the declared range could not hold. */
242
+ @state() private retry?: { label: string; action: V3AuthoringAction }
241
243
  @state() private tab: SideTab = 'properties'
242
244
  /** The frame the bar folds - found after the first render, since the bar is drawn before it. */
243
245
  @state() private panes?: OxThreePane
@@ -654,12 +656,19 @@ export class FigureV3Editor extends LitElement {
654
656
  }
655
657
  /** One command, one step of the history. A refusal a person can meet here is said in their words. */
656
658
  private linkCommand(action: V3AuthoringAction) {
659
+ this.retry = undefined
657
660
  try {
658
661
  this.session!.command(action)
659
662
  this.error = ''
660
663
  this.changed()
661
664
  } catch (error) {
662
665
  const code = (error as { code?: string })?.code
666
+ const narrowing = narrowingRetryOf(action, error, (key, params) => i18next.t(key, params))
667
+ if (narrowing) {
668
+ this.error = narrowing.message
669
+ this.retry = { label: narrowing.label, action: narrowing.retry as V3AuthoringAction }
670
+ return
671
+ }
663
672
  this.error =
664
673
  code === 'DIMENSION_CYCLE'
665
674
  ? i18next.t('figure.text.follow-would-loop')
@@ -973,7 +982,14 @@ export class FigureV3Editor extends LitElement {
973
982
  <span slot="status" class="status" role="status" ?dirty=${status === 'figure.text.unsaved-changes'}>${i18next.t(status)}</span>
974
983
  <button slot="commit" save ?disabled=${unavailable || (!view.dirty && !!view.id)} @click=${this.save}>${i18next.t('figure.button.save')}</button>
975
984
  </ox-canvas-bar>
976
- ${this.error || view.error ? html`<div role="alert" class="error">${this.error || view.error}</div>` : null}
985
+ ${this.error || view.error
986
+ ? html`<div role="alert" class="error">
987
+ ${this.error || view.error}
988
+ ${this.retry && this.error
989
+ ? html`<button act @click=${() => this.linkCommand(this.retry!.action)}><md-icon>straighten</md-icon>${this.retry.label}</button>`
990
+ : null}
991
+ </div>`
992
+ : null}
977
993
  <ox-three-pane>
978
994
  <figure-v3-parts
979
995
  slot="start"
@@ -23,8 +23,8 @@ const modelOf = (asset: V3Asset) =>
23
23
  export type Follow =
24
24
  | { of: 'own' }
25
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 }
27
- | { of: 'part'; part: string; axis: Axis }
26
+ | { of: 'instance'; axis: Axis; times: number; plus?: number }
27
+ | { of: 'part'; part: string; axis: Axis; times?: number; plus?: number }
28
28
  | { of: 'input'; input: string }
29
29
  /** Worked out some other way (the converter writes expressions) — shown, not offered as a choice. */
30
30
  | { of: 'expression' }
@@ -48,13 +48,29 @@ export function followOf(asset: V3Asset, part: string, axis: Axis): Follow {
48
48
  const constant = (ref: string) => m.constants.find(c => c.id === ref)
49
49
  /* The constant factors met on the way back: a share of the instance size is `size × share`. */
50
50
  let times = 1
51
+ /*
52
+ A link the author made is `source × times + plus` (link-dimension): its outermost sum with a number is the plus --
53
+ a leaf the opening's width less its gaps. Deeper sums, or a measure no link routes, read as worked out elsewhere.
54
+ */
55
+ let plus = 0
56
+ let at = start
57
+ const outer = node && writerOf(start)
58
+ if (outer?.op === 'add@1') {
59
+ const numbers = outer.args.filter(arg => constant(arg))
60
+ const onward = outer.args.filter(arg => !constant(arg))
61
+ if (numbers.length === 1 && onward.length === 1) {
62
+ plus = constant(numbers[0]!)!.value
63
+ at = onward[0]!
64
+ }
65
+ }
66
+ const withPlus = <T extends object>(f: T) => (plus ? { ...f, plus } : f)
51
67
  const walk = (ref: string): Follow => {
52
68
  if (seen.has(ref)) return { of: 'expression' }
53
69
  seen.add(ref)
54
70
  const instance = /^size\.([xyz])$/.exec(ref)
55
- if (instance) return { of: 'instance', axis: instance[1] as Axis, times: Math.round(times * 1e6) / 1e6 }
71
+ if (instance) return withPlus({ of: 'instance' as const, axis: instance[1] as Axis, times: Math.round(times * 1e6) / 1e6 })
56
72
  const other = /^(.+)\.dimension\.([xyz])\.value$/.exec(ref)
57
- if (other) return { of: 'part', part: other[1]!, axis: other[2] as Axis }
73
+ if (other) return withPlus({ of: 'part' as const, part: other[1]!, axis: other[2] as Axis, ...(times !== 1 ? { times: Math.round(times * 1e6) / 1e6 } : {}) })
58
74
  const input = m.inputs.find(i => i.id === ref)
59
75
  if (input) return ref.startsWith(`${part}.`) ? { of: 'own' } : { of: 'input', input: ref }
60
76
  const writer = writerOf(ref)
@@ -72,7 +88,7 @@ export function followOf(asset: V3Asset, part: string, axis: Axis): Follow {
72
88
  else if (factors.some(v => v !== 0) || !['mul@1', 'div@1', 'add@1', 'sub@1'].includes(writer.op)) return { of: 'expression' }
73
89
  return walk(onward[0]!)
74
90
  }
75
- return walk(start)
91
+ return walk(at)
76
92
  }
77
93
 
78
94
  /** Whether the asset declares an instance size on an axis — only then can a dimension follow it. */
@@ -154,7 +170,8 @@ export function rowAxesOf(asset: V3Asset, part: string): Axis[] {
154
170
  export const rowOf = (asset: V3Asset, part: string) => asset.joints?.find(o => o.body1.part === part)?.repeat
155
171
 
156
172
  /** Where a part sits across the face it is fastened to, on one axis: centred, level with an edge, or a distance off centre. */
157
- export type Alignment = 'centre' | 'min' | 'max' | 'mm'
173
+ /** `share`: the distance as a share of the figure's size on that axis, so it keeps its place when the figure is resized. */
174
+ export type Alignment = 'centre' | 'min' | 'max' | 'mm' | 'share'
158
175
 
159
176
  /**
160
177
  * The axes across the face a part is fastened to — the two a fastening does not set by itself. Edges to line up with
@@ -180,10 +197,13 @@ export function alignOf(
180
197
  mm: Partial<Record<Axis, number>>,
181
198
  twisted = false
182
199
  ) {
183
- const align: Partial<Record<Axis, 'min' | 'max' | { mm: number }>> = {}
200
+ const align: Partial<Record<Axis, 'min' | 'max' | { mm: number | { from: { of: 'instance'; axis: Axis }; times: number } }>> = {}
184
201
  for (const { axis, edges } of alignAxesOf(asset, part, target, twisted)) {
185
202
  const one = how[axis] ?? 'centre'
186
- if (one === 'mm' && mm[axis]) align[axis] = { mm: mm[axis]! }
203
+ const size = asset.designInputs[`size.${axis}`]
204
+ if (one === 'share' && mm[axis] && hasInstanceSize(asset, axis) && size)
205
+ align[axis] = { mm: { from: { of: 'instance', axis }, times: Math.round((mm[axis]! / size) * 1e6) / 1e6 } }
206
+ else if (one === 'mm' && mm[axis]) align[axis] = { mm: mm[axis]! }
187
207
  else if ((one === 'min' || one === 'max') && edges) align[axis] = one
188
208
  }
189
209
  return Object.keys(align).length ? align : undefined
@@ -220,6 +240,8 @@ export interface Motion {
220
240
  control?: { id: string; unit?: string; min?: number; max?: number }
221
241
  /** A joint that moves with another (URDF's mimic): the leader's value times `multiplier`, plus `offset`. */
222
242
  follows?: { joint: string; multiplier: number; offset?: number }
243
+ /** A slide whose distance is a part's length (plus a difference): the control is its share. */
244
+ travel?: { part: string; axis: string; plus?: number }
223
245
  }
224
246
 
225
247
  export function motionOf(asset: V3Asset, part: string): Motion | undefined {
@@ -246,7 +268,8 @@ export function motionOf(asset: V3Asset, part: string): Motion | undefined {
246
268
  }
247
269
  return undefined
248
270
  }
249
- return { kind, axis, control: control(quantity), ...(record?.mimic ? { follows: record.mimic } : {}) }
271
+ const travel = record?.travel?.source && 'part' in record.travel.source ? { part: record.travel.source.part, axis: record.travel.source.axis, ...(record.travel.plus ? { plus: record.travel.plus } : {}) } : undefined
272
+ return { kind, axis, control: control(quantity), ...(record?.mimic ? { follows: record.mimic } : {}), ...(travel ? { travel } : {}) }
250
273
  }
251
274
 
252
275
  /** What the motion form holds: how the part moves, and either its own control or the joint it moves with. */
@@ -260,6 +283,12 @@ export interface MotionForm {
260
283
  follow: string
261
284
  multiplier: number
262
285
  offset: number
286
+ /**
287
+ * A slide whose distance follows a part's length (figure-model `travel`): `<part>:<axis>`, empty for a distance in
288
+ * millimetres. The control is then the share of that length, 0..1, and the stroke grows when the part does.
289
+ */
290
+ travel?: string
291
+ travelPlus?: number
263
292
  }
264
293
 
265
294
  /**
@@ -285,6 +314,10 @@ export function motionJoint(joint: V3Joint, m: MotionForm, part: string): V3Join
285
314
  axis: m.axis.toUpperCase() as 'X' | 'Y' | 'Z'
286
315
  }
287
316
  if (m.follow) return { ...moving, mimic: { joint: m.follow, multiplier: m.multiplier, ...(m.offset ? { offset: m.offset } : {}) } }
317
+ if (m.kind === 'slide' && m.travel) {
318
+ const [of, axis] = m.travel.split(':')
319
+ return { ...moving, travel: { source: { of: 'part', part: of!, axis: axis as Axis }, ...(m.travelPlus ? { plus: m.travelPlus } : {}) }, state: { id: m.id || `${part}-${m.kind}` } }
320
+ }
288
321
  return { ...moving, ...(m.kind === 'continuous' ? {} : { lowerLimit: m.min, upperLimit: m.max }), state: { id: m.id || `${part}-${m.kind}` } }
289
322
  }
290
323
 
@@ -19,3 +19,22 @@ export function sizeRangeRefusalOf(error: unknown, say: Say): string | null {
19
19
  part: part ?? ''
20
20
  })
21
21
  }
22
+
23
+ /**
24
+ * A link refused because the size range it would leave cannot hold: measuring again cannot help (the link is not there
25
+ * to measure), so the author is offered the same link that narrows the range to what the figure then allows.
26
+ */
27
+ export function narrowingRetryOf<A extends { kind: string }>(action: A, error: unknown, say: Say): { message: string; label: string; retry: A & { narrowRange: true } } | null {
28
+ if (action.kind !== 'link-dimension' || (error as { code?: string })?.code !== 'INSTANCE_SIZE_RANGE') return null
29
+ const message = error instanceof Error ? error.message : String(error)
30
+ const found = /size\.([xyz]):\s*(below|above) ([\d.]+) mm [^(;]*(?:\(([^)]+)\))?;/.exec(message)
31
+ if (!found) return null
32
+ const [, axis, side, raw, part] = found
33
+ const mm = Number(raw).toLocaleString(undefined, { maximumFractionDigits: 1 })
34
+ const words = { axis: axis!.toUpperCase(), mm, part: part ?? '' }
35
+ return {
36
+ message: say(side === 'below' ? 'figure.text.link-needs-smaller-range' : 'figure.text.link-needs-larger-range', words),
37
+ label: say(side === 'below' ? 'figure.button.narrow-range-from' : 'figure.button.narrow-range-to', words),
38
+ retry: { ...action, narrowRange: true }
39
+ }
40
+ }
@@ -17,6 +17,12 @@ import type { V3BudgetLine } from '../modeller/v3-maturity.js'
17
17
  import { OxPrompt } from '@operato/popup'
18
18
  import { openPopup } from '@operato/layout'
19
19
  import '../modeller/figure-v3-first-save.js'
20
+ import { activePalette } from '@hatiolab/things-scene'
21
+ import {
22
+ clearAssistantSurface,
23
+ notifyAssistantSurface,
24
+ provideAssistantSurface
25
+ } from '@things-factory/ai-assistant/dist-client/index.js'
20
26
  import type { FirstSave } from '../modeller/v3-first-save.js'
21
27
 
22
28
  /**
@@ -119,20 +125,45 @@ export class FigureV3ModellerPage extends PageView {
119
125
  }
120
126
  private wasDirty = false
121
127
  private titled?: string | null
122
- private announce = () => {
128
+ /** The figure the AI dock was last told about - another id is another conversation. */
129
+ private assistantFor?: string
130
+ /*
131
+ The figure on screen is what the AI dock talks about (design/ai/shared-dock.md §3). A figure not saved yet has no
132
+ row to hang a conversation on, so it provides nothing and the dock stays on the application's conversation.
133
+ The draft is read at the moment of sending, never cached - the dock must not answer about a shape already changed.
134
+ */
135
+ private provideAssistant = () => {
123
136
  const view = this.session.read()
124
- if (!this.active || view.loading || view.saving || view.error || !view.context) return
125
- window.dispatchEvent(
126
- new CustomEvent('figure-ai-context', {
127
- detail: {
128
- figureId: view.id,
129
- source: JSON.parse(this.session.source()),
130
- context: view.context,
131
- revision: view.revision,
132
- format: 'v3'
133
- }
137
+ if (!this.active || view.loading || view.error || !view.context) return
138
+ if (!view.id) {
139
+ this.withdrawAssistant()
140
+ return
141
+ }
142
+ if (this.assistantFor === view.id) {
143
+ notifyAssistantSurface(this)
144
+ return
145
+ }
146
+ const id = view.id
147
+ this.assistantFor = id
148
+ provideAssistantSurface(this, {
149
+ key: `figure:${id}`,
150
+ subject: 'figure',
151
+ icon: 'category',
152
+ label: () => this.session.read().name || id,
153
+ detail: () => (this.session.read().dirty ? i18next.t('figure.text.unsaved-changes') : ''),
154
+ anchor: () => ({ anchorType: 'figure', anchorId: id }),
155
+ hostContext: () => ({
156
+ surface: 'figure-modeller',
157
+ editorMode: 'existing',
158
+ figureId: id,
159
+ draftSource: JSON.parse(this.session.source()),
160
+ palette: Object.keys(activePalette())
134
161
  })
135
- )
162
+ })
163
+ }
164
+ private withdrawAssistant() {
165
+ this.assistantFor = undefined
166
+ clearAssistantSurface(this)
136
167
  }
137
168
  private changed = () => {
138
169
  this.revision++
@@ -144,7 +175,7 @@ export class FigureV3ModellerPage extends PageView {
144
175
  this.titled = view.name
145
176
  this.updateContext()
146
177
  }
147
- if (!view.saving) this.announce()
178
+ if (!view.saving) this.provideAssistant()
148
179
  /*
149
180
  A new figure's first save gives it an id: the address becomes the figure's own, in place of the history entry.
150
181
  Left at the bare modeller address, opening the modeller again from the menu was taken for the same page and kept
@@ -279,11 +310,10 @@ export class FigureV3ModellerPage extends PageView {
279
310
  }
280
311
  connectedCallback() {
281
312
  super.connectedCallback()
282
- window.addEventListener('figure-ai-context-request', this.announce)
283
313
  window.addEventListener('figure-v3-apply', this.applyAI)
284
314
  }
285
315
  disconnectedCallback() {
286
- window.removeEventListener('figure-ai-context-request', this.announce)
316
+ this.withdrawAssistant()
287
317
  window.removeEventListener('figure-v3-apply', this.applyAI)
288
318
  super.disconnectedCallback()
289
319
  }
@@ -307,7 +337,7 @@ export class FigureV3ModellerPage extends PageView {
307
337
  async pageUpdated(changes: Record<string, unknown>, lifecycle: { resourceId?: string }) {
308
338
  if (!this.active) {
309
339
  if (this.session.read().loading) this.session.close()
310
- window.dispatchEvent(new CustomEvent('figure-ai-context', { detail: undefined }))
340
+ this.withdrawAssistant()
311
341
  return
312
342
  }
313
343
  const id = lifecycle?.resourceId
@@ -0,0 +1 @@
1
+ export declare const FIGURE_CANDIDATE_TOOL = "proposeFigureCandidate";
@@ -0,0 +1,19 @@
1
+ /*
2
+ * What the AI dock does with a figure candidate - figure-ui declares it because figure-service declares the tool
3
+ * (`proposeFigureCandidate`, design/ai/shared-dock.md §4).
4
+ *
5
+ * Today a candidate cannot be taken anywhere. The proposer writes V2 figures and the modeller opens V3 only; the V2
6
+ * modeller that reviewed candidates was removed with its listeners (`figure-ai-review`, `figure-ai-stage`), and the
7
+ * server already refuses to propose for a V3 figure (`V3_PROPOSAL_UNAVAILABLE`). So the outcome is `unavailable`,
8
+ * said to the person in words, not a silent pass - until a proposer writes V3 changes, which the modeller takes
9
+ * through `figure-v3-apply`.
10
+ */
11
+ import { i18next } from '@operato/i18n';
12
+ import { registerProposalHandler } from '@things-factory/ai-assistant/dist-client/index.js';
13
+ export const FIGURE_CANDIDATE_TOOL = 'proposeFigureCandidate';
14
+ registerProposalHandler(FIGURE_CANDIDATE_TOOL, async (_proposal, context) => {
15
+ const note = i18next.t('figure.text.candidate-cannot-open');
16
+ context.notify('info', note);
17
+ return { verdict: 'unavailable', errorCode: 'v3-review-missing', note };
18
+ });
19
+ //# sourceMappingURL=figure-assistant.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"figure-assistant.js","sourceRoot":"","sources":["../client/figure-assistant.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,eAAe,CAAA;AACvC,OAAO,EAAE,uBAAuB,EAAE,MAAM,mDAAmD,CAAA;AAE3F,MAAM,CAAC,MAAM,qBAAqB,GAAG,wBAAwB,CAAA;AAE7D,uBAAuB,CAAC,qBAAqB,EAAE,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,EAAE;IAC1E,MAAM,IAAI,GAAG,OAAO,CAAC,CAAC,CAAC,mCAAmC,CAAC,CAAA;IAC3D,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,CAAA;IAC5B,OAAO,EAAE,OAAO,EAAE,aAAa,EAAE,SAAS,EAAE,mBAAmB,EAAE,IAAI,EAAE,CAAA;AACzE,CAAC,CAAC,CAAA","sourcesContent":["/*\n * What the AI dock does with a figure candidate - figure-ui declares it because figure-service declares the tool\n * (`proposeFigureCandidate`, design/ai/shared-dock.md §4).\n *\n * Today a candidate cannot be taken anywhere. The proposer writes V2 figures and the modeller opens V3 only; the V2\n * modeller that reviewed candidates was removed with its listeners (`figure-ai-review`, `figure-ai-stage`), and the\n * server already refuses to propose for a V3 figure (`V3_PROPOSAL_UNAVAILABLE`). So the outcome is `unavailable`,\n * said to the person in words, not a silent pass - until a proposer writes V3 changes, which the modeller takes\n * through `figure-v3-apply`.\n */\nimport { i18next } from '@operato/i18n'\nimport { registerProposalHandler } from '@things-factory/ai-assistant/dist-client/index.js'\n\nexport const FIGURE_CANDIDATE_TOOL = 'proposeFigureCandidate'\n\nregisterProposalHandler(FIGURE_CANDIDATE_TOOL, async (_proposal, context) => {\n const note = i18next.t('figure.text.candidate-cannot-open')\n context.notify('info', note)\n return { verdict: 'unavailable', errorCode: 'v3-review-missing', note }\n})\n"]}
@@ -1,2 +1,3 @@
1
1
  export * from './types.js';
2
2
  export * from './graphql/index.js';
3
+ export * from './figure-assistant.js';
@@ -1,3 +1,4 @@
1
1
  export * from './types.js';
2
2
  export * from './graphql/index.js';
3
+ export * from './figure-assistant.js';
3
4
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../client/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAA;AAC1B,cAAc,oBAAoB,CAAA","sourcesContent":["export * from './types.js'\nexport * from './graphql/index.js'\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../client/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAA;AAC1B,cAAc,oBAAoB,CAAA;AAClC,cAAc,uBAAuB,CAAA","sourcesContent":["export * from './types.js'\nexport * from './graphql/index.js'\nexport * from './figure-assistant.js'\n"]}
@@ -15,7 +15,7 @@ export declare class FigureV3Capability extends FigureV3Capability_base {
15
15
  /** The selected part, whose places are marked. */
16
16
  selected: string;
17
17
  locked: boolean;
18
- render(): typeof nothing | import("lit-html").TemplateResult<1>;
18
+ render(): import("lit-html").TemplateResult<1> | typeof nothing;
19
19
  private setRung;
20
20
  private pick;
21
21
  }
@@ -18,7 +18,7 @@ export declare class FigureV3Clips extends FigureV3Clips_base {
18
18
  /** Where each clip is scrubbed to, in seconds. */
19
19
  private at;
20
20
  private adding;
21
- render(): typeof nothing | import("lit-html").TemplateResult<1>;
21
+ render(): import("lit-html").TemplateResult<1> | typeof nothing;
22
22
  private renderClip;
23
23
  private renderTrack;
24
24
  private renderAdd;
@@ -22,7 +22,7 @@ export declare class FigureV3Controls extends FigureV3Controls_base {
22
22
  private open;
23
23
  /** Values in hand while a slider is dragged: the dial and the number follow them before the release commits. */
24
24
  private live;
25
- render(): typeof nothing | import("lit-html").TemplateResult<1>;
25
+ render(): import("lit-html").TemplateResult<1> | typeof nothing;
26
26
  private renderControl;
27
27
  private renderDial;
28
28
  private renderSettings;
@@ -38,6 +38,8 @@ export declare class FigureV3Links extends FigureV3Links_base {
38
38
  /** The figure holds things: `Capacity` and `Holdable`, the ladder's middle rung. One part edit, one undo step. */
39
39
  private declareHolding;
40
40
  private renderDimensions;
41
+ /** The same source, the same share, a new difference: a leaf the opening less its gaps (link-dimension's `plus`). */
42
+ private followPlus;
41
43
  private follow;
42
44
  /**
43
45
  * Positions worked out from other values — V2's anchor rules, as the converter wrote them — each with the way to