@things-factory/figure-ui 10.1.271 → 10.1.273
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/data-interface.ts +30 -18
- package/client/modeller/editor-styles.ts +17 -6
- package/client/modeller/editor.ts +2 -0
- package/client/modeller/fasten-form.ts +81 -6
- package/client/modeller/fasten-geometry.ts +5 -3
- package/client/modeller/figure-cavity-drawing.ts +1 -3
- package/client/modeller/figure-clips.ts +101 -8
- package/client/modeller/figure-data-panel.ts +384 -200
- package/client/modeller/figure-fasten-dialog.ts +41 -30
- package/client/modeller/figure-fasten-drawing.ts +24 -14
- package/client/modeller/figure-first-save.ts +4 -3
- package/client/modeller/figure-links.ts +109 -37
- package/client/modeller/figure-parts.ts +7 -11
- package/client/modeller/figure-preview.ts +299 -79
- 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 +17 -3
- 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 +11 -8
- package/dist-client/modeller/data-interface.js +27 -17
- package/dist-client/modeller/data-interface.js.map +1 -1
- package/dist-client/modeller/editor-styles.js +17 -6
- package/dist-client/modeller/editor-styles.js.map +1 -1
- package/dist-client/modeller/editor.js +2 -0
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/fasten-form.d.ts +13 -0
- package/dist-client/modeller/fasten-form.js +72 -7
- package/dist-client/modeller/fasten-form.js.map +1 -1
- package/dist-client/modeller/fasten-geometry.d.ts +3 -2
- package/dist-client/modeller/fasten-geometry.js +5 -3
- 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-data-panel.d.ts +10 -19
- package/dist-client/modeller/figure-data-panel.js +382 -190
- package/dist-client/modeller/figure-data-panel.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 +24 -14
- 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 +120 -51
- package/dist-client/modeller/figure-links.js.map +1 -1
- package/dist-client/modeller/figure-parts.js +7 -11
- package/dist-client/modeller/figure-parts.js.map +1 -1
- package/dist-client/modeller/figure-preview.d.ts +13 -0
- package/dist-client/modeller/figure-preview.js +296 -80
- 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 +8 -7
- package/test/browser/editing-page.js +1 -1
- package/test/browser/editing.check.mjs +6 -5
- package/test/browser/links.check.mjs +30 -0
- package/test/browser/modeller.check.mjs +2 -2
- package/test/browser/parts-page.js +4 -0
- package/test/browser/parts.check.mjs +16 -10
- package/test/browser/screen-page.js +12 -1
- package/test/browser/screen.check.mjs +142 -11
- package/test/browser/section.check.mjs +21 -0
- package/test/browser/serve-response.mjs +5 -2
- package/test/browser/side-panel-sweep.check.mjs +171 -10
- package/test/clips.test.ts +49 -4
- package/test/data-motion.test.ts +39 -8
- package/test/fasten-form.test.ts +69 -0
- package/test/fasten-geometry.test.ts +13 -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 +31 -11
- package/translations/es.json +30 -10
- package/translations/ja.json +30 -10
- package/translations/ko.json +32 -12
- package/translations/ms.json +30 -10
- package/translations/zh.json +30 -10
- 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
|
@@ -5,7 +5,7 @@ import { create, Model, niceGridStep } from '@hatiolab/things-scene'
|
|
|
5
5
|
import type { GizmoReach, GizmoResult } from './gizmo.js'
|
|
6
6
|
import type { FigureRenderPart } from './renderer.js'
|
|
7
7
|
import { DEFAULT_VIEW, sceneLook, WORKBENCH, type ViewSettings } from '@things-factory/figure-viewer/dist-client/figure-view.js'
|
|
8
|
-
import { boardFor, changedKeys, componentOf, gizmoResultOf, needsRefit, type Board } from './stage-model.js'
|
|
8
|
+
import { boardFor, changedKeys, componentOf, drawnSpanOf, gizmoResultOf, needsRefit, outgrowsFit, type Board, type DrawnSpan } from './stage-model.js'
|
|
9
9
|
import { JOINT_FAN } from './joint-fan.js'
|
|
10
10
|
import { SLOT_MARK, SLOT_MARK_CHOSEN } from './slot-layout-host.js'
|
|
11
11
|
import { PREVIEW, TARGET_FACE } from './command-preview.js'
|
|
@@ -112,6 +112,7 @@ export class FigureCanvas extends LitElement {
|
|
|
112
112
|
private board?: Board & { depth: number }
|
|
113
113
|
/** The figure's reach when the view was last fitted to it. */
|
|
114
114
|
private fittedFor?: Board & { depth: number }
|
|
115
|
+
private fittedSpan?: DrawnSpan
|
|
115
116
|
private refitTimer?: ReturnType<typeof setTimeout>
|
|
116
117
|
private standingFor?: string
|
|
117
118
|
private watcher?: ResizeObserver
|
|
@@ -174,6 +175,7 @@ export class FigureCanvas extends LitElement {
|
|
|
174
175
|
/** The whole figure in view: the board's fit, then the 3D camera's own. */
|
|
175
176
|
fit() {
|
|
176
177
|
this.fittedFor = boardFor(this.parts)
|
|
178
|
+
this.fittedSpan = drawnSpanOf(this.parts)
|
|
177
179
|
this.scene?.fit?.('ratio')
|
|
178
180
|
this.scene?.root?.fitCameraToModel?.()
|
|
179
181
|
}
|
|
@@ -203,7 +205,7 @@ export class FigureCanvas extends LitElement {
|
|
|
203
205
|
drawn as it is typed -- 「3」, 「30」, 「300」 on the way to 3000 -- so the reach is judged after the last change.
|
|
204
206
|
*/
|
|
205
207
|
clearTimeout(this.refitTimer)
|
|
206
|
-
if (this.parts.length) this.refitTimer = setTimeout(() => needsRefit(this.fittedFor, boardFor(this.parts)) && this.fit(), REFIT_SETTLE_MS)
|
|
208
|
+
if (this.parts.length) this.refitTimer = setTimeout(() => (needsRefit(this.fittedFor, boardFor(this.parts)) || outgrowsFit(this.fittedSpan, drawnSpanOf(this.parts))) && this.fit(), REFIT_SETTLE_MS)
|
|
207
209
|
return
|
|
208
210
|
}
|
|
209
211
|
this.teardown()
|
package/client/modeller/clips.ts
CHANGED
|
@@ -47,6 +47,12 @@ export function clipsOf(asset: FigureAsset): Clip[] {
|
|
|
47
47
|
})
|
|
48
48
|
clips.set(d.clip, clip)
|
|
49
49
|
}
|
|
50
|
+
/*
|
|
51
|
+
Tracks in the order the controls are listed, not the order their drivers were last written: an edit writes a
|
|
52
|
+
driver anew at the end, and a track keyed jumped to the bottom of its clip (tutorial run 2026-11-12).
|
|
53
|
+
*/
|
|
54
|
+
const order = new Map(asset.document.model.inputs.map((i, n) => [i.id, n]))
|
|
55
|
+
for (const clip of clips.values()) clip.tracks.sort((a, b) => (order.get(a.state) ?? 0) - (order.get(b.state) ?? 0))
|
|
50
56
|
return [...clips.values()]
|
|
51
57
|
}
|
|
52
58
|
|
|
@@ -95,6 +101,25 @@ export const groupEdit = (group: ClipGroup, patch: Partial<Omit<ClipGroup, 'id'>
|
|
|
95
101
|
group: { id: group.id, label: patch.label ?? group.label, clips: patch.clips ?? group.clips, defaultClip: patch.defaultClip ?? group.defaultClip, transitionSeconds: patch.transitionSeconds ?? group.transitionSeconds }
|
|
96
102
|
})
|
|
97
103
|
|
|
104
|
+
/**
|
|
105
|
+
* The controls a playing clip drives in the preview, each with that clip: a clip in no group played at a speed above 0,
|
|
106
|
+
* or the clip a motion group plays at a speed above 0 (its default until one is chosen).
|
|
107
|
+
*/
|
|
108
|
+
export function playedControlsOf(
|
|
109
|
+
asset: FigureAsset,
|
|
110
|
+
driving: Record<string, number>,
|
|
111
|
+
moving: Record<string, { clip: string; speed: number }>
|
|
112
|
+
): Map<string, string> {
|
|
113
|
+
const playing = new Set(Object.entries(driving).filter(([, speed]) => speed > 0).map(([clip]) => clip))
|
|
114
|
+
for (const group of asset.motionGroups ?? []) {
|
|
115
|
+
const m = moving[group.id]
|
|
116
|
+
if (m && m.speed > 0) playing.add(m.clip || group.defaultClip)
|
|
117
|
+
}
|
|
118
|
+
const played = new Map<string, string>()
|
|
119
|
+
for (const d of asset.drivers ?? []) if (playing.has(d.clip) && !played.has(d.state)) played.set(d.state, d.clip)
|
|
120
|
+
return played
|
|
121
|
+
}
|
|
122
|
+
|
|
98
123
|
/** The pose a clip gives at a moment, as one batch of state changes -- one step of the history. */
|
|
99
124
|
export function poseAt(asset: FigureAsset, clip: string, seconds: number): FigureInputChange[] {
|
|
100
125
|
const units = new Map(asset.document.model.inputs.map(i => [i.id, i.unit]))
|
|
@@ -133,6 +158,25 @@ export function withoutKey(keys: readonly ClipKey[], index: number): ClipKey[] |
|
|
|
133
158
|
}
|
|
134
159
|
|
|
135
160
|
/** A name no clip has, for a copy: `walk-copy`, then `walk-copy-2`, … */
|
|
161
|
+
/**
|
|
162
|
+
* A clip copied as one step (review 2026-11-11: a walk copied into a run had to be put in a motion group first, by a
|
|
163
|
+
* section further down, the button grey until then). A copy plays the same controls, so the two are one group: a clip in
|
|
164
|
+
* none is put in a new one, named after it, before it is copied.
|
|
165
|
+
*/
|
|
166
|
+
export function copyClipEdits(asset: FigureAsset, clip: string, named = 'motion'): unknown[] {
|
|
167
|
+
const copy = { kind: 'copy-clip' as const, clip, name: freeClipNameOf(asset, clip) }
|
|
168
|
+
if ((asset.motionGroups ?? []).some(g => g.clips.includes(clip))) return [copy]
|
|
169
|
+
/*
|
|
170
|
+
The group is named for what it is, not after the first clip: the preview reads "<group> clip · walk | run", and a
|
|
171
|
+
group named "walk" read as if run were a kind of walk (tutorial run 2026-11-12).
|
|
172
|
+
*/
|
|
173
|
+
const labels = new Set((asset.motionGroups ?? []).map(g => g.label))
|
|
174
|
+
let label = named
|
|
175
|
+
for (let n = 2; labels.has(label); n++) label = `${named} ${n}`
|
|
176
|
+
const group = { id: freeGroupIdOf(asset), label, clips: [clip], defaultClip: clip, transitionSeconds: 0.2 }
|
|
177
|
+
return [{ kind: 'set-motion-group' as const, group }, copy]
|
|
178
|
+
}
|
|
179
|
+
|
|
136
180
|
export function freeClipNameOf(asset: FigureAsset, clip: string): string {
|
|
137
181
|
const taken = new Set((asset.drivers ?? []).map(d => d.clip))
|
|
138
182
|
const base = `${clip}-copy`
|
|
@@ -215,8 +259,15 @@ export type TrackJoin = { kind: 'free' } | { kind: 'group'; group: string; label
|
|
|
215
259
|
* door that has 「닫기」, and the two become one group, one playing at a time, without having to design the group first.
|
|
216
260
|
* All of it one step of the history.
|
|
217
261
|
*/
|
|
218
|
-
export function newClipEdits(
|
|
219
|
-
|
|
262
|
+
export function newClipEdits(
|
|
263
|
+
asset: FigureAsset,
|
|
264
|
+
state: string,
|
|
265
|
+
clip: string,
|
|
266
|
+
duration: number,
|
|
267
|
+
keys: ClipKey[],
|
|
268
|
+
interpolation?: ClipTrack['interpolation']
|
|
269
|
+
): { edits: unknown[]; join: TrackJoin } {
|
|
270
|
+
const track = { kind: 'set-driver' as const, state, clip, duration, keys, ...(interpolation ? { interpolation } : {}) }
|
|
220
271
|
const writers = [...new Set((asset.drivers ?? []).filter(d => d.state === state).map(d => d.clip))]
|
|
221
272
|
if (!writers.length) return { edits: [track], join: { kind: 'free' } }
|
|
222
273
|
const group = (asset.motionGroups ?? []).find(g => writers.some(c => g.clips.includes(c)))
|
|
@@ -245,7 +296,25 @@ export function rememberedPoseEdits(asset: FigureAsset, clip: string, seconds: n
|
|
|
245
296
|
const edits: unknown[] = []
|
|
246
297
|
for (const track of found.tracks) {
|
|
247
298
|
const now = values[track.state]
|
|
248
|
-
if (now === undefined
|
|
299
|
+
if (now === undefined) continue
|
|
300
|
+
/*
|
|
301
|
+
* A wrapping track's end reads as its start (review 2026-11-11: a walk kept at its end changed nothing, and a pose
|
|
302
|
+
* kept at 0 left the end key behind -- the loop jumped at its seam). So at either end the key there is compared and
|
|
303
|
+
* written itself, and a loop that closed -- start and end alike -- stays closed. A loop left open on purpose (a ramp
|
|
304
|
+
* that starts over) keeps its other end. A track that adds up turns on past its end and is read as before.
|
|
305
|
+
*/
|
|
306
|
+
if (shiftable(track) && (at === 0 || at === found.duration)) {
|
|
307
|
+
const keyAt = (s: number) => track.keys.find(k => k.seconds === s)?.value
|
|
308
|
+
const start = keyAt(0)
|
|
309
|
+
const end = keyAt(found.duration)
|
|
310
|
+
const mine = at === 0 ? start : end
|
|
311
|
+
if (same(now, mine ?? (there.get(track.state) ?? now))) continue
|
|
312
|
+
const closed = start !== undefined && end !== undefined && same(start, end)
|
|
313
|
+
const keys = withKey(track.keys, { seconds: at, value: now })
|
|
314
|
+
edits.push(trackEdit(found, track, { keys: closed ? withKey(keys, { seconds: at === 0 ? found.duration : 0, value: now }) : keys }))
|
|
315
|
+
continue
|
|
316
|
+
}
|
|
317
|
+
if (same(now, there.get(track.state) ?? now)) continue
|
|
249
318
|
edits.push(trackEdit(found, track, { keys: withKey(track.keys, { seconds: at, value: now }) }))
|
|
250
319
|
}
|
|
251
320
|
const free = new Set(undrivenOf(asset, clip))
|
|
@@ -261,7 +330,8 @@ export function rememberedPoseEdits(asset: FigureAsset, clip: string, seconds: n
|
|
|
261
330
|
continue
|
|
262
331
|
}
|
|
263
332
|
const keys = at === 0 ? [{ seconds: 0, value: now }, { seconds: found.duration, value: now }] : [{ seconds: 0, value: was }, { seconds: at, value: now }]
|
|
264
|
-
|
|
333
|
+
/* A track a kept pose starts goes from pose to pose: eased in and out of each, as a shown move is (motion contract). */
|
|
334
|
+
edits.push({ kind: 'set-driver' as const, state: input.id, clip, keys, interpolation: 'smooth' as const })
|
|
265
335
|
}
|
|
266
336
|
return { edits, skipped }
|
|
267
337
|
}
|
|
@@ -94,13 +94,14 @@ import { methodOf, type DataMethod } from '@things-factory/figure-viewer/dist-cl
|
|
|
94
94
|
export { methodOf, type DataMethod }
|
|
95
95
|
|
|
96
96
|
/**
|
|
97
|
-
* The methods a field allows (ADR-0166 2): a number as it is, scaled
|
|
98
|
-
*
|
|
97
|
+
* The methods a field allows (ADR-0166 2, amendment 1): a number as it is, scaled, by a code table (status codes sent as
|
|
98
|
+
* numbers, 4002) or by a range table; a text or true/false by a code table, or as it is where a behavior takes it so; a
|
|
99
|
+
* list as it is (its items). A new field is made by a table.
|
|
99
100
|
*/
|
|
100
101
|
export function methodsOf(node: FigureDataNode | null, choices: BehaviorChoice[]): DataMethod[] {
|
|
101
102
|
if (!node) return ['code', 'ranges']
|
|
102
103
|
const fitting = choices.some(c => fitsField(node, c))
|
|
103
|
-
if (node.kind === 'number') return [...(fitting ? (['value', 'scale'] as const) : []), 'ranges']
|
|
104
|
+
if (node.kind === 'number') return [...(fitting ? (['value', 'scale'] as const) : []), 'code', 'ranges']
|
|
104
105
|
if (node.kind === 'text' || node.kind === 'boolean') return [...(fitting ? (['value'] as const) : []), 'code']
|
|
105
106
|
return fitting ? ['value'] : []
|
|
106
107
|
}
|
|
@@ -170,6 +171,12 @@ export function bindingDraftOf(asset: FigureAsset, accessor: string, node: Figur
|
|
|
170
171
|
/* ---------------------------------------------------------------- tables (ADR-0166 3) */
|
|
171
172
|
|
|
172
173
|
/** The first code a new code table names, and the next ones: `CODE`, `CODE2`, … */
|
|
174
|
+
/** A new code for a number field's code table: one past the largest taken, or 0 (ADR-0166 amendment 1). */
|
|
175
|
+
export function freeNumberCodeOf(taken: string[]): string {
|
|
176
|
+
const numbers = taken.map(Number).filter(n => Number.isInteger(n) && n >= 0)
|
|
177
|
+
return String(numbers.length ? Math.max(...numbers) + 1 : 0)
|
|
178
|
+
}
|
|
179
|
+
|
|
173
180
|
export function freeCodeOf(taken: string[]): string {
|
|
174
181
|
if (!taken.includes('CODE')) return 'CODE'
|
|
175
182
|
let n = 2
|
|
@@ -186,28 +193,26 @@ export const legendRoleOf = (id: string): string =>
|
|
|
186
193
|
|
|
187
194
|
/**
|
|
188
195
|
* A first table on a field (ADR-0166 10): a row per value of a closed field, an open field's rows typed later, one
|
|
189
|
-
* interval over the whole of a number.
|
|
190
|
-
* table's keys, starting with one code; a range table's a number. Returns the binding and, for a new field, the schema
|
|
191
|
-
* with it.
|
|
196
|
+
* interval over the whole of a number. The field is one the schema has: the data tab adds a binding in the field's row.
|
|
192
197
|
*/
|
|
193
198
|
export function tableDraftOf(
|
|
194
199
|
asset: FigureAsset,
|
|
195
|
-
field: { path: string; node: FigureDataNode }
|
|
200
|
+
field: { path: string; node: FigureDataNode },
|
|
196
201
|
method: 'code' | 'ranges',
|
|
197
202
|
targets: FigureDataTargets
|
|
198
|
-
): { binding: FigureDataTableBinding
|
|
203
|
+
): { binding: FigureDataTableBinding } {
|
|
199
204
|
const taken = (asset.dataInterface?.bindings ?? []).map(b => b.id)
|
|
200
|
-
const
|
|
201
|
-
const name = made ? field.name : (field.path.replace(/\[\]/g, '').split('.').pop() ?? field.path)
|
|
205
|
+
const name = field.path.replace(/\[\]/g, '').split('.').pop() ?? field.path
|
|
202
206
|
const id = freeIdOf(taken, name.toLowerCase())
|
|
203
|
-
const node
|
|
204
|
-
const accessor =
|
|
207
|
+
const node = field.node
|
|
208
|
+
const accessor = accessorOfRow(field.path)
|
|
205
209
|
const table: Record<string, FigureDataTableEntry> =
|
|
206
|
-
method === 'code'
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
210
|
+
method === 'code'
|
|
211
|
+
? node.kind === 'number'
|
|
212
|
+
? { [freeNumberCodeOf([])]: {} }
|
|
213
|
+
: Object.fromEntries(domainOf(node).map(v => [String(v), {}]))
|
|
214
|
+
: { [`${node.kind === 'number' ? (node.min ?? '') : ''}~`]: {} }
|
|
215
|
+
return { binding: { id, accessor, rule: 'table', lookup: method, targets: tidyTargets(targets), table } }
|
|
211
216
|
}
|
|
212
217
|
|
|
213
218
|
/** Targets with empty lists left out, as the kernel keeps them. */
|
|
@@ -217,8 +222,9 @@ const tidyTargets = (t: FigureDataTargets): FigureDataTargets => ({
|
|
|
217
222
|
...(t.group ? { group: t.group } : {})
|
|
218
223
|
})
|
|
219
224
|
|
|
220
|
-
/** A look with only the columns its targets take: a target taken away takes its columns with it. */
|
|
225
|
+
/** A look with only the columns its targets take: a target taken away takes its columns with it. A look that keeps stays so. */
|
|
221
226
|
function lookFor(look: FigureDataTableEntry, t: FigureDataTargets): FigureDataTableEntry {
|
|
227
|
+
if (look.keep) return { keep: true }
|
|
222
228
|
const out: FigureDataTableEntry = {}
|
|
223
229
|
if (t.parts?.length) {
|
|
224
230
|
if (look.color !== undefined) out.color = look.color
|
|
@@ -245,6 +251,12 @@ export function tableWithTargets(b: FigureDataTableBinding, targets: FigureDataT
|
|
|
245
251
|
/** The table with one look set: the whole look given, a column left out cleared. */
|
|
246
252
|
export const tableWithLook = (b: FigureDataTableBinding, key: string, look: FigureDataTableEntry): FigureDataTableBinding => ({ ...b, table: { ...b.table, [key]: lookFor(look, b.targets) } })
|
|
247
253
|
|
|
254
|
+
/**
|
|
255
|
+
* The table whose other values keep what it last showed, or go to the default look (an empty one): the 「그대로 둠」 of
|
|
256
|
+
* the 「그 밖의 값」 row (ADR-0166 amendment 1).
|
|
257
|
+
*/
|
|
258
|
+
export const tableWithKeep = (b: FigureDataTableBinding, keep: boolean): FigureDataTableBinding => ({ ...b, table: { ...b.table, default: keep ? { keep: true } : {} } })
|
|
259
|
+
|
|
248
260
|
/** The table's rows renamed, added or taken away, in order; `default` kept last. */
|
|
249
261
|
export function tableWithKeys(b: FigureDataTableBinding, rows: Array<[string, FigureDataTableEntry]>): FigureDataTableBinding {
|
|
250
262
|
const d = b.table.default
|
|
@@ -411,14 +411,25 @@ export const EditorStyles = css`
|
|
|
411
411
|
z-index: 2;
|
|
412
412
|
display: grid;
|
|
413
413
|
gap: 4px;
|
|
414
|
-
padding:
|
|
415
|
-
|
|
416
|
-
|
|
414
|
+
padding: 6px 12px;
|
|
415
|
+
/* Solid where a theme gives no colour: a sticky line with none let the rows scrolled under it show through. */
|
|
416
|
+
background: var(--md-sys-color-surface-container-lowest, #fff);
|
|
417
|
+
border-bottom: 1px solid var(--md-sys-color-outline-variant, rgba(0, 0, 0, 0.12));
|
|
417
418
|
}
|
|
418
|
-
/*
|
|
419
|
+
/*
|
|
420
|
+
The part's name and its kind on one line: two lines on every tab, over a name field saying it again on the
|
|
421
|
+
properties tab, took the panel's top (user 2026-11-12).
|
|
422
|
+
*/
|
|
419
423
|
div[subject-summary] {
|
|
420
|
-
display:
|
|
421
|
-
|
|
424
|
+
display: flex;
|
|
425
|
+
align-items: baseline;
|
|
426
|
+
gap: 6px;
|
|
427
|
+
min-width: 0;
|
|
428
|
+
}
|
|
429
|
+
div[subject-summary] > * {
|
|
430
|
+
overflow: hidden;
|
|
431
|
+
white-space: nowrap;
|
|
432
|
+
text-overflow: ellipsis;
|
|
422
433
|
}
|
|
423
434
|
div[subject-summary] b {
|
|
424
435
|
font-size: var(--ui-t-body, 13px);
|
|
@@ -2215,6 +2215,8 @@ export class FigureEditor extends LitElement {
|
|
|
2215
2215
|
.asset=${asset}
|
|
2216
2216
|
.selected=${this.selected}
|
|
2217
2217
|
?locked=${unavailable}
|
|
2218
|
+
?previewing=${this.middle === 'preview'}
|
|
2219
|
+
@show-preview=${() => (this.middle = 'preview')}
|
|
2218
2220
|
@data-sample=${(e: CustomEvent) => (this.dataSample = e.detail.data)}
|
|
2219
2221
|
@legend-sample=${(e: CustomEvent) => (this.legendSample = e.detail.legends)}
|
|
2220
2222
|
@part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
|
|
@@ -5,6 +5,7 @@ import { faceViewOf, facesAsDrawnOf, type FaceView, type FastenMode, type Fasten
|
|
|
5
5
|
import type { FastenChange } from './figure-fasten-drawing.js'
|
|
6
6
|
import {
|
|
7
7
|
alignOf,
|
|
8
|
+
alignAxesOf,
|
|
8
9
|
alignRowsOf,
|
|
9
10
|
canFasten,
|
|
10
11
|
facesOf,
|
|
@@ -50,6 +51,11 @@ export interface FastenForm {
|
|
|
50
51
|
alignInput: Partial<Record<Axis, string>>
|
|
51
52
|
/** On a point of the target's curved surface instead of a face (ADR-0140 3.9): where, as the record says it. */
|
|
52
53
|
surface?: FigureSurfacePlace
|
|
54
|
+
/**
|
|
55
|
+
* Opened on the part's own fastening: aimed again, it goes there with that fastening's gap and place across the face
|
|
56
|
+
* (tutorial 2026-11-12). 「현재 상태로 붙이기」 -- where it stands kept -- is a loose part's.
|
|
57
|
+
*/
|
|
58
|
+
fastened?: boolean
|
|
53
59
|
}
|
|
54
60
|
|
|
55
61
|
export const freshFasten = (): FastenForm => ({
|
|
@@ -70,13 +76,64 @@ export function fastenFormOf(revision: FigureRevision, part: string): FastenForm
|
|
|
70
76
|
const own = fasteningOf(revision, part)
|
|
71
77
|
if (!own || own.atOrigin) {
|
|
72
78
|
/* A part still where it was added was put nowhere by the author: fastened, it goes on the face, no gap to keep. */
|
|
73
|
-
|
|
74
|
-
return !own && standsWhereAdded(pose) ? { ...freshFasten(), gapFrom: 'pressed' } : freshFasten()
|
|
79
|
+
return addedUnplaced(revision, part) ? { ...freshFasten(), gapFrom: 'pressed' } : freshFasten()
|
|
75
80
|
}
|
|
76
|
-
/*
|
|
77
|
-
|
|
81
|
+
/*
|
|
82
|
+
Its fastening is the author's, gap and place: aimed at another face or part through 「바꾸기」 it goes there with them
|
|
83
|
+
(tutorial 2026-11-12: a pillar copied from the front one and turned to the body's back face stayed in front, its gap
|
|
84
|
+
worked out as the whole body's depth).
|
|
85
|
+
*/
|
|
86
|
+
return { ...freshFasten(), ...own, gapFrom: 'typed', fastened: true } as FastenForm
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A part fastened to nothing that still stands where the palette put it: the author has put it nowhere to keep. */
|
|
90
|
+
function addedUnplaced(revision: FigureRevision, part: string): boolean {
|
|
91
|
+
if (fasteningOf(revision, part)) return false
|
|
92
|
+
const pose = (revision.geometry.find(g => (g as { id: string }).id === part) as { pose?: { r: number[][]; t: number[] } } | undefined)?.pose
|
|
93
|
+
return standsWhereAdded(pose)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The face a part just added starts on when aimed at `target`: the one asked, unless another part is fastened there
|
|
98
|
+
* already -- then the first face of the target nothing is fastened to (review 2026-11-11: a thigh aimed at a pelvis
|
|
99
|
+
* started on its top, where the body stands, and went inside the body until the bottom was chosen). A part the author
|
|
100
|
+
* has placed keeps the face it stands by: a face holds many parts (rollers and a lid on one frame).
|
|
101
|
+
*/
|
|
102
|
+
function freeFaceOf(revision: FigureRevision, part: string, target: string, asked: FigureFace): FigureFace {
|
|
103
|
+
const on = (end: unknown) => end as { part?: string; face?: string }
|
|
104
|
+
const joints = revision.asset.joints ?? []
|
|
105
|
+
const taken = new Set(joints.filter(j => on(j.body0).part === target && on(j.body1).part !== part).map(j => on(j.body0).face))
|
|
106
|
+
/* The face the target itself stands on its own parent by: a thigh hung under a pelvis touches it with its top. */
|
|
107
|
+
let hungBy: FigureFace | undefined
|
|
108
|
+
for (const j of joints)
|
|
109
|
+
if (on(j.body1).part === target && on(j.body0).face && (j as { facing?: string }).facing !== 'flush') {
|
|
110
|
+
hungBy = OPPOSITE[on(j.body0).face as FigureFace]
|
|
111
|
+
taken.add(hungBy)
|
|
112
|
+
}
|
|
113
|
+
/*
|
|
114
|
+
A tall part hung by its side -- an upper arm on a torso -- goes on downward: what is fastened to it starts under its
|
|
115
|
+
bottom end, not on its top beside the shoulder (tutorial run 2026-11-12: a forearm started on the upper arm's top
|
|
116
|
+
and its bottom had to be picked by hand).
|
|
117
|
+
*/
|
|
118
|
+
if (hungBy && FIGURE_FACES[hungBy] !== 'y' && !taken.has('bottom') && tallOf(revision, target)) return 'bottom'
|
|
119
|
+
if (!taken.has(asked)) return asked
|
|
120
|
+
/* The face across from the one taken first: a body on the top, legs under the bottom. */
|
|
121
|
+
const across = OPPOSITE[asked]
|
|
122
|
+
const faces = facesOf(revision.asset, target)
|
|
123
|
+
if (faces.includes(across) && !taken.has(across)) return across
|
|
124
|
+
return (faces.find(face => !taken.has(face)) as FigureFace | undefined) ?? asked
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Whether a part as drawn is at least half again as tall as it is wide and deep, read off its own faces. */
|
|
128
|
+
function tallOf(revision: FigureRevision, part: string): boolean {
|
|
129
|
+
const faces = (revision.geometry.find(g => (g as { id: string }).id === part) as { shape?: { faces?: Record<string, { at: number }> } } | undefined)?.shape?.faces
|
|
130
|
+
const span = (lo: string, hi: string) => (faces?.[hi] && faces?.[lo] ? Math.abs(faces[hi]!.at - faces[lo]!.at) : undefined)
|
|
131
|
+
const [x, y, z] = [span('left', 'right'), span('bottom', 'top'), span('back', 'front')]
|
|
132
|
+
return x !== undefined && y !== undefined && z !== undefined && y >= 1.5 * Math.max(x, z)
|
|
78
133
|
}
|
|
79
134
|
|
|
135
|
+
const OPPOSITE: Record<FigureFace, FigureFace> = { top: 'bottom', bottom: 'top', left: 'right', right: 'left', front: 'back', back: 'front' }
|
|
136
|
+
|
|
80
137
|
/** What a part may be fastened to: every other part that can be, each said whether it would close a loop. */
|
|
81
138
|
export function fastenTargetsOf(revision: FigureRevision, part: string): Array<{ part: string; loops: boolean }> {
|
|
82
139
|
const asset = revision.asset
|
|
@@ -127,7 +184,12 @@ export function aimFasten(
|
|
|
127
184
|
'plane' in to
|
|
128
185
|
? { ...form, toPlane: true, target: '', facing: form.toPlane ? form.facing : (figurePlaneFacingOf(revision.asset) ?? form.facing) }
|
|
129
186
|
: 'part' in to
|
|
130
|
-
? {
|
|
187
|
+
? {
|
|
188
|
+
...form,
|
|
189
|
+
toPlane: false,
|
|
190
|
+
target: to.part,
|
|
191
|
+
targetFace: to.part !== form.target && addedUnplaced(revision, part) ? freeFaceOf(revision, part, to.part, form.targetFace) : form.targetFace
|
|
192
|
+
}
|
|
131
193
|
: { ...form, targetFace: to.targetFace }
|
|
132
194
|
const theirFaces = f.target ? facesOf(revision.asset, f.target) : FACES
|
|
133
195
|
const targetFace = (theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0]) as FigureFace
|
|
@@ -136,7 +198,14 @@ export function aimFasten(
|
|
|
136
198
|
: f.target
|
|
137
199
|
? { part: f.target, face: targetFace }
|
|
138
200
|
: undefined
|
|
139
|
-
|
|
201
|
+
/*
|
|
202
|
+
* A part still where the palette put it has no place of the author's to keep: it goes to the face's centre (review
|
|
203
|
+
* 2026-11-11: a shin added at the origin and aimed at a thigh 85 mm off it was put 85 mm off the thigh's centre; an
|
|
204
|
+
* arm aimed at the body's side stood off the face, out of reach of the drawing).
|
|
205
|
+
*/
|
|
206
|
+
const centred = { align: {}, alignMm: {}, alignInput: {} }
|
|
207
|
+
if (f.fastened) return f
|
|
208
|
+
return { ...f, ...(aim && !addedUnplaced(revision, part) ? placeKeptOf(revision, part, aim) : centred) }
|
|
140
209
|
}
|
|
141
210
|
|
|
142
211
|
/** A change the drawing asks: a face, a way, a gap or a place across the face. */
|
|
@@ -192,6 +261,11 @@ export interface FastenPlan {
|
|
|
192
261
|
view?: FaceView
|
|
193
262
|
/** The axes across the face asked in rows: where the drawing cannot set the place. */
|
|
194
263
|
alignRows: { axis: Axis; edges: boolean }[]
|
|
264
|
+
/**
|
|
265
|
+
* Every axis across the face, the drawing's too: the details ask each by a number as well (review 2026-11-11: a thigh
|
|
266
|
+
* dragged to a pelvis's edge overshot by 47 mm, and dragged back by eye).
|
|
267
|
+
*/
|
|
268
|
+
alignAxes?: { axis: Axis; edges: boolean }[]
|
|
195
269
|
action?: FigureAuthoringAction
|
|
196
270
|
/** The face the command would meet, lit on the canvas while it is tried. */
|
|
197
271
|
face?: { part: string; face: string }
|
|
@@ -251,6 +325,7 @@ function fastenPlanWithout(revision: FigureRevision, part: string, f: FastenForm
|
|
|
251
325
|
mode,
|
|
252
326
|
view,
|
|
253
327
|
alignRows: alignRowsOf(asset, aim, view, f.alignInput),
|
|
328
|
+
alignAxes: alignAxesOf(asset, aim),
|
|
254
329
|
action,
|
|
255
330
|
...(f.toPlane ? {} : { face: { part: f.target, face: targetFace } })
|
|
256
331
|
}
|
|
@@ -188,8 +188,10 @@ export function fastenLabelsOf(
|
|
|
188
188
|
/**
|
|
189
189
|
* How much of an axis the face drawing shows, mm: room around the face for the part to go past its edges by about half
|
|
190
190
|
* itself, and for a part bigger than the face lined up with one of its edges (a mast flush with a low body's bottom) to
|
|
191
|
-
* show whole; a plane with no size, three parts wide.
|
|
191
|
+
* show whole; a plane with no size, three parts wide. A part standing `at` mm off the centre is reached too, its far
|
|
192
|
+
* edge inside (roll cradle tutorial 2026-11-12: a box left far up had its size label cut off the drawing).
|
|
192
193
|
*/
|
|
193
|
-
export function drawingSpanOf(face: number | undefined, part: number): number {
|
|
194
|
-
|
|
194
|
+
export function drawingSpanOf(face: number | undefined, part: number, at = 0): number {
|
|
195
|
+
const room = face !== undefined ? Math.max(face + part, 2 * part - face) : part * 3
|
|
196
|
+
return Math.max(room, 2 * Math.abs(at) + part)
|
|
195
197
|
}
|
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
import { css, html, LitElement, nothing, svg } from 'lit'
|
|
2
2
|
import { customElement, property, state } from 'lit/decorators.js'
|
|
3
|
-
import { ref } from 'lit/directives/ref.js'
|
|
4
3
|
|
|
5
4
|
import { i18next, localize } from '@operato/i18n'
|
|
6
5
|
import { figureCavitySectionOf } from '@hatiolab/figure-model'
|
|
7
6
|
import type { FigureAuthoringAction, FigureCavityRequest, FigureEvaluatedShape, FigureSection } from '@hatiolab/figure-model'
|
|
7
|
+
import { focusOnRender } from './focus-on-render.js'
|
|
8
8
|
import { cavityRefusalWordsOf, cavityWorkingOf, END_FACE, endToggled, mm, SIDE_FACE, wallDragged } from './section-controls.js'
|
|
9
9
|
|
|
10
10
|
type Axis = 'x' | 'y' | 'z'
|
|
11
11
|
|
|
12
|
-
/** A field opened by a press takes the keys at once. */
|
|
13
|
-
const focusOnRender = () => ref(el => el && requestAnimationFrame(() => (el as HTMLInputElement).select()))
|
|
14
12
|
type End = 'start' | 'end'
|
|
15
13
|
type Body = { id: string; shape: FigureEvaluatedShape }
|
|
16
14
|
|
|
@@ -8,7 +8,7 @@ import { i18next, localize } from '@operato/i18n'
|
|
|
8
8
|
import { unitWord } from './unit-words.js'
|
|
9
9
|
import type { FigureAsset } from '@hatiolab/figure-model'
|
|
10
10
|
|
|
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
|
+
import { clipGroupsOf, withKeyChanged, clipsOf, copiedTrackEdit, copyClipEdits, 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'
|
|
12
12
|
import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
|
|
13
13
|
import { HelpToggles } from './help.js'
|
|
14
14
|
|
|
@@ -37,6 +37,51 @@ export class FigureClips extends localize(i18next)(LitElement) {
|
|
|
37
37
|
:host {
|
|
38
38
|
display: block;
|
|
39
39
|
}
|
|
40
|
+
/*
|
|
41
|
+
The pose bar: the clip being keyed, its moment and 「현재 자세 기록」, first under the controls and held at the top
|
|
42
|
+
while the clips scroll. With walk and run, run's own button stood under all of walk's tracks, a long scroll from
|
|
43
|
+
the controls typed for it (tutorial run 2026-11-12).
|
|
44
|
+
*/
|
|
45
|
+
div[pose-bar] {
|
|
46
|
+
position: sticky;
|
|
47
|
+
top: 0;
|
|
48
|
+
z-index: 1;
|
|
49
|
+
display: flex;
|
|
50
|
+
flex-wrap: wrap;
|
|
51
|
+
align-items: center;
|
|
52
|
+
gap: 6px 10px;
|
|
53
|
+
margin-bottom: 8px;
|
|
54
|
+
padding: 8px;
|
|
55
|
+
background: var(--md-sys-color-surface-container, var(--md-sys-color-surface));
|
|
56
|
+
border: 1px solid var(--md-sys-color-primary);
|
|
57
|
+
border-radius: 8px;
|
|
58
|
+
}
|
|
59
|
+
div[pose-bar] span[clip-choices] {
|
|
60
|
+
display: flex;
|
|
61
|
+
flex-wrap: wrap;
|
|
62
|
+
gap: 4px;
|
|
63
|
+
}
|
|
64
|
+
div[pose-bar] span[clip-choices] button {
|
|
65
|
+
font: inherit;
|
|
66
|
+
color: inherit;
|
|
67
|
+
padding: 1px 10px;
|
|
68
|
+
background: transparent;
|
|
69
|
+
border: 1px solid var(--md-sys-color-outline-variant);
|
|
70
|
+
border-radius: 10px;
|
|
71
|
+
cursor: pointer;
|
|
72
|
+
}
|
|
73
|
+
div[pose-bar] span[clip-choices] button[aria-pressed='true'] {
|
|
74
|
+
background: var(--md-sys-color-secondary-container);
|
|
75
|
+
border-color: var(--md-sys-color-secondary);
|
|
76
|
+
font-weight: 600;
|
|
77
|
+
}
|
|
78
|
+
div[pose-bar] label[at] input {
|
|
79
|
+
width: 64px;
|
|
80
|
+
}
|
|
81
|
+
div[pose-bar] p[hint] {
|
|
82
|
+
flex-basis: 100%;
|
|
83
|
+
margin: 0;
|
|
84
|
+
}
|
|
40
85
|
section[clip] {
|
|
41
86
|
display: grid;
|
|
42
87
|
gap: 8px;
|
|
@@ -158,6 +203,8 @@ export class FigureClips extends localize(i18next)(LitElement) {
|
|
|
158
203
|
|
|
159
204
|
/** Where each clip is scrubbed to, in seconds. */
|
|
160
205
|
@state() private at: Record<string, number> = {}
|
|
206
|
+
/** The clip the pose bar keys: the one last scrubbed. */
|
|
207
|
+
@state() private active = ''
|
|
161
208
|
@state() private adding = { state: '', clip: '', name: '', duration: 2 }
|
|
162
209
|
@state() private grouping = { clip: '', label: '' }
|
|
163
210
|
/** The track whose copy is being set up: onto which control, how many seconds later. */
|
|
@@ -180,6 +227,7 @@ export class FigureClips extends localize(i18next)(LitElement) {
|
|
|
180
227
|
/* Nothing to play yet: said inside the section it belongs to, with the section's own margins. */
|
|
181
228
|
const empty = !clips.length && !undrivenOf(asset).length && !asset.motionGroups?.length
|
|
182
229
|
return html`
|
|
230
|
+
${clips.length ? this.renderPoseBar(clips) : nothing}
|
|
183
231
|
<section>
|
|
184
232
|
<h3>${i18next.t('figure.label.clips')}${this.help.button('figure.text.clips-explained')}</h3>
|
|
185
233
|
${this.help.hint('figure.text.clips-explained')}
|
|
@@ -198,12 +246,16 @@ export class FigureClips extends localize(i18next)(LitElement) {
|
|
|
198
246
|
return html`<section clip>
|
|
199
247
|
<div form>
|
|
200
248
|
<span>${i18next.t('figure.label.clip-name')}</span>
|
|
201
|
-
<input .value=${clip.name} ?disabled=${this.locked} @change=${(e: Event) => this.
|
|
249
|
+
<input .value=${clip.name} ?disabled=${this.locked} @change=${(e: Event) => this.rename(clip, (e.target as HTMLInputElement).value)} />
|
|
202
250
|
<span>${i18next.t('figure.label.clip-duration')}</span>
|
|
203
251
|
<input type="number" min="0.1" step="any" .value=${String(clip.duration)} ?disabled=${this.locked} @change=${(e: Event) => this.edit({ kind: 'set-clip', clip: clip.name, duration: number(e) })} />
|
|
204
252
|
</div>
|
|
205
|
-
<button act copy-clip ?disabled=${this.locked
|
|
206
|
-
@click=${() =>
|
|
253
|
+
<button act copy-clip ?disabled=${this.locked} title=${i18next.t(grouped ? 'figure.text.copy-clip-explained' : 'figure.text.copy-clip-makes-group')}
|
|
254
|
+
@click=${() => {
|
|
255
|
+
/* The copy is what is keyed next: the pose bar turns to it. */
|
|
256
|
+
this.active = freeClipNameOf(this.asset!, clip.name)
|
|
257
|
+
this.edits(copyClipEdits(this.asset!, clip.name, i18next.t('figure.label.motion-group-named')))
|
|
258
|
+
}}>
|
|
207
259
|
<md-icon>content_copy</md-icon>${i18next.t('figure.button.copy-clip')}
|
|
208
260
|
</button>
|
|
209
261
|
<div row>
|
|
@@ -236,6 +288,35 @@ export class FigureClips extends localize(i18next)(LitElement) {
|
|
|
236
288
|
</section>`
|
|
237
289
|
}
|
|
238
290
|
|
|
291
|
+
/**
|
|
292
|
+
* The clip being keyed -- the one last scrubbed or chosen here -- with its moment and 「현재 자세 기록」, right under the
|
|
293
|
+
* controls: type the controls, then record, without scrolling to the clip.
|
|
294
|
+
*/
|
|
295
|
+
private renderPoseBar(clips: Clip[]) {
|
|
296
|
+
const clip = clips.find(c => c.name === this.active) ?? clips[0]!
|
|
297
|
+
const at = Math.min(this.at[clip.name] ?? 0, clip.duration)
|
|
298
|
+
return html`<div pose-bar>
|
|
299
|
+
${clips.length > 1
|
|
300
|
+
? html`<span clip-choices role="group" aria-label=${i18next.t('figure.label.clips')}>
|
|
301
|
+
${clips.map(c => html`<button type="button" aria-pressed=${String(c === clip)} ?disabled=${this.locked} @click=${() => this.scrub(c, Math.min(this.at[c.name] ?? 0, c.duration))}>${c.name}</button>`)}
|
|
302
|
+
</span>`
|
|
303
|
+
: html`<strong>${clip.name}</strong>`}
|
|
304
|
+
<label at>
|
|
305
|
+
<input
|
|
306
|
+
type="number"
|
|
307
|
+
min="0"
|
|
308
|
+
max=${clip.duration}
|
|
309
|
+
step="any"
|
|
310
|
+
.value=${String(Math.round(at * 1000) / 1000)}
|
|
311
|
+
?disabled=${this.locked}
|
|
312
|
+
aria-label=${i18next.t('figure.label.clip-time')}
|
|
313
|
+
@change=${(e: Event) => this.scrub(clip, Math.min(Math.max((e.target as HTMLInputElement).valueAsNumber, 0), clip.duration))}
|
|
314
|
+
/>s
|
|
315
|
+
</label>
|
|
316
|
+
${this.renderRemember(clip, at)}
|
|
317
|
+
</div>`
|
|
318
|
+
}
|
|
319
|
+
|
|
239
320
|
/** 「이 자세 기억하기」: every control moved since the clip's pose was last shown or kept, as keys at this moment, one step. */
|
|
240
321
|
private renderRemember(clip: Clip, at: number) {
|
|
241
322
|
const { edits, skipped } = rememberedPoseEdits(this.asset!, clip.name, at, this.values, this.base[clip.name] ?? this.values)
|
|
@@ -380,9 +461,11 @@ export class FigureClips extends localize(i18next)(LitElement) {
|
|
|
380
461
|
const taken = !joining && clips.some(c => c.name === name)
|
|
381
462
|
const ready = !!name && !taken && duration > 0 && !!input
|
|
382
463
|
const join = !joining && input && name ? newClipEdits(asset, stateId!, name, duration, []).join : undefined
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
464
|
+
/* A track that holds the pose now is the start of a move from pose to pose: smooth (review 2026-11-11, a walk's nine
|
|
465
|
+
tracks each changed by hand). The range preset sweeps at a steady rate, as before. */
|
|
466
|
+
const add = (keys: ClipKey[], interpolation?: 'smooth') => {
|
|
467
|
+
if (joining) this.edit({ kind: 'set-driver', state: stateId, clip: name, keys, ...(interpolation ? { interpolation } : {}) })
|
|
468
|
+
else this.edits(newClipEdits(asset, stateId!, name, duration, keys, interpolation).edits)
|
|
386
469
|
/* The name was this clip's; the next new clip is named again. */
|
|
387
470
|
this.adding = { ...a, name: '', state: '' }
|
|
388
471
|
}
|
|
@@ -409,7 +492,7 @@ export class FigureClips extends localize(i18next)(LitElement) {
|
|
|
409
492
|
: join?.kind === 'group'
|
|
410
493
|
? html`<p hint track-join>${i18next.t('figure.text.track-joins-group', { name, group: join.label })}</p>`
|
|
411
494
|
: nothing}
|
|
412
|
-
<button act ?disabled=${this.locked || !ready} @click=${() => add(newTrackKeys(input!, this.values[stateId!], duration))}>
|
|
495
|
+
<button act ?disabled=${this.locked || !ready} @click=${() => add(newTrackKeys(input!, this.values[stateId!], duration), 'smooth')}>
|
|
413
496
|
<md-icon>add</md-icon>${i18next.t('figure.button.add-track')}
|
|
414
497
|
</button>
|
|
415
498
|
<button act range-track ?disabled=${this.locked || !ready} title=${i18next.t('figure.text.add-track-range-explained')}
|
|
@@ -515,12 +598,22 @@ export class FigureClips extends localize(i18next)(LitElement) {
|
|
|
515
598
|
</section>`
|
|
516
599
|
}
|
|
517
600
|
|
|
601
|
+
/** A clip renamed: the pose bar, its moment and its pose stay with it under the new name. */
|
|
602
|
+
private rename(clip: Clip, name: string) {
|
|
603
|
+
const moved = <T,>(by: Record<string, T>) => (clip.name in by ? { ...by, [name]: by[clip.name]! } : by)
|
|
604
|
+
this.at = moved(this.at)
|
|
605
|
+
this.base = moved(this.base)
|
|
606
|
+
if (this.active === clip.name) this.active = name
|
|
607
|
+
this.edit({ kind: 'set-clip', clip: clip.name, name })
|
|
608
|
+
}
|
|
609
|
+
|
|
518
610
|
/** Show the clip's pose at a moment: one batch of input changes, not saved as the default. */
|
|
519
611
|
/** Show the clip's pose at a moment. While the bar is dragged the pose is live (drawn, not recorded); the release
|
|
520
612
|
* applies it as one batch of input changes. */
|
|
521
613
|
private scrub(clip: Clip, seconds: number, live = false) {
|
|
522
614
|
if (!this.asset || !Number.isFinite(seconds)) return
|
|
523
615
|
this.at = { ...this.at, [clip.name]: seconds }
|
|
616
|
+
this.active = clip.name
|
|
524
617
|
const changes = poseAt(this.asset, clip.name, seconds)
|
|
525
618
|
if (!changes.length) return
|
|
526
619
|
if (live) {
|