@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.
Files changed (113) hide show
  1. package/client/graphql/index.ts +2 -0
  2. package/client/modeller/canvas.ts +4 -2
  3. package/client/modeller/clips.ts +74 -4
  4. package/client/modeller/data-interface.ts +14 -0
  5. package/client/modeller/editor-styles.ts +11 -0
  6. package/client/modeller/editor.ts +39 -11
  7. package/client/modeller/fasten-form.ts +73 -3
  8. package/client/modeller/fasten-geometry.ts +11 -1
  9. package/client/modeller/figure-cavity-drawing.ts +1 -3
  10. package/client/modeller/figure-clips.ts +101 -8
  11. package/client/modeller/figure-fasten-dialog.ts +41 -30
  12. package/client/modeller/figure-fasten-drawing.ts +5 -4
  13. package/client/modeller/figure-first-save.ts +4 -3
  14. package/client/modeller/figure-links.ts +85 -28
  15. package/client/modeller/figure-preview.ts +50 -4
  16. package/client/modeller/figure-size-editor.ts +7 -2
  17. package/client/modeller/first-save.ts +8 -0
  18. package/client/modeller/focus-on-render.ts +4 -0
  19. package/client/modeller/palette.ts +27 -0
  20. package/client/modeller/pivot.ts +76 -0
  21. package/client/modeller/product-session.ts +5 -1
  22. package/client/modeller/size-response.ts +14 -0
  23. package/client/modeller/stage-model.ts +50 -6
  24. package/client/types.ts +2 -0
  25. package/dist-client/graphql/index.js +2 -0
  26. package/dist-client/graphql/index.js.map +1 -1
  27. package/dist-client/modeller/canvas.d.ts +1 -0
  28. package/dist-client/modeller/canvas.js +3 -2
  29. package/dist-client/modeller/canvas.js.map +1 -1
  30. package/dist-client/modeller/clips.d.ts +23 -6
  31. package/dist-client/modeller/clips.js +69 -4
  32. package/dist-client/modeller/clips.js.map +1 -1
  33. package/dist-client/modeller/data-interface.d.ts +5 -0
  34. package/dist-client/modeller/data-interface.js +15 -0
  35. package/dist-client/modeller/data-interface.js.map +1 -1
  36. package/dist-client/modeller/editor-styles.js +11 -0
  37. package/dist-client/modeller/editor-styles.js.map +1 -1
  38. package/dist-client/modeller/editor.d.ts +6 -0
  39. package/dist-client/modeller/editor.js +35 -11
  40. package/dist-client/modeller/editor.js.map +1 -1
  41. package/dist-client/modeller/fasten-form.d.ts +8 -0
  42. package/dist-client/modeller/fasten-form.js +68 -5
  43. package/dist-client/modeller/fasten-form.js.map +1 -1
  44. package/dist-client/modeller/fasten-geometry.d.ts +6 -0
  45. package/dist-client/modeller/fasten-geometry.js +10 -1
  46. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  47. package/dist-client/modeller/figure-cavity-drawing.js +1 -3
  48. package/dist-client/modeller/figure-cavity-drawing.js.map +1 -1
  49. package/dist-client/modeller/figure-clips.d.ts +9 -0
  50. package/dist-client/modeller/figure-clips.js +104 -8
  51. package/dist-client/modeller/figure-clips.js.map +1 -1
  52. package/dist-client/modeller/figure-fasten-dialog.js +39 -24
  53. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  54. package/dist-client/modeller/figure-fasten-drawing.js +5 -4
  55. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  56. package/dist-client/modeller/figure-first-save.js +4 -3
  57. package/dist-client/modeller/figure-first-save.js.map +1 -1
  58. package/dist-client/modeller/figure-links.d.ts +9 -0
  59. package/dist-client/modeller/figure-links.js +89 -35
  60. package/dist-client/modeller/figure-links.js.map +1 -1
  61. package/dist-client/modeller/figure-preview.js +48 -4
  62. package/dist-client/modeller/figure-preview.js.map +1 -1
  63. package/dist-client/modeller/figure-size-editor.js +7 -3
  64. package/dist-client/modeller/figure-size-editor.js.map +1 -1
  65. package/dist-client/modeller/first-save.d.ts +5 -0
  66. package/dist-client/modeller/first-save.js +7 -0
  67. package/dist-client/modeller/first-save.js.map +1 -1
  68. package/dist-client/modeller/focus-on-render.d.ts +2 -0
  69. package/dist-client/modeller/focus-on-render.js +4 -0
  70. package/dist-client/modeller/focus-on-render.js.map +1 -0
  71. package/dist-client/modeller/links.d.ts +1 -1
  72. package/dist-client/modeller/palette.d.ts +14 -0
  73. package/dist-client/modeller/palette.js +23 -0
  74. package/dist-client/modeller/palette.js.map +1 -1
  75. package/dist-client/modeller/pivot.d.ts +12 -0
  76. package/dist-client/modeller/pivot.js +59 -0
  77. package/dist-client/modeller/pivot.js.map +1 -0
  78. package/dist-client/modeller/product-session.js +6 -2
  79. package/dist-client/modeller/product-session.js.map +1 -1
  80. package/dist-client/modeller/size-response.d.ts +6 -0
  81. package/dist-client/modeller/size-response.js +14 -0
  82. package/dist-client/modeller/size-response.js.map +1 -1
  83. package/dist-client/modeller/stage-model.d.ts +17 -3
  84. package/dist-client/modeller/stage-model.js +44 -6
  85. package/dist-client/modeller/stage-model.js.map +1 -1
  86. package/dist-client/route.d.ts +1 -1
  87. package/dist-client/tsconfig.tsbuildinfo +1 -1
  88. package/dist-client/types.d.ts +2 -0
  89. package/dist-client/types.js.map +1 -1
  90. package/package.json +5 -5
  91. package/test/browser/editing.check.mjs +6 -5
  92. package/test/browser/section.check.mjs +21 -0
  93. package/test/clips.test.ts +49 -4
  94. package/test/data-motion.test.ts +8 -0
  95. package/test/fasten-form.test.ts +57 -0
  96. package/test/fasten-geometry.test.ts +25 -1
  97. package/test/first-part-on-plane.test.ts +52 -0
  98. package/test/first-save.test.ts +11 -1
  99. package/test/pivot.test.ts +57 -0
  100. package/test/save-landed.test.ts +20 -1
  101. package/test/size-response.test.ts +10 -2
  102. package/test/stage-model.test.ts +23 -1
  103. package/translations/en.json +9 -0
  104. package/translations/es.json +9 -0
  105. package/translations/ja.json +9 -0
  106. package/translations/ko.json +9 -0
  107. package/translations/ms.json +9 -0
  108. package/translations/zh.json +9 -0
  109. package/tutorial/humanoid/README.md +22 -0
  110. package/tutorial/humanoid/chapters.mjs +247 -0
  111. package/tutorial/humanoid/encode.mjs +26 -0
  112. package/tutorial/humanoid/lib.mjs +197 -0
  113. package/tutorial/humanoid/record.mjs +65 -0
@@ -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()
@@ -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
  }
@@ -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
- session.detachAsCopy()
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 part = paletteRecipe(kind, figurePartIdFor(this.session!.revisionNow()!.asset, kind))
570
- this.session!.add(part)
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 => renderGateFinding(finding, true, nothing, parts))}
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) return freshFasten()
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
- ? { ...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
+ }
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
- 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) }
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
- if (Math.abs(d - at) <= catchMm && inside > 0) return edges ? { how, caught: 'inside' } : { how: 'mm', mm: Math.round(at), caught: 'inside' }
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.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) {