@things-factory/figure-ui 10.1.271 → 10.1.272
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 +4 -2
- package/client/modeller/clips.ts +74 -4
- package/client/modeller/fasten-form.ts +69 -4
- package/client/modeller/figure-cavity-drawing.ts +1 -3
- package/client/modeller/figure-clips.ts +101 -8
- package/client/modeller/figure-fasten-dialog.ts +41 -30
- package/client/modeller/figure-fasten-drawing.ts +3 -1
- package/client/modeller/figure-first-save.ts +4 -3
- package/client/modeller/figure-links.ts +85 -28
- package/client/modeller/figure-preview.ts +41 -3
- package/client/modeller/figure-size-editor.ts +7 -2
- package/client/modeller/first-save.ts +8 -0
- package/client/modeller/focus-on-render.ts +4 -0
- package/client/modeller/pivot.ts +76 -0
- package/client/modeller/size-response.ts +14 -0
- package/client/modeller/stage-model.ts +50 -6
- package/dist-client/modeller/canvas.d.ts +1 -0
- package/dist-client/modeller/canvas.js +3 -2
- package/dist-client/modeller/canvas.js.map +1 -1
- package/dist-client/modeller/clips.d.ts +21 -4
- package/dist-client/modeller/clips.js +69 -4
- package/dist-client/modeller/clips.js.map +1 -1
- package/dist-client/modeller/fasten-form.d.ts +8 -0
- package/dist-client/modeller/fasten-form.js +64 -5
- package/dist-client/modeller/fasten-form.js.map +1 -1
- package/dist-client/modeller/figure-cavity-drawing.js +1 -3
- package/dist-client/modeller/figure-cavity-drawing.js.map +1 -1
- package/dist-client/modeller/figure-clips.d.ts +9 -0
- package/dist-client/modeller/figure-clips.js +104 -8
- package/dist-client/modeller/figure-clips.js.map +1 -1
- package/dist-client/modeller/figure-fasten-dialog.js +39 -24
- package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
- package/dist-client/modeller/figure-fasten-drawing.js +3 -1
- package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
- package/dist-client/modeller/figure-first-save.js +4 -3
- package/dist-client/modeller/figure-first-save.js.map +1 -1
- package/dist-client/modeller/figure-links.d.ts +9 -0
- package/dist-client/modeller/figure-links.js +89 -35
- package/dist-client/modeller/figure-links.js.map +1 -1
- package/dist-client/modeller/figure-preview.js +39 -3
- package/dist-client/modeller/figure-preview.js.map +1 -1
- package/dist-client/modeller/figure-size-editor.js +7 -3
- package/dist-client/modeller/figure-size-editor.js.map +1 -1
- package/dist-client/modeller/first-save.d.ts +5 -0
- package/dist-client/modeller/first-save.js +7 -0
- package/dist-client/modeller/first-save.js.map +1 -1
- package/dist-client/modeller/focus-on-render.d.ts +2 -0
- package/dist-client/modeller/focus-on-render.js +4 -0
- package/dist-client/modeller/focus-on-render.js.map +1 -0
- package/dist-client/modeller/pivot.d.ts +12 -0
- package/dist-client/modeller/pivot.js +59 -0
- package/dist-client/modeller/pivot.js.map +1 -0
- package/dist-client/modeller/size-response.d.ts +6 -0
- package/dist-client/modeller/size-response.js +14 -0
- package/dist-client/modeller/size-response.js.map +1 -1
- package/dist-client/modeller/stage-model.d.ts +17 -3
- package/dist-client/modeller/stage-model.js +44 -6
- package/dist-client/modeller/stage-model.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/test/browser/editing.check.mjs +6 -5
- package/test/browser/section.check.mjs +21 -0
- package/test/clips.test.ts +49 -4
- package/test/fasten-form.test.ts +44 -0
- package/test/first-save.test.ts +11 -1
- package/test/pivot.test.ts +57 -0
- package/test/size-response.test.ts +10 -2
- package/test/stage-model.test.ts +23 -1
- 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
- package/tutorial/humanoid/README.md +22 -0
- package/tutorial/humanoid/chapters.mjs +247 -0
- package/tutorial/humanoid/encode.mjs +26 -0
- package/tutorial/humanoid/lib.mjs +197 -0
- package/tutorial/humanoid/record.mjs +65 -0
|
@@ -50,7 +50,7 @@ import {
|
|
|
50
50
|
stateSizeFixedOf,
|
|
51
51
|
type StateSizeForm
|
|
52
52
|
} from './links.js'
|
|
53
|
-
import { measureFactor } from './size-response.js'
|
|
53
|
+
import { measureFactor, sizeAxisWord } from './size-response.js'
|
|
54
54
|
import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
|
|
55
55
|
import { insideAlignmentOf } from './fasten-geometry.js'
|
|
56
56
|
import { fastenFormOf, fastenPlanOf, fastenSentenceOf } from './fasten-form.js'
|
|
@@ -58,9 +58,22 @@ import type { FigureRevision } from './revision.js'
|
|
|
58
58
|
import { HelpToggles } from './help.js'
|
|
59
59
|
import { groupMenu, GroupMenuStyles } from './group-menu.js'
|
|
60
60
|
import { jointAxisWord } from './joint-axis.js'
|
|
61
|
+
import { PIVOT_POINTS, pivotPointOf, pivotPointsOf, type PivotPoint } from './pivot.js'
|
|
61
62
|
import { placeTerms } from '@things-factory/figure-viewer/dist-client/detail-facts.js'
|
|
62
63
|
|
|
63
64
|
const freshMotion = (): MotionForm => ({ kind: 'slide', axis: 'x', id: '', min: 0, max: 500, follow: '', multiplier: 1, offset: 0, travel: '', travelPlus: 0 })
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The motion a part's form starts from: a part hung on another part most often turns there -- a thigh at the hip, a door
|
|
68
|
+
* at its frame -- so it starts as a turn; a part on the plane, or on nothing, as a slide (review 2026-11-11: eight
|
|
69
|
+
* joints of a humanoid, each changed from slide to turn by hand).
|
|
70
|
+
*/
|
|
71
|
+
export function motionStartOf(revision: FigureRevision | undefined, part: string): MotionForm {
|
|
72
|
+
const own = revision ? fasteningOf(revision, part) : undefined
|
|
73
|
+
return own && !own.toPlane && own.target ? { ...freshMotion(), kind: 'turn', min: -90, max: 90 } : freshMotion()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const sameMotion = (a: MotionForm, b: MotionForm) => JSON.stringify(a) === JSON.stringify(b)
|
|
64
77
|
const freshRow = () => ({ axis: '' as Axis | '', pitch: 200, fills: true, count: 5 })
|
|
65
78
|
const freshPlace = () => ({ role: 'grip' as 'seat' | 'grip', face: 'top' as FigureFace, label: '', accepts: '', capacity: 1 })
|
|
66
79
|
|
|
@@ -85,6 +98,17 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
85
98
|
:host {
|
|
86
99
|
display: block;
|
|
87
100
|
}
|
|
101
|
+
/* The turn's axis through the part's top, middle or bottom, by name, under the numbers it fills in. */
|
|
102
|
+
div[pivot-points] {
|
|
103
|
+
display: flex;
|
|
104
|
+
align-items: center;
|
|
105
|
+
gap: 8px;
|
|
106
|
+
margin: 4px 0 8px;
|
|
107
|
+
}
|
|
108
|
+
div[pivot-points] > span {
|
|
109
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
110
|
+
font-size: var(--ui-t-small, 11.5px);
|
|
111
|
+
}
|
|
88
112
|
/* A group's sub-question, under its title: quieter than the title, as a label is. */
|
|
89
113
|
p[group-question] {
|
|
90
114
|
margin: 4px 0 0;
|
|
@@ -216,7 +240,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
216
240
|
if (changed.has('asset') || changed.has('partId')) this.fixNote = ''
|
|
217
241
|
if (this.asset && this.partId && this.partId !== this.loadedFor) {
|
|
218
242
|
this.loadedFor = this.partId
|
|
219
|
-
this.motion =
|
|
243
|
+
this.motion = motionStartOf(this.revision, this.partId)
|
|
220
244
|
this.place = freshPlace()
|
|
221
245
|
this.placeEditing = null
|
|
222
246
|
this.row = freshRow()
|
|
@@ -224,6 +248,10 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
224
248
|
this.rowCandidate = undefined
|
|
225
249
|
this.editingMotion = false
|
|
226
250
|
this.motionAxis = 0
|
|
251
|
+
} else if (changed.has('revision') && this.partId && !this.editingMotion) {
|
|
252
|
+
/* Fastened since the form was loaded, a form nobody has touched starts as that part's motion now does. */
|
|
253
|
+
const was = motionStartOf(changed.get('revision') as FigureRevision | undefined, this.partId)
|
|
254
|
+
if (sameMotion(this.motion, was)) this.motion = motionStartOf(this.revision, this.partId)
|
|
227
255
|
}
|
|
228
256
|
}
|
|
229
257
|
|
|
@@ -624,7 +652,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
624
652
|
const part = this.partId
|
|
625
653
|
const axes = followableAxes(asset, part)
|
|
626
654
|
if (!axes.length) return nothing
|
|
627
|
-
const word = (axis: Axis) =>
|
|
655
|
+
const word = (axis: Axis) => sizeAxisWord(asset, part, axis, i18next.t('figure.label.size-diameter'))
|
|
628
656
|
const sized = axes.flatMap(axis => {
|
|
629
657
|
const f = followOf(asset, part, axis)
|
|
630
658
|
return f.of === 'state' ? [{ axis, f }] : []
|
|
@@ -760,7 +788,33 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
760
788
|
</div>`
|
|
761
789
|
)}
|
|
762
790
|
</div>
|
|
763
|
-
</div
|
|
791
|
+
</div>
|
|
792
|
+
${this.renderPivotPoints(m)}`
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/*
|
|
796
|
+
The axis put through the part's top end, middle or bottom end by name: the numbers above are measured from the
|
|
797
|
+
joint's origin, which a person cannot see (tutorial run 2026-11-12: a shoulder given Y 120 "to reach the shoulder"
|
|
798
|
+
turned about a point above it). The button that matches what is typed stands pressed.
|
|
799
|
+
*/
|
|
800
|
+
private pivotCache?: { key: string; points: ReturnType<typeof pivotPointsOf> }
|
|
801
|
+
private renderPivotPoints(m: MotionForm) {
|
|
802
|
+
if (!this.revision || m.throughFollows) return nothing
|
|
803
|
+
const key = `${this.revision.revision}:${this.partId}:${m.axis}`
|
|
804
|
+
if (this.pivotCache?.key !== key) this.pivotCache = { key, points: pivotPointsOf(this.revision, this.partId, m.axis as Axis) }
|
|
805
|
+
const points = this.pivotCache.points
|
|
806
|
+
if (!points) return nothing
|
|
807
|
+
return html`<div pivot-points>
|
|
808
|
+
<span>${i18next.t('figure.label.pivot-at')}</span>
|
|
809
|
+
<ox-input-select-buttons
|
|
810
|
+
.value=${pivotPointOf(points, m.through, m.axis as Axis) ?? ''}
|
|
811
|
+
.options=${PIVOT_POINTS.map(p => ({ value: p, display: i18next.t(`figure.label.pivot-${p}`) }))}
|
|
812
|
+
@change=${(e: CustomEvent) => {
|
|
813
|
+
const at = points[e.detail as PivotPoint]
|
|
814
|
+
if (at) this.motion = { ...m, through: { ...at } }
|
|
815
|
+
}}
|
|
816
|
+
></ox-input-select-buttons>
|
|
817
|
+
</div>`
|
|
764
818
|
}
|
|
765
819
|
|
|
766
820
|
private renderMotion(asset: FigureAsset) {
|
|
@@ -844,6 +898,8 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
844
898
|
const adding = k > 0 && k >= figureMotionsOf(joint).length
|
|
845
899
|
const follow = leaders.some(j => j.id === m.follow) ? m.follow : ''
|
|
846
900
|
const refusal = motionFormRefusalOf(m, follow)
|
|
901
|
+
/* Following another joint is offered after the part's own control: a row above it moved every field down a row, and
|
|
902
|
+
a name typed there chose a joint to follow (review 2026-11-11). */
|
|
847
903
|
return html`
|
|
848
904
|
${k > 0 ? html`<p motion-axis-heading><b>${i18next.t('figure.label.motion-axis-n', { n: k + 1 })}</b></p>` : nothing}
|
|
849
905
|
<div pair>
|
|
@@ -858,31 +914,8 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
858
914
|
${AXES.map(a => html`<option value=${a} ?selected=${a === m.axis}>${i18next.t('figure.label.motion-axis')} ${a.toUpperCase()}</option>`)}
|
|
859
915
|
</select>`}
|
|
860
916
|
</div>
|
|
861
|
-
${m.followSum
|
|
862
|
-
? html`<div form>
|
|
863
|
-
<span>${i18next.t('figure.label.motion-follow')}</span>
|
|
864
|
-
<span>${m.followSum.map(t => `${t.joint} × ${t.multiplier}`).join(' + ')}</span>
|
|
865
|
-
<span>${i18next.t('figure.label.motion-offset')} (${unit})</span>
|
|
866
|
-
<input type="number" step="any" .value=${String(m.offset)} @change=${(e: Event) => (this.motion = { ...m, offset: Number((e.target as HTMLInputElement).value) || 0 })} />
|
|
867
|
-
</div>`
|
|
868
|
-
: leaders.length
|
|
869
|
-
? html`<div form>
|
|
870
|
-
<span>${i18next.t('figure.label.motion-follow')}</span>
|
|
871
|
-
<select @change=${(e: Event) => (this.motion = { ...m, follow: (e.target as HTMLSelectElement).value })}>
|
|
872
|
-
<option value="" ?selected=${!follow}>${i18next.t('figure.label.motion-own-control')}</option>
|
|
873
|
-
${leaders.map(j => html`<option value=${j.id} ?selected=${j.id === follow}>${j.part}${j.state?.id ? ` · ${j.state.id}` : j.id !== j.part ? ` · ${j.id}` : ''}</option>`)}
|
|
874
|
-
</select>
|
|
875
|
-
</div>`
|
|
876
|
-
: nothing}
|
|
877
|
-
${m.followSum
|
|
917
|
+
${m.followSum || follow
|
|
878
918
|
? nothing
|
|
879
|
-
: follow
|
|
880
|
-
? html`<div form>
|
|
881
|
-
<span>${i18next.t('figure.label.motion-multiplier')}</span>
|
|
882
|
-
<input type="number" step="any" .value=${String(m.multiplier)} @change=${(e: Event) => (this.motion = { ...m, multiplier: Number((e.target as HTMLInputElement).value) })} />
|
|
883
|
-
<span>${i18next.t('figure.label.motion-offset')} (${unit})</span>
|
|
884
|
-
<input type="number" step="any" .value=${String(m.offset)} @change=${(e: Event) => (this.motion = { ...m, offset: Number((e.target as HTMLInputElement).value) || 0 })} />
|
|
885
|
-
</div>`
|
|
886
919
|
: html`<div form>
|
|
887
920
|
${m.kind === 'slide'
|
|
888
921
|
? html`<span>${i18next.t('figure.label.motion-travel')}</span>
|
|
@@ -910,6 +943,30 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
910
943
|
<input type="number" step="any" .value=${String(m.max)} @change=${(e: Event) => (this.motion = { ...m, max: Number((e.target as HTMLInputElement).value) })} />
|
|
911
944
|
</div>`}
|
|
912
945
|
</div>`}
|
|
946
|
+
${m.followSum
|
|
947
|
+
? html`<div form>
|
|
948
|
+
<span>${i18next.t('figure.label.motion-follow')}</span>
|
|
949
|
+
<span>${m.followSum.map(t => `${t.joint} × ${t.multiplier}`).join(' + ')}</span>
|
|
950
|
+
<span>${i18next.t('figure.label.motion-offset')} (${unit})</span>
|
|
951
|
+
<input type="number" step="any" .value=${String(m.offset)} @change=${(e: Event) => (this.motion = { ...m, offset: Number((e.target as HTMLInputElement).value) || 0 })} />
|
|
952
|
+
</div>`
|
|
953
|
+
: leaders.length
|
|
954
|
+
? html`<div form>
|
|
955
|
+
<span>${i18next.t('figure.label.motion-follow')}</span>
|
|
956
|
+
<select @change=${(e: Event) => (this.motion = { ...m, follow: (e.target as HTMLSelectElement).value })}>
|
|
957
|
+
<option value="" ?selected=${!follow}>${i18next.t('figure.label.motion-own-control')}</option>
|
|
958
|
+
${leaders.map(j => html`<option value=${j.id} ?selected=${j.id === follow}>${j.part}${j.state?.id ? ` · ${j.state.id}` : j.id !== j.part ? ` · ${j.id}` : ''}</option>`)}
|
|
959
|
+
</select>
|
|
960
|
+
</div>`
|
|
961
|
+
: nothing}
|
|
962
|
+
${!m.followSum && follow
|
|
963
|
+
? html`<div form>
|
|
964
|
+
<span>${i18next.t('figure.label.motion-multiplier')}</span>
|
|
965
|
+
<input type="number" step="any" .value=${String(m.multiplier)} @change=${(e: Event) => (this.motion = { ...m, multiplier: Number((e.target as HTMLInputElement).value) })} />
|
|
966
|
+
<span>${i18next.t('figure.label.motion-offset')} (${unit})</span>
|
|
967
|
+
<input type="number" step="any" .value=${String(m.offset)} @change=${(e: Event) => (this.motion = { ...m, offset: Number((e.target as HTMLInputElement).value) || 0 })} />
|
|
968
|
+
</div>`
|
|
969
|
+
: nothing}
|
|
913
970
|
${m.kind === 'slide' ? nothing : this.renderThrough(m)}
|
|
914
971
|
<button
|
|
915
972
|
act
|
|
@@ -16,6 +16,7 @@ import { dataValueAt, domainOf, motionWrittenByData, sampledStatesOf, typedValue
|
|
|
16
16
|
import { derivedOf, type FigureRevision } from './revision.js'
|
|
17
17
|
import { authoredSizeOf, bodyAtSize, PREVIEW_FACTOR, PREVIEW_WAYS, previewClampsOf, previewInstances, previewOneInstance, previewTypeOf, setupPartOf, sizeSpanOf, sizeWithinRange } from '@things-factory/figure-viewer/dist-client/preview-model.js'
|
|
18
18
|
import { previewSizeWordsOf } from './preview-size.js'
|
|
19
|
+
import { playedControlsOf } from './clips.js'
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* The figure placed on a board.
|
|
@@ -250,6 +251,28 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
250
251
|
border: 1px solid rgba(255, 255, 255, 0.22);
|
|
251
252
|
border-radius: 5px;
|
|
252
253
|
}
|
|
254
|
+
/* A motion group's clips as buttons: a list the system draws is not seen in a recording of the preview. */
|
|
255
|
+
div[clips] span[clip-choices] {
|
|
256
|
+
grid-column: 2 / -1;
|
|
257
|
+
display: flex;
|
|
258
|
+
flex-wrap: wrap;
|
|
259
|
+
gap: 4px;
|
|
260
|
+
min-width: 0;
|
|
261
|
+
}
|
|
262
|
+
div[clips] span[clip-choices] button {
|
|
263
|
+
font: inherit;
|
|
264
|
+
color: inherit;
|
|
265
|
+
padding: 1px 8px;
|
|
266
|
+
background-color: rgba(255, 255, 255, 0.08);
|
|
267
|
+
border: 1px solid rgba(255, 255, 255, 0.22);
|
|
268
|
+
border-radius: 10px;
|
|
269
|
+
cursor: pointer;
|
|
270
|
+
}
|
|
271
|
+
div[clips] span[clip-choices] button[aria-pressed='true'] {
|
|
272
|
+
background-color: rgba(255, 255, 255, 0.3);
|
|
273
|
+
border-color: rgba(255, 255, 255, 0.6);
|
|
274
|
+
font-weight: 600;
|
|
275
|
+
}
|
|
253
276
|
div[clips] svg[dial] {
|
|
254
277
|
width: 20px;
|
|
255
278
|
height: 20px;
|
|
@@ -556,6 +579,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
556
579
|
${i18next.t(this.controlsFolded ? 'figure.button.controls-unfold' : 'figure.button.controls-fold')}
|
|
557
580
|
</button>`
|
|
558
581
|
if (this.controlsFolded) return html`<div clips folded>${fold}</div>`
|
|
582
|
+
const played = playedControlsOf(asset, this.driving, this.moving)
|
|
559
583
|
return html`<div clips>
|
|
560
584
|
${fold}
|
|
561
585
|
${changed.length
|
|
@@ -580,6 +604,18 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
580
604
|
/* The step the editor's controls take (\`controlsOf\`): a degree for an angle, a hundredth of the range otherwise. */
|
|
581
605
|
const step = steps.get(control.id) ?? (control.max - control.min) / 100
|
|
582
606
|
const label = asset.stateInputs?.[control.id]?.label ?? control.id
|
|
607
|
+
/*
|
|
608
|
+
A control a playing clip drives moves on the figure while its row stood still at its last number -- 0 under a
|
|
609
|
+
walking leg (review 2026-11-11). The row says the clip has it, and is not given a value until the clip stops.
|
|
610
|
+
*/
|
|
611
|
+
const by = played.get(control.id)
|
|
612
|
+
if (by)
|
|
613
|
+
return html`<label played title=${i18next.t('figure.text.control-played-explained', { clip: by })}>
|
|
614
|
+
<span>${label} <span drive>${unitWord(control.unit)}</span></span>
|
|
615
|
+
<span></span>
|
|
616
|
+
<input type="range" min=${control.min} max=${control.max} .value=${String(value)} disabled />
|
|
617
|
+
<output>${i18next.t('figure.text.control-played', { clip: by })}</output>
|
|
618
|
+
</label>`
|
|
583
619
|
return html`<label>
|
|
584
620
|
<span>${label} <span drive>${unitWord(control.unit)}</span></span>
|
|
585
621
|
${this.renderDial(control, value)}
|
|
@@ -628,9 +664,11 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
628
664
|
? nothing
|
|
629
665
|
: html`<label>
|
|
630
666
|
<span>${group.label} <span drive>${i18next.t('figure.label.clip')}</span></span>
|
|
631
|
-
<
|
|
632
|
-
${group.clips.map(
|
|
633
|
-
|
|
667
|
+
<span clip-choices role="group" aria-label=${group.label}>
|
|
668
|
+
${group.clips.map(
|
|
669
|
+
c => html`<button type="button" aria-pressed=${String(c === playing.clip)} @click=${() => this.move(group.id, { ...playing, clip: c })}>${c}</button>`
|
|
670
|
+
)}
|
|
671
|
+
</span>
|
|
634
672
|
</label>`}
|
|
635
673
|
${byData.speed
|
|
636
674
|
? nothing
|
|
@@ -10,6 +10,7 @@ import { scrubLabel } from './live.js'
|
|
|
10
10
|
import {
|
|
11
11
|
measureFactor,
|
|
12
12
|
sameSizeSource,
|
|
13
|
+
sizeAxisWord,
|
|
13
14
|
sizeInputOf,
|
|
14
15
|
sizeRelationOf,
|
|
15
16
|
sizeRelationSteps,
|
|
@@ -39,10 +40,13 @@ export class FigureSizeEditor extends localize(i18next)(LitElement) {
|
|
|
39
40
|
:host { display: block; min-width: 0; }
|
|
40
41
|
.heading, .fields {
|
|
41
42
|
display: grid;
|
|
42
|
-
grid-template-columns: 24px minmax(55px, .85fr) 32px minmax(65px, 1fr) 42px;
|
|
43
|
+
grid-template-columns: var(--axis-column, 24px) minmax(55px, .85fr) 32px minmax(65px, 1fr) 42px;
|
|
43
44
|
gap: 6px;
|
|
44
45
|
align-items: center;
|
|
45
46
|
}
|
|
47
|
+
/* A round body's rows name its diameter (「X·Z · 지름」): every row's first column widens alike, so the rows still line up. */
|
|
48
|
+
:host([named-axes]) { --axis-column: 76px; }
|
|
49
|
+
.fields > span:first-child { white-space: nowrap; }
|
|
46
50
|
.heading { color: var(--md-sys-color-on-surface-variant); padding-bottom: 6px; }
|
|
47
51
|
.dimension { padding: 3px 0; }
|
|
48
52
|
.variable { display: flex; align-items: center; justify-content: center; min-height: var(--figure-control-height); }
|
|
@@ -98,6 +102,7 @@ export class FigureSizeEditor extends localize(i18next)(LitElement) {
|
|
|
98
102
|
const axes = followableAxes(asset, this.partId)
|
|
99
103
|
const targets = asset.appearance.map(a => a.target).filter(p => p !== this.partId && followableAxes(asset, p).length)
|
|
100
104
|
const uniform = asset.sizing === 'uniform'
|
|
105
|
+
this.toggleAttribute('named-axes', axes.some(axis => measureFactor(asset, this.partId, axis) === 2))
|
|
101
106
|
return html`
|
|
102
107
|
<div class="heading" aria-hidden="true">
|
|
103
108
|
<span>${i18next.t('figure.label.size-axis')}</span><span>${i18next.t('figure.label.size-measure')}</span><span>${i18next.t('figure.label.size-follows')}</span><span>${i18next.t('figure.label.size-source')}</span><span></span>
|
|
@@ -116,7 +121,7 @@ export class FigureSizeEditor extends localize(i18next)(LitElement) {
|
|
|
116
121
|
}
|
|
117
122
|
const currentMode = mode === 'custom' ? 'ratio' : mode
|
|
118
123
|
const input = sizeInputOf(asset, this.partId, axis)
|
|
119
|
-
const label =
|
|
124
|
+
const label = sizeAxisWord(asset, this.partId, axis, i18next.t('figure.label.size-diameter'))
|
|
120
125
|
/* The selects show when asked for, or when what it follows is not what its fastening gives -- or nothing is. */
|
|
121
126
|
const choosing = relation.variable && (this.choosing.has(key) || !sameSizeSource(source, suggested))
|
|
122
127
|
const selectedSource = source ? (source.of === 'instance' ? '@figure' : source.part) : '@custom'
|
|
@@ -11,6 +11,14 @@ export function typeOf(raw: string): string {
|
|
|
11
11
|
return raw.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The identifier as it is being typed: `typeOf`, but the hyphen just typed at the end stays, so the next word can follow
|
|
16
|
+
* it (tutorial 2026-11-11: `fork-agv` typed key by key came out `forkagv`). The save takes `typeOf` of it.
|
|
17
|
+
*/
|
|
18
|
+
export function typingTypeOf(raw: string): string {
|
|
19
|
+
return raw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+/, '')
|
|
20
|
+
}
|
|
21
|
+
|
|
14
22
|
/**
|
|
15
23
|
* Whether this name could be released, by the format's own rules (`type-not-identifier`, `type-is-placeholder`).
|
|
16
24
|
* Saving under it is not blocked -- it can still change before release -- but the author hears it now.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { compileFigureGraph } from '@hatiolab/figure-model'
|
|
2
|
+
|
|
3
|
+
import type { Axis } from './links.js'
|
|
4
|
+
import type { FigureRevision } from './revision.js'
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* Where a turn's axis may be put by name: the part's top end, its middle, its bottom end. The motion form's 「축이
|
|
8
|
+
* 지나는 점」 is measured from the joint's origin along the joint's own axes, which a person cannot see -- in the
|
|
9
|
+
* humanoid lesson the shoulder was given Y 120 to lift the pivot "to the shoulder" when the origin already stood at the
|
|
10
|
+
* arm's top end (attached level with the torso's top), and the arm turned about a point 120 mm above it (tutorial run
|
|
11
|
+
* 2026-11-12). A named point is worked out here from the part as it rests, so the number is right whatever the face,
|
|
12
|
+
* the alignment or the gap.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type PivotPoint = 'top' | 'middle' | 'bottom'
|
|
16
|
+
export const PIVOT_POINTS: readonly PivotPoint[] = ['top', 'middle', 'bottom']
|
|
17
|
+
|
|
18
|
+
type Pose = { r: number[][]; t: number[] }
|
|
19
|
+
type Faces = Partial<Record<'left' | 'right' | 'bottom' | 'top' | 'back' | 'front', { at: number }>>
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The `through` that puts a turn's axis through each named point of `part`, in the joint's frame: null when the part
|
|
23
|
+
* has no joint to another part, or its shape has no faces to measure. A distance along the turn's own axis (`along`)
|
|
24
|
+
* moves nothing and is left out: the shoulder's points read Y alone, not an X the person would wonder about.
|
|
25
|
+
*/
|
|
26
|
+
export function pivotPointsOf(revision: FigureRevision, part: string, along?: Axis): Record<PivotPoint, Partial<Record<Axis, number>>> | null {
|
|
27
|
+
const joint = revision.asset.joints?.find(j => j.body1.part === part)
|
|
28
|
+
if (!joint || !('part' in joint.body0)) return null
|
|
29
|
+
const faces = (revision.geometry.find(g => (g as { id: string }).id === part) as { shape?: { faces?: Faces } } | undefined)?.shape?.faces
|
|
30
|
+
const mid = (lo: keyof Faces, hi: keyof Faces) => (faces?.[lo] && faces?.[hi] ? (faces[lo]!.at + faces[hi]!.at) / 2 : undefined)
|
|
31
|
+
const [x, z] = [mid('left', 'right'), mid('back', 'front')]
|
|
32
|
+
if (x === undefined || z === undefined || !faces?.top || !faces?.bottom) return null
|
|
33
|
+
const pose = restingPoseOf(revision, part)
|
|
34
|
+
if (!pose) return null
|
|
35
|
+
/* The pose carries the part's own frame into the joint's: a point of the part, said in the joint's frame. */
|
|
36
|
+
const inJoint = (p: number[]): Partial<Record<Axis, number>> => {
|
|
37
|
+
const v = [0, 1, 2].map(i => pose.r[i]![0]! * p[0]! + pose.r[i]![1]! * p[1]! + pose.r[i]![2]! * p[2]! + pose.t[i]!)
|
|
38
|
+
const out: Partial<Record<Axis, number>> = {}
|
|
39
|
+
;(['x', 'y', 'z'] as const).forEach((a, i) => {
|
|
40
|
+
const mm = Math.round(v[i]! * 10) / 10
|
|
41
|
+
if (a !== along && Math.abs(mm) >= 0.05) out[a] = mm
|
|
42
|
+
})
|
|
43
|
+
return out
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
top: inJoint([x, faces.top.at, z]),
|
|
47
|
+
middle: inJoint([x, (faces.top.at + faces.bottom.at) / 2, z]),
|
|
48
|
+
bottom: inJoint([x, faces.bottom.at, z])
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The part's pose in its joint's frame with every control at its default: the joint at rest, not as the sliders left it. */
|
|
53
|
+
function restingPoseOf(revision: FigureRevision, part: string): Pose | null {
|
|
54
|
+
const m = revision.asset.document.model as unknown as { nodes: { id: string; outputs: Record<string, string> }[] }
|
|
55
|
+
const node = m.nodes.find(n => n.id === `${part}.pose`)
|
|
56
|
+
if (!node) return null
|
|
57
|
+
try {
|
|
58
|
+
const values = compileFigureGraph(m as never).evaluate({ ...revision.inputs, ...(revision.asset.stateDefaults ?? {}) }).values as Record<string, unknown>
|
|
59
|
+
const pose = values[Object.values(node.outputs)[0]!] as Pose | undefined
|
|
60
|
+
return pose?.r && pose?.t ? pose : null
|
|
61
|
+
} catch {
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Which named point a `through` is, when it is one: the form marks the button it matches. */
|
|
67
|
+
export function pivotPointOf(
|
|
68
|
+
points: Record<PivotPoint, Partial<Record<Axis, number>>> | null,
|
|
69
|
+
through: Partial<Record<Axis, number>> | undefined,
|
|
70
|
+
along?: Axis
|
|
71
|
+
): PivotPoint | undefined {
|
|
72
|
+
if (!points) return undefined
|
|
73
|
+
const same = (a: Partial<Record<Axis, number>>, b: Partial<Record<Axis, number>> = {}) =>
|
|
74
|
+
(['x', 'y', 'z'] as const).every(k => k === along || Math.abs((a[k] ?? 0) - (b[k] ?? 0)) < 0.5)
|
|
75
|
+
return PIVOT_POINTS.find(p => same(points[p], through))
|
|
76
|
+
}
|
|
@@ -13,6 +13,20 @@ function editOf(asset: FigureAsset, part: string, axis: Axis) {
|
|
|
13
13
|
}
|
|
14
14
|
export const measureFactor = (asset: FigureAsset, part: string, axis: Axis) => editOf(asset, part, axis)?.edit.k ?? 1
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* A size row's name: the axis, and 「지름」 for a measure set by its radius. A round body's one diameter across sets the
|
|
18
|
+
* axis it has no row of its own for as well, and says so -- a sphere read 「X · 지름」 and 「Y · 지름」, and a person
|
|
19
|
+
* looked for its Z (tutorial run 2026-11-12); now 「X·Z · 지름」 across and 「Y · 지름」 up.
|
|
20
|
+
*/
|
|
21
|
+
export function sizeAxisWord(asset: FigureAsset, part: string, axis: Axis, diameter: string): string {
|
|
22
|
+
if (measureFactor(asset, part, axis) !== 2) return axis.toUpperCase()
|
|
23
|
+
const axes = followableAxes(asset, part)
|
|
24
|
+
const across = (['x', 'y', 'z'] as const).filter(a => !axes.includes(a))
|
|
25
|
+
const first = axes.find(a => measureFactor(asset, part, a) === 2)
|
|
26
|
+
const named = first === axis && across.length ? [axis, ...across].map(a => a.toUpperCase()).join('·') : axis.toUpperCase()
|
|
27
|
+
return `${named} · ${diameter}`
|
|
28
|
+
}
|
|
29
|
+
|
|
16
30
|
/** Only expose direct own inputs (including the canonical identity wrapper). Never overwrite an arbitrary expression. */
|
|
17
31
|
export function sizeInputOf(asset: FigureAsset, part: string, axis: Axis): string | undefined {
|
|
18
32
|
const e = editOf(asset, part, axis)
|
|
@@ -183,20 +183,64 @@ export function boardFor(parts: readonly FigureRenderPart[], at: Board & { depth
|
|
|
183
183
|
return {
|
|
184
184
|
width: Math.max(at.width, Math.ceil(reach('x') * 1.2), 200),
|
|
185
185
|
height: Math.max(at.height, Math.ceil(reach('z') * 1.2), 200),
|
|
186
|
-
/*
|
|
187
|
-
|
|
186
|
+
/*
|
|
187
|
+
Only grows, as the plan does: a raised arm made the board taller than it was, and a new board lost the camera. The
|
|
188
|
+
height is reached from the floor, as width and depth are from the middle: a pelvis set 880 mm up stood as tall as
|
|
189
|
+
its own 180 mm, and the view never followed it up (review 2026-11-11).
|
|
190
|
+
*/
|
|
191
|
+
depth: Math.max(at.depth ?? 0, Math.ceil(Math.max(size.y, box.isEmpty() ? 0 : Math.max(Math.abs(box.max.y), Math.abs(box.min.y))) * 1.1), 200)
|
|
188
192
|
}
|
|
189
193
|
}
|
|
190
194
|
|
|
195
|
+
/** Where the figure's drawn parts reach, in mm: the box the view was last fitted round, or the one it has now. */
|
|
196
|
+
export interface DrawnSpan {
|
|
197
|
+
min: [number, number, number]
|
|
198
|
+
max: [number, number, number]
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function drawnSpanOf(parts: readonly FigureRenderPart[]): DrawnSpan | undefined {
|
|
202
|
+
const box = new THREE.Box3()
|
|
203
|
+
for (const part of parts) {
|
|
204
|
+
const frame = frameOf(part)
|
|
205
|
+
if (!frame) continue
|
|
206
|
+
const half = frame.extent.clone().multiplyScalar(0.5)
|
|
207
|
+
for (const x of [-1, 1]) for (const y of [-1, 1]) for (const z of [-1, 1])
|
|
208
|
+
box.expandByPoint(new THREE.Vector3(x * half.x, y * half.y, z * half.z).add(frame.centre).applyMatrix4(frame.rotation).add(frame.origin))
|
|
209
|
+
}
|
|
210
|
+
return box.isEmpty() ? undefined : { min: box.min.toArray() as [number, number, number], max: box.max.toArray() as [number, number, number] }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Whether the figure now reaches past the box the view was fitted round, on any side, by more than a tenth of that box
|
|
215
|
+
* (review 2026-11-11: a thigh hung under a pelvis ran off the bottom of the view; the board's size alone, measured from
|
|
216
|
+
* the middle and the floor, had not changed enough to say so).
|
|
217
|
+
*/
|
|
218
|
+
export function outgrowsFit(fitted: DrawnSpan | undefined, now: DrawnSpan | undefined): boolean {
|
|
219
|
+
if (!fitted || !now) return false
|
|
220
|
+
return [0, 1, 2].some(i => {
|
|
221
|
+
const room = Math.max((fitted.max[i]! - fitted.min[i]!) * 0.1, 20)
|
|
222
|
+
return now.max[i]! > fitted.max[i]! + room || now.min[i]! < fitted.min[i]! - room
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** How far the figure may grow past its last fit before the view follows: inside the fit's own margin. */
|
|
227
|
+
export const REFIT_GROWN = 1.1
|
|
228
|
+
|
|
191
229
|
/**
|
|
192
230
|
* Whether the figure's reach has changed so far since the view was last fitted that the camera should fit it again:
|
|
193
|
-
*
|
|
194
|
-
* put the camera inside it, and the author saw grey until pressing 「화면에 맞추기」). A drag that nudges
|
|
195
|
-
* move the camera.
|
|
231
|
+
* grown past `REFIT_GROWN`, or shrunk below half, on any axis (first 15 minutes, 2026-10-16: a 100 mm box typed to
|
|
232
|
+
* 3000 × 800 × 600 put the camera inside it, and the author saw grey until pressing 「화면에 맞추기」). A drag that nudges
|
|
233
|
+
* a part within the board's rounding does not move the camera.
|
|
196
234
|
*/
|
|
235
|
+
|
|
197
236
|
export function needsRefit(fitted: (Board & { depth: number }) | undefined, now: Board & { depth: number }): boolean {
|
|
198
237
|
if (!fitted) return false
|
|
199
|
-
|
|
238
|
+
/*
|
|
239
|
+
Grown past the room the fit leaves round it (the fit's margin, 1.15) the figure runs off the view: a head added on a
|
|
240
|
+
body went out at the top, and the author pressed the fit after every part (review 2026-11-11). Shrunk, it is only
|
|
241
|
+
small in the view: fitted again at half.
|
|
242
|
+
*/
|
|
243
|
+
return (['width', 'height', 'depth'] as const).some(key => now[key] > fitted[key] * REFIT_GROWN || now[key] < fitted[key] / 2)
|
|
200
244
|
}
|
|
201
245
|
|
|
202
246
|
/**
|
|
@@ -3,7 +3,7 @@ import { LitElement, html, css, unsafeCSS } from 'lit';
|
|
|
3
3
|
import { customElement, property, query } from 'lit/decorators.js';
|
|
4
4
|
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
|
-
import { boardFor, changedKeys, componentOf, gizmoResultOf, needsRefit } from './stage-model.js';
|
|
6
|
+
import { boardFor, changedKeys, componentOf, drawnSpanOf, gizmoResultOf, needsRefit, outgrowsFit } from './stage-model.js';
|
|
7
7
|
import { JOINT_FAN } from './joint-fan.js';
|
|
8
8
|
import { SLOT_MARK, SLOT_MARK_CHOSEN } from './slot-layout-host.js';
|
|
9
9
|
import { PREVIEW, TARGET_FACE } from './command-preview.js';
|
|
@@ -146,6 +146,7 @@ let FigureCanvas = class FigureCanvas extends LitElement {
|
|
|
146
146
|
/** The whole figure in view: the board's fit, then the 3D camera's own. */
|
|
147
147
|
fit() {
|
|
148
148
|
this.fittedFor = boardFor(this.parts);
|
|
149
|
+
this.fittedSpan = drawnSpanOf(this.parts);
|
|
149
150
|
this.scene?.fit?.('ratio');
|
|
150
151
|
this.scene?.root?.fitCameraToModel?.();
|
|
151
152
|
}
|
|
@@ -176,7 +177,7 @@ let FigureCanvas = class FigureCanvas extends LitElement {
|
|
|
176
177
|
*/
|
|
177
178
|
clearTimeout(this.refitTimer);
|
|
178
179
|
if (this.parts.length)
|
|
179
|
-
this.refitTimer = setTimeout(() => needsRefit(this.fittedFor, boardFor(this.parts)) && this.fit(), REFIT_SETTLE_MS);
|
|
180
|
+
this.refitTimer = setTimeout(() => (needsRefit(this.fittedFor, boardFor(this.parts)) || outgrowsFit(this.fittedSpan, drawnSpanOf(this.parts))) && this.fit(), REFIT_SETTLE_MS);
|
|
180
181
|
return;
|
|
181
182
|
}
|
|
182
183
|
this.teardown();
|