@things-factory/figure-ui 10.1.27 → 10.1.28

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 (75) hide show
  1. package/client/graphql/index.ts +23 -0
  2. package/client/modeller/base-box.ts +104 -0
  3. package/client/modeller/figure-animations.ts +81 -8
  4. package/client/modeller/figure-canvas.ts +18 -3
  5. package/client/modeller/figure-inspector.ts +151 -2
  6. package/client/modeller/figure-preview.ts +55 -0
  7. package/client/modeller/figure-report.ts +9 -4
  8. package/client/modeller/figure-side.ts +9 -1
  9. package/client/modeller/figure-source.ts +8 -0
  10. package/client/modeller/joint-edits.ts +66 -5
  11. package/client/modeller/joint-gizmo.ts +118 -0
  12. package/client/modeller/pose-defaults.ts +67 -0
  13. package/client/modeller/proposal.ts +9 -3
  14. package/client/modeller/scene-budget.ts +31 -40
  15. package/client/pages/figure-list-page.ts +4 -9
  16. package/client/pages/figure-modeller-page.ts +124 -41
  17. package/dist-client/graphql/index.d.ts +7 -0
  18. package/dist-client/graphql/index.js +21 -0
  19. package/dist-client/graphql/index.js.map +1 -1
  20. package/dist-client/modeller/base-box.d.ts +47 -0
  21. package/dist-client/modeller/base-box.js +52 -0
  22. package/dist-client/modeller/base-box.js.map +1 -0
  23. package/dist-client/modeller/figure-animations.d.ts +15 -0
  24. package/dist-client/modeller/figure-animations.js +77 -8
  25. package/dist-client/modeller/figure-animations.js.map +1 -1
  26. package/dist-client/modeller/figure-canvas.d.ts +9 -0
  27. package/dist-client/modeller/figure-canvas.js +14 -3
  28. package/dist-client/modeller/figure-canvas.js.map +1 -1
  29. package/dist-client/modeller/figure-inspector.d.ts +26 -0
  30. package/dist-client/modeller/figure-inspector.js +139 -2
  31. package/dist-client/modeller/figure-inspector.js.map +1 -1
  32. package/dist-client/modeller/figure-preview.d.ts +15 -0
  33. package/dist-client/modeller/figure-preview.js +46 -0
  34. package/dist-client/modeller/figure-preview.js.map +1 -1
  35. package/dist-client/modeller/figure-report.d.ts +3 -2
  36. package/dist-client/modeller/figure-report.js +9 -4
  37. package/dist-client/modeller/figure-report.js.map +1 -1
  38. package/dist-client/modeller/figure-side.d.ts +5 -1
  39. package/dist-client/modeller/figure-side.js +12 -0
  40. package/dist-client/modeller/figure-side.js.map +1 -1
  41. package/dist-client/modeller/figure-source.d.ts +7 -0
  42. package/dist-client/modeller/figure-source.js.map +1 -1
  43. package/dist-client/modeller/joint-edits.d.ts +23 -3
  44. package/dist-client/modeller/joint-edits.js +53 -5
  45. package/dist-client/modeller/joint-edits.js.map +1 -1
  46. package/dist-client/modeller/joint-gizmo.d.ts +31 -0
  47. package/dist-client/modeller/joint-gizmo.js +104 -0
  48. package/dist-client/modeller/joint-gizmo.js.map +1 -0
  49. package/dist-client/modeller/pose-defaults.d.ts +9 -0
  50. package/dist-client/modeller/pose-defaults.js +35 -0
  51. package/dist-client/modeller/pose-defaults.js.map +1 -0
  52. package/dist-client/modeller/proposal.d.ts +1 -1
  53. package/dist-client/modeller/proposal.js +8 -3
  54. package/dist-client/modeller/proposal.js.map +1 -1
  55. package/dist-client/modeller/scene-budget.d.ts +31 -34
  56. package/dist-client/modeller/scene-budget.js +28 -37
  57. package/dist-client/modeller/scene-budget.js.map +1 -1
  58. package/dist-client/pages/figure-list-page.js +4 -9
  59. package/dist-client/pages/figure-list-page.js.map +1 -1
  60. package/dist-client/pages/figure-modeller-page.d.ts +43 -0
  61. package/dist-client/pages/figure-modeller-page.js +121 -40
  62. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  63. package/dist-client/tsconfig.tsbuildinfo +1 -1
  64. package/package.json +3 -3
  65. package/test/ai-proposal-contract.test.ts +16 -11
  66. package/test/base-box.test.ts +154 -0
  67. package/test/joint-edits.test.ts +37 -2
  68. package/test/joint-gizmo.test.ts +174 -0
  69. package/test/pose-defaults.test.ts +71 -0
  70. package/test/scene-budget.test.ts +53 -0
  71. package/translations/en.json +13 -2
  72. package/translations/ja.json +13 -2
  73. package/translations/ko.json +13 -2
  74. package/translations/ms.json +13 -2
  75. package/translations/zh.json +13 -2
@@ -174,6 +174,14 @@ export interface PartModel {
174
174
  */
175
175
  hidden?: boolean
176
176
 
177
+ /**
178
+ * 씬이 이것을 **집지 않는다** — 클릭·마퀴·기즈모 어디에도 안 걸린다.
179
+ *
180
+ * 저작면에만 서는 것(관절 부채꼴)이 쓴다. 저장 형식에는 없는 칸이라 `toFigureSource` 가
181
+ * 싣지 않는다.
182
+ */
183
+ locked?: boolean
184
+
177
185
  /** 색 토큰 이름 (`palette.primary` 등). */
178
186
  token?: string
179
187
  /*
@@ -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,118 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ */
4
+
5
+ import type { FigureJoint, FigurePart } 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
+ }
@@ -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}