@things-factory/figure-ui 10.1.151 → 10.1.153
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/modeller/ai-proposal.ts +14 -13
- package/client/modeller/command-preview-controller.ts +210 -0
- package/client/modeller/editor-styles.ts +307 -0
- package/client/modeller/editor.ts +65 -634
- package/client/modeller/fasten-geometry.ts +32 -0
- package/client/modeller/figure-first-save.ts +9 -1
- package/client/modeller/figure-links.ts +32 -14
- package/client/modeller/mesh-import.ts +0 -10
- package/client/modeller/modeller-keys.ts +60 -0
- package/client/modeller/pane-fold.ts +61 -0
- package/client/modeller/panel-memory.ts +53 -0
- package/client/modeller/refusal-teller.ts +93 -0
- package/client/modeller/refusals.ts +32 -2
- package/dist-client/modeller/ai-proposal.d.ts +6 -5
- package/dist-client/modeller/ai-proposal.js +9 -9
- package/dist-client/modeller/ai-proposal.js.map +1 -1
- package/dist-client/modeller/clips.d.ts +1 -1
- package/dist-client/modeller/command-preview-controller.d.ts +75 -0
- package/dist-client/modeller/command-preview-controller.js +178 -0
- package/dist-client/modeller/command-preview-controller.js.map +1 -0
- package/dist-client/modeller/editor-styles.d.ts +2 -0
- package/dist-client/modeller/editor-styles.js +307 -0
- package/dist-client/modeller/editor-styles.js.map +1 -0
- package/dist-client/modeller/editor.d.ts +5 -56
- package/dist-client/modeller/editor.js +76 -656
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/fasten-geometry.d.ts +13 -0
- package/dist-client/modeller/fasten-geometry.js +29 -0
- package/dist-client/modeller/fasten-geometry.js.map +1 -1
- package/dist-client/modeller/figure-first-save.d.ts +1 -0
- package/dist-client/modeller/figure-first-save.js +8 -1
- package/dist-client/modeller/figure-first-save.js.map +1 -1
- package/dist-client/modeller/figure-links.d.ts +9 -2
- package/dist-client/modeller/figure-links.js +35 -14
- package/dist-client/modeller/figure-links.js.map +1 -1
- package/dist-client/modeller/mesh-import.d.ts +0 -2
- package/dist-client/modeller/mesh-import.js +0 -11
- package/dist-client/modeller/mesh-import.js.map +1 -1
- package/dist-client/modeller/modeller-keys.d.ts +22 -0
- package/dist-client/modeller/modeller-keys.js +43 -0
- package/dist-client/modeller/modeller-keys.js.map +1 -0
- package/dist-client/modeller/pane-fold.d.ts +15 -0
- package/dist-client/modeller/pane-fold.js +60 -0
- package/dist-client/modeller/pane-fold.js.map +1 -0
- package/dist-client/modeller/panel-memory.d.ts +15 -0
- package/dist-client/modeller/panel-memory.js +54 -0
- package/dist-client/modeller/panel-memory.js.map +1 -0
- package/dist-client/modeller/product-session.d.ts +4 -28
- package/dist-client/modeller/refusal-teller.d.ts +36 -0
- package/dist-client/modeller/refusal-teller.js +81 -0
- package/dist-client/modeller/refusal-teller.js.map +1 -0
- package/dist-client/modeller/refusals.d.ts +12 -1
- package/dist-client/modeller/refusals.js +26 -1
- package/dist-client/modeller/refusals.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/test/browser/build.mjs +7 -2
- package/test/browser/editing.check.mjs +14 -0
- package/test/browser/fix-alignment.check.mjs +112 -0
- package/test/browser/links.check.mjs +69 -13
- package/test/browser/mesh-select.check.mjs +91 -0
- package/test/browser/modeller-page.js +36 -0
- package/test/browser/modeller-revision.check.mjs +18 -6
- package/test/browser/modeller.check.mjs +25 -0
- package/test/browser/part-ids.check.mjs +24 -6
- package/test/browser/parts-page.js +9 -1
- package/test/browser/parts.check.mjs +61 -2
- package/test/browser/places-mesh.check.mjs +25 -9
- package/test/browser/product-page.check.mjs +42 -2
- package/test/browser/row-preview.check.mjs +81 -0
- package/test/browser/screen.check.mjs +35 -1
- package/test/clip-ai-proposal.test.ts +4 -1
- package/test/clip-consent.test.ts +9 -2
- package/test/fields.test.ts +5 -9
- package/test/fix-alignment.test.ts +32 -0
- package/test/layout.test.ts +3 -1
- package/test/mesh-import.test.ts +1 -8
- package/test/parts-wiring.test.ts +1 -5
- package/test/refusals.test.ts +46 -10
- package/test/theme.test.ts +5 -15
- package/translations/en.json +8 -0
- package/translations/es.json +8 -0
- package/translations/ja.json +8 -0
- package/translations/ko.json +8 -0
- package/translations/ms.json +8 -0
- package/translations/zh.json +8 -0
|
@@ -125,6 +125,38 @@ export function alignmentAt(d: number, face: number | undefined, part: number, e
|
|
|
125
125
|
return { how: 'mm', mm: Math.round(d) }
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* The alignment that brings the footprint inside the face, for 정렬·간격 고치기: on each axis it comes past an edge, the
|
|
130
|
+
* least move -- to that edge from inside (`min` / `max`, or that distance where edges are not offered) -- or the centre
|
|
131
|
+
* when the part is wider than the face and cannot be inside. `inside` when nothing of it is past an edge: what is out
|
|
132
|
+
* is something fastened on it. `unmeasured` when the face has no size to measure against.
|
|
133
|
+
*/
|
|
134
|
+
export function insideAlignmentOf(
|
|
135
|
+
view: FaceView | undefined,
|
|
136
|
+
align: Partial<Record<Axis, Alignment>>,
|
|
137
|
+
alignMm: Partial<Record<Axis, number>>
|
|
138
|
+
): { align: Partial<Record<Axis, Alignment>>; alignMm: Partial<Record<Axis, number>>; moved: Axis[] } | { cannot: 'inside' | 'unmeasured' } {
|
|
139
|
+
if (!view || view.axes.some(a => view.face[a] === undefined)) return { cannot: 'unmeasured' }
|
|
140
|
+
const next = { align: { ...align }, alignMm: { ...alignMm }, moved: [] as Axis[] }
|
|
141
|
+
for (const a of view.axes) {
|
|
142
|
+
const face = view.face[a]!,
|
|
143
|
+
part = view.part[a]
|
|
144
|
+
const at = offsetOf(align[a], alignMm[a], face, part)
|
|
145
|
+
const low = -face / 2 - (at - part / 2) > 0.5,
|
|
146
|
+
high = at + part / 2 - face / 2 > 0.5
|
|
147
|
+
if (!low && !high) continue
|
|
148
|
+
next.moved.push(a)
|
|
149
|
+
if (part > face) {
|
|
150
|
+
next.align[a] = 'centre'
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
const edge = ((low ? -1 : 1) * (face - part)) / 2
|
|
154
|
+
if (view.edges[a]) next.align[a] = low ? 'min' : 'max'
|
|
155
|
+
else ((next.align[a] = 'mm'), (next.alignMm[a] = Math.round(edge)))
|
|
156
|
+
}
|
|
157
|
+
return next.moved.length ? next : { cannot: 'inside' }
|
|
158
|
+
}
|
|
159
|
+
|
|
128
160
|
/** How far the footprint comes past the face's edges, per side, and whether it touches the face at all. */
|
|
129
161
|
export function overhangOf(view: FaceView, at: Record<Axis, number>): { touching: boolean; sides: Array<{ axis: Axis; side: 'low' | 'high'; mm: number }> } {
|
|
130
162
|
const sides: Array<{ axis: Axis; side: 'low' | 'high'; mm: number }> = []
|
|
@@ -63,6 +63,15 @@ export class FigureFirstSave extends LitElement {
|
|
|
63
63
|
if (offer.suggested !== undefined) this.suggested = offer.suggested
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/*
|
|
67
|
+
The name is focused here, each time the question opens. The browser honours `autofocus` once per document: the
|
|
68
|
+
question asked again opened with nothing focused, and any autofocus taken earlier on the page left even the first
|
|
69
|
+
opening unfocused (2026-10-11, product-page check).
|
|
70
|
+
*/
|
|
71
|
+
firstUpdated() {
|
|
72
|
+
this.renderRoot.querySelector<HTMLInputElement>('#name')?.focus()
|
|
73
|
+
}
|
|
74
|
+
|
|
66
75
|
render() {
|
|
67
76
|
return html`
|
|
68
77
|
<div class="what">${i18next.t('figure.text.save-new-figure-desc')}</div>
|
|
@@ -70,7 +79,6 @@ export class FigureFirstSave extends LitElement {
|
|
|
70
79
|
<label for="name">${i18next.t('figure.label.name')}</label>
|
|
71
80
|
<input
|
|
72
81
|
id="name"
|
|
73
|
-
autofocus
|
|
74
82
|
.value=${this.name}
|
|
75
83
|
placeholder=${i18next.t('figure.text.figure-name-example')}
|
|
76
84
|
@input=${(e: Event) => (this.name = (e.target as HTMLInputElement).value)}
|
|
@@ -6,7 +6,7 @@ import './figure-slot-layout-panel.js'
|
|
|
6
6
|
import { customElement, property, state } from 'lit/decorators.js'
|
|
7
7
|
|
|
8
8
|
import { i18next, localize } from '@operato/i18n'
|
|
9
|
-
import { FIGURE_FACES } from '@hatiolab/figure-model'
|
|
9
|
+
import { FIGURE_FACES, figureFaceOverhangOf } from '@hatiolab/figure-model'
|
|
10
10
|
import type { FigureAsset, FigureAuthoringAction, FigureDimensionSource, FigureFace } from '@hatiolab/figure-model'
|
|
11
11
|
|
|
12
12
|
import {
|
|
@@ -56,7 +56,7 @@ import {
|
|
|
56
56
|
import { SidePanelStyles } from './side-panel-styles.js'
|
|
57
57
|
import './figure-fasten-drawing.js'
|
|
58
58
|
import type { FastenChange } from './figure-fasten-drawing.js'
|
|
59
|
-
import { faceViewOf, modeOf, myFaceFor, turnsInPlace, type FastenMode } from './fasten-geometry.js'
|
|
59
|
+
import { faceViewOf, insideAlignmentOf, modeOf, myFaceFor, turnsInPlace, type FaceView, type FastenMode } from './fasten-geometry.js'
|
|
60
60
|
import type { FigureRevision } from './revision.js'
|
|
61
61
|
import { HelpToggles } from './help.js'
|
|
62
62
|
import { jointAxisWord } from './joint-axis.js'
|
|
@@ -186,6 +186,8 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
186
186
|
/** The fastening section, marked for a moment when "open attachment settings" brings it into view. */
|
|
187
187
|
@state() private spotlit = false
|
|
188
188
|
@state() private fasten = freshFasten()
|
|
189
|
+
/** Why 정렬·간격 고치기 moved nothing, said under it; empty once it moved the part or the part changed. */
|
|
190
|
+
@state() private fixNote = ''
|
|
189
191
|
@state() private motion: MotionForm = freshMotion()
|
|
190
192
|
@state() private place = freshPlace()
|
|
191
193
|
/** How places are made here: one at a time, or by the figure's rule. Unset: by the rule when the figure has one. */
|
|
@@ -201,7 +203,9 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
201
203
|
/* The part the forms were loaded for. A panel made with a part already chosen loaded nothing, and said "editing the
|
|
202
204
|
fastening it has" over an empty target (2026-09-27, :3391, the doors). */
|
|
203
205
|
private loadedFor?: string
|
|
204
|
-
willUpdate(
|
|
206
|
+
willUpdate(changed: Map<string, unknown>) {
|
|
207
|
+
/* What 고치기 said is about the figure as it was: another step or another part, and it no longer holds. */
|
|
208
|
+
if (changed.has('asset') || changed.has('partId')) this.fixNote = ''
|
|
205
209
|
if (this.asset && this.partId && this.partId !== this.loadedFor) {
|
|
206
210
|
this.loadedFor = this.partId
|
|
207
211
|
/* A part's own fastening is not another part's choices: the form opens on it (walk 1, 2026-09-26). */
|
|
@@ -430,16 +434,27 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
430
434
|
* holding places instead).
|
|
431
435
|
*/
|
|
432
436
|
/** The face the part sits on, in view with the focus on it: where the place across the face is dragged or keyed. */
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
437
|
+
/**
|
|
438
|
+
* 정렬·간격 고치기: the part moved inside the face it is fastened on, as one step -- the least move on each side it is
|
|
439
|
+
* out, or centred when it is wider than the face. When nothing of it can be moved, why is said under the button, not
|
|
440
|
+
* nothing at all (:3300, 2026-10-11: the button only scrolled to the drawing, which was already in view).
|
|
441
|
+
*/
|
|
442
|
+
private async fixAlignment(view: FaceView | undefined, target: string) {
|
|
443
|
+
/* Whether it is out is the kernel's measure (a round face is not measured there); the drawing's sizes say how far to move. */
|
|
444
|
+
const own = this.revision ? figureFaceOverhangOf(this.revision.asset, this.partId, this.revision.inputs) : undefined
|
|
445
|
+
const fixed =
|
|
446
|
+
own?.state === 'unknown'
|
|
447
|
+
? ({ cannot: 'unmeasured' } as const)
|
|
448
|
+
: own?.state === 'out' || own?.state === 'possible'
|
|
449
|
+
? insideAlignmentOf(view, this.fasten.align, this.fasten.alignMm)
|
|
450
|
+
: ({ cannot: 'inside' } as const)
|
|
451
|
+
if ('cannot' in fixed) {
|
|
452
|
+
this.fixNote = i18next.t(`figure.text.fix-alignment-${fixed.cannot}`, { part: this.partId, on: target })
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
this.fixNote = ''
|
|
456
|
+
this.fasten = { ...this.fasten, align: fixed.align, alignMm: fixed.alignMm }
|
|
457
|
+
await this.onFastenRelease(true)
|
|
443
458
|
}
|
|
444
459
|
|
|
445
460
|
/** The fastening form, open on this part and in view: for "fasten it first" from elsewhere. */
|
|
@@ -600,7 +615,10 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
600
615
|
? html`<div overhang role="status">
|
|
601
616
|
${this.overhangWords.map(line => html`<p>${line}</p>`)}
|
|
602
617
|
${current
|
|
603
|
-
? html`<button act @click=${() => this.
|
|
618
|
+
? html`<button act fix-alignment ?disabled=${this.locked} @click=${() => this.fixAlignment(view, held ? f.target : '')}>
|
|
619
|
+
<md-icon>tune</md-icon>${i18next.t('figure.button.fix-alignment')}
|
|
620
|
+
</button>
|
|
621
|
+
${this.fixNote ? html`<p hint fix-note>${this.fixNote}</p>` : nothing}`
|
|
604
622
|
: nothing}
|
|
605
623
|
</div>`
|
|
606
624
|
: nothing}
|
|
@@ -38,16 +38,6 @@ export function meshChoicesOf(upload: MeshUpload): Array<{ node?: number; name:
|
|
|
38
38
|
]
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
/** A part id from a node's name: letter-led, letters, digits, `_` and `-`; another number when it is taken. */
|
|
42
|
-
export function meshPartIdOf(name: string, taken: ReadonlySet<string>): string {
|
|
43
|
-
const cleaned = name.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^[^a-zA-Z]+/, '').replace(/-+$/, '')
|
|
44
|
-
const base = cleaned || 'mesh'
|
|
45
|
-
if (!taken.has(base)) return base
|
|
46
|
-
let n = 2
|
|
47
|
-
while (taken.has(`${base}-${n}`)) n++
|
|
48
|
-
return `${base}-${n}`
|
|
49
|
-
}
|
|
50
|
-
|
|
51
41
|
/**
|
|
52
42
|
* The part: its bounds and counts as the server read them, its size the reach along its longest axis (so it is 1:1),
|
|
53
43
|
* and -- for a node -- standing where the node stood in the file, so a crane taken apart node by node stands as it did.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ReactiveController, ReactiveControllerHost } from 'lit'
|
|
2
|
+
import { keyActionOf, type KeyAction } from './keys.js'
|
|
3
|
+
|
|
4
|
+
/** What the keys ask of the editor. */
|
|
5
|
+
export interface ModellerKeysHost {
|
|
6
|
+
/** Whether the modeller takes keys now: a figure open, not loading or saving, and no section editor open. */
|
|
7
|
+
taking(): boolean
|
|
8
|
+
/** The scene, whose keys the modeller's are too. */
|
|
9
|
+
scene(): Element | null
|
|
10
|
+
selected(): string
|
|
11
|
+
act(action: KeyAction): void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The modeller's keys (`keys.ts`): undo, redo, duplicate, remove, back to the whole figure, the gizmo's modes. Heard on
|
|
16
|
+
* the window, first (capture), while the editor is on the page.
|
|
17
|
+
*/
|
|
18
|
+
export class ModellerKeys implements ReactiveController {
|
|
19
|
+
constructor(
|
|
20
|
+
host: ReactiveControllerHost,
|
|
21
|
+
private readonly of: ModellerKeysHost
|
|
22
|
+
) {
|
|
23
|
+
host.addController(this)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
hostConnected() {
|
|
27
|
+
window.addEventListener('keydown', this.onKey, true)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
hostDisconnected() {
|
|
31
|
+
window.removeEventListener('keydown', this.onKey, true)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private onKey = (e: KeyboardEvent) => {
|
|
35
|
+
if (!this.of.taking()) return
|
|
36
|
+
const canvas = this.of.scene()
|
|
37
|
+
const path = e.composedPath()
|
|
38
|
+
const action = keyActionOf(
|
|
39
|
+
{
|
|
40
|
+
key: e.key,
|
|
41
|
+
ctrlKey: e.ctrlKey,
|
|
42
|
+
metaKey: e.metaKey,
|
|
43
|
+
shiftKey: e.shiftKey,
|
|
44
|
+
altKey: e.altKey,
|
|
45
|
+
target: path[0] as HTMLElement,
|
|
46
|
+
inScene: !!canvas && path.includes(canvas)
|
|
47
|
+
},
|
|
48
|
+
this.of.selected()
|
|
49
|
+
)
|
|
50
|
+
if (!action) return
|
|
51
|
+
e.preventDefault()
|
|
52
|
+
/*
|
|
53
|
+
The session's history is the only one. With the canvas focused the scene took Ctrl+Z as well, undoing on its own
|
|
54
|
+
board the last value the canvas had written -- which the canvas then told as a drag: an undo of a drag on a
|
|
55
|
+
part placed by a rule wrote the drag again (2026-09-25). Heard first (capture) and kept from the scene.
|
|
56
|
+
*/
|
|
57
|
+
e.stopPropagation()
|
|
58
|
+
this.of.act(action)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ReactiveController, ReactiveControllerHost } from 'lit'
|
|
2
|
+
import { i18next } from '@operato/i18n'
|
|
3
|
+
import type { OxThreePane } from '@operato/layout'
|
|
4
|
+
import { notify } from '@operato/layout'
|
|
5
|
+
|
|
6
|
+
/** Whether the narrow fold was said in this browser before; true from now on. Storage refused: said, as before. */
|
|
7
|
+
function foldSaid(): boolean {
|
|
8
|
+
try {
|
|
9
|
+
if (localStorage.getItem('figure-fold-said')) return true
|
|
10
|
+
localStorage.setItem('figure-fold-said', '1')
|
|
11
|
+
} catch {
|
|
12
|
+
/* No storage here: said every time, as it was. */
|
|
13
|
+
}
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/*
|
|
18
|
+
Narrow, the parts list gives way to the canvas rather than all three shrinking (designer 2026-09-27, U11): under
|
|
19
|
+
FOLD_START_UNDER px the canvas would be left under about 560 px beside both sides. The fold is said once, and the
|
|
20
|
+
bar's own toggle opens it again; opened by the author, it is not folded again until the screen has been wide.
|
|
21
|
+
Said once in this browser, not on every opening (2026-10-02): the list folds as before, the notice does not come back.
|
|
22
|
+
*/
|
|
23
|
+
export class PaneFold implements ReactiveController {
|
|
24
|
+
static readonly FOLD_START_UNDER = 1100
|
|
25
|
+
private foldedForWidth = false
|
|
26
|
+
private keptOpenByAuthor = false
|
|
27
|
+
private widthWatch = new ResizeObserver(([entry]) => this.fitTo(entry!.contentRect.width))
|
|
28
|
+
|
|
29
|
+
/** `panes` is the frame the bar folds, once it is drawn. */
|
|
30
|
+
constructor(
|
|
31
|
+
private readonly host: ReactiveControllerHost & HTMLElement,
|
|
32
|
+
private readonly panes: () => OxThreePane | undefined
|
|
33
|
+
) {
|
|
34
|
+
host.addController(this)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
hostConnected() {
|
|
38
|
+
this.widthWatch.observe(this.host)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
hostDisconnected() {
|
|
42
|
+
this.widthWatch.disconnect()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
fitTo(width: number) {
|
|
46
|
+
const panes = this.panes()
|
|
47
|
+
if (!panes || !width) return
|
|
48
|
+
if (this.foldedForWidth && !panes.foldStart) this.keptOpenByAuthor = true
|
|
49
|
+
if (width < PaneFold.FOLD_START_UNDER) {
|
|
50
|
+
if (!panes.foldStart && !this.keptOpenByAuthor) {
|
|
51
|
+
panes.foldStart = true
|
|
52
|
+
this.foldedForWidth = true
|
|
53
|
+
if (!foldSaid()) notify({ level: 'info', message: i18next.t('figure.text.parts-panel-folded-narrow') })
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
if (this.foldedForWidth && panes.foldStart) panes.foldStart = false
|
|
57
|
+
this.foldedForWidth = false
|
|
58
|
+
this.keptOpenByAuthor = false
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Per model and per part, how far its properties panel was scrolled and which of its folds were open, so going back
|
|
3
|
+
to one finds it as it was left (designer 2026-09-27, B.1). The path stays held at the top, so a scroll kept cannot
|
|
4
|
+
hide which part the panel speaks of. Another model starts clean.
|
|
5
|
+
*/
|
|
6
|
+
export class PanelMemory {
|
|
7
|
+
private kept = new Map<string, { top: number; open: boolean[] }>()
|
|
8
|
+
private restoreFor?: string
|
|
9
|
+
|
|
10
|
+
/** `root` is where the properties panel is drawn: the editor's render root. */
|
|
11
|
+
constructor(private readonly root: () => ParentNode) {}
|
|
12
|
+
|
|
13
|
+
private panel() {
|
|
14
|
+
return this.root().querySelector<HTMLElement>('div[panel]')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
private foldsOf(panel: HTMLElement): HTMLDetailsElement[] {
|
|
18
|
+
const folds = [...panel.querySelectorAll('details')]
|
|
19
|
+
for (const inner of panel.querySelectorAll('figure-links')) folds.push(...(inner.shadowRoot?.querySelectorAll('details') ?? []))
|
|
20
|
+
return folds
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Another model: nothing kept. */
|
|
24
|
+
clear() {
|
|
25
|
+
this.kept.clear()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The selection moves from `was` to `now` ('' is the model): the panel as `was` left it is kept, `now`'s put back after the update. */
|
|
29
|
+
leave(was: string, now: string) {
|
|
30
|
+
const panel = this.panel()
|
|
31
|
+
if (panel) this.kept.set(was || '@model', { top: panel.scrollTop, open: this.foldsOf(panel).map(d => d.open) })
|
|
32
|
+
this.restoreFor = now || '@model'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** After the update: the panel of the part chosen as it was left, while that part is still the one chosen. */
|
|
36
|
+
restore(selected: () => string) {
|
|
37
|
+
if (this.restoreFor === undefined) return
|
|
38
|
+
const key = this.restoreFor
|
|
39
|
+
this.restoreFor = undefined
|
|
40
|
+
/*
|
|
41
|
+
Put back as soon as the links have drawn, before any click can come: the updates are microtasks. Put back a
|
|
42
|
+
frame later, it closed a fold opened by a click in that frame (2026-10-11, the row on a bench under load).
|
|
43
|
+
*/
|
|
44
|
+
const panel = this.panel()
|
|
45
|
+
if (panel)
|
|
46
|
+
void Promise.all([...panel.querySelectorAll<HTMLElement & { updateComplete?: Promise<unknown> }>('figure-links')].map(el => el.updateComplete)).then(() => {
|
|
47
|
+
if ((selected() || '@model') !== key) return
|
|
48
|
+
const kept = this.kept.get(key)
|
|
49
|
+
if (kept) this.foldsOf(panel).forEach((fold, i) => i < kept.open.length && (fold.open = kept.open[i]!))
|
|
50
|
+
panel.scrollTop = kept?.top ?? 0
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { PropertyValues } from 'lit'
|
|
2
|
+
import { i18next } from '@operato/i18n'
|
|
3
|
+
import { notify } from '@operato/layout'
|
|
4
|
+
import { OxPrompt } from '@operato/popup'
|
|
5
|
+
import type { FigureAuthoringAction, FigureClipLoss } from '@hatiolab/figure-model'
|
|
6
|
+
import type { FigureProductSession } from './product-session.js'
|
|
7
|
+
import { clipLossOf } from './refusals.js'
|
|
8
|
+
|
|
9
|
+
/** The same link offered again with the size range narrowed, after a link the declared range could not hold. */
|
|
10
|
+
export type Retry = { label: string; action?: FigureAuthoringAction; run?: () => void }
|
|
11
|
+
|
|
12
|
+
/** What the teller reads of the editor, and what it asks of it. */
|
|
13
|
+
export interface RefusalHost {
|
|
14
|
+
session(): FigureProductSession | undefined
|
|
15
|
+
error(): string
|
|
16
|
+
notice(): string
|
|
17
|
+
retry(): Retry | undefined
|
|
18
|
+
/** Said as the editor's refusal. */
|
|
19
|
+
refuse(message: string): void
|
|
20
|
+
/** A narrowed link, confirmed: run as the editor runs a command. */
|
|
21
|
+
command(action: FigureAuthoringAction): void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Refusals and notices go to the app's shared surfaces, as every screen's do (user, 2026-09-27: 「왜 모두가 사용하는
|
|
26
|
+
* notification 오류 표현 방법을 사용하지 않고」 · 「혹은 oxprompt 에서 확인하고 진행하던지」). A refusal with a way on --
|
|
27
|
+
* delete and fit the space again, narrow the range -- asks in the app's prompt and goes on when confirmed; any
|
|
28
|
+
* other refusal is the app's notification, which stays until it is closed.
|
|
29
|
+
*/
|
|
30
|
+
export class RefusalTeller {
|
|
31
|
+
private toldViewError = ''
|
|
32
|
+
|
|
33
|
+
constructor(private readonly of: RefusalHost) {}
|
|
34
|
+
|
|
35
|
+
/** After the editor's update: what changed in its refusal and notice, and the session's own error, said once. */
|
|
36
|
+
tell(changed: PropertyValues) {
|
|
37
|
+
const error = this.of.error()
|
|
38
|
+
if (changed.has('error') && error) {
|
|
39
|
+
const retry = this.of.retry()
|
|
40
|
+
if (retry) this.ask(error, retry)
|
|
41
|
+
else notify({ level: 'error', message: error })
|
|
42
|
+
}
|
|
43
|
+
const notice = this.of.notice()
|
|
44
|
+
if (changed.has('notice') && notice) notify({ level: 'info', message: notice })
|
|
45
|
+
const viewError = this.of.session()?.read()?.error ?? ''
|
|
46
|
+
if (viewError && viewError !== this.toldViewError) notify({ level: 'error', message: viewError })
|
|
47
|
+
this.toldViewError = viewError
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/*
|
|
51
|
+
An edit that would stop clips is refused by figure-model with the clips named (CLIPS_DROPPED). The person is told
|
|
52
|
+
which, and why, before anything is written; agreed, the same edit is made again letting exactly those go, as one
|
|
53
|
+
step undo brings back (designer 2026-09-27).
|
|
54
|
+
*/
|
|
55
|
+
askClipLoss(error: unknown, again: () => void): boolean {
|
|
56
|
+
const clips = clipLossOf(error)
|
|
57
|
+
if (!clips) return false
|
|
58
|
+
/* The consent is for this edit, on this figure as it is now: held with it while the person reads. */
|
|
59
|
+
const session = this.of.session()!
|
|
60
|
+
const { context, revision } = session.read()
|
|
61
|
+
void this.confirmClipLoss(clips, again, { session, context: context!, revision: revision! })
|
|
62
|
+
return true
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private async confirmClipLoss(clips: FigureClipLoss[], again: () => void, asked: { session: FigureProductSession; context: string; revision: number }) {
|
|
66
|
+
const agreed = await OxPrompt.open({
|
|
67
|
+
type: 'warning',
|
|
68
|
+
text: i18next.t('figure.text.clips-would-go', { clips: clips.map(c => i18next.t(`figure.text.clip-${c.why}`, { clip: c.clip })).join(', ') }),
|
|
69
|
+
confirmButton: { text: i18next.t('figure.button.let-clips-go') },
|
|
70
|
+
cancelButton: { text: i18next.t('figure.button.keep-editing') }
|
|
71
|
+
})
|
|
72
|
+
if (!agreed) return
|
|
73
|
+
/* Changed while the person read -- another figure, or an edit in between: the consent was not for this; nothing is applied. */
|
|
74
|
+
const now = this.of.session() === asked.session ? asked.session.read() : undefined
|
|
75
|
+
if (!now || now.context !== asked.context || now.revision !== asked.revision) {
|
|
76
|
+
this.of.refuse(i18next.t('figure.text.clips-consent-stale'))
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
asked.session.withConsent({ dropClips: clips.map(c => c.id), context: asked.context, revision: asked.revision }, again)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private async ask(message: string, retry: Retry) {
|
|
83
|
+
const agreed = await OxPrompt.open({
|
|
84
|
+
type: 'question',
|
|
85
|
+
text: message,
|
|
86
|
+
confirmButton: { text: retry.label },
|
|
87
|
+
cancelButton: { text: i18next.t('figure.button.keep-editing') }
|
|
88
|
+
})
|
|
89
|
+
if (!agreed) return
|
|
90
|
+
if (retry.run) retry.run()
|
|
91
|
+
else this.of.command(retry.action!)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* English is for logs and readers of the kernel, and a screen that read it went quiet whenever a sentence changed or a
|
|
8
8
|
* new one came under the same code (ADR-0054). A refusal with no details the screen knows is said with its code.
|
|
9
9
|
*/
|
|
10
|
-
import { FIGURE_INSTANCE_SIZE_INPUTS, type FigureRefusalDetails } from '@hatiolab/figure-model'
|
|
10
|
+
import { FIGURE_INSTANCE_SIZE_INPUTS, type FigureClipLoss, type FigureRefusalDetails } from '@hatiolab/figure-model'
|
|
11
11
|
|
|
12
12
|
type Say = (key: string, params: Record<string, string>) => string
|
|
13
13
|
type Reason = FigureRefusalDetails['reason']
|
|
@@ -70,6 +70,23 @@ export function placementRefusalOf(error: unknown, say: Say): string | null {
|
|
|
70
70
|
})
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/** A refusal carried on in a record (an AI proposal's outcome) in figure-model's own shape: its code and details. */
|
|
74
|
+
export interface FigureRefusalRecord {
|
|
75
|
+
code?: string
|
|
76
|
+
details?: FigureRefusalDetails
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The record of a refusal: figure-model's code and details, copied as they are. */
|
|
80
|
+
export function refusalRecordOf(error: unknown): FigureRefusalRecord {
|
|
81
|
+
const { details } = (error ?? {}) as FigureRefusalRecord
|
|
82
|
+
return { code: codeOf(error), ...(details ? { details } : {}) }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The clips an edit would stop, as figure-model named them (CLIPS_DROPPED) -- the person is asked before any go; nothing for any other refusal. */
|
|
86
|
+
export function clipLossOf(error: unknown): FigureClipLoss[] | null {
|
|
87
|
+
return codeOf(error) === 'CLIPS_DROPPED' ? (detailsOf(error, 'clips-dropped')?.clips ?? []) : null
|
|
88
|
+
}
|
|
89
|
+
|
|
73
90
|
/** A part refused because the declared room reads it -- the one refusal deleting it with the room refitted answers. */
|
|
74
91
|
export function inDeclaredRoom(error: unknown): boolean {
|
|
75
92
|
return codeOf(error) === 'PART_REFERENCED' && !!detailsOf(error, 'in-declared-space')
|
|
@@ -101,6 +118,9 @@ export function placedRefusalOf(error: unknown, part: string, say: Say): string
|
|
|
101
118
|
|
|
102
119
|
/** A part name refused: not a name, already used, or another part is named under the part. The rule is the kernel's. */
|
|
103
120
|
export function partNameRefusalOf(error: unknown, say: Say): string | null {
|
|
121
|
+
/* A new part named as one already is: the same sentence as a part renamed so. */
|
|
122
|
+
const takenId = detailsOf(error, 'part-id-taken')
|
|
123
|
+
if (takenId) return say('figure.text.part-name-taken', { name: takenId.part })
|
|
104
124
|
if (codeOf(error) !== 'PART_ID') return null
|
|
105
125
|
const invalid = detailsOf(error, 'name-invalid')
|
|
106
126
|
if (invalid) return say('figure.text.part-name-invalid', { name: invalid.name })
|
|
@@ -236,7 +256,8 @@ export function inputRangeRefusalOf(error: unknown, say: Say): string | null {
|
|
|
236
256
|
|
|
237
257
|
/**
|
|
238
258
|
* A row past its limits: a pitch so fine it would hold more copies than a row takes, a row that would expand to more
|
|
239
|
-
* bodies than a figure draws,
|
|
259
|
+
* bodies than a figure draws, a number of copies outside what a row may have or more than its length holds apart, a
|
|
260
|
+
* row inside a row along the same axis, or a part whose face something holds made into a row.
|
|
240
261
|
*/
|
|
241
262
|
export function rowLimitRefusalOf(error: unknown, say: Say): string | null {
|
|
242
263
|
const copies = detailsOf(error, 'copies-over-limit')
|
|
@@ -245,9 +266,18 @@ export function rowLimitRefusalOf(error: unknown, say: Say): string | null {
|
|
|
245
266
|
if (bodies) return say('figure.text.row-geometry-over-limit', { count: bodies.count.toLocaleString(), limit: bodies.limit.toLocaleString() })
|
|
246
267
|
const count = detailsOf(error, 'row-copies-out-of-range')
|
|
247
268
|
if (count) return say('figure.text.row-copies-out-of-range', { count: count.count.toLocaleString(), min: String(count.min), max: String(count.max) })
|
|
269
|
+
const fit = detailsOf(error, 'row-does-not-fit')
|
|
270
|
+
if (fit) return say('figure.text.row-does-not-fit', { count: fit.count.toLocaleString(), length: mmOf(fit.length), extent: mmOf(fit.extent) })
|
|
271
|
+
const axis = detailsOf(error, 'row-same-axis')
|
|
272
|
+
if (axis) return say('figure.text.row-same-axis', { part: axis.part, outer: axis.outer, axis: axis.axis.toUpperCase() })
|
|
273
|
+
const held = detailsOf(error, 'surface-on-row')
|
|
274
|
+
if (held) return say(`figure.text.row-face-held-${heldByOf(held.at)}`, { part: held.part })
|
|
248
275
|
return null
|
|
249
276
|
}
|
|
250
277
|
|
|
278
|
+
/* What holds a face, from the contract path the kernel names it by: the slot layout, a place, the footprint. */
|
|
279
|
+
const heldByOf = (at: string) => (at.startsWith('slotLayout') ? 'slot-layout' : at.startsWith('bindings') ? 'place' : at.startsWith('occupancy') ? 'footprint' : 'other')
|
|
280
|
+
|
|
251
281
|
/** A row's pitch under which the figure, at the length it is authored, would hold more copies than a row takes. */
|
|
252
282
|
export function repeatPitchRefusalOf(error: unknown, say: Say): string | null {
|
|
253
283
|
if (codeOf(error) !== 'INPUT_RANGE') return null
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { FigureProposalStep } from '@hatiolab/figure-model';
|
|
2
2
|
import type { FigureProductSession } from './product-session.js';
|
|
3
|
+
import { type FigureRefusalRecord } from './refusals.js';
|
|
3
4
|
/** What the card carries, as `proposeFigureCandidate` returned it. */
|
|
4
5
|
export interface FigureProposalCard {
|
|
5
6
|
/** The proposal's own name, given by the tool: a save records it, a new figure made from it is created under it. */
|
|
@@ -10,8 +11,11 @@ export interface FigureProposalCard {
|
|
|
10
11
|
context?: string;
|
|
11
12
|
revision?: number;
|
|
12
13
|
}
|
|
13
|
-
/**
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* The `figure-apply` event's detail. The page that takes it sets `applied`, and when refused `error` and the refusal in
|
|
16
|
+
* figure-model's shape (`code`, `details`, the `clips` it would stop).
|
|
17
|
+
*/
|
|
18
|
+
export interface FigureApplyDetail extends FigureRefusalRecord {
|
|
15
19
|
key?: string;
|
|
16
20
|
steps: FigureProposalStep[];
|
|
17
21
|
create: boolean;
|
|
@@ -19,9 +23,6 @@ export interface FigureApplyDetail {
|
|
|
19
23
|
revision?: number;
|
|
20
24
|
applied?: boolean;
|
|
21
25
|
error?: string;
|
|
22
|
-
code?: string;
|
|
23
|
-
/** The clips a refused proposal would have stopped (CLIPS_DROPPED), by name. */
|
|
24
|
-
clips?: string[];
|
|
25
26
|
}
|
|
26
27
|
export type Say = (key: string, params?: Record<string, string>) => string;
|
|
27
28
|
export interface FigureProposalTarget {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { clipLossOf, refusalRecordOf } from './refusals.js';
|
|
1
2
|
/**
|
|
2
3
|
* The dock's "check the result" (ADR-0097 §5.6 item 5): what the server recorded of a proposal. Saved into figures:
|
|
3
4
|
* accepted, named. Nothing recorded: not known -- applied and not saved yet, undone, or never taken -- so `undefined`,
|
|
@@ -11,7 +12,9 @@ export function proposalOutcomeFrom(saved, say) {
|
|
|
11
12
|
}
|
|
12
13
|
/** The workspace refuses a request made on another version; said as what happened, not as the kernel's code. */
|
|
13
14
|
const STALE = new Set(['REVISION_CONFLICT', 'CONTEXT_CONFLICT']);
|
|
14
|
-
function refused(
|
|
15
|
+
function refused(refusal, say) {
|
|
16
|
+
const { code, error } = refusal;
|
|
17
|
+
const clips = clipLossOf(refusal);
|
|
15
18
|
/*
|
|
16
19
|
A proposal that would stop clips is refused whole; nobody but the person may agree to that, and a proposal is not
|
|
17
20
|
asked about -- the person edits by hand if they mean it (designer 2026-09-27).
|
|
@@ -20,8 +23,8 @@ function refused(code, error, say, clips) {
|
|
|
20
23
|
? say('figure.text.proposal-stale')
|
|
21
24
|
: code === 'NEW_FIGURE_UNSAVED'
|
|
22
25
|
? (error ?? '')
|
|
23
|
-
:
|
|
24
|
-
? say('figure.text.proposal-would-drop-clips', { clips: (
|
|
26
|
+
: clips
|
|
27
|
+
? say('figure.text.proposal-would-drop-clips', { clips: clips.map(c => c.clip).join(', ') })
|
|
25
28
|
: say('figure.text.proposal-refused', { reason: error ?? '' });
|
|
26
29
|
return { verdict: 'refused', errorCode: code ?? 'refused', note };
|
|
27
30
|
}
|
|
@@ -58,11 +61,8 @@ export function takeFigureOffer(session, detail, { onSavedFigure, say }) {
|
|
|
58
61
|
}
|
|
59
62
|
catch (error) {
|
|
60
63
|
detail.applied = false;
|
|
61
|
-
detail
|
|
64
|
+
Object.assign(detail, refusalRecordOf(error));
|
|
62
65
|
detail.error = error instanceof Error ? error.message : String(error);
|
|
63
|
-
const clips = error?.clips;
|
|
64
|
-
if (clips)
|
|
65
|
-
detail.clips = clips.map(c => c.clip);
|
|
66
66
|
return false;
|
|
67
67
|
}
|
|
68
68
|
}
|
|
@@ -80,7 +80,7 @@ export async function runFigureProposal(card, target, say) {
|
|
|
80
80
|
if (detail.applied)
|
|
81
81
|
return accepted();
|
|
82
82
|
if (detail.applied === false)
|
|
83
|
-
return refused(detail
|
|
83
|
+
return refused(detail, say);
|
|
84
84
|
/* A revision belongs to the figure the model saw; with that modeller gone there is nowhere true to apply it. */
|
|
85
85
|
if (!create)
|
|
86
86
|
return { verdict: 'unavailable', errorCode: 'modeller-not-open', note: say('figure.text.proposal-needs-modeller') };
|
|
@@ -89,7 +89,7 @@ export async function runFigureProposal(card, target, say) {
|
|
|
89
89
|
return accepted();
|
|
90
90
|
return opened.code === 'not-opened'
|
|
91
91
|
? { verdict: 'unavailable', errorCode: 'not-opened', note: say('figure.text.proposal-not-opened') }
|
|
92
|
-
: refused(opened
|
|
92
|
+
: refused(opened, say);
|
|
93
93
|
}
|
|
94
94
|
/*
|
|
95
95
|
A new figure's proposal handed to the modeller page across a navigation: the handler leaves it here and opens the
|