@things-factory/figure-ui 10.1.64 → 10.1.65

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 (49) hide show
  1. package/client/figure-assistant.ts +26 -10
  2. package/client/graphql/index.ts +0 -58
  3. package/client/modeller/v3-ai-proposal.ts +111 -0
  4. package/client/modeller/v3-product-session.ts +10 -1
  5. package/client/pages/figure-v3-modeller-page.ts +35 -2
  6. package/client/types.ts +0 -39
  7. package/dist-client/figure-assistant.js +22 -10
  8. package/dist-client/figure-assistant.js.map +1 -1
  9. package/dist-client/graphql/index.d.ts +1 -19
  10. package/dist-client/graphql/index.js +0 -46
  11. package/dist-client/graphql/index.js.map +1 -1
  12. package/dist-client/modeller/figure-v3-capability.d.ts +1 -1
  13. package/dist-client/modeller/figure-v3-clips.d.ts +1 -1
  14. package/dist-client/modeller/figure-v3-controls.d.ts +1 -1
  15. package/dist-client/modeller/figure-v3-links.d.ts +1 -1
  16. package/dist-client/modeller/figure-v3-material.d.ts +1 -1
  17. package/dist-client/modeller/figure-v3-properties.d.ts +1 -1
  18. package/dist-client/modeller/v3-ai-proposal.d.ts +51 -0
  19. package/dist-client/modeller/v3-ai-proposal.js +64 -0
  20. package/dist-client/modeller/v3-ai-proposal.js.map +1 -0
  21. package/dist-client/modeller/v3-clips.d.ts +1 -1
  22. package/dist-client/modeller/v3-product-session.d.ts +35 -1
  23. package/dist-client/modeller/v3-product-session.js +8 -0
  24. package/dist-client/modeller/v3-product-session.js.map +1 -1
  25. package/dist-client/pages/figure-v3-modeller-page.d.ts +7 -1
  26. package/dist-client/pages/figure-v3-modeller-page.js +41 -2
  27. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  28. package/dist-client/route.d.ts +1 -1
  29. package/dist-client/tsconfig.fui.tsbuildinfo +1 -1
  30. package/dist-client/tsconfig.tsbuildinfo +1 -1
  31. package/dist-client/types.d.ts +0 -44
  32. package/dist-client/types.js.map +1 -1
  33. package/package.json +4 -4
  34. package/test/figure-assistant-surface.test.ts +9 -6
  35. package/test/v3-ai-proposal.test.ts +112 -0
  36. package/translations/en.json +6 -1
  37. package/translations/ja.json +6 -1
  38. package/translations/ko.json +6 -1
  39. package/translations/ms.json +6 -1
  40. package/translations/zh.json +6 -1
  41. package/client/modeller/proposal-session.ts +0 -98
  42. package/client/modeller/proposal.ts +0 -171
  43. package/dist-client/modeller/proposal-session.d.ts +0 -28
  44. package/dist-client/modeller/proposal-session.js +0 -66
  45. package/dist-client/modeller/proposal-session.js.map +0 -1
  46. package/dist-client/modeller/proposal.d.ts +0 -45
  47. package/dist-client/modeller/proposal.js +0 -114
  48. package/dist-client/modeller/proposal.js.map +0 -1
  49. package/test/ai-proposal-contract.test.ts +0 -563
@@ -1,66 +0,0 @@
1
- import { diffProposal } from './proposal.js';
2
- function selectedKeys(current, source) {
3
- return new Set(diffProposal(current, source).map(change => change.key));
4
- }
5
- /** 서버 후보를 검토 세션으로 연다. 저장 형식이 아니면 화면에 후보를 열지 않는다. */
6
- export function openAiProposal(current, proposal) {
7
- const source = JSON.parse(proposal.source);
8
- if (!source || !Array.isArray(source.parts)) {
9
- throw new Error('the AI candidate is not a figure source: parts is missing or not an array');
10
- }
11
- return {
12
- source,
13
- title: '',
14
- picked: selectedKeys(current, source),
15
- note: '',
16
- metrics: {
17
- grade: proposal.grade,
18
- triangles: proposal.triangles,
19
- groups: proposal.groups,
20
- attempts: proposal.attempts,
21
- quality: proposal.quality
22
- }
23
- };
24
- }
25
- /** 발행 게이트가 측정한 한-앵커 수정도 같은 후보 세션으로 연다. */
26
- export function openSizingFix(current, fix) {
27
- if (!current.parts.some(part => part.name === fix.part))
28
- return undefined;
29
- const source = {
30
- ...current,
31
- parts: current.parts.map(part => part.name === fix.part ? { ...part, anchor: { ...(part.anchor ?? {}), [fix.axis]: fix.to } } : part)
32
- };
33
- return {
34
- source,
35
- title: '',
36
- fix,
37
- picked: selectedKeys(current, source),
38
- note: ''
39
- };
40
- }
41
- /** 후보 검토 중 사람의 선택을 뒤집는다. 기존 Set을 바꾸지 않아 Lit 상태 경계도 보존한다. */
42
- export function toggleProposalChange(session, key) {
43
- const picked = new Set(session.picked);
44
- if (picked.has(key))
45
- picked.delete(key);
46
- else
47
- picked.add(key);
48
- return { ...session, picked };
49
- }
50
- /** 사람의 결정을, 다음 AI 요청에만 쓰는 제한된 피드백으로 만든다. */
51
- export function feedbackFromSession(current, session, outcome) {
52
- if (!session.metrics)
53
- return undefined;
54
- return {
55
- outcome,
56
- selectedChanges: outcome === 'accepted' ? session.picked.size : 0,
57
- totalChanges: diffProposal(current, session.source).length,
58
- note: session.note.trim().slice(0, 500) || undefined,
59
- grade: session.metrics.grade,
60
- quality: {
61
- status: session.metrics.quality.status,
62
- findingCodes: session.metrics.quality.findings.map(finding => finding.code)
63
- }
64
- };
65
- }
66
- //# sourceMappingURL=proposal-session.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"proposal-session.js","sourceRoot":"","sources":["../../client/modeller/proposal-session.ts"],"names":[],"mappings":"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","sourcesContent":["import type { FigureSource } from '@hatiolab/figure-model'\n\nimport type { FigureGateFix, FigureProposal, ProposalFeedback } from '../types.js'\nimport { diffProposal } from './proposal.js'\n\n/**\n * 한 번의 AI 후보 검토에만 존재하는 상태다.\n *\n * 후보는 정본이 아니다. 후보 원본·선택·메모·측정값을 한 값으로 묶어 두면 화면이\n * 수락/거절 중 일부만 이전 후보에서 남기는 일이 없다.\n */\nexport interface ProposalSession {\n source: FigureSource\n title: string\n picked: Set<string>\n note: string\n metrics?: Pick<FigureProposal, 'grade' | 'triangles' | 'groups' | 'attempts' | 'quality'>\n /**\n * The release-check fix this session was opened from, if any. The page translates the title from it;\n * this module stays free of i18n because node tests import it directly.\n */\n fix?: FigureGateFix\n}\n\nfunction selectedKeys(current: FigureSource | undefined, source: FigureSource): Set<string> {\n return new Set(diffProposal(current, source).map(change => change.key))\n}\n\n/** 서버 후보를 검토 세션으로 연다. 저장 형식이 아니면 화면에 후보를 열지 않는다. */\nexport function openAiProposal(current: FigureSource | undefined, proposal: FigureProposal): ProposalSession {\n const source = JSON.parse(proposal.source) as FigureSource\n if (!source || !Array.isArray(source.parts)) {\n throw new Error('the AI candidate is not a figure source: parts is missing or not an array')\n }\n\n return {\n source,\n title: '',\n picked: selectedKeys(current, source),\n note: '',\n metrics: {\n grade: proposal.grade,\n triangles: proposal.triangles,\n groups: proposal.groups,\n attempts: proposal.attempts,\n quality: proposal.quality\n }\n }\n}\n\n/** 발행 게이트가 측정한 한-앵커 수정도 같은 후보 세션으로 연다. */\nexport function openSizingFix(current: FigureSource, fix: FigureGateFix): ProposalSession | undefined {\n if (!current.parts.some(part => part.name === fix.part)) return undefined\n\n const source: FigureSource = {\n ...current,\n parts: current.parts.map(part =>\n part.name === fix.part ? { ...part, anchor: { ...(part.anchor ?? {}), [fix.axis]: fix.to } } : part\n )\n }\n\n return {\n source,\n title: '',\n fix,\n picked: selectedKeys(current, source),\n note: ''\n }\n}\n\n/** 후보 검토 중 사람의 선택을 뒤집는다. 기존 Set을 바꾸지 않아 Lit 상태 경계도 보존한다. */\nexport function toggleProposalChange(session: ProposalSession, key: string): ProposalSession {\n const picked = new Set(session.picked)\n if (picked.has(key)) picked.delete(key)\n else picked.add(key)\n return { ...session, picked }\n}\n\n/** 사람의 결정을, 다음 AI 요청에만 쓰는 제한된 피드백으로 만든다. */\nexport function feedbackFromSession(\n current: FigureSource | undefined,\n session: ProposalSession,\n outcome: ProposalFeedback['outcome']\n): ProposalFeedback | undefined {\n if (!session.metrics) return undefined\n\n return {\n outcome,\n selectedChanges: outcome === 'accepted' ? session.picked.size : 0,\n totalChanges: diffProposal(current, session.source).length,\n note: session.note.trim().slice(0, 500) || undefined,\n grade: session.metrics.grade,\n quality: {\n status: session.metrics.quality.status,\n findingCodes: session.metrics.quality.findings.map(finding => finding.code)\n }\n }\n}\n"]}
@@ -1,45 +0,0 @@
1
- import type { FigurePart, FigureSource } from '@hatiolab/figure-model';
2
- /**
3
- * AI 후보를 지금 것과 비교하고, 고른 것만 받아들인다.
4
- *
5
- * 비교는 **저장 형식**으로 한다. 편집 중의 데이터는 씬 모델이지만, 후보는 서버에서
6
- * FigureSource 로 오고 기준 치수·디테일 등급처럼 부품 밖의 값도 함께 봐야 한다.
7
- *
8
- * 부품 편집 규칙은 여기 없다 — `part-edits.ts` 가 씬 모델 용어로 갖고 있다.
9
- */
10
- /**
11
- * 후보가 무엇을 바꾸자는 것인가.
12
- *
13
- * 후보를 통째로만 받게 두면 「몸통은 좋은데 모터 자리가 틀렸다」에 답할 수 없다 —
14
- * 그게 가장 흔한 반응이다. 그래서 후보를 **변경 목록**으로 본다.
15
- *
16
- * 부품은 이름으로 짝짓는다. `FigurePart.name` 이 저장되는 식별자이고 바인딩이
17
- * 그 이름을 가리키므로, 이름이 같으면 같은 부품이다.
18
- *
19
- * 기준 치수와 디테일 등급도 목록에 넣는다. 부품만 골라 받으면 새 부품이 옛 기준
20
- * 상자 밖으로 나가 **검증에 걸릴 수 있다** — 무엇을 함께 받아야 하는지 보여야 한다.
21
- */
22
- export type ChangeKind = 'added' | 'changed' | 'removed' | 'base' | 'placement' | 'detail' | 'styleKit' | 'animations' | 'parameters' | 'capabilities' | 'joints';
23
- export interface ProposalChange {
24
- kind: ChangeKind;
25
- /** 부품 변경이면 부품 이름. 그 밖이면 무엇이 바뀌는지. */
26
- key: string;
27
- /** 부품 변경일 때 후보 쪽 부품. */
28
- part?: FigurePart;
29
- }
30
- export declare function diffProposal(current: FigureSource | undefined, proposal: FigureSource): ProposalChange[];
31
- /**
32
- * 고른 변경만 받는다.
33
- *
34
- * **타입 이름은 지금 것을 지킨다.** 만든 뒤에는 고칠 수 없는 저장 식별자이고,
35
- * 배치된 인스턴스가 그 이름으로 청사진을 찾는다. 후보가 다른 이름을 지어 왔어도
36
- * 그것 때문에 이미 놓인 것이 빈 자리가 되면 안 된다.
37
- *
38
- * `picked` 를 주지 않으면 전부 받는다 — 그것이 보통의 길이다.
39
- *
40
- * 더해지는 부품은 **뒤에 붙인다.** 후보가 놓은 자리에 끼워 넣으면, 일부만 골랐을
41
- * 때 순서가 어디서 온 것인지 알 수 없게 된다.
42
- */
43
- export declare function applyProposal(current: FigureSource | undefined, proposal: FigureSource, picked?: Set<string>): FigureSource;
44
- /** 전부 받는다. */
45
- export declare function takeProposal(current: FigureSource | undefined, proposal: FigureSource): FigureSource;
@@ -1,114 +0,0 @@
1
- /**
2
- * 부품 밖에서 바뀔 수 있는 것 전부.
3
- *
4
- * 여기 없는 필드는 **조용히 버려진다** — 후보가 바꿔 왔어도 목록에 안 뜨고 받아도
5
- * 반영되지 않는다. 형식에 필드가 늘면 여기에 더해야 한다.
6
- *
7
- * 그 일이 실제로 났다(2026-09-15). `animations` · `capabilities` · `placement` 셋이 빠져 있었다.
8
- * 후보가 애니메이션만 더해 오면 변경 목록이 **비어서** 「n건 반영」 단추가 잠기고, 저작자는
9
- * 후보를 받지도 버리지도 못한 채 멈춘다. 다른 변경과 섞여 오면 단추는 눌리는데 애니메이션만
10
- * 말없이 사라진다. 그리고 `anchor` 는 도형 수준 필드였다가 형식에서 없어진 것인데(2026-09-10)
11
- * 여기 남아 있었다 — 이 목록이 형식을 따라가지 않은 것이 양쪽으로 드러난 셈이다.
12
- *
13
- * 같은 일이 `joints` 로 한 번 더 났다(2026-09-18). 형식에 관절이 서고 저작 화면도 지었는데 이
14
- * 목록만 그대로여서, 관절을 더해 온 후보는 변경 목록에 안 뜨고 받아도 관절이 사라졌다. 형식의
15
- * 칸을 세는 시험이 그것을 잡았다.
16
- *
17
- * 형식의 `FigureSource` 는 `type` · `parts` 를 빼면 이 일곱이 전부다. `type` 은 저장되는
18
- * 식별자라 후보가 바꿔 와도 받지 않고(`applyProposal` 참고), `version` 은 저작자가 고를 것이
19
- * 아니라 저장할 때 서버가 찍는다.
20
- */
21
- const FIGURE_FIELDS = ['base', 'placement', 'detailLevel', 'styleKit', 'animations', 'parameters', 'capabilities', 'joints'];
22
- const FIELD_KIND = {
23
- base: 'base',
24
- placement: 'placement',
25
- detailLevel: 'detail',
26
- styleKit: 'styleKit',
27
- animations: 'animations',
28
- parameters: 'parameters',
29
- capabilities: 'capabilities',
30
- joints: 'joints'
31
- };
32
- /** 부품 둘이 같은가. 순서까지 같은 JSON 으로 견준다 — 필드 순서는 만드는 쪽이 정한다. */
33
- function samePart(a, b) {
34
- return JSON.stringify(sortKeys(a)) === JSON.stringify(sortKeys(b));
35
- }
36
- /** 키 순서를 맞춰 견줄 수 있게 한다. */
37
- function sortKeys(value) {
38
- if (Array.isArray(value))
39
- return value.map(sortKeys);
40
- if (value && typeof value === 'object') {
41
- const out = {};
42
- for (const key of Object.keys(value).sort()) {
43
- out[key] = sortKeys(value[key]);
44
- }
45
- return out;
46
- }
47
- return value;
48
- }
49
- export function diffProposal(current, proposal) {
50
- const changes = [];
51
- const mine = new Map((current?.parts ?? []).map(part => [part.name, part]));
52
- const theirs = new Map(proposal.parts.map(part => [part.name, part]));
53
- for (const field of FIGURE_FIELDS) {
54
- if (!current)
55
- break;
56
- const before = JSON.stringify(sortKeys(current[field]));
57
- const after = JSON.stringify(sortKeys(proposal[field]));
58
- if (before !== after) {
59
- changes.push({ kind: FIELD_KIND[field], key: field });
60
- }
61
- }
62
- for (const part of proposal.parts) {
63
- const old = mine.get(part.name);
64
- if (!old)
65
- changes.push({ kind: 'added', key: part.name, part });
66
- else if (!samePart(old, part))
67
- changes.push({ kind: 'changed', key: part.name, part });
68
- }
69
- for (const part of current?.parts ?? []) {
70
- if (!theirs.has(part.name))
71
- changes.push({ kind: 'removed', key: part.name });
72
- }
73
- return changes;
74
- }
75
- /**
76
- * 고른 변경만 받는다.
77
- *
78
- * **타입 이름은 지금 것을 지킨다.** 만든 뒤에는 고칠 수 없는 저장 식별자이고,
79
- * 배치된 인스턴스가 그 이름으로 청사진을 찾는다. 후보가 다른 이름을 지어 왔어도
80
- * 그것 때문에 이미 놓인 것이 빈 자리가 되면 안 된다.
81
- *
82
- * `picked` 를 주지 않으면 전부 받는다 — 그것이 보통의 길이다.
83
- *
84
- * 더해지는 부품은 **뒤에 붙인다.** 후보가 놓은 자리에 끼워 넣으면, 일부만 골랐을
85
- * 때 순서가 어디서 온 것인지 알 수 없게 된다.
86
- */
87
- export function applyProposal(current, proposal, picked) {
88
- const takes = (change) => !picked || picked.has(change.key);
89
- const changes = diffProposal(current, proposal);
90
- if (!current) {
91
- return { ...proposal };
92
- }
93
- const removed = new Set(changes.filter(c => c.kind === 'removed' && takes(c)).map(c => c.key));
94
- const replaced = new Map(changes.filter(c => c.kind === 'changed' && takes(c)).map(c => [c.key, c.part]));
95
- const added = changes.filter(c => c.kind === 'added' && takes(c)).map(c => c.part);
96
- const parts = [
97
- ...current.parts.filter(part => !removed.has(part.name)).map(part => replaced.get(part.name) ?? part),
98
- ...added
99
- ];
100
- // 고른 필드만 후보 값으로 바꾼다. 안 고른 것은 지금 값이 남는다.
101
- const fields = {};
102
- for (const field of FIGURE_FIELDS) {
103
- const change = changes.find(c => c.key === field);
104
- if (change && takes(change)) {
105
- fields[field] = proposal[field];
106
- }
107
- }
108
- return { ...current, ...fields, parts };
109
- }
110
- /** 전부 받는다. */
111
- export function takeProposal(current, proposal) {
112
- return applyProposal(current, proposal);
113
- }
114
- //# sourceMappingURL=proposal.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"proposal.js","sourceRoot":"","sources":["../../client/modeller/proposal.ts"],"names":[],"mappings":"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","sourcesContent":["import type { FigurePart, FigureSource } from '@hatiolab/figure-model'\n\n/**\n * AI 후보를 지금 것과 비교하고, 고른 것만 받아들인다.\n *\n * 비교는 **저장 형식**으로 한다. 편집 중의 데이터는 씬 모델이지만, 후보는 서버에서\n * FigureSource 로 오고 기준 치수·디테일 등급처럼 부품 밖의 값도 함께 봐야 한다.\n *\n * 부품 편집 규칙은 여기 없다 — `part-edits.ts` 가 씬 모델 용어로 갖고 있다.\n */\n\n/**\n * 후보가 무엇을 바꾸자는 것인가.\n *\n * 후보를 통째로만 받게 두면 「몸통은 좋은데 모터 자리가 틀렸다」에 답할 수 없다 —\n * 그게 가장 흔한 반응이다. 그래서 후보를 **변경 목록**으로 본다.\n *\n * 부품은 이름으로 짝짓는다. `FigurePart.name` 이 저장되는 식별자이고 바인딩이\n * 그 이름을 가리키므로, 이름이 같으면 같은 부품이다.\n *\n * 기준 치수와 디테일 등급도 목록에 넣는다. 부품만 골라 받으면 새 부품이 옛 기준\n * 상자 밖으로 나가 **검증에 걸릴 수 있다** — 무엇을 함께 받아야 하는지 보여야 한다.\n */\nexport type ChangeKind =\n | 'added'\n | 'changed'\n | 'removed'\n | 'base'\n | 'placement'\n | 'detail'\n | 'styleKit'\n | 'animations'\n | 'parameters'\n | 'capabilities'\n | 'joints'\n\n/**\n * 부품 밖에서 바뀔 수 있는 것 전부.\n *\n * 여기 없는 필드는 **조용히 버려진다** — 후보가 바꿔 왔어도 목록에 안 뜨고 받아도\n * 반영되지 않는다. 형식에 필드가 늘면 여기에 더해야 한다.\n *\n * 그 일이 실제로 났다(2026-09-15). `animations` · `capabilities` · `placement` 셋이 빠져 있었다.\n * 후보가 애니메이션만 더해 오면 변경 목록이 **비어서** 「n건 반영」 단추가 잠기고, 저작자는\n * 후보를 받지도 버리지도 못한 채 멈춘다. 다른 변경과 섞여 오면 단추는 눌리는데 애니메이션만\n * 말없이 사라진다. 그리고 `anchor` 는 도형 수준 필드였다가 형식에서 없어진 것인데(2026-09-10)\n * 여기 남아 있었다 — 이 목록이 형식을 따라가지 않은 것이 양쪽으로 드러난 셈이다.\n *\n * 같은 일이 `joints` 로 한 번 더 났다(2026-09-18). 형식에 관절이 서고 저작 화면도 지었는데 이\n * 목록만 그대로여서, 관절을 더해 온 후보는 변경 목록에 안 뜨고 받아도 관절이 사라졌다. 형식의\n * 칸을 세는 시험이 그것을 잡았다.\n *\n * 형식의 `FigureSource` 는 `type` · `parts` 를 빼면 이 일곱이 전부다. `type` 은 저장되는\n * 식별자라 후보가 바꿔 와도 받지 않고(`applyProposal` 참고), `version` 은 저작자가 고를 것이\n * 아니라 저장할 때 서버가 찍는다.\n */\nconst FIGURE_FIELDS = ['base', 'placement', 'detailLevel', 'styleKit', 'animations', 'parameters', 'capabilities', 'joints'] as const\nconst FIELD_KIND: Record<(typeof FIGURE_FIELDS)[number], ChangeKind> = {\n base: 'base',\n placement: 'placement',\n detailLevel: 'detail',\n styleKit: 'styleKit',\n animations: 'animations',\n parameters: 'parameters',\n capabilities: 'capabilities',\n joints: 'joints'\n}\n\nexport interface ProposalChange {\n kind: ChangeKind\n /** 부품 변경이면 부품 이름. 그 밖이면 무엇이 바뀌는지. */\n key: string\n /** 부품 변경일 때 후보 쪽 부품. */\n part?: FigurePart\n}\n\n/** 부품 둘이 같은가. 순서까지 같은 JSON 으로 견준다 — 필드 순서는 만드는 쪽이 정한다. */\nfunction samePart(a: FigurePart, b: FigurePart): boolean {\n return JSON.stringify(sortKeys(a)) === JSON.stringify(sortKeys(b))\n}\n\n/** 키 순서를 맞춰 견줄 수 있게 한다. */\nfunction sortKeys(value: unknown): unknown {\n if (Array.isArray(value)) return value.map(sortKeys)\n if (value && typeof value === 'object') {\n const out: Record<string, unknown> = {}\n for (const key of Object.keys(value as object).sort()) {\n out[key] = sortKeys((value as Record<string, unknown>)[key])\n }\n return out\n }\n return value\n}\n\nexport function diffProposal(current: FigureSource | undefined, proposal: FigureSource): ProposalChange[] {\n const changes: ProposalChange[] = []\n const mine = new Map((current?.parts ?? []).map(part => [part.name, part]))\n const theirs = new Map(proposal.parts.map(part => [part.name, part]))\n\n for (const field of FIGURE_FIELDS) {\n if (!current) break\n const before = JSON.stringify(sortKeys(current[field]))\n const after = JSON.stringify(sortKeys(proposal[field]))\n if (before !== after) {\n changes.push({ kind: FIELD_KIND[field], key: field })\n }\n }\n\n for (const part of proposal.parts) {\n const old = mine.get(part.name)\n if (!old) changes.push({ kind: 'added', key: part.name, part })\n else if (!samePart(old, part)) changes.push({ kind: 'changed', key: part.name, part })\n }\n for (const part of current?.parts ?? []) {\n if (!theirs.has(part.name)) changes.push({ kind: 'removed', key: part.name })\n }\n\n return changes\n}\n\n/**\n * 고른 변경만 받는다.\n *\n * **타입 이름은 지금 것을 지킨다.** 만든 뒤에는 고칠 수 없는 저장 식별자이고,\n * 배치된 인스턴스가 그 이름으로 청사진을 찾는다. 후보가 다른 이름을 지어 왔어도\n * 그것 때문에 이미 놓인 것이 빈 자리가 되면 안 된다.\n *\n * `picked` 를 주지 않으면 전부 받는다 — 그것이 보통의 길이다.\n *\n * 더해지는 부품은 **뒤에 붙인다.** 후보가 놓은 자리에 끼워 넣으면, 일부만 골랐을\n * 때 순서가 어디서 온 것인지 알 수 없게 된다.\n */\nexport function applyProposal(\n current: FigureSource | undefined,\n proposal: FigureSource,\n picked?: Set<string>\n): FigureSource {\n const takes = (change: ProposalChange) => !picked || picked.has(change.key)\n const changes = diffProposal(current, proposal)\n\n if (!current) {\n return { ...proposal }\n }\n\n const removed = new Set(changes.filter(c => c.kind === 'removed' && takes(c)).map(c => c.key))\n const replaced = new Map(\n changes.filter(c => c.kind === 'changed' && takes(c)).map(c => [c.key, c.part as FigurePart])\n )\n const added = changes.filter(c => c.kind === 'added' && takes(c)).map(c => c.part as FigurePart)\n\n const parts = [\n ...current.parts.filter(part => !removed.has(part.name)).map(part => replaced.get(part.name) ?? part),\n ...added\n ]\n\n // 고른 필드만 후보 값으로 바꾼다. 안 고른 것은 지금 값이 남는다.\n const fields: Record<string, unknown> = {}\n for (const field of FIGURE_FIELDS) {\n const change = changes.find(c => c.key === field)\n if (change && takes(change)) {\n fields[field] = proposal[field]\n }\n }\n\n return { ...current, ...fields, parts } as FigureSource\n}\n\n/** 전부 받는다. */\nexport function takeProposal(current: FigureSource | undefined, proposal: FigureSource): FigureSource {\n return applyProposal(current, proposal)\n}\n"]}