@things-factory/figure-ui 10.1.52 → 10.1.54

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 (53) 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 +19 -8
  5. package/client/modeller/v3-links.ts +45 -12
  6. package/client/modeller/v3-refusals.ts +38 -0
  7. package/client/pages/figure-v3-modeller-page.ts +49 -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-clips.d.ts +1 -1
  23. package/dist-client/modeller/v3-editor.d.ts +2 -0
  24. package/dist-client/modeller/v3-editor.js +21 -8
  25. package/dist-client/modeller/v3-editor.js.map +1 -1
  26. package/dist-client/modeller/v3-links.d.ts +24 -2
  27. package/dist-client/modeller/v3-links.js +32 -8
  28. package/dist-client/modeller/v3-links.js.map +1 -1
  29. package/dist-client/modeller/v3-refusals.d.ts +19 -0
  30. package/dist-client/modeller/v3-refusals.js +43 -0
  31. package/dist-client/modeller/v3-refusals.js.map +1 -1
  32. package/dist-client/pages/figure-v3-modeller-page.d.ts +4 -1
  33. package/dist-client/pages/figure-v3-modeller-page.js +44 -15
  34. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  35. package/dist-client/route.d.ts +1 -1
  36. package/dist-client/tsconfig.tsbuildinfo +1 -1
  37. package/package.json +6 -5
  38. package/test/browser/modeller.check.mjs +39 -0
  39. package/test/figure-assistant-surface.test.ts +52 -0
  40. package/test/v3-first-save.test.ts +8 -0
  41. package/test/v3-links.test.ts +74 -2
  42. package/test/v3-parts-wiring.test.ts +4 -2
  43. package/test/v3-refusals.test.ts +51 -0
  44. package/translations/en.json +15 -1
  45. package/translations/ja.json +15 -1
  46. package/translations/ko.json +15 -1
  47. package/translations/ms.json +15 -1
  48. package/translations/zh.json +15 -1
  49. package/client/modeller/figure-ai-target.ts +0 -23
  50. package/dist-client/modeller/figure-ai-target.d.ts +0 -18
  51. package/dist-client/modeller/figure-ai-target.js +0 -22
  52. package/dist-client/modeller/figure-ai-target.js.map +0 -1
  53. 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, partRefusalOf, 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
@@ -425,12 +427,7 @@ export class FigureV3Editor extends LitElement {
425
427
  this.changed()
426
428
  } catch (error) {
427
429
  /* The refusal a person meets most: another part stands on this one or follows its size. Said in their words. */
428
- this.error =
429
- (error as { code?: string })?.code === 'PART_REFERENCED'
430
- ? i18next.t('figure.text.part-is-referenced', { part: id })
431
- : error instanceof Error
432
- ? error.message
433
- : String(error)
430
+ this.error = partRefusalOf(error, id, (key, params) => i18next.t(key, params)) ?? (error instanceof Error ? error.message : String(error))
434
431
  }
435
432
  }
436
433
  private toggleHiddenPart(id: string) {
@@ -654,12 +651,19 @@ export class FigureV3Editor extends LitElement {
654
651
  }
655
652
  /** One command, one step of the history. A refusal a person can meet here is said in their words. */
656
653
  private linkCommand(action: V3AuthoringAction) {
654
+ this.retry = undefined
657
655
  try {
658
656
  this.session!.command(action)
659
657
  this.error = ''
660
658
  this.changed()
661
659
  } catch (error) {
662
660
  const code = (error as { code?: string })?.code
661
+ const narrowing = narrowingRetryOf(action, error, (key, params) => i18next.t(key, params))
662
+ if (narrowing) {
663
+ this.error = narrowing.message
664
+ this.retry = { label: narrowing.label, action: narrowing.retry as V3AuthoringAction }
665
+ return
666
+ }
663
667
  this.error =
664
668
  code === 'DIMENSION_CYCLE'
665
669
  ? i18next.t('figure.text.follow-would-loop')
@@ -973,7 +977,14 @@ export class FigureV3Editor extends LitElement {
973
977
  <span slot="status" class="status" role="status" ?dirty=${status === 'figure.text.unsaved-changes'}>${i18next.t(status)}</span>
974
978
  <button slot="commit" save ?disabled=${unavailable || (!view.dirty && !!view.id)} @click=${this.save}>${i18next.t('figure.button.save')}</button>
975
979
  </ox-canvas-bar>
976
- ${this.error || view.error ? html`<div role="alert" class="error">${this.error || view.error}</div>` : null}
980
+ ${this.error || view.error
981
+ ? html`<div role="alert" class="error">
982
+ ${this.error || view.error}
983
+ ${this.retry && this.error
984
+ ? html`<button act @click=${() => this.linkCommand(this.retry!.action)}><md-icon>straighten</md-icon>${this.retry.label}</button>`
985
+ : null}
986
+ </div>`
987
+ : null}
977
988
  <ox-three-pane>
978
989
  <figure-v3-parts
979
990
  slot="start"
@@ -1,4 +1,4 @@
1
- import { V3_FACES, v3ShapeFaces, proposeV3Occupancy, compileV3Graph, defaultInstanceSizeRange, foldInstanceSizeRange, v3AttachmentsOf, v3BindingsOf, v3InstanceSizeLimitsOf } from '@hatiolab/figure-model'
1
+ import { V3_FACES, v3ShapeFaces, v3PartsExtentOf, compileV3Graph, defaultInstanceSizeRange, foldInstanceSizeRange, v3AttachmentsOf, v3BindingsOf, v3InstanceSizeLimitsOf } from '@hatiolab/figure-model'
2
2
  import type { V3SizeRange } from '@hatiolab/figure-model'
3
3
  import type { V3Asset, V3BindingPlace, V3Face, V3Joint } from '@hatiolab/figure-model'
4
4
 
@@ -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
 
@@ -366,8 +399,8 @@ export function instanceRangeOf(asset: V3Asset): { declared: V3SizeRange; measur
366
399
  */
367
400
  export function instanceRangeToDeclare(asset: V3Asset): { range: V3SizeRange; at: Record<Axis, number> } | undefined {
368
401
  if (instanceRangeOf(asset) || !asset.appearance.length) return undefined
369
- const proposal = proposeV3Occupancy(asset, { over: 'rest' })
370
- const at = Object.fromEntries(AXES.map(axis => [axis, Math.round((proposal.at[axis].max - proposal.at[axis].min) * 1e6) / 1e6])) as Record<Axis, number>
402
+ /* The parts as they rest -- not the room their joints swing them through, which declared a door 8208 mm every way. */
403
+ const at = v3PartsExtentOf(asset) as Record<Axis, number>
371
404
  if (AXES.some(axis => !(at[axis] > 0))) return undefined
372
405
  const start = defaultInstanceSizeRange(at)
373
406
  return { range: foldInstanceSizeRange(start, v3InstanceSizeLimitsOf(asset, start)).range, at }
@@ -19,3 +19,41 @@ 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
+ }
41
+
42
+ /**
43
+ * Why a part cannot be deleted, in the author's words -- figure-model names four reasons (PART_REFERENCED): parts hang
44
+ * from it, the declared space holds it, another part follows its size or place, or a joint moves with its joint. One
45
+ * sentence for all of them told the author to undo a link that was not there (the architect's workbench, 2026-09-25).
46
+ */
47
+ export function partRefusalOf(error: unknown, part: string, say: Say): string | null {
48
+ if ((error as { code?: string })?.code !== 'PART_REFERENCED') return null
49
+ /* figure-model's sentence after its code and path: 「PART_REFERENCED: top: top holds leg-2, lamp; …」. */
50
+ const message = (error instanceof Error ? error.message : String(error)).replace(/^[A-Z_]+: [^:]+: /, '')
51
+ const holds = / holds (.+?); take (?:them|it) off first/.exec(message)
52
+ if (holds) return say('figure.text.part-holds-others', { part, others: holds[1]! })
53
+ if (/is in the space the figure declares/.test(message)) return say('figure.text.part-in-declared-space', { part })
54
+ const moves = /^(.+?) (?:moves|move) with /.exec(message)
55
+ if (moves) return say('figure.text.part-is-led', { part, others: moves[1]! })
56
+ const follows = /^(.+?) (?:follows|follow) .+'s size or place/.exec(message)
57
+ if (follows) return say('figure.text.part-is-followed', { part, others: follows[1]! })
58
+ return say('figure.text.part-is-referenced', { part })
59
+ }
@@ -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
@@ -317,6 +347,9 @@ export class FigureV3ModellerPage extends PageView {
317
347
  this.discardApproved = false
318
348
  if (id) {
319
349
  this.draft = undefined
350
+ /* The row is read after the figure opens; until then the last figure's type must not stand in the field (C2). */
351
+ this.row = { version: 0 }
352
+ this.typeError = ''
320
353
  const opening = this.session.open(id, { discard })
321
354
  this.changed()
322
355
  await opening
@@ -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