@things-factory/figure-ui 10.1.27 → 10.1.29

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 (91) hide show
  1. package/client/graphql/index.ts +23 -0
  2. package/client/modeller/angle-dial.ts +70 -0
  3. package/client/modeller/base-box.ts +104 -0
  4. package/client/modeller/figure-animations.ts +90 -17
  5. package/client/modeller/figure-canvas.ts +24 -6
  6. package/client/modeller/figure-inspector.ts +169 -5
  7. package/client/modeller/figure-preview.ts +110 -3
  8. package/client/modeller/figure-report.ts +20 -32
  9. package/client/modeller/figure-side.ts +10 -1
  10. package/client/modeller/figure-source.ts +8 -0
  11. package/client/modeller/joint-edits.ts +66 -5
  12. package/client/modeller/joint-gizmo.ts +180 -0
  13. package/client/modeller/part-edits.ts +75 -9
  14. package/client/modeller/pose-defaults.ts +67 -0
  15. package/client/modeller/proposal.ts +9 -3
  16. package/client/modeller/scene-budget.ts +31 -40
  17. package/client/pages/figure-list-page.ts +4 -9
  18. package/client/pages/figure-modeller-page.ts +144 -43
  19. package/client/types.ts +2 -0
  20. package/dist-client/graphql/index.d.ts +7 -0
  21. package/dist-client/graphql/index.js +21 -0
  22. package/dist-client/graphql/index.js.map +1 -1
  23. package/dist-client/modeller/angle-dial.d.ts +32 -0
  24. package/dist-client/modeller/angle-dial.js +41 -0
  25. package/dist-client/modeller/angle-dial.js.map +1 -0
  26. package/dist-client/modeller/base-box.d.ts +47 -0
  27. package/dist-client/modeller/base-box.js +52 -0
  28. package/dist-client/modeller/base-box.js.map +1 -0
  29. package/dist-client/modeller/figure-animations.d.ts +19 -4
  30. package/dist-client/modeller/figure-animations.js +86 -17
  31. package/dist-client/modeller/figure-animations.js.map +1 -1
  32. package/dist-client/modeller/figure-canvas.d.ts +9 -0
  33. package/dist-client/modeller/figure-canvas.js +20 -6
  34. package/dist-client/modeller/figure-canvas.js.map +1 -1
  35. package/dist-client/modeller/figure-inspector.d.ts +37 -0
  36. package/dist-client/modeller/figure-inspector.js +159 -5
  37. package/dist-client/modeller/figure-inspector.js.map +1 -1
  38. package/dist-client/modeller/figure-preview.d.ts +26 -0
  39. package/dist-client/modeller/figure-preview.js +99 -3
  40. package/dist-client/modeller/figure-preview.js.map +1 -1
  41. package/dist-client/modeller/figure-report.d.ts +11 -6
  42. package/dist-client/modeller/figure-report.js +20 -35
  43. package/dist-client/modeller/figure-report.js.map +1 -1
  44. package/dist-client/modeller/figure-side.d.ts +5 -1
  45. package/dist-client/modeller/figure-side.js +13 -0
  46. package/dist-client/modeller/figure-side.js.map +1 -1
  47. package/dist-client/modeller/figure-source.d.ts +7 -0
  48. package/dist-client/modeller/figure-source.js.map +1 -1
  49. package/dist-client/modeller/joint-edits.d.ts +23 -3
  50. package/dist-client/modeller/joint-edits.js +53 -5
  51. package/dist-client/modeller/joint-edits.js.map +1 -1
  52. package/dist-client/modeller/joint-gizmo.d.ts +34 -0
  53. package/dist-client/modeller/joint-gizmo.js +158 -0
  54. package/dist-client/modeller/joint-gizmo.js.map +1 -0
  55. package/dist-client/modeller/part-edits.d.ts +29 -4
  56. package/dist-client/modeller/part-edits.js +61 -9
  57. package/dist-client/modeller/part-edits.js.map +1 -1
  58. package/dist-client/modeller/pose-defaults.d.ts +9 -0
  59. package/dist-client/modeller/pose-defaults.js +35 -0
  60. package/dist-client/modeller/pose-defaults.js.map +1 -0
  61. package/dist-client/modeller/proposal.d.ts +1 -1
  62. package/dist-client/modeller/proposal.js +8 -3
  63. package/dist-client/modeller/proposal.js.map +1 -1
  64. package/dist-client/modeller/scene-budget.d.ts +31 -34
  65. package/dist-client/modeller/scene-budget.js +28 -37
  66. package/dist-client/modeller/scene-budget.js.map +1 -1
  67. package/dist-client/pages/figure-list-page.js +4 -9
  68. package/dist-client/pages/figure-list-page.js.map +1 -1
  69. package/dist-client/pages/figure-modeller-page.d.ts +50 -0
  70. package/dist-client/pages/figure-modeller-page.js +141 -42
  71. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  72. package/dist-client/tsconfig.tsbuildinfo +1 -1
  73. package/dist-client/types.d.ts +2 -0
  74. package/dist-client/types.js.map +1 -1
  75. package/package.json +5 -5
  76. package/test/ai-proposal-contract.test.ts +16 -11
  77. package/test/angle-dial.test.ts +78 -0
  78. package/test/base-box.test.ts +165 -0
  79. package/test/expect.ts +2 -0
  80. package/test/i18n-prefix-guard.test.ts +17 -0
  81. package/test/joint-edits.test.ts +37 -2
  82. package/test/joint-gizmo.test.ts +174 -0
  83. package/test/joint-needle.test.ts +162 -0
  84. package/test/part-edits.test.ts +100 -0
  85. package/test/pose-defaults.test.ts +71 -0
  86. package/test/scene-budget.test.ts +82 -0
  87. package/translations/en.json +16 -9
  88. package/translations/ja.json +16 -9
  89. package/translations/ko.json +16 -9
  90. package/translations/ms.json +16 -9
  91. package/translations/zh.json +16 -9
@@ -30,6 +30,55 @@ export function parentChoices(parts, part) {
30
30
  }
31
31
  return parts.map(one => one.name).filter(name => !!name && !below.has(name));
32
32
  }
33
+ /** 한 축에서 부품이 차지하는 구간. */
34
+ const reachOf = (part, axis) => ({
35
+ min: part.transform.position[axis] - part.transform.size[axis] / 2,
36
+ max: part.transform.position[axis] + part.transform.size[axis] / 2
37
+ });
38
+ /**
39
+ * 새 관절이 설 자리 — **부품이 붙은 부품과 맞닿는 면의 한가운데.**
40
+ *
41
+ * 경첩은 두 부품이 닿는 자리에 있다. 팔꿈치는 윗팔과 앞팔이 만나는 곳이고, 문은 문틀에 닿는
42
+ * 모서리에 있다. 그래서 처음 값은 그 자리여야 한다.
43
+ *
44
+ * 한동안 **부품 아랫면 한가운데**를 썼다. 위로 쌓는 부품에는 맞는 자리인데(로봇 팔이 그렇다),
45
+ * 옆에 달린 부품에는 틀린다 — 캐비닛 옆에 달린 문의 축이 문 바닥에 생긴다. 저작자는 그것을
46
+ * 화면에서 보고 손으로 옮겨야 했고, 어디로 옮길지는 두 부품의 치수를 머릿속에서 빼야 나왔다.
47
+ *
48
+ * **가장 벌어진 축이 맞닿는 축이다.** 두 상자가 겹치는 축에서는 뺀 값이 음수이고, 마주 보는
49
+ * 축에서만 0 이나 양수가 된다. 나머지 두 축은 겹치는 구간의 한가운데를 쓴다 — 문의 축은 문
50
+ * 높이의 한가운데에 있지 위아래 끝에 있지 않다.
51
+ *
52
+ * 붙은 부품이 없으면(Figure 틀에 직접 붙는다) 아랫면 한가운데다. 그때 맞닿는 것은 바닥이다.
53
+ *
54
+ * ⚠ 부품의 제 회전은 보지 않는다. 비스듬히 세운 부품에서는 축에 나란한 상자로 셈한 자리가
55
+ * 나오고, 그것은 **처음 값**이다 — 저작자가 옮길 수 있고, 화면의 부채꼴이 어디인지 보여 준다.
56
+ */
57
+ export function seamOrigin(part, parent) {
58
+ const { position, size } = part.transform;
59
+ if (!parent)
60
+ return { x: position.x, y: position.y - size.y / 2, z: position.z };
61
+ const axes = ['x', 'y', 'z'];
62
+ const gaps = axes.map(axis => {
63
+ const mine = reachOf(part, axis);
64
+ const theirs = reachOf(parent, axis);
65
+ return { axis, gap: Math.max(mine.min - theirs.max, theirs.min - mine.max), mine, theirs };
66
+ });
67
+ const meeting = gaps.reduce((widest, one) => (one.gap > widest.gap ? one : widest));
68
+ const origin = { x: position.x, y: position.y, z: position.z };
69
+ for (const { axis, mine, theirs } of gaps) {
70
+ if (axis === meeting.axis) {
71
+ /* 맞닿는 면은 **내 면**이다. 부모 쪽 면을 쓰면 둘 사이가 벌어져 있을 때 축이 허공에 선다. */
72
+ origin[axis] = mine.min >= theirs.max ? mine.min : mine.max;
73
+ continue;
74
+ }
75
+ const from = Math.max(mine.min, theirs.min);
76
+ const to = Math.min(mine.max, theirs.max);
77
+ origin[axis] = from <= to ? (from + to) / 2 : position[axis];
78
+ }
79
+ return { x: round4(origin.x), y: round4(origin.y), z: round4(origin.z) };
80
+ }
81
+ const round4 = (value) => Math.round(value * 10000) / 10000;
33
82
  function freeName(taken, stem) {
34
83
  const used = new Set(taken);
35
84
  if (!used.has(stem))
@@ -85,14 +134,14 @@ function isPlainDriver(parameter, joint, span) {
85
134
  /**
86
135
  * Sets how a part moves relative to its parent, or clears the joint with `undefined`.
87
136
  *
88
- * - From none: adds a joint whose origin is the centre of the part's bottom face, with a vertical axis and
89
- * starting limits, and a parameter that drives it over those limits.
137
+ * - From none: adds a joint at the seam with the parent (`seamOrigin`), with a vertical axis and starting
138
+ * limits, and a parameter that drives it over those limits.
90
139
  * - To none: removes the joint and every channel that drove it; a parameter or clip left with no channel
91
140
  * is removed too, because the format refuses an empty one.
92
141
  * - Between types: keeps the origin and axis, sets limits for the new type (none for continuous), and
93
142
  * moves a plain driving parameter along with them.
94
143
  */
95
- export function setJointType(draft, part, type) {
144
+ export function setJointType(draft, part, type, parent) {
96
145
  const joints = draft.joints ?? [];
97
146
  const existing = joints.find(joint => joint.child === part.name);
98
147
  if (type === undefined) {
@@ -101,12 +150,11 @@ export function setJointType(draft, part, type) {
101
150
  return withoutJoint(draft, existing.name);
102
151
  }
103
152
  if (!existing) {
104
- const { position, size } = part.transform;
105
153
  const joint = {
106
154
  name: freeName([...joints.map(one => one.name)], `${part.name}-joint`),
107
155
  child: part.name,
108
156
  type,
109
- origin: { x: position.x, y: position.y - size.y / 2, z: position.z },
157
+ origin: seamOrigin(part, parent),
110
158
  axis: { x: 0, y: 1, z: 0 }
111
159
  };
112
160
  if (type !== 'continuous')
@@ -1 +1 @@
1
- {"version":3,"file":"joint-edits.js","sourceRoot":"","sources":["../../client/modeller/joint-edits.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Copyright © HatioLab Inc. All rights reserved.\n */\n\nimport type { FigureJoint, FigurePart, JointType, Vec3 } from '@hatiolab/figure-model'\n\nimport type { FigureDraft, PartModel } from './figure-source.js'\n\n/**\n * Editing joints and parents (ADR-0066). Pure functions over the draft and the part list, so the inspector\n * only renders and dispatches.\n *\n * A joint is always created together with the parameter that drives it. Values reach a joint only through\n * `parameters`, and the preview draws a slider per parameter, so a joint without one could be defined but\n * never seen moving.\n */\n\ntype Parameter = NonNullable<FigureDraft['parameters']>[number]\n\n/** The limits a new joint starts with. Revolute in degrees, prismatic in millimetres. */\nconst NEW_LIMITS: Record<Exclude<JointType, 'continuous'>, { min: number; max: number }> = {\n revolute: { min: -90, max: 90 },\n prismatic: { min: 0, max: 500 }\n}\n\n/** The span a parameter driving a continuous joint offers: one turn either way. */\nconst CONTINUOUS_SPAN = { min: -360, max: 360 }\n\n/** The joint whose child is this part, if any. */\nexport function jointOf(draft: FigureDraft | undefined, part: string): FigureJoint | undefined {\n return draft?.joints?.find(joint => joint.child === part)\n}\n\n/**\n * Parts a part may be attached to: every other part except itself and anything attached below it, since\n * either would close a loop the format refuses (`parent-cycle`).\n */\nexport function parentChoices(parts: readonly PartModel[], part: string): string[] {\n const below = new Set<string>([part])\n let grew = true\n while (grew) {\n grew = false\n for (const one of parts) {\n if (one.parent !== undefined && below.has(one.parent) && !below.has(one.name)) {\n below.add(one.name)\n grew = true\n }\n }\n }\n return parts.map(one => one.name).filter(name => !!name && !below.has(name))\n}\n\nfunction freeName(taken: Iterable<string>, stem: string): string {\n const used = new Set(taken)\n if (!used.has(stem)) return stem\n for (let n = 2; ; n++) if (!used.has(`${stem}-${n}`)) return `${stem}-${n}`\n}\n\n/** The span the driving parameter offers for a joint: its limits, or one turn either way when it has none. */\nfunction spanOf(joint: FigureJoint): { min: number; max: number } {\n return joint.limits ?? CONTINUOUS_SPAN\n}\n\nfunction drivingParameter(joint: FigureJoint): Parameter {\n const span = spanOf(joint)\n return {\n name: joint.name,\n range: { unit: joint.type === 'prismatic' ? 'mm' : 'deg', min: span.min, max: span.max },\n default: 0,\n clip: {\n channels: [\n {\n target: joint.name,\n keys: [\n { at: 0, value: span.min },\n { at: 1, value: span.max }\n ]\n }\n ]\n }\n }\n}\n\n/**\n * Whether a parameter is the plain one `setJointType` made for a joint: one channel on that joint, keys at\n * 0 and 1 spanning the given limits. Only such a parameter follows the joint when its limits or type change;\n * one the author reshaped is left as written.\n */\nfunction isPlainDriver(parameter: Parameter, joint: FigureJoint, span: { min: number; max: number }): boolean {\n const channels = parameter.clip?.channels ?? []\n if (channels.length !== 1) return false\n const [channel] = channels\n if (channel!.target !== joint.name || channel!.path !== undefined) return false\n const keys = channel!.keys as { at: number; value: number }[]\n return (\n keys.length === 2 &&\n keys[0]!.at === 0 &&\n keys[1]!.at === 1 &&\n keys[0]!.value === span.min &&\n keys[1]!.value === span.max &&\n parameter.range?.min === span.min &&\n parameter.range?.max === span.max\n )\n}\n\n/**\n * Sets how a part moves relative to its parent, or clears the joint with `undefined`.\n *\n * - From none: adds a joint whose origin is the centre of the part's bottom face, with a vertical axis and\n * starting limits, and a parameter that drives it over those limits.\n * - To none: removes the joint and every channel that drove it; a parameter or clip left with no channel\n * is removed too, because the format refuses an empty one.\n * - Between types: keeps the origin and axis, sets limits for the new type (none for continuous), and\n * moves a plain driving parameter along with them.\n */\nexport function setJointType(draft: FigureDraft, part: FigurePart, type: JointType | undefined): FigureDraft {\n const joints = draft.joints ?? []\n const existing = joints.find(joint => joint.child === part.name)\n\n if (type === undefined) {\n if (!existing) return draft\n return withoutJoint(draft, existing.name)\n }\n\n if (!existing) {\n const { position, size } = part.transform\n const joint: FigureJoint = {\n name: freeName([...joints.map(one => one.name)], `${part.name}-joint`),\n child: part.name,\n type,\n origin: { x: position.x, y: position.y - size.y / 2, z: position.z },\n axis: { x: 0, y: 1, z: 0 }\n }\n if (type !== 'continuous') joint.limits = { ...NEW_LIMITS[type] }\n\n const parameters = draft.parameters ?? []\n const taken = [...parameters.map(one => one.name), ...(draft.animations ?? []).map(one => one.name)]\n const driver = drivingParameter(joint)\n driver.name = freeName(taken, joint.name)\n\n return { ...draft, joints: [...joints, joint], parameters: [...parameters, driver] }\n }\n\n if (existing.type === type) return draft\n\n const next: FigureJoint = { ...existing, type }\n if (type === 'continuous') delete next.limits\n else if (existing.type === 'continuous' || (existing.type === 'prismatic') !== (type === 'prismatic')) {\n next.limits = { ...NEW_LIMITS[type] }\n }\n\n return replaceJoint(draft, existing, next)\n}\n\n/** Changes a joint's origin, axis or limits. A plain driving parameter follows new limits. */\nexport function patchJoint(\n draft: FigureDraft,\n name: string,\n patch: { origin?: Vec3; axis?: Vec3; limits?: { min: number; max: number } }\n): FigureDraft {\n const existing = draft.joints?.find(joint => joint.name === name)\n if (!existing) return draft\n const next: FigureJoint = { ...existing, ...patch }\n if (existing.type === 'continuous') delete next.limits\n return replaceJoint(draft, existing, next)\n}\n\nfunction replaceJoint(draft: FigureDraft, before: FigureJoint, after: FigureJoint): FigureDraft {\n const joints = (draft.joints ?? []).map(joint => (joint === before ? after : joint))\n const was = spanOf(before)\n const unitChanged = (before.type === 'prismatic') !== (after.type === 'prismatic')\n const spanChanged = was.min !== spanOf(after).min || was.max !== spanOf(after).max\n\n if (!unitChanged && !spanChanged) return { ...draft, joints }\n\n const parameters = (draft.parameters ?? []).map(parameter => {\n if (!isPlainDriver(parameter, before, was)) return parameter\n const driver = drivingParameter(after)\n return { ...parameter, range: driver.range, clip: { ...parameter.clip, channels: driver.clip.channels } }\n })\n return { ...draft, joints, parameters }\n}\n\nfunction withoutJoint(draft: FigureDraft, name: string): FigureDraft {\n const keep = <T extends { channels?: { target: string }[] }>(one: T) => (one.channels ?? []).filter(channel => channel.target !== name)\n\n const parameters = (draft.parameters ?? [])\n .map(parameter => ({ ...parameter, clip: { ...parameter.clip, channels: keep(parameter.clip) } }) as Parameter)\n .filter(parameter => parameter.clip.channels.length > 0)\n const animations = (draft.animations ?? [])\n .map(clip => ({ ...clip, channels: keep(clip) }) as NonNullable<FigureDraft['animations']>[number])\n .filter(clip => clip.channels.length > 0)\n const joints = (draft.joints ?? []).filter(joint => joint.name !== name)\n\n const next: FigureDraft = { ...draft }\n if (joints.length > 0) next.joints = joints\n else delete next.joints\n if (draft.parameters !== undefined) next.parameters = parameters\n if (draft.animations !== undefined) next.animations = animations\n return next\n}\n"]}
1
+ {"version":3,"file":"joint-edits.js","sourceRoot":"","sources":["../../client/modeller/joint-edits.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Copyright © HatioLab Inc. All rights reserved.\n */\n\nimport type { FigureJoint, FigurePart, JointType, Vec3 } from '@hatiolab/figure-model'\n\nimport type { FigureDraft, PartModel } from './figure-source.js'\n\n/**\n * Editing joints and parents (ADR-0066). Pure functions over the draft and the part list, so the inspector\n * only renders and dispatches.\n *\n * A joint is always created together with the parameter that drives it. Values reach a joint only through\n * `parameters`, and the preview draws a slider per parameter, so a joint without one could be defined but\n * never seen moving.\n */\n\ntype Parameter = NonNullable<FigureDraft['parameters']>[number]\n\n/** The limits a new joint starts with. Revolute in degrees, prismatic in millimetres. */\nconst NEW_LIMITS: Record<Exclude<JointType, 'continuous'>, { min: number; max: number }> = {\n revolute: { min: -90, max: 90 },\n prismatic: { min: 0, max: 500 }\n}\n\n/** The span a parameter driving a continuous joint offers: one turn either way. */\nconst CONTINUOUS_SPAN = { min: -360, max: 360 }\n\n/** The joint whose child is this part, if any. */\nexport function jointOf(draft: FigureDraft | undefined, part: string): FigureJoint | undefined {\n return draft?.joints?.find(joint => joint.child === part)\n}\n\n/**\n * Parts a part may be attached to: every other part except itself and anything attached below it, since\n * either would close a loop the format refuses (`parent-cycle`).\n */\nexport function parentChoices(parts: readonly PartModel[], part: string): string[] {\n const below = new Set<string>([part])\n let grew = true\n while (grew) {\n grew = false\n for (const one of parts) {\n if (one.parent !== undefined && below.has(one.parent) && !below.has(one.name)) {\n below.add(one.name)\n grew = true\n }\n }\n }\n return parts.map(one => one.name).filter(name => !!name && !below.has(name))\n}\n\ntype Span = { min: number; max: number }\n\n/** 한 축에서 부품이 차지하는 구간. */\nconst reachOf = (part: FigurePart, axis: 'x' | 'y' | 'z'): Span => ({\n min: part.transform.position[axis] - part.transform.size[axis] / 2,\n max: part.transform.position[axis] + part.transform.size[axis] / 2\n})\n\n/**\n * 새 관절이 설 자리 — **부품이 붙은 부품과 맞닿는 면의 한가운데.**\n *\n * 경첩은 두 부품이 닿는 자리에 있다. 팔꿈치는 윗팔과 앞팔이 만나는 곳이고, 문은 문틀에 닿는\n * 모서리에 있다. 그래서 처음 값은 그 자리여야 한다.\n *\n * 한동안 **부품 아랫면 한가운데**를 썼다. 위로 쌓는 부품에는 맞는 자리인데(로봇 팔이 그렇다),\n * 옆에 달린 부품에는 틀린다 — 캐비닛 옆에 달린 문의 축이 문 바닥에 생긴다. 저작자는 그것을\n * 화면에서 보고 손으로 옮겨야 했고, 어디로 옮길지는 두 부품의 치수를 머릿속에서 빼야 나왔다.\n *\n * **가장 벌어진 축이 맞닿는 축이다.** 두 상자가 겹치는 축에서는 뺀 값이 음수이고, 마주 보는\n * 축에서만 0 이나 양수가 된다. 나머지 두 축은 겹치는 구간의 한가운데를 쓴다 — 문의 축은 문\n * 높이의 한가운데에 있지 위아래 끝에 있지 않다.\n *\n * 붙은 부품이 없으면(Figure 틀에 직접 붙는다) 아랫면 한가운데다. 그때 맞닿는 것은 바닥이다.\n *\n * ⚠ 부품의 제 회전은 보지 않는다. 비스듬히 세운 부품에서는 축에 나란한 상자로 셈한 자리가\n * 나오고, 그것은 **처음 값**이다 — 저작자가 옮길 수 있고, 화면의 부채꼴이 어디인지 보여 준다.\n */\nexport function seamOrigin(part: FigurePart, parent?: FigurePart): Vec3 {\n const { position, size } = part.transform\n if (!parent) return { x: position.x, y: position.y - size.y / 2, z: position.z }\n\n const axes = ['x', 'y', 'z'] as const\n const gaps = axes.map(axis => {\n const mine = reachOf(part, axis)\n const theirs = reachOf(parent, axis)\n return { axis, gap: Math.max(mine.min - theirs.max, theirs.min - mine.max), mine, theirs }\n })\n\n const meeting = gaps.reduce((widest, one) => (one.gap > widest.gap ? one : widest))\n const origin = { x: position.x, y: position.y, z: position.z }\n\n for (const { axis, mine, theirs } of gaps) {\n if (axis === meeting.axis) {\n /* 맞닿는 면은 **내 면**이다. 부모 쪽 면을 쓰면 둘 사이가 벌어져 있을 때 축이 허공에 선다. */\n origin[axis] = mine.min >= theirs.max ? mine.min : mine.max\n continue\n }\n const from = Math.max(mine.min, theirs.min)\n const to = Math.min(mine.max, theirs.max)\n origin[axis] = from <= to ? (from + to) / 2 : position[axis]\n }\n\n return { x: round4(origin.x), y: round4(origin.y), z: round4(origin.z) }\n}\n\nconst round4 = (value: number) => Math.round(value * 10000) / 10000\n\nfunction freeName(taken: Iterable<string>, stem: string): string {\n const used = new Set(taken)\n if (!used.has(stem)) return stem\n for (let n = 2; ; n++) if (!used.has(`${stem}-${n}`)) return `${stem}-${n}`\n}\n\n/** The span the driving parameter offers for a joint: its limits, or one turn either way when it has none. */\nfunction spanOf(joint: FigureJoint): { min: number; max: number } {\n return joint.limits ?? CONTINUOUS_SPAN\n}\n\nfunction drivingParameter(joint: FigureJoint): Parameter {\n const span = spanOf(joint)\n return {\n name: joint.name,\n range: { unit: joint.type === 'prismatic' ? 'mm' : 'deg', min: span.min, max: span.max },\n default: 0,\n clip: {\n channels: [\n {\n target: joint.name,\n keys: [\n { at: 0, value: span.min },\n { at: 1, value: span.max }\n ]\n }\n ]\n }\n }\n}\n\n/**\n * Whether a parameter is the plain one `setJointType` made for a joint: one channel on that joint, keys at\n * 0 and 1 spanning the given limits. Only such a parameter follows the joint when its limits or type change;\n * one the author reshaped is left as written.\n */\nfunction isPlainDriver(parameter: Parameter, joint: FigureJoint, span: { min: number; max: number }): boolean {\n const channels = parameter.clip?.channels ?? []\n if (channels.length !== 1) return false\n const [channel] = channels\n if (channel!.target !== joint.name || channel!.path !== undefined) return false\n const keys = channel!.keys as { at: number; value: number }[]\n return (\n keys.length === 2 &&\n keys[0]!.at === 0 &&\n keys[1]!.at === 1 &&\n keys[0]!.value === span.min &&\n keys[1]!.value === span.max &&\n parameter.range?.min === span.min &&\n parameter.range?.max === span.max\n )\n}\n\n/**\n * Sets how a part moves relative to its parent, or clears the joint with `undefined`.\n *\n * - From none: adds a joint at the seam with the parent (`seamOrigin`), with a vertical axis and starting\n * limits, and a parameter that drives it over those limits.\n * - To none: removes the joint and every channel that drove it; a parameter or clip left with no channel\n * is removed too, because the format refuses an empty one.\n * - Between types: keeps the origin and axis, sets limits for the new type (none for continuous), and\n * moves a plain driving parameter along with them.\n */\nexport function setJointType(\n draft: FigureDraft,\n part: FigurePart,\n type: JointType | undefined,\n parent?: FigurePart\n): FigureDraft {\n const joints = draft.joints ?? []\n const existing = joints.find(joint => joint.child === part.name)\n\n if (type === undefined) {\n if (!existing) return draft\n return withoutJoint(draft, existing.name)\n }\n\n if (!existing) {\n const joint: FigureJoint = {\n name: freeName([...joints.map(one => one.name)], `${part.name}-joint`),\n child: part.name,\n type,\n origin: seamOrigin(part, parent),\n axis: { x: 0, y: 1, z: 0 }\n }\n if (type !== 'continuous') joint.limits = { ...NEW_LIMITS[type] }\n\n const parameters = draft.parameters ?? []\n const taken = [...parameters.map(one => one.name), ...(draft.animations ?? []).map(one => one.name)]\n const driver = drivingParameter(joint)\n driver.name = freeName(taken, joint.name)\n\n return { ...draft, joints: [...joints, joint], parameters: [...parameters, driver] }\n }\n\n if (existing.type === type) return draft\n\n const next: FigureJoint = { ...existing, type }\n if (type === 'continuous') delete next.limits\n else if (existing.type === 'continuous' || (existing.type === 'prismatic') !== (type === 'prismatic')) {\n next.limits = { ...NEW_LIMITS[type] }\n }\n\n return replaceJoint(draft, existing, next)\n}\n\n/** Changes a joint's origin, axis or limits. A plain driving parameter follows new limits. */\nexport function patchJoint(\n draft: FigureDraft,\n name: string,\n patch: { origin?: Vec3; axis?: Vec3; limits?: { min: number; max: number } }\n): FigureDraft {\n const existing = draft.joints?.find(joint => joint.name === name)\n if (!existing) return draft\n const next: FigureJoint = { ...existing, ...patch }\n if (existing.type === 'continuous') delete next.limits\n return replaceJoint(draft, existing, next)\n}\n\nfunction replaceJoint(draft: FigureDraft, before: FigureJoint, after: FigureJoint): FigureDraft {\n const joints = (draft.joints ?? []).map(joint => (joint === before ? after : joint))\n const was = spanOf(before)\n const unitChanged = (before.type === 'prismatic') !== (after.type === 'prismatic')\n const spanChanged = was.min !== spanOf(after).min || was.max !== spanOf(after).max\n\n if (!unitChanged && !spanChanged) return { ...draft, joints }\n\n const parameters = (draft.parameters ?? []).map(parameter => {\n if (!isPlainDriver(parameter, before, was)) return parameter\n const driver = drivingParameter(after)\n return { ...parameter, range: driver.range, clip: { ...parameter.clip, channels: driver.clip.channels } }\n })\n return { ...draft, joints, parameters }\n}\n\nfunction withoutJoint(draft: FigureDraft, name: string): FigureDraft {\n const keep = <T extends { channels?: { target: string }[] }>(one: T) => (one.channels ?? []).filter(channel => channel.target !== name)\n\n const parameters = (draft.parameters ?? [])\n .map(parameter => ({ ...parameter, clip: { ...parameter.clip, channels: keep(parameter.clip) } }) as Parameter)\n .filter(parameter => parameter.clip.channels.length > 0)\n const animations = (draft.animations ?? [])\n .map(clip => ({ ...clip, channels: keep(clip) }) as NonNullable<FigureDraft['animations']>[number])\n .filter(clip => clip.channels.length > 0)\n const joints = (draft.joints ?? []).filter(joint => joint.name !== name)\n\n const next: FigureDraft = { ...draft }\n if (joints.length > 0) next.joints = joints\n else delete next.joints\n if (draft.parameters !== undefined) next.parameters = parameters\n if (draft.animations !== undefined) next.animations = animations\n return next\n}\n"]}
@@ -0,0 +1,34 @@
1
+ import type { FigureJoint, FigurePart, FigureSource } from '@hatiolab/figure-model';
2
+ import type { FigureDraft, PartModel } from './figure-source.js';
3
+ /**
4
+ * 관절이 **어느 자리에서 어느 쪽으로 얼마나 도는지**를 저작면에 그린다.
5
+ *
6
+ * 축은 `+x · -x · +y …` 중에서 고른다. 이름만 보고 어느 쪽으로 도는지 아는 사람은 없고, 지금은
7
+ * 미리보기로 건너가 막대를 끝까지 밀어 봐야 안다 — 여섯 축짜리 로봇에서는 그 왕복이 여섯 번이다.
8
+ * 도는 자리를 그 자리에 그려 두면 고르는 순간 보인다.
9
+ *
10
+ * ## 부채꼴 하나다
11
+ *
12
+ * 관절의 기준점에 **가동 범위만큼 벌어진 얇은 부채꼴**을 눕힌다. 부채꼴의 면이 도는 평면이고,
13
+ * 두께 방향이 축이며, 벌어진 각이 `limits` 다. 무한 회전(`continuous`)은 온전한 원이다.
14
+ *
15
+ * 부품 하나로 그린다 — 씬에 겹쳐 그리는 길을 따로 뚫지 않는다. 저작면은 부품을 컴포넌트로
16
+ * 세우는 자리이고, 이 부채꼴도 그 규칙을 그대로 따른다. `locked` 라 클릭·마퀴·기즈모가 집지
17
+ * 않고, 저장 형식으로는 나가지 않는다(저작면 안에서만 만들어 붙인다).
18
+ */
19
+ /** 저작면에만 있는 이름. 부품 목록에는 없다. */
20
+ export declare const JOINT_GIZMO = "__joint-gizmo";
21
+ /** 축 벡터를 `+x` 꼴의 이름으로. 형식은 단위 벡터를 쓰고 저작면은 여섯 중 하나를 고른다. */
22
+ export declare function axisName(axis: {
23
+ x: number;
24
+ y: number;
25
+ z: number;
26
+ }): string | undefined;
27
+ /**
28
+ * 관절 하나를 그리는 부품. 그릴 수 없으면 `undefined` — 축이 비스듬하면 부채꼴로 그릴 수 없고,
29
+ * 그때 아무 쪽이나 골라 그리면 **틀린 방향을 가리킨다.**
30
+ */
31
+ export declare function gizmoPart(joint: FigureJoint, child: PartModel | undefined, draft: FigureDraft): PartModel | undefined;
32
+ /** 미리보기에만 있는 이름. 저장 형식에는 없다. */
33
+ export declare const NEEDLE = "__needle-";
34
+ export declare function jointNeedles(source: FigureSource): FigurePart[];
@@ -0,0 +1,158 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ */
4
+ import { partFromFigure } from './figure-source.js';
5
+ /**
6
+ * 관절이 **어느 자리에서 어느 쪽으로 얼마나 도는지**를 저작면에 그린다.
7
+ *
8
+ * 축은 `+x · -x · +y …` 중에서 고른다. 이름만 보고 어느 쪽으로 도는지 아는 사람은 없고, 지금은
9
+ * 미리보기로 건너가 막대를 끝까지 밀어 봐야 안다 — 여섯 축짜리 로봇에서는 그 왕복이 여섯 번이다.
10
+ * 도는 자리를 그 자리에 그려 두면 고르는 순간 보인다.
11
+ *
12
+ * ## 부채꼴 하나다
13
+ *
14
+ * 관절의 기준점에 **가동 범위만큼 벌어진 얇은 부채꼴**을 눕힌다. 부채꼴의 면이 도는 평면이고,
15
+ * 두께 방향이 축이며, 벌어진 각이 `limits` 다. 무한 회전(`continuous`)은 온전한 원이다.
16
+ *
17
+ * 부품 하나로 그린다 — 씬에 겹쳐 그리는 길을 따로 뚫지 않는다. 저작면은 부품을 컴포넌트로
18
+ * 세우는 자리이고, 이 부채꼴도 그 규칙을 그대로 따른다. `locked` 라 클릭·마퀴·기즈모가 집지
19
+ * 않고, 저장 형식으로는 나가지 않는다(저작면 안에서만 만들어 붙인다).
20
+ */
21
+ /** 저작면에만 있는 이름. 부품 목록에는 없다. */
22
+ export const JOINT_GIZMO = '__joint-gizmo';
23
+ /** 부채꼴을 몇 조각으로 그리나. 한 조각이 6도쯤이면 원이 원으로 보인다. */
24
+ const STEP = 6;
25
+ /** 두께. 얇아야 부품을 가리지 않고, 너무 얇으면 옆에서 볼 때 사라진다. */
26
+ const THICKNESS = 6;
27
+ /**
28
+ * 도는 평면과 두께 방향을 축에 맞춘다.
29
+ *
30
+ * 부채꼴은 단면이 눕고 **제 y 방향으로** 밀려 두꺼워지는 도형이라, 기본 상태의 축이 +y 다.
31
+ * 다른 축은 부품 회전으로 눕힌다 — 부품이 제 회전을 지는 것과 같은 방법이고, 형식이 이미
32
+ * 아는 길이다.
33
+ *
34
+ * `turn` 은 단면 안에서 각을 재는 방향이다. 씬의 회전과 단면의 각이 도는 쪽이 반대인 축이
35
+ * 있어서, 그것을 여기 한 곳에 적어 둔다 — 부채꼴이 실제로 도는 쪽과 반대로 벌어지면
36
+ * 가리키는 뜻이 뒤집힌다.
37
+ */
38
+ const AXIS_LAY = {
39
+ '+y': { turn: -1 },
40
+ '-y': { turn: 1 },
41
+ '+x': { rotation: { z: 90 }, turn: 1 },
42
+ '-x': { rotation: { z: 90 }, turn: -1 },
43
+ '+z': { rotation: { x: 90 }, turn: -1 },
44
+ '-z': { rotation: { x: 90 }, turn: 1 }
45
+ };
46
+ /** 축 벡터를 `+x` 꼴의 이름으로. 형식은 단위 벡터를 쓰고 저작면은 여섯 중 하나를 고른다. */
47
+ export function axisName(axis) {
48
+ const named = [
49
+ ['x', axis.x],
50
+ ['y', axis.y],
51
+ ['z', axis.z]
52
+ ];
53
+ const on = named.filter(([, value]) => value !== 0);
54
+ if (on.length !== 1)
55
+ return undefined;
56
+ const [name, value] = on[0];
57
+ return `${value > 0 ? '+' : '-'}${name}`;
58
+ }
59
+ /** 이 관절이 도는 범위. 무한 회전은 한 바퀴다. */
60
+ function span(joint) {
61
+ if (joint.type === 'continuous' || !joint.limits)
62
+ return { from: -180, to: 180 };
63
+ return { from: joint.limits.min, to: joint.limits.max };
64
+ }
65
+ /** 부채꼴의 단면. 점은 부품 중심 기준의 밀리미터다. */
66
+ function sector(from, to, radius, turn) {
67
+ const path = [];
68
+ const full = Math.abs(to - from) >= 359.999;
69
+ if (!full)
70
+ path.push({ x: 0, y: 0 });
71
+ const steps = Math.max(2, Math.ceil(Math.abs(to - from) / STEP));
72
+ for (let i = 0; i <= steps; i++) {
73
+ const degrees = from + ((to - from) * i) / steps;
74
+ const rad = (degrees * Math.PI * turn) / 180;
75
+ path.push({ x: +(Math.cos(rad) * radius).toFixed(3), y: +(Math.sin(rad) * radius).toFixed(3) });
76
+ }
77
+ return path;
78
+ }
79
+ /**
80
+ * 관절 하나를 그리는 부품. 그릴 수 없으면 `undefined` — 축이 비스듬하면 부채꼴로 그릴 수 없고,
81
+ * 그때 아무 쪽이나 골라 그리면 **틀린 방향을 가리킨다.**
82
+ */
83
+ export function gizmoPart(joint, child, draft) {
84
+ const lay = AXIS_LAY[axisName(joint.axis) ?? ''];
85
+ if (!lay)
86
+ return undefined;
87
+ /* 반지름은 **도는 부품의 크기**에서 온다. 부채꼴이 그 부품보다 작으면 무엇이 도는지 안 읽힌다. */
88
+ const reach = child ? Math.max(child.width, child.height, child.depth) : 0;
89
+ const radius = Math.max(60, Math.round(reach * 0.6));
90
+ const { from, to } = span(joint);
91
+ const part = {
92
+ name: JOINT_GIZMO,
93
+ primitive: 'polygon',
94
+ transform: {
95
+ position: { ...joint.origin },
96
+ size: { x: radius * 2, y: THICKNESS, z: radius * 2 },
97
+ ...(lay.rotation ? { rotation: lay.rotation } : {})
98
+ },
99
+ shape: { path: sector(from, to, radius, lay.turn) },
100
+ material: { token: 'palette.accent', transparent: true }
101
+ };
102
+ return { ...partFromFigure(part, draft), locked: true };
103
+ }
104
+ /*
105
+ * 미리보기에서 **관절이 돌았다는 것**을 보이게 하는 바늘.
106
+ *
107
+ * 상자 위에 원기둥을 얹고 그 축으로 돌리면 화면이 그대로다 — 원기둥은 제 축에서 대칭이라
108
+ * 돌아도 같은 그림이다. 저작자는 막대를 76도까지 밀고도 아무것도 못 보고 「관절이 안 먹는다」로
109
+ * 읽는다. 값은 제대로 갔고 부품도 돌았는데 볼 것이 없었을 뿐이다.
110
+ *
111
+ * 그래서 관절이 모는 부품에 얇은 막대 하나를 **자식으로** 붙인다. 부모가 관절의 자식이라
112
+ * 관절이 도는 만큼 같이 돌고, 축에서 비켜나 있어 대칭인 부품에서도 움직임이 보인다. 매 프레임
113
+ * 무엇을 다시 그릴 일도, 막대를 밀 때마다 씬을 다시 세울 일도 없다.
114
+ *
115
+ * **미리보기에만 붙인다.** 저장 형식에도, 저작면에도, 카드 그림에도 안 들어간다 — 미리보기는
116
+ * 크기 반응을 보는 작업대이고 거기서만 쓰는 표시다.
117
+ */
118
+ /** 미리보기에만 있는 이름. 저장 형식에는 없다. */
119
+ export const NEEDLE = '__needle-';
120
+ /**
121
+ * 막대의 굵기 — **길이에 견주어** 잡는다.
122
+ *
123
+ * 8mm 로 고정했더니 미리보기에서 안 보였다. 미리보기는 여덟 대를 한 화면에 세우는 자리라
124
+ * 100mm 짜리 부품이 화면에서 20 픽셀쯤이고, 그 위의 8mm 는 한 픽셀이 안 된다. 세어 보면
125
+ * 막대는 분명히 서 있는데(부품 둘짜리 표본의 인스턴스에 mesh 셋 · 삼각형 72) 눈에 안 띈다.
126
+ */
127
+ const needleThick = (radius) => Math.max(6, Math.round(radius / 5));
128
+ /** 축에서 비켜날 방향. 그 축과 직각이어야 도는 것이 보인다. */
129
+ const ASIDE = { x: 'z', y: 'x', z: 'x' };
130
+ export function jointNeedles(source) {
131
+ const byName = new Map(source.parts.map(part => [part.name, part]));
132
+ return (source.joints ?? []).flatMap(joint => {
133
+ const axis = axisName(joint.axis);
134
+ if (!axis)
135
+ return [];
136
+ const child = byName.get(joint.child);
137
+ const size = child?.transform.size;
138
+ const reach = size ? Math.max(size.x, size.y, size.z) : 0;
139
+ const radius = Math.max(60, Math.round(reach * 0.6));
140
+ const aside = ASIDE[axis.slice(1)];
141
+ const thick = needleThick(radius);
142
+ return [
143
+ {
144
+ name: `${NEEDLE}${joint.name}`,
145
+ primitive: 'cube',
146
+ transform: {
147
+ position: { ...joint.origin, [aside]: joint.origin[aside] + radius / 2 },
148
+ size: aside === 'x'
149
+ ? { x: radius, y: thick, z: thick }
150
+ : { x: thick, y: thick, z: radius }
151
+ },
152
+ material: { token: 'palette.accent' },
153
+ parent: joint.child
154
+ }
155
+ ];
156
+ });
157
+ }
158
+ //# sourceMappingURL=joint-gizmo.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"joint-gizmo.js","sourceRoot":"","sources":["../../client/modeller/joint-gizmo.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AAGnD;;;;;;;;;;;;;;;GAeG;AAEH,+BAA+B;AAC/B,MAAM,CAAC,MAAM,WAAW,GAAG,eAAe,CAAA;AAE1C,+CAA+C;AAC/C,MAAM,IAAI,GAAG,CAAC,CAAA;AAEd,+CAA+C;AAC/C,MAAM,SAAS,GAAG,CAAC,CAAA;AAEnB;;;;;;;;;;GAUG;AACH,MAAM,QAAQ,GAAqF;IACjG,IAAI,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE;IAClB,IAAI,EAAE,EAAE,IAAI,EAAE,CAAC,EAAE;IACjB,IAAI,EAAE,EAAE,QAAQ,EAAE,EAAE,CAAC,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,EAAE;IACtC,IAAI,EAAE,EAAE,QAAQ,EAAE,EAAE,CAAC,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE;IACvC,IAAI,EAAE,EAAE,QAAQ,EAAE,EAAE,CAAC,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE;IACvC,IAAI,EAAE,EAAE,QAAQ,EAAE,EAAE,CAAC,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,EAAE;CACvC,CAAA;AAED,2DAA2D;AAC3D,MAAM,UAAU,QAAQ,CAAC,IAAyC;IAChE,MAAM,KAAK,GAAG;QACZ,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC,CAAC;QACb,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC,CAAC;QACb,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC,CAAC;KACL,CAAA;IACV,MAAM,EAAE,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,KAAK,KAAK,CAAC,CAAC,CAAA;IACnD,IAAI,EAAE,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,SAAS,CAAA;IACrC,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,CAAE,CAAA;IAC5B,OAAO,GAAG,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,GAAG,IAAI,EAAE,CAAA;AAC1C,CAAC;AAED,iCAAiC;AACjC,SAAS,IAAI,CAAC,KAAkB;IAC9B,IAAI,KAAK,CAAC,IAAI,KAAK,YAAY,IAAI,CAAC,KAAK,CAAC,MAAM;QAAE,OAAO,EAAE,IAAI,EAAE,CAAC,GAAG,EAAE,EAAE,EAAE,GAAG,EAAE,CAAA;IAChF,OAAO,EAAE,IAAI,EAAE,KAAK,CAAC,MAAM,CAAC,GAAG,EAAE,EAAE,EAAE,KAAK,CAAC,MAAM,CAAC,GAAG,EAAE,CAAA;AACzD,CAAC;AAED,mCAAmC;AACnC,SAAS,MAAM,CAAC,IAAY,EAAE,EAAU,EAAE,MAAc,EAAE,IAAY;IACpE,MAAM,IAAI,GAA+B,EAAE,CAAA;IAC3C,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,EAAE,GAAG,IAAI,CAAC,IAAI,OAAO,CAAA;IAE3C,IAAI,CAAC,IAAI;QAAE,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAA;IAEpC,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,GAAG,IAAI,CAAC,GAAG,IAAI,CAAC,CAAC,CAAA;IAChE,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,KAAK,EAAE,CAAC,EAAE,EAAE,CAAC;QAChC,MAAM,OAAO,GAAG,IAAI,GAAG,CAAC,CAAC,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,KAAK,CAAA;QAChD,MAAM,GAAG,GAAG,CAAC,OAAO,GAAG,IAAI,CAAC,EAAE,GAAG,IAAI,CAAC,GAAG,GAAG,CAAA;QAC5C,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,CAAA;IACjG,CAAC;IAED,OAAO,IAAI,CAAA;AACb,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,SAAS,CAAC,KAAkB,EAAE,KAA4B,EAAE,KAAkB;IAC5F,MAAM,GAAG,GAAG,QAAQ,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAA;IAChD,IAAI,CAAC,GAAG;QAAE,OAAO,SAAS,CAAA;IAE1B,6DAA6D;IAC7D,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;IAC1E,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,GAAG,GAAG,CAAC,CAAC,CAAA;IAEpD,MAAM,EAAE,IAAI,EAAE,EAAE,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,CAAA;IAChC,MAAM,IAAI,GAAe;QACvB,IAAI,EAAE,WAAW;QACjB,SAAS,EAAE,SAAS;QACpB,SAAS,EAAE;YACT,QAAQ,EAAE,EAAE,GAAG,KAAK,CAAC,MAAM,EAAE;YAC7B,IAAI,EAAE,EAAE,CAAC,EAAE,MAAM,GAAG,CAAC,EAAE,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,MAAM,GAAG,CAAC,EAAE;YACpD,GAAG,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,GAAG,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SACpD;QACD,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,EAAE,EAAE,MAAM,EAAE,GAAG,CAAC,IAAI,CAAC,EAAE;QACnD,QAAQ,EAAE,EAAE,KAAK,EAAE,gBAAgB,EAAE,WAAW,EAAE,IAAI,EAAE;KACzD,CAAA;IAED,OAAO,EAAE,GAAG,cAAc,CAAC,IAAI,EAAE,KAAK,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAA;AACzD,CAAC;AAED;;;;;;;;;;;;;GAaG;AAEH,gCAAgC;AAChC,MAAM,CAAC,MAAM,MAAM,GAAG,WAAW,CAAA;AAEjC;;;;;;GAMG;AACH,MAAM,WAAW,GAAG,CAAC,MAAc,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAA;AAE3E,wCAAwC;AACxC,MAAM,KAAK,GAA8B,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,CAAA;AAEnE,MAAM,UAAU,YAAY,CAAC,MAAoB;IAC/C,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,CAAA;IAEnE,OAAO,CAAC,MAAM,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE;QAC3C,MAAM,IAAI,GAAG,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;QACjC,IAAI,CAAC,IAAI;YAAE,OAAO,EAAE,CAAA;QAEpB,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,CAAA;QACrC,MAAM,IAAI,GAAG,KAAK,EAAE,SAAS,CAAC,IAAI,CAAA;QAClC,MAAM,KAAK,GAAG,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;QACzD,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,GAAG,GAAG,CAAC,CAAC,CAAA;QACpD,MAAM,KAAK,GAAG,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAE,CAAA;QACnC,MAAM,KAAK,GAAG,WAAW,CAAC,MAAM,CAAC,CAAA;QAEjC,OAAO;YACL;gBACE,IAAI,EAAE,GAAG,MAAM,GAAG,KAAK,CAAC,IAAI,EAAE;gBAC9B,SAAS,EAAE,MAAe;gBAC1B,SAAS,EAAE;oBACT,QAAQ,EAAE,EAAE,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,KAAK,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,MAAM,GAAG,CAAC,EAAE;oBACxE,IAAI,EACF,KAAK,KAAK,GAAG;wBACX,CAAC,CAAC,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE;wBACnC,CAAC,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE;iBACxC;gBACD,QAAQ,EAAE,EAAE,KAAK,EAAE,gBAAgB,EAAE;gBACrC,MAAM,EAAE,KAAK,CAAC,KAAK;aACpB;SACF,CAAA;IACH,CAAC,CAAC,CAAA;AACJ,CAAC","sourcesContent":["/*\n * Copyright © HatioLab Inc. All rights reserved.\n */\n\nimport type { FigureJoint, FigurePart, FigureSource } from '@hatiolab/figure-model'\n\nimport { partFromFigure } from './figure-source.js'\nimport type { FigureDraft, PartModel } from './figure-source.js'\n\n/**\n * 관절이 **어느 자리에서 어느 쪽으로 얼마나 도는지**를 저작면에 그린다.\n *\n * 축은 `+x · -x · +y …` 중에서 고른다. 이름만 보고 어느 쪽으로 도는지 아는 사람은 없고, 지금은\n * 미리보기로 건너가 막대를 끝까지 밀어 봐야 안다 — 여섯 축짜리 로봇에서는 그 왕복이 여섯 번이다.\n * 도는 자리를 그 자리에 그려 두면 고르는 순간 보인다.\n *\n * ## 부채꼴 하나다\n *\n * 관절의 기준점에 **가동 범위만큼 벌어진 얇은 부채꼴**을 눕힌다. 부채꼴의 면이 도는 평면이고,\n * 두께 방향이 축이며, 벌어진 각이 `limits` 다. 무한 회전(`continuous`)은 온전한 원이다.\n *\n * 부품 하나로 그린다 — 씬에 겹쳐 그리는 길을 따로 뚫지 않는다. 저작면은 부품을 컴포넌트로\n * 세우는 자리이고, 이 부채꼴도 그 규칙을 그대로 따른다. `locked` 라 클릭·마퀴·기즈모가 집지\n * 않고, 저장 형식으로는 나가지 않는다(저작면 안에서만 만들어 붙인다).\n */\n\n/** 저작면에만 있는 이름. 부품 목록에는 없다. */\nexport const JOINT_GIZMO = '__joint-gizmo'\n\n/** 부채꼴을 몇 조각으로 그리나. 한 조각이 6도쯤이면 원이 원으로 보인다. */\nconst STEP = 6\n\n/** 두께. 얇아야 부품을 가리지 않고, 너무 얇으면 옆에서 볼 때 사라진다. */\nconst THICKNESS = 6\n\n/**\n * 도는 평면과 두께 방향을 축에 맞춘다.\n *\n * 부채꼴은 단면이 눕고 **제 y 방향으로** 밀려 두꺼워지는 도형이라, 기본 상태의 축이 +y 다.\n * 다른 축은 부품 회전으로 눕힌다 — 부품이 제 회전을 지는 것과 같은 방법이고, 형식이 이미\n * 아는 길이다.\n *\n * `turn` 은 단면 안에서 각을 재는 방향이다. 씬의 회전과 단면의 각이 도는 쪽이 반대인 축이\n * 있어서, 그것을 여기 한 곳에 적어 둔다 — 부채꼴이 실제로 도는 쪽과 반대로 벌어지면\n * 가리키는 뜻이 뒤집힌다.\n */\nconst AXIS_LAY: Record<string, { rotation?: FigurePart['transform']['rotation']; turn: 1 | -1 }> = {\n '+y': { turn: -1 },\n '-y': { turn: 1 },\n '+x': { rotation: { z: 90 }, turn: 1 },\n '-x': { rotation: { z: 90 }, turn: -1 },\n '+z': { rotation: { x: 90 }, turn: -1 },\n '-z': { rotation: { x: 90 }, turn: 1 }\n}\n\n/** 축 벡터를 `+x` 꼴의 이름으로. 형식은 단위 벡터를 쓰고 저작면은 여섯 중 하나를 고른다. */\nexport function axisName(axis: { x: number; y: number; z: number }): string | undefined {\n const named = [\n ['x', axis.x],\n ['y', axis.y],\n ['z', axis.z]\n ] as const\n const on = named.filter(([, value]) => value !== 0)\n if (on.length !== 1) return undefined\n const [name, value] = on[0]!\n return `${value > 0 ? '+' : '-'}${name}`\n}\n\n/** 이 관절이 도는 범위. 무한 회전은 한 바퀴다. */\nfunction span(joint: FigureJoint): { from: number; to: number } {\n if (joint.type === 'continuous' || !joint.limits) return { from: -180, to: 180 }\n return { from: joint.limits.min, to: joint.limits.max }\n}\n\n/** 부채꼴의 단면. 점은 부품 중심 기준의 밀리미터다. */\nfunction sector(from: number, to: number, radius: number, turn: 1 | -1): { x: number; y: number }[] {\n const path: { x: number; y: number }[] = []\n const full = Math.abs(to - from) >= 359.999\n\n if (!full) path.push({ x: 0, y: 0 })\n\n const steps = Math.max(2, Math.ceil(Math.abs(to - from) / STEP))\n for (let i = 0; i <= steps; i++) {\n const degrees = from + ((to - from) * i) / steps\n const rad = (degrees * Math.PI * turn) / 180\n path.push({ x: +(Math.cos(rad) * radius).toFixed(3), y: +(Math.sin(rad) * radius).toFixed(3) })\n }\n\n return path\n}\n\n/**\n * 관절 하나를 그리는 부품. 그릴 수 없으면 `undefined` — 축이 비스듬하면 부채꼴로 그릴 수 없고,\n * 그때 아무 쪽이나 골라 그리면 **틀린 방향을 가리킨다.**\n */\nexport function gizmoPart(joint: FigureJoint, child: PartModel | undefined, draft: FigureDraft): PartModel | undefined {\n const lay = AXIS_LAY[axisName(joint.axis) ?? '']\n if (!lay) return undefined\n\n /* 반지름은 **도는 부품의 크기**에서 온다. 부채꼴이 그 부품보다 작으면 무엇이 도는지 안 읽힌다. */\n const reach = child ? Math.max(child.width, child.height, child.depth) : 0\n const radius = Math.max(60, Math.round(reach * 0.6))\n\n const { from, to } = span(joint)\n const part: FigurePart = {\n name: JOINT_GIZMO,\n primitive: 'polygon',\n transform: {\n position: { ...joint.origin },\n size: { x: radius * 2, y: THICKNESS, z: radius * 2 },\n ...(lay.rotation ? { rotation: lay.rotation } : {})\n },\n shape: { path: sector(from, to, radius, lay.turn) },\n material: { token: 'palette.accent', transparent: true }\n }\n\n return { ...partFromFigure(part, draft), locked: true }\n}\n\n/*\n * 미리보기에서 **관절이 돌았다는 것**을 보이게 하는 바늘.\n *\n * 상자 위에 원기둥을 얹고 그 축으로 돌리면 화면이 그대로다 — 원기둥은 제 축에서 대칭이라\n * 돌아도 같은 그림이다. 저작자는 막대를 76도까지 밀고도 아무것도 못 보고 「관절이 안 먹는다」로\n * 읽는다. 값은 제대로 갔고 부품도 돌았는데 볼 것이 없었을 뿐이다.\n *\n * 그래서 관절이 모는 부품에 얇은 막대 하나를 **자식으로** 붙인다. 부모가 관절의 자식이라\n * 관절이 도는 만큼 같이 돌고, 축에서 비켜나 있어 대칭인 부품에서도 움직임이 보인다. 매 프레임\n * 무엇을 다시 그릴 일도, 막대를 밀 때마다 씬을 다시 세울 일도 없다.\n *\n * **미리보기에만 붙인다.** 저장 형식에도, 저작면에도, 카드 그림에도 안 들어간다 — 미리보기는\n * 크기 반응을 보는 작업대이고 거기서만 쓰는 표시다.\n */\n\n/** 미리보기에만 있는 이름. 저장 형식에는 없다. */\nexport const NEEDLE = '__needle-'\n\n/**\n * 막대의 굵기 — **길이에 견주어** 잡는다.\n *\n * 8mm 로 고정했더니 미리보기에서 안 보였다. 미리보기는 여덟 대를 한 화면에 세우는 자리라\n * 100mm 짜리 부품이 화면에서 20 픽셀쯤이고, 그 위의 8mm 는 한 픽셀이 안 된다. 세어 보면\n * 막대는 분명히 서 있는데(부품 둘짜리 표본의 인스턴스에 mesh 셋 · 삼각형 72) 눈에 안 띈다.\n */\nconst needleThick = (radius: number) => Math.max(6, Math.round(radius / 5))\n\n/** 축에서 비켜날 방향. 그 축과 직각이어야 도는 것이 보인다. */\nconst ASIDE: Record<string, 'x' | 'z'> = { x: 'z', y: 'x', z: 'x' }\n\nexport function jointNeedles(source: FigureSource): FigurePart[] {\n const byName = new Map(source.parts.map(part => [part.name, part]))\n\n return (source.joints ?? []).flatMap(joint => {\n const axis = axisName(joint.axis)\n if (!axis) return []\n\n const child = byName.get(joint.child)\n const size = child?.transform.size\n const reach = size ? Math.max(size.x, size.y, size.z) : 0\n const radius = Math.max(60, Math.round(reach * 0.6))\n const aside = ASIDE[axis.slice(1)]!\n const thick = needleThick(radius)\n\n return [\n {\n name: `${NEEDLE}${joint.name}`,\n primitive: 'cube' as const,\n transform: {\n position: { ...joint.origin, [aside]: joint.origin[aside] + radius / 2 },\n size:\n aside === 'x'\n ? { x: radius, y: thick, z: thick }\n : { x: thick, y: thick, z: radius }\n },\n material: { token: 'palette.accent' },\n parent: joint.child\n }\n ]\n })\n}\n"]}
@@ -5,11 +5,36 @@ export declare function partLimit(draft: FigureDraft): number;
5
5
  /**
6
6
  * 새 부품 하나를 만든다.
7
7
  *
8
- * 기준 상자의 3분의 1 크기로, **바닥 가운데**에 놓는다. 추가하자마자 화면에 보여야 한다 —
9
- * 크기 0 으로 놓으면 사용자에게는 아무 일도 일어나지 않은 것으로 보인다.
8
+ * 기준 상자의 3분의 1 크기다. 추가하자마자 화면에 보여야 한다 — 크기 0 으로 놓으면
9
+ * 사용자에게는 아무 일도 일어나지 않은 것으로 보인다.
10
+ *
11
+ * ## 어디에 놓나 — 고른 부품 **위**다
12
+ *
13
+ * 전에는 언제나 바닥 한가운데였다. 첫 부품은 그 자리가 맞지만 둘째부터는 **첫 부품 속에**
14
+ * 들어간다 — 같은 자리에 같은 크기로 놓이니 화면에는 덩어리 하나뿐이고, 저작자는 무엇이
15
+ * 늘었는지 못 본다. 「추가하자마자 보여야 한다」는 그때 이미 깨져 있었다.
16
+ *
17
+ * 그래서 고른 부품이 있으면 그 부품의 **윗면**에 얹고 발자국을 가운데로 맞춘다. 기계는
18
+ * 대개 그렇게 쌓인다(받침 → 어깨 → 팔). 관절을 걸 때도 이 자리가 필요하다 — 맞닿는 면이
19
+ * 실제로 있어야 `seamOrigin` 이 회전 중심을 그 면에서 잡는다.
20
+ *
21
+ * 위에 얹으면 기준 상자를 넘는 경우에는 바닥 한가운데로 간다. 넘은 채로 놓으면 발행이
22
+ * 막히고(`part-outside-base`), 그것을 고치는 일이 「부품을 추가했다」의 대가가 된다.
23
+ * 가로·세로는 상자 안으로 당긴다 — 고른 부품이 가장자리에 있으면 그 위가 상자 밖이다.
24
+ */
25
+ export declare function newPart(draft: FigureDraft, parts: PartModel[], primitive: PrimitiveKind, onTopOf?: PartModel): PartModel;
26
+ /**
27
+ * 씬에서 온 부품을 밀리미터로 맞춘다.
28
+ *
29
+ * 기즈모는 비율을 곱해서 크기를 낸다 — 420 에 1.72 를 곱하면 722.4180046035418 이고, 손대지
30
+ * 않은 축도 384.99999999999994 처럼 흔들린다. 저작자가 끈 결과가 소수 넷째 자리까지 남을
31
+ * 까닭이 없고, 칸에는 「722.4…」로 잘려 보인다. 이 편집기의 다른 길은 전부 정수 밀리미터로
32
+ * 맞춘다(부품을 새로 놓을 때 · 가운데 맞추기 · 격자 맞추기).
33
+ *
34
+ * 크기는 1 밑으로 내려가지 않는다 — 0 이면 부피가 사라지고 배율이 0 으로 나뉜다.
10
35
  */
11
- export declare function newPart(draft: FigureDraft, parts: PartModel[], primitive: PrimitiveKind): PartModel;
12
- export declare function addPart(draft: FigureDraft, parts: PartModel[], primitive: PrimitiveKind): PartModel[];
36
+ export declare function roundPart(part: PartModel): PartModel;
37
+ export declare function addPart(draft: FigureDraft, parts: PartModel[], primitive: PrimitiveKind, onTopOf?: PartModel): PartModel[];
13
38
  export declare function removePart(parts: PartModel[], index: number): PartModel[];
14
39
  /** 부품을 복제한다. 이름만 새로 붙인다 — 중복되면 검증에서 걸린다. */
15
40
  export declare function duplicatePart(parts: PartModel[], index: number): PartModel[];
@@ -23,6 +23,11 @@ function freeName(taken, base) {
23
23
  return candidate;
24
24
  }
25
25
  }
26
+ const round = (value) => Math.round(value);
27
+ /** 값을 두 끝 사이로 당긴다. 끝이 뒤집혀 있으면(부품이 상자보다 크다) 앞의 끝을 따른다. */
28
+ function clamp(value, least, most) {
29
+ return Math.max(least, Math.min(most, value));
30
+ }
26
31
  /** 이 초안에 부품을 몇 개까지 넣을 수 있는지. 디테일 등급이 결정한다. */
27
32
  export function partLimit(draft) {
28
33
  return PART_LIMIT[(draft.detailLevel ?? 'M')];
@@ -30,24 +35,50 @@ export function partLimit(draft) {
30
35
  /**
31
36
  * 새 부품 하나를 만든다.
32
37
  *
33
- * 기준 상자의 3분의 1 크기로, **바닥 가운데**에 놓는다. 추가하자마자 화면에 보여야 한다 —
34
- * 크기 0 으로 놓으면 사용자에게는 아무 일도 일어나지 않은 것으로 보인다.
38
+ * 기준 상자의 3분의 1 크기다. 추가하자마자 화면에 보여야 한다 — 크기 0 으로 놓으면
39
+ * 사용자에게는 아무 일도 일어나지 않은 것으로 보인다.
40
+ *
41
+ * ## 어디에 놓나 — 고른 부품 **위**다
42
+ *
43
+ * 전에는 언제나 바닥 한가운데였다. 첫 부품은 그 자리가 맞지만 둘째부터는 **첫 부품 속에**
44
+ * 들어간다 — 같은 자리에 같은 크기로 놓이니 화면에는 덩어리 하나뿐이고, 저작자는 무엇이
45
+ * 늘었는지 못 본다. 「추가하자마자 보여야 한다」는 그때 이미 깨져 있었다.
46
+ *
47
+ * 그래서 고른 부품이 있으면 그 부품의 **윗면**에 얹고 발자국을 가운데로 맞춘다. 기계는
48
+ * 대개 그렇게 쌓인다(받침 → 어깨 → 팔). 관절을 걸 때도 이 자리가 필요하다 — 맞닿는 면이
49
+ * 실제로 있어야 `seamOrigin` 이 회전 중심을 그 면에서 잡는다.
50
+ *
51
+ * 위에 얹으면 기준 상자를 넘는 경우에는 바닥 한가운데로 간다. 넘은 채로 놓으면 발행이
52
+ * 막히고(`part-outside-base`), 그것을 고치는 일이 「부품을 추가했다」의 대가가 된다.
53
+ * 가로·세로는 상자 안으로 당긴다 — 고른 부품이 가장자리에 있으면 그 위가 상자 밖이다.
35
54
  */
36
- export function newPart(draft, parts, primitive) {
55
+ export function newPart(draft, parts, primitive, onTopOf) {
37
56
  const taken = new Set(parts.map(part => part.name));
38
57
  const width = Math.max(1, Math.round(draft.width / 3));
39
58
  const height = Math.max(1, Math.round(draft.height / 3));
40
59
  const depth = Math.max(1, Math.round(draft.depth / 3));
60
+ // 바닥 한가운데 — 고를 부품이 없을 때, 그리고 위가 상자 밖일 때의 자리.
61
+ const middle = {
62
+ left: Math.round((draft.width - width) / 2),
63
+ top: Math.round((draft.height - height) / 2),
64
+ // `zPos` 는 부피의 밑면이라 0 이 곧 상자 바닥이다.
65
+ zPos: 0
66
+ };
67
+ const above = onTopOf && {
68
+ left: clamp(Math.round(onTopOf.left + onTopOf.width / 2 - width / 2), 0, draft.width - width),
69
+ top: clamp(Math.round(onTopOf.top + onTopOf.height / 2 - height / 2), 0, draft.height - height),
70
+ zPos: Math.round(onTopOf.zPos + onTopOf.depth)
71
+ };
72
+ const at = above && above.zPos + depth <= draft.depth ? above : middle;
41
73
  const part = {
42
74
  type: 'figure-part',
43
75
  name: freeName(taken, primitive),
44
76
  primitive,
45
- left: Math.round((draft.width - width) / 2),
46
- top: Math.round((draft.height - height) / 2),
77
+ left: at.left,
78
+ top: at.top,
47
79
  width,
48
80
  height,
49
- // 바닥에 붙인다. `zPos` 는 부피의 밑면이라 0 이 곧 상자 바닥이다.
50
- zPos: 0,
81
+ zPos: at.zPos,
51
82
  depth,
52
83
  // 팔레트 토큰은 기존 부품의 것을 물려받는다 — 토큰이 늘어날 때마다 재질 그룹이 하나씩 는다.
53
84
  token: parts[0]?.token ?? 'palette.primary'
@@ -69,8 +100,29 @@ export function newPart(draft, parts, primitive) {
69
100
  }
70
101
  return part;
71
102
  }
72
- export function addPart(draft, parts, primitive) {
73
- return [...parts, newPart(draft, parts, primitive)];
103
+ /**
104
+ * 씬에서 부품을 밀리미터로 맞춘다.
105
+ *
106
+ * 기즈모는 비율을 곱해서 크기를 낸다 — 420 에 1.72 를 곱하면 722.4180046035418 이고, 손대지
107
+ * 않은 축도 384.99999999999994 처럼 흔들린다. 저작자가 끈 결과가 소수 넷째 자리까지 남을
108
+ * 까닭이 없고, 칸에는 「722.4…」로 잘려 보인다. 이 편집기의 다른 길은 전부 정수 밀리미터로
109
+ * 맞춘다(부품을 새로 놓을 때 · 가운데 맞추기 · 격자 맞추기).
110
+ *
111
+ * 크기는 1 밑으로 내려가지 않는다 — 0 이면 부피가 사라지고 배율이 0 으로 나뉜다.
112
+ */
113
+ export function roundPart(part) {
114
+ return {
115
+ ...part,
116
+ left: round(part.left),
117
+ top: round(part.top),
118
+ zPos: round(part.zPos),
119
+ width: Math.max(1, round(part.width)),
120
+ height: Math.max(1, round(part.height)),
121
+ depth: Math.max(1, round(part.depth))
122
+ };
123
+ }
124
+ export function addPart(draft, parts, primitive, onTopOf) {
125
+ return [...parts, newPart(draft, parts, primitive, onTopOf)];
74
126
  }
75
127
  export function removePart(parts, index) {
76
128
  return parts.filter((_, at) => at !== index);