@things-factory/figure-ui 10.1.265 → 10.1.266
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/align.ts +13 -1
- package/client/modeller/clips.ts +90 -1
- package/client/modeller/editor-styles.ts +36 -0
- package/client/modeller/editor.ts +53 -6
- package/client/modeller/figure-clips.ts +82 -34
- package/client/modeller/joint-drag.ts +32 -2
- package/dist-client/modeller/align.d.ts +5 -0
- package/dist-client/modeller/align.js +10 -1
- package/dist-client/modeller/align.js.map +1 -1
- package/dist-client/modeller/clips.d.ts +56 -4
- package/dist-client/modeller/clips.js +89 -0
- package/dist-client/modeller/clips.js.map +1 -1
- package/dist-client/modeller/editor-styles.js +36 -0
- package/dist-client/modeller/editor-styles.js.map +1 -1
- package/dist-client/modeller/editor.d.ts +7 -0
- package/dist-client/modeller/editor.js +58 -5
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/figure-clips.d.ts +13 -1
- package/dist-client/modeller/figure-clips.js +87 -34
- package/dist-client/modeller/figure-clips.js.map +1 -1
- package/dist-client/modeller/joint-drag.d.ts +21 -2
- package/dist-client/modeller/joint-drag.js +20 -1
- package/dist-client/modeller/joint-drag.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/test/align.test.ts +18 -0
- package/test/clips.test.ts +83 -0
- package/test/joint-drag.test.ts +49 -0
- package/test/joint-frames.test.ts +3 -2
- package/translations/en.json +22 -2
- package/translations/es.json +22 -2
- package/translations/ja.json +22 -2
- package/translations/ko.json +22 -2
- package/translations/ms.json +22 -2
- package/translations/zh.json +22 -2
package/client/modeller/align.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { figureHangingChainOf, figureOccupiedBoxesOf, figureWritersOf } from '@hatiolab/figure-model'
|
|
1
|
+
import { figureHangingChainOf, figureMotionsOf, figureOccupiedBoxesOf, figureWritersOf, motionStateIdOf } from '@hatiolab/figure-model'
|
|
2
2
|
import type { FigureAsset, FigureInputChange, FigurePartEdit, FigureWorldBox } from '@hatiolab/figure-model'
|
|
3
3
|
|
|
4
4
|
import { derivedOf, type FigureRevision } from './revision.js'
|
|
@@ -114,6 +114,18 @@ export function mirrorPlanOf(asset: FigureAsset, part: string, axis: 'x' | 'z'):
|
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* The clips whose tracks come with a mirrored copy (figure-model's mirror-part, review 2026-11-09): those that play a
|
|
119
|
+
* control of the part, or of what hangs from it when the chain is taken. Said in the form before the copy is made.
|
|
120
|
+
*/
|
|
121
|
+
export function mirroredClipsOf(asset: FigureAsset, part: string, chain: boolean): string[] {
|
|
122
|
+
const parts = chain ? figureHangingChainOf(asset, part) : [part]
|
|
123
|
+
const controls = new Set(
|
|
124
|
+
(asset.joints ?? []).filter(j => parts.includes(j.body1.part)).flatMap(j => figureMotionsOf(j).filter(m => !m.mimic).map(m => motionStateIdOf(m)))
|
|
125
|
+
)
|
|
126
|
+
return [...new Set((asset.drivers ?? []).filter(d => controls.has(d.state)).map(d => d.clip))]
|
|
127
|
+
}
|
|
128
|
+
|
|
117
129
|
/** The one edit the form comes to. */
|
|
118
130
|
export function mirrorEditOf(part: string, form: MirrorForm): FigurePartEdit {
|
|
119
131
|
return {
|
package/client/modeller/clips.ts
CHANGED
|
@@ -16,7 +16,7 @@ export interface ClipTrack {
|
|
|
16
16
|
min: number
|
|
17
17
|
max: number
|
|
18
18
|
keys: ClipKey[]
|
|
19
|
-
interpolation: 'linear' | 'step'
|
|
19
|
+
interpolation: 'linear' | 'step' | 'smooth'
|
|
20
20
|
loop: 'wrap' | 'hold'
|
|
21
21
|
accumulates: boolean
|
|
22
22
|
}
|
|
@@ -147,6 +147,18 @@ export const copyTargetsOf = (asset: FigureAsset, clip: string): string[] => [
|
|
|
147
147
|
...new Set([...(asset.drivers ?? []).filter(d => d.clip === clip).map(d => d.state), ...undrivenOf(asset, clip)])
|
|
148
148
|
]
|
|
149
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Where a smooth track may be moved round its loop to, in seconds later, when `seconds` is not one of them: figure-model
|
|
152
|
+
* moves a smooth track only so that its new start is one of its keys (`driver-shift-off-key`). Null when it may go there.
|
|
153
|
+
*/
|
|
154
|
+
export function shiftOffKeyOf(track: ClipTrack, duration: number, seconds: number): number[] | null {
|
|
155
|
+
if (track.interpolation !== 'smooth' || !seconds) return null
|
|
156
|
+
const D = duration
|
|
157
|
+
const phase = ((seconds % D) + D) % D
|
|
158
|
+
const fits = [...new Set(track.keys.map(k => round((D - k.seconds) % D)))].sort((a, b) => a - b)
|
|
159
|
+
return fits.some(s => Math.abs(s - phase) < 1e-6) ? null : fits
|
|
160
|
+
}
|
|
161
|
+
|
|
150
162
|
/** Whether a track can be moved round its loop: one that wraps and does not add up (figure-model's rule). */
|
|
151
163
|
export const shiftable = (track: ClipTrack) => track.loop === 'wrap' && !track.accumulates
|
|
152
164
|
|
|
@@ -176,3 +188,80 @@ export const trackEdit = (clip: Clip, track: ClipTrack, patch: Partial<Pick<Clip
|
|
|
176
188
|
loop: patch.loop ?? track.loop,
|
|
177
189
|
interpolation: patch.interpolation ?? track.interpolation
|
|
178
190
|
})
|
|
191
|
+
|
|
192
|
+
/** Whether two control values are one: the figure's numbers carry float noise past a millionth. */
|
|
193
|
+
const same = (a: number, b: number) => Math.abs(a - b) < 1e-6
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* A new track's keys. By default it holds the pose the figure stands in now, from start to end: a track added to a pose
|
|
197
|
+
* made with care does not throw it to the control's ends (review 2026-11-09). `range` is the preset that swings the
|
|
198
|
+
* control over its whole range and back, so the loop closes where it began.
|
|
199
|
+
*/
|
|
200
|
+
export function newTrackKeys(input: { min?: number; max?: number }, current: number | undefined, duration: number, how: 'hold' | 'range' = 'hold'): ClipKey[] {
|
|
201
|
+
const min = input.min ?? 0
|
|
202
|
+
const max = input.max ?? min
|
|
203
|
+
if (how === 'range') return [{ seconds: 0, value: min }, { seconds: round(duration / 2), value: max }, { seconds: duration, value: min }]
|
|
204
|
+
const value = current ?? min
|
|
205
|
+
return [{ seconds: 0, value }, { seconds: duration, value }]
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** What a new clip on a control joins, so that it may play it: nothing, a group already there, or a group made with the clip that plays it now. */
|
|
209
|
+
export type TrackJoin = { kind: 'free' } | { kind: 'group'; group: string; label: string } | { kind: 'new-group'; group: string; label: string; clip: string }
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The edits that make a new clip `clip` with one track on `state` (ADR-0051 §3.2: only clips of one motion group play
|
|
213
|
+
* one control). A control no clip plays is taken as it is. A control a group's clips play puts the new clip in that
|
|
214
|
+
* group. A control one clip in no group plays makes a group of that clip and the new one -- the person adds 「열기」 to a
|
|
215
|
+
* door that has 「닫기」, and the two become one group, one playing at a time, without having to design the group first.
|
|
216
|
+
* All of it one step of the history.
|
|
217
|
+
*/
|
|
218
|
+
export function newClipEdits(asset: FigureAsset, state: string, clip: string, duration: number, keys: ClipKey[]): { edits: unknown[]; join: TrackJoin } {
|
|
219
|
+
const track = { kind: 'set-driver' as const, state, clip, duration, keys }
|
|
220
|
+
const writers = [...new Set((asset.drivers ?? []).filter(d => d.state === state).map(d => d.clip))]
|
|
221
|
+
if (!writers.length) return { edits: [track], join: { kind: 'free' } }
|
|
222
|
+
const group = (asset.motionGroups ?? []).find(g => writers.some(c => g.clips.includes(c)))
|
|
223
|
+
if (group) return { edits: [{ ...track, group: group.id }], join: { kind: 'group', group: group.id, label: group.label } }
|
|
224
|
+
const id = freeGroupIdOf(asset)
|
|
225
|
+
const label = asset.stateInputs?.[state]?.label ?? state
|
|
226
|
+
const other = writers[0]!
|
|
227
|
+
return {
|
|
228
|
+
edits: [{ kind: 'set-motion-group' as const, group: { id, label, clips: [other], defaultClip: other, transitionSeconds: 0.2 } }, { ...track, group: id }],
|
|
229
|
+
join: { kind: 'new-group', group: id, label, clip: other }
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 「이 자세 기억하기」: the pose the figure stands in now, kept in `clip` at `seconds` in one step (review 2026-11-09:
|
|
235
|
+
* a six-axis pose was six tracks keyed one by one). A track whose value there differs gets a key; a control the clip
|
|
236
|
+
* does not play yet and that moved since `base` -- the pose the clip was last shown or kept in -- gets a track that
|
|
237
|
+
* starts from where it was and reaches the pose at that moment (from the start when the moment is 0). A moved control
|
|
238
|
+
* another clip outside this clip's group plays is not taken; it is named in `skipped`.
|
|
239
|
+
*/
|
|
240
|
+
export function rememberedPoseEdits(asset: FigureAsset, clip: string, seconds: number, values: Record<string, number>, base: Record<string, number>): { edits: unknown[]; skipped: string[] } {
|
|
241
|
+
const found = clipsOf(asset).find(c => c.name === clip)
|
|
242
|
+
if (!found) return { edits: [], skipped: [] }
|
|
243
|
+
const at = round(Math.min(Math.max(seconds, 0), found.duration))
|
|
244
|
+
const there = new Map(poseAt(asset, clip, at).map(c => [c.input, c.value]))
|
|
245
|
+
const edits: unknown[] = []
|
|
246
|
+
for (const track of found.tracks) {
|
|
247
|
+
const now = values[track.state]
|
|
248
|
+
if (now === undefined || same(now, there.get(track.state) ?? now)) continue
|
|
249
|
+
edits.push(trackEdit(found, track, { keys: withKey(track.keys, { seconds: at, value: now }) }))
|
|
250
|
+
}
|
|
251
|
+
const free = new Set(undrivenOf(asset, clip))
|
|
252
|
+
const skipped: string[] = []
|
|
253
|
+
const played = new Set(found.tracks.map(t => t.state))
|
|
254
|
+
for (const input of asset.document.model.inputs) {
|
|
255
|
+
if (input.role !== 'state' || played.has(input.id)) continue
|
|
256
|
+
const now = values[input.id]
|
|
257
|
+
const was = base[input.id]
|
|
258
|
+
if (now === undefined || was === undefined || same(now, was)) continue
|
|
259
|
+
if (!free.has(input.id)) {
|
|
260
|
+
skipped.push(input.id)
|
|
261
|
+
continue
|
|
262
|
+
}
|
|
263
|
+
const keys = at === 0 ? [{ seconds: 0, value: now }, { seconds: found.duration, value: now }] : [{ seconds: 0, value: was }, { seconds: at, value: now }]
|
|
264
|
+
edits.push({ kind: 'set-driver' as const, state: input.id, clip, keys })
|
|
265
|
+
}
|
|
266
|
+
return { edits, skipped }
|
|
267
|
+
}
|
|
@@ -133,6 +133,42 @@ export const EditorStyles = css`
|
|
|
133
133
|
min-height: 0;
|
|
134
134
|
}
|
|
135
135
|
/* Which size handles change one diameter (ADR-0140 4.1): a quiet line over the canvas, out of the gizmo's way. */
|
|
136
|
+
/* What a drag does on the joint of the part chosen: one line under the board, its buttons quiet text (review 2026-11-09). */
|
|
137
|
+
p[joint-handle] {
|
|
138
|
+
position: absolute;
|
|
139
|
+
bottom: 12px;
|
|
140
|
+
left: 50%;
|
|
141
|
+
z-index: 5;
|
|
142
|
+
transform: translateX(-50%);
|
|
143
|
+
display: flex;
|
|
144
|
+
flex-wrap: wrap;
|
|
145
|
+
align-items: center;
|
|
146
|
+
justify-content: center;
|
|
147
|
+
gap: 2px 8px;
|
|
148
|
+
/* As wide as its words: at left 50% the line would otherwise wrap at half the board. */
|
|
149
|
+
width: max-content;
|
|
150
|
+
max-width: calc(100% - 24px);
|
|
151
|
+
box-sizing: border-box;
|
|
152
|
+
margin: 0;
|
|
153
|
+
padding: 4px 6px 4px 12px;
|
|
154
|
+
border-radius: 16px;
|
|
155
|
+
background: var(--md-sys-color-surface-container-high);
|
|
156
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
157
|
+
font-size: var(--ui-t-small, 11.5px);
|
|
158
|
+
}
|
|
159
|
+
p[joint-handle] button {
|
|
160
|
+
padding: 2px 6px;
|
|
161
|
+
border: none;
|
|
162
|
+
border-radius: 4px;
|
|
163
|
+
background: none;
|
|
164
|
+
color: var(--md-sys-color-primary);
|
|
165
|
+
font: inherit;
|
|
166
|
+
font-weight: 600;
|
|
167
|
+
cursor: pointer;
|
|
168
|
+
}
|
|
169
|
+
p[joint-handle] button:hover {
|
|
170
|
+
background: var(--md-sys-color-surface-container-highest);
|
|
171
|
+
}
|
|
136
172
|
p[same-measure] {
|
|
137
173
|
position: absolute;
|
|
138
174
|
bottom: 12px;
|
|
@@ -48,7 +48,7 @@ import { groupFields, ownerOf, partLabelOf, repeatOf, type FieldLike, type Field
|
|
|
48
48
|
import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
|
|
49
49
|
import { EditorStyles } from './editor-styles.js'
|
|
50
50
|
import { ScrollbarStyles } from '@operato/styles'
|
|
51
|
-
import { alignChanges, canMirror, mirrorEditOf, mirrorPlanOf, type AlignTool, type MirrorForm } from './align.js'
|
|
51
|
+
import { alignChanges, canMirror, mirrorEditOf, mirroredClipsOf, mirrorPlanOf, type AlignTool, type MirrorForm } from './align.js'
|
|
52
52
|
import type { FigurePaletteKind, FigurePartRow } from './figure-parts.js'
|
|
53
53
|
import { paletteRecipe } from './palette.js'
|
|
54
54
|
import { meshChoicesOf, meshPartOf } from './mesh-import.js'
|
|
@@ -74,7 +74,7 @@ import { sizeInputOf } from './size-response.js'
|
|
|
74
74
|
import { canFollow, followableAxes, endPlaceKeyOf, fieldWordsOf, followOf, sizeFollowersOf, measureNow, partSummaryOf, instanceRangeOf, instanceSizeToDeclare, proportionToFollow, rangeWith, turnsAboutAJoint, valueNow } from './links.js'
|
|
75
75
|
import { jointFanOf } from './joint-fan.js'
|
|
76
76
|
import { derivedOf, type FigureRevision } from './revision.js'
|
|
77
|
-
import { jointDragOf } from './joint-drag.js'
|
|
77
|
+
import { jointDragOf, jointHandleOf } from './joint-drag.js'
|
|
78
78
|
import type { FirstSave } from './first-save.js'
|
|
79
79
|
import { isLive, livePartsOf, liveOverSession, NOTHING_LIVE, scrubLabel, type LiveValues } from './live.js'
|
|
80
80
|
import { startingShapeKindOfBody } from './starting-shape.js'
|
|
@@ -196,6 +196,8 @@ export class FigureEditor extends LitElement {
|
|
|
196
196
|
@state() private middle: 'edit' | 'preview' = 'edit'
|
|
197
197
|
/** The gizmo shown on the selected part, as the tools in the bar and W · E · R choose it; none once the part changes. */
|
|
198
198
|
@state() private gizmoMode?: 'translate' | 'rotate' | 'scale'
|
|
199
|
+
/** 「관절 위치 편집」: while on, a drag on a part that hangs from a joint that moves moves where the joint sits, not its pose. Off on each new selection. */
|
|
200
|
+
@state() private jointPlacing = false
|
|
199
201
|
/** A line saying what a command did besides what was asked; gone at the next command. */
|
|
200
202
|
@state() private notice = ''
|
|
201
203
|
/**
|
|
@@ -330,7 +332,10 @@ export class FigureEditor extends LitElement {
|
|
|
330
332
|
this.panelMemory.leave((_changed.get('selected') as string | undefined) ?? '', this.selected)
|
|
331
333
|
}
|
|
332
334
|
if (view && !view.geometry.some(g => ownerOf(g.id) === this.selected)) this.selected = ''
|
|
333
|
-
if (_changed.has('selected'))
|
|
335
|
+
if (_changed.has('selected')) {
|
|
336
|
+
this.gizmoMode = undefined
|
|
337
|
+
this.jointPlacing = false
|
|
338
|
+
}
|
|
334
339
|
/* The window is about the part chosen on the properties tab, being edited: it does not follow anywhere else. */
|
|
335
340
|
if (this.fastening && (_changed.has('selected') || !this.selected || this.tab !== 'properties' || this.middle !== 'edit')) this.closeFastening()
|
|
336
341
|
/*
|
|
@@ -358,6 +363,29 @@ export class FigureEditor extends LitElement {
|
|
|
358
363
|
if (closed && this.selected === editing) this.selected = next
|
|
359
364
|
})
|
|
360
365
|
}
|
|
366
|
+
/**
|
|
367
|
+
* On a part that hangs from a joint that moves, what a drag does, before it is dragged (review 2026-11-09): the handle
|
|
368
|
+
* that poses it, or why none does and where it is moved from -- and 「관절 위치 편집」 to move where the joint sits.
|
|
369
|
+
*/
|
|
370
|
+
private renderJointHandle(unavailable: boolean) {
|
|
371
|
+
const asset = this.asset()
|
|
372
|
+
if (unavailable || this.gizmoMode === 'scale' || !this.selected || !asset) return nothing
|
|
373
|
+
const handle = jointHandleOf(asset, this.selected)
|
|
374
|
+
if (!handle) return nothing
|
|
375
|
+
if (this.jointPlacing)
|
|
376
|
+
return html`<p joint-handle role="status">${i18next.t('figure.text.joint-placing', { joint: handle.joint })}
|
|
377
|
+
<button act @click=${() => (this.jointPlacing = false)}>${i18next.t('figure.button.joint-place-off')}</button></p>`
|
|
378
|
+
const said =
|
|
379
|
+
handle.kind === 'mimic'
|
|
380
|
+
? i18next.t('figure.text.gizmo-joint-mimic', { joint: handle.joint, leader: handle.leader })
|
|
381
|
+
: handle.kind === 'several-axes'
|
|
382
|
+
? i18next.t('figure.text.gizmo-joint-several-axes', { joint: handle.joint })
|
|
383
|
+
: i18next.t(`figure.text.joint-handle-${handle.kind}`, { joint: handle.joint })
|
|
384
|
+
const leaderPart = handle.kind === 'mimic' ? handle.leaderPart : undefined
|
|
385
|
+
return html`<p joint-handle role="status">${said}
|
|
386
|
+
${leaderPart ? html`<button act @click=${() => this.choose(leaderPart)}>${i18next.t('figure.button.select-leader', { leader: (handle as { leader: string }).leader })}</button>` : nothing}
|
|
387
|
+
<button act @click=${() => (this.jointPlacing = true)}>${i18next.t('figure.button.joint-place-on')}</button></p>`
|
|
388
|
+
}
|
|
361
389
|
/** While the size tool is on a round part: which of its handles change the one diameter (ADR-0140 4.1). */
|
|
362
390
|
private renderSameMeasure(unavailable: boolean) {
|
|
363
391
|
if (unavailable || this.gizmoMode !== 'scale' || !this.selected) return nothing
|
|
@@ -693,11 +721,26 @@ export class FigureEditor extends LitElement {
|
|
|
693
721
|
const shown = this.shown()!
|
|
694
722
|
const asset = shown.asset
|
|
695
723
|
const drawn = view.geometry.find(g => g.id === id) as unknown as FigureRenderPart | undefined
|
|
696
|
-
const drag = drawn ? jointDragOf(asset, ownerOf(id), drawn, result, shown.inputs) : null
|
|
724
|
+
const drag = drawn ? jointDragOf(asset, ownerOf(id), drawn, result, shown.inputs, { place: this.jointPlacing }) : null
|
|
697
725
|
if (drag) {
|
|
698
726
|
if (drag.kind === 'value') this.run(() => this.session!.apply([{ kind: 'set-state', input: drag.input, unit: drag.unit, value: drag.value }]))
|
|
699
727
|
else if (drag.kind === 'joint') this.run(() => this.session!.command({ kind: 'set-joint', joint: drag.joint }))
|
|
700
|
-
else {
|
|
728
|
+
else if (drag.reason === 'place-off') {
|
|
729
|
+
/* A pose drag that would have moved the joint: nothing is written, and the way to move it is offered. */
|
|
730
|
+
this.refusals.point(i18next.t('figure.text.gizmo-joint-place-off', { joint: drag.joint }), {
|
|
731
|
+
label: i18next.t('figure.button.joint-place-on'),
|
|
732
|
+
run: () => (this.jointPlacing = true)
|
|
733
|
+
})
|
|
734
|
+
this.requestUpdate()
|
|
735
|
+
} else if (drag.reason === 'mimic') {
|
|
736
|
+
/* A joint that follows another: the one it follows is a click away. */
|
|
737
|
+
const leader = jointHandleOf(asset, ownerOf(id))
|
|
738
|
+
const leaderPart = leader?.kind === 'mimic' ? leader.leaderPart : undefined
|
|
739
|
+
const message = i18next.t('figure.text.gizmo-joint-mimic', { joint: drag.joint, leader: drag.leader ?? '' })
|
|
740
|
+
if (leaderPart) this.refusals.point(message, { label: i18next.t('figure.button.select-leader', { leader: drag.leader ?? '' }), run: () => this.choose(leaderPart) })
|
|
741
|
+
else this.refusals.say(message)
|
|
742
|
+
this.requestUpdate()
|
|
743
|
+
} else {
|
|
701
744
|
this.error = i18next.t(`figure.text.gizmo-joint-${drag.reason}`, { joint: drag.joint, leader: drag.leader ?? '', axis: drag.axis ?? '' })
|
|
702
745
|
this.requestUpdate()
|
|
703
746
|
}
|
|
@@ -809,6 +852,9 @@ export class FigureEditor extends LitElement {
|
|
|
809
852
|
${i18next.t('figure.label.mirror-keep-motion')}
|
|
810
853
|
</label>`
|
|
811
854
|
: nothing}
|
|
855
|
+
${(clips => (clips.length ? html`<p hint mirror-clips>${i18next.t('figure.text.mirror-copies-clips', { clips: clips.join(', ') })}</p>` : nothing))(
|
|
856
|
+
mirroredClipsOf(this.shown()!.asset, m.part, m.form.chain)
|
|
857
|
+
)}
|
|
812
858
|
<div pair>
|
|
813
859
|
<button act ?disabled=${unavailable} @click=${() => this.mirrorWith(m.part, m.form)}>${i18next.t('figure.button.mirror-' + m.form.axis)}</button>
|
|
814
860
|
<button act @click=${() => (this.mirroring = undefined)}>${i18next.t('figure.button.cancel')}</button>
|
|
@@ -1112,6 +1158,7 @@ export class FigureEditor extends LitElement {
|
|
|
1112
1158
|
@states=${(e: CustomEvent) => this.run(() => this.session!.apply(e.detail.changes))}
|
|
1113
1159
|
@states-live=${(e: CustomEvent) => this.hold(e.detail.values)}
|
|
1114
1160
|
@part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
|
|
1161
|
+
@part-edits=${(e: CustomEvent) => this.run(() => this.session!.applyRecipe(e.detail.edits))}
|
|
1115
1162
|
></figure-clips>`
|
|
1116
1163
|
}
|
|
1117
1164
|
/** One command, one step of the history. A refusal a person can meet here is said in their words. */
|
|
@@ -2061,7 +2108,7 @@ export class FigureEditor extends LitElement {
|
|
|
2061
2108
|
@render-error=${(e: CustomEvent<Error>) => {
|
|
2062
2109
|
this.error = e.detail.message
|
|
2063
2110
|
}}
|
|
2064
|
-
></figure-canvas>${this.renderSameMeasure(unavailable)}</div>
|
|
2111
|
+
></figure-canvas>${this.renderSameMeasure(unavailable)}${this.renderJointHandle(unavailable)}</div>
|
|
2065
2112
|
<aside slot="end">
|
|
2066
2113
|
<div tabs>
|
|
2067
2114
|
${this.renderTab('properties', 'tune', i18next.t('figure.label.properties'))}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import '@material/web/icon/icon.js'
|
|
2
2
|
|
|
3
3
|
import { css, html, LitElement, nothing } from 'lit'
|
|
4
|
+
import type { PropertyValues } from 'lit'
|
|
4
5
|
import { customElement, property, state } from 'lit/decorators.js'
|
|
5
6
|
|
|
6
7
|
import { i18next, localize } from '@operato/i18n'
|
|
7
8
|
import { unitWord } from './unit-words.js'
|
|
8
9
|
import type { FigureAsset } from '@hatiolab/figure-model'
|
|
9
10
|
|
|
10
|
-
import { clipGroupsOf, withKeyChanged, clipsOf, copiedTrackEdit, copyTargetsOf, freeClipNameOf, freeGroupIdOf, groupEdit, poseAt, shiftable, trackEdit, undrivenOf, withKey, withoutKey, type Clip, type ClipGroup, type ClipTrack } from './clips.js'
|
|
11
|
+
import { clipGroupsOf, withKeyChanged, clipsOf, copiedTrackEdit, copyTargetsOf, freeClipNameOf, freeGroupIdOf, groupEdit, newClipEdits, newTrackKeys, poseAt, rememberedPoseEdits, shiftable, shiftOffKeyOf, trackEdit, undrivenOf, withKey, withoutKey, type Clip, type ClipGroup, type ClipKey, type ClipTrack } from './clips.js'
|
|
11
12
|
import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
|
|
12
13
|
import { HelpToggles } from './help.js'
|
|
13
14
|
|
|
@@ -23,7 +24,11 @@ const typed = (value: number) => String(Math.round(value * 1000) / 1000)
|
|
|
23
24
|
* then kept as a key. Every change is one part edit.
|
|
24
25
|
*
|
|
25
26
|
* Clips of one motion group play one at a time on the same controls -- idle, walk, run (ADR-0051 §3.2): only clips of
|
|
26
|
-
* one group may play the same control
|
|
27
|
+
* one group may play the same control. A new clip on a control another clip plays is put in a group as it is made --
|
|
28
|
+
* that clip's, or one made of the two -- and the panel says so before the button is pressed (review 2026-11-09).
|
|
29
|
+
*
|
|
30
|
+
* 「이 자세 기억하기」 keeps the pose the figure stands in now at the scrubbed moment, every control that moved, in one
|
|
31
|
+
* step. What moved is read against the pose the clip was last shown or kept in (`base`).
|
|
27
32
|
*/
|
|
28
33
|
@customElement('figure-clips')
|
|
29
34
|
export class FigureClips extends localize(i18next)(LitElement) {
|
|
@@ -66,6 +71,11 @@ export class FigureClips extends localize(i18next)(LitElement) {
|
|
|
66
71
|
min-width: 0;
|
|
67
72
|
overflow-wrap: anywhere;
|
|
68
73
|
}
|
|
74
|
+
/* How it moves between keys: its own row under the head, as wide as the track, its words not cut. */
|
|
75
|
+
div[track-head] select[interpolation] {
|
|
76
|
+
grid-row: 3;
|
|
77
|
+
grid-column: 1 / -1;
|
|
78
|
+
}
|
|
69
79
|
div[track-head] button[act] {
|
|
70
80
|
width: 30px;
|
|
71
81
|
min-height: var(--figure-control-height);
|
|
@@ -148,10 +158,20 @@ export class FigureClips extends localize(i18next)(LitElement) {
|
|
|
148
158
|
|
|
149
159
|
/** Where each clip is scrubbed to, in seconds. */
|
|
150
160
|
@state() private at: Record<string, number> = {}
|
|
151
|
-
@state() private adding = { state: '', clip: '', name: '', duration: 2
|
|
161
|
+
@state() private adding = { state: '', clip: '', name: '', duration: 2 }
|
|
152
162
|
@state() private grouping = { clip: '', label: '' }
|
|
153
163
|
/** The track whose copy is being set up: onto which control, how many seconds later. */
|
|
154
164
|
@state() private copying?: { clip: string; state: string; target: string; seconds: number }
|
|
165
|
+
/** Per clip, the pose it was last shown or kept in: a control that differs from it now has been moved by the person. */
|
|
166
|
+
@state() private base: Record<string, Record<string, number>> = {}
|
|
167
|
+
|
|
168
|
+
protected willUpdate(changed: PropertyValues) {
|
|
169
|
+
if (!changed.has('asset') || !this.asset) return
|
|
170
|
+
/* A clip seen for the first time takes the pose the figure stands in now as where its moves are read from. */
|
|
171
|
+
const names = clipsOf(this.asset).map(c => c.name)
|
|
172
|
+
const fresh = names.filter(n => !this.base[n])
|
|
173
|
+
if (fresh.length) this.base = { ...Object.fromEntries(Object.entries(this.base).filter(([n]) => names.includes(n))), ...Object.fromEntries(fresh.map(n => [n, { ...this.values }])) }
|
|
174
|
+
}
|
|
155
175
|
|
|
156
176
|
render() {
|
|
157
177
|
const asset = this.asset
|
|
@@ -211,10 +231,25 @@ export class FigureClips extends localize(i18next)(LitElement) {
|
|
|
211
231
|
/>s
|
|
212
232
|
</label>
|
|
213
233
|
</div>
|
|
234
|
+
${this.renderRemember(clip, at)}
|
|
214
235
|
${clip.tracks.map(track => this.renderTrack(clip, track, at))}
|
|
215
236
|
</section>`
|
|
216
237
|
}
|
|
217
238
|
|
|
239
|
+
/** 「이 자세 기억하기」: every control moved since the clip's pose was last shown or kept, as keys at this moment, one step. */
|
|
240
|
+
private renderRemember(clip: Clip, at: number) {
|
|
241
|
+
const { edits, skipped } = rememberedPoseEdits(this.asset!, clip.name, at, this.values, this.base[clip.name] ?? this.values)
|
|
242
|
+
const label = (id: string) => this.asset!.stateInputs?.[id]?.label ?? id
|
|
243
|
+
return html`<button act remember-pose ?disabled=${this.locked || !edits.length} title=${edits.length ? '' : i18next.t('figure.text.remember-pose-nothing')}
|
|
244
|
+
@click=${() => {
|
|
245
|
+
this.edits(edits)
|
|
246
|
+
this.base = { ...this.base, [clip.name]: { ...this.values } }
|
|
247
|
+
}}>
|
|
248
|
+
<md-icon>photo_camera</md-icon>${i18next.t('figure.button.remember-pose', { seconds: at.toFixed(2) })}
|
|
249
|
+
</button>
|
|
250
|
+
${skipped.length ? html`<p hint>${i18next.t('figure.text.remember-pose-skipped', { controls: skipped.map(label).join(', ') })}</p>` : nothing}`
|
|
251
|
+
}
|
|
252
|
+
|
|
218
253
|
private renderTrack(clip: Clip, track: ClipTrack, at: number) {
|
|
219
254
|
const current = this.values[track.state]
|
|
220
255
|
return html`<div track>
|
|
@@ -228,6 +263,15 @@ export class FigureClips extends localize(i18next)(LitElement) {
|
|
|
228
263
|
<option value="wrap" ?selected=${track.loop === 'wrap'}>${i18next.t('figure.label.clip-loop-wrap')}</option>
|
|
229
264
|
<option value="hold" ?selected=${track.loop === 'hold'}>${i18next.t('figure.label.clip-loop-hold')}</option>
|
|
230
265
|
</select>
|
|
266
|
+
<select
|
|
267
|
+
interpolation
|
|
268
|
+
aria-label=${i18next.t('figure.label.clip-interpolation')}
|
|
269
|
+
title=${i18next.t('figure.label.clip-interpolation')}
|
|
270
|
+
?disabled=${this.locked}
|
|
271
|
+
@change=${(e: Event) => this.edit(trackEdit(clip, track, { interpolation: (e.target as HTMLSelectElement).value as ClipTrack['interpolation'] }))}
|
|
272
|
+
>
|
|
273
|
+
${(['linear', 'smooth', 'step'] as const).map(i => html`<option value=${i} ?selected=${track.interpolation === i}>${i18next.t(`figure.label.clip-interpolation-${i}`)}</option>`)}
|
|
274
|
+
</select>
|
|
231
275
|
<button act copy-track ?disabled=${this.locked} title=${i18next.t('figure.button.copy-track')} aria-label=${i18next.t('figure.button.copy-track')}
|
|
232
276
|
@click=${() => (this.copying = this.copying?.clip === clip.name && this.copying.state === track.state ? undefined : { clip: clip.name, state: track.state, target: track.state, seconds: 0 })}>
|
|
233
277
|
<md-icon>content_copy</md-icon>
|
|
@@ -292,6 +336,8 @@ export class FigureClips extends localize(i18next)(LitElement) {
|
|
|
292
336
|
const moves = shiftable(track)
|
|
293
337
|
const seconds = moves && Number.isFinite(c.seconds) ? c.seconds : 0
|
|
294
338
|
const same = c.target === track.state && !seconds
|
|
339
|
+
/* A smooth track goes round only onto one of its keys: said, with the moments it can go to, and not applied. */
|
|
340
|
+
const fits = moves ? shiftOffKeyOf(track, clip.duration, seconds) : null
|
|
295
341
|
return html`<div copy-form>
|
|
296
342
|
<div form>
|
|
297
343
|
<span>${i18next.t('figure.label.copy-track-onto')}</span>
|
|
@@ -305,8 +351,9 @@ export class FigureClips extends localize(i18next)(LitElement) {
|
|
|
305
351
|
<small>/ ${typed(clip.duration)}s</small>
|
|
306
352
|
</label>
|
|
307
353
|
</div>
|
|
354
|
+
${fits ? html`<p hint off-key>${i18next.t('figure.text.copy-track-off-key', { seconds: fits.map(typed).join(', ') })}</p>` : nothing}
|
|
308
355
|
<div pair>
|
|
309
|
-
<button act ?disabled=${this.locked || same}
|
|
356
|
+
<button act ?disabled=${this.locked || same || !!fits}
|
|
310
357
|
@click=${() => {
|
|
311
358
|
this.edit(copiedTrackEdit(asset, clip, track, c.target, seconds))
|
|
312
359
|
this.copying = undefined
|
|
@@ -319,10 +366,12 @@ export class FigureClips extends localize(i18next)(LitElement) {
|
|
|
319
366
|
private renderAdd(asset: FigureAsset, clips: Clip[]) {
|
|
320
367
|
const a = this.adding
|
|
321
368
|
const joining = clips.find(c => c.name === a.clip)
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
369
|
+
/*
|
|
370
|
+
The controls a track can take. Joining a clip: those it may play. A new clip: any control -- one another clip
|
|
371
|
+
plays puts the new clip in a group with it, said below before the button is pressed (review 2026-11-09: the
|
|
372
|
+
controls used to vanish from the list, and the group had to be designed first).
|
|
373
|
+
*/
|
|
374
|
+
const free = joining ? undrivenOf(asset, joining.name) : asset.document.model.inputs.filter(i => i.role === 'state').map(i => i.id)
|
|
326
375
|
const stateId = free.includes(a.state) ? a.state : free[0]
|
|
327
376
|
const name = joining ? joining.name : a.name.trim()
|
|
328
377
|
const input = asset.document.model.inputs.find(i => i.id === stateId)
|
|
@@ -330,6 +379,13 @@ export class FigureClips extends localize(i18next)(LitElement) {
|
|
|
330
379
|
/* A new clip takes a name no clip has: the same name would rewrite that clip's track, not add one. */
|
|
331
380
|
const taken = !joining && clips.some(c => c.name === name)
|
|
332
381
|
const ready = !!name && !taken && duration > 0 && !!input
|
|
382
|
+
const join = !joining && input && name ? newClipEdits(asset, stateId!, name, duration, []).join : undefined
|
|
383
|
+
const add = (keys: ClipKey[]) => {
|
|
384
|
+
if (joining) this.edit({ kind: 'set-driver', state: stateId, clip: name, keys })
|
|
385
|
+
else this.edits(newClipEdits(asset, stateId!, name, duration, keys).edits)
|
|
386
|
+
/* The name was this clip's; the next new clip is named again. */
|
|
387
|
+
this.adding = { ...a, name: '', state: '' }
|
|
388
|
+
}
|
|
333
389
|
return html`<section><h3>${i18next.t('figure.label.add-track')}</h3>
|
|
334
390
|
<div form>
|
|
335
391
|
<span>${i18next.t('figure.label.controls')}</span>
|
|
@@ -341,13 +397,6 @@ export class FigureClips extends localize(i18next)(LitElement) {
|
|
|
341
397
|
<option value="" ?selected=${!joining}>${i18next.t('figure.label.new-clip')}</option>
|
|
342
398
|
${clips.map(c => html`<option value=${c.name} ?selected=${c === joining}>${c.name}</option>`)}
|
|
343
399
|
</select>
|
|
344
|
-
${joining || !groups.length
|
|
345
|
-
? nothing
|
|
346
|
-
: html`<span>${i18next.t('figure.label.motion-group')}</span>
|
|
347
|
-
<select @change=${(e: Event) => (this.adding = { ...a, group: (e.target as HTMLSelectElement).value })}>
|
|
348
|
-
<option value="" ?selected=${!group}>${i18next.t('figure.label.no-motion-group')}</option>
|
|
349
|
-
${groups.map(g => html`<option value=${g.id} ?selected=${g === group}>${g.label}</option>`)}
|
|
350
|
-
</select>`}
|
|
351
400
|
${joining
|
|
352
401
|
? nothing
|
|
353
402
|
: html`<span>${i18next.t('figure.label.clip-name')}</span>
|
|
@@ -355,26 +404,17 @@ export class FigureClips extends localize(i18next)(LitElement) {
|
|
|
355
404
|
<span>${i18next.t('figure.label.clip-duration')}</span>
|
|
356
405
|
<input type="number" min="0.1" step="any" .value=${String(a.duration)} @change=${(e: Event) => (this.adding = { ...a, duration: (e.target as HTMLInputElement).valueAsNumber })} />`}
|
|
357
406
|
</div>
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
state: stateId,
|
|
365
|
-
clip: name,
|
|
366
|
-
...(joining ? {} : { duration }),
|
|
367
|
-
...(group ? { group: group.id } : {}),
|
|
368
|
-
keys: [
|
|
369
|
-
{ seconds: 0, value: input!.min },
|
|
370
|
-
{ seconds: duration, value: input!.max }
|
|
371
|
-
]
|
|
372
|
-
})
|
|
373
|
-
/* The name was this clip's; the next new clip is named again. */
|
|
374
|
-
this.adding = { ...a, name: '', state: '' }
|
|
375
|
-
}}
|
|
376
|
-
>
|
|
407
|
+
${join?.kind === 'new-group'
|
|
408
|
+
? html`<p hint track-join>${i18next.t('figure.text.track-makes-group', { clip: join.clip, name, group: join.label })}</p>`
|
|
409
|
+
: join?.kind === 'group'
|
|
410
|
+
? html`<p hint track-join>${i18next.t('figure.text.track-joins-group', { name, group: join.label })}</p>`
|
|
411
|
+
: nothing}
|
|
412
|
+
<button act ?disabled=${this.locked || !ready} @click=${() => add(newTrackKeys(input!, this.values[stateId!], duration))}>
|
|
377
413
|
<md-icon>add</md-icon>${i18next.t('figure.button.add-track')}
|
|
414
|
+
</button>
|
|
415
|
+
<button act range-track ?disabled=${this.locked || !ready} title=${i18next.t('figure.text.add-track-range-explained')}
|
|
416
|
+
@click=${() => add(newTrackKeys(input!, this.values[stateId!], duration, 'range'))}>
|
|
417
|
+
<md-icon>swap_horiz</md-icon>${i18next.t('figure.button.add-track-range')}
|
|
378
418
|
</button></section>`
|
|
379
419
|
}
|
|
380
420
|
|
|
@@ -489,6 +529,8 @@ export class FigureClips extends localize(i18next)(LitElement) {
|
|
|
489
529
|
return
|
|
490
530
|
}
|
|
491
531
|
this.dispatchEvent(new CustomEvent('states', { detail: { changes }, bubbles: true, composed: true }))
|
|
532
|
+
/* The clip's pose shown: what moves from here is the person's. */
|
|
533
|
+
this.base = { ...this.base, [clip.name]: { ...this.values, ...Object.fromEntries(changes.map(c => [c.input, c.value])) } }
|
|
492
534
|
}
|
|
493
535
|
|
|
494
536
|
/** A key's moment or value typed: one edit of the track; a moment the track cannot take puts the field back. */
|
|
@@ -506,4 +548,10 @@ export class FigureClips extends localize(i18next)(LitElement) {
|
|
|
506
548
|
private edit(edit: unknown) {
|
|
507
549
|
this.dispatchEvent(new CustomEvent('part-edit', { detail: { edit }, bubbles: true, composed: true }))
|
|
508
550
|
}
|
|
551
|
+
|
|
552
|
+
/** Several edits as one step of the history. */
|
|
553
|
+
private edits(edits: unknown[]) {
|
|
554
|
+
if (edits.length === 1) return this.edit(edits[0])
|
|
555
|
+
this.dispatchEvent(new CustomEvent('part-edits', { detail: { edits }, bubbles: true, composed: true }))
|
|
556
|
+
}
|
|
509
557
|
}
|
|
@@ -17,12 +17,16 @@ import type { FigureRenderPart } from './renderer.js'
|
|
|
17
17
|
*
|
|
18
18
|
* What would break a relation is refused and said: an origin that follows another part's size, a joint that mimics
|
|
19
19
|
* another, a turn on a joint that does not turn.
|
|
20
|
+
*
|
|
21
|
+
* **Posing and placing are two things** (review 2026-11-09): on a joint that moves, a drag changes the pose -- its value.
|
|
22
|
+
* Where the joint sits is moved only while 「관절 위치 편집」 is on (`place`); off, such a drag is refused with the way to
|
|
23
|
+
* turn it on, so a pose being made never moves the joint unseen. A fixed joint has no pose: a drag on it places it.
|
|
20
24
|
*/
|
|
21
25
|
|
|
22
26
|
export type JointDrag =
|
|
23
27
|
| { kind: 'value'; input: string; unit: 'deg' | 'mm' | 'ratio'; value: number }
|
|
24
28
|
| { kind: 'joint'; joint: FigureJoint }
|
|
25
|
-
| { kind: 'refused'; reason: 'mimic' | 'fixed-turn' | 'origin-follows' | 'turned-slide' | 'off-axis' | 'several-axes'; joint: string; leader?: string; axis?: string }
|
|
29
|
+
| { kind: 'refused'; reason: 'mimic' | 'fixed-turn' | 'origin-follows' | 'turned-slide' | 'off-axis' | 'several-axes' | 'place-off'; joint: string; leader?: string; axis?: string }
|
|
26
30
|
|
|
27
31
|
type Axis = 'x' | 'y' | 'z'
|
|
28
32
|
const AXES: Axis[] = ['x', 'y', 'z']
|
|
@@ -50,7 +54,7 @@ function jointFrameRotation(j: FigureJoint, a: FigureNamedMotion | undefined, pa
|
|
|
50
54
|
return now.multiply(own.invert()).multiply(turn.invert())
|
|
51
55
|
}
|
|
52
56
|
|
|
53
|
-
export function jointDragOf(asset: FigureAsset, id: string, part: FigureRenderPart, result: GizmoResult, values: Record<string, number
|
|
57
|
+
export function jointDragOf(asset: FigureAsset, id: string, part: FigureRenderPart, result: GizmoResult, values: Record<string, number>, options: { place?: boolean } = {}): JointDrag | null {
|
|
54
58
|
const j = asset.joints?.find(joint => joint.body1.part === id)
|
|
55
59
|
/* A size is the part's own, not the joint's: a scale drag goes to the part's measures (the editor drops the shift). */
|
|
56
60
|
if (!j || result.scale || (!result.rotation && !result.position)) return null
|
|
@@ -102,6 +106,9 @@ export function jointDragOf(asset: FigureAsset, id: string, part: FigureRenderPa
|
|
|
102
106
|
}
|
|
103
107
|
}
|
|
104
108
|
|
|
109
|
+
/* A joint that moves is posed by a drag; its place is moved only while placing is on. */
|
|
110
|
+
if (a && !options.place) return { kind: 'refused', reason: 'place-off', joint: j.id }
|
|
111
|
+
|
|
105
112
|
/* Moving the joint's origin: the change in the frame it is placed in, before `origin.parentRotate` turns the joint frame. */
|
|
106
113
|
const local = moved.clone().applyMatrix4(frame.clone().multiply(rotateMatrix(j).invert()).invert())
|
|
107
114
|
const origin: NonNullable<FigureJoint['origin']> = { ...(j.origin ?? {}) }
|
|
@@ -136,3 +143,26 @@ export function jointDragOf(asset: FigureAsset, id: string, part: FigureRenderPa
|
|
|
136
143
|
}
|
|
137
144
|
return { kind: 'joint', joint: { ...j, origin } }
|
|
138
145
|
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* What a drag on the part does, said before it is dragged (review 2026-11-09: 「왜 안 움직이지?」): which handle changes
|
|
149
|
+
* its pose, or why none does and where it is moved from instead. Null for a part on no joint, or on a fixed one.
|
|
150
|
+
*/
|
|
151
|
+
export type JointHandle =
|
|
152
|
+
| { kind: 'turn' | 'slide'; joint: string }
|
|
153
|
+
| { kind: 'mimic'; joint: string; leader: string; leaderPart?: string }
|
|
154
|
+
| { kind: 'several-axes'; joint: string }
|
|
155
|
+
|
|
156
|
+
export function jointHandleOf(asset: FigureAsset, part: string): JointHandle | null {
|
|
157
|
+
const j = asset.joints?.find(joint => joint.body1.part === part)
|
|
158
|
+
if (!j) return null
|
|
159
|
+
const axes = figureMotionsOf(j)
|
|
160
|
+
if (!axes.length) return null
|
|
161
|
+
if (axes.length > 1) return { kind: 'several-axes', joint: j.id }
|
|
162
|
+
const a = axes[0]!
|
|
163
|
+
if (a.mimic) {
|
|
164
|
+
const leader = figureMimicTermsOf(a.mimic)!.sum[0]!.joint
|
|
165
|
+
return { kind: 'mimic', joint: j.id, leader, leaderPart: asset.joints?.find(o => o.id === leader)?.body1.part }
|
|
166
|
+
}
|
|
167
|
+
return { kind: a.type === 'revolute' ? 'turn' : 'slide', joint: j.id }
|
|
168
|
+
}
|
|
@@ -48,6 +48,11 @@ export declare function mirrorPlanOf(asset: FigureAsset, part: string, axis: 'x'
|
|
|
48
48
|
asks: boolean;
|
|
49
49
|
form: MirrorForm;
|
|
50
50
|
};
|
|
51
|
+
/**
|
|
52
|
+
* The clips whose tracks come with a mirrored copy (figure-model's mirror-part, review 2026-11-09): those that play a
|
|
53
|
+
* control of the part, or of what hangs from it when the chain is taken. Said in the form before the copy is made.
|
|
54
|
+
*/
|
|
55
|
+
export declare function mirroredClipsOf(asset: FigureAsset, part: string, chain: boolean): string[];
|
|
51
56
|
/** The one edit the form comes to. */
|
|
52
57
|
export declare function mirrorEditOf(part: string, form: MirrorForm): FigurePartEdit;
|
|
53
58
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { figureHangingChainOf, figureOccupiedBoxesOf, figureWritersOf } from '@hatiolab/figure-model';
|
|
1
|
+
import { figureHangingChainOf, figureMotionsOf, figureOccupiedBoxesOf, figureWritersOf, motionStateIdOf } from '@hatiolab/figure-model';
|
|
2
2
|
import { derivedOf } from './revision.js';
|
|
3
3
|
const AXES = ['x', 'y', 'z'];
|
|
4
4
|
/**
|
|
@@ -79,6 +79,15 @@ export function mirrorPlanOf(asset, part, axis) {
|
|
|
79
79
|
form: { axis, chain: true, from: named ? `${head}-` : '', to: named ? `${swapped}-` : '', keepMotion: false }
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* The clips whose tracks come with a mirrored copy (figure-model's mirror-part, review 2026-11-09): those that play a
|
|
84
|
+
* control of the part, or of what hangs from it when the chain is taken. Said in the form before the copy is made.
|
|
85
|
+
*/
|
|
86
|
+
export function mirroredClipsOf(asset, part, chain) {
|
|
87
|
+
const parts = chain ? figureHangingChainOf(asset, part) : [part];
|
|
88
|
+
const controls = new Set((asset.joints ?? []).filter(j => parts.includes(j.body1.part)).flatMap(j => figureMotionsOf(j).filter(m => !m.mimic).map(m => motionStateIdOf(m))));
|
|
89
|
+
return [...new Set((asset.drivers ?? []).filter(d => controls.has(d.state)).map(d => d.clip))];
|
|
90
|
+
}
|
|
82
91
|
/** The one edit the form comes to. */
|
|
83
92
|
export function mirrorEditOf(part, form) {
|
|
84
93
|
return {
|