@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.
- 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 +37 -5
- package/client/modeller/v3-links.ts +58 -10
- package/client/modeller/v3-refusals.ts +19 -0
- package/client/pages/figure-v3-modeller-page.ts +46 -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 +3 -1
- 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-editor.d.ts +2 -0
- package/dist-client/modeller/v3-editor.js +39 -4
- package/dist-client/modeller/v3-editor.js.map +1 -1
- package/dist-client/modeller/v3-links.d.ts +34 -2
- package/dist-client/modeller/v3-links.js +46 -6
- package/dist-client/modeller/v3-links.js.map +1 -1
- package/dist-client/modeller/v3-refusals.d.ts +13 -0
- package/dist-client/modeller/v3-refusals.js +20 -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 +41 -15
- package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -5
- package/test/browser/modeller.check.mjs +64 -0
- package/test/figure-assistant-surface.test.ts +52 -0
- package/test/v3-links.test.ts +81 -2
- package/test/v3-refusals.test.ts +21 -0
- package/translations/en.json +13 -1
- package/translations/ja.json +13 -1
- package/translations/ko.json +13 -1
- package/translations/ms.json +13 -1
- package/translations/zh.json +13 -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,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
|
-
|
|
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
|
|
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(
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
|
@@ -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;
|
|
@@ -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():
|
|
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
|