@things-factory/figure-ui 10.1.115 → 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 (53) hide show
  1. package/client/graphql/index.ts +6 -1
  2. package/client/modeller/editor.ts +3 -9
  3. package/client/modeller/figure-links.ts +20 -7
  4. package/client/modeller/joint-drag.ts +3 -3
  5. package/client/modeller/links.ts +28 -11
  6. package/client/modeller/recipes.ts +6 -1
  7. package/client/pages/figure-list-page.ts +4 -4
  8. package/client/types.ts +1 -1
  9. package/client/viewparts/detail-motion.ts +11 -0
  10. package/client/viewparts/figure-asset-viewer.ts +177 -0
  11. package/client/viewparts/figure-detail-dock.ts +187 -0
  12. package/dist-client/graphql/index.d.ts +5 -0
  13. package/dist-client/graphql/index.js +5 -1
  14. package/dist-client/graphql/index.js.map +1 -1
  15. package/dist-client/modeller/editor.d.ts +3 -2
  16. package/dist-client/modeller/editor.js +3 -9
  17. package/dist-client/modeller/editor.js.map +1 -1
  18. package/dist-client/modeller/figure-links.js +35 -22
  19. package/dist-client/modeller/figure-links.js.map +1 -1
  20. package/dist-client/modeller/joint-drag.js +3 -3
  21. package/dist-client/modeller/joint-drag.js.map +1 -1
  22. package/dist-client/modeller/links.d.ts +18 -3
  23. package/dist-client/modeller/links.js +21 -10
  24. package/dist-client/modeller/links.js.map +1 -1
  25. package/dist-client/modeller/recipes.js +5 -1
  26. package/dist-client/modeller/recipes.js.map +1 -1
  27. package/dist-client/pages/figure-list-page.d.ts +2 -3
  28. package/dist-client/pages/figure-list-page.js +4 -4
  29. package/dist-client/pages/figure-list-page.js.map +1 -1
  30. package/dist-client/tsconfig.tsbuildinfo +1 -1
  31. package/dist-client/types.d.ts +1 -1
  32. package/dist-client/types.js.map +1 -1
  33. package/dist-client/viewparts/detail-motion.d.ts +6 -0
  34. package/dist-client/viewparts/detail-motion.js +9 -0
  35. package/dist-client/viewparts/detail-motion.js.map +1 -0
  36. package/dist-client/viewparts/figure-asset-viewer.d.ts +31 -0
  37. package/dist-client/viewparts/figure-asset-viewer.js +198 -0
  38. package/dist-client/viewparts/figure-asset-viewer.js.map +1 -0
  39. package/dist-client/viewparts/figure-detail-dock.d.ts +36 -0
  40. package/dist-client/viewparts/figure-detail-dock.js +224 -0
  41. package/dist-client/viewparts/figure-detail-dock.js.map +1 -0
  42. package/package.json +6 -6
  43. package/test/browser/detail-page.js +34 -0
  44. package/test/browser/serve-detail.mjs +10 -0
  45. package/test/detail-motion.test.ts +26 -0
  46. package/test/links.test.ts +20 -1
  47. package/test/modeller-route.test.ts +4 -2
  48. package/test/verdict.test.ts +3 -1
  49. package/translations/en.json +19 -1
  50. package/translations/ja.json +19 -1
  51. package/translations/ko.json +19 -1
  52. package/translations/ms.json +19 -1
  53. 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[]> {
@@ -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>
@@ -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) })
@@ -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}` } }
@@ -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
  }
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
+ }