@things-factory/figure-ui 10.1.223 → 10.1.224
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/canvas.ts +2 -2
- package/client/modeller/consumer-capabilities.ts +66 -0
- package/client/modeller/data-interface.ts +230 -80
- package/client/modeller/editor.ts +14 -7
- package/client/modeller/figure-capability-panel.ts +145 -1
- package/client/modeller/figure-data-panel.ts +495 -188
- package/client/modeller/figure-links.ts +130 -12
- package/client/modeller/figure-preview.ts +19 -2
- package/client/modeller/figure-slot-layout-panel.ts +7 -3
- package/client/modeller/links.ts +58 -0
- package/client/modeller/refusals.ts +12 -4
- package/client/modeller/slot-layout-host.ts +76 -5
- package/dist-client/modeller/canvas.js +2 -2
- package/dist-client/modeller/canvas.js.map +1 -1
- package/dist-client/modeller/consumer-capabilities.d.ts +29 -0
- package/dist-client/modeller/consumer-capabilities.js +60 -0
- package/dist-client/modeller/consumer-capabilities.js.map +1 -0
- package/dist-client/modeller/data-interface.d.ts +83 -17
- package/dist-client/modeller/data-interface.js +194 -82
- package/dist-client/modeller/data-interface.js.map +1 -1
- package/dist-client/modeller/editor.d.ts +5 -2
- package/dist-client/modeller/editor.js +16 -5
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/figure-capability-panel.d.ts +12 -0
- package/dist-client/modeller/figure-capability-panel.js +123 -1
- package/dist-client/modeller/figure-capability-panel.js.map +1 -1
- package/dist-client/modeller/figure-data-panel.d.ts +57 -13
- package/dist-client/modeller/figure-data-panel.js +461 -180
- package/dist-client/modeller/figure-data-panel.js.map +1 -1
- package/dist-client/modeller/figure-links.d.ts +11 -0
- package/dist-client/modeller/figure-links.js +137 -16
- package/dist-client/modeller/figure-links.js.map +1 -1
- package/dist-client/modeller/figure-preview.d.ts +4 -0
- package/dist-client/modeller/figure-preview.js +19 -1
- package/dist-client/modeller/figure-preview.js.map +1 -1
- package/dist-client/modeller/figure-slot-layout-panel.js +6 -4
- package/dist-client/modeller/figure-slot-layout-panel.js.map +1 -1
- package/dist-client/modeller/links.d.ts +22 -0
- package/dist-client/modeller/links.js +48 -0
- package/dist-client/modeller/links.js.map +1 -1
- package/dist-client/modeller/refusals.js +14 -6
- package/dist-client/modeller/refusals.js.map +1 -1
- package/dist-client/modeller/slot-layout-host.d.ts +28 -3
- package/dist-client/modeller/slot-layout-host.js +68 -6
- package/dist-client/modeller/slot-layout-host.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +7 -7
- package/test/browser/data-table-page.js +9 -0
- package/test/browser/links.check.mjs +25 -3
- package/test/consumer-capabilities.test.ts +116 -0
- package/test/data-motion.test.ts +94 -27
- package/test/links.test.ts +15 -0
- package/test/slot-layout-host.test.ts +63 -5
- package/translations/en.json +81 -23
- package/translations/es.json +81 -23
- package/translations/ja.json +81 -23
- package/translations/ko.json +81 -23
- package/translations/ms.json +81 -23
- package/translations/zh.json +81 -23
|
@@ -8,7 +8,7 @@ import { customElement, property, state } from 'lit/decorators.js'
|
|
|
8
8
|
|
|
9
9
|
import { i18next, localize } from '@operato/i18n'
|
|
10
10
|
import { figureFaceOverhangOf, figureMotionsOf, motionIdOf } from '@hatiolab/figure-model'
|
|
11
|
-
import type { FigureAsset, FigureAuthoringAction, FigureFace } from '@hatiolab/figure-model'
|
|
11
|
+
import type { FigureAsset, FigureAuthoringAction, FigureBindingPlace, FigureFace } from '@hatiolab/figure-model'
|
|
12
12
|
|
|
13
13
|
import {
|
|
14
14
|
acceptsOf,
|
|
@@ -39,8 +39,17 @@ import {
|
|
|
39
39
|
rowOf,
|
|
40
40
|
positionsWorkedOut,
|
|
41
41
|
type Axis,
|
|
42
|
-
type MotionForm
|
|
42
|
+
type MotionForm,
|
|
43
|
+
followOf,
|
|
44
|
+
followableAxes,
|
|
45
|
+
measureNow,
|
|
46
|
+
freshStateSize,
|
|
47
|
+
stateSizeIdFor,
|
|
48
|
+
stateSizeCommandOf,
|
|
49
|
+
stateSizeFixedOf,
|
|
50
|
+
type StateSizeForm
|
|
43
51
|
} from './links.js'
|
|
52
|
+
import { measureFactor } from './size-response.js'
|
|
44
53
|
import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
|
|
45
54
|
import { insideAlignmentOf } from './fasten-geometry.js'
|
|
46
55
|
import { fastenFormOf, fastenPlanOf, fastenSentenceOf } from './fasten-form.js'
|
|
@@ -179,7 +188,11 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
179
188
|
/** Why 정렬·간격 고치기 moved nothing, said under it; empty once it moved the part or the part changed. */
|
|
180
189
|
@state() private fixNote = ''
|
|
181
190
|
@state() private motion: MotionForm = freshMotion()
|
|
191
|
+
/* The form for a measure a control sets (「상태에 따라 크기」). */
|
|
192
|
+
@state() private stateSize: StateSizeForm = freshStateSize()
|
|
182
193
|
@state() private place = freshPlace()
|
|
194
|
+
/** The id of the place the form changes, kept so the place keeps its id (a release refuses a place id that goes); null adds one. */
|
|
195
|
+
@state() private placeEditing: string | null = null
|
|
183
196
|
/** How places are made here: one at a time, or by the figure's rule. Unset: by the rule when the figure has one. */
|
|
184
197
|
@state() private placesBy?: 'one' | 'rule'
|
|
185
198
|
@state() private row = freshRow()
|
|
@@ -204,6 +217,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
204
217
|
this.loadedFor = this.partId
|
|
205
218
|
this.motion = freshMotion()
|
|
206
219
|
this.place = freshPlace()
|
|
220
|
+
this.placeEditing = null
|
|
207
221
|
this.row = freshRow()
|
|
208
222
|
this.rowOpen = false
|
|
209
223
|
this.rowCandidate = undefined
|
|
@@ -238,7 +252,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
238
252
|
if (!asset || !this.partId) return nothing
|
|
239
253
|
if (this.section === 'motion')
|
|
240
254
|
return html`<section><h3>${i18next.t('figure.label.motion')}${this.help.button('figure.text.motion-explained')}</h3>${this.help.hint('figure.text.motion-explained')}
|
|
241
|
-
${this.renderMotion(asset)}${this.renderShown(asset)}</section>`
|
|
255
|
+
${this.renderMotion(asset)}${this.renderShown(asset)}${this.renderStateSize(asset)}</section>`
|
|
242
256
|
if (this.section === 'places') {
|
|
243
257
|
/*
|
|
244
258
|
One idea, places, two ways to make them (ADR-0118): one at a time on this part, or many by a rule -- the figure's
|
|
@@ -306,6 +320,12 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
306
320
|
return shown
|
|
307
321
|
}
|
|
308
322
|
|
|
323
|
+
/** A place on this part into the form, to change it under its own id. */
|
|
324
|
+
private editPlace(one: FigureBindingPlace) {
|
|
325
|
+
this.placeEditing = one.id
|
|
326
|
+
this.place = { role: one.role, face: one.face ?? 'top', label: one.label, accepts: (one.accepts ?? []).join(', '), capacity: one.capacity }
|
|
327
|
+
}
|
|
328
|
+
|
|
309
329
|
/** Where things are held on this part: a seat things rest on, or a grip things are held in. */
|
|
310
330
|
private renderPlaces(asset: FigureAsset) {
|
|
311
331
|
const faces = placeFacesOf(asset, this.partId)
|
|
@@ -321,6 +341,9 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
321
341
|
<b bound>${one.label}</b>
|
|
322
342
|
<small>${one.face ? i18next.t('figure.label.face-' + one.face) + ' · ' : ''}${placeTerms(one, (key, params) => i18next.t(key, params))}</small>
|
|
323
343
|
</span>
|
|
344
|
+
<button quiet edit-place ?disabled=${this.locked} @click=${() => this.editPlace(one)}>
|
|
345
|
+
<md-icon>edit</md-icon>${i18next.t('figure.button.edit-place')}
|
|
346
|
+
</button>
|
|
324
347
|
<button quiet ?disabled=${this.locked} @click=${() => this.command({ kind: 'remove-binding', id: one.id })}>
|
|
325
348
|
<md-icon>close</md-icon>${i18next.t('figure.button.remove-place')}
|
|
326
349
|
</button>
|
|
@@ -345,28 +368,35 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
345
368
|
<span>${i18next.t('figure.label.place-name')}</span>
|
|
346
369
|
<input .value=${p.label} placeholder=${roleName(p.role)} @change=${(e: Event) => (this.place = { ...p, label: (e.target as HTMLInputElement).value })} />
|
|
347
370
|
<span>${i18next.t('figure.label.place-accepts')}</span>
|
|
348
|
-
<input .value=${p.accepts} placeholder
|
|
371
|
+
<input .value=${p.accepts} placeholder=${i18next.t('figure.text.place-accepts-any')} @input=${(e: Event) => (this.place = { ...p, accepts: (e.target as HTMLInputElement).value })} />
|
|
349
372
|
<span>${i18next.t('figure.label.place-capacity')}</span>
|
|
350
373
|
<input type="number" min="1" step="1" .value=${String(p.capacity)} @change=${(e: Event) => (this.place = { ...p, capacity: (e.target as HTMLInputElement).valueAsNumber })} />
|
|
351
374
|
</div>
|
|
352
|
-
${accepts.length ? nothing : html`<small>${i18next.t('figure.text.place-accepts-needed')}</small>`}
|
|
353
375
|
<button
|
|
354
376
|
act
|
|
355
|
-
?disabled=${this.locked || !
|
|
356
|
-
@click=${() =>
|
|
377
|
+
?disabled=${this.locked || !capacityOk}
|
|
378
|
+
@click=${() => {
|
|
357
379
|
this.command({
|
|
358
380
|
kind: 'add-binding',
|
|
359
|
-
id: placeIdFor(asset, this.partId, p.role),
|
|
381
|
+
id: this.placeEditing ?? placeIdFor(asset, this.partId, p.role),
|
|
360
382
|
label: p.label.trim() || roleName(p.role),
|
|
361
383
|
role: p.role,
|
|
362
384
|
part: this.partId,
|
|
363
385
|
face,
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
386
|
+
/* None named: it takes any item (ADR-0051 §4.1 decision 5). */
|
|
387
|
+
...(accepts.length ? { accepts } : {}),
|
|
388
|
+
capacity: p.capacity,
|
|
389
|
+
...(this.placeEditing ? { replace: true } : {})
|
|
390
|
+
})
|
|
391
|
+
this.placeEditing = null
|
|
392
|
+
this.place = freshPlace()
|
|
393
|
+
}}
|
|
367
394
|
>
|
|
368
|
-
<md-icon>back_hand</md-icon>${i18next.t('figure.button.add-place')}
|
|
395
|
+
<md-icon>back_hand</md-icon>${i18next.t(this.placeEditing ? 'figure.button.save-place' : 'figure.button.add-place')}
|
|
369
396
|
</button>
|
|
397
|
+
${this.placeEditing
|
|
398
|
+
? html`<button quiet @click=${() => ((this.placeEditing = null), (this.place = freshPlace()))}>${i18next.t('figure.button.cancel-place-edit')}</button>`
|
|
399
|
+
: nothing}
|
|
370
400
|
`
|
|
371
401
|
}
|
|
372
402
|
|
|
@@ -582,6 +612,94 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
582
612
|
* When the part is drawn: always, or while a control is at least a value -- a lamp lit while the gate is open. One
|
|
583
613
|
* part edit (`set-visibility`), one step. Offered once the figure has a control; a motion is what makes one.
|
|
584
614
|
*/
|
|
615
|
+
/**
|
|
616
|
+
* A measure a control sets (modeller snag C1, 2026-10-26): a wound roll shrinking to its core. Each measure that does is
|
|
617
|
+
* one line with the way back to a fixed number; the form makes another. Measures read as the size row shows them --
|
|
618
|
+
* a cylinder's X as its diameter.
|
|
619
|
+
*/
|
|
620
|
+
private renderStateSize(asset: FigureAsset) {
|
|
621
|
+
const revision = this.revision
|
|
622
|
+
if (!revision) return nothing
|
|
623
|
+
const part = this.partId
|
|
624
|
+
const axes = followableAxes(asset, part)
|
|
625
|
+
if (!axes.length) return nothing
|
|
626
|
+
const word = (axis: Axis) => `${axis.toUpperCase()}${measureFactor(asset, part, axis) === 2 ? ' · ' + i18next.t('figure.label.size-diameter') : ''}`
|
|
627
|
+
const unitWord = (unit: string) => i18next.t(`figure.label.state-unit-${unit}`)
|
|
628
|
+
const sized = axes.flatMap(axis => {
|
|
629
|
+
const f = followOf(asset, part, axis)
|
|
630
|
+
return f.of === 'state' ? [{ axis, f }] : []
|
|
631
|
+
})
|
|
632
|
+
const free = axes.filter(axis => !sized.some(one => one.axis === axis))
|
|
633
|
+
const form = this.stateSize
|
|
634
|
+
const axis = form.axis && free.includes(form.axis) ? form.axis : free[0] ?? ''
|
|
635
|
+
const factor = axis ? measureFactor(asset, part, axis) : 1
|
|
636
|
+
const atMax = Number.isFinite(form.atMax) ? form.atMax : axis ? (measureNow(revision, part, axis) ?? NaN) : NaN
|
|
637
|
+
const id = form.id || stateSizeIdFor(asset, part)
|
|
638
|
+
const controls = visibilityControlsOf(asset)
|
|
639
|
+
const command = stateSizeCommandOf(asset, part, { ...form, axis, atMax }, factor, id)
|
|
640
|
+
/* A control already there is taken with its unit and range; another unit or range would be refused. */
|
|
641
|
+
const pickControl = (value: string) => {
|
|
642
|
+
const there = controls.find(c => c.id === value)
|
|
643
|
+
this.stateSize = there && ['percent', 'ratio', 'mm'].includes(there.unit)
|
|
644
|
+
? { ...form, id: value, unit: there.unit as StateSizeForm['unit'], min: there.min, max: there.max }
|
|
645
|
+
: { ...form, id: value }
|
|
646
|
+
}
|
|
647
|
+
const number = (key: 'min' | 'max' | 'atMin' | 'atMax') => (e: Event) => (this.stateSize = { ...form, axis, [key]: (e.target as HTMLInputElement).valueAsNumber })
|
|
648
|
+
return html`<h4 state-size>${i18next.t('figure.label.state-size')}</h4>
|
|
649
|
+
${sized.map(({ axis: one, f }) => {
|
|
650
|
+
const k = measureFactor(asset, part, one)
|
|
651
|
+
const input = controls.find(c => c.id === f.input)
|
|
652
|
+
return html`<div pair state-size-row=${one}>
|
|
653
|
+
<span>${word(one)}
|
|
654
|
+
<small>${i18next.t('figure.text.state-size-row', {
|
|
655
|
+
control: input?.label ?? f.input,
|
|
656
|
+
min: String(input?.min ?? ''),
|
|
657
|
+
max: String(input?.max ?? ''),
|
|
658
|
+
unit: input ? unitWord(input.unit) : '',
|
|
659
|
+
from: String(Math.round(f.at.min * k * 1000) / 1000),
|
|
660
|
+
to: String(Math.round(f.at.max * k * 1000) / 1000)
|
|
661
|
+
})}</small>
|
|
662
|
+
</span>
|
|
663
|
+
<button act ?disabled=${this.locked} @click=${() => {
|
|
664
|
+
const back = stateSizeFixedOf(asset, part, one)
|
|
665
|
+
if (back) this.command(back)
|
|
666
|
+
}}><md-icon>close</md-icon>${i18next.t('figure.button.state-size-fix')}</button>
|
|
667
|
+
</div>`
|
|
668
|
+
})}
|
|
669
|
+
${free.length
|
|
670
|
+
? html`<div form state-size-form>
|
|
671
|
+
<span>${i18next.t('figure.label.state-size-measure')}</span>
|
|
672
|
+
<select ?disabled=${this.locked} @change=${(e: Event) => (this.stateSize = { ...form, axis: (e.target as HTMLSelectElement).value as Axis, atMax: NaN })}>
|
|
673
|
+
${free.map(one => html`<option value=${one} ?selected=${one === axis}>${word(one)}</option>`)}
|
|
674
|
+
</select>
|
|
675
|
+
<span>${i18next.t('figure.label.control-id')}</span>
|
|
676
|
+
<input list="state-size-controls" .value=${id} ?disabled=${this.locked} @change=${(e: Event) => pickControl((e.target as HTMLInputElement).value.trim())} />
|
|
677
|
+
<datalist id="state-size-controls">${controls.map(c => html`<option value=${c.id}>${c.label}</option>`)}</datalist>
|
|
678
|
+
<span>${i18next.t('figure.label.state-size-unit')}</span>
|
|
679
|
+
<select ?disabled=${this.locked} @change=${(e: Event) => {
|
|
680
|
+
const unit = (e.target as HTMLSelectElement).value as StateSizeForm['unit']
|
|
681
|
+
this.stateSize = { ...form, axis, unit, ...(unit === 'percent' ? { min: 0, max: 100 } : unit === 'ratio' ? { min: 0, max: 1 } : {}) }
|
|
682
|
+
}}>
|
|
683
|
+
${(['percent', 'ratio', 'mm'] as const).map(u => html`<option value=${u} ?selected=${u === form.unit}>${unitWord(u)}</option>`)}
|
|
684
|
+
</select>
|
|
685
|
+
<span>${i18next.t('figure.label.control-range')} (${unitWord(form.unit)})</span>
|
|
686
|
+
<div pair>
|
|
687
|
+
<input type="number" step="any" .value=${String(form.min)} ?disabled=${this.locked} @change=${number('min')} />
|
|
688
|
+
<input type="number" step="any" .value=${String(form.max)} ?disabled=${this.locked} @change=${number('max')} />
|
|
689
|
+
</div>
|
|
690
|
+
<span>${i18next.t('figure.label.state-size-at-min')} (mm)</span>
|
|
691
|
+
<input type="number" step="any" min="0" state-size-at-min .value=${Number.isFinite(form.atMin) ? String(form.atMin) : ''} ?disabled=${this.locked} @change=${number('atMin')} />
|
|
692
|
+
<span>${i18next.t('figure.label.state-size-at-max')} (mm)</span>
|
|
693
|
+
<input type="number" step="any" min="0" state-size-at-max .value=${Number.isFinite(atMax) ? String(atMax) : ''} ?disabled=${this.locked} @change=${number('atMax')} />
|
|
694
|
+
</div>
|
|
695
|
+
<button act add-state-size ?disabled=${this.locked || !command} @click=${() => {
|
|
696
|
+
if (!command) return
|
|
697
|
+
this.command(command)
|
|
698
|
+
this.stateSize = freshStateSize()
|
|
699
|
+
}}><md-icon>open_in_full</md-icon>${i18next.t('figure.button.add-state-size')}</button>`
|
|
700
|
+
: nothing}`
|
|
701
|
+
}
|
|
702
|
+
|
|
585
703
|
private renderShown(asset: FigureAsset) {
|
|
586
704
|
const controls = visibilityControlsOf(asset)
|
|
587
705
|
if (!controls.length) return nothing
|
|
@@ -4,7 +4,7 @@ import { customElement, property, query, state } from 'lit/decorators.js'
|
|
|
4
4
|
import { i18next, localize } from '@operato/i18n'
|
|
5
5
|
import { compileFigureAsset } from '@hatiolab/figure-model'
|
|
6
6
|
import type { FigureAsset, FigureDataRow, FigureDataValue, FigureGraphStateInput } from '@hatiolab/figure-model'
|
|
7
|
-
import { create, registerFigure, unregisterFigure } from '@hatiolab/things-scene'
|
|
7
|
+
import { create, figureLegendSourcesChanged, registerFigure, unregisterFigure } from '@hatiolab/things-scene'
|
|
8
8
|
|
|
9
9
|
import { DEFAULT_VIEW, previewBoardDepth, sceneLook, WORKBENCH, type ViewSettings } from '@things-factory/figure-viewer/dist-client/figure-view.js'
|
|
10
10
|
import { dialShape } from './angle-dial.js'
|
|
@@ -33,7 +33,11 @@ interface SceneHandle {
|
|
|
33
33
|
resize?: () => void
|
|
34
34
|
fit?: (type?: string) => void
|
|
35
35
|
/** The scene's container: it can make the content again while keeping the 3D renderer and the camera (undo and redo use it). */
|
|
36
|
-
rootContainer?: {
|
|
36
|
+
rootContainer?: {
|
|
37
|
+
rebuildContentModel?: (model: unknown, options?: { preserveThreeD?: boolean }) => void
|
|
38
|
+
/** The scene's legends, by name (ADR-0166 7): what its figures' tables choose from. */
|
|
39
|
+
style?: Record<string, unknown>
|
|
40
|
+
}
|
|
37
41
|
root?: {
|
|
38
42
|
set?: (patch: Record<string, unknown>) => void
|
|
39
43
|
fitCameraToModel?: () => void
|
|
@@ -265,6 +269,8 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
265
269
|
@state() private valuing: Record<string, FigureDataValue> = {}
|
|
266
270
|
/** Data tried in the data tab: the preview's figures take it as their \`data\`, as a board's data source gives it; null when none is. */
|
|
267
271
|
@property({ attribute: false }) sample: unknown = null
|
|
272
|
+
/** Sample legends by role (ADR-0166 10): the scene's legends named as the roles, as a board would give them. */
|
|
273
|
+
@property({ attribute: false }) legends: Record<string, unknown> = {}
|
|
268
274
|
/** The part chosen in the editor. The size help starts on it, so the help speaks of the part the author was working on. */
|
|
269
275
|
@property() selected = ''
|
|
270
276
|
@state() private setupPart = ''
|
|
@@ -526,8 +532,17 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
526
532
|
if (this.sample !== null && this.sample !== undefined) this.tellData(this.sample)
|
|
527
533
|
}
|
|
528
534
|
|
|
535
|
+
/** The sample legends as the scene's (its root container's, where a board's are): its tables choose again. */
|
|
536
|
+
private tellLegends() {
|
|
537
|
+
const container = this.scene?.rootContainer
|
|
538
|
+
if (!container) return
|
|
539
|
+
container.style = { ...this.legends }
|
|
540
|
+
figureLegendSourcesChanged()
|
|
541
|
+
}
|
|
542
|
+
|
|
529
543
|
willUpdate(changed: Map<string, unknown>) {
|
|
530
544
|
if (changed.has('sample') && this.scene) this.tellData(this.sample)
|
|
545
|
+
if (changed.has('legends') && this.scene) this.tellLegends()
|
|
531
546
|
if (!this.hasUpdated) return
|
|
532
547
|
if (!this.active) {
|
|
533
548
|
this.stale = true
|
|
@@ -622,6 +637,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
622
637
|
this.scene!.rootContainer!.rebuildContentModel!(model, { preserveThreeD: true })
|
|
623
638
|
this.standing = signature
|
|
624
639
|
this.cannot = ''
|
|
640
|
+
this.tellLegends()
|
|
625
641
|
this.tellSample()
|
|
626
642
|
return
|
|
627
643
|
} catch {
|
|
@@ -633,6 +649,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
633
649
|
this.scene = create({
|
|
634
650
|
target: this.stage,
|
|
635
651
|
model,
|
|
652
|
+
style: { ...this.legends },
|
|
636
653
|
mode: 0
|
|
637
654
|
}) as SceneHandle
|
|
638
655
|
this.standing = signature
|
|
@@ -7,11 +7,13 @@ import { compileFigureAsset, type FigureAsset, type SlotLayout } from '@hatiolab
|
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
anchorFacesOf,
|
|
10
|
+
anchorRoomOf,
|
|
10
11
|
anchorIdFor,
|
|
11
12
|
figureSlotIssues,
|
|
12
13
|
slotLayoutCommandOf,
|
|
13
14
|
slotLayoutFormOf,
|
|
14
15
|
slotPresetsOf,
|
|
16
|
+
startSizeOf,
|
|
15
17
|
type SlotAnchor,
|
|
16
18
|
type SlotAnchorFace,
|
|
17
19
|
type SlotLayoutForm
|
|
@@ -170,13 +172,15 @@ export class FigureSlotLayoutPanel extends localize(i18next)(LitElement) {
|
|
|
170
172
|
const value = {
|
|
171
173
|
canUseDefault: false,
|
|
172
174
|
presets: slotPresetsOf(form.anchors),
|
|
173
|
-
/* The first anchor's face is the size a preset is laid out over
|
|
174
|
-
presetSize:
|
|
175
|
+
/* The first anchor's face is the size a preset is laid out over, the room over it the slot height: no number to type. */
|
|
176
|
+
presetSize: startSizeOf(form.anchors, anchorFaces, anchorRoomOf(form.anchors, geometry)),
|
|
177
|
+
/* No layout yet: the whole face is one slot, shown at once and put in on Apply. */
|
|
178
|
+
startFrom: 'single',
|
|
175
179
|
anchors: form.anchors.map(a => ({ id: a.id, label: `${a.id} · ${a.part} ${i18next.t('figure.label.face-' + a.face)}` })),
|
|
176
180
|
anchorFaces,
|
|
177
181
|
validate: (layout: SlotLayout) => figureSlotIssues(layout, form.anchors.map(a => a.id)),
|
|
178
182
|
issueText: (issue: { code: string; path: string }) =>
|
|
179
|
-
issue.code === '
|
|
183
|
+
issue.code === 'ACCEPTS_EMPTY' ? `${issue.path}: ${i18next.t('figure.text.slot-accepts-empty-list')}` : undefined
|
|
180
184
|
}
|
|
181
185
|
this.options = { key, asset, value }
|
|
182
186
|
return value
|
package/client/modeller/links.ts
CHANGED
|
@@ -163,6 +163,9 @@ export function partSummaryOf(
|
|
|
163
163
|
} else if (f.of === 'input') {
|
|
164
164
|
key = 'figure.text.summary-size-input'
|
|
165
165
|
params = { field: fieldWordsOf(f.input, k => say(k)) }
|
|
166
|
+
} else if (f.of === 'state') {
|
|
167
|
+
key = 'figure.text.summary-size-state'
|
|
168
|
+
params = { control: f.input }
|
|
166
169
|
} else key = 'figure.text.summary-size-worked-out'
|
|
167
170
|
const id = key + JSON.stringify(params)
|
|
168
171
|
lines.set(id, { key, params, axes: [...(lines.get(id)?.axes ?? []), axis] })
|
|
@@ -729,6 +732,8 @@ export function followWordsOf(follow: Follow, say: (key: string, params?: Record
|
|
|
729
732
|
return say('figure.label.follows-part-axis', { part: follow.part, axis: follow.axis.toUpperCase() })
|
|
730
733
|
case 'input':
|
|
731
734
|
return follow.input
|
|
735
|
+
case 'state':
|
|
736
|
+
return say('figure.label.follows-state', { control: follow.input })
|
|
732
737
|
case 'expression':
|
|
733
738
|
return say('figure.label.follows-expression')
|
|
734
739
|
}
|
|
@@ -744,6 +749,8 @@ export function choiceOf(follow: Follow): string {
|
|
|
744
749
|
return `part:${follow.part}:${follow.axis}`
|
|
745
750
|
case 'input':
|
|
746
751
|
return `input:${follow.input}`
|
|
752
|
+
case 'state':
|
|
753
|
+
return `state:${follow.input}`
|
|
747
754
|
case 'expression':
|
|
748
755
|
return 'expression'
|
|
749
756
|
}
|
|
@@ -863,3 +870,54 @@ export function turnsAboutAJoint(asset: FigureAsset): boolean {
|
|
|
863
870
|
|
|
864
871
|
/** The joint made still: its fastening, its place and its row kept, its motion gone (the kernel's `figureJointAsFixed`, ADR-0140 D6). */
|
|
865
872
|
export const fixedJointOf = figureJointAsFixed
|
|
873
|
+
|
|
874
|
+
/** The form that sets a measure by a control (「상태에 따라 크기」): which measure, the control, and the measure at its two ends. */
|
|
875
|
+
export interface StateSizeForm {
|
|
876
|
+
axis: Axis | ''
|
|
877
|
+
id: string
|
|
878
|
+
unit: 'percent' | 'ratio' | 'mm'
|
|
879
|
+
min: number
|
|
880
|
+
max: number
|
|
881
|
+
/** The measure at the control's least and most, as the size row shows it (a cylinder's diameter). */
|
|
882
|
+
atMin: number
|
|
883
|
+
atMax: number
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
export const freshStateSize = (): StateSizeForm => ({ axis: '', id: '', unit: 'percent', min: 0, max: 100, atMin: NaN, atMax: NaN })
|
|
887
|
+
|
|
888
|
+
/** The control a new one would be named: `fill`, then `<part>-fill` when that is taken by another kind of value. */
|
|
889
|
+
export function stateSizeIdFor(asset: FigureAsset, part: string): string {
|
|
890
|
+
const taken = modelOf(asset).inputs.find(i => i.id === 'fill')
|
|
891
|
+
return !taken || taken.role === 'state' ? 'fill' : `${part}-fill`
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* The command the form sends, or why it cannot: a control with a range, and a positive measure at each end. The
|
|
896
|
+
* measure is typed as the row shows it and sent as the part's own (a diameter halved). A measure that follows something
|
|
897
|
+
* else is replaced: the author chose this one.
|
|
898
|
+
*/
|
|
899
|
+
export function stateSizeCommandOf(asset: FigureAsset, part: string, form: StateSizeForm, factor: number, id: string): FigureAuthoringAction | undefined {
|
|
900
|
+
if (!form.axis || !id) return undefined
|
|
901
|
+
if (!(Number.isFinite(form.min) && Number.isFinite(form.max) && form.max > form.min)) return undefined
|
|
902
|
+
if (!(form.atMin > 0 && form.atMax > 0)) return undefined
|
|
903
|
+
const following = followOf(asset, part, form.axis).of !== 'own'
|
|
904
|
+
return {
|
|
905
|
+
kind: 'size-by-state',
|
|
906
|
+
part,
|
|
907
|
+
axis: form.axis,
|
|
908
|
+
state: { id, unit: form.unit, min: form.min, max: form.max },
|
|
909
|
+
at: { min: form.atMin / factor, max: form.atMax / factor },
|
|
910
|
+
...(following ? { replace: true } : {})
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** A measure a control sets, back to a fixed number: the one it has where its control rests. */
|
|
915
|
+
export function stateSizeFixedOf(asset: FigureAsset, part: string, axis: Axis): FigureAuthoringAction | undefined {
|
|
916
|
+
const f = followOf(asset, part, axis)
|
|
917
|
+
if (f.of !== 'state') return undefined
|
|
918
|
+
const input = modelOf(asset).inputs.find(i => i.id === f.input)
|
|
919
|
+
if (!input || input.min === undefined || input.max === undefined) return undefined
|
|
920
|
+
const rest = asset.stateDefaults[f.input] ?? input.max
|
|
921
|
+
const mm = f.at.min + ((f.at.max - f.at.min) * (rest - input.min)) / (input.max - input.min)
|
|
922
|
+
return { kind: 'unlink-dimension', part, axis, mm: Math.round(mm * 1e6) / 1e6 }
|
|
923
|
+
}
|
|
@@ -269,10 +269,18 @@ export function dataMotionRefusalOf(error: unknown, say: Say): string | null {
|
|
|
269
269
|
high: domain.within ? numberOf(domain.within[1]) : '',
|
|
270
270
|
value: domain.value === undefined ? '' : String(domain.value)
|
|
271
271
|
})
|
|
272
|
-
const
|
|
273
|
-
if (
|
|
274
|
-
const
|
|
275
|
-
if (
|
|
272
|
+
const target = detailsOf(error, 'data-table-target')
|
|
273
|
+
if (target) return say('figure.text.data-table-target-' + target.what, { binding: target.binding, id: target.id ?? '' })
|
|
274
|
+
const entry = detailsOf(error, 'data-table-entry')
|
|
275
|
+
if (entry)
|
|
276
|
+
return say('figure.text.data-table-entry-' + entry.what, {
|
|
277
|
+
binding: entry.binding,
|
|
278
|
+
key: entry.key === 'default' ? say('figure.label.data-otherwise', {}) : entry.key,
|
|
279
|
+
id: entry.id ?? '',
|
|
280
|
+
value: entry.value === undefined ? '' : String(typeof entry.value === 'object' ? JSON.stringify(entry.value) : entry.value)
|
|
281
|
+
})
|
|
282
|
+
const legend = detailsOf(error, 'data-legend-invalid')
|
|
283
|
+
if (legend) return say('figure.text.data-legend-invalid', { role: legend.role })
|
|
276
284
|
const clipAbsent = detailsOf(error, 'motion-clip-absent')
|
|
277
285
|
if (clipAbsent) return say('figure.text.motion-clip-absent', { clip: clipAbsent.clip, group: clipAbsent.group })
|
|
278
286
|
const shared = detailsOf(error, 'motion-clip-shared')
|
|
@@ -9,12 +9,15 @@
|
|
|
9
9
|
* Kept free of Lit, i18n and the editor's element so node tests read it directly.
|
|
10
10
|
*/
|
|
11
11
|
import {
|
|
12
|
+
figureBindingsOf,
|
|
13
|
+
figureShapeBoundsOf,
|
|
12
14
|
figureSlotLayoutOf,
|
|
13
15
|
figureSlotLayoutIssues,
|
|
14
16
|
slotLayoutPreset,
|
|
15
17
|
SLOT_LAYOUT_PRESET_KEYS,
|
|
16
18
|
SLOT_LAYOUT_PRESET_PARAMS,
|
|
17
19
|
type FigureAsset,
|
|
20
|
+
type FigureFace,
|
|
18
21
|
type SlotLayout,
|
|
19
22
|
type SlotPresetSize
|
|
20
23
|
} from '@hatiolab/figure-model'
|
|
@@ -46,8 +49,28 @@ export function slotLayoutFormOf(asset: FigureAsset, part: string): { form: Slot
|
|
|
46
49
|
return { form: { label: '', role: 'seat', anchors: part ? [{ id: part, part, face: 'top' }] : [] } }
|
|
47
50
|
}
|
|
48
51
|
|
|
49
|
-
/** How big each anchor's face is: things-scene's, the
|
|
50
|
-
export { figureAnchorFacesOf as anchorFacesOf } from '@hatiolab/things-scene'
|
|
52
|
+
/** How big each anchor's face is, and the room the figure leaves over it: things-scene's, the ones a board's placed figure is measured with too. */
|
|
53
|
+
export { figureAnchorFacesOf as anchorFacesOf, figureAnchorRoomOf as anchorRoomOf } from '@hatiolab/things-scene'
|
|
54
|
+
import { figureAnchorRoomOf } from '@hatiolab/things-scene'
|
|
55
|
+
|
|
56
|
+
/* How high a place over a face is when nobody said: the room the figure leaves over it, else the face's shorter side. */
|
|
57
|
+
const heightOver = (room: number | undefined, width: number, depth: number) => ((room ?? 0) > 0 ? room! : Math.min(width, depth))
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Where an author starts with no numbers to type (slot layout drawn on the face, step 1): the first anchor's face, and a
|
|
61
|
+
* slot as high as the room the figure leaves over it (a forklift's forks: up to its mast) -- the face's shorter side when
|
|
62
|
+
* the face is the figure's top. Nothing when the face is not known: the editor then asks for the size.
|
|
63
|
+
*/
|
|
64
|
+
export function startSizeOf(
|
|
65
|
+
anchors: readonly SlotAnchor[],
|
|
66
|
+
faces: Record<string, { width: number; depth: number }>,
|
|
67
|
+
room: Record<string, number>
|
|
68
|
+
): Partial<SlotPresetSize> {
|
|
69
|
+
const first = anchors[0]
|
|
70
|
+
const face = first && faces[first.id]
|
|
71
|
+
if (!face) return {}
|
|
72
|
+
return { width: face.width, depth: face.depth, slotHeight: heightOver(room[first.id], face.width, face.depth) }
|
|
73
|
+
}
|
|
51
74
|
|
|
52
75
|
/**
|
|
53
76
|
* Where an author starts: the equipment presets (figure-model, ADR-0118 decision 8), laid out on this figure's anchors --
|
|
@@ -67,13 +90,13 @@ export function slotPresetsOf(anchors: readonly SlotAnchor[]) {
|
|
|
67
90
|
}
|
|
68
91
|
|
|
69
92
|
/*
|
|
70
|
-
* What figure-model asks beyond the editor's own checks:
|
|
71
|
-
*
|
|
93
|
+
* What figure-model asks beyond the editor's own checks: a slot names the types it takes or takes any item, never an
|
|
94
|
+
* empty list (ADR-0051 §4.1 decision 5, 2026-10-27). The kernel's own check (`figureSlotLayoutIssues`, the one a
|
|
72
95
|
* placement's override is held to as well), said before Apply rather than refused after it; the editor already shows the
|
|
73
96
|
* common findings, so only the figure's own are added.
|
|
74
97
|
*/
|
|
75
98
|
export function figureSlotIssues(layout: SlotLayout, anchors: readonly string[] = layout.tiers.map(t => t.anchorId)): SlotEditorIssue[] {
|
|
76
|
-
return figureSlotLayoutIssues(layout, anchors).filter(issue => issue.code === '
|
|
99
|
+
return figureSlotLayoutIssues(layout, anchors).filter(issue => issue.code === 'ACCEPTS_EMPTY')
|
|
77
100
|
}
|
|
78
101
|
|
|
79
102
|
/** The command the panel sends: the whole layout, its anchors and its name, as one step of the history. */
|
|
@@ -97,6 +120,8 @@ export function anchorIdFor(part: string, taken: readonly string[]): string {
|
|
|
97
120
|
|
|
98
121
|
/* The token the canvas draws slots in: translucent, never picked, never saved. */
|
|
99
122
|
export const SLOT_MARK = '__slot'
|
|
123
|
+
/* The token the selected part's places are drawn in: the same box, in the primary colour, so the list and the canvas meet. */
|
|
124
|
+
export const SLOT_MARK_CHOSEN = '__slot_chosen'
|
|
100
125
|
|
|
101
126
|
type Evaluated = { id: string; pose: { r: number[][]; t: number[] }; region: { dimensions: number[] } | null }
|
|
102
127
|
|
|
@@ -126,3 +151,49 @@ export function slotMarksOf(places: readonly Evaluated[], layout: SlotLayout | u
|
|
|
126
151
|
}
|
|
127
152
|
return marks
|
|
128
153
|
}
|
|
154
|
+
|
|
155
|
+
/* Which way each face looks out of its part: the axis and the side. */
|
|
156
|
+
const OUTWARD: Record<FigureFace, ['x' | 'y' | 'z', 1 | -1]> = { top: ['y', 1], bottom: ['y', -1], right: ['x', 1], left: ['x', -1], front: ['z', 1], back: ['z', -1] }
|
|
157
|
+
/* The two axes a face lies along. */
|
|
158
|
+
const ACROSS = { x: ['y', 'z'], y: ['x', 'z'], z: ['x', 'y'] } as const
|
|
159
|
+
|
|
160
|
+
type Body = { id: string; shape?: Parameters<typeof figureShapeBoundsOf>[0]; pose?: { r: number[][]; t: number[] } }
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The places held one by one -- a forklift's fork, a lift's plate -- drawn where they are, as the slot layout's are: a
|
|
164
|
+
* place given a size as a box of that size out of its face, one without as its part's face raised as high as a slot
|
|
165
|
+
* would start there (the room over it, else the face's shorter side), so an author sees which face things sit on or
|
|
166
|
+
* hang from and about how much room they have. The selected part's places in the primary colour.
|
|
167
|
+
*/
|
|
168
|
+
export function placeMarksOf(places: readonly Evaluated[], asset: FigureAsset, geometry: readonly Body[], selected?: string) {
|
|
169
|
+
const at = new Map(places.map(p => [p.id, p] as const))
|
|
170
|
+
const marks: FigureRenderPart[] = []
|
|
171
|
+
for (const one of figureBindingsOf(asset)) {
|
|
172
|
+
const p = at.get(one.id)
|
|
173
|
+
if (one.slot || !one.face || !p) continue
|
|
174
|
+
const [axis, side] = OUTWARD[one.face]
|
|
175
|
+
const [u, v] = ACROSS[axis]
|
|
176
|
+
let size: Record<'x' | 'y' | 'z', number>
|
|
177
|
+
if (one.region) size = one.region
|
|
178
|
+
else {
|
|
179
|
+
const shape = geometry.find(g => g.id === one.part)?.shape
|
|
180
|
+
if (!shape) continue
|
|
181
|
+
const b = figureShapeBoundsOf(shape)
|
|
182
|
+
const face = { x: b.x.max - b.x.min, y: b.y.max - b.y.min, z: b.z.max - b.z.min }
|
|
183
|
+
const room = axis === 'y' ? figureAnchorRoomOf([{ id: one.id, part: one.part!, face: one.face }], geometry as never)[one.id] : 0
|
|
184
|
+
size = { ...face, [axis]: heightOver(room, face[u], face[v]) }
|
|
185
|
+
}
|
|
186
|
+
const out = size[axis]
|
|
187
|
+
marks.push({
|
|
188
|
+
id: `${SLOT_MARK}:${one.id}`,
|
|
189
|
+
shape: {
|
|
190
|
+
provider: 'extrude',
|
|
191
|
+
dimensions: { width: size.x, height: size.y, depth: size.z },
|
|
192
|
+
extrusion: { axis, section: { kind: 'rectangle', size: { [u]: size[u], [v]: size[v] } }, start: side > 0 ? 0 : -out, end: side > 0 ? out : 0 }
|
|
193
|
+
},
|
|
194
|
+
pose: p.pose,
|
|
195
|
+
material: { token: one.part === selected ? SLOT_MARK_CHOSEN : SLOT_MARK, transparent: true }
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
return marks
|
|
199
|
+
}
|
|
@@ -5,7 +5,7 @@ import { create, Model, niceGridStep } from '@hatiolab/things-scene';
|
|
|
5
5
|
import { DEFAULT_VIEW, sceneLook, WORKBENCH } from '@things-factory/figure-viewer/dist-client/figure-view.js';
|
|
6
6
|
import { boardFor, changedKeys, componentOf, gizmoResultOf, needsRefit } from './stage-model.js';
|
|
7
7
|
import { JOINT_FAN } from './joint-fan.js';
|
|
8
|
-
import { SLOT_MARK } from './slot-layout-host.js';
|
|
8
|
+
import { SLOT_MARK, SLOT_MARK_CHOSEN } from './slot-layout-host.js';
|
|
9
9
|
import { PREVIEW, TARGET_FACE } from './command-preview.js';
|
|
10
10
|
import { ownerOf } from './fields.js';
|
|
11
11
|
/* How long the figure stands still before the camera judges whether to follow it. */
|
|
@@ -225,7 +225,7 @@ let FigureCanvas = class FigureCanvas extends LitElement {
|
|
|
225
225
|
/* The fan in the accent colour, translucent; `capturable: false` keeps clicks, the marquee and the gizmo off it. */
|
|
226
226
|
const fan = this.fan ? componentOf(this.fan, { ...this.palette, [JOINT_FAN]: this.palette['palette.accent'] ?? FAN_COLOUR }, this.board) : null;
|
|
227
227
|
const accent = this.palette['palette.accent'] ?? FAN_COLOUR;
|
|
228
|
-
const slots = this.marks.map(part => componentOf(part, { ...this.palette, [SLOT_MARK]: this.palette['palette.secondary'] ?? accent }, this.board));
|
|
228
|
+
const slots = this.marks.map(part => componentOf(part, { ...this.palette, [SLOT_MARK]: this.palette['palette.secondary'] ?? accent, [SLOT_MARK_CHOSEN]: this.palette['palette.primary'] ?? accent }, this.board));
|
|
229
229
|
const shown = [...(fan ? [fan] : []), ...slots, ...this.ghost.map(part => componentOf(part, { ...this.palette, [PREVIEW]: accent, [TARGET_FACE]: accent }, this.board)), ...(this.highlight ? [componentOf(this.highlight, { ...this.palette, [TARGET_FACE]: accent }, this.board)] : [])];
|
|
230
230
|
return [...parts, ...shown.filter((c) => !!c).map(c => ({ ...c, capturable: false, locked: true }))];
|
|
231
231
|
}
|