@things-factory/figure-ui 10.1.263 → 10.1.266

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