@things-factory/figure-ui 10.1.270 → 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/graphql/index.ts +2 -0
- package/client/modeller/canvas.ts +4 -2
- package/client/modeller/clips.ts +74 -4
- package/client/modeller/data-interface.ts +14 -0
- package/client/modeller/editor-styles.ts +11 -0
- package/client/modeller/editor.ts +39 -11
- package/client/modeller/fasten-form.ts +73 -3
- package/client/modeller/fasten-geometry.ts +11 -1
- 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 +5 -4
- package/client/modeller/figure-first-save.ts +4 -3
- package/client/modeller/figure-links.ts +85 -28
- package/client/modeller/figure-preview.ts +50 -4
- 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/palette.ts +27 -0
- package/client/modeller/pivot.ts +76 -0
- package/client/modeller/product-session.ts +5 -1
- package/client/modeller/size-response.ts +14 -0
- package/client/modeller/stage-model.ts +50 -6
- package/client/types.ts +2 -0
- package/dist-client/graphql/index.js +2 -0
- package/dist-client/graphql/index.js.map +1 -1
- 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 +23 -6
- package/dist-client/modeller/clips.js +69 -4
- package/dist-client/modeller/clips.js.map +1 -1
- package/dist-client/modeller/data-interface.d.ts +5 -0
- package/dist-client/modeller/data-interface.js +15 -0
- package/dist-client/modeller/data-interface.js.map +1 -1
- package/dist-client/modeller/editor-styles.js +11 -0
- package/dist-client/modeller/editor-styles.js.map +1 -1
- package/dist-client/modeller/editor.d.ts +6 -0
- package/dist-client/modeller/editor.js +35 -11
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/fasten-form.d.ts +8 -0
- package/dist-client/modeller/fasten-form.js +68 -5
- package/dist-client/modeller/fasten-form.js.map +1 -1
- package/dist-client/modeller/fasten-geometry.d.ts +6 -0
- package/dist-client/modeller/fasten-geometry.js +10 -1
- package/dist-client/modeller/fasten-geometry.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 +5 -4
- 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 +48 -4
- 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/links.d.ts +1 -1
- package/dist-client/modeller/palette.d.ts +14 -0
- package/dist-client/modeller/palette.js +23 -0
- package/dist-client/modeller/palette.js.map +1 -1
- 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/product-session.js +6 -2
- package/dist-client/modeller/product-session.js.map +1 -1
- 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/route.d.ts +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/types.d.ts +2 -0
- package/dist-client/types.js.map +1 -1
- package/package.json +5 -5
- 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/data-motion.test.ts +8 -0
- package/test/fasten-form.test.ts +57 -0
- package/test/fasten-geometry.test.ts +25 -1
- package/test/first-part-on-plane.test.ts +52 -0
- package/test/first-save.test.ts +11 -1
- package/test/pivot.test.ts +57 -0
- package/test/save-landed.test.ts +20 -1
- package/test/size-response.test.ts +10 -2
- package/test/stage-model.test.ts +23 -1
- package/translations/en.json +9 -0
- package/translations/es.json +9 -0
- package/translations/ja.json +9 -0
- package/translations/ko.json +9 -0
- package/translations/ms.json +9 -0
- package/translations/zh.json +9 -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
|
@@ -89,7 +89,28 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
|
|
|
89
89
|
}
|
|
90
90
|
details[fasten-detail] > div[form] {
|
|
91
91
|
margin-top: 8px;
|
|
92
|
-
max-width:
|
|
92
|
+
max-width: 420px;
|
|
93
|
+
}
|
|
94
|
+
/* Where across the face, as buttons: a list the system draws is not seen in a recording, nor reached by a key in every browser. */
|
|
95
|
+
div[align-choices] {
|
|
96
|
+
display: flex;
|
|
97
|
+
flex-wrap: wrap;
|
|
98
|
+
gap: 4px;
|
|
99
|
+
}
|
|
100
|
+
div[align-choices] button {
|
|
101
|
+
font: inherit;
|
|
102
|
+
font-size: var(--ui-t-small, 11.5px);
|
|
103
|
+
padding: 3px 8px;
|
|
104
|
+
border-radius: 12px;
|
|
105
|
+
border: 1px solid var(--md-sys-color-outline-variant, #c4c7c5);
|
|
106
|
+
background: transparent;
|
|
107
|
+
color: inherit;
|
|
108
|
+
cursor: pointer;
|
|
109
|
+
}
|
|
110
|
+
div[align-choices] button[aria-pressed='true'] {
|
|
111
|
+
border-color: var(--md-sys-color-primary, #0d8f86);
|
|
112
|
+
background: var(--md-sys-color-primary-container, #cdeeea);
|
|
113
|
+
font-weight: 600;
|
|
93
114
|
}
|
|
94
115
|
span[fasten-summary] {
|
|
95
116
|
opacity: 0.8;
|
|
@@ -253,7 +274,7 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
|
|
|
253
274
|
<summary>
|
|
254
275
|
${i18next.t('figure.label.fasten-detail')}${detail.length ? html` · <span fasten-summary>${detail.join(' · ')}</span>` : nothing}
|
|
255
276
|
</summary>
|
|
256
|
-
${plan.alignRows.length ? this.renderAlignment(plan.alignRows) : nothing}
|
|
277
|
+
${(plan.alignAxes ?? plan.alignRows).length ? this.renderAlignment(plan.alignAxes ?? plan.alignRows) : nothing}
|
|
257
278
|
<div form>
|
|
258
279
|
<span>${i18next.t('figure.label.gap')}</span>
|
|
259
280
|
<input
|
|
@@ -384,34 +405,24 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
|
|
|
384
405
|
: 'centre'
|
|
385
406
|
: (f.align[axis] ?? 'centre')
|
|
386
407
|
return html`<span>${i18next.t('figure.label.align-' + axis)}</span>
|
|
387
|
-
<
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
edges
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
}
|
|
406
|
-
<option value="mm" ?selected=${how === 'mm'}>${i18next.t('figure.label.align-mm')}</option>
|
|
407
|
-
${
|
|
408
|
-
this.revision && hasInstanceSize(this.revision.asset, axis)
|
|
409
|
-
? html`<option value="share" ?selected=${how === 'share'}>
|
|
410
|
-
${i18next.t('figure.label.align-share')}
|
|
411
|
-
</option>`
|
|
412
|
-
: nothing
|
|
413
|
-
}
|
|
414
|
-
</select>
|
|
408
|
+
<div align-choices role="group" aria-label=${i18next.t('figure.label.align-' + axis)}>
|
|
409
|
+
${[
|
|
410
|
+
{ value: 'centre' as Alignment, words: i18next.t('figure.label.align-centre'), shown: true },
|
|
411
|
+
{ value: 'min' as Alignment, words: i18next.t(`figure.label.align-${axis}-min`), shown: edges },
|
|
412
|
+
{ value: 'max' as Alignment, words: i18next.t(`figure.label.align-${axis}-max`), shown: edges },
|
|
413
|
+
{
|
|
414
|
+
value: 'input' as Alignment,
|
|
415
|
+
words: f.alignInput?.[axis] ? i18next.t('figure.label.align-input', { field: fieldWordsOf(f.alignInput[axis]!, key => i18next.t(key)) }) : '',
|
|
416
|
+
shown: !!f.alignInput?.[axis]
|
|
417
|
+
},
|
|
418
|
+
{ value: 'mm' as Alignment, words: i18next.t('figure.label.align-mm'), shown: true },
|
|
419
|
+
{ value: 'share' as Alignment, words: i18next.t('figure.label.align-share'), shown: !!this.revision && hasInstanceSize(this.revision.asset, axis) }
|
|
420
|
+
]
|
|
421
|
+
.filter(choice => choice.shown)
|
|
422
|
+
.map(
|
|
423
|
+
choice => html`<button type="button" aria-pressed=${String(how === choice.value)} @click=${() => set(axis, choice.value)}>${choice.words}</button>`
|
|
424
|
+
)}
|
|
425
|
+
</div>
|
|
415
426
|
${
|
|
416
427
|
how === 'mm' || how === 'share'
|
|
417
428
|
? html`<span></span
|
|
@@ -6,7 +6,8 @@ import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-
|
|
|
6
6
|
import { figureAroundNormalOf, figureAroundOf, figureCaughtAngleOf, figureSurfaceAnglesOf, figureSurfacePlaceOf } from '@hatiolab/figure-model'
|
|
7
7
|
import type { FigureFace, FigureSurfacePlace } from '@hatiolab/figure-model'
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { focusOnRender } from './focus-on-render.js'
|
|
10
|
+
import { alignmentAt, drawingSpanOf, fastenLabelsOf, offsetOf, overhangOf, screenOf, type FaceView, type FastenMode } from './fasten-geometry.js'
|
|
10
11
|
import type { Alignment } from './links.js'
|
|
11
12
|
|
|
12
13
|
type Axis = 'x' | 'y' | 'z'
|
|
@@ -332,8 +333,7 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
|
|
|
332
333
|
const { h, v, sign } = screenOf(view.axes)
|
|
333
334
|
const W = WHERE_W,
|
|
334
335
|
H = WHERE_H
|
|
335
|
-
|
|
336
|
-
const span = (a: Axis) => (view.face[a] !== undefined ? view.face[a]! + view.part[a] : view.part[a] * 3)
|
|
336
|
+
const span = (a: Axis) => drawingSpanOf(view.face[a], view.part[a])
|
|
337
337
|
const k = Math.min((W - 40) / span(h), (H - 40) / span(v))
|
|
338
338
|
this.scale = k
|
|
339
339
|
const cx = W / 2,
|
|
@@ -433,7 +433,8 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
|
|
|
433
433
|
this.change({ axis: a, how: 'mm', mm: Math.round(value) })
|
|
434
434
|
this.released()
|
|
435
435
|
}}
|
|
436
|
-
@blur=${() => (this.typing = undefined)}
|
|
436
|
+
@blur=${() => (this.typing = undefined)}
|
|
437
|
+
${focusOnRender()} />`
|
|
437
438
|
}
|
|
438
439
|
|
|
439
440
|
private grab(ev: PointerEvent, at: Record<Axis, number>, caught: Partial<Record<Axis, string>>) {
|
|
@@ -7,7 +7,7 @@ import { CommonPopupStyles, FormFieldStyles } from '@operato/styles'
|
|
|
7
7
|
import '@material/web/button/filled-button.js'
|
|
8
8
|
import '@material/web/button/text-button.js'
|
|
9
9
|
|
|
10
|
-
import { firstSaveRefusalOf, offeredTypeOf, releasableType, typeOf, type FirstSave } from './first-save.js'
|
|
10
|
+
import { firstSaveRefusalOf, offeredTypeOf, releasableType, typeOf, typingTypeOf, type FirstSave } from './first-save.js'
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* The first save of a new figure: its type and its name, asked once, in the suite's popup look.
|
|
@@ -97,13 +97,13 @@ export class FigureFirstSave extends LitElement {
|
|
|
97
97
|
.value=${this.type}
|
|
98
98
|
placeholder=${i18next.t('figure.text.figure-type-example')}
|
|
99
99
|
@input=${(e: Event) => {
|
|
100
|
-
this.type =
|
|
100
|
+
this.type = typingTypeOf((e.target as HTMLInputElement).value)
|
|
101
101
|
;(e.target as HTMLInputElement).value = this.type
|
|
102
102
|
this.refusal = null
|
|
103
103
|
}}
|
|
104
104
|
@keydown=${this.onKey}
|
|
105
105
|
/>
|
|
106
|
-
${this.type && !releasableType(this.type)
|
|
106
|
+
${typeOf(this.type) && !releasableType(typeOf(this.type))
|
|
107
107
|
? html`<div class="note">${i18next.t('figure.text.type-name-will-not-release')}</div>`
|
|
108
108
|
: this.suggested && this.type === this.suggested
|
|
109
109
|
? html`<div class="note">${i18next.t('figure.text.identifier-suggested')}</div>`
|
|
@@ -132,6 +132,7 @@ export class FigureFirstSave extends LitElement {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
private confirm = () => {
|
|
135
|
+
this.type = typeOf(this.type)
|
|
135
136
|
this.refusal = firstSaveRefusalOf(this.type, this.taken)
|
|
136
137
|
if (this.refusal) return
|
|
137
138
|
const namespace = this.namespaces.length > 1 ? this.chosenNamespace : undefined
|
|
@@ -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
|
|
@@ -12,10 +12,11 @@ import { DEFAULT_VIEW, previewBoardDepth, sceneLook, WORKBENCH, type ViewSetting
|
|
|
12
12
|
import { parametersForClip } from '@things-factory/figure-viewer/dist-client/detail-motion.js'
|
|
13
13
|
import { controlsOf } from '@things-factory/figure-viewer/dist-client/controls.js'
|
|
14
14
|
import { dialShape } from './angle-dial.js'
|
|
15
|
-
import { dataValueAt, domainOf, motionWrittenByData, typedValueOf, valueRowsOf } from './data-interface.js'
|
|
15
|
+
import { dataValueAt, domainOf, motionWrittenByData, sampledStatesOf, typedValueOf, valueRowsOf } from './data-interface.js'
|
|
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;
|
|
@@ -550,11 +573,13 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
550
573
|
|
|
551
574
|
private renderDrivers(asset: FigureAsset, controls: FigureGraphStateInput[], clips: string[]) {
|
|
552
575
|
const steps = new Map(controlsOf(asset).map(c => [c.id, c.step]))
|
|
576
|
+
const sampledStates = sampledStatesOf(asset, this.sample)
|
|
553
577
|
const changed = controls.filter(c => this.giving[c.id] !== undefined && this.giving[c.id] !== asset.stateDefaults[c.id])
|
|
554
578
|
const fold = html`<button fold-controls aria-expanded=${String(!this.controlsFolded)} @click=${() => (this.controlsFolded = !this.controlsFolded)}>
|
|
555
579
|
${i18next.t(this.controlsFolded ? 'figure.button.controls-unfold' : 'figure.button.controls-fold')}
|
|
556
580
|
</button>`
|
|
557
581
|
if (this.controlsFolded) return html`<div clips folded>${fold}</div>`
|
|
582
|
+
const played = playedControlsOf(asset, this.driving, this.moving)
|
|
558
583
|
return html`<div clips>
|
|
559
584
|
${fold}
|
|
560
585
|
${changed.length
|
|
@@ -568,10 +593,29 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
568
593
|
</button>`
|
|
569
594
|
: nothing}
|
|
570
595
|
${controls.map(control => {
|
|
596
|
+
/* A control the data tab's sample sets is what the figure shows, as a value row the sample gives: said, not offered. */
|
|
597
|
+
const fromSample = sampledStates[control.id]
|
|
598
|
+
if (fromSample !== undefined)
|
|
599
|
+
return html`<label sampled title=${i18next.t('figure.text.data-value-from-sample')}>
|
|
600
|
+
<span>${asset.stateInputs?.[control.id]?.label ?? control.id} <span drive>${i18next.t('figure.label.data-value-from-sample')}</span></span>
|
|
601
|
+
<output>${Math.round(fromSample * 100) / 100} ${unitWord(control.unit)}</output>
|
|
602
|
+
</label>`
|
|
571
603
|
const value = this.giving[control.id] ?? asset.stateDefaults[control.id] ?? control.min
|
|
572
604
|
/* The step the editor's controls take (\`controlsOf\`): a degree for an angle, a hundredth of the range otherwise. */
|
|
573
605
|
const step = steps.get(control.id) ?? (control.max - control.min) / 100
|
|
574
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>`
|
|
575
619
|
return html`<label>
|
|
576
620
|
<span>${label} <span drive>${unitWord(control.unit)}</span></span>
|
|
577
621
|
${this.renderDial(control, value)}
|
|
@@ -620,9 +664,11 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
620
664
|
? nothing
|
|
621
665
|
: html`<label>
|
|
622
666
|
<span>${group.label} <span drive>${i18next.t('figure.label.clip')}</span></span>
|
|
623
|
-
<
|
|
624
|
-
${group.clips.map(
|
|
625
|
-
|
|
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>
|
|
626
672
|
</label>`}
|
|
627
673
|
${byData.speed
|
|
628
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.
|
|
@@ -69,3 +69,30 @@ export function paletteRecipe(kind: FigurePaletteKind, id: string): FigurePrimit
|
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Whether a part added now is the figure's base, added fastened to its mounting plane: the figure has no part yet and
|
|
75
|
+
* stands on a plane -- the floor (also when not said), the ceiling or a wall. A centred figure has no plane to stand on.
|
|
76
|
+
*/
|
|
77
|
+
export function firstPartFastensToPlane(drawn: readonly unknown[], placement: string | undefined): boolean {
|
|
78
|
+
return drawn.length === 0 && placement !== 'center'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const PALETTE_KINDS: FigurePaletteKind[] = ['box', 'cylinder', 'rounded-box', 'sphere', 'frustum', 'polygon', 'pyramid', 'pipe', 'plate', 'wall', 'hollow-box', 'portal']
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Whether a drawn part still stands where the palette put it -- unturned, at one of the places a part is added at. Such a
|
|
85
|
+
* part was not placed by the author: fastening it has no place of theirs to keep (tutorial 2026-11-11, a mast added
|
|
86
|
+
* inside the body was fastened 550 mm deep in it).
|
|
87
|
+
*/
|
|
88
|
+
export function standsWhereAdded(pose: { r: number[][]; t: number[] } | undefined): boolean {
|
|
89
|
+
if (!pose) return false
|
|
90
|
+
const unturned = pose.r.every((row, i) => row.every((v, j) => Math.abs(v - (i === j ? 1 : 0)) < 1e-9))
|
|
91
|
+
return (
|
|
92
|
+
unturned &&
|
|
93
|
+
PALETTE_KINDS.some(kind => {
|
|
94
|
+
const { x, y, z } = paletteRecipe(kind, 'p').position
|
|
95
|
+
return [x, y, z].every((v, i) => Math.abs(v - pose.t[i]!) < 1e-6)
|
|
96
|
+
})
|
|
97
|
+
)
|
|
98
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -265,7 +265,11 @@ export function createFigureProductSession(store: FigureProductStore) {
|
|
|
265
265
|
*/
|
|
266
266
|
detachAsCopy() {
|
|
267
267
|
if (saving) throw refusal('SESSION_SAVING', '저장이 끝난 뒤 편집해 주세요.')
|
|
268
|
-
|
|
268
|
+
/*
|
|
269
|
+
After a conflict, or to release edits that break the released contract as a new figure type (the release gate's
|
|
270
|
+
way out, tutorial 2026-11-11): either way the figure it came from stays as the server has it.
|
|
271
|
+
*/
|
|
272
|
+
if (!conflict && !id) throw refusal('SESSION_NOT_SAVED', '저장된 피규어가 아닙니다.')
|
|
269
273
|
id = undefined
|
|
270
274
|
baseline = undefined
|
|
271
275
|
creationId = crypto.randomUUID()
|