@things-factory/figure-ui 10.1.20 → 10.1.22

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.
@@ -5,6 +5,8 @@ import { live } from 'lit/directives/live.js'
5
5
  import { i18next, localize } from '@operato/i18n'
6
6
  import { ScrollbarStyles } from '@operato/styles'
7
7
 
8
+ import type { FigureCapability } from '@hatiolab/figure-model'
9
+
8
10
  import type { FigureDraft, PartModel } from './figure-source.js'
9
11
 
10
12
  /**
@@ -12,7 +14,7 @@ import type { FigureDraft, PartModel } from './figure-source.js'
12
14
  *
13
15
  * ## 왜 탭 하나가 필요한가
14
16
  *
15
- * 지금까지 저작한 것은 **생김새**뿐이었다. 컨베이어를 아무리 잘 그려도 보드에 놓으면
17
+ * 지금까지 저작한 것은 **생김새**뿐이었다. 컨베이어를 아무리 잘 그려도 도면에 놓으면
16
18
  * 상자 하나를 못 받는다 — 물건을 받고 담고 흘려보내는 것은 씬의 capability 가 하는 일이고,
17
19
  * 손으로 쓴 컴포넌트는 클래스 선언 한 줄로 그것을 졌다. 저작한 도형에는 그 줄을 쓸 자리가
18
20
  * 없어서, 여기서 고른 것이 그 줄이 된다.
@@ -31,7 +33,12 @@ import type { FigureDraft, PartModel } from './figure-source.js'
31
33
  /** 사다리 한 칸 — 화면이 묻는 말과, 그것이 되는 능력 이름. */
32
34
  type Rung = 'none' | 'hold' | 'flow'
33
35
 
34
- const RUNGS: Readonly<Record<Rung, readonly string[]>> = Object.freeze({
36
+ /*
37
+ 칸마다의 이름은 **형식의 능력 이름**이다. `string[]` 으로 두면 사다리에 오타를 적어도
38
+ 타입이 통과하고, 그 도형은 발행까지 지난 뒤 씬에서 죽는다 — `hoist` 가 그렇게 나갔다
39
+ (ADR-0051 ④). `as const` 로 덮지 않는 이유도 같다: 그건 좁히는 것이 아니라 가리는 것이다.
40
+ */
41
+ const RUNGS: Readonly<Record<Rung, readonly FigureCapability[]>> = Object.freeze({
35
42
  none: [],
36
43
  hold: ['Capacity', 'Holdable'],
37
44
  flow: ['FlowNode']
@@ -42,7 +49,7 @@ function rungOf(names: readonly string[] | undefined): Rung | null {
42
49
  const has = new Set(names ?? [])
43
50
  if (has.size === 0) return 'none'
44
51
 
45
- for (const [rung, wanted] of Object.entries(RUNGS) as [Rung, readonly string[]][]) {
52
+ for (const [rung, wanted] of Object.entries(RUNGS) as [Rung, readonly FigureCapability[]][]) {
46
53
  if (wanted.length === has.size && wanted.every(name => has.has(name))) return rung
47
54
  }
48
55
  return null
@@ -352,7 +359,7 @@ export class FigureCapabilities extends localize(i18next)(LitElement) {
352
359
  const draft = this.draft
353
360
  if (!draft) return
354
361
 
355
- this.tellBoard({ ...draft, capabilities: RUNGS[which].length > 0 ? [...RUNGS[which]] : undefined })
362
+ this.tellDraft({ ...draft, capabilities: RUNGS[which].length > 0 ? [...RUNGS[which]] : undefined })
356
363
 
357
364
  if (which === 'none') {
358
365
  this.parts.forEach((part, index) => {
@@ -365,7 +372,7 @@ export class FigureCapabilities extends localize(i18next)(LitElement) {
365
372
  }
366
373
  }
367
374
 
368
- private tellBoard(draft: FigureDraft) {
375
+ private tellDraft(draft: FigureDraft) {
369
376
  this.draft = draft
370
377
  this.dispatchEvent(new CustomEvent('update-draft', { detail: { draft }, bubbles: true, composed: true }))
371
378
  }
@@ -336,9 +336,44 @@ function partTo(part: PartModel, half: Vec3): FigurePart {
336
336
  }) as FigurePart
337
337
  }
338
338
 
339
+ /**
340
+ * 옛 `drive: 'hold'` clip 을 파라미터로 옮긴다 — **여는 순간에.**
341
+ *
342
+ * 형식은 값이 만드는 자세를 `parameters` 로 옮겼고(ADR-0051 ①), `compile()` 이 옛 원본을
343
+ * 청사진으로 만들 때 같은 이전을 한다. **거기서만 하면 저작 화면이 어긋난다** — 씬은
344
+ * 파라미터로 세우는데 저작 화면은 원본을 그대로 읽어 애니메이션 칸에 놓는다.
345
+ *
346
+ * 실제로 그랬다. 카탈로그의 리프터를 열면 `lift-height` 가 애니메이션 목록에 앉고,
347
+ * 미리보기는 그것에 **배속 손잡이**를 붙인다. 손잡이를 끝까지 밀어도 아무 일도 안 난다 —
348
+ * 청사진에는 그 이름의 clip 이 없고 파라미터로 가 있기 때문이다. 저작자가 보는 것은
349
+ * 「움직이지 않는 리프터」다.
350
+ *
351
+ * 범위는 `%` 0~100 이다. 옛 clip 은 제 수가 무엇인지 말한 적이 없으므로 **지어내지
352
+ * 않는다** — 저작자가 mm 로 고쳐 쓸 자리를 열어 줄 뿐이다.
353
+ */
354
+ function partedMotion(source: FigureSource): Pick<FigureDraft, 'animations' | 'parameters'> {
355
+ const animations: NonNullable<FigureDraft['animations']> = []
356
+ const parameters: NonNullable<FigureDraft['parameters']> = [...(source.parameters ?? [])]
357
+
358
+ for (const clip of source.animations ?? []) {
359
+ const { drive, ...rest } = clip as typeof clip & { drive?: string }
360
+ if (drive !== 'hold') {
361
+ animations.push(rest)
362
+ continue
363
+ }
364
+ parameters.push({ name: clip.name, range: { unit: '%', min: 0, max: 100 }, clip: { channels: clip.channels } })
365
+ }
366
+
367
+ return {
368
+ animations: animations.length > 0 ? animations : undefined,
369
+ parameters: parameters.length > 0 ? parameters : undefined
370
+ }
371
+ }
372
+
339
373
  /** 형식을 씬 모델로 편다. */
340
374
  export function fromFigureSource(source: FigureSource): { draft: FigureDraft; parts: PartModel[] } {
341
375
  const half: Vec3 = { x: source.base.x / 2, y: source.base.y / 2, z: source.base.z / 2 }
376
+ const motion = partedMotion(source)
342
377
 
343
378
  const draft = withoutEmpty({
344
379
  version: source.version,
@@ -349,8 +384,8 @@ export function fromFigureSource(source: FigureSource): { draft: FigureDraft; pa
349
384
  placement: source.placement,
350
385
  detailLevel: source.detailLevel,
351
386
  styleKit: source.styleKit,
352
- animations: source.animations,
353
- parameters: source.parameters,
387
+ animations: motion.animations,
388
+ parameters: motion.parameters,
354
389
  capabilities: source.capabilities
355
390
  }) as FigureDraft
356
391
 
@@ -37,6 +37,6 @@ export declare class FigureCapabilities extends FigureCapabilities_base {
37
37
  * 되고, 그 도형은 멀쩡해 보이면서 아무것도 못 받는다.
38
38
  */
39
39
  private setRung;
40
- private tellBoard;
40
+ private tellDraft;
41
41
  }
42
42
  export {};
@@ -4,6 +4,11 @@ import { customElement, property } from 'lit/decorators.js';
4
4
  import { live } from 'lit/directives/live.js';
5
5
  import { i18next, localize } from '@operato/i18n';
6
6
  import { ScrollbarStyles } from '@operato/styles';
7
+ /*
8
+ 칸마다의 이름은 **형식의 능력 이름**이다. `string[]` 으로 두면 사다리에 오타를 적어도
9
+ 타입이 통과하고, 그 도형은 발행까지 지난 뒤 씬에서 죽는다 — `hoist` 가 그렇게 나갔다
10
+ (ADR-0051 ④). `as const` 로 덮지 않는 이유도 같다: 그건 좁히는 것이 아니라 가리는 것이다.
11
+ */
7
12
  const RUNGS = Object.freeze({
8
13
  none: [],
9
14
  hold: ['Capacity', 'Holdable'],
@@ -314,7 +319,7 @@ let FigureCapabilities = class FigureCapabilities extends localize(i18next)(LitE
314
319
  const draft = this.draft;
315
320
  if (!draft)
316
321
  return;
317
- this.tellBoard({ ...draft, capabilities: RUNGS[which].length > 0 ? [...RUNGS[which]] : undefined });
322
+ this.tellDraft({ ...draft, capabilities: RUNGS[which].length > 0 ? [...RUNGS[which]] : undefined });
318
323
  if (which === 'none') {
319
324
  this.parts.forEach((part, index) => {
320
325
  if (!part.capability)
@@ -324,7 +329,7 @@ let FigureCapabilities = class FigureCapabilities extends localize(i18next)(LitE
324
329
  });
325
330
  }
326
331
  }
327
- tellBoard(draft) {
332
+ tellDraft(draft) {
328
333
  this.draft = draft;
329
334
  this.dispatchEvent(new CustomEvent('update-draft', { detail: { draft }, bubbles: true, composed: true }));
330
335
  }
@@ -1 +1 @@
1
- {"version":3,"file":"figure-capabilities.js","sourceRoot":"","sources":["../../client/modeller/figure-capabilities.ts"],"names":[],"mappings":"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","sourcesContent":["import { css, html, LitElement } from 'lit'\nimport { customElement, property } from 'lit/decorators.js'\nimport { live } from 'lit/directives/live.js'\n\nimport { i18next, localize } from '@operato/i18n'\nimport { ScrollbarStyles } from '@operato/styles'\n\nimport type { FigureDraft, PartModel } from './figure-source.js'\n\n/**\n * 이 도형이 **무엇을 하는가.**\n *\n * ## 왜 탭 하나가 필요한가\n *\n * 지금까지 저작한 것은 **생김새**뿐이었다. 컨베이어를 아무리 잘 그려도 보드에 놓으면\n * 상자 하나를 못 받는다 — 물건을 받고 담고 흘려보내는 것은 씬의 capability 가 하는 일이고,\n * 손으로 쓴 컴포넌트는 클래스 선언 한 줄로 그것을 졌다. 저작한 도형에는 그 줄을 쓸 자리가\n * 없어서, 여기서 고른 것이 그 줄이 된다.\n *\n * ## 사다리로 묻는다\n *\n * 능력의 이름(`Capacity` · `Holdable` · `Transferable` · `FlowNode`)을 그대로 늘어놓고\n * 고르라고 하면, 저작자는 넷의 관계를 먼저 배워야 한다. 실제로 물어야 하는 것은 하나다 —\n * **이 도형이 물건을 다루나, 다룬다면 지나가나.** 그래서 세 칸의 사다리로 묻고, 이름은\n * 고른 뒤에 보여 준다.\n *\n * 윗 칸이 아랫 칸을 포함한다(`FlowNode` 가 나머지 셋을 스스로 흡수한다). 그래서 사다리이지\n * 체크상자가 아니다.\n */\n\n/** 사다리 한 칸 — 화면이 묻는 말과, 그것이 되는 능력 이름. */\ntype Rung = 'none' | 'hold' | 'flow'\n\nconst RUNGS: Readonly<Record<Rung, readonly string[]>> = Object.freeze({\n none: [],\n hold: ['Capacity', 'Holdable'],\n flow: ['FlowNode']\n})\n\n/** 선언된 이름 집합이 사다리 어느 칸인가. 어느 칸도 아니면 `null` — 지어내지 않는다. */\nfunction rungOf(names: readonly string[] | undefined): Rung | null {\n const has = new Set(names ?? [])\n if (has.size === 0) return 'none'\n\n for (const [rung, wanted] of Object.entries(RUNGS) as [Rung, readonly string[]][]) {\n if (wanted.length === has.size && wanted.every(name => has.has(name))) return rung\n }\n return null\n}\n\n@customElement('figure-capabilities')\nexport class FigureCapabilities extends localize(i18next)(LitElement) {\n static styles = [\n ScrollbarStyles,\n css`\n :host {\n display: flex;\n flex-direction: column;\n min-height: 0;\n overflow-y: auto;\n background-color: var(--md-sys-color-surface-container-lowest);\n color: var(--md-sys-color-on-surface);\n font: var(--label-font, inherit);\n font-size: 0.76rem;\n }\n\n section {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-medium, 8px);\n padding: var(--spacing-large, 12px);\n border-bottom: 1px solid var(--md-sys-color-outline-variant);\n }\n\n /* 다른 탭들과 같은 눈금이다 — 탭마다 다른 제목 규격을 쓰면 따로 논다 */\n h3 {\n margin: 0 0 2px 0;\n font-size: var(--ui-t-micro, 11px);\n font-weight: 700;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));\n }\n\n p[quiet] {\n margin: 0;\n font-size: 0.72rem;\n line-height: 1.5;\n color: var(--md-sys-color-on-surface-variant);\n }\n\n /* 사다리 — 고르는 것이 하나라 라디오다 */\n label[rung] {\n display: flex;\n align-items: flex-start;\n gap: var(--spacing-medium, 8px);\n padding: 6px 8px;\n border: 1px solid var(--md-sys-color-outline-variant);\n border-radius: 4px;\n cursor: pointer;\n }\n label[rung][on] {\n border-color: var(--md-sys-color-primary);\n background-color: var(--md-sys-color-surface-container);\n }\n label[rung]:hover:not([on]) {\n background-color: var(--md-sys-color-surface-container-low);\n }\n label[rung] span {\n display: flex;\n flex-direction: column;\n gap: 1px;\n }\n label[rung] strong {\n font-weight: 600;\n }\n label[rung] em {\n font-style: normal;\n font-size: 0.7rem;\n line-height: 1.4;\n color: var(--md-sys-color-on-surface-variant);\n }\n\n input[type='radio'] {\n flex: none;\n margin: 2px 0 0 0;\n accent-color: var(--md-sys-color-primary);\n cursor: pointer;\n }\n\n /* 실제로 씬에 붙는 이름. 고른 뒤에 보여 준다 */\n code[names] {\n font: var(--mono-font, ui-monospace, monospace);\n font-size: 0.7rem;\n color: var(--md-sys-color-on-surface-variant);\n }\n\n /* 지정된 부품 한 줄 — 읽는 것이지 고치는 것이 아니다. 눌러 그 부품으로 간다 */\n button[marked] {\n display: flex;\n align-items: baseline;\n gap: var(--spacing-medium, 8px);\n width: 100%;\n padding: 5px 8px;\n text-align: left;\n font: inherit;\n color: inherit;\n background: none;\n border: 1px solid var(--md-sys-color-outline-variant);\n border-radius: 4px;\n cursor: pointer;\n }\n button[marked]:hover {\n background-color: var(--md-sys-color-surface-container);\n }\n button[marked] b {\n flex: none;\n font-weight: 600;\n }\n button[marked] span {\n flex: none;\n font-size: 0.72rem;\n color: var(--md-sys-color-primary);\n }\n button[marked] em {\n flex: 1;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n text-align: right;\n font-style: normal;\n font-size: 0.7rem;\n color: var(--md-sys-color-on-surface-variant);\n }\n\n /* 부품마다 한 줄. 자리를 고른 것만 아래로 펼친다 */\n div[part] {\n display: flex;\n flex-direction: column;\n gap: 4px;\n padding: 6px 0;\n border-top: 1px solid var(--md-sys-color-outline-variant);\n }\n div[part]:first-of-type {\n border-top: none;\n }\n div[head] {\n display: flex;\n align-items: center;\n gap: var(--spacing-medium, 8px);\n }\n div[head] b {\n flex: 1;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-weight: 600;\n }\n\n select,\n input[type='text'],\n input[type='number'] {\n padding: 4px 6px;\n font: var(--input-field-font, inherit);\n font-size: 0.74rem;\n color: var(--md-sys-color-on-surface);\n background-color: var(--md-sys-color-surface);\n border: 1px solid var(--md-sys-color-outline-variant);\n border-radius: 4px;\n }\n /* 이름이 길어질 수 있는 쪽(문)까지 안 잘리는 폭이다 */\n div[head] select {\n flex: none;\n width: 8.5em;\n }\n\n div[detail] {\n display: grid;\n grid-template-columns: 4.5em 1fr;\n align-items: center;\n gap: 4px var(--spacing-medium, 8px);\n padding-left: var(--spacing-medium, 8px);\n }\n div[detail] > span {\n font-size: 0.72rem;\n color: var(--md-sys-color-on-surface-variant);\n }\n div[detail] input {\n width: 100%;\n box-sizing: border-box;\n }\n div[detail] em {\n grid-column: 2;\n font-style: normal;\n font-size: 0.7rem;\n color: var(--md-sys-color-on-surface-variant);\n }\n `\n ]\n\n @property({ type: Object }) draft?: FigureDraft\n @property({ type: Array }) parts: PartModel[] = []\n\n render() {\n const rung = rungOf(this.draft?.capabilities)\n const names = this.draft?.capabilities ?? []\n\n return html`\n <section>\n <h3>${i18next.t('figure.label.what-this-does')}</h3>\n\n ${this.rung('none', rung)} ${this.rung('hold', rung)} ${this.rung('flow', rung)}\n ${rung === null\n ? html`<p quiet>${i18next.t('figure.text.capability-hand-written')}</p>`\n : ''}\n ${names.length > 0 ? html`<code names>${names.join(' · ')}</code>` : ''}\n </section>\n\n ${this.renderAnchors(rung)}\n `\n }\n\n /**\n * 자리와 문 — **여기서는 읽기만 한다.**\n *\n * 지정은 속성 탭에서 한다. 부품을 고치는 곳이 한 군데여야 하기 때문이다 — 목록을 여기 또\n * 두면 같은 부품을 두 화면에서 고치게 되고, 저작자는 어느 쪽이 이기는지 매번 생각해야 한다.\n *\n * 그래도 요약은 남긴다. 「어느 부품이 자리인가」는 부품을 하나씩 눌러 보며 알아낼 일이\n * 아니다 — 자리는 그려지지 않으므로 화면을 봐서는 영영 못 찾는다.\n */\n private renderAnchors(rung: Rung | null) {\n if (rung === 'none') {\n return html`\n <section>\n <h3>${i18next.t('figure.label.slots-and-ports')}</h3>\n <p quiet>${i18next.t('figure.text.slots-need-capability')}</p>\n </section>\n `\n }\n\n const marked = this.parts.filter(part => part.capability)\n\n return html`\n <section>\n <h3>${i18next.t('figure.label.slots-and-ports')}</h3>\n <p quiet>${i18next.t('figure.text.anchors-are-not-drawn')}</p>\n\n ${marked.length === 0\n ? html`<p quiet>${i18next.t('figure.text.designate-in-properties')}</p>`\n : marked.map(part => this.renderMarked(part))}\n </section>\n `\n }\n\n /** 지정된 부품 한 줄. 누르면 그 부품이 골라진다 — 고치는 자리는 속성 탭이다. */\n private renderMarked(part: PartModel) {\n const spec = part.capability!\n\n return html`\n <button marked @click=${() => this.pick(part)}>\n <b>${part.name}</b>\n <span>${spec.roles.map(role => i18next.t('figure.label.role-' + role)).join(' · ')}</span>\n ${spec.accepts?.length ? html`<em>${spec.accepts.join(' · ')}</em>` : ''}\n </button>\n `\n }\n\n /** 이 부품을 고르게 한다. 부품 목록에서 누른 것과 같은 알림이다. */\n private pick(part: PartModel) {\n const index = this.parts.indexOf(part)\n if (index < 0) return\n\n this.dispatchEvent(new CustomEvent('select-part', { detail: { index }, bubbles: true, composed: true }))\n }\n\n /**\n * 사다리 한 칸.\n *\n * `checked` 를 `live()` 로 묶는다. 그냥 두면 Lit 이 **마지막으로 그린 값**과 견주므로,\n * 브라우저가 DOM 을 바꿨는데 모델은 안 바뀐 경우 되돌리지 않는다. 실제로 그랬다 — 고르는\n * 도중 도형이 다시 실렸더니 점은 「흐른다」에 있고 강조 상자는 「그리기만 한다」에 있었다.\n * 화면이 모델과 다른 말을 하는 상태다. `live()` 는 **지금 DOM 의 값**과 견준다.\n */\n private rung(which: Rung, chosen: Rung | null) {\n return html`\n <label rung ?on=${chosen === which}>\n <input\n type=\"radio\"\n name=\"rung\"\n .checked=${live(chosen === which)}\n @change=${() => this.setRung(which)}\n />\n <span>\n <strong>${i18next.t(`figure.label.rung-${which}`)}</strong>\n <em>${i18next.t(`figure.text.rung-${which}-note`)}</em>\n </span>\n </label>\n `\n }\n\n /**\n * 사다리를 내리면 자리도 함께 거둔다.\n *\n * 능력을 「그리기만 한다」로 되돌려 놓고 부품에 자리 표시가 남아 있으면, 형식은 자리를\n * 그대로 실어 보내는데 씬은 그것을 쓸 클래스가 없다. **선언은 있고 길이 없는** 상태가\n * 되고, 그 도형은 멀쩡해 보이면서 아무것도 못 받는다.\n */\n private setRung(which: Rung) {\n const draft = this.draft\n if (!draft) return\n\n this.tellBoard({ ...draft, capabilities: RUNGS[which].length > 0 ? [...RUNGS[which]] : undefined })\n\n if (which === 'none') {\n this.parts.forEach((part, index) => {\n if (!part.capability) return\n const { capability, ...rest } = part\n this.dispatchEvent(\n new CustomEvent('update-part', { detail: { index, part: rest }, bubbles: true, composed: true })\n )\n })\n }\n }\n\n private tellBoard(draft: FigureDraft) {\n this.draft = draft\n this.dispatchEvent(new CustomEvent('update-draft', { detail: { draft }, bubbles: true, composed: true }))\n }\n}\n"]}
1
+ {"version":3,"file":"figure-capabilities.js","sourceRoot":"","sources":["../../client/modeller/figure-capabilities.ts"],"names":[],"mappings":"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","sourcesContent":["import { css, html, LitElement } from 'lit'\nimport { customElement, property } from 'lit/decorators.js'\nimport { live } from 'lit/directives/live.js'\n\nimport { i18next, localize } from '@operato/i18n'\nimport { ScrollbarStyles } from '@operato/styles'\n\nimport type { FigureCapability } from '@hatiolab/figure-model'\n\nimport type { FigureDraft, PartModel } from './figure-source.js'\n\n/**\n * 이 도형이 **무엇을 하는가.**\n *\n * ## 왜 탭 하나가 필요한가\n *\n * 지금까지 저작한 것은 **생김새**뿐이었다. 컨베이어를 아무리 잘 그려도 도면에 놓으면\n * 상자 하나를 못 받는다 — 물건을 받고 담고 흘려보내는 것은 씬의 capability 가 하는 일이고,\n * 손으로 쓴 컴포넌트는 클래스 선언 한 줄로 그것을 졌다. 저작한 도형에는 그 줄을 쓸 자리가\n * 없어서, 여기서 고른 것이 그 줄이 된다.\n *\n * ## 사다리로 묻는다\n *\n * 능력의 이름(`Capacity` · `Holdable` · `Transferable` · `FlowNode`)을 그대로 늘어놓고\n * 고르라고 하면, 저작자는 넷의 관계를 먼저 배워야 한다. 실제로 물어야 하는 것은 하나다 —\n * **이 도형이 물건을 다루나, 다룬다면 지나가나.** 그래서 세 칸의 사다리로 묻고, 이름은\n * 고른 뒤에 보여 준다.\n *\n * 윗 칸이 아랫 칸을 포함한다(`FlowNode` 가 나머지 셋을 스스로 흡수한다). 그래서 사다리이지\n * 체크상자가 아니다.\n */\n\n/** 사다리 한 칸 — 화면이 묻는 말과, 그것이 되는 능력 이름. */\ntype Rung = 'none' | 'hold' | 'flow'\n\n/*\n 칸마다의 이름은 **형식의 능력 이름**이다. `string[]` 으로 두면 사다리에 오타를 적어도\n 타입이 통과하고, 그 도형은 발행까지 지난 뒤 씬에서 죽는다 — `hoist` 가 그렇게 나갔다\n (ADR-0051 ④). `as const` 로 덮지 않는 이유도 같다: 그건 좁히는 것이 아니라 가리는 것이다.\n*/\nconst RUNGS: Readonly<Record<Rung, readonly FigureCapability[]>> = Object.freeze({\n none: [],\n hold: ['Capacity', 'Holdable'],\n flow: ['FlowNode']\n})\n\n/** 선언된 이름 집합이 사다리 어느 칸인가. 어느 칸도 아니면 `null` — 지어내지 않는다. */\nfunction rungOf(names: readonly string[] | undefined): Rung | null {\n const has = new Set(names ?? [])\n if (has.size === 0) return 'none'\n\n for (const [rung, wanted] of Object.entries(RUNGS) as [Rung, readonly FigureCapability[]][]) {\n if (wanted.length === has.size && wanted.every(name => has.has(name))) return rung\n }\n return null\n}\n\n@customElement('figure-capabilities')\nexport class FigureCapabilities extends localize(i18next)(LitElement) {\n static styles = [\n ScrollbarStyles,\n css`\n :host {\n display: flex;\n flex-direction: column;\n min-height: 0;\n overflow-y: auto;\n background-color: var(--md-sys-color-surface-container-lowest);\n color: var(--md-sys-color-on-surface);\n font: var(--label-font, inherit);\n font-size: 0.76rem;\n }\n\n section {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-medium, 8px);\n padding: var(--spacing-large, 12px);\n border-bottom: 1px solid var(--md-sys-color-outline-variant);\n }\n\n /* 다른 탭들과 같은 눈금이다 — 탭마다 다른 제목 규격을 쓰면 따로 논다 */\n h3 {\n margin: 0 0 2px 0;\n font-size: var(--ui-t-micro, 11px);\n font-weight: 700;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));\n }\n\n p[quiet] {\n margin: 0;\n font-size: 0.72rem;\n line-height: 1.5;\n color: var(--md-sys-color-on-surface-variant);\n }\n\n /* 사다리 — 고르는 것이 하나라 라디오다 */\n label[rung] {\n display: flex;\n align-items: flex-start;\n gap: var(--spacing-medium, 8px);\n padding: 6px 8px;\n border: 1px solid var(--md-sys-color-outline-variant);\n border-radius: 4px;\n cursor: pointer;\n }\n label[rung][on] {\n border-color: var(--md-sys-color-primary);\n background-color: var(--md-sys-color-surface-container);\n }\n label[rung]:hover:not([on]) {\n background-color: var(--md-sys-color-surface-container-low);\n }\n label[rung] span {\n display: flex;\n flex-direction: column;\n gap: 1px;\n }\n label[rung] strong {\n font-weight: 600;\n }\n label[rung] em {\n font-style: normal;\n font-size: 0.7rem;\n line-height: 1.4;\n color: var(--md-sys-color-on-surface-variant);\n }\n\n input[type='radio'] {\n flex: none;\n margin: 2px 0 0 0;\n accent-color: var(--md-sys-color-primary);\n cursor: pointer;\n }\n\n /* 실제로 씬에 붙는 이름. 고른 뒤에 보여 준다 */\n code[names] {\n font: var(--mono-font, ui-monospace, monospace);\n font-size: 0.7rem;\n color: var(--md-sys-color-on-surface-variant);\n }\n\n /* 지정된 부품 한 줄 — 읽는 것이지 고치는 것이 아니다. 눌러 그 부품으로 간다 */\n button[marked] {\n display: flex;\n align-items: baseline;\n gap: var(--spacing-medium, 8px);\n width: 100%;\n padding: 5px 8px;\n text-align: left;\n font: inherit;\n color: inherit;\n background: none;\n border: 1px solid var(--md-sys-color-outline-variant);\n border-radius: 4px;\n cursor: pointer;\n }\n button[marked]:hover {\n background-color: var(--md-sys-color-surface-container);\n }\n button[marked] b {\n flex: none;\n font-weight: 600;\n }\n button[marked] span {\n flex: none;\n font-size: 0.72rem;\n color: var(--md-sys-color-primary);\n }\n button[marked] em {\n flex: 1;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n text-align: right;\n font-style: normal;\n font-size: 0.7rem;\n color: var(--md-sys-color-on-surface-variant);\n }\n\n /* 부품마다 한 줄. 자리를 고른 것만 아래로 펼친다 */\n div[part] {\n display: flex;\n flex-direction: column;\n gap: 4px;\n padding: 6px 0;\n border-top: 1px solid var(--md-sys-color-outline-variant);\n }\n div[part]:first-of-type {\n border-top: none;\n }\n div[head] {\n display: flex;\n align-items: center;\n gap: var(--spacing-medium, 8px);\n }\n div[head] b {\n flex: 1;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-weight: 600;\n }\n\n select,\n input[type='text'],\n input[type='number'] {\n padding: 4px 6px;\n font: var(--input-field-font, inherit);\n font-size: 0.74rem;\n color: var(--md-sys-color-on-surface);\n background-color: var(--md-sys-color-surface);\n border: 1px solid var(--md-sys-color-outline-variant);\n border-radius: 4px;\n }\n /* 이름이 길어질 수 있는 쪽(문)까지 안 잘리는 폭이다 */\n div[head] select {\n flex: none;\n width: 8.5em;\n }\n\n div[detail] {\n display: grid;\n grid-template-columns: 4.5em 1fr;\n align-items: center;\n gap: 4px var(--spacing-medium, 8px);\n padding-left: var(--spacing-medium, 8px);\n }\n div[detail] > span {\n font-size: 0.72rem;\n color: var(--md-sys-color-on-surface-variant);\n }\n div[detail] input {\n width: 100%;\n box-sizing: border-box;\n }\n div[detail] em {\n grid-column: 2;\n font-style: normal;\n font-size: 0.7rem;\n color: var(--md-sys-color-on-surface-variant);\n }\n `\n ]\n\n @property({ type: Object }) draft?: FigureDraft\n @property({ type: Array }) parts: PartModel[] = []\n\n render() {\n const rung = rungOf(this.draft?.capabilities)\n const names = this.draft?.capabilities ?? []\n\n return html`\n <section>\n <h3>${i18next.t('figure.label.what-this-does')}</h3>\n\n ${this.rung('none', rung)} ${this.rung('hold', rung)} ${this.rung('flow', rung)}\n ${rung === null\n ? html`<p quiet>${i18next.t('figure.text.capability-hand-written')}</p>`\n : ''}\n ${names.length > 0 ? html`<code names>${names.join(' · ')}</code>` : ''}\n </section>\n\n ${this.renderAnchors(rung)}\n `\n }\n\n /**\n * 자리와 문 — **여기서는 읽기만 한다.**\n *\n * 지정은 속성 탭에서 한다. 부품을 고치는 곳이 한 군데여야 하기 때문이다 — 목록을 여기 또\n * 두면 같은 부품을 두 화면에서 고치게 되고, 저작자는 어느 쪽이 이기는지 매번 생각해야 한다.\n *\n * 그래도 요약은 남긴다. 「어느 부품이 자리인가」는 부품을 하나씩 눌러 보며 알아낼 일이\n * 아니다 — 자리는 그려지지 않으므로 화면을 봐서는 영영 못 찾는다.\n */\n private renderAnchors(rung: Rung | null) {\n if (rung === 'none') {\n return html`\n <section>\n <h3>${i18next.t('figure.label.slots-and-ports')}</h3>\n <p quiet>${i18next.t('figure.text.slots-need-capability')}</p>\n </section>\n `\n }\n\n const marked = this.parts.filter(part => part.capability)\n\n return html`\n <section>\n <h3>${i18next.t('figure.label.slots-and-ports')}</h3>\n <p quiet>${i18next.t('figure.text.anchors-are-not-drawn')}</p>\n\n ${marked.length === 0\n ? html`<p quiet>${i18next.t('figure.text.designate-in-properties')}</p>`\n : marked.map(part => this.renderMarked(part))}\n </section>\n `\n }\n\n /** 지정된 부품 한 줄. 누르면 그 부품이 골라진다 — 고치는 자리는 속성 탭이다. */\n private renderMarked(part: PartModel) {\n const spec = part.capability!\n\n return html`\n <button marked @click=${() => this.pick(part)}>\n <b>${part.name}</b>\n <span>${spec.roles.map(role => i18next.t('figure.label.role-' + role)).join(' · ')}</span>\n ${spec.accepts?.length ? html`<em>${spec.accepts.join(' · ')}</em>` : ''}\n </button>\n `\n }\n\n /** 이 부품을 고르게 한다. 부품 목록에서 누른 것과 같은 알림이다. */\n private pick(part: PartModel) {\n const index = this.parts.indexOf(part)\n if (index < 0) return\n\n this.dispatchEvent(new CustomEvent('select-part', { detail: { index }, bubbles: true, composed: true }))\n }\n\n /**\n * 사다리 한 칸.\n *\n * `checked` 를 `live()` 로 묶는다. 그냥 두면 Lit 이 **마지막으로 그린 값**과 견주므로,\n * 브라우저가 DOM 을 바꿨는데 모델은 안 바뀐 경우 되돌리지 않는다. 실제로 그랬다 — 고르는\n * 도중 도형이 다시 실렸더니 점은 「흐른다」에 있고 강조 상자는 「그리기만 한다」에 있었다.\n * 화면이 모델과 다른 말을 하는 상태다. `live()` 는 **지금 DOM 의 값**과 견준다.\n */\n private rung(which: Rung, chosen: Rung | null) {\n return html`\n <label rung ?on=${chosen === which}>\n <input\n type=\"radio\"\n name=\"rung\"\n .checked=${live(chosen === which)}\n @change=${() => this.setRung(which)}\n />\n <span>\n <strong>${i18next.t(`figure.label.rung-${which}`)}</strong>\n <em>${i18next.t(`figure.text.rung-${which}-note`)}</em>\n </span>\n </label>\n `\n }\n\n /**\n * 사다리를 내리면 자리도 함께 거둔다.\n *\n * 능력을 「그리기만 한다」로 되돌려 놓고 부품에 자리 표시가 남아 있으면, 형식은 자리를\n * 그대로 실어 보내는데 씬은 그것을 쓸 클래스가 없다. **선언은 있고 길이 없는** 상태가\n * 되고, 그 도형은 멀쩡해 보이면서 아무것도 못 받는다.\n */\n private setRung(which: Rung) {\n const draft = this.draft\n if (!draft) return\n\n this.tellDraft({ ...draft, capabilities: RUNGS[which].length > 0 ? [...RUNGS[which]] : undefined })\n\n if (which === 'none') {\n this.parts.forEach((part, index) => {\n if (!part.capability) return\n const { capability, ...rest } = part\n this.dispatchEvent(\n new CustomEvent('update-part', { detail: { index, part: rest }, bubbles: true, composed: true })\n )\n })\n }\n }\n\n private tellDraft(draft: FigureDraft) {\n this.draft = draft\n this.dispatchEvent(new CustomEvent('update-draft', { detail: { draft }, bubbles: true, composed: true }))\n }\n}\n"]}
@@ -126,9 +126,41 @@ function partTo(part, half) {
126
126
  capability: part.capability
127
127
  });
128
128
  }
129
+ /**
130
+ * 옛 `drive: 'hold'` clip 을 파라미터로 옮긴다 — **여는 순간에.**
131
+ *
132
+ * 형식은 값이 만드는 자세를 `parameters` 로 옮겼고(ADR-0051 ①), `compile()` 이 옛 원본을
133
+ * 청사진으로 만들 때 같은 이전을 한다. **거기서만 하면 저작 화면이 어긋난다** — 씬은
134
+ * 파라미터로 세우는데 저작 화면은 원본을 그대로 읽어 애니메이션 칸에 놓는다.
135
+ *
136
+ * 실제로 그랬다. 카탈로그의 리프터를 열면 `lift-height` 가 애니메이션 목록에 앉고,
137
+ * 미리보기는 그것에 **배속 손잡이**를 붙인다. 손잡이를 끝까지 밀어도 아무 일도 안 난다 —
138
+ * 청사진에는 그 이름의 clip 이 없고 파라미터로 가 있기 때문이다. 저작자가 보는 것은
139
+ * 「움직이지 않는 리프터」다.
140
+ *
141
+ * 범위는 `%` 0~100 이다. 옛 clip 은 제 수가 무엇인지 말한 적이 없으므로 **지어내지
142
+ * 않는다** — 저작자가 mm 로 고쳐 쓸 자리를 열어 줄 뿐이다.
143
+ */
144
+ function partedMotion(source) {
145
+ const animations = [];
146
+ const parameters = [...(source.parameters ?? [])];
147
+ for (const clip of source.animations ?? []) {
148
+ const { drive, ...rest } = clip;
149
+ if (drive !== 'hold') {
150
+ animations.push(rest);
151
+ continue;
152
+ }
153
+ parameters.push({ name: clip.name, range: { unit: '%', min: 0, max: 100 }, clip: { channels: clip.channels } });
154
+ }
155
+ return {
156
+ animations: animations.length > 0 ? animations : undefined,
157
+ parameters: parameters.length > 0 ? parameters : undefined
158
+ };
159
+ }
129
160
  /** 형식을 씬 모델로 편다. */
130
161
  export function fromFigureSource(source) {
131
162
  const half = { x: source.base.x / 2, y: source.base.y / 2, z: source.base.z / 2 };
163
+ const motion = partedMotion(source);
132
164
  const draft = withoutEmpty({
133
165
  version: source.version,
134
166
  width: source.base.x,
@@ -138,8 +170,8 @@ export function fromFigureSource(source) {
138
170
  placement: source.placement,
139
171
  detailLevel: source.detailLevel,
140
172
  styleKit: source.styleKit,
141
- animations: source.animations,
142
- parameters: source.parameters,
173
+ animations: motion.animations,
174
+ parameters: motion.parameters,
143
175
  capabilities: source.capabilities
144
176
  });
145
177
  return { draft, parts: source.parts.map(part => partFrom(part, half)) };
@@ -1 +1 @@
1
- {"version":3,"file":"figure-source.js","sourceRoot":"","sources":["../../client/modeller/figure-source.ts"],"names":[],"mappings":"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","sourcesContent":["import { FIGURE_SOURCE_VERSION, type DetailLevel, type FigurePart, type FigurePlacement, type FigureSource, type Vec3 } from '@hatiolab/figure-model'\n\n/**\n * Scene model <-> `FigureSource`.\n *\n * ## Why this is the only place that converts\n *\n * While authoring, the source of truth is the **scene model** -- part\n * components sitting on a draft. `FigureSource` is produced from it when the\n * asset is saved or scored.\n *\n * The two use different vocabularies. The scene inherited 2D canvas terms\n * (`width`/`height` in plan, `depth` for height, `left`/`top`/`zPos` for\n * position); the format uses the axes 3D tools use (`x` right, `y` up, `z`\n * front, position at the part's **centre**).\n *\n * **Convert in one place.** Two places drift, and this format has already had\n * one incident caused by axes disagreeing.\n *\n * ## It was called `fold`, and that was wrong\n *\n * `fold` is an established term -- it reduces a structure to a summary, and\n * this repo already uses it correctly (`foldKpi`, `foldProbeResults`). Nothing\n * is reduced here: the same information comes out in a different shape, and it\n * goes back unchanged. A reader who learned `fold` from `foldKpi` reads this\n * one as reducing something, and the user did exactly that.\n *\n * ## The mapping\n *\n * The draft is the base box; its centre is the format's origin.\n *\n * format scene\n * ──────────────── ──────────────────────────────\n * base.x draft width\n * base.y draft depth <- height\n * base.z draft height\n *\n * size.x width\n * size.y depth\n * size.z height\n *\n * position.x left + width/2 - draft width/2\n * position.y zPos + depth/2 - draft depth/2 <- zPos is the underside\n * position.z top + height/2 - draft height/2\n *\n * rotation.x rotationX (scene radians, format degrees)\n * rotation.y -rotation\n * rotation.z rotationY\n *\n * The rotation names are crossed because of the scene: `rotationY` becomes\n * three's z (`real-object.ts`, `get rotation()`). That stays inside this table.\n *\n * ## Fields nothing reads yet still travel\n *\n * `materialSlot`, `keepRound`, `sizing`, `anchor`, `repeat` and `label` exist in the format\n * and the editor ignores them. They still have to survive a round trip. When a\n * source proposed by AI is opened, edited and written back, a field that\n * disappears **without an error** is one the author cannot know about.\n *\n * **Five have gone that way here**: `ports`, `animations`, `anchor`,\n * `shape.hollow` and `keepRound`. The last one the test caught first, which\n * is the guard below actually earning its keep. The third showed up as a conveyor motor that stayed on the\n * floor no matter what the sample said; the fourth as a hollowed tray that\n * came out a solid block.\n *\n * All four passed the round-trip test, because the fixture it uses did not\n * carry the field. So the test now reads the **field list the format declares**\n * rather than trusting the fixture.\n *\n * `shape` is unpacked into `round`, `path` and `hollow` rather than carried\n * whole, because the editor holds those as separate state. Every field added\n * under `shape` costs a line here, and forgetting that line was the fourth\n * of the four.\n */\n/** 판 컴포넌트의 상태. 씬의 용어 그대로다. */\nexport interface FigureDraft {\n /** 형식 버전. */\n version?: number\n /*\n The three sizes keep the scene component's names, and the scene assigns its axes differently\n from the format: here `depth` is the vertical one. Naming the axis alone (\"the y axis\") does\n not say which of the two conventions is meant, and this interface sits on the boundary between\n them — so each field names the `FigureSource` field it becomes. `toFigureSource` and\n `fromFigureSource` below are the only places that may disagree with these three lines.\n */\n /** 가로 너비 (mm) → `base.x`. */\n width: number\n /** 앞뒤 깊이 (mm) → `base.z`. 저작자가 위에서 내려다볼 때의 세로다. */\n height: number\n /** 높이 (mm) → `base.y`. 서 있는 방향이다. */\n depth: number\n /** 컴포넌트 타입 이름. **저장되는 식별자다.** */\n figureType?: string\n /** 3D 배치 기준면 ('floor' | 'ceiling' | 'center'). */\n placement?: FigurePlacement\n detailLevel?: DetailLevel\n styleKit?: string\n /**\n * 움직임. **부품 바깥에 있다** — clip 하나가 부품 여럿을 함께 움직이기 때문이다.\n *\n * 편집면이 아직 이것을 만지지 않는다. 그래도 여기 실어 두는 것은, 안 실으면 열었다\n * 저장하는 것만으로 **조용히 지워지기** 때문이다.\n */\n animations?: FigureSource['animations']\n /**\n * 값이 만드는 자세 — **애니메이션과 다른 칸이다.**\n *\n * 저것은 시각이 흘러 움직이는 것(도는 롤러)이고 이것은 인스턴스가 준 값이 그대로 자세가\n * 되는 것(1200mm 내려온 호이스트)이다. 한동안 애니메이션 안에 `drive: 'hold'` 로 숨어\n * 있었고, 그래서 「길이값을 변수로 노출하라」는 요청에 아무도 이것을 못 찾았다(ADR-0051).\n */\n parameters?: FigureSource['parameters']\n /**\n * 이 도형이 지는 능력. 씬의 capability mixin 이름이다.\n *\n * **부품이 아니라 도형 전체의 것이다** — 「이 컨베이어는 흐름의 마디다」는 부품 하나가\n * 아니라 도형이 지는 성질이다. 부품 쪽 `capability` 는 그 능력을 **어느 부품이 받쳐\n * 주나**를 말하는 다른 칸이다.\n */\n capabilities?: FigureSource['capabilities']\n}\n\n/** 부품 컴포넌트의 상태. 씬의 용어 그대로다. */\nexport interface PartModel {\n type: 'figure-part'\n /** 부품 이름. **저장되는 식별자다.** 씬은 이 값을 검색에도 쓴다. */\n name: string\n primitive: FigurePart['primitive']\n\n left: number\n top: number\n width: number\n height: number\n /** 부피의 **밑면** 높이. 씬의 'floor' 배치에서 그렇게 정해져 있다. */\n zPos: number\n depth: number\n\n /** 라디안. 씬이 그렇게 쓴다. */\n rotationX?: number\n rotation?: number\n rotationY?: number\n\n segments?: number\n round?: number\n path?: { x: number; y: number }[]\n /** 속을 판 단면. 편집면이 아직 안 만지지만 잃어버리면 통이 통짜 상자가 된다. */\n hollow?: NonNullable<FigurePart['shape']>['hollow']\n\n /**\n * 편집 중에만 쓰는 상태 — **저장 형식으로 넘어가지 않는다.**\n *\n * 씬이 이미 갖고 있는 속성이다. 편집 모드에서는 `faint`(반투명 + 색을 버리고 회색으로\n * 수렴)로 보이고, 뷰 모드에서는 아예 안 그려진다. 판단은 씬의 `modeling-faint` 한 곳에\n * 있고 2D 캔버스·3D 재질·DOM 세 채널이 같이 쓴다.\n *\n * 숨김은 \"지금 이 뒤를 만지려고\" 하는 것이지 그 부품의 성질이 아니다. 그래서\n * `toFigureSource` 가 싣지 않고, 다시 열면 전부 보이는 상태로 돌아온다.\n */\n hidden?: boolean\n\n /** 색 토큰 이름 (`palette.primary` 등). */\n token?: string\n /*\n `FigurePart['material']` is required in the format, so these index into it directly. The\n `NonNullable` on `shape` above is not the same case — `shape` is optional there.\n */\n preset?: FigurePart['material']['preset']\n flatShading?: boolean\n transparent?: boolean\n /**\n * 자체발광. 없으면 안 빛난다.\n *\n * 바깥의 `token` 이 **꺼진 모습**이고 이 안의 `token` 이 **켜진 모습**이다 — 꺼진\n * 전구는 빨갛지 않고 어두운 플라스틱이다.\n */\n emissive?: FigurePart['material']['emissive']\n surface?: FigurePart['material']['surface']\n\n /* 아직 아무 일도 안 하지만 잃어버리지 않는다. */\n materialSlot?: FigurePart['materialSlot']\n /** 단면을 원으로 지킬까. 곡면만 뜻이 있고 기본은 지키는 것이다. */\n keepRound?: FigurePart['keepRound']\n sizing?: FigurePart['sizing']\n /** 축마다 어느 면을 붙잡나. `sizing` 이 정한 것을 덮어쓴다. */\n anchor?: FigurePart['anchor']\n repeat?: FigurePart['repeat']\n label?: FigurePart['label']\n /**\n * 이 부품이 능력의 어느 자리인가 — 물건이 앉는 자리(slot)이거나 드나드는 문(port)이다.\n *\n * 이 칸이 있는 부품은 **그려지지 않는다.** 저작면에서는 어디인지 보여야 하니 그리지만,\n * 청사진에서는 `groups` 가 아니라 `anchors` 로 간다.\n */\n capability?: FigurePart['capability']\n}\n\nconst RAD_TO_DEG = 180 / Math.PI\nconst DEG_TO_RAD = Math.PI / 180\n\n/** 값이 있는 것만 남긴다. 없는 필드를 `undefined` 로 두면 접었다 편 것이 원본과 달라진다. */\nfunction withoutEmpty<T extends object>(value: T): T {\n const out = {} as Record<string, unknown>\n for (const [key, item] of Object.entries(value)) {\n if (item !== undefined) out[key] = item\n }\n return out as T\n}\n\n/** 소수점 넷째 자리까지. 라디안↔도를 오가며 생기는 꼬리를 자른다. */\nexport function round4(value: number): number {\n return Math.round(value * 1e4) / 1e4\n}\n\n/**\n * 판의 절반. 형식의 원점이 판 중심이라 이 값이 늘 함께 다닌다.\n *\n * 축 이름은 **형식의 것**이다 — x 가로 · y 높이 · z 세로.\n */\nexport function halfOf(draft: FigureDraft): Vec3 {\n return { x: draft.width / 2, y: draft.depth / 2, z: draft.height / 2 }\n}\n\n/*\n * ── The placement is not in these numbers ────────────────────────────────────\n *\n * `placement` says which reference surface a figure of this kind attaches to — a conveyor stands\n * on the floor, an OHT hangs from the ceiling. That is a fact about the figure *type*, and the\n * scene acts on it when an instance is placed: `floor` puts the box on the ground, `ceiling` at\n * the ceiling height less the figure's own height. Nothing about the authored shape changes.\n *\n * So the conversion below does not read it, and the editing canvas does not either. The box is\n * always `0 … base.y` there and `zPos` is always the part's underside. Two figures with identical\n * parts and different placements have identical `parts` arrays, which is what the format says.\n *\n * This used to be wired the other way: the canvas fed the figure's placement to the model layer's\n * `state.placement`, which is a scene-wide coordinate mode ('floor' | 'space' | 'inverted') that\n * flips what `zPos` names for everything in that layer. It stood in for the archetype because the\n * modeller holds exactly one figure — and it cost a whole day of defects, because every number the\n * author typed then meant a different face depending on a dropdown. That coordinate mode is now\n * where it belongs: a workbench setting in `figure-view.ts`, applied to the preview alone.\n */\n\n/**\n * 저작자가 보는 축이 씬의 어느 용어인가.\n *\n * 씬은 2D 캔버스에서 온 용어를 쓰므로 축 이름이 그대로 없다. 이 표가 **유일한** 대응이다 —\n * 편집 규칙이 저마다 자기 표를 들면 한 곳을 고칠 때 다른 곳이 남는다.\n */\nexport const AXIS_KEYS = {\n x: { at: 'left', size: 'width' },\n y: { at: 'zPos', size: 'depth' },\n z: { at: 'top', size: 'height' }\n} as const\n\nexport type PlaneAxis = keyof typeof AXIS_KEYS\n\n/** 판이 그 축으로 얼마나 넓은가. */\nexport function draftExtent(draft: FigureDraft, axis: PlaneAxis): number {\n return axis === 'x' ? draft.width : axis === 'y' ? draft.depth : draft.height\n}\n\n/** 부품 하나를 저장 형식으로 변환한다. 저작 화면이 저작자의 어휘로 보일 때 쓴다. */\nexport function partToFigure(part: PartModel, draft: FigureDraft): FigurePart {\n return partTo(part, halfOf(draft))\n}\n\n/** 부품 하나를 씬 용어로 되돌린다. */\nexport function partFromFigure(part: FigurePart, draft: FigureDraft): PartModel {\n return partFrom(part, halfOf(draft))\n}\n\n/** 씬 모델을 저장 형식으로 변환한다. */\nexport function toFigureSource(draft: FigureDraft, parts: PartModel[]): FigureSource {\n const half = halfOf(draft)\n\n return withoutEmpty({\n version: draft.version ?? FIGURE_SOURCE_VERSION,\n type: draft.figureType ?? '',\n base: { x: draft.width, y: draft.depth, z: draft.height },\n placement: draft.placement,\n detailLevel: draft.detailLevel,\n styleKit: draft.styleKit,\n parts: parts.map(part => partTo(part, half)),\n animations: draft.animations,\n parameters: draft.parameters,\n capabilities: draft.capabilities\n }) as FigureSource\n}\n\nfunction partTo(part: PartModel, half: Vec3): FigurePart {\n const rotation = withoutEmpty({\n x: part.rotationX ? round4(part.rotationX * RAD_TO_DEG) : undefined,\n y: part.rotation ? round4(-part.rotation * RAD_TO_DEG) : undefined,\n z: part.rotationY ? round4(part.rotationY * RAD_TO_DEG) : undefined\n })\n\n const shape = withoutEmpty({ round: part.round, path: part.path, hollow: part.hollow })\n /*\n 재질이 하나도 없으면 **칸 자체를 안 적는다.**\n\n 빈 객체를 적으면 형식이 「재질은 있는데 무엇으로 칠할지 모른다」로 읽어\n `material-unresolvable` 을 낸다. 그리지 않는 자리(`capability`)에는 재질이 없는 것이\n 정상이고, 그 부품이 조용히 오류를 달고 다니게 된다.\n */\n const material = withoutEmpty({\n token: part.token,\n preset: part.preset,\n flatShading: part.flatShading,\n transparent: part.transparent,\n emissive: part.emissive,\n surface: part.surface\n })\n\n return withoutEmpty({\n name: part.name,\n primitive: part.primitive,\n transform: withoutEmpty({\n position: {\n x: round4(part.left + part.width / 2 - half.x),\n y: round4(part.zPos + part.depth / 2 - half.y),\n z: round4(part.top + part.height / 2 - half.z)\n },\n size: { x: part.width, y: part.depth, z: part.height },\n rotation: Object.keys(rotation).length > 0 ? rotation : undefined\n }),\n segments: part.segments,\n shape: Object.keys(shape).length > 0 ? shape : undefined,\n material: Object.keys(material).length > 0 ? material : undefined,\n materialSlot: part.materialSlot,\n keepRound: part.keepRound,\n sizing: part.sizing,\n anchor: part.anchor,\n repeat: part.repeat,\n label: part.label,\n capability: part.capability\n }) as FigurePart\n}\n\n/** 형식을 씬 모델로 편다. */\nexport function fromFigureSource(source: FigureSource): { draft: FigureDraft; parts: PartModel[] } {\n const half: Vec3 = { x: source.base.x / 2, y: source.base.y / 2, z: source.base.z / 2 }\n\n const draft = withoutEmpty({\n version: source.version,\n width: source.base.x,\n height: source.base.z,\n depth: source.base.y,\n figureType: source.type,\n placement: source.placement,\n detailLevel: source.detailLevel,\n styleKit: source.styleKit,\n animations: source.animations,\n parameters: source.parameters,\n capabilities: source.capabilities\n }) as FigureDraft\n\n return { draft, parts: source.parts.map(part => partFrom(part, half)) }\n}\n\nfunction partFrom(part: FigurePart, half: Vec3): PartModel {\n const { position, size, rotation } = part.transform\n const material = part.material ?? {}\n\n return withoutEmpty({\n type: 'figure-part',\n name: part.name,\n primitive: part.primitive,\n\n left: round4(half.x + position.x - size.x / 2),\n top: round4(half.z + position.z - size.z / 2),\n width: size.x,\n height: size.z,\n zPos: round4(half.y + position.y - size.y / 2),\n depth: size.y,\n\n /*\n 라디안은 자르지 않는다.\n\n 소수 넷째에서 잘랐더니 도로 되돌릴 때 57 배로 커져 45 도가 45.0001 이 됐다.\n 자르는 것은 **사람이 읽는 쪽**(도)에서만 한다.\n */\n rotationX: rotation?.x ? rotation.x * DEG_TO_RAD : undefined,\n rotation: rotation?.y ? -rotation.y * DEG_TO_RAD : undefined,\n rotationY: rotation?.z ? rotation.z * DEG_TO_RAD : undefined,\n\n segments: part.segments,\n round: part.shape?.round,\n path: part.shape?.path,\n hollow: part.shape?.hollow,\n\n token: material.token,\n preset: material.preset,\n flatShading: material.flatShading,\n transparent: material.transparent,\n emissive: material.emissive,\n surface: material.surface,\n\n materialSlot: part.materialSlot,\n keepRound: part.keepRound,\n sizing: part.sizing,\n anchor: part.anchor,\n repeat: part.repeat,\n label: part.label,\n capability: part.capability\n }) as PartModel\n}\n"]}
1
+ {"version":3,"file":"figure-source.js","sourceRoot":"","sources":["../../client/modeller/figure-source.ts"],"names":[],"mappings":"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","sourcesContent":["import { FIGURE_SOURCE_VERSION, type DetailLevel, type FigurePart, type FigurePlacement, type FigureSource, type Vec3 } from '@hatiolab/figure-model'\n\n/**\n * Scene model <-> `FigureSource`.\n *\n * ## Why this is the only place that converts\n *\n * While authoring, the source of truth is the **scene model** -- part\n * components sitting on a draft. `FigureSource` is produced from it when the\n * asset is saved or scored.\n *\n * The two use different vocabularies. The scene inherited 2D canvas terms\n * (`width`/`height` in plan, `depth` for height, `left`/`top`/`zPos` for\n * position); the format uses the axes 3D tools use (`x` right, `y` up, `z`\n * front, position at the part's **centre**).\n *\n * **Convert in one place.** Two places drift, and this format has already had\n * one incident caused by axes disagreeing.\n *\n * ## It was called `fold`, and that was wrong\n *\n * `fold` is an established term -- it reduces a structure to a summary, and\n * this repo already uses it correctly (`foldKpi`, `foldProbeResults`). Nothing\n * is reduced here: the same information comes out in a different shape, and it\n * goes back unchanged. A reader who learned `fold` from `foldKpi` reads this\n * one as reducing something, and the user did exactly that.\n *\n * ## The mapping\n *\n * The draft is the base box; its centre is the format's origin.\n *\n * format scene\n * ──────────────── ──────────────────────────────\n * base.x draft width\n * base.y draft depth <- height\n * base.z draft height\n *\n * size.x width\n * size.y depth\n * size.z height\n *\n * position.x left + width/2 - draft width/2\n * position.y zPos + depth/2 - draft depth/2 <- zPos is the underside\n * position.z top + height/2 - draft height/2\n *\n * rotation.x rotationX (scene radians, format degrees)\n * rotation.y -rotation\n * rotation.z rotationY\n *\n * The rotation names are crossed because of the scene: `rotationY` becomes\n * three's z (`real-object.ts`, `get rotation()`). That stays inside this table.\n *\n * ## Fields nothing reads yet still travel\n *\n * `materialSlot`, `keepRound`, `sizing`, `anchor`, `repeat` and `label` exist in the format\n * and the editor ignores them. They still have to survive a round trip. When a\n * source proposed by AI is opened, edited and written back, a field that\n * disappears **without an error** is one the author cannot know about.\n *\n * **Five have gone that way here**: `ports`, `animations`, `anchor`,\n * `shape.hollow` and `keepRound`. The last one the test caught first, which\n * is the guard below actually earning its keep. The third showed up as a conveyor motor that stayed on the\n * floor no matter what the sample said; the fourth as a hollowed tray that\n * came out a solid block.\n *\n * All four passed the round-trip test, because the fixture it uses did not\n * carry the field. So the test now reads the **field list the format declares**\n * rather than trusting the fixture.\n *\n * `shape` is unpacked into `round`, `path` and `hollow` rather than carried\n * whole, because the editor holds those as separate state. Every field added\n * under `shape` costs a line here, and forgetting that line was the fourth\n * of the four.\n */\n/** 판 컴포넌트의 상태. 씬의 용어 그대로다. */\nexport interface FigureDraft {\n /** 형식 버전. */\n version?: number\n /*\n The three sizes keep the scene component's names, and the scene assigns its axes differently\n from the format: here `depth` is the vertical one. Naming the axis alone (\"the y axis\") does\n not say which of the two conventions is meant, and this interface sits on the boundary between\n them — so each field names the `FigureSource` field it becomes. `toFigureSource` and\n `fromFigureSource` below are the only places that may disagree with these three lines.\n */\n /** 가로 너비 (mm) → `base.x`. */\n width: number\n /** 앞뒤 깊이 (mm) → `base.z`. 저작자가 위에서 내려다볼 때의 세로다. */\n height: number\n /** 높이 (mm) → `base.y`. 서 있는 방향이다. */\n depth: number\n /** 컴포넌트 타입 이름. **저장되는 식별자다.** */\n figureType?: string\n /** 3D 배치 기준면 ('floor' | 'ceiling' | 'center'). */\n placement?: FigurePlacement\n detailLevel?: DetailLevel\n styleKit?: string\n /**\n * 움직임. **부품 바깥에 있다** — clip 하나가 부품 여럿을 함께 움직이기 때문이다.\n *\n * 편집면이 아직 이것을 만지지 않는다. 그래도 여기 실어 두는 것은, 안 실으면 열었다\n * 저장하는 것만으로 **조용히 지워지기** 때문이다.\n */\n animations?: FigureSource['animations']\n /**\n * 값이 만드는 자세 — **애니메이션과 다른 칸이다.**\n *\n * 저것은 시각이 흘러 움직이는 것(도는 롤러)이고 이것은 인스턴스가 준 값이 그대로 자세가\n * 되는 것(1200mm 내려온 호이스트)이다. 한동안 애니메이션 안에 `drive: 'hold'` 로 숨어\n * 있었고, 그래서 「길이값을 변수로 노출하라」는 요청에 아무도 이것을 못 찾았다(ADR-0051).\n */\n parameters?: FigureSource['parameters']\n /**\n * 이 도형이 지는 능력. 씬의 capability mixin 이름이다.\n *\n * **부품이 아니라 도형 전체의 것이다** — 「이 컨베이어는 흐름의 마디다」는 부품 하나가\n * 아니라 도형이 지는 성질이다. 부품 쪽 `capability` 는 그 능력을 **어느 부품이 받쳐\n * 주나**를 말하는 다른 칸이다.\n */\n capabilities?: FigureSource['capabilities']\n}\n\n/** 부품 컴포넌트의 상태. 씬의 용어 그대로다. */\nexport interface PartModel {\n type: 'figure-part'\n /** 부품 이름. **저장되는 식별자다.** 씬은 이 값을 검색에도 쓴다. */\n name: string\n primitive: FigurePart['primitive']\n\n left: number\n top: number\n width: number\n height: number\n /** 부피의 **밑면** 높이. 씬의 'floor' 배치에서 그렇게 정해져 있다. */\n zPos: number\n depth: number\n\n /** 라디안. 씬이 그렇게 쓴다. */\n rotationX?: number\n rotation?: number\n rotationY?: number\n\n segments?: number\n round?: number\n path?: { x: number; y: number }[]\n /** 속을 판 단면. 편집면이 아직 안 만지지만 잃어버리면 통이 통짜 상자가 된다. */\n hollow?: NonNullable<FigurePart['shape']>['hollow']\n\n /**\n * 편집 중에만 쓰는 상태 — **저장 형식으로 넘어가지 않는다.**\n *\n * 씬이 이미 갖고 있는 속성이다. 편집 모드에서는 `faint`(반투명 + 색을 버리고 회색으로\n * 수렴)로 보이고, 뷰 모드에서는 아예 안 그려진다. 판단은 씬의 `modeling-faint` 한 곳에\n * 있고 2D 캔버스·3D 재질·DOM 세 채널이 같이 쓴다.\n *\n * 숨김은 \"지금 이 뒤를 만지려고\" 하는 것이지 그 부품의 성질이 아니다. 그래서\n * `toFigureSource` 가 싣지 않고, 다시 열면 전부 보이는 상태로 돌아온다.\n */\n hidden?: boolean\n\n /** 색 토큰 이름 (`palette.primary` 등). */\n token?: string\n /*\n `FigurePart['material']` is required in the format, so these index into it directly. The\n `NonNullable` on `shape` above is not the same case — `shape` is optional there.\n */\n preset?: FigurePart['material']['preset']\n flatShading?: boolean\n transparent?: boolean\n /**\n * 자체발광. 없으면 안 빛난다.\n *\n * 바깥의 `token` 이 **꺼진 모습**이고 이 안의 `token` 이 **켜진 모습**이다 — 꺼진\n * 전구는 빨갛지 않고 어두운 플라스틱이다.\n */\n emissive?: FigurePart['material']['emissive']\n surface?: FigurePart['material']['surface']\n\n /* 아직 아무 일도 안 하지만 잃어버리지 않는다. */\n materialSlot?: FigurePart['materialSlot']\n /** 단면을 원으로 지킬까. 곡면만 뜻이 있고 기본은 지키는 것이다. */\n keepRound?: FigurePart['keepRound']\n sizing?: FigurePart['sizing']\n /** 축마다 어느 면을 붙잡나. `sizing` 이 정한 것을 덮어쓴다. */\n anchor?: FigurePart['anchor']\n repeat?: FigurePart['repeat']\n label?: FigurePart['label']\n /**\n * 이 부품이 능력의 어느 자리인가 — 물건이 앉는 자리(slot)이거나 드나드는 문(port)이다.\n *\n * 이 칸이 있는 부품은 **그려지지 않는다.** 저작면에서는 어디인지 보여야 하니 그리지만,\n * 청사진에서는 `groups` 가 아니라 `anchors` 로 간다.\n */\n capability?: FigurePart['capability']\n}\n\nconst RAD_TO_DEG = 180 / Math.PI\nconst DEG_TO_RAD = Math.PI / 180\n\n/** 값이 있는 것만 남긴다. 없는 필드를 `undefined` 로 두면 접었다 편 것이 원본과 달라진다. */\nfunction withoutEmpty<T extends object>(value: T): T {\n const out = {} as Record<string, unknown>\n for (const [key, item] of Object.entries(value)) {\n if (item !== undefined) out[key] = item\n }\n return out as T\n}\n\n/** 소수점 넷째 자리까지. 라디안↔도를 오가며 생기는 꼬리를 자른다. */\nexport function round4(value: number): number {\n return Math.round(value * 1e4) / 1e4\n}\n\n/**\n * 판의 절반. 형식의 원점이 판 중심이라 이 값이 늘 함께 다닌다.\n *\n * 축 이름은 **형식의 것**이다 — x 가로 · y 높이 · z 세로.\n */\nexport function halfOf(draft: FigureDraft): Vec3 {\n return { x: draft.width / 2, y: draft.depth / 2, z: draft.height / 2 }\n}\n\n/*\n * ── The placement is not in these numbers ────────────────────────────────────\n *\n * `placement` says which reference surface a figure of this kind attaches to — a conveyor stands\n * on the floor, an OHT hangs from the ceiling. That is a fact about the figure *type*, and the\n * scene acts on it when an instance is placed: `floor` puts the box on the ground, `ceiling` at\n * the ceiling height less the figure's own height. Nothing about the authored shape changes.\n *\n * So the conversion below does not read it, and the editing canvas does not either. The box is\n * always `0 … base.y` there and `zPos` is always the part's underside. Two figures with identical\n * parts and different placements have identical `parts` arrays, which is what the format says.\n *\n * This used to be wired the other way: the canvas fed the figure's placement to the model layer's\n * `state.placement`, which is a scene-wide coordinate mode ('floor' | 'space' | 'inverted') that\n * flips what `zPos` names for everything in that layer. It stood in for the archetype because the\n * modeller holds exactly one figure — and it cost a whole day of defects, because every number the\n * author typed then meant a different face depending on a dropdown. That coordinate mode is now\n * where it belongs: a workbench setting in `figure-view.ts`, applied to the preview alone.\n */\n\n/**\n * 저작자가 보는 축이 씬의 어느 용어인가.\n *\n * 씬은 2D 캔버스에서 온 용어를 쓰므로 축 이름이 그대로 없다. 이 표가 **유일한** 대응이다 —\n * 편집 규칙이 저마다 자기 표를 들면 한 곳을 고칠 때 다른 곳이 남는다.\n */\nexport const AXIS_KEYS = {\n x: { at: 'left', size: 'width' },\n y: { at: 'zPos', size: 'depth' },\n z: { at: 'top', size: 'height' }\n} as const\n\nexport type PlaneAxis = keyof typeof AXIS_KEYS\n\n/** 판이 그 축으로 얼마나 넓은가. */\nexport function draftExtent(draft: FigureDraft, axis: PlaneAxis): number {\n return axis === 'x' ? draft.width : axis === 'y' ? draft.depth : draft.height\n}\n\n/** 부품 하나를 저장 형식으로 변환한다. 저작 화면이 저작자의 어휘로 보일 때 쓴다. */\nexport function partToFigure(part: PartModel, draft: FigureDraft): FigurePart {\n return partTo(part, halfOf(draft))\n}\n\n/** 부품 하나를 씬 용어로 되돌린다. */\nexport function partFromFigure(part: FigurePart, draft: FigureDraft): PartModel {\n return partFrom(part, halfOf(draft))\n}\n\n/** 씬 모델을 저장 형식으로 변환한다. */\nexport function toFigureSource(draft: FigureDraft, parts: PartModel[]): FigureSource {\n const half = halfOf(draft)\n\n return withoutEmpty({\n version: draft.version ?? FIGURE_SOURCE_VERSION,\n type: draft.figureType ?? '',\n base: { x: draft.width, y: draft.depth, z: draft.height },\n placement: draft.placement,\n detailLevel: draft.detailLevel,\n styleKit: draft.styleKit,\n parts: parts.map(part => partTo(part, half)),\n animations: draft.animations,\n parameters: draft.parameters,\n capabilities: draft.capabilities\n }) as FigureSource\n}\n\nfunction partTo(part: PartModel, half: Vec3): FigurePart {\n const rotation = withoutEmpty({\n x: part.rotationX ? round4(part.rotationX * RAD_TO_DEG) : undefined,\n y: part.rotation ? round4(-part.rotation * RAD_TO_DEG) : undefined,\n z: part.rotationY ? round4(part.rotationY * RAD_TO_DEG) : undefined\n })\n\n const shape = withoutEmpty({ round: part.round, path: part.path, hollow: part.hollow })\n /*\n 재질이 하나도 없으면 **칸 자체를 안 적는다.**\n\n 빈 객체를 적으면 형식이 「재질은 있는데 무엇으로 칠할지 모른다」로 읽어\n `material-unresolvable` 을 낸다. 그리지 않는 자리(`capability`)에는 재질이 없는 것이\n 정상이고, 그 부품이 조용히 오류를 달고 다니게 된다.\n */\n const material = withoutEmpty({\n token: part.token,\n preset: part.preset,\n flatShading: part.flatShading,\n transparent: part.transparent,\n emissive: part.emissive,\n surface: part.surface\n })\n\n return withoutEmpty({\n name: part.name,\n primitive: part.primitive,\n transform: withoutEmpty({\n position: {\n x: round4(part.left + part.width / 2 - half.x),\n y: round4(part.zPos + part.depth / 2 - half.y),\n z: round4(part.top + part.height / 2 - half.z)\n },\n size: { x: part.width, y: part.depth, z: part.height },\n rotation: Object.keys(rotation).length > 0 ? rotation : undefined\n }),\n segments: part.segments,\n shape: Object.keys(shape).length > 0 ? shape : undefined,\n material: Object.keys(material).length > 0 ? material : undefined,\n materialSlot: part.materialSlot,\n keepRound: part.keepRound,\n sizing: part.sizing,\n anchor: part.anchor,\n repeat: part.repeat,\n label: part.label,\n capability: part.capability\n }) as FigurePart\n}\n\n/**\n * 옛 `drive: 'hold'` clip 을 파라미터로 옮긴다 — **여는 순간에.**\n *\n * 형식은 값이 만드는 자세를 `parameters` 로 옮겼고(ADR-0051 ①), `compile()` 이 옛 원본을\n * 청사진으로 만들 때 같은 이전을 한다. **거기서만 하면 저작 화면이 어긋난다** — 씬은\n * 파라미터로 세우는데 저작 화면은 원본을 그대로 읽어 애니메이션 칸에 놓는다.\n *\n * 실제로 그랬다. 카탈로그의 리프터를 열면 `lift-height` 가 애니메이션 목록에 앉고,\n * 미리보기는 그것에 **배속 손잡이**를 붙인다. 손잡이를 끝까지 밀어도 아무 일도 안 난다 —\n * 청사진에는 그 이름의 clip 이 없고 파라미터로 가 있기 때문이다. 저작자가 보는 것은\n * 「움직이지 않는 리프터」다.\n *\n * 범위는 `%` 0~100 이다. 옛 clip 은 제 수가 무엇인지 말한 적이 없으므로 **지어내지\n * 않는다** — 저작자가 mm 로 고쳐 쓸 자리를 열어 줄 뿐이다.\n */\nfunction partedMotion(source: FigureSource): Pick<FigureDraft, 'animations' | 'parameters'> {\n const animations: NonNullable<FigureDraft['animations']> = []\n const parameters: NonNullable<FigureDraft['parameters']> = [...(source.parameters ?? [])]\n\n for (const clip of source.animations ?? []) {\n const { drive, ...rest } = clip as typeof clip & { drive?: string }\n if (drive !== 'hold') {\n animations.push(rest)\n continue\n }\n parameters.push({ name: clip.name, range: { unit: '%', min: 0, max: 100 }, clip: { channels: clip.channels } })\n }\n\n return {\n animations: animations.length > 0 ? animations : undefined,\n parameters: parameters.length > 0 ? parameters : undefined\n }\n}\n\n/** 형식을 씬 모델로 편다. */\nexport function fromFigureSource(source: FigureSource): { draft: FigureDraft; parts: PartModel[] } {\n const half: Vec3 = { x: source.base.x / 2, y: source.base.y / 2, z: source.base.z / 2 }\n const motion = partedMotion(source)\n\n const draft = withoutEmpty({\n version: source.version,\n width: source.base.x,\n height: source.base.z,\n depth: source.base.y,\n figureType: source.type,\n placement: source.placement,\n detailLevel: source.detailLevel,\n styleKit: source.styleKit,\n animations: motion.animations,\n parameters: motion.parameters,\n capabilities: source.capabilities\n }) as FigureDraft\n\n return { draft, parts: source.parts.map(part => partFrom(part, half)) }\n}\n\nfunction partFrom(part: FigurePart, half: Vec3): PartModel {\n const { position, size, rotation } = part.transform\n const material = part.material ?? {}\n\n return withoutEmpty({\n type: 'figure-part',\n name: part.name,\n primitive: part.primitive,\n\n left: round4(half.x + position.x - size.x / 2),\n top: round4(half.z + position.z - size.z / 2),\n width: size.x,\n height: size.z,\n zPos: round4(half.y + position.y - size.y / 2),\n depth: size.y,\n\n /*\n 라디안은 자르지 않는다.\n\n 소수 넷째에서 잘랐더니 도로 되돌릴 때 57 배로 커져 45 도가 45.0001 이 됐다.\n 자르는 것은 **사람이 읽는 쪽**(도)에서만 한다.\n */\n rotationX: rotation?.x ? rotation.x * DEG_TO_RAD : undefined,\n rotation: rotation?.y ? -rotation.y * DEG_TO_RAD : undefined,\n rotationY: rotation?.z ? rotation.z * DEG_TO_RAD : undefined,\n\n segments: part.segments,\n round: part.shape?.round,\n path: part.shape?.path,\n hollow: part.shape?.hollow,\n\n token: material.token,\n preset: material.preset,\n flatShading: material.flatShading,\n transparent: material.transparent,\n emissive: material.emissive,\n surface: material.surface,\n\n materialSlot: part.materialSlot,\n keepRound: part.keepRound,\n sizing: part.sizing,\n anchor: part.anchor,\n repeat: part.repeat,\n label: part.label,\n capability: part.capability\n }) as PartModel\n}\n"]}