@things-factory/figure-ui 10.1.17 → 10.1.19

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.
@@ -57,46 +57,17 @@ export const PREVIEW_WAYS: readonly { name: string; axes: readonly ('x' | 'y' |
57
57
  { name: 'x·y·z', axes: ['x', 'y', 'z'] }
58
58
  ]
59
59
 
60
- /**
61
- * 도형의 상자 **밑면**이 씬의 어느 높이에 오나.
62
- *
63
- * 배치 기준이 하는 일이 이것 하나다 — 좌표를 뒤집는 것이 아니라 **높이를 정하는 것.**
64
- * 바닥 기반은 땅에 서고, 천정 기반은 천장에서 제 키만큼 내려온 자리에 매달리고,
65
- * 중심 기반은 그 사이에 뜬다. `operato-scene` 의 archetype 이 같은 말을 한다
66
- * (`archetypes.ts`: ceiling — *zPos derived from ceiling minus depth*).
67
- */
68
- function bottomOf(placement: FigureSource['placement'], height: number, ceiling: number): number {
69
- switch (placement) {
70
- case 'ceiling':
71
- return ceiling - height
72
- case 'center':
73
- return (ceiling - height) / 2
74
- case 'floor':
75
- default:
76
- return 0
77
- }
78
- }
79
-
80
- /**
81
- * 밑면 높이를 씬이 받는 `zPos` 로 옮긴다.
60
+ /*
61
+ * 높이는 여기서 정한다.
82
62
  *
83
- * 씬은 부품 중심을 `zPos + geometricOffsetY` 놓고, 오프셋이 좌표 기준마다 다르다
84
- * (`real-object.ts`). 그래서 같은 밑면을 말하는 `zPos` 기준마다 다른 수다 — 바닥
85
- * 기준이면 밑면 그대로, 중심 기준이면 중심, 천장 기준이면 윗면.
63
+ * 한동안 배치 기준을 높이로 옮기는 계산을 파일이 들고 있었다. 이제 씬이 한다 —
64
+ * `FigureRealObject.effectiveZPos` 청사진의 `placement` 레이어의 `heights` 정한다
65
+ * (ADR-0045, `operato-twin/design/04-decisions.md`). 보드가 그렇게 세우므로 「보드에 놓았을
66
+ * 때의 모습」을 말하는 이 화면도 그렇게 세워야 한다 — 여기서 `zPos` 를 적으면 **적은 값이
67
+ * 이겨서**, 씬이 무엇을 할지가 이 화면에서만 안 보인다.
86
68
  *
87
- * **여기가 유일하게 좌표 기준을 아는 자리다.** 저작한 값에는 이것이 안 들어간다.
69
+ * 규칙이 벌이면 갈린다. 오늘 하루가 이야기였다.
88
70
  */
89
- function zPosOf(bottom: number, height: number, reference: ViewSettings['placement']): number {
90
- switch (reference) {
91
- case 'space':
92
- return bottom + height / 2
93
- case 'inverted':
94
- return bottom + height
95
- case 'floor':
96
- default:
97
- return bottom
98
- }
99
- }
100
71
 
101
72
  interface SceneHandle {
102
73
  dispose?: () => void
@@ -146,11 +117,11 @@ export class FigurePreview extends localize(i18next)(LitElement) {
146
117
  }
147
118
 
148
119
  /*
149
- Clip 조작. **저작자가 제 애니메이션을 만져 볼 유일한 자리다.**
120
+ 움직임 조작. **저작자가 제 애니메이션과 파라미터를 만져 볼 유일한 자리다.**
150
121
 
151
- 보드에서는 트윈이 clip 몬다 — 「닙이 40% 열렸다」는 사실이 어디선가 온다. 저작
152
- 중에는 그 사실을 주는 것이 없어서, 손대지 않으면 hold clip 은 0 에 머문 채
153
- 영영 안 움직인다. 포크가 안 올라가는 것을 저작자가 「안 되는구나」로 읽게 된다.
122
+ 도면에서는 트윈이 값을 준다 — 「닙이 40% 닫혔다」는 사실이 어디선가 온다. 저작
123
+ 중에는 그 사실을 주는 것이 없어서, 손대지 않으면 파라미터는 기본값에 머문 채 영영
124
+ 안 움직인다. 포크가 안 올라가는 것을 저작자가 「안 되는구나」로 읽게 된다.
154
125
 
155
126
  **미리보기 것이지 자산의 것이 아니다.** 여기서 민 값은 저장되지 않는다.
156
127
  */
@@ -223,11 +194,14 @@ export class FigurePreview extends localize(i18next)(LitElement) {
223
194
  private standing?: string
224
195
  private watcher?: ResizeObserver
225
196
 
226
- /** 지금 민 clip 값. clip 이름 → 수. **저장되지 않는다.** */
197
+ /** 지금 민 clip 배속. clip 이름 → 수. **저장되지 않는다.** */
227
198
  @state() private driving: Record<string, number> = {}
199
+ /** 지금 민 파라미터 값. 이름 → `range` 의 단위로 잰 수. **저장되지 않는다.** */
200
+ @state() private giving: Record<string, number> = {}
228
201
 
229
202
  render() {
230
203
  const clips = this.source?.animations ?? []
204
+ const parameters = this.source?.parameters ?? []
231
205
 
232
206
  return html`
233
207
  <div stage></div>
@@ -236,31 +210,54 @@ export class FigurePreview extends localize(i18next)(LitElement) {
236
210
  : html`<div legend>
237
211
  ${PREVIEW_WAYS.map(way => html`<span>${way.name}</span>`)}
238
212
  </div>
239
- ${clips.length > 0 ? this.renderClips(clips) : ''}`}
213
+ ${clips.length + parameters.length > 0 ? this.renderDrivers(clips, parameters) : ''}`}
240
214
  `
241
215
  }
242
216
 
243
217
  /**
244
- * Clip 하나에 손잡이 하나.
218
+ * 움직임 하나에 손잡이 하나.
245
219
  *
246
- * 수의 뜻이 `drive` 따라 다르므로 그것을 이름 옆에 적는다. `loop` 배속이라 0 이
247
- * 멈춤이고, `hold` 0~1 위치라 0 처음이다. 둘을 같은 눈금으로 두면 「1」이 무슨
248
- * 뜻인지 매번 헷갈린다.
220
+ * 갈래를 **눈금이 다른 채로** 낸다. clip 배속이라 0 멈춤이고 0~3 뜻이
221
+ * 있으며, 파라미터는 물리량이라 `range` 눈금이고 단위가 따라 붙는다. 하나의
222
+ * 0~1 로 뭉뚱그리면 「1」이 무슨 뜻인지 매번 헷갈리고, 1200mm 를 넣어 보려던 저작자가
223
+ * 넣을 자리를 못 찾는다.
249
224
  */
250
- private renderClips(clips: NonNullable<FigureSource['animations']>) {
225
+ private renderDrivers(
226
+ clips: NonNullable<FigureSource['animations']>,
227
+ parameters: NonNullable<FigureSource['parameters']>
228
+ ) {
251
229
  return html`
252
230
  <div clips>
231
+ ${parameters.map(parameter => {
232
+ const range = parameter.range
233
+ const value = this.giving[parameter.name] ?? parameter.default ?? range?.min ?? 0
234
+ const step = range ? Math.max((range.max - range.min) / 100, 0.01) : 1
235
+
236
+ return html`
237
+ <label>
238
+ <span>${parameter.label ?? parameter.name} <span drive>${range?.unit ?? ''}</span></span>
239
+ <input
240
+ type="range"
241
+ min=${range?.min ?? 0}
242
+ max=${range?.max ?? 1}
243
+ step=${step}
244
+ .value=${String(value)}
245
+ @input=${(e: Event) => this.give(parameter.name, Number((e.target as HTMLInputElement).value))}
246
+ />
247
+ <output>${Math.round(value)}</output>
248
+ </label>
249
+ `
250
+ })}
253
251
  ${clips.map(clip => {
254
- const loop = (clip.drive ?? 'loop') === 'loop'
255
- const value = this.driving[clip.name] ?? (loop ? 1 : 0)
252
+ const value = this.driving[clip.name] ?? 1
256
253
 
257
254
  return html`
258
255
  <label>
259
- <span>${clip.name} <span drive>${loop ? '배속' : '위치'}</span></span>
256
+ <span>${clip.name} <span drive>배속</span></span>
260
257
  <input
261
258
  type="range"
262
259
  min="0"
263
- max=${loop ? 3 : 1}
260
+ max="3"
264
261
  step="0.05"
265
262
  .value=${String(value)}
266
263
  @input=${(e: Event) => this.drive(clip.name, Number((e.target as HTMLInputElement).value))}
@@ -281,9 +278,23 @@ export class FigurePreview extends localize(i18next)(LitElement) {
281
278
  */
282
279
  private drive(name: string, value: number): void {
283
280
  this.driving = { ...this.driving, [name]: value }
281
+ this.tellInstances('clips', this.driving)
282
+ }
283
+
284
+ /**
285
+ * 파라미터 값을 준다 — **`range` 의 단위 그대로다.**
286
+ *
287
+ * 0~1 로 바꿔서 주지 않는다. 그 환산은 런타임의 일이고, 여기서 한 번 더 하면 두 곳이
288
+ * 갈릴 수 있다. 저작자가 화면에서 보는 1200 이 씬에 그대로 간다.
289
+ */
290
+ private give(name: string, value: number): void {
291
+ this.giving = { ...this.giving, [name]: value }
292
+ this.tellInstances('parameters', this.giving)
293
+ }
284
294
 
295
+ private tellInstances(key: 'clips' | 'parameters', values: Record<string, number>): void {
285
296
  for (const component of this.scene?.root?.components ?? []) {
286
- component.set?.('clips', { ...this.driving })
297
+ component.set?.(key, { ...values })
287
298
  }
288
299
  }
289
300
 
@@ -293,8 +304,8 @@ export class FigurePreview extends localize(i18next)(LitElement) {
293
304
  * 조명·환경·바탕은 씬이 스스로 반응한다(`three-capability` 의 `onchange`). 슬라이더를
294
305
  * 끄는 동안 다시 세우면 카메라가 매 프레임 처음으로 돌아간다.
295
306
  *
296
- * 좌표 기준과 천장 높이는 다르다. 그 둘은 컴포넌트의 `zPos` 만들 쓰였으므로,
297
- * 이미 세워 둔 것에 넣어 봐야 자리가 안 바뀐다. 그것만 다시 세운다.
307
+ * 좌표 기준과 천장 높이는 다르다. 씬이 둘로 인스턴스 높이를 정하는데, 계산은 세울
308
+ * 때 한 번 돈다 — 이미 세워 둔 것에 넣어 봐야 자리가 안 바뀐다. 그것만 다시 세운다.
298
309
  */
299
310
  willUpdate(changed: Map<string, unknown>) {
300
311
  if (!this.hasUpdated) return
@@ -344,7 +355,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
344
355
  }
345
356
 
346
357
  const view = this.view ?? DEFAULT_VIEW
347
- /* 좌표 기준과 천장 높이가 컴포넌트의 `zPos` 를 만든다 — 서명에 안 넣으면 다시 세워도 그대로 선다. */
358
+ /* 씬이 둘로 인스턴스 높이를 정한다 — 서명에 안 넣으면 다시 세워도 그대로 선다. */
348
359
  const signature = JSON.stringify([source, view.placement, view.ceilingHeight])
349
360
  if (this.scene && this.standing === signature) return
350
361
 
@@ -399,7 +410,6 @@ export class FigurePreview extends localize(i18next)(LitElement) {
399
410
  const width = Math.round(base.x * grow.x)
400
411
  const height = Math.round(base.z * grow.z)
401
412
  const tall = Math.round(base.y * grow.y)
402
- const bottom = bottomOf(source.placement, tall, view.ceilingHeight)
403
413
 
404
414
  return {
405
415
  type: source.type,
@@ -409,8 +419,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
409
419
  top: Math.round(row * cell.z + (cell.z - height) / 2),
410
420
  width,
411
421
  height,
412
- depth: tall,
413
- zPos: Math.round(zPosOf(bottom, tall, view.placement))
422
+ depth: tall
414
423
  }
415
424
  })
416
425
 
@@ -418,17 +427,15 @@ export class FigurePreview extends localize(i18next)(LitElement) {
418
427
  width: Math.round(cell.x * COLUMNS),
419
428
  height: Math.round(cell.z * rows),
420
429
  /*
421
- 판의 높이도 가장 큰 인스턴스를 담아야 한다.
430
+ 판의 높이도 가장 큰 인스턴스를 담아야 한다. 안 주면 카메라가 평면만 보고 자리를
431
+ 잡아서, 키가 큰 모델은 밑동만 보인다 — 시그널타워를 2 배로 놓으면 2200 인데 판이
432
+ 그것을 모르면 받침만 화면에 든다.
422
433
 
423
- 주면 카메라가 평면만 보고 자리를 잡아서, 키가 큰 모델은 밑동만 보인다.
424
- 시그널타워를 2 배로 놓으면 2200 인데 판이 그것을 모르면 받침만 화면에 든다.
425
-
426
- 가장 높은 **윗면**으로 잡는다. 천정 기반은 천장 높이에 매달리므로 제 키보다
427
- 훨씬 위에 있다 — 키만 보고 잡으면 천장에 걸린 것이 화면 밖으로 나간다.
434
+ 어디에 올려 놓을지는 **씬이 정하므로**(`FigureRealObject.effectiveZPos`), 여기서는
435
+ 어느 경우에도 담기는 경계만 잡는다. 바닥 기반은 가장 것의 키까지, 천정 기반은
436
+ 천장까지다. 배치를 높이로 옮기는 계산을 여기서 다시 쓰면 그것이 두 벌째가 된다.
428
437
  */
429
- depth: Math.round(
430
- Math.max(...components.map(c => bottomOf(source.placement, c.depth, view.ceilingHeight) + c.depth)) * 1.1
431
- )
438
+ depth: Math.round(Math.max(view.ceilingHeight, base.y * biggest) * 1.1)
432
439
  }
433
440
 
434
441
  this.scene = create({
@@ -447,11 +454,17 @@ export class FigurePreview extends localize(i18next)(LitElement) {
447
454
  올려도 편집 캔버스만 밝아지고 이 화면은 그대로여서, 같은 것을 두 화면이
448
455
  다르게 보여 주었다.
449
456
 
450
- `sceneLook` 이 좌표 기준과 천장 높이를 빼고 준다 — 둘은 씬에 그대로 넣을
451
- 값이 아니라 위에서 `zPos` 정하는 쓴다. 좌표 기준만 여기 따로 싣는다.
457
+ `sceneLook` 이 좌표 기준과 천장 높이를 빼고 준다 — 씬이 아는 이름이 아니거나
458
+ (`ceilingHeight`) 편집 캔버스에 새면 되는 것(`placement`)이라서다. 아래에
459
+ 씬의 낱말로 따로 싣는다.
452
460
  */
453
461
  ...sceneLook(view),
454
462
  placement: view.placement,
463
+ /*
464
+ 부착면의 높이. 씬이 이 자리를 갖고(`real-object.ts` 의 `sceneHeights`), 천정 기반
465
+ 도형이 여기서 제 높이를 얻는다. 보드에서는 현장이 이 값을 준다.
466
+ */
467
+ heights: { floor: 0, ceiling: view.ceilingHeight },
455
468
  pixelRatio: Math.min(3, (window.devicePixelRatio || 1) * 2),
456
469
  components
457
470
  },
@@ -175,7 +175,7 @@ export class FigureSide extends localize(i18next)(LitElement) {
175
175
  * 0 이면 아무것도 안 붙인다 — 「0」을 붙이면 없는 것이 있는 것처럼 자리를 차지한다.
176
176
  */
177
177
  private get clipCount(): number | undefined {
178
- const n = this.draft?.animations?.length ?? 0
178
+ const n = (this.draft?.animations?.length ?? 0) + (this.draft?.parameters?.length ?? 0)
179
179
  return n === 0 ? undefined : n
180
180
  }
181
181
 
@@ -102,6 +102,14 @@ export interface FigureDraft {
102
102
  * 저장하는 것만으로 **조용히 지워지기** 때문이다.
103
103
  */
104
104
  animations?: FigureSource['animations']
105
+ /**
106
+ * 값이 만드는 자세 — **애니메이션과 다른 칸이다.**
107
+ *
108
+ * 저것은 시각이 흘러 움직이는 것(도는 롤러)이고 이것은 인스턴스가 준 값이 그대로 자세가
109
+ * 되는 것(1200mm 내려온 호이스트)이다. 한동안 애니메이션 안에 `drive: 'hold'` 로 숨어
110
+ * 있었고, 그래서 「길이값을 변수로 노출하라」는 요청에 아무도 이것을 못 찾았다(ADR-0051).
111
+ */
112
+ parameters?: FigureSource['parameters']
105
113
  /**
106
114
  * 이 도형이 지는 능력. 씬의 capability mixin 이름이다.
107
115
  *
@@ -274,6 +282,7 @@ export function toFigureSource(draft: FigureDraft, parts: PartModel[]): FigureSo
274
282
  styleKit: draft.styleKit,
275
283
  parts: parts.map(part => partTo(part, half)),
276
284
  animations: draft.animations,
285
+ parameters: draft.parameters,
277
286
  capabilities: draft.capabilities
278
287
  }) as FigureSource
279
288
  }
@@ -341,6 +350,7 @@ export function fromFigureSource(source: FigureSource): { draft: FigureDraft; pa
341
350
  detailLevel: source.detailLevel,
342
351
  styleKit: source.styleKit,
343
352
  animations: source.animations,
353
+ parameters: source.parameters,
344
354
  capabilities: source.capabilities
345
355
  }) as FigureDraft
346
356
 
@@ -30,6 +30,7 @@ export type ChangeKind =
30
30
  | 'detail'
31
31
  | 'styleKit'
32
32
  | 'animations'
33
+ | 'parameters'
33
34
  | 'capabilities'
34
35
 
35
36
  /**
@@ -48,13 +49,14 @@ export type ChangeKind =
48
49
  * 식별자라 후보가 바꿔 와도 받지 않고(`applyProposal` 참고), `version` 은 저작자가 고를 것이
49
50
  * 아니라 저장할 때 서버가 찍는다.
50
51
  */
51
- const FIGURE_FIELDS = ['base', 'placement', 'detailLevel', 'styleKit', 'animations', 'capabilities'] as const
52
+ const FIGURE_FIELDS = ['base', 'placement', 'detailLevel', 'styleKit', 'animations', 'parameters', 'capabilities'] as const
52
53
  const FIELD_KIND: Record<(typeof FIGURE_FIELDS)[number], ChangeKind> = {
53
54
  base: 'base',
54
55
  placement: 'placement',
55
56
  detailLevel: 'detail',
56
57
  styleKit: 'styleKit',
57
58
  animations: 'animations',
59
+ parameters: 'parameters',
58
60
  capabilities: 'capabilities'
59
61
  }
60
62
 
@@ -1184,6 +1184,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
1184
1184
  detail: 'tune',
1185
1185
  styleKit: 'palette',
1186
1186
  animations: 'animation',
1187
+ parameters: 'tune',
1187
1188
  capabilities: 'hub'
1188
1189
  } satisfies Record<proposals.ChangeKind, string>)[change.kind]
1189
1190
 
@@ -5,10 +5,18 @@ export declare class FigureAnimations extends FigureAnimations_base {
5
5
  static styles: import("lit").CSSResult[];
6
6
  draft?: FigureDraft;
7
7
  parts: PartModel[];
8
- /** 펼쳐 둔 clip. 이름이 아니라 자리로 잡는다 — 이름은 편집 중에 바뀐다. */
8
+ /**
9
+ * 펼쳐 둔 것. 이름이 아니라 **목록과 자리**로 잡는다 — 이름은 편집 중에 바뀐다.
10
+ *
11
+ * 목록이 둘이므로 자리 하나로는 모자란다. `clip:0` 과 `parameter:0` 은 다른 것이다.
12
+ */
9
13
  private open;
10
14
  private recipeTarget;
11
15
  private get clips();
16
+ private get parameters();
17
+ /** 두 목록의 이름을 한자리에서 본다 — 인스턴스가 이름 하나로 값을 준다. */
18
+ private get allNames();
19
+ private channelsOf;
12
20
  /** 부품 이름 — 채널이 이것을 가리킨다. */
13
21
  private get partNames();
14
22
  /**
@@ -26,8 +34,17 @@ export declare class FigureAnimations extends FigureAnimations_base {
26
34
  * 그래서 무엇을 만들 수 있는지 예를 들고, 그것이 어떻게 움직이는지(사실이 들어온다)를 적는다.
27
35
  */
28
36
  private renderRecipes;
29
- private renderClips;
37
+ /** 이름이 다른 것과 겹치나. 두 목록을 함께 본다 — 인스턴스는 이름 하나로 값을 준다. */
38
+ private taken;
30
39
  private renderClip;
40
+ /**
41
+ * 파라미터 하나 — **이름·범위·자세.**
42
+ *
43
+ * 범위가 화면에 있어야 하는 이유는 그것이 **인스턴스와 주고받는 말**이기 때문이다.
44
+ * 곡선은 0~1 이고 인스턴스는 mm 로 준다. 그 사이를 런타임이 맵하는데, 무엇부터 무엇
45
+ * 까지인지 저작자가 정하지 않으면 값을 줄 수가 없다.
46
+ */
47
+ private renderParameter;
31
48
  private renderChannels;
32
49
  /**
33
50
  * 채널 하나 — 어느 부품의 어느 속성을 움직이나.
@@ -69,13 +86,25 @@ export declare class FigureAnimations extends FigureAnimations_base {
69
86
  */
70
87
  private tell;
71
88
  private patchClip;
89
+ private patchParameter;
90
+ /** 채널 묶음을 통째로 얹는다. **어느 목록이든 같은 자리에 닿는다.** */
91
+ private setChannels;
72
92
  private patchChannel;
73
93
  private addClip;
74
- /** Recipe creates a valid, editable two-pose clip; it never saves or publishes the Figure. */
94
+ private addParameter;
95
+ /**
96
+ * Recipe creates a valid, editable two-pose motion; it never saves or publishes the Figure.
97
+ *
98
+ * 어느 목록으로 가는지는 레시피가 안다 — 도는 롤러는 애니메이션이고 열리는 문은
99
+ * 파라미터다. 저작자에게 그 갈래를 먼저 고르게 하지 않는 이유가 이것이다.
100
+ */
75
101
  private addRecipe;
76
102
  private renameClip;
77
- private setDrive;
103
+ private renameParameter;
104
+ private setRange;
105
+ private setDefault;
78
106
  private removeClip;
107
+ private removeParameter;
79
108
  private addChannel;
80
109
  private setChannel;
81
110
  /**