@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
@@ -50,6 +50,63 @@ export function parentChoices(parts: readonly PartModel[], part: string): string
50
50
  return parts.map(one => one.name).filter(name => !!name && !below.has(name))
51
51
  }
52
52
 
53
+ type Span = { min: number; max: number }
54
+
55
+ /** 한 축에서 부품이 차지하는 구간. */
56
+ const reachOf = (part: FigurePart, axis: 'x' | 'y' | 'z'): Span => ({
57
+ min: part.transform.position[axis] - part.transform.size[axis] / 2,
58
+ max: part.transform.position[axis] + part.transform.size[axis] / 2
59
+ })
60
+
61
+ /**
62
+ * 새 관절이 설 자리 — **부품이 붙은 부품과 맞닿는 면의 한가운데.**
63
+ *
64
+ * 경첩은 두 부품이 닿는 자리에 있다. 팔꿈치는 윗팔과 앞팔이 만나는 곳이고, 문은 문틀에 닿는
65
+ * 모서리에 있다. 그래서 처음 값은 그 자리여야 한다.
66
+ *
67
+ * 한동안 **부품 아랫면 한가운데**를 썼다. 위로 쌓는 부품에는 맞는 자리인데(로봇 팔이 그렇다),
68
+ * 옆에 달린 부품에는 틀린다 — 캐비닛 옆에 달린 문의 축이 문 바닥에 생긴다. 저작자는 그것을
69
+ * 화면에서 보고 손으로 옮겨야 했고, 어디로 옮길지는 두 부품의 치수를 머릿속에서 빼야 나왔다.
70
+ *
71
+ * **가장 벌어진 축이 맞닿는 축이다.** 두 상자가 겹치는 축에서는 뺀 값이 음수이고, 마주 보는
72
+ * 축에서만 0 이나 양수가 된다. 나머지 두 축은 겹치는 구간의 한가운데를 쓴다 — 문의 축은 문
73
+ * 높이의 한가운데에 있지 위아래 끝에 있지 않다.
74
+ *
75
+ * 붙은 부품이 없으면(Figure 틀에 직접 붙는다) 아랫면 한가운데다. 그때 맞닿는 것은 바닥이다.
76
+ *
77
+ * ⚠ 부품의 제 회전은 보지 않는다. 비스듬히 세운 부품에서는 축에 나란한 상자로 셈한 자리가
78
+ * 나오고, 그것은 **처음 값**이다 — 저작자가 옮길 수 있고, 화면의 부채꼴이 어디인지 보여 준다.
79
+ */
80
+ export function seamOrigin(part: FigurePart, parent?: FigurePart): Vec3 {
81
+ const { position, size } = part.transform
82
+ if (!parent) return { x: position.x, y: position.y - size.y / 2, z: position.z }
83
+
84
+ const axes = ['x', 'y', 'z'] as const
85
+ const gaps = axes.map(axis => {
86
+ const mine = reachOf(part, axis)
87
+ const theirs = reachOf(parent, axis)
88
+ return { axis, gap: Math.max(mine.min - theirs.max, theirs.min - mine.max), mine, theirs }
89
+ })
90
+
91
+ const meeting = gaps.reduce((widest, one) => (one.gap > widest.gap ? one : widest))
92
+ const origin = { x: position.x, y: position.y, z: position.z }
93
+
94
+ for (const { axis, mine, theirs } of gaps) {
95
+ if (axis === meeting.axis) {
96
+ /* 맞닿는 면은 **내 면**이다. 부모 쪽 면을 쓰면 둘 사이가 벌어져 있을 때 축이 허공에 선다. */
97
+ origin[axis] = mine.min >= theirs.max ? mine.min : mine.max
98
+ continue
99
+ }
100
+ const from = Math.max(mine.min, theirs.min)
101
+ const to = Math.min(mine.max, theirs.max)
102
+ origin[axis] = from <= to ? (from + to) / 2 : position[axis]
103
+ }
104
+
105
+ return { x: round4(origin.x), y: round4(origin.y), z: round4(origin.z) }
106
+ }
107
+
108
+ const round4 = (value: number) => Math.round(value * 10000) / 10000
109
+
53
110
  function freeName(taken: Iterable<string>, stem: string): string {
54
111
  const used = new Set(taken)
55
112
  if (!used.has(stem)) return stem
@@ -106,14 +163,19 @@ function isPlainDriver(parameter: Parameter, joint: FigureJoint, span: { min: nu
106
163
  /**
107
164
  * Sets how a part moves relative to its parent, or clears the joint with `undefined`.
108
165
  *
109
- * - From none: adds a joint whose origin is the centre of the part's bottom face, with a vertical axis and
110
- * starting limits, and a parameter that drives it over those limits.
166
+ * - From none: adds a joint at the seam with the parent (`seamOrigin`), with a vertical axis and starting
167
+ * limits, and a parameter that drives it over those limits.
111
168
  * - To none: removes the joint and every channel that drove it; a parameter or clip left with no channel
112
169
  * is removed too, because the format refuses an empty one.
113
170
  * - Between types: keeps the origin and axis, sets limits for the new type (none for continuous), and
114
171
  * moves a plain driving parameter along with them.
115
172
  */
116
- export function setJointType(draft: FigureDraft, part: FigurePart, type: JointType | undefined): FigureDraft {
173
+ export function setJointType(
174
+ draft: FigureDraft,
175
+ part: FigurePart,
176
+ type: JointType | undefined,
177
+ parent?: FigurePart
178
+ ): FigureDraft {
117
179
  const joints = draft.joints ?? []
118
180
  const existing = joints.find(joint => joint.child === part.name)
119
181
 
@@ -123,12 +185,11 @@ export function setJointType(draft: FigureDraft, part: FigurePart, type: JointTy
123
185
  }
124
186
 
125
187
  if (!existing) {
126
- const { position, size } = part.transform
127
188
  const joint: FigureJoint = {
128
189
  name: freeName([...joints.map(one => one.name)], `${part.name}-joint`),
129
190
  child: part.name,
130
191
  type,
131
- origin: { x: position.x, y: position.y - size.y / 2, z: position.z },
192
+ origin: seamOrigin(part, parent),
132
193
  axis: { x: 0, y: 1, z: 0 }
133
194
  }
134
195
  if (type !== 'continuous') joint.limits = { ...NEW_LIMITS[type] }
@@ -0,0 +1,180 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ */
4
+
5
+ import type { FigureJoint, FigurePart, FigureSource } from '@hatiolab/figure-model'
6
+
7
+ import { partFromFigure } from './figure-source.js'
8
+ import type { FigureDraft, PartModel } from './figure-source.js'
9
+
10
+ /**
11
+ * 관절이 **어느 자리에서 어느 쪽으로 얼마나 도는지**를 저작면에 그린다.
12
+ *
13
+ * 축은 `+x · -x · +y …` 중에서 고른다. 이름만 보고 어느 쪽으로 도는지 아는 사람은 없고, 지금은
14
+ * 미리보기로 건너가 막대를 끝까지 밀어 봐야 안다 — 여섯 축짜리 로봇에서는 그 왕복이 여섯 번이다.
15
+ * 도는 자리를 그 자리에 그려 두면 고르는 순간 보인다.
16
+ *
17
+ * ## 부채꼴 하나다
18
+ *
19
+ * 관절의 기준점에 **가동 범위만큼 벌어진 얇은 부채꼴**을 눕힌다. 부채꼴의 면이 도는 평면이고,
20
+ * 두께 방향이 축이며, 벌어진 각이 `limits` 다. 무한 회전(`continuous`)은 온전한 원이다.
21
+ *
22
+ * 부품 하나로 그린다 — 씬에 겹쳐 그리는 길을 따로 뚫지 않는다. 저작면은 부품을 컴포넌트로
23
+ * 세우는 자리이고, 이 부채꼴도 그 규칙을 그대로 따른다. `locked` 라 클릭·마퀴·기즈모가 집지
24
+ * 않고, 저장 형식으로는 나가지 않는다(저작면 안에서만 만들어 붙인다).
25
+ */
26
+
27
+ /** 저작면에만 있는 이름. 부품 목록에는 없다. */
28
+ export const JOINT_GIZMO = '__joint-gizmo'
29
+
30
+ /** 부채꼴을 몇 조각으로 그리나. 한 조각이 6도쯤이면 원이 원으로 보인다. */
31
+ const STEP = 6
32
+
33
+ /** 두께. 얇아야 부품을 가리지 않고, 너무 얇으면 옆에서 볼 때 사라진다. */
34
+ const THICKNESS = 6
35
+
36
+ /**
37
+ * 도는 평면과 두께 방향을 축에 맞춘다.
38
+ *
39
+ * 부채꼴은 단면이 눕고 **제 y 방향으로** 밀려 두꺼워지는 도형이라, 기본 상태의 축이 +y 다.
40
+ * 다른 축은 부품 회전으로 눕힌다 — 부품이 제 회전을 지는 것과 같은 방법이고, 형식이 이미
41
+ * 아는 길이다.
42
+ *
43
+ * `turn` 은 단면 안에서 각을 재는 방향이다. 씬의 회전과 단면의 각이 도는 쪽이 반대인 축이
44
+ * 있어서, 그것을 여기 한 곳에 적어 둔다 — 부채꼴이 실제로 도는 쪽과 반대로 벌어지면
45
+ * 가리키는 뜻이 뒤집힌다.
46
+ */
47
+ const AXIS_LAY: Record<string, { rotation?: FigurePart['transform']['rotation']; turn: 1 | -1 }> = {
48
+ '+y': { turn: -1 },
49
+ '-y': { turn: 1 },
50
+ '+x': { rotation: { z: 90 }, turn: 1 },
51
+ '-x': { rotation: { z: 90 }, turn: -1 },
52
+ '+z': { rotation: { x: 90 }, turn: -1 },
53
+ '-z': { rotation: { x: 90 }, turn: 1 }
54
+ }
55
+
56
+ /** 축 벡터를 `+x` 꼴의 이름으로. 형식은 단위 벡터를 쓰고 저작면은 여섯 중 하나를 고른다. */
57
+ export function axisName(axis: { x: number; y: number; z: number }): string | undefined {
58
+ const named = [
59
+ ['x', axis.x],
60
+ ['y', axis.y],
61
+ ['z', axis.z]
62
+ ] as const
63
+ const on = named.filter(([, value]) => value !== 0)
64
+ if (on.length !== 1) return undefined
65
+ const [name, value] = on[0]!
66
+ return `${value > 0 ? '+' : '-'}${name}`
67
+ }
68
+
69
+ /** 이 관절이 도는 범위. 무한 회전은 한 바퀴다. */
70
+ function span(joint: FigureJoint): { from: number; to: number } {
71
+ if (joint.type === 'continuous' || !joint.limits) return { from: -180, to: 180 }
72
+ return { from: joint.limits.min, to: joint.limits.max }
73
+ }
74
+
75
+ /** 부채꼴의 단면. 점은 부품 중심 기준의 밀리미터다. */
76
+ function sector(from: number, to: number, radius: number, turn: 1 | -1): { x: number; y: number }[] {
77
+ const path: { x: number; y: number }[] = []
78
+ const full = Math.abs(to - from) >= 359.999
79
+
80
+ if (!full) path.push({ x: 0, y: 0 })
81
+
82
+ const steps = Math.max(2, Math.ceil(Math.abs(to - from) / STEP))
83
+ for (let i = 0; i <= steps; i++) {
84
+ const degrees = from + ((to - from) * i) / steps
85
+ const rad = (degrees * Math.PI * turn) / 180
86
+ path.push({ x: +(Math.cos(rad) * radius).toFixed(3), y: +(Math.sin(rad) * radius).toFixed(3) })
87
+ }
88
+
89
+ return path
90
+ }
91
+
92
+ /**
93
+ * 관절 하나를 그리는 부품. 그릴 수 없으면 `undefined` — 축이 비스듬하면 부채꼴로 그릴 수 없고,
94
+ * 그때 아무 쪽이나 골라 그리면 **틀린 방향을 가리킨다.**
95
+ */
96
+ export function gizmoPart(joint: FigureJoint, child: PartModel | undefined, draft: FigureDraft): PartModel | undefined {
97
+ const lay = AXIS_LAY[axisName(joint.axis) ?? '']
98
+ if (!lay) return undefined
99
+
100
+ /* 반지름은 **도는 부품의 크기**에서 온다. 부채꼴이 그 부품보다 작으면 무엇이 도는지 안 읽힌다. */
101
+ const reach = child ? Math.max(child.width, child.height, child.depth) : 0
102
+ const radius = Math.max(60, Math.round(reach * 0.6))
103
+
104
+ const { from, to } = span(joint)
105
+ const part: FigurePart = {
106
+ name: JOINT_GIZMO,
107
+ primitive: 'polygon',
108
+ transform: {
109
+ position: { ...joint.origin },
110
+ size: { x: radius * 2, y: THICKNESS, z: radius * 2 },
111
+ ...(lay.rotation ? { rotation: lay.rotation } : {})
112
+ },
113
+ shape: { path: sector(from, to, radius, lay.turn) },
114
+ material: { token: 'palette.accent', transparent: true }
115
+ }
116
+
117
+ return { ...partFromFigure(part, draft), locked: true }
118
+ }
119
+
120
+ /*
121
+ * 미리보기에서 **관절이 돌았다는 것**을 보이게 하는 바늘.
122
+ *
123
+ * 상자 위에 원기둥을 얹고 그 축으로 돌리면 화면이 그대로다 — 원기둥은 제 축에서 대칭이라
124
+ * 돌아도 같은 그림이다. 저작자는 막대를 76도까지 밀고도 아무것도 못 보고 「관절이 안 먹는다」로
125
+ * 읽는다. 값은 제대로 갔고 부품도 돌았는데 볼 것이 없었을 뿐이다.
126
+ *
127
+ * 그래서 관절이 모는 부품에 얇은 막대 하나를 **자식으로** 붙인다. 부모가 관절의 자식이라
128
+ * 관절이 도는 만큼 같이 돌고, 축에서 비켜나 있어 대칭인 부품에서도 움직임이 보인다. 매 프레임
129
+ * 무엇을 다시 그릴 일도, 막대를 밀 때마다 씬을 다시 세울 일도 없다.
130
+ *
131
+ * **미리보기에만 붙인다.** 저장 형식에도, 저작면에도, 카드 그림에도 안 들어간다 — 미리보기는
132
+ * 크기 반응을 보는 작업대이고 거기서만 쓰는 표시다.
133
+ */
134
+
135
+ /** 미리보기에만 있는 이름. 저장 형식에는 없다. */
136
+ export const NEEDLE = '__needle-'
137
+
138
+ /**
139
+ * 막대의 굵기 — **길이에 견주어** 잡는다.
140
+ *
141
+ * 8mm 로 고정했더니 미리보기에서 안 보였다. 미리보기는 여덟 대를 한 화면에 세우는 자리라
142
+ * 100mm 짜리 부품이 화면에서 20 픽셀쯤이고, 그 위의 8mm 는 한 픽셀이 안 된다. 세어 보면
143
+ * 막대는 분명히 서 있는데(부품 둘짜리 표본의 인스턴스에 mesh 셋 · 삼각형 72) 눈에 안 띈다.
144
+ */
145
+ const needleThick = (radius: number) => Math.max(6, Math.round(radius / 5))
146
+
147
+ /** 축에서 비켜날 방향. 그 축과 직각이어야 도는 것이 보인다. */
148
+ const ASIDE: Record<string, 'x' | 'z'> = { x: 'z', y: 'x', z: 'x' }
149
+
150
+ export function jointNeedles(source: FigureSource): FigurePart[] {
151
+ const byName = new Map(source.parts.map(part => [part.name, part]))
152
+
153
+ return (source.joints ?? []).flatMap(joint => {
154
+ const axis = axisName(joint.axis)
155
+ if (!axis) return []
156
+
157
+ const child = byName.get(joint.child)
158
+ const size = child?.transform.size
159
+ const reach = size ? Math.max(size.x, size.y, size.z) : 0
160
+ const radius = Math.max(60, Math.round(reach * 0.6))
161
+ const aside = ASIDE[axis.slice(1)]!
162
+ const thick = needleThick(radius)
163
+
164
+ return [
165
+ {
166
+ name: `${NEEDLE}${joint.name}`,
167
+ primitive: 'cube' as const,
168
+ transform: {
169
+ position: { ...joint.origin, [aside]: joint.origin[aside] + radius / 2 },
170
+ size:
171
+ aside === 'x'
172
+ ? { x: radius, y: thick, z: thick }
173
+ : { x: thick, y: thick, z: radius }
174
+ },
175
+ material: { token: 'palette.accent' },
176
+ parent: joint.child
177
+ }
178
+ ]
179
+ })
180
+ }
@@ -27,6 +27,13 @@ function freeName(taken: Set<string>, base: string): string {
27
27
  }
28
28
  }
29
29
 
30
+ const round = (value: number) => Math.round(value)
31
+
32
+ /** 값을 두 끝 사이로 당긴다. 끝이 뒤집혀 있으면(부품이 상자보다 크다) 앞의 끝을 따른다. */
33
+ function clamp(value: number, least: number, most: number): number {
34
+ return Math.max(least, Math.min(most, value))
35
+ }
36
+
30
37
  /** 이 초안에 부품을 몇 개까지 넣을 수 있는지. 디테일 등급이 결정한다. */
31
38
  export function partLimit(draft: FigureDraft): number {
32
39
  return PART_LIMIT[(draft.detailLevel ?? 'M') as DetailLevel]
@@ -35,26 +42,58 @@ export function partLimit(draft: FigureDraft): number {
35
42
  /**
36
43
  * 새 부품 하나를 만든다.
37
44
  *
38
- * 기준 상자의 3분의 1 크기로, **바닥 가운데**에 놓는다. 추가하자마자 화면에 보여야 한다 —
39
- * 크기 0 으로 놓으면 사용자에게는 아무 일도 일어나지 않은 것으로 보인다.
45
+ * 기준 상자의 3분의 1 크기다. 추가하자마자 화면에 보여야 한다 — 크기 0 으로 놓으면
46
+ * 사용자에게는 아무 일도 일어나지 않은 것으로 보인다.
47
+ *
48
+ * ## 어디에 놓나 — 고른 부품 **위**다
49
+ *
50
+ * 전에는 언제나 바닥 한가운데였다. 첫 부품은 그 자리가 맞지만 둘째부터는 **첫 부품 속에**
51
+ * 들어간다 — 같은 자리에 같은 크기로 놓이니 화면에는 덩어리 하나뿐이고, 저작자는 무엇이
52
+ * 늘었는지 못 본다. 「추가하자마자 보여야 한다」는 그때 이미 깨져 있었다.
53
+ *
54
+ * 그래서 고른 부품이 있으면 그 부품의 **윗면**에 얹고 발자국을 가운데로 맞춘다. 기계는
55
+ * 대개 그렇게 쌓인다(받침 → 어깨 → 팔). 관절을 걸 때도 이 자리가 필요하다 — 맞닿는 면이
56
+ * 실제로 있어야 `seamOrigin` 이 회전 중심을 그 면에서 잡는다.
57
+ *
58
+ * 위에 얹으면 기준 상자를 넘는 경우에는 바닥 한가운데로 간다. 넘은 채로 놓으면 발행이
59
+ * 막히고(`part-outside-base`), 그것을 고치는 일이 「부품을 추가했다」의 대가가 된다.
60
+ * 가로·세로는 상자 안으로 당긴다 — 고른 부품이 가장자리에 있으면 그 위가 상자 밖이다.
40
61
  */
41
- export function newPart(draft: FigureDraft, parts: PartModel[], primitive: PrimitiveKind): PartModel {
62
+ export function newPart(
63
+ draft: FigureDraft,
64
+ parts: PartModel[],
65
+ primitive: PrimitiveKind,
66
+ onTopOf?: PartModel
67
+ ): PartModel {
42
68
  const taken = new Set(parts.map(part => part.name))
43
69
 
44
70
  const width = Math.max(1, Math.round(draft.width / 3))
45
71
  const height = Math.max(1, Math.round(draft.height / 3))
46
72
  const depth = Math.max(1, Math.round(draft.depth / 3))
47
73
 
74
+ // 바닥 한가운데 — 고를 부품이 없을 때, 그리고 위가 상자 밖일 때의 자리.
75
+ const middle = {
76
+ left: Math.round((draft.width - width) / 2),
77
+ top: Math.round((draft.height - height) / 2),
78
+ // `zPos` 는 부피의 밑면이라 0 이 곧 상자 바닥이다.
79
+ zPos: 0
80
+ }
81
+ const above = onTopOf && {
82
+ left: clamp(Math.round(onTopOf.left + onTopOf.width / 2 - width / 2), 0, draft.width - width),
83
+ top: clamp(Math.round(onTopOf.top + onTopOf.height / 2 - height / 2), 0, draft.height - height),
84
+ zPos: Math.round(onTopOf.zPos + onTopOf.depth)
85
+ }
86
+ const at = above && above.zPos + depth <= draft.depth ? above : middle
87
+
48
88
  const part: PartModel = {
49
89
  type: 'figure-part',
50
90
  name: freeName(taken, primitive),
51
91
  primitive,
52
- left: Math.round((draft.width - width) / 2),
53
- top: Math.round((draft.height - height) / 2),
92
+ left: at.left,
93
+ top: at.top,
54
94
  width,
55
95
  height,
56
- // 바닥에 붙인다. `zPos` 는 부피의 밑면이라 0 이 곧 상자 바닥이다.
57
- zPos: 0,
96
+ zPos: at.zPos,
58
97
  depth,
59
98
  // 팔레트 토큰은 기존 부품의 것을 물려받는다 — 토큰이 늘어날 때마다 재질 그룹이 하나씩 는다.
60
99
  token: parts[0]?.token ?? 'palette.primary'
@@ -79,8 +118,35 @@ export function newPart(draft: FigureDraft, parts: PartModel[], primitive: Primi
79
118
  return part
80
119
  }
81
120
 
82
- export function addPart(draft: FigureDraft, parts: PartModel[], primitive: PrimitiveKind): PartModel[] {
83
- return [...parts, newPart(draft, parts, primitive)]
121
+ /**
122
+ * 씬에서 부품을 밀리미터로 맞춘다.
123
+ *
124
+ * 기즈모는 비율을 곱해서 크기를 낸다 — 420 에 1.72 를 곱하면 722.4180046035418 이고, 손대지
125
+ * 않은 축도 384.99999999999994 처럼 흔들린다. 저작자가 끈 결과가 소수 넷째 자리까지 남을
126
+ * 까닭이 없고, 칸에는 「722.4…」로 잘려 보인다. 이 편집기의 다른 길은 전부 정수 밀리미터로
127
+ * 맞춘다(부품을 새로 놓을 때 · 가운데 맞추기 · 격자 맞추기).
128
+ *
129
+ * 크기는 1 밑으로 내려가지 않는다 — 0 이면 부피가 사라지고 배율이 0 으로 나뉜다.
130
+ */
131
+ export function roundPart(part: PartModel): PartModel {
132
+ return {
133
+ ...part,
134
+ left: round(part.left),
135
+ top: round(part.top),
136
+ zPos: round(part.zPos),
137
+ width: Math.max(1, round(part.width)),
138
+ height: Math.max(1, round(part.height)),
139
+ depth: Math.max(1, round(part.depth))
140
+ }
141
+ }
142
+
143
+ export function addPart(
144
+ draft: FigureDraft,
145
+ parts: PartModel[],
146
+ primitive: PrimitiveKind,
147
+ onTopOf?: PartModel
148
+ ): PartModel[] {
149
+ return [...parts, newPart(draft, parts, primitive, onTopOf)]
84
150
  }
85
151
 
86
152
  export function removePart(parts: PartModel[], index: number): PartModel[] {
@@ -0,0 +1,67 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ */
4
+
5
+ import type { FigureDraft } from './figure-source.js'
6
+
7
+ /**
8
+ * 지금 세워 둔 자세를 **기본값으로 앉힌다.**
9
+ *
10
+ * ## 왜 필요한가
11
+ *
12
+ * 관절의 휴지 자세는 값이 0 인 자세다(ADR-0066). 팔이 여섯 마디면 그 자세는 곧게 선 기둥이라,
13
+ * 목록에서도 도면에서도 로봇으로 안 보인다. 로봇다운 자세는 **파라미터의 기본값**이 만든다 —
14
+ * 어깨 -35, 팔꿈치 70 을 적어 두면 아무도 값을 주지 않을 때 그 자세로 선다.
15
+ *
16
+ * 그것을 손으로 적으려면 미리보기에서 막대를 맞춘 뒤 그 수를 읽어 파라미터 칸마다 옮겨야 한다.
17
+ * 여섯 축이면 여섯 번이고, 옮기는 동안 화면의 자세와 적은 수가 갈린다. 여기서 하는 일은 그
18
+ * 옮겨 적기 하나다.
19
+ *
20
+ * ## 무엇을 건드리나
21
+ *
22
+ * **막대를 민 파라미터만** 건드린다. 안 민 것은 이미 제 기본값에 서 있으므로 적을 것이 없다.
23
+ * 값이 지금 기본값과 같으면 그 파라미터는 **그대로 둔다** — 새 객체로 바꾸면 고친 것이 없는데도
24
+ * 「저장 안 됨」이 붙는다. 하나도 안 바뀌면 받은 초안을 그대로 돌려준다.
25
+ */
26
+
27
+ type Parameter = NonNullable<FigureDraft['parameters']>[number]
28
+
29
+ /** 지금 이 파라미터가 서는 값. 형식의 `valueOf` 와 같은 순서로 읽는다. */
30
+ function standing(parameter: Parameter): number {
31
+ return parameter.default ?? parameter.range?.min ?? 0
32
+ }
33
+
34
+ /** 범위 밖은 끝에서 문다. 막대는 범위 안에서만 움직이지만, 값은 다른 길로도 들어온다. */
35
+ function settled(value: number, parameter: Parameter): number {
36
+ const range = parameter.range
37
+ const bound = range ? Math.min(Math.max(value, range.min), range.max) : value
38
+ return Math.round(bound * 10000) / 10000
39
+ }
40
+
41
+ /** 무엇이 달라지나 — 이름과 바뀔 값. 비어 있으면 앉힐 것이 없다. */
42
+ export function poseChanges(
43
+ draft: FigureDraft | undefined,
44
+ values: Record<string, number>
45
+ ): { name: string; from: number; to: number }[] {
46
+ return (draft?.parameters ?? []).flatMap(parameter => {
47
+ const given = values[parameter.name]
48
+ if (given === undefined || !Number.isFinite(given)) return []
49
+
50
+ const to = settled(given, parameter)
51
+ const from = standing(parameter)
52
+ return to === from ? [] : [{ name: parameter.name, from, to }]
53
+ })
54
+ }
55
+
56
+ /** 지금 자세를 기본값으로 앉힌 초안. 앉힐 것이 없으면 **받은 초안 그대로**다. */
57
+ export function poseAsDefaults(draft: FigureDraft, values: Record<string, number>): FigureDraft {
58
+ const changes = new Map(poseChanges(draft, values).map(change => [change.name, change.to]))
59
+ if (changes.size === 0) return draft
60
+
61
+ return {
62
+ ...draft,
63
+ parameters: (draft.parameters ?? []).map(parameter =>
64
+ changes.has(parameter.name) ? { ...parameter, default: changes.get(parameter.name)! } : parameter
65
+ )
66
+ }
67
+ }
@@ -32,6 +32,7 @@ export type ChangeKind =
32
32
  | 'animations'
33
33
  | 'parameters'
34
34
  | 'capabilities'
35
+ | 'joints'
35
36
 
36
37
  /**
37
38
  * 부품 밖에서 바뀔 수 있는 것 전부.
@@ -45,11 +46,15 @@ export type ChangeKind =
45
46
  * 말없이 사라진다. 그리고 `anchor` 는 도형 수준 필드였다가 형식에서 없어진 것인데(2026-09-10)
46
47
  * 여기 남아 있었다 — 이 목록이 형식을 따라가지 않은 것이 양쪽으로 드러난 셈이다.
47
48
  *
48
- * 형식의 `FigureSource` `type` · `parts` 빼면 여섯이 전부다. `type` 저장되는
49
+ * 같은 일이 `joints` 났다(2026-09-18). 형식에 관절이 서고 저작 화면도 지었는데
50
+ * 목록만 그대로여서, 관절을 더해 온 후보는 변경 목록에 안 뜨고 받아도 관절이 사라졌다. 형식의
51
+ * 칸을 세는 시험이 그것을 잡았다.
52
+ *
53
+ * 형식의 `FigureSource` 는 `type` · `parts` 를 빼면 이 일곱이 전부다. `type` 은 저장되는
49
54
  * 식별자라 후보가 바꿔 와도 받지 않고(`applyProposal` 참고), `version` 은 저작자가 고를 것이
50
55
  * 아니라 저장할 때 서버가 찍는다.
51
56
  */
52
- const FIGURE_FIELDS = ['base', 'placement', 'detailLevel', 'styleKit', 'animations', 'parameters', 'capabilities'] as const
57
+ const FIGURE_FIELDS = ['base', 'placement', 'detailLevel', 'styleKit', 'animations', 'parameters', 'capabilities', 'joints'] as const
53
58
  const FIELD_KIND: Record<(typeof FIGURE_FIELDS)[number], ChangeKind> = {
54
59
  base: 'base',
55
60
  placement: 'placement',
@@ -57,7 +62,8 @@ const FIELD_KIND: Record<(typeof FIGURE_FIELDS)[number], ChangeKind> = {
57
62
  styleKit: 'styleKit',
58
63
  animations: 'animations',
59
64
  parameters: 'parameters',
60
- capabilities: 'capabilities'
65
+ capabilities: 'capabilities',
66
+ joints: 'joints'
61
67
  }
62
68
 
63
69
  export interface ProposalChange {
@@ -1,58 +1,52 @@
1
1
  import type { FigureCost } from '@hatiolab/figure-model'
2
2
 
3
3
  /**
4
- * 한 도면이 감당하는 양 — 「몇 개까지 편한가」의 분모.
4
+ * 한 화면이 감당하는 양 — 「몇 개까지 편한가」의 분모.
5
5
  *
6
- * ## 값은 figure-model 것이어야 한다
6
+ * ## 놓은 개수가 아니라 **보이는 개수**다
7
7
  *
8
- * figure-model `LIMITS` 전부 **Figure 하나짜리** 한도다(묶음 3 · 애니메이션 2 …).
9
- * 「몇 개를 놓을 있나」는 도면이 감당하는 양을 알아야 나오는데,값은 아직
10
- * 어디에도 선언돼 있지 않다. 여기 두는 것은 임시다 figure-model 선언하면
11
- * 파일을 지운다. 두 벌이 되면 갈린다.
8
+ * 3D 요청이 있어야 다시 그린다. 가만히 있는 도형은 프레임마다 아무 값도 치르지 않는다 —
9
+ * 900 대를 놓아 두어도 60fps 다. 값을 치르는 것은 **카메라 안에 들어 있고 프레임에 다시
10
+ * 그려지는** 것뿐이다. 그래서 파일의 분모는 「한 도면에 놓는 수」가 아니라 「한 화면에 보이는
11
+ * 부품 묶음 수」다.
12
12
  *
13
- * ## 지어낸 수가 아니다
13
+ * ## 수는 잰 값이다
14
14
  *
15
- * `docs/design/component-authoring-tool.md` §4.15 「목표 수치」에 적힌 값이다.
15
+ * 전에는 설계 문서의 목표치(도면당 draw call 2,500)를 썼고, 그 문서가 스스로 「측정 없이 정한
16
+ * 목표」라고 적어 두었다. 2026-09-18 에 실기에서 쟀다
17
+ * (`things-scene/test/browser/measure-figure-frames.mjs`, Apple M5, 저작 화면과 같은 조명 · 그림자):
16
18
  *
17
- * 도면 자산 500
18
- * draw call ≤ 2500
19
- * 삼각형 ≤ 50 만
19
+ * 묶음 9 짜리 6 축 로봇 600 대 보이면 59.5 fps · 700 대에서 56.7 fps
20
+ * 묶음 2 짜리 컨베이어 1,600 대 보이면 59.3 fps · 2,500 대에서 36.8 fps
20
21
  *
21
- * 문서가 스스로 이렇게 적어 두었다 **「측정 없이 정한 목표다. 저작도구가 서면
22
- * 실제 도면으로 재고 조정한다」**(미결 D-20). 그래서 화면에도 근거를 함께 띄운다.
23
- * 숫자만 크게 내걸면 재 본 값처럼 읽힌다.
24
- */
25
- /**
26
- * 그림자 패스가 draw call 을 두 배로 만든다.
27
- *
28
- * 실측이다(2026-09-05, Apple M5). 자산 500~5,500 어디서나 실측이 figure-model 예측의
29
- * **정확히 2.00 배**로 나왔고, 그림자 맵을 끄면 예측과 같아졌다. 그림자 맵은 장면을
30
- * 한 번 더 그리기 때문이다. figure-model 의 `at.drawCalls` 는 색 패스만 센다.
22
+ * 도형의 꺾이는 지점이 **인스턴스 × 묶음 = 3,600 ~ 5,400** 들어온다. 보수적으로 4,000 을
23
+ * 쓴다. 도형이 달라도 곱이 비슷한 데서 꺾이므로, 분모는 묶음 수로 둔다.
31
24
  *
32
- * ## 등급 S 그림자를 진다
25
+ * 수는 **이 기계에서 이 씬으로** 잰 값이다. 더 낮은 GPU 나 브라우저에서는 더 일찍 꺾인다.
26
+ * 화면에는 잰 기계와 날짜를 함께 적는다 — 적지 않으면 어디서나 참인 수처럼 읽힌다.
33
27
  *
34
- * 설계 문서 §4.15 「그림자는 등급 M 이상만」이라고 정했다. 그래서 처음에는 등급이
35
- * S 면 이 곱을 빼려 했다. **그런데 등급 S 로 세워 재 보니 draw call 이 똑같이 두 배로
36
- * 나왔다** — 그 규칙은 문서에만 있고 제품에는 없다.
28
+ * ## 그림자는 이미 들어 있다
37
29
  *
38
- * 그래서 등급을 보지 않고 언제나 곱한다. 없는 규칙을 믿고 셈하면 화면이 「S 하면
39
- * 놓을 있다」고 거짓말을 한다. things-scene 규칙을 구현하면 그때
40
- * 등급을 보면 된다 그쪽에 알릴 것.
30
+ * 2026-09-05 에는 draw call figure-model 예측과 견주며 그림자 패스가 배로 만든다고
31
+ * 쟀다. 지금 값은 **그림자를 프레임 시간을 직접 잰 것**이라 배수가 이미 안에 있다.
32
+ * 다시 곱하면 세는 것이다.
41
33
  */
42
- export const SHADOW_PASS_FACTOR = 2
43
34
 
44
35
  export const SCENE_BUDGET = {
45
- /** 한 도면의 draw call. 로우폴리에서 먼저 차는 쪽이 대개 이것이다. */
46
- drawCalls: 2500,
47
- /** 한 도면의 삼각형. */
36
+ /** 한 화면에 보이는 부품 묶음. 60fps 꺾이는 지점, 실측. */
37
+ groupsOnScreen: 4000,
38
+ /** 한 화면의 삼각형. **이 수는 아직 안 쟀다** — 설계 문서의 목표치다. */
48
39
  triangles: 500_000
49
40
  } as const
50
41
 
42
+ /** 어디서 언제 잰 값인가. 화면이 함께 보여 준다 — 적지 않으면 어디서나 참인 수로 읽힌다. */
43
+ export const MEASURED = { gpu: 'Apple M5', on: '2026-09-18', fps: 60 } as const
44
+
51
45
  /** 무엇이 먼저 차나. */
52
- export type BindingBudget = 'drawCalls' | 'triangles'
46
+ export type BindingBudget = 'groups' | 'triangles'
53
47
 
54
48
  export interface FigureCapacity {
55
- /** 이 형상을개까지 놓을 있나. */
49
+ /** 이 형상이 **한 화면에 보일 때까지** 60fps 로 도나. */
56
50
  instances: number
57
51
  /** 먼저 차는 예산. **여기부터 고치면 된다.** */
58
52
  bound: BindingBudget
@@ -89,14 +83,11 @@ export function capacityFrom(groups: number, triangles: number): FigureCapacity
89
83
  return undefined
90
84
  }
91
85
 
92
- // 화면이 실제로 내는 draw call. 그림자 패스가 장면을 더 그린다 — 등급과 무관하게.
93
- const calls = groups * SHADOW_PASS_FACTOR
94
-
95
- const byDrawCalls = calls > 0 ? Math.floor(SCENE_BUDGET.drawCalls / calls) : Infinity
86
+ const byGroups = groups > 0 ? Math.floor(SCENE_BUDGET.groupsOnScreen / groups) : Infinity
96
87
  const byTriangles = triangles > 0 ? Math.floor(SCENE_BUDGET.triangles / triangles) : Infinity
97
88
 
98
- return byDrawCalls <= byTriangles
99
- ? { instances: byDrawCalls, bound: 'drawCalls', per: calls, budget: SCENE_BUDGET.drawCalls }
89
+ return byGroups <= byTriangles
90
+ ? { instances: byGroups, bound: 'groups', per: groups, budget: SCENE_BUDGET.groupsOnScreen }
100
91
  : { instances: byTriangles, bound: 'triangles', per: triangles, budget: SCENE_BUDGET.triangles }
101
92
  }
102
93
 
@@ -10,7 +10,7 @@ import { i18next, localize } from '@operato/i18n'
10
10
  import { p13n } from '@operato/p13n'
11
11
  import { auth } from '@things-factory/auth-base/dist-client'
12
12
  import { navigate, PageView } from '@operato/shell'
13
- import { CommonGristStyles, CommonHeaderStyles, ScrollbarStyles } from '@operato/styles'
13
+ import { CommonGristStyles, CommonHeaderStyles, ScrollbarStyles, WorkSurfacePageStyles } from '@operato/styles'
14
14
 
15
15
  import { capacityFrom } from '../modeller/scene-budget.js'
16
16
  import { deleteFigure, fetchFigureList } from '../graphql/index.js'
@@ -78,14 +78,13 @@ const FigureListPageBase = p13n(localize(i18next)(PageView))
78
78
  @customElement('figure-list-page')
79
79
  export class FigureListPage extends FigureListPageBase {
80
80
  static styles = [
81
+ /* The frame owns the page shell; the grist is the content it sizes (ADR-0060 decision 8). */
82
+ WorkSurfacePageStyles,
81
83
  ScrollbarStyles,
82
84
  CommonGristStyles,
83
85
  CommonHeaderStyles,
84
86
  css`
85
87
  :host {
86
- display: flex;
87
- flex-direction: column;
88
- width: 100%;
89
88
  background-color: var(--md-sys-color-surface);
90
89
  color: var(--md-sys-color-on-surface);
91
90
  /* 떠 있는 단추의 기준 상자 */
@@ -129,11 +128,6 @@ export class FigureListPage extends FigureListPageBase {
129
128
  }
130
129
 
131
130
  /* 격자가 바깥에 요구하는 것은 확정된 높이 하나뿐이다 — 스크롤은 격자가 안에서 한다 */
132
- ox-grist {
133
- flex: 1;
134
- min-height: 0;
135
- }
136
-
137
131
  /*
138
132
  ── 그림이 주인공인 카탈로그 ────────────────────────────────────────
139
133
  카드 기본값은 글이 많은 목록(직원·결재함)에 맞춰져 있다. 여기서는 사람이 먼저 보는 것이
@@ -210,6 +204,7 @@ export class FigureListPage extends FigureListPageBase {
210
204
  render() {
211
205
  return html`
212
206
  <ox-grist
207
+ class="page-fill"
213
208
  mode="CARD"
214
209
  @search-tag=${(e: CustomEvent) => this.findByTag(e.detail.tag)}
215
210
  .config=${this.gristConfig}