@things-factory/figure-ui 10.1.271 → 10.1.272

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/client/modeller/canvas.ts +4 -2
  2. package/client/modeller/clips.ts +74 -4
  3. package/client/modeller/fasten-form.ts +69 -4
  4. package/client/modeller/figure-cavity-drawing.ts +1 -3
  5. package/client/modeller/figure-clips.ts +101 -8
  6. package/client/modeller/figure-fasten-dialog.ts +41 -30
  7. package/client/modeller/figure-fasten-drawing.ts +3 -1
  8. package/client/modeller/figure-first-save.ts +4 -3
  9. package/client/modeller/figure-links.ts +85 -28
  10. package/client/modeller/figure-preview.ts +41 -3
  11. package/client/modeller/figure-size-editor.ts +7 -2
  12. package/client/modeller/first-save.ts +8 -0
  13. package/client/modeller/focus-on-render.ts +4 -0
  14. package/client/modeller/pivot.ts +76 -0
  15. package/client/modeller/size-response.ts +14 -0
  16. package/client/modeller/stage-model.ts +50 -6
  17. package/dist-client/modeller/canvas.d.ts +1 -0
  18. package/dist-client/modeller/canvas.js +3 -2
  19. package/dist-client/modeller/canvas.js.map +1 -1
  20. package/dist-client/modeller/clips.d.ts +21 -4
  21. package/dist-client/modeller/clips.js +69 -4
  22. package/dist-client/modeller/clips.js.map +1 -1
  23. package/dist-client/modeller/fasten-form.d.ts +8 -0
  24. package/dist-client/modeller/fasten-form.js +64 -5
  25. package/dist-client/modeller/fasten-form.js.map +1 -1
  26. package/dist-client/modeller/figure-cavity-drawing.js +1 -3
  27. package/dist-client/modeller/figure-cavity-drawing.js.map +1 -1
  28. package/dist-client/modeller/figure-clips.d.ts +9 -0
  29. package/dist-client/modeller/figure-clips.js +104 -8
  30. package/dist-client/modeller/figure-clips.js.map +1 -1
  31. package/dist-client/modeller/figure-fasten-dialog.js +39 -24
  32. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  33. package/dist-client/modeller/figure-fasten-drawing.js +3 -1
  34. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  35. package/dist-client/modeller/figure-first-save.js +4 -3
  36. package/dist-client/modeller/figure-first-save.js.map +1 -1
  37. package/dist-client/modeller/figure-links.d.ts +9 -0
  38. package/dist-client/modeller/figure-links.js +89 -35
  39. package/dist-client/modeller/figure-links.js.map +1 -1
  40. package/dist-client/modeller/figure-preview.js +39 -3
  41. package/dist-client/modeller/figure-preview.js.map +1 -1
  42. package/dist-client/modeller/figure-size-editor.js +7 -3
  43. package/dist-client/modeller/figure-size-editor.js.map +1 -1
  44. package/dist-client/modeller/first-save.d.ts +5 -0
  45. package/dist-client/modeller/first-save.js +7 -0
  46. package/dist-client/modeller/first-save.js.map +1 -1
  47. package/dist-client/modeller/focus-on-render.d.ts +2 -0
  48. package/dist-client/modeller/focus-on-render.js +4 -0
  49. package/dist-client/modeller/focus-on-render.js.map +1 -0
  50. package/dist-client/modeller/pivot.d.ts +12 -0
  51. package/dist-client/modeller/pivot.js +59 -0
  52. package/dist-client/modeller/pivot.js.map +1 -0
  53. package/dist-client/modeller/size-response.d.ts +6 -0
  54. package/dist-client/modeller/size-response.js +14 -0
  55. package/dist-client/modeller/size-response.js.map +1 -1
  56. package/dist-client/modeller/stage-model.d.ts +17 -3
  57. package/dist-client/modeller/stage-model.js +44 -6
  58. package/dist-client/modeller/stage-model.js.map +1 -1
  59. package/dist-client/tsconfig.tsbuildinfo +1 -1
  60. package/package.json +4 -4
  61. package/test/browser/editing.check.mjs +6 -5
  62. package/test/browser/section.check.mjs +21 -0
  63. package/test/clips.test.ts +49 -4
  64. package/test/fasten-form.test.ts +44 -0
  65. package/test/first-save.test.ts +11 -1
  66. package/test/pivot.test.ts +57 -0
  67. package/test/size-response.test.ts +10 -2
  68. package/test/stage-model.test.ts +23 -1
  69. package/translations/en.json +8 -0
  70. package/translations/es.json +8 -0
  71. package/translations/ja.json +8 -0
  72. package/translations/ko.json +8 -0
  73. package/translations/ms.json +8 -0
  74. package/translations/zh.json +8 -0
  75. package/tutorial/humanoid/README.md +22 -0
  76. package/tutorial/humanoid/chapters.mjs +247 -0
  77. package/tutorial/humanoid/encode.mjs +26 -0
  78. package/tutorial/humanoid/lib.mjs +197 -0
  79. 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()
@@ -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(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 }
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 || same(now, there.get(track.state) ?? now)) continue
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
- edits.push({ kind: 'set-driver' as const, state: input.id, clip, keys })
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
  }
@@ -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,
@@ -70,13 +71,60 @@ export function fastenFormOf(revision: FigureRevision, part: string): FastenForm
70
71
  const own = fasteningOf(revision, part)
71
72
  if (!own || own.atOrigin) {
72
73
  /* A part still where it was added was put nowhere by the author: fastened, it goes on the face, no gap to keep. */
73
- const pose = (revision.geometry.find(g => (g as { id: string }).id === part) as { pose?: { r: number[][]; t: number[] } } | undefined)?.pose
74
- return !own && standsWhereAdded(pose) ? { ...freshFasten(), gapFrom: 'pressed' } : freshFasten()
74
+ return addedUnplaced(revision, part) ? { ...freshFasten(), gapFrom: 'pressed' } : freshFasten()
75
75
  }
76
76
  /* A gap the record works out is the author's; a number is where the part stands, so aiming again keeps it there. */
77
77
  return { ...freshFasten(), ...own, gapFrom: own.gapKept !== undefined ? 'typed' : 'kept' } as FastenForm
78
78
  }
79
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
+
80
128
  /** What a part may be fastened to: every other part that can be, each said whether it would close a loop. */
81
129
  export function fastenTargetsOf(revision: FigureRevision, part: string): Array<{ part: string; loops: boolean }> {
82
130
  const asset = revision.asset
@@ -127,7 +175,12 @@ export function aimFasten(
127
175
  'plane' in to
128
176
  ? { ...form, toPlane: true, target: '', facing: form.toPlane ? form.facing : (figurePlaneFacingOf(revision.asset) ?? form.facing) }
129
177
  : 'part' in to
130
- ? { ...form, toPlane: false, target: to.part }
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
+ }
131
184
  : { ...form, targetFace: to.targetFace }
132
185
  const theirFaces = f.target ? facesOf(revision.asset, f.target) : FACES
133
186
  const targetFace = (theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0]) as FigureFace
@@ -136,7 +189,13 @@ export function aimFasten(
136
189
  : f.target
137
190
  ? { part: f.target, face: targetFace }
138
191
  : undefined
139
- return { ...f, ...(aim ? placeKeptOf(revision, part, aim) : { align: {}, alignMm: {}, alignInput: {} }) }
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) }
140
199
  }
141
200
 
142
201
  /** A change the drawing asks: a face, a way, a gap or a place across the face. */
@@ -192,6 +251,11 @@ export interface FastenPlan {
192
251
  view?: FaceView
193
252
  /** The axes across the face asked in rows: where the drawing cannot set the place. */
194
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 }[]
195
259
  action?: FigureAuthoringAction
196
260
  /** The face the command would meet, lit on the canvas while it is tried. */
197
261
  face?: { part: string; face: string }
@@ -251,6 +315,7 @@ function fastenPlanWithout(revision: FigureRevision, part: string, f: FastenForm
251
315
  mode,
252
316
  view,
253
317
  alignRows: alignRowsOf(asset, aim, view, f.alignInput),
318
+ alignAxes: alignAxesOf(asset, aim),
254
319
  action,
255
320
  ...(f.toPlane ? {} : { face: { part: f.target, face: targetFace } })
256
321
  }
@@ -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.edit({ kind: 'set-clip', clip: clip.name, name: (e.target as HTMLInputElement).value })} />
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 || !grouped} title=${i18next.t(grouped ? 'figure.text.copy-clip-explained' : 'figure.text.copy-clip-needs-group')}
206
- @click=${() => this.edit({ kind: 'copy-clip', clip: clip.name, name: freeClipNameOf(this.asset!, clip.name) })}>
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
- 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)
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) {
@@ -89,7 +89,28 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
89
89
  }
90
90
  details[fasten-detail] > div[form] {
91
91
  margin-top: 8px;
92
- max-width: 320px;
92
+ max-width: 420px;
93
+ }
94
+ /* Where across the face, as buttons: a list the system draws is not seen in a recording, nor reached by a key in every browser. */
95
+ div[align-choices] {
96
+ display: flex;
97
+ flex-wrap: wrap;
98
+ gap: 4px;
99
+ }
100
+ div[align-choices] button {
101
+ font: inherit;
102
+ font-size: var(--ui-t-small, 11.5px);
103
+ padding: 3px 8px;
104
+ border-radius: 12px;
105
+ border: 1px solid var(--md-sys-color-outline-variant, #c4c7c5);
106
+ background: transparent;
107
+ color: inherit;
108
+ cursor: pointer;
109
+ }
110
+ div[align-choices] button[aria-pressed='true'] {
111
+ border-color: var(--md-sys-color-primary, #0d8f86);
112
+ background: var(--md-sys-color-primary-container, #cdeeea);
113
+ font-weight: 600;
93
114
  }
94
115
  span[fasten-summary] {
95
116
  opacity: 0.8;
@@ -253,7 +274,7 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
253
274
  <summary>
254
275
  ${i18next.t('figure.label.fasten-detail')}${detail.length ? html` · <span fasten-summary>${detail.join(' · ')}</span>` : nothing}
255
276
  </summary>
256
- ${plan.alignRows.length ? this.renderAlignment(plan.alignRows) : nothing}
277
+ ${(plan.alignAxes ?? plan.alignRows).length ? this.renderAlignment(plan.alignAxes ?? plan.alignRows) : nothing}
257
278
  <div form>
258
279
  <span>${i18next.t('figure.label.gap')}</span>
259
280
  <input
@@ -384,34 +405,24 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
384
405
  : 'centre'
385
406
  : (f.align[axis] ?? 'centre')
386
407
  return html`<span>${i18next.t('figure.label.align-' + axis)}</span>
387
- <select @change=${(e: Event) => set(axis, (e.target as HTMLSelectElement).value as Alignment)}>
388
- <option value="centre" ?selected=${how === 'centre'}>${i18next.t('figure.label.align-centre')}</option>
389
- ${
390
- edges
391
- ? html`<option value="min" ?selected=${how === 'min'}>
392
- ${i18next.t(`figure.label.align-${axis}-min`)}
393
- </option>
394
- <option value="max" ?selected=${how === 'max'}>
395
- ${i18next.t(`figure.label.align-${axis}-max`)}
396
- </option>`
397
- : nothing
398
- }
399
- ${
400
- f.alignInput?.[axis]
401
- ? html`<option value="input" ?selected=${how === 'input'}>
402
- ${i18next.t('figure.label.align-input', { field: fieldWordsOf(f.alignInput[axis]!, key => i18next.t(key)) })}
403
- </option>`
404
- : nothing
405
- }
406
- <option value="mm" ?selected=${how === 'mm'}>${i18next.t('figure.label.align-mm')}</option>
407
- ${
408
- this.revision && hasInstanceSize(this.revision.asset, axis)
409
- ? html`<option value="share" ?selected=${how === 'share'}>
410
- ${i18next.t('figure.label.align-share')}
411
- </option>`
412
- : nothing
413
- }
414
- </select>
408
+ <div align-choices role="group" aria-label=${i18next.t('figure.label.align-' + axis)}>
409
+ ${[
410
+ { value: 'centre' as Alignment, words: i18next.t('figure.label.align-centre'), shown: true },
411
+ { value: 'min' as Alignment, words: i18next.t(`figure.label.align-${axis}-min`), shown: edges },
412
+ { value: 'max' as Alignment, words: i18next.t(`figure.label.align-${axis}-max`), shown: edges },
413
+ {
414
+ value: 'input' as Alignment,
415
+ words: f.alignInput?.[axis] ? i18next.t('figure.label.align-input', { field: fieldWordsOf(f.alignInput[axis]!, key => i18next.t(key)) }) : '',
416
+ shown: !!f.alignInput?.[axis]
417
+ },
418
+ { value: 'mm' as Alignment, words: i18next.t('figure.label.align-mm'), shown: true },
419
+ { value: 'share' as Alignment, words: i18next.t('figure.label.align-share'), shown: !!this.revision && hasInstanceSize(this.revision.asset, axis) }
420
+ ]
421
+ .filter(choice => choice.shown)
422
+ .map(
423
+ choice => html`<button type="button" aria-pressed=${String(how === choice.value)} @click=${() => set(axis, choice.value)}>${choice.words}</button>`
424
+ )}
425
+ </div>
415
426
  ${
416
427
  how === 'mm' || how === 'share'
417
428
  ? html`<span></span
@@ -6,6 +6,7 @@ import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-
6
6
  import { figureAroundNormalOf, figureAroundOf, figureCaughtAngleOf, figureSurfaceAnglesOf, figureSurfacePlaceOf } from '@hatiolab/figure-model'
7
7
  import type { FigureFace, FigureSurfacePlace } from '@hatiolab/figure-model'
8
8
 
9
+ import { focusOnRender } from './focus-on-render.js'
9
10
  import { alignmentAt, drawingSpanOf, fastenLabelsOf, offsetOf, overhangOf, screenOf, type FaceView, type FastenMode } from './fasten-geometry.js'
10
11
  import type { Alignment } from './links.js'
11
12
 
@@ -432,7 +433,8 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
432
433
  this.change({ axis: a, how: 'mm', mm: Math.round(value) })
433
434
  this.released()
434
435
  }}
435
- @blur=${() => (this.typing = undefined)} />`
436
+ @blur=${() => (this.typing = undefined)}
437
+ ${focusOnRender()} />`
436
438
  }
437
439
 
438
440
  private grab(ev: PointerEvent, at: Record<Axis, number>, caught: Partial<Record<Axis, string>>) {
@@ -7,7 +7,7 @@ import { CommonPopupStyles, FormFieldStyles } from '@operato/styles'
7
7
  import '@material/web/button/filled-button.js'
8
8
  import '@material/web/button/text-button.js'
9
9
 
10
- import { firstSaveRefusalOf, offeredTypeOf, releasableType, typeOf, type FirstSave } from './first-save.js'
10
+ import { firstSaveRefusalOf, offeredTypeOf, releasableType, typeOf, typingTypeOf, type FirstSave } from './first-save.js'
11
11
 
12
12
  /**
13
13
  * The first save of a new figure: its type and its name, asked once, in the suite's popup look.
@@ -97,13 +97,13 @@ export class FigureFirstSave extends LitElement {
97
97
  .value=${this.type}
98
98
  placeholder=${i18next.t('figure.text.figure-type-example')}
99
99
  @input=${(e: Event) => {
100
- this.type = typeOf((e.target as HTMLInputElement).value)
100
+ this.type = typingTypeOf((e.target as HTMLInputElement).value)
101
101
  ;(e.target as HTMLInputElement).value = this.type
102
102
  this.refusal = null
103
103
  }}
104
104
  @keydown=${this.onKey}
105
105
  />
106
- ${this.type && !releasableType(this.type)
106
+ ${typeOf(this.type) && !releasableType(typeOf(this.type))
107
107
  ? html`<div class="note">${i18next.t('figure.text.type-name-will-not-release')}</div>`
108
108
  : this.suggested && this.type === this.suggested
109
109
  ? html`<div class="note">${i18next.t('figure.text.identifier-suggested')}</div>`
@@ -132,6 +132,7 @@ export class FigureFirstSave extends LitElement {
132
132
  }
133
133
 
134
134
  private confirm = () => {
135
+ this.type = typeOf(this.type)
135
136
  this.refusal = firstSaveRefusalOf(this.type, this.taken)
136
137
  if (this.refusal) return
137
138
  const namespace = this.namespaces.length > 1 ? this.chosenNamespace : undefined