@things-factory/figure-ui 10.1.51 → 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 +37 -5
  5. package/client/modeller/v3-links.ts +58 -10
  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 +3 -1
  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 +39 -4
  24. package/dist-client/modeller/v3-editor.js.map +1 -1
  25. package/dist-client/modeller/v3-links.d.ts +34 -2
  26. package/dist-client/modeller/v3-links.js +46 -6
  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 +64 -0
  37. package/test/figure-assistant-surface.test.ts +52 -0
  38. package/test/v3-links.test.ts +81 -2
  39. package/test/v3-refusals.test.ts +21 -0
  40. package/translations/en.json +13 -1
  41. package/translations/ja.json +13 -1
  42. package/translations/ko.json +13 -1
  43. package/translations/ms.json +13 -1
  44. package/translations/zh.json +13 -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,10 +28,10 @@ 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
- import { instanceRangeOf, rangeDiffers, turnsAboutAJoint } from './v3-links.js'
34
+ import { instanceRangeOf, instanceRangeToDeclare, rangeDiffers, turnsAboutAJoint } from './v3-links.js'
35
35
  import { jointFanOf } from './v3-joint-fan.js'
36
36
  import { jointDragOf } from './v3-joint-drag.js'
37
37
  import type { FirstSave } from './v3-first-save.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')
@@ -721,7 +730,7 @@ export class FigureV3Editor extends LitElement {
721
730
  label.addEventListener('pointercancel', up)
722
731
  }
723
732
  /** The measured range, kept for the revision it was measured at: measuring evaluates the figure at its edges. */
724
- private range?: { at: string; of: ReturnType<typeof instanceRangeOf> }
733
+ private range?: { at: string; of: ReturnType<typeof instanceRangeOf>; toDeclare?: ReturnType<typeof instanceRangeToDeclare> }
725
734
  /** The sizes a board may give the figure, and the way to measure them again after its parts changed. */
726
735
  private renderInstanceRange(asset: V3Asset | undefined, unavailable: boolean) {
727
736
  if (!asset) return nothing
@@ -729,14 +738,30 @@ export class FigureV3Editor extends LitElement {
729
738
  const at = `${this.session?.read().context ?? ''}:${this.revision}`
730
739
  if (this.range?.at !== at) {
731
740
  let of: ReturnType<typeof instanceRangeOf>
741
+ let toDeclare: ReturnType<typeof instanceRangeToDeclare>
732
742
  try {
733
743
  of = instanceRangeOf(asset)
734
744
  } catch {
735
745
  of = undefined
736
746
  }
737
- this.range = { at, of }
747
+ try {
748
+ toDeclare = of ? undefined : instanceRangeToDeclare(asset)
749
+ } catch {
750
+ toDeclare = undefined
751
+ }
752
+ this.range = { at, of, toDeclare }
738
753
  }
739
754
  const range = this.range.of
755
+ /* No size yet: the figure is declared its size from its parts, so a board can resize it and parts can follow it. */
756
+ const toDeclare = this.range.toDeclare
757
+ if (!range && toDeclare)
758
+ return html`<section>
759
+ <h3>${i18next.t('figure.label.instance-range')}</h3>
760
+ <p hint>${i18next.t('figure.text.instance-size-undeclared')}</p>
761
+ <button act ?disabled=${unavailable} @click=${() => this.linkCommand({ kind: 'declare-instance-size', range: toDeclare.range, at: toDeclare.at })}>
762
+ <md-icon>straighten</md-icon>${i18next.t('figure.button.declare-instance-size')}
763
+ </button>
764
+ </section>`
740
765
  if (!range) return nothing
741
766
  const mm = (v: number) => Math.round(v).toLocaleString()
742
767
  const differs = rangeDiffers(range.declared, range.measured)
@@ -957,7 +982,14 @@ export class FigureV3Editor extends LitElement {
957
982
  <span slot="status" class="status" role="status" ?dirty=${status === 'figure.text.unsaved-changes'}>${i18next.t(status)}</span>
958
983
  <button slot="commit" save ?disabled=${unavailable || (!view.dirty && !!view.id)} @click=${this.save}>${i18next.t('figure.button.save')}</button>
959
984
  </ox-canvas-bar>
960
- ${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}
961
993
  <ox-three-pane>
962
994
  <figure-v3-parts
963
995
  slot="start"
@@ -1,4 +1,4 @@
1
- import { V3_FACES, v3ShapeFaces, compileV3Graph, defaultInstanceSizeRange, foldInstanceSizeRange, v3AttachmentsOf, v3BindingsOf, v3InstanceSizeLimitsOf } from '@hatiolab/figure-model'
1
+ import { V3_FACES, v3ShapeFaces, proposeV3Occupancy, 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
 
@@ -358,6 +391,21 @@ export function instanceRangeOf(asset: V3Asset): { declared: V3SizeRange; measur
358
391
  return { declared, measured }
359
392
  }
360
393
 
394
+ /**
395
+ * The size range to declare for a figure that has none yet: its parts' extent now as the size it is authored at, and
396
+ * as wide as its parts allow around it (the same measure 「다시 재기」 uses). Undefined when the figure already has a
397
+ * size or has nothing to measure. Without a declared size a board cannot resize the figure, and no part is offered
398
+ * to follow its size -- every figure made in the modeller was left there (user, 2026-09-25).
399
+ */
400
+ export function instanceRangeToDeclare(asset: V3Asset): { range: V3SizeRange; at: Record<Axis, number> } | undefined {
401
+ if (instanceRangeOf(asset) || !asset.appearance.length) return undefined
402
+ const proposal = proposeV3Occupancy(asset, { over: 'rest' })
403
+ const at = Object.fromEntries(AXES.map(axis => [axis, Math.round((proposal.at[axis].max - proposal.at[axis].min) * 1e6) / 1e6])) as Record<Axis, number>
404
+ if (AXES.some(axis => !(at[axis] > 0))) return undefined
405
+ const start = defaultInstanceSizeRange(at)
406
+ return { range: foldInstanceSizeRange(start, v3InstanceSizeLimitsOf(asset, start)).range, at }
407
+ }
408
+
361
409
  /** Whether measuring again would change the declared range (to a micrometre). */
362
410
  export function rangeDiffers(a: V3SizeRange, b: V3SizeRange): boolean {
363
411
  return AXES.some(axis => Math.abs(a[axis].min - b[axis].min) > 1e-3 || Math.abs(a[axis].max - b[axis].max) > 1e-3)
@@ -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;
@@ -32,12 +32,14 @@ export declare class FigureV3Links extends FigureV3Links_base {
32
32
  private place;
33
33
  private row;
34
34
  willUpdate(changed: Map<string, unknown>): void;
35
- render(): typeof nothing | import("lit-html").TemplateResult<1>;
35
+ render(): import("lit-html").TemplateResult<1> | typeof nothing;
36
36
  /** Where things are held on this part: a seat things rest on, or a grip things are held in (parity row ⑤). */
37
37
  private renderPlaces;
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