@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.
- package/client/figure-assistant.ts +20 -0
- package/client/index.ts +1 -0
- package/client/modeller/figure-v3-links.ts +56 -4
- package/client/modeller/v3-editor.ts +19 -8
- package/client/modeller/v3-links.ts +45 -12
- package/client/modeller/v3-refusals.ts +38 -0
- package/client/pages/figure-v3-modeller-page.ts +49 -16
- package/dist-client/figure-assistant.d.ts +1 -0
- package/dist-client/figure-assistant.js +19 -0
- package/dist-client/figure-assistant.js.map +1 -0
- package/dist-client/index.d.ts +1 -0
- package/dist-client/index.js +1 -0
- package/dist-client/index.js.map +1 -1
- package/dist-client/modeller/figure-v3-capability.d.ts +1 -1
- package/dist-client/modeller/figure-v3-clips.d.ts +1 -1
- package/dist-client/modeller/figure-v3-controls.d.ts +1 -1
- package/dist-client/modeller/figure-v3-links.d.ts +2 -0
- package/dist-client/modeller/figure-v3-links.js +57 -4
- package/dist-client/modeller/figure-v3-links.js.map +1 -1
- package/dist-client/modeller/figure-v3-material.d.ts +1 -1
- package/dist-client/modeller/figure-v3-properties.d.ts +1 -1
- package/dist-client/modeller/v3-clips.d.ts +1 -1
- package/dist-client/modeller/v3-editor.d.ts +2 -0
- package/dist-client/modeller/v3-editor.js +21 -8
- package/dist-client/modeller/v3-editor.js.map +1 -1
- package/dist-client/modeller/v3-links.d.ts +24 -2
- package/dist-client/modeller/v3-links.js +32 -8
- package/dist-client/modeller/v3-links.js.map +1 -1
- package/dist-client/modeller/v3-refusals.d.ts +19 -0
- package/dist-client/modeller/v3-refusals.js +43 -0
- package/dist-client/modeller/v3-refusals.js.map +1 -1
- package/dist-client/pages/figure-v3-modeller-page.d.ts +4 -1
- package/dist-client/pages/figure-v3-modeller-page.js +44 -15
- package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
- package/dist-client/route.d.ts +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -5
- package/test/browser/modeller.check.mjs +39 -0
- package/test/figure-assistant-surface.test.ts +52 -0
- package/test/v3-first-save.test.ts +8 -0
- package/test/v3-links.test.ts +74 -2
- package/test/v3-parts-wiring.test.ts +4 -2
- package/test/v3-refusals.test.ts +51 -0
- package/translations/en.json +15 -1
- package/translations/ja.json +15 -1
- package/translations/ko.json +15 -1
- package/translations/ms.json +15 -1
- package/translations/zh.json +15 -1
- package/client/modeller/figure-ai-target.ts +0 -23
- package/dist-client/modeller/figure-ai-target.d.ts +0 -18
- package/dist-client/modeller/figure-ai-target.js +0 -22
- package/dist-client/modeller/figure-ai-target.js.map +0 -1
- 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
|
@@ -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
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
370
|
-
const at =
|
|
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
|
-
|
|
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.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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"]}
|
package/dist-client/index.d.ts
CHANGED
package/dist-client/index.js
CHANGED
package/dist-client/index.js.map
CHANGED
|
@@ -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():
|
|
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():
|
|
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():
|
|
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
|