@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
package/client/graphql/index.ts
CHANGED
|
@@ -473,6 +473,7 @@ export async function inspectFigure(id: string): Promise<FigureInspection> {
|
|
|
473
473
|
how
|
|
474
474
|
at
|
|
475
475
|
blocking
|
|
476
|
+
asNewType
|
|
476
477
|
}
|
|
477
478
|
}
|
|
478
479
|
}
|
|
@@ -504,6 +505,7 @@ export async function inspectFigureDraft(source: string, id?: string): Promise<F
|
|
|
504
505
|
how
|
|
505
506
|
at
|
|
506
507
|
blocking
|
|
508
|
+
asNewType
|
|
507
509
|
}
|
|
508
510
|
}
|
|
509
511
|
}
|
|
@@ -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
|
}
|
|
@@ -387,6 +387,20 @@ export function sampleOf(
|
|
|
387
387
|
}
|
|
388
388
|
}
|
|
389
389
|
|
|
390
|
+
/**
|
|
391
|
+
* The state inputs a sample of data sets on this figure, by id: what the preview shows for them instead of a slider. None
|
|
392
|
+
* when there is no sample, or the figure refuses it.
|
|
393
|
+
*/
|
|
394
|
+
export function sampledStatesOf(asset: FigureAsset, sample: unknown): Record<string, number> {
|
|
395
|
+
if (sample === null || sample === undefined || !asset.dataInterface) return {}
|
|
396
|
+
try {
|
|
397
|
+
const read = sampleOf(asset, sample)
|
|
398
|
+
return read.issues.length ? {} : Object.fromEntries(Object.entries(read.result.states).map(([id, s]) => [id, s.value]))
|
|
399
|
+
} catch {
|
|
400
|
+
return {}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
390
404
|
/** The rows a person gives a value by hand, as a board's saved values: one value, read by a binding, not inside a list. */
|
|
391
405
|
export const valueRowsOf = (asset: FigureAsset): FigureDataRow[] => schemaRowsOf(asset).filter(r => isValue(r.node) && r.bindings.length > 0 && !r.path.includes('[]'))
|
|
392
406
|
|
|
@@ -94,6 +94,17 @@ export const EditorStyles = css`
|
|
|
94
94
|
a promise to whoever draws the figure, is outlined.
|
|
95
95
|
*/
|
|
96
96
|
ox-canvas-bar button[save],
|
|
97
|
+
div[release-blockers] button[as-new-type] {
|
|
98
|
+
justify-self: start;
|
|
99
|
+
margin-top: 4px;
|
|
100
|
+
padding: 4px 10px;
|
|
101
|
+
border: 1px solid var(--md-sys-color-outline);
|
|
102
|
+
border-radius: 999px;
|
|
103
|
+
color: var(--md-sys-color-primary);
|
|
104
|
+
background: var(--md-sys-color-surface);
|
|
105
|
+
font: inherit;
|
|
106
|
+
cursor: pointer;
|
|
107
|
+
}
|
|
97
108
|
ox-canvas-bar button[publish] {
|
|
98
109
|
display: inline-flex;
|
|
99
110
|
align-items: center;
|
|
@@ -50,7 +50,7 @@ import { EditorStyles } from './editor-styles.js'
|
|
|
50
50
|
import { ScrollbarStyles } from '@operato/styles'
|
|
51
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
|
-
import { paletteRecipe } from './palette.js'
|
|
53
|
+
import { firstPartFastensToPlane, paletteRecipe } from './palette.js'
|
|
54
54
|
import { meshChoicesOf, meshPartOf } from './mesh-import.js'
|
|
55
55
|
import './figure-mesh-choice.js'
|
|
56
56
|
import { uploadFigureMesh } from '../graphql/index.js'
|
|
@@ -544,13 +544,8 @@ export class FigureEditor extends LitElement {
|
|
|
544
544
|
}).result as SaveConflictChoice | undefined
|
|
545
545
|
if (this.session !== session || !session.read().conflict) return
|
|
546
546
|
if (choice === 'latest') window.open(location.href, '_blank', 'noopener')
|
|
547
|
-
else if (choice === 'copy')
|
|
548
|
-
|
|
549
|
-
/* The page forgets the row of the figure left: the copy's identifier is asked, not the other's offered. */
|
|
550
|
-
this.dispatchEvent(new CustomEvent('figure-detached', { bubbles: true, composed: true }))
|
|
551
|
-
this.changed()
|
|
552
|
-
await this.save()
|
|
553
|
-
} else if (choice === 'overwrite') {
|
|
547
|
+
else if (choice === 'copy') await this.saveAsNewFigure()
|
|
548
|
+
else if (choice === 'overwrite') {
|
|
554
549
|
const agreed = await OxPrompt.open({
|
|
555
550
|
type: 'warning',
|
|
556
551
|
text: i18next.t('figure.text.overwrite-latest-warning'),
|
|
@@ -562,12 +557,35 @@ export class FigureEditor extends LitElement {
|
|
|
562
557
|
await this.save()
|
|
563
558
|
}
|
|
564
559
|
}
|
|
560
|
+
/**
|
|
561
|
+
* The edits kept apart as a new figure, the one opened left as the server has it: a copy after a save conflict, or a
|
|
562
|
+
* new figure type for edits a released figure cannot take (the release gate's way out, tutorial 2026-11-11). The first
|
|
563
|
+
* save asks its identifier.
|
|
564
|
+
*/
|
|
565
|
+
private async saveAsNewFigure() {
|
|
566
|
+
this.session!.detachAsCopy()
|
|
567
|
+
this.blockersOpen = false
|
|
568
|
+
/* The page forgets the row of the figure left: the copy's identifier is asked, not the other's offered. */
|
|
569
|
+
this.dispatchEvent(new CustomEvent('figure-detached', { bubbles: true, composed: true }))
|
|
570
|
+
this.changed()
|
|
571
|
+
await this.save()
|
|
572
|
+
}
|
|
565
573
|
private add(kind: FigurePaletteKind) {
|
|
566
574
|
if (!this.session) return
|
|
567
575
|
this.run(() => {
|
|
568
576
|
/* The id is figure-model's to choose: what is in use is its rule, not a list the screen keeps. */
|
|
569
|
-
const
|
|
570
|
-
|
|
577
|
+
const asset = this.session!.revisionNow()!.asset
|
|
578
|
+
const part = paletteRecipe(kind, figurePartIdFor(asset, kind))
|
|
579
|
+
/*
|
|
580
|
+
* The first part of a figure that stands on a plane (floor, ceiling, wall) is its base: it is added fastened to
|
|
581
|
+
* that plane, one step of the history, so a size typed next keeps it on the plane instead of sinking through it.
|
|
582
|
+
*/
|
|
583
|
+
if (firstPartFastensToPlane(this.session!.read().geometry ?? [], asset?.placement)) {
|
|
584
|
+
this.session!.applyRecipe([
|
|
585
|
+
{ kind: 'add-part', part },
|
|
586
|
+
{ kind: 'attach', part: part.id, to: { plane: 'mounting-plane' } }
|
|
587
|
+
])
|
|
588
|
+
} else this.session!.add(part)
|
|
571
589
|
this.selected = part.id
|
|
572
590
|
})
|
|
573
591
|
}
|
|
@@ -1615,7 +1633,17 @@ export class FigureEditor extends LitElement {
|
|
|
1615
1633
|
return html`<div release-blockers role="dialog" aria-label=${i18next.t('figure.text.must-fix-to-release')} @keydown=${(e: KeyboardEvent) => e.key === 'Escape' && (this.blockersOpen = false)}>
|
|
1616
1634
|
<ul gate>
|
|
1617
1635
|
<li head>${i18next.t('figure.text.must-fix-to-release')}</li>
|
|
1618
|
-
${blockers.map(finding =>
|
|
1636
|
+
${blockers.map(finding =>
|
|
1637
|
+
renderGateFinding(
|
|
1638
|
+
finding,
|
|
1639
|
+
true,
|
|
1640
|
+
/* A break of the released contract: the edits go out as a new figure type, the placed ones keep the old one. */
|
|
1641
|
+
finding.asNewType
|
|
1642
|
+
? html`<button as-new-type @click=${() => this.saveAsNewFigure()}>${i18next.t('figure.button.save-as-new-type')}</button>`
|
|
1643
|
+
: nothing,
|
|
1644
|
+
parts
|
|
1645
|
+
)
|
|
1646
|
+
)}
|
|
1619
1647
|
</ul>
|
|
1620
1648
|
</div>`
|
|
1621
1649
|
}
|
|
@@ -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,
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
type Axis
|
|
20
21
|
} from './links.js'
|
|
21
22
|
import type { FigureRevision } from './revision.js'
|
|
23
|
+
import { standsWhereAdded } from './palette.js'
|
|
22
24
|
import { ownerOf } from './fields.js'
|
|
23
25
|
|
|
24
26
|
/*
|
|
@@ -67,11 +69,62 @@ export const freshFasten = (): FastenForm => ({
|
|
|
67
69
|
/** The part's own fastening as a form, or a fresh one: a window opens on what the part has (walk 1, 2026-09-26). */
|
|
68
70
|
export function fastenFormOf(revision: FigureRevision, part: string): FastenForm {
|
|
69
71
|
const own = fasteningOf(revision, part)
|
|
70
|
-
if (!own || own.atOrigin)
|
|
72
|
+
if (!own || own.atOrigin) {
|
|
73
|
+
/* A part still where it was added was put nowhere by the author: fastened, it goes on the face, no gap to keep. */
|
|
74
|
+
return addedUnplaced(revision, part) ? { ...freshFasten(), gapFrom: 'pressed' } : freshFasten()
|
|
75
|
+
}
|
|
71
76
|
/* A gap the record works out is the author's; a number is where the part stands, so aiming again keeps it there. */
|
|
72
77
|
return { ...freshFasten(), ...own, gapFrom: own.gapKept !== undefined ? 'typed' : 'kept' } as FastenForm
|
|
73
78
|
}
|
|
74
79
|
|
|
80
|
+
/** A part fastened to nothing that still stands where the palette put it: the author has put it nowhere to keep. */
|
|
81
|
+
function addedUnplaced(revision: FigureRevision, part: string): boolean {
|
|
82
|
+
if (fasteningOf(revision, part)) return false
|
|
83
|
+
const pose = (revision.geometry.find(g => (g as { id: string }).id === part) as { pose?: { r: number[][]; t: number[] } } | undefined)?.pose
|
|
84
|
+
return standsWhereAdded(pose)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The face a part just added starts on when aimed at `target`: the one asked, unless another part is fastened there
|
|
89
|
+
* already -- then the first face of the target nothing is fastened to (review 2026-11-11: a thigh aimed at a pelvis
|
|
90
|
+
* started on its top, where the body stands, and went inside the body until the bottom was chosen). A part the author
|
|
91
|
+
* has placed keeps the face it stands by: a face holds many parts (rollers and a lid on one frame).
|
|
92
|
+
*/
|
|
93
|
+
function freeFaceOf(revision: FigureRevision, part: string, target: string, asked: FigureFace): FigureFace {
|
|
94
|
+
const on = (end: unknown) => end as { part?: string; face?: string }
|
|
95
|
+
const joints = revision.asset.joints ?? []
|
|
96
|
+
const taken = new Set(joints.filter(j => on(j.body0).part === target && on(j.body1).part !== part).map(j => on(j.body0).face))
|
|
97
|
+
/* The face the target itself stands on its own parent by: a thigh hung under a pelvis touches it with its top. */
|
|
98
|
+
let hungBy: FigureFace | undefined
|
|
99
|
+
for (const j of joints)
|
|
100
|
+
if (on(j.body1).part === target && on(j.body0).face && (j as { facing?: string }).facing !== 'flush') {
|
|
101
|
+
hungBy = OPPOSITE[on(j.body0).face as FigureFace]
|
|
102
|
+
taken.add(hungBy)
|
|
103
|
+
}
|
|
104
|
+
/*
|
|
105
|
+
A tall part hung by its side -- an upper arm on a torso -- goes on downward: what is fastened to it starts under its
|
|
106
|
+
bottom end, not on its top beside the shoulder (tutorial run 2026-11-12: a forearm started on the upper arm's top
|
|
107
|
+
and its bottom had to be picked by hand).
|
|
108
|
+
*/
|
|
109
|
+
if (hungBy && FIGURE_FACES[hungBy] !== 'y' && !taken.has('bottom') && tallOf(revision, target)) return 'bottom'
|
|
110
|
+
if (!taken.has(asked)) return asked
|
|
111
|
+
/* The face across from the one taken first: a body on the top, legs under the bottom. */
|
|
112
|
+
const across = OPPOSITE[asked]
|
|
113
|
+
const faces = facesOf(revision.asset, target)
|
|
114
|
+
if (faces.includes(across) && !taken.has(across)) return across
|
|
115
|
+
return (faces.find(face => !taken.has(face)) as FigureFace | undefined) ?? asked
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Whether a part as drawn is at least half again as tall as it is wide and deep, read off its own faces. */
|
|
119
|
+
function tallOf(revision: FigureRevision, part: string): boolean {
|
|
120
|
+
const faces = (revision.geometry.find(g => (g as { id: string }).id === part) as { shape?: { faces?: Record<string, { at: number }> } } | undefined)?.shape?.faces
|
|
121
|
+
const span = (lo: string, hi: string) => (faces?.[hi] && faces?.[lo] ? Math.abs(faces[hi]!.at - faces[lo]!.at) : undefined)
|
|
122
|
+
const [x, y, z] = [span('left', 'right'), span('bottom', 'top'), span('back', 'front')]
|
|
123
|
+
return x !== undefined && y !== undefined && z !== undefined && y >= 1.5 * Math.max(x, z)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const OPPOSITE: Record<FigureFace, FigureFace> = { top: 'bottom', bottom: 'top', left: 'right', right: 'left', front: 'back', back: 'front' }
|
|
127
|
+
|
|
75
128
|
/** What a part may be fastened to: every other part that can be, each said whether it would close a loop. */
|
|
76
129
|
export function fastenTargetsOf(revision: FigureRevision, part: string): Array<{ part: string; loops: boolean }> {
|
|
77
130
|
const asset = revision.asset
|
|
@@ -122,7 +175,12 @@ export function aimFasten(
|
|
|
122
175
|
'plane' in to
|
|
123
176
|
? { ...form, toPlane: true, target: '', facing: form.toPlane ? form.facing : (figurePlaneFacingOf(revision.asset) ?? form.facing) }
|
|
124
177
|
: 'part' in to
|
|
125
|
-
? {
|
|
178
|
+
? {
|
|
179
|
+
...form,
|
|
180
|
+
toPlane: false,
|
|
181
|
+
target: to.part,
|
|
182
|
+
targetFace: to.part !== form.target && addedUnplaced(revision, part) ? freeFaceOf(revision, part, to.part, form.targetFace) : form.targetFace
|
|
183
|
+
}
|
|
126
184
|
: { ...form, targetFace: to.targetFace }
|
|
127
185
|
const theirFaces = f.target ? facesOf(revision.asset, f.target) : FACES
|
|
128
186
|
const targetFace = (theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0]) as FigureFace
|
|
@@ -131,7 +189,13 @@ export function aimFasten(
|
|
|
131
189
|
: f.target
|
|
132
190
|
? { part: f.target, face: targetFace }
|
|
133
191
|
: undefined
|
|
134
|
-
|
|
192
|
+
/*
|
|
193
|
+
* 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
|
|
194
|
+
* 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
|
|
195
|
+
* arm aimed at the body's side stood off the face, out of reach of the drawing).
|
|
196
|
+
*/
|
|
197
|
+
const centred = { align: {}, alignMm: {}, alignInput: {} }
|
|
198
|
+
return { ...f, ...(aim && !addedUnplaced(revision, part) ? placeKeptOf(revision, part, aim) : centred) }
|
|
135
199
|
}
|
|
136
200
|
|
|
137
201
|
/** A change the drawing asks: a face, a way, a gap or a place across the face. */
|
|
@@ -187,6 +251,11 @@ export interface FastenPlan {
|
|
|
187
251
|
view?: FaceView
|
|
188
252
|
/** The axes across the face asked in rows: where the drawing cannot set the place. */
|
|
189
253
|
alignRows: { axis: Axis; edges: boolean }[]
|
|
254
|
+
/**
|
|
255
|
+
* Every axis across the face, the drawing's too: the details ask each by a number as well (review 2026-11-11: a thigh
|
|
256
|
+
* dragged to a pelvis's edge overshot by 47 mm, and dragged back by eye).
|
|
257
|
+
*/
|
|
258
|
+
alignAxes?: { axis: Axis; edges: boolean }[]
|
|
190
259
|
action?: FigureAuthoringAction
|
|
191
260
|
/** The face the command would meet, lit on the canvas while it is tried. */
|
|
192
261
|
face?: { part: string; face: string }
|
|
@@ -246,6 +315,7 @@ function fastenPlanWithout(revision: FigureRevision, part: string, f: FastenForm
|
|
|
246
315
|
mode,
|
|
247
316
|
view,
|
|
248
317
|
alignRows: alignRowsOf(asset, aim, view, f.alignInput),
|
|
318
|
+
alignAxes: alignAxesOf(asset, aim),
|
|
249
319
|
action,
|
|
250
320
|
...(f.toPlane ? {} : { face: { part: f.target, face: targetFace } })
|
|
251
321
|
}
|
|
@@ -100,7 +100,8 @@ export function alignmentAt(d: number, face: number | undefined, part: number, e
|
|
|
100
100
|
[-inside, 'min'],
|
|
101
101
|
[inside, 'max']
|
|
102
102
|
] as const)
|
|
103
|
-
|
|
103
|
+
/* Level with an edge whatever the sizes: a part bigger than the face (a mast on a low body) lines up with it too. */
|
|
104
|
+
if (Math.abs(d - at) <= catchMm && inside !== 0) return edges ? { how, caught: 'inside' } : { how: 'mm', mm: Math.round(at), caught: 'inside' }
|
|
104
105
|
for (const at of [-outside, outside]) if (Math.abs(d - at) <= catchMm) return { how: 'mm', mm: Math.round(at), caught: 'outside' }
|
|
105
106
|
}
|
|
106
107
|
return { how: 'mm', mm: Math.round(d) }
|
|
@@ -183,3 +184,12 @@ export function fastenLabelsOf(
|
|
|
183
184
|
if (below - 11 < edge + 2 && below + 3 > edge - 2 && edge + 14 <= height - 4) return { face: facePlace, part: { x: part.x + part.w / 2, y: edge + 14, inside: false } }
|
|
184
185
|
return { face: facePlace, part: { x: part.x + part.w / 2, y: below <= height - 20 ? below : part.y - 5, inside: false } }
|
|
185
186
|
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
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
|
+
* 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.
|
|
192
|
+
*/
|
|
193
|
+
export function drawingSpanOf(face: number | undefined, part: number): number {
|
|
194
|
+
return face !== undefined ? Math.max(face + part, 2 * part - face) : part * 3
|
|
195
|
+
}
|
|
@@ -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) {
|