@things-factory/figure-ui 10.1.114 → 10.1.116

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 (71) hide show
  1. package/client/graphql/index.ts +6 -1
  2. package/client/modeller/canvas.ts +6 -0
  3. package/client/modeller/editor.ts +4 -9
  4. package/client/modeller/figure-links.ts +20 -7
  5. package/client/modeller/figure-preview.ts +34 -10
  6. package/client/modeller/joint-drag.ts +3 -3
  7. package/client/modeller/links.ts +28 -11
  8. package/client/modeller/preview-model.ts +8 -0
  9. package/client/modeller/recipes.ts +6 -1
  10. package/client/pages/figure-list-page.ts +4 -4
  11. package/client/pages/figure-modeller-page.ts +7 -1
  12. package/client/types.ts +1 -1
  13. package/client/viewparts/detail-motion.ts +11 -0
  14. package/client/viewparts/figure-asset-viewer.ts +177 -0
  15. package/client/viewparts/figure-detail-dock.ts +187 -0
  16. package/dist-client/graphql/index.d.ts +5 -0
  17. package/dist-client/graphql/index.js +5 -1
  18. package/dist-client/graphql/index.js.map +1 -1
  19. package/dist-client/modeller/canvas.js +7 -0
  20. package/dist-client/modeller/canvas.js.map +1 -1
  21. package/dist-client/modeller/clips.d.ts +1 -1
  22. package/dist-client/modeller/editor.d.ts +3 -2
  23. package/dist-client/modeller/editor.js +4 -9
  24. package/dist-client/modeller/editor.js.map +1 -1
  25. package/dist-client/modeller/figure-links.js +35 -22
  26. package/dist-client/modeller/figure-links.js.map +1 -1
  27. package/dist-client/modeller/figure-preview.d.ts +2 -0
  28. package/dist-client/modeller/figure-preview.js +38 -10
  29. package/dist-client/modeller/figure-preview.js.map +1 -1
  30. package/dist-client/modeller/joint-drag.js +3 -3
  31. package/dist-client/modeller/joint-drag.js.map +1 -1
  32. package/dist-client/modeller/links.d.ts +18 -3
  33. package/dist-client/modeller/links.js +21 -10
  34. package/dist-client/modeller/links.js.map +1 -1
  35. package/dist-client/modeller/preview-model.d.ts +5 -0
  36. package/dist-client/modeller/preview-model.js +7 -0
  37. package/dist-client/modeller/preview-model.js.map +1 -1
  38. package/dist-client/modeller/recipes.js +5 -1
  39. package/dist-client/modeller/recipes.js.map +1 -1
  40. package/dist-client/pages/figure-list-page.d.ts +2 -3
  41. package/dist-client/pages/figure-list-page.js +4 -4
  42. package/dist-client/pages/figure-list-page.js.map +1 -1
  43. package/dist-client/pages/figure-modeller-page.d.ts +1 -0
  44. package/dist-client/pages/figure-modeller-page.js +7 -1
  45. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  46. package/dist-client/route.d.ts +1 -1
  47. package/dist-client/tsconfig.tsbuildinfo +1 -1
  48. package/dist-client/types.d.ts +1 -1
  49. package/dist-client/types.js.map +1 -1
  50. package/dist-client/viewparts/detail-motion.d.ts +6 -0
  51. package/dist-client/viewparts/detail-motion.js +9 -0
  52. package/dist-client/viewparts/detail-motion.js.map +1 -0
  53. package/dist-client/viewparts/figure-asset-viewer.d.ts +31 -0
  54. package/dist-client/viewparts/figure-asset-viewer.js +198 -0
  55. package/dist-client/viewparts/figure-asset-viewer.js.map +1 -0
  56. package/dist-client/viewparts/figure-detail-dock.d.ts +36 -0
  57. package/dist-client/viewparts/figure-detail-dock.js +224 -0
  58. package/dist-client/viewparts/figure-detail-dock.js.map +1 -0
  59. package/package.json +6 -6
  60. package/test/browser/detail-page.js +34 -0
  61. package/test/browser/serve-detail.mjs +10 -0
  62. package/test/detail-motion.test.ts +26 -0
  63. package/test/links.test.ts +20 -1
  64. package/test/modeller-route.test.ts +12 -2
  65. package/test/preview.test.ts +39 -1
  66. package/test/verdict.test.ts +3 -1
  67. package/translations/en.json +19 -1
  68. package/translations/ja.json +19 -1
  69. package/translations/ko.json +19 -1
  70. package/translations/ms.json +19 -1
  71. package/translations/zh.json +19 -1
@@ -383,6 +383,11 @@ export async function inspectFigureDraft(source: string, id?: string): Promise<F
383
383
  * 전 초안은 서버가 모르므로 판정도 화면에서 한다 — 형식의 `contractChanges` 를 같이 쓴다.
384
384
  */
385
385
  export async function fetchReleasedSource(id: string): Promise<string | undefined> {
386
+ return (await fetchReleasedFigure(id))?.source
387
+ }
388
+
389
+ /** Read-only catalogue preview: source and version come from the same released snapshot. */
390
+ export async function fetchReleasedFigure(id: string): Promise<{ version: number; source: string } | undefined> {
386
391
  const response = await client.query({
387
392
  query: gql`
388
393
  query FigureReleased($id: String!) {
@@ -396,7 +401,7 @@ export async function fetchReleasedSource(id: string): Promise<string | undefine
396
401
  fetchPolicy: 'network-only'
397
402
  })
398
403
 
399
- return unwrap<{ source?: string }>(response, 'figureReleased')?.source ?? undefined
404
+ return unwrap<{ version: number; source: string } | undefined>(response, 'figureReleased') ?? undefined
400
405
  }
401
406
 
402
407
  export async function fetchFigureVersions(id: string): Promise<FigureVersion[]> {
@@ -280,6 +280,12 @@ export class FigureCanvas extends LitElement {
280
280
  const root = layer?.root
281
281
  if (!layer || !root?.on || this.viewOnly) return
282
282
  root.on('selected', (after: unknown) => {
283
+ /*
284
+ A canvas taken off the screen disposes its scene, and the scene empties its selection as it goes. Told out,
285
+ that emptied the editor's choice whenever the middle turned to the preview (U03): the listener lit set on this
286
+ element hears it even after it left the page. A canvas not on the page has nothing to say about the choice.
287
+ */
288
+ if (!this.isConnected) return
283
289
  const name = (after as SceneComponent[] | undefined)?.[0]?.state?.name
284
290
  const id = typeof name === 'string' ? name : ''
285
291
  if (ownerOf(id) !== this.selected) this.dispatchEvent(new CustomEvent('part-selected', { detail: id, bubbles: true, composed: true }))
@@ -1807,8 +1807,9 @@ export class FigureEditor extends LitElement {
1807
1807
  }
1808
1808
  }
1809
1809
  /**
1810
- * A new figure's first question, on the canvas where the work will be (designer 2026-09-27, U04): a basic shape, a
1811
- * recipe, or an AI draft when the page offers one. Nothing is added until one is chosen; the type, the space, the
1810
+ * A new figure's first question, on the canvas where the work will be (designer 2026-09-27, U04): a basic shape
1811
+ * or an AI draft when the page offers one. Recipes require a host part and are reached from the parts panel after
1812
+ * that part exists. Nothing is added until one is chosen; the type, the space, the
1812
1813
  * mounting face and the tags are not asked for first.
1813
1814
  */
1814
1815
  /** The section editor over the canvas, the figure behind it drawing what the working points make. */
@@ -1886,13 +1887,6 @@ export class FigureEditor extends LitElement {
1886
1887
  </div>
1887
1888
  <small>${i18next.t('figure.text.start-shape-more')}</small>
1888
1889
  </section>
1889
- <section>
1890
- <h3>${i18next.t('figure.label.start-recipe')}</h3>
1891
- <button start-button start-recipe ?disabled=${!this.templates.length} @click=${() => ((this.catalogOpen = true), (this.recipeError = ''))}>
1892
- ${i18next.t('figure.button.start-recipe')}
1893
- </button>
1894
- ${this.templates.length ? nothing : html`<small>${i18next.t('figure.text.start-no-recipes')}</small>`}
1895
- </section>
1896
1890
  ${this.aiDraft
1897
1891
  ? html`<section>
1898
1892
  <h3>${i18next.t('figure.label.start-ai')}</h3>
@@ -2046,6 +2040,7 @@ export class FigureEditor extends LitElement {
2046
2040
  .asset=${asset}
2047
2041
  .context=${view.context ?? ''}
2048
2042
  .view=${this.view}
2043
+ .selected=${this.selected}
2049
2044
  @configure-size=${async (e: CustomEvent<{ part: string }>) => {
2050
2045
  this.middle = 'edit'
2051
2046
  this.selected = e.detail.part
@@ -864,8 +864,10 @@ export class FigureLinks extends localize(i18next)(LitElement) {
864
864
  ${now.travel
865
865
  ? html`<small>${i18next.t('figure.label.motion-travel-is', { part: now.travel.part, axis: now.travel.axis.toUpperCase(), plus: now.travel.plus ?? 0 })}</small>`
866
866
  : nothing}
867
- ${now.follows
868
- ? html`<small>${i18next.t('figure.label.motion-follows', { joint: now.follows.joint, multiplier: now.follows.multiplier, offset: now.follows.offset ?? 0 })}</small>`
867
+ ${now.follows?.sum.length === 1
868
+ ? html`<small>${i18next.t('figure.label.motion-follows', { joint: now.follows.sum[0]!.joint, multiplier: now.follows.sum[0]!.multiplier, offset: now.follows.offset })}</small>`
869
+ : now.follows
870
+ ? html`<small>${i18next.t('figure.label.motion-follow')}: ${now.follows.sum.map(t => `${t.joint} × ${t.multiplier}`).join(' + ')} + ${now.follows.offset}</small>`
869
871
  : now.control
870
872
  ? html`<small>${now.control.id} (${now.control.unit}) ${now.control.min} … ${now.control.max}</small>`
871
873
  : nothing}
@@ -912,7 +914,14 @@ export class FigureLinks extends localize(i18next)(LitElement) {
912
914
  ${AXES.map(a => html`<option value=${a} ?selected=${a === m.axis}>${i18next.t('figure.label.motion-axis')} ${a.toUpperCase()}</option>`)}
913
915
  </select>
914
916
  </div>
915
- ${leaders.length
917
+ ${m.followSum
918
+ ? html`<div form>
919
+ <span>${i18next.t('figure.label.motion-follow')}</span>
920
+ <span>${m.followSum.map(t => `${t.joint} × ${t.multiplier}`).join(' + ')}</span>
921
+ <span>${i18next.t('figure.label.motion-offset')} (${unit})</span>
922
+ <input type="number" step="any" .value=${String(m.offset)} @change=${(e: Event) => (this.motion = { ...m, offset: Number((e.target as HTMLInputElement).value) || 0 })} />
923
+ </div>`
924
+ : leaders.length
916
925
  ? html`<div form>
917
926
  <span>${i18next.t('figure.label.motion-follow')}</span>
918
927
  <select @change=${(e: Event) => (this.motion = { ...m, follow: (e.target as HTMLSelectElement).value })}>
@@ -921,7 +930,9 @@ export class FigureLinks extends localize(i18next)(LitElement) {
921
930
  </select>
922
931
  </div>`
923
932
  : nothing}
924
- ${follow
933
+ ${m.followSum
934
+ ? nothing
935
+ : follow
925
936
  ? html`<div form>
926
937
  <span>${i18next.t('figure.label.motion-multiplier')}</span>
927
938
  <input type="number" step="any" .value=${String(m.multiplier)} @change=${(e: Event) => (this.motion = { ...m, multiplier: Number((e.target as HTMLInputElement).value) })} />
@@ -932,7 +943,9 @@ export class FigureLinks extends localize(i18next)(LitElement) {
932
943
  ${m.kind === 'slide'
933
944
  ? html`<span>${i18next.t('figure.label.motion-travel')}</span>
934
945
  <select @change=${(e: Event) => (this.motion = { ...m, travel: (e.target as HTMLSelectElement).value })}>
935
- <option value="" ?selected=${!m.travel}>${i18next.t('figure.label.motion-travel-mm')}</option>
946
+ ${m.travelSum
947
+ ? html`<option value="" ?selected=${!m.travel}>${m.travelSum.sum.map(t => `${t.from.of === 'input' ? t.from.input : `${t.from.of === 'part' ? t.from.part : i18next.t('figure.label.motion-travel')} · ${t.from.axis.toUpperCase()}`}${t.times !== undefined && t.times !== 1 ? ` × ${t.times}` : ''}`).join(' + ')}</option>`
948
+ : html`<option value="" ?selected=${!m.travel}>${i18next.t('figure.label.motion-travel-mm')}</option>`}
936
949
  ${asset.appearance
937
950
  .map(look => look.target)
938
951
  .filter(one => canFollow(asset, one))
@@ -945,7 +958,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
945
958
  : nothing}
946
959
  <span>${i18next.t('figure.label.control-id')}</span>
947
960
  <input .value=${m.id} placeholder=${`${this.partId}-${m.kind}`} @change=${(e: Event) => (this.motion = { ...m, id: (e.target as HTMLInputElement).value.trim() })} />
948
- ${m.kind === 'continuous' || (m.kind === 'slide' && m.travel)
961
+ ${m.kind === 'continuous' || (m.kind === 'slide' && (m.travel || m.travelSum))
949
962
  ? nothing
950
963
  : html`<span>${i18next.t('figure.label.control-range')} (${unit})</span>
951
964
  <div pair>
@@ -955,7 +968,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
955
968
  </div>`}
956
969
  <button
957
970
  act
958
- ?disabled=${this.locked || (follow ? !(Number.isFinite(m.multiplier) && m.multiplier !== 0) : m.kind !== 'continuous' && !(m.kind === 'slide' && m.travel) && !(m.max > m.min && m.min <= 0 && 0 <= m.max))}
971
+ ?disabled=${this.locked || (m.followSum ? false : follow ? !(Number.isFinite(m.multiplier) && m.multiplier !== 0) : m.kind !== 'continuous' && !(m.kind === 'slide' && (m.travel || m.travelSum)) && !(m.max > m.min && m.min <= 0 && 0 <= m.max))}
959
972
  title=${!follow && m.kind !== 'continuous' && !(m.min <= 0 && 0 <= m.max) ? i18next.t('figure.text.joint-range-holds-zero') : ''}
960
973
  @click=${() => {
961
974
  this.command({ kind: 'set-joint', joint: motionJoint(joint, { ...m, follow }, this.partId) })
@@ -8,7 +8,7 @@ import { create, registerFigure, unregisterFigure } from '@hatiolab/things-scene
8
8
 
9
9
  import { DEFAULT_VIEW, previewBoardDepth, sceneLook, WORKBENCH, type ViewSettings } from './figure-view.js'
10
10
  import { dialShape } from './angle-dial.js'
11
- import { authoredSizeOf, PREVIEW_FACTOR, PREVIEW_WAYS, previewClampsOf, previewInstances } from './preview-model.js'
11
+ import { authoredSizeOf, PREVIEW_FACTOR, PREVIEW_WAYS, previewClampsOf, previewInstances, setupPartOf } from './preview-model.js'
12
12
 
13
13
  /**
14
14
  * The figure placed on a board.
@@ -52,7 +52,25 @@ export class FigurePreview extends localize(i18next)(LitElement) {
52
52
  width: 100%;
53
53
  height: 100%;
54
54
  }
55
- div[setup] { position: absolute; bottom: 8px; left: 8px; max-width: min(320px, calc(100% - 16px)); padding: 12px; border-radius: 8px; background: rgba(28,28,30,.9); color: #fff; display: grid; gap: 8px; }
55
+ /*
56
+ The size help and the posing controls share the stage's bottom edge. Each stood in its own corner, so on a narrow
57
+ stage (the 1024 width, both side panels open) the controls lay over the help's button. One row that wraps keeps both
58
+ whole: side by side when there is room, the controls above the help when there is not.
59
+ */
60
+ div[foot] {
61
+ position: absolute;
62
+ left: 8px;
63
+ right: 8px;
64
+ bottom: 8px;
65
+ display: flex;
66
+ flex-wrap: wrap-reverse;
67
+ justify-content: space-between;
68
+ align-items: flex-end;
69
+ gap: 8px;
70
+ pointer-events: none;
71
+ }
72
+ div[foot] > * { pointer-events: auto; }
73
+ div[setup] { max-width: min(320px, 100%); padding: 12px; border-radius: 8px; background: rgba(28,28,30,.9); color: #fff; display: grid; gap: 8px; }
56
74
  div[setup] p { margin: 0; line-height: 1.5; }
57
75
  div[setup] select, div[setup] button { font: inherit; padding: 8px; min-width: 0; }
58
76
  /* Which is which: side by side means nothing without a legend. */
@@ -76,9 +94,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
76
94
  color: #ffcc80;
77
95
  }
78
96
  div[clips] {
79
- position: absolute;
80
- right: 8px;
81
- bottom: 8px;
97
+ margin-left: auto;
82
98
  display: flex;
83
99
  flex-direction: column;
84
100
  gap: 6px;
@@ -162,6 +178,8 @@ export class FigurePreview extends localize(i18next)(LitElement) {
162
178
  /** Clip speeds and control values given here -- the preview's, not the figure's. */
163
179
  @state() private driving: Record<string, number> = {}
164
180
  @state() private giving: Record<string, number> = {}
181
+ /** The part chosen in the editor. The size help starts on it, so the help speaks of the part the author was working on. */
182
+ @property() selected = ''
165
183
  @state() private setupPart = ''
166
184
 
167
185
  /** What is being tried here now -- clip speeds and control values -- for the AI dock to hear with a request; none when nothing is. */
@@ -186,6 +204,8 @@ export class FigurePreview extends localize(i18next)(LitElement) {
186
204
  } catch {
187
205
  clampsByWay = {}
188
206
  }
207
+ /* The part the help speaks of: the one picked in it, else the one chosen in the editor, else the first. */
208
+ const setupPart = setupPartOf(this.setupPart, this.selected, asset?.appearance.map(p => p.target) ?? [])
189
209
  return html`<div stage></div>
190
210
  ${this.cannot
191
211
  ? html`<div cannot>${this.cannot}</div>`
@@ -200,14 +220,14 @@ export class FigurePreview extends localize(i18next)(LitElement) {
200
220
  </span>`
201
221
  })}
202
222
  </div>
203
- ${asset?.appearance.length ? html`<div setup>
223
+ <div foot>${asset?.appearance.length ? html`<div setup>
204
224
  <p>${i18next.t('figure.text.preview-setup')}</p>
205
225
  <select aria-label=${i18next.t('figure.label.resize-part')} @change=${(e: Event) => this.setupPart = (e.target as HTMLSelectElement).value}>
206
- ${asset.appearance.map(p => html`<option value=${p.target} ?selected=${p.target === this.setupPart}>${p.target}</option>`)}
226
+ ${asset.appearance.map(p => html`<option value=${p.target} ?selected=${p.target === setupPart}>${p.target}</option>`)}
207
227
  </select>
208
- <button @click=${() => this.dispatchEvent(new CustomEvent('configure-size', { detail: { part: asset.appearance.some(p => p.target === this.setupPart) ? this.setupPart : asset.appearance[0]!.target }, bubbles: true, composed: true }))}>${i18next.t('figure.button.configure-size')}</button>
228
+ <button @click=${() => this.dispatchEvent(new CustomEvent('configure-size', { detail: { part: setupPart }, bubbles: true, composed: true }))}>${i18next.t('figure.button.configure-size')}</button>
209
229
  </div>` : nothing}
210
- ${controls.length + clips.length ? this.renderDrivers(asset!, controls, clips) : nothing}`}`
230
+ ${controls.length + clips.length ? this.renderDrivers(asset!, controls, clips) : nothing}</div>`}`
211
231
  }
212
232
 
213
233
  private renderDrivers(asset: FigureAsset, controls: FigureAsset['document']['model']['inputs'], clips: string[]) {
@@ -237,7 +257,11 @@ export class FigurePreview extends localize(i18next)(LitElement) {
237
257
  </label>`
238
258
  })}
239
259
  ${clips.map(clip => {
240
- const value = this.driving[clip] ?? 1
260
+ /*
261
+ A clip nothing has driven stands still, here as on a board: the instances get a speed only from `drive`. The
262
+ slider showed 1.00 over rollers that did not turn (2026-10-03). It shows what plays -- 0 until moved.
263
+ */
264
+ const value = this.driving[clip] ?? 0
241
265
  return html`<label>
242
266
  <span>${clip} <span drive>${i18next.t('figure.label.playback-speed')}</span></span>
243
267
  <span></span>
@@ -1,5 +1,5 @@
1
1
  import * as THREE from 'three'
2
- import { compileFigureAsset } from '@hatiolab/figure-model'
2
+ import { compileFigureAsset, figureMimicTermsOf } from '@hatiolab/figure-model'
3
3
  import type { FigureAsset, FigureJoint } from '@hatiolab/figure-model'
4
4
 
5
5
  import type { GizmoResult } from './gizmo.js'
@@ -56,7 +56,7 @@ export function jointDragOf(asset: FigureAsset, id: string, part: FigureRenderPa
56
56
 
57
57
  if (result.rotation) {
58
58
  if (!turns) return { kind: 'refused', reason: j.type === 'prismatic' ? 'turned-slide' : 'fixed-turn', joint: j.id }
59
- if (j.mimic) return { kind: 'refused', reason: 'mimic', joint: j.id, leader: j.mimic.joint }
59
+ if (j.mimic) return { kind: 'refused', reason: 'mimic', joint: j.id, leader: figureMimicTermsOf(j.mimic)!.sum.map(t => t.joint).join(', ') }
60
60
  /* The turn about the joint's axis (the twist of the change): the only turn the joint can make. */
61
61
  const before = new THREE.Quaternion().setFromRotationMatrix(matrixOf(part.pose.r as number[][]))
62
62
  const after = new THREE.Quaternion().setFromRotationMatrix(eulerMatrix(result.rotation))
@@ -77,7 +77,7 @@ export function jointDragOf(asset: FigureAsset, id: string, part: FigureRenderPa
77
77
  const frame = jointFrameRotation(j, part, turns ? now : 0)
78
78
 
79
79
  if (j.type === 'prismatic') {
80
- if (j.mimic) return { kind: 'refused', reason: 'mimic', joint: j.id, leader: j.mimic.joint }
80
+ if (j.mimic) return { kind: 'refused', reason: 'mimic', joint: j.id, leader: figureMimicTermsOf(j.mimic)!.sum.map(t => t.joint).join(', ') }
81
81
  /* How far one unit of the value moves the part along its axis, measured on the figure itself (a ratio travels a length). */
82
82
  const along = unit(j.axis!).applyMatrix4(frame).normalize()
83
83
  const at = (v: number) => {
@@ -1,4 +1,4 @@
1
- import { FIGURE_FACES, figureShapeFaces, figurePartsExtentOf, compileFigureAsset, compileFigureGraph, figureFollowOf, proposeFigureProportion, defaultInstanceSizeRange, foldInstanceSizeRange, figureAttachmentsOf, figureBindingsOf, figureInstanceSizeLimitsOf, figureFollowableAxesOf, figureShapeProviderOf } from '@hatiolab/figure-model'
1
+ import { FIGURE_FACES, figureShapeFaces, figurePartsExtentOf, compileFigureAsset, compileFigureGraph, figureFollowOf, proposeFigureProportion, defaultInstanceSizeRange, foldInstanceSizeRange, figureAttachmentsOf, figureBindingsOf, figureInstanceSizeLimitsOf, figureFollowableAxesOf, figureShapeProviderOf, figureMimicTermsOf } from '@hatiolab/figure-model'
2
2
  import type { FigureFollow, FigureSizeRange } from '@hatiolab/figure-model'
3
3
  import type { FigureAsset, FigureBindingPlace, FigureFace, FigureJoint } from '@hatiolab/figure-model'
4
4
  import { startingShapeKindOf } from './starting-shape.js'
@@ -458,7 +458,8 @@ export interface Motion {
458
458
  axis: Axis | '?'
459
459
  control?: { id: string; unit?: string; min?: number; max?: number }
460
460
  /** A joint that moves with another (URDF's mimic): the leader's value times `multiplier`, plus `offset`. */
461
- follows?: { joint: string; multiplier: number; offset?: number }
461
+ /** What it follows, as a sum of leaders each with its multiplier, plus an offset (one leader is a sum of one). */
462
+ follows?: { sum: Array<{ joint: string; multiplier: number }>; offset: number }
462
463
  /** A slide whose distance is a part's length (plus a difference): the control is its share. */
463
464
  travel?: { part: string; axis: string; plus?: number }
464
465
  }
@@ -487,8 +488,9 @@ export function motionOf(asset: FigureAsset, part: string): Motion | undefined {
487
488
  }
488
489
  return undefined
489
490
  }
490
- const travel = record?.travel?.source && 'part' in record.travel.source ? { part: record.travel.source.part, axis: record.travel.source.axis, ...(record.travel.plus ? { plus: record.travel.plus } : {}) } : undefined
491
- return { kind, axis, control: control(quantity), ...(record?.mimic ? { follows: record.mimic } : {}), ...(travel ? { travel } : {}) }
491
+ const one = record?.travel && 'source' in record.travel ? record.travel : undefined
492
+ const travel = one?.source && 'part' in one.source ? { part: one.source.part, axis: one.source.axis, ...(one.plus ? { plus: one.plus } : {}) } : undefined
493
+ return { kind, axis, control: control(quantity), ...(record?.mimic ? { follows: figureMimicTermsOf(record.mimic)! } : {}), ...(travel ? { travel } : {}) }
492
494
  }
493
495
 
494
496
  /** What the motion form holds: how the part moves, and either its own control or the joint it moves with. */
@@ -508,6 +510,13 @@ export interface MotionForm {
508
510
  */
509
511
  travel?: string
510
512
  travelPlus?: number
513
+ /**
514
+ * Kept as they are and not edited here: a follower of several leaders (`{ sum, offset }`, the offset still in
515
+ * `offset`) and a stroke that is a sum of lengths. The form says them and writes them back unchanged, so saving
516
+ * a range or an axis does not turn a follower into a joint of its own or drop its stroke.
517
+ */
518
+ followSum?: Array<{ joint: string; multiplier: number }>
519
+ travelSum?: Extract<NonNullable<FigureJoint['travel']>, { sum: unknown }>
511
520
  }
512
521
 
513
522
  /**
@@ -517,18 +526,24 @@ export interface MotionForm {
517
526
  */
518
527
  export function motionFormOf(joint: FigureJoint): MotionForm {
519
528
  const kind: MotionForm['kind'] = joint.type === 'prismatic' ? 'slide' : joint.lowerLimit === undefined && joint.upperLimit === undefined && !joint.mimic ? 'continuous' : 'turn'
520
- const travel = joint.travel?.source && 'part' in joint.travel.source ? `${joint.travel.source.part}:${joint.travel.source.axis}` : ''
529
+ const one = joint.travel && 'source' in joint.travel ? joint.travel : undefined
530
+ const travel = one?.source && 'part' in one.source ? `${one.source.part}:${one.source.axis}` : ''
531
+ const travelSum = joint.travel && 'sum' in joint.travel ? joint.travel : undefined
532
+ const terms = figureMimicTermsOf(joint.mimic)
533
+ const single = terms?.sum.length === 1 ? terms.sum[0] : undefined
521
534
  return {
522
535
  kind,
523
536
  axis: (joint.axis ?? 'X').toLowerCase() as Axis,
524
537
  id: joint.state?.id ?? '',
525
- min: travel ? 0 : (joint.lowerLimit ?? (kind === 'slide' ? 0 : -90)),
526
- max: travel ? 500 : (joint.upperLimit ?? (kind === 'slide' ? 500 : 90)),
527
- follow: joint.mimic?.joint ?? '',
528
- multiplier: joint.mimic?.multiplier ?? 1,
529
- offset: joint.mimic?.offset ?? 0,
538
+ min: travel || travelSum ? 0 : (joint.lowerLimit ?? (kind === 'slide' ? 0 : -90)),
539
+ max: travel || travelSum ? 500 : (joint.upperLimit ?? (kind === 'slide' ? 500 : 90)),
540
+ follow: single?.joint ?? '',
541
+ multiplier: single?.multiplier ?? 1,
542
+ offset: terms?.offset ?? 0,
530
543
  travel,
531
- travelPlus: joint.travel?.plus ?? 0
544
+ travelPlus: one?.plus ?? 0,
545
+ ...(terms && !single ? { followSum: terms.sum } : {}),
546
+ ...(travelSum ? { travelSum } : {})
532
547
  }
533
548
  }
534
549
 
@@ -557,6 +572,8 @@ export function motionJoint(joint: FigureJoint, m: MotionForm, part: string): Fi
557
572
  axis: m.axis.toUpperCase() as 'X' | 'Y' | 'Z'
558
573
  }
559
574
  if (m.follow) return { ...moving, mimic: { joint: m.follow, multiplier: m.multiplier, ...(m.offset ? { offset: m.offset } : {}) } }
575
+ if (m.followSum) return { ...moving, mimic: { sum: m.followSum, ...(m.offset ? { offset: m.offset } : {}) } }
576
+ if (m.kind === 'slide' && m.travelSum && !m.travel) return { ...moving, travel: m.travelSum, state: { id: m.id || `${part}-${m.kind}` } }
560
577
  if (m.kind === 'slide' && m.travel) {
561
578
  const [of, axis] = m.travel.split(':')
562
579
  return { ...moving, travel: { source: { of: 'part', part: of!, axis: axis as Axis }, ...(m.travelPlus ? { plus: m.travelPlus } : {}) }, state: { id: m.id || `${part}-${m.kind}` } }
@@ -74,3 +74,11 @@ export function previewClampsOf(asset: FigureAsset, size: { x: number; y: number
74
74
  ])
75
75
  )
76
76
  }
77
+
78
+ /**
79
+ * The part the preview's size help speaks of: the one picked in the help, else the one chosen in the editor, else the
80
+ * first that has a size rule. A choice that is not among the parts (removed, or a part with no rule) is passed over.
81
+ */
82
+ export function setupPartOf(picked: string, chosen: string, targets: string[]): string {
83
+ return [picked, chosen].find(id => !!id && targets.includes(id)) ?? targets[0] ?? ''
84
+ }
@@ -39,7 +39,7 @@ export function recipeWorkings(recipe: FigureRecipe): Array<{ key: string; words
39
39
  for (const m of d.moves)
40
40
  lines.push(
41
41
  m.follows
42
- ? { key: 'figure.text.recipe-moves-with', words: { part: recipePartName(m.part), leader: recipePartName(m.follows) } }
42
+ ? { key: 'figure.text.recipe-moves-with', words: { part: recipePartName(m.part), leader: recipeLeaders(m.follows).map(recipePartName).join(', ') } }
43
43
  : { key: m.kind === 'slide' ? 'figure.text.recipe-slides' : 'figure.text.recipe-turns', words: { part: recipePartName(m.part) } }
44
44
  )
45
45
  if (d.controls.length) lines.push({ key: 'figure.text.recipe-controls', words: { count: d.controls.length } })
@@ -48,6 +48,11 @@ export function recipeWorkings(recipe: FigureRecipe): Array<{ key: string; words
48
48
  return lines
49
49
  }
50
50
 
51
+ /** Released kernels return one leader; newer kernels can return an affine sum. */
52
+ function recipeLeaders(follows: string | { sum: Array<{ joint: string }> }): string[] {
53
+ return typeof follows === 'string' ? [follows] : follows.sum.map(term => term.joint)
54
+ }
55
+
51
56
  /** A recipe's words in the language on screen: that language, else English, else the first it has. */
52
57
  export function recipeWord(words: Record<string, string> | undefined, language: string): string {
53
58
  if (!words) return ''
@@ -16,6 +16,7 @@ import { capacityFrom } from '../modeller/scene-budget.js'
16
16
  import { deleteFigure, fetchFigureList } from '../graphql/index.js'
17
17
  import type { Figure } from '../types.js'
18
18
  import { modellerPathOf } from '../modeller/modeller-route.js'
19
+ import { openFigureDetail } from '../viewparts/figure-detail-dock.js'
19
20
 
20
21
  /**
21
22
  * 이 값 아래면 점수를 색으로 눈에 띄게 한다.
@@ -62,9 +63,8 @@ const FigureListPageBase = p13n(localize(i18next)(PageView))
62
63
  /**
63
64
  * Figure 카탈로그.
64
65
  *
65
- * 이 화면은 저작면으로 가는 문이다. 여기서 오래 머물 화면이 아니므로 꾸미지 않는다. 카드에는
66
- * **고를 때 쓰는 것만** 싣는다 — 그림과 이름, 그리고 아직 초안이라는 것. 배치 한도·성숙도·삼각형
67
- * 수는 고른 뒤에 따지는 값이라 표 모드의 칸으로 남기고 저작면에서 본다.
66
+ * 카드를 고르면 상세 패널에서 저장본과 발행판을 살펴본다. 크기와 움직임을 시험한 뒤
67
+ * 「편집 화면 열기」로 저작면에 들어간다. 목록 질의는 자산 원본을 싣지 않는다.
68
68
  *
69
69
  * ## 왜 손으로 만든 카드가 아니라 격자인가
70
70
  *
@@ -531,7 +531,7 @@ export class FigureListPage extends FigureListPageBase {
531
531
  handlers: {
532
532
  click: (_columns: any, _data: any, _column: any, record: Figure) => {
533
533
  if (record?.id) {
534
- this.openModeller(record)
534
+ openFigureDetail(record)
535
535
  }
536
536
  }
537
537
  }
@@ -472,8 +472,14 @@ export class FigureModellerPage extends PageView {
472
472
  return answer
473
473
  }
474
474
  get context() {
475
+ const view = this.session.read()
475
476
  return {
476
- title: this.session.read().name ?? 'Figure',
477
+ title: view.name ?? 'Figure',
478
+ /*
479
+ A figure that has an id was opened from the catalog (or saved there), so the rail marks the catalog. Without this
480
+ the rail matches the page name and marks "new figure" while an existing one is being edited.
481
+ */
482
+ menu: view.id ? 'figure-list' : undefined,
477
483
  actions: [],
478
484
  /*
479
485
  No heading line: the canvas is what this page is for, and the name is already in the properties' first section
package/client/types.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * 화면이 다루는 모양. 도형 자산의 타입은 `@hatiolab/figure-model` 이 정한다 — 여기서 다시 적지 않는다.
3
3
  */
4
4
 
5
- /** 서버가 돌려주는 Figure 한 벌. `source` 는 저작면에서만 받는다. */
5
+ /** 서버가 돌려주는 Figure 한 벌. `source` 는 상세 또는 저작면을 열 때만 받는다. */
6
6
  export interface Figure {
7
7
  id: string
8
8
  /** 컴포넌트 타입 이름. **만든 뒤에 고칠 수 없다.** */
@@ -0,0 +1,11 @@
1
+ import type { FigureAsset } from '@hatiolab/figure-model'
2
+
3
+ /** External values win over drivers; release only the controls owned by the chosen clip. */
4
+ export function parametersForClip(asset: FigureAsset, values: Record<string, number>, clip: string) {
5
+ const driven = new Set((asset.drivers ?? []).filter(d => d.clip === clip).map(d => d.state))
6
+ return Object.fromEntries(Object.entries(values).filter(([id]) => !driven.has(id)))
7
+ }
8
+
9
+ export function clipForControl(asset: FigureAsset, id: string) {
10
+ return asset.drivers?.find(d => d.state === id)?.clip
11
+ }
@@ -0,0 +1,177 @@
1
+ import { css, html, LitElement, nothing, type PropertyValues } from 'lit'
2
+ import { customElement, property, query, state } from 'lit/decorators.js'
3
+ import { i18next, localize } from '@operato/i18n'
4
+ import { compileFigureAsset, FIGURE_INSTANCE_SIZE_INPUTS, type FigureAsset } from '@hatiolab/figure-model'
5
+ import { create, registerFigure, unregisterFigure } from '@hatiolab/things-scene'
6
+ import { authoredSizeOf } from '../modeller/preview-model.js'
7
+ import { controlsOf } from '../modeller/controls.js'
8
+ import { DEFAULT_VIEW, previewBoardDepth, sceneLook } from '../modeller/figure-view.js'
9
+ import { clipForControl, parametersForClip } from './detail-motion.js'
10
+
11
+ type Axis = 'x' | 'y' | 'z'
12
+ interface Scene {
13
+ dispose?(): void
14
+ resize?(): void
15
+ fit?(type?: string): void
16
+ root?: {
17
+ fitCameraToModel?(): void
18
+ components?: Array<{ set(key: string, value: unknown): void }>
19
+ }
20
+ }
21
+
22
+ /** A single board instance. Sizes and poses are transient, and never change the asset or call a mutation. */
23
+ @customElement('figure-asset-viewer')
24
+ export class FigureAssetViewer extends localize(i18next)(LitElement) {
25
+ static styles = css`
26
+ :host { display: block; min-width: 0; }
27
+ [viewport] { height: 300px; position: relative; background: #858585; }
28
+ [stage] { position: absolute; inset: 0; width: 100%; height: 100%; }
29
+ section { padding: 12px 16px; border-bottom: 1px solid var(--md-sys-color-outline-variant); }
30
+ h3 { margin: 0 0 10px; font-size: var(--ui-t-body, 13px); }
31
+ label { display: grid; grid-template-columns: 72px minmax(0, 1fr) 90px; align-items: center; gap: 8px; margin: 8px 0; }
32
+ input { min-width: 0; width: 100%; box-sizing: border-box; }
33
+ input[type=number] { padding: 6px; }
34
+ input, button { font: inherit; color: inherit; }
35
+ button { padding: 8px 12px; border: 1px solid var(--md-sys-color-outline); border-radius: 8px; background: var(--md-sys-color-surface); cursor: pointer; }
36
+ [clips] { display: flex; flex-wrap: wrap; gap: 8px; }
37
+ p { font-size: 12px; line-height: 1.5; }
38
+ [error] { color: var(--md-sys-color-error); }
39
+ `
40
+ @property({ attribute: false }) asset?: FigureAsset
41
+ @query('[stage]') private stage!: HTMLElement
42
+ @state() private sizes: Record<Axis, number> = { x: 1, y: 1, z: 1 }
43
+ @state() private values: Record<string, number> = {}
44
+ @state() private playing = ''
45
+ @state() private error = ''
46
+ private scene?: Scene
47
+ private registered?: string
48
+ private observer?: ResizeObserver
49
+ private needsScene = false
50
+ private played = new Set<string>()
51
+
52
+ connectedCallback() {
53
+ super.connectedCallback()
54
+ this.needsScene = true
55
+ if (this.hasUpdated) this.observer?.observe(this.stage)
56
+ this.requestUpdate()
57
+ }
58
+
59
+ protected updated(changed: PropertyValues) {
60
+ // Reconnection and a new asset can arrive in the same update. Build exactly once.
61
+ if (changed.has('asset') || this.needsScene) {
62
+ this.needsScene = false
63
+ this.standUp()
64
+ }
65
+ }
66
+ firstUpdated() {
67
+ this.observer = new ResizeObserver(() => this.scene?.resize?.())
68
+ this.observer.observe(this.stage)
69
+ }
70
+ disconnectedCallback() {
71
+ super.disconnectedCallback()
72
+ this.observer?.disconnect()
73
+ this.teardown()
74
+ }
75
+
76
+ render() {
77
+ const asset = this.asset
78
+ return html`<div viewport><div stage></div></div>
79
+ ${this.error ? html`<p error role="alert">${this.error}</p>` : nothing}
80
+ ${asset ? html`<section>
81
+ <h3>${i18next.t('figure.label.detail-size')}</h3>
82
+ ${(['x', 'y', 'z'] as const).map(axis => {
83
+ const input = asset.document.model.inputs.find(input => input.id === FIGURE_INSTANCE_SIZE_INPUTS[axis])
84
+ return html`<label><span>${axis.toUpperCase()} · mm</span>
85
+ <input type="range" aria-label=${`${axis.toUpperCase()} mm`} min=${input?.min ?? this.sizes[axis]} max=${input?.max ?? this.sizes[axis]} step="any"
86
+ .value=${String(this.sizes[axis])} ?disabled=${!input || input.min === input.max}
87
+ @input=${(e: Event) => this.resize(axis, (e.target as HTMLInputElement).valueAsNumber)}>
88
+ <input type="number" aria-label=${`${axis.toUpperCase()} mm value`} min=${input?.min ?? this.sizes[axis]} max=${input?.max ?? this.sizes[axis]} step="any"
89
+ .value=${String(this.sizes[axis])} ?disabled=${!input || input.min === input.max}
90
+ @change=${(e: Event) => this.resize(axis, (e.target as HTMLInputElement).valueAsNumber)}>
91
+ </label>`
92
+ })}
93
+ <button @click=${() => this.reset()}>${i18next.t('figure.button.detail-reset')}</button>
94
+ <p>${i18next.t('figure.text.detail-transient')}</p>
95
+ </section>
96
+ <section><h3>${i18next.t('figure.label.motion')}</h3>
97
+ ${controlsOf(asset, this.values).map(c => html`<label><span>${c.label} · ${c.unit}</span>
98
+ <input type="range" aria-label=${c.label} min=${c.min} max=${c.max} step=${c.step || 'any'} .value=${String(c.value)}
99
+ ?disabled=${!!this.playing && clipForControl(asset, c.id) === this.playing}
100
+ @input=${(e: Event) => this.pose(c.id, (e.target as HTMLInputElement).valueAsNumber)}>
101
+ <output>${this.playing && clipForControl(asset, c.id) === this.playing ? i18next.t('figure.text.detail-playing')
102
+ : this.played.has(clipForControl(asset, c.id) ?? '') && !(c.id in this.values) ? i18next.t('figure.text.detail-paused') : Math.round(c.value * 100) / 100}</output></label>`)}
103
+ <div clips>${[...new Set((asset.drivers ?? []).map(d => d.clip))].map(name => html`<button aria-pressed=${String(this.playing === name)}
104
+ @click=${() => this.play(name)}>${this.playing === name ? '⏸' : '▶'} ${name}</button>`)}</div>
105
+ ${!controlsOf(asset).length && ![...new Set((asset.drivers ?? []).map(d => d.clip))].length ? html`<p>${i18next.t('figure.text.detail-no-motion')}</p>` : nothing}
106
+ </section>` : nothing}`
107
+ }
108
+
109
+ private tell(key: string, value: unknown) {
110
+ this.scene?.root?.components?.[0]?.set(key, value)
111
+ }
112
+ private resize(axis: Axis, value: number) {
113
+ const input = this.asset?.document.model.inputs.find(input => input.id === FIGURE_INSTANCE_SIZE_INPUTS[axis])
114
+ if (!input || !Number.isFinite(value)) { this.requestUpdate(); return }
115
+ const used = Math.min(input.max, Math.max(input.min, value))
116
+ this.sizes = { ...this.sizes, [axis]: used }
117
+ this.tell(axis === 'x' ? 'width' : axis === 'y' ? 'depth' : 'height', used)
118
+ this.scene?.root?.fitCameraToModel?.()
119
+ }
120
+ private pose(id: string, value: number) {
121
+ if (!Number.isFinite(value)) return
122
+ this.values = { ...this.values, [id]: value }
123
+ this.tell('parameters', this.values)
124
+ }
125
+ private play(name: string) {
126
+ this.playing = this.playing === name ? '' : name
127
+ if (this.playing && this.asset) {
128
+ this.played.add(name)
129
+ this.values = parametersForClip(this.asset, this.values, name)
130
+ this.tell('parameters', this.values)
131
+ }
132
+ this.tell('clips', Object.fromEntries([...new Set((this.asset?.drivers ?? []).map(d => d.clip))].map(key => [key, key === this.playing ? 1 : 0])))
133
+ }
134
+ private reset() {
135
+ if (!this.asset) return
136
+ // A new instance also resets driver clocks. Clearing speeds alone freezes the last pose.
137
+ this.standUp()
138
+ }
139
+ private standUp() {
140
+ this.teardown()
141
+ this.error = ''
142
+ const asset = this.asset
143
+ if (!asset) return
144
+ this.values = {}
145
+ this.playing = ''
146
+ this.played.clear()
147
+ try {
148
+ compileFigureAsset(asset)
149
+ if (!asset.appearance.length) throw Error(i18next.t('figure.text.preview-needs-a-part'))
150
+ this.sizes = authoredSizeOf(asset)
151
+ const type = `figure-detail-${crypto.randomUUID()}`
152
+ registerFigure(type, asset)
153
+ this.registered = type
154
+ const size = this.sizes
155
+ this.scene = create({ target: this.stage, mode: 0, model: {
156
+ type: 'model-layer', width: size.x * 2, height: size.z * 2,
157
+ depth: previewBoardDepth({ placement: asset.placement, base: { y: size.y } }, DEFAULT_VIEW.ceilingHeight, 1),
158
+ threed: true, sky: 'color', ...sceneLook(DEFAULT_VIEW),
159
+ placement: DEFAULT_VIEW.placement, heights: { floor: 0, ceiling: DEFAULT_VIEW.ceilingHeight },
160
+ pixelRatio: Math.min(3, (window.devicePixelRatio || 1) * 2),
161
+ components: [{ type, id: 'detail-instance', left: size.x / 2, top: size.z / 2, width: size.x, height: size.z, depth: size.y }]
162
+ } }) as Scene
163
+ this.scene.resize?.()
164
+ this.scene.fit?.('ratio')
165
+ this.scene.root?.fitCameraToModel?.()
166
+ } catch (error) {
167
+ this.error = (error as Error).message
168
+ this.teardown()
169
+ }
170
+ }
171
+ private teardown() {
172
+ this.scene?.dispose?.()
173
+ this.scene = undefined
174
+ if (this.registered) unregisterFigure(this.registered)
175
+ this.registered = undefined
176
+ }
177
+ }