@things-factory/figure-ui 10.1.26 → 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 (83) 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 +253 -32
  4. package/client/modeller/figure-canvas.ts +18 -3
  5. package/client/modeller/figure-inspector.ts +292 -3
  6. package/client/modeller/figure-preview.ts +57 -2
  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 +24 -4
  10. package/client/modeller/figure-view.ts +20 -0
  11. package/client/modeller/joint-edits.ts +262 -0
  12. package/client/modeller/joint-gizmo.ts +118 -0
  13. package/client/modeller/pose-defaults.ts +67 -0
  14. package/client/modeller/proposal.ts +9 -3
  15. package/client/modeller/scene-budget.ts +31 -40
  16. package/client/pages/figure-list-page.ts +4 -9
  17. package/client/pages/figure-modeller-page.ts +124 -41
  18. package/dist-client/graphql/index.d.ts +7 -0
  19. package/dist-client/graphql/index.js +21 -0
  20. package/dist-client/graphql/index.js.map +1 -1
  21. package/dist-client/modeller/base-box.d.ts +47 -0
  22. package/dist-client/modeller/base-box.js +52 -0
  23. package/dist-client/modeller/base-box.js.map +1 -0
  24. package/dist-client/modeller/figure-animations.d.ts +29 -0
  25. package/dist-client/modeller/figure-animations.js +228 -25
  26. package/dist-client/modeller/figure-animations.js.map +1 -1
  27. package/dist-client/modeller/figure-canvas.d.ts +9 -0
  28. package/dist-client/modeller/figure-canvas.js +14 -3
  29. package/dist-client/modeller/figure-canvas.js.map +1 -1
  30. package/dist-client/modeller/figure-inspector.d.ts +37 -0
  31. package/dist-client/modeller/figure-inspector.js +253 -2
  32. package/dist-client/modeller/figure-inspector.js.map +1 -1
  33. package/dist-client/modeller/figure-preview.d.ts +15 -0
  34. package/dist-client/modeller/figure-preview.js +48 -2
  35. package/dist-client/modeller/figure-preview.js.map +1 -1
  36. package/dist-client/modeller/figure-report.d.ts +3 -2
  37. package/dist-client/modeller/figure-report.js +9 -4
  38. package/dist-client/modeller/figure-report.js.map +1 -1
  39. package/dist-client/modeller/figure-side.d.ts +5 -1
  40. package/dist-client/modeller/figure-side.js +12 -0
  41. package/dist-client/modeller/figure-side.js.map +1 -1
  42. package/dist-client/modeller/figure-source.d.ts +15 -0
  43. package/dist-client/modeller/figure-source.js +8 -4
  44. package/dist-client/modeller/figure-source.js.map +1 -1
  45. package/dist-client/modeller/figure-view.d.ts +16 -0
  46. package/dist-client/modeller/figure-view.js +15 -0
  47. package/dist-client/modeller/figure-view.js.map +1 -1
  48. package/dist-client/modeller/joint-edits.d.ts +49 -0
  49. package/dist-client/modeller/joint-edits.js +223 -0
  50. package/dist-client/modeller/joint-edits.js.map +1 -0
  51. package/dist-client/modeller/joint-gizmo.d.ts +31 -0
  52. package/dist-client/modeller/joint-gizmo.js +104 -0
  53. package/dist-client/modeller/joint-gizmo.js.map +1 -0
  54. package/dist-client/modeller/pose-defaults.d.ts +9 -0
  55. package/dist-client/modeller/pose-defaults.js +35 -0
  56. package/dist-client/modeller/pose-defaults.js.map +1 -0
  57. package/dist-client/modeller/proposal.d.ts +1 -1
  58. package/dist-client/modeller/proposal.js +8 -3
  59. package/dist-client/modeller/proposal.js.map +1 -1
  60. package/dist-client/modeller/scene-budget.d.ts +31 -34
  61. package/dist-client/modeller/scene-budget.js +28 -37
  62. package/dist-client/modeller/scene-budget.js.map +1 -1
  63. package/dist-client/pages/figure-list-page.js +4 -9
  64. package/dist-client/pages/figure-list-page.js.map +1 -1
  65. package/dist-client/pages/figure-modeller-page.d.ts +43 -0
  66. package/dist-client/pages/figure-modeller-page.js +121 -40
  67. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  68. package/dist-client/tsconfig.tsbuildinfo +1 -1
  69. package/dist-server/tsconfig.tsbuildinfo +1 -1
  70. package/package.json +3 -3
  71. package/test/ai-proposal-contract.test.ts +16 -11
  72. package/test/base-box.test.ts +154 -0
  73. package/test/figure-source.test.ts +32 -0
  74. package/test/joint-edits.test.ts +165 -0
  75. package/test/joint-gizmo.test.ts +174 -0
  76. package/test/pose-defaults.test.ts +71 -0
  77. package/test/preview-board-depth.test.ts +27 -0
  78. package/test/scene-budget.test.ts +53 -0
  79. package/translations/en.json +37 -2
  80. package/translations/ja.json +37 -2
  81. package/translations/ko.json +37 -2
  82. package/translations/ms.json +37 -2
  83. package/translations/zh.json +37 -2
@@ -0,0 +1,165 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import { validate } from '@hatiolab/figure-model'
5
+ import type { FigurePart } from '@hatiolab/figure-model'
6
+
7
+ import { parentChoices, patchJoint, setJointType, jointOf } from '../client/modeller/joint-edits.ts'
8
+ import { fromFigureSource, partToFigure, toFigureSource } from '../client/modeller/figure-source.ts'
9
+ import type { FigureDraft, PartModel } from '../client/modeller/figure-source.ts'
10
+
11
+ /*
12
+ * Editing joints (ADR-0066). Every result is checked against the format's own validate, so an edit that
13
+ * produces a source the format refuses fails here instead of on save.
14
+ */
15
+
16
+ const part = (name: string, zPos: number, depth: number, parent?: string): PartModel => ({
17
+ type: 'figure-part',
18
+ name,
19
+ primitive: 'cube',
20
+ left: 50,
21
+ top: 50,
22
+ width: 100,
23
+ height: 100,
24
+ zPos,
25
+ depth,
26
+ token: 'palette.primary',
27
+ ...(parent ? { parent } : {})
28
+ })
29
+
30
+ const PARTS: PartModel[] = [part('base', 0, 100), part('arm', 100, 400, 'base'), part('tip', 500, 100, 'arm')]
31
+ const DRAFT: FigureDraft = { version: 2, figureType: 'ARM', width: 200, height: 200, depth: 600, detailLevel: 'M' }
32
+
33
+ const figurePart = (draft: FigureDraft, name: string): FigurePart => partToFigure(PARTS.find(p => p.name === name)!, draft)
34
+ const errorsOf = (draft: FigureDraft) => validate(toFigureSource(draft, PARTS)).errors.map(e => `${e.code}@${e.path}`)
35
+
36
+ describe('joint edits', () => {
37
+ it('offers as parents every part except itself and what hangs below it', () => {
38
+ assert.deepEqual(parentChoices(PARTS, 'arm'), ['base'])
39
+ // arm and tip hang below base, so base has nothing to attach to
40
+ assert.deepEqual(parentChoices(PARTS, 'base'), [])
41
+ assert.deepEqual(parentChoices([...PARTS, part('lamp', 0, 10)], 'arm'), ['base', 'lamp'])
42
+ assert.deepEqual(parentChoices(PARTS, 'tip'), ['base', 'arm'])
43
+ })
44
+
45
+ it('adds a joint where the part meets its parent, with a parameter that drives it', () => {
46
+ const draft = setJointType(DRAFT, figurePart(DRAFT, 'arm'), 'revolute', figurePart(DRAFT, 'base'))
47
+ const joint = jointOf(draft, 'arm')!
48
+ assert.deepEqual(joint, {
49
+ name: 'arm-joint',
50
+ child: 'arm',
51
+ type: 'revolute',
52
+ origin: { x: 0, y: 100, z: 0 },
53
+ axis: { x: 0, y: 1, z: 0 },
54
+ limits: { min: -90, max: 90 }
55
+ })
56
+ assert.deepEqual(draft.parameters, [
57
+ {
58
+ name: 'arm-joint',
59
+ range: { unit: 'deg', min: -90, max: 90 },
60
+ default: 0,
61
+ clip: { channels: [{ target: 'arm-joint', keys: [{ at: 0, value: -90 }, { at: 1, value: 90 }] }] }
62
+ }
63
+ ])
64
+ assert.deepEqual(errorsOf(draft), [])
65
+ })
66
+
67
+ it('moves a plain driving parameter to millimetres when the joint starts sliding', () => {
68
+ const turning = setJointType(DRAFT, figurePart(DRAFT, 'arm'), 'revolute')
69
+ const sliding = setJointType(turning, figurePart(turning, 'arm'), 'prismatic')
70
+ assert.deepEqual(jointOf(sliding, 'arm')!.limits, { min: 0, max: 500 })
71
+ assert.deepEqual(sliding.parameters![0]!.range, { unit: 'mm', min: 0, max: 500 })
72
+ assert.deepEqual(errorsOf(sliding), [])
73
+ })
74
+
75
+ it('drops limits for a continuous joint and still validates', () => {
76
+ const turning = setJointType(DRAFT, figurePart(DRAFT, 'arm'), 'revolute')
77
+ const endless = setJointType(turning, figurePart(turning, 'arm'), 'continuous')
78
+ assert.equal('limits' in jointOf(endless, 'arm')!, false)
79
+ assert.deepEqual(endless.parameters![0]!.range, { unit: 'deg', min: -360, max: 360 })
80
+ assert.deepEqual(errorsOf(endless), [])
81
+ })
82
+
83
+ it('moves a plain driving parameter with new limits, and leaves one the author reshaped alone', () => {
84
+ const turning = setJointType(DRAFT, figurePart(DRAFT, 'arm'), 'revolute')
85
+ const wider = patchJoint(turning, 'arm-joint', { limits: { min: -120, max: 150 } })
86
+ assert.deepEqual(wider.parameters![0]!.range, { unit: 'deg', min: -120, max: 150 })
87
+ assert.deepEqual(errorsOf(wider), [])
88
+
89
+ const reshaped: FigureDraft = {
90
+ ...turning,
91
+ parameters: [
92
+ {
93
+ ...turning.parameters![0]!,
94
+ clip: { channels: [{ target: 'arm-joint', keys: [{ at: 0, value: 0 }, { at: 0.5, value: 45 }, { at: 1, value: 90 }] }] }
95
+ }
96
+ ]
97
+ }
98
+ const kept = patchJoint(reshaped, 'arm-joint', { limits: { min: -120, max: 150 } })
99
+ assert.deepEqual(kept.parameters, reshaped.parameters)
100
+ })
101
+
102
+ it('clearing the joint removes it and every channel that drove it, and nothing else', () => {
103
+ const turning = setJointType(DRAFT, figurePart(DRAFT, 'arm'), 'revolute')
104
+ const withClip: FigureDraft = {
105
+ ...turning,
106
+ animations: [
107
+ {
108
+ name: 'wiggle',
109
+ channels: [
110
+ { target: 'arm-joint', keys: [{ at: 0, value: 0 }, { at: 1, value: 30 }] },
111
+ { target: 'tip', path: 'rotation', keys: [{ at: 0, value: { x: 0, y: 0, z: 0 } }, { at: 1, value: { x: 0, y: 90, z: 0 } }] }
112
+ ]
113
+ }
114
+ ]
115
+ }
116
+ const cleared = setJointType(withClip, figurePart(withClip, 'arm'), undefined)
117
+ assert.equal(cleared.joints, undefined)
118
+ assert.deepEqual(cleared.parameters, [])
119
+ assert.deepEqual(cleared.animations![0]!.channels.map(c => c.target), ['tip'])
120
+ assert.deepEqual(errorsOf(cleared), [])
121
+ })
122
+
123
+ /*
124
+ * 관절은 두 부품이 **맞닿는 자리**에 선다. 위로 쌓은 부품에서는 아랫면이지만, 옆에 달린
125
+ * 부품에서는 옆면이다 — 그 구분이 없으면 캐비닛 문의 축이 문 바닥에 생긴다.
126
+ */
127
+ describe('새 관절이 서는 자리', () => {
128
+ const at = (over: Partial<PartModel>): PartModel => ({ ...part('door', 0, 100), ...over })
129
+
130
+ it('위에 쌓인 부품은 아랫면에서 만난다', () => {
131
+ /* arm 은 base(0..100) 위 100..500 이다. 맞닿는 면이 100 이고, 가로로는 둘이 겹치는 가운데다. */
132
+ const joint = jointOf(setJointType(DRAFT, figurePart(DRAFT, 'arm'), 'revolute', figurePart(DRAFT, 'base')), 'arm')!
133
+ assert.deepEqual(joint.origin, { x: 0, y: 100, z: 0 })
134
+ })
135
+
136
+ it('옆에 달린 부품은 옆면에서 만난다 — 아랫면이 아니다', () => {
137
+ /* 문은 몸통(가운데 0, 폭 100)의 오른쪽에 붙어 선다. 축은 문의 왼쪽 면, 높이는 겹치는 가운데. */
138
+ const body = at({ name: 'body', left: 0, top: 0, width: 100, height: 100, zPos: 0, depth: 400 })
139
+ const door = at({ name: 'door', left: 100, top: 0, width: 20, height: 100, zPos: 40, depth: 320, parent: 'body' })
140
+ const parts = [body, door]
141
+ const draft: FigureDraft = { ...DRAFT, width: 400, height: 400, depth: 400 }
142
+ const shown = (one: PartModel) => partToFigure(one, draft)
143
+
144
+ const joint = jointOf(setJointType(draft, shown(door), 'revolute', shown(body)), 'door')!
145
+ const half = draft.width / 2
146
+
147
+ assert.equal(joint.origin.x, 100 - half, '문의 왼쪽 면에 선다')
148
+ assert.equal(joint.origin.y, 200, '겹치는 높이(40..360 과 0..400)의 한가운데다')
149
+ assert.deepEqual(parts.map(one => one.name), ['body', 'door'])
150
+ })
151
+
152
+ it('붙은 부품이 없으면 아랫면이다 — 그때 맞닿는 것은 바닥이다', () => {
153
+ const joint = jointOf(setJointType(DRAFT, figurePart(DRAFT, 'base'), 'revolute'), 'base')!
154
+ assert.equal(joint.origin.y, 0)
155
+ })
156
+ })
157
+
158
+ it('a joint survives opening and saving', () => {
159
+ const turning = setJointType(DRAFT, figurePart(DRAFT, 'arm'), 'revolute')
160
+ const saved = toFigureSource(turning, PARTS)
161
+ const { draft, parts } = fromFigureSource(saved)
162
+ assert.deepEqual(toFigureSource(draft, parts), saved)
163
+ assert.equal(saved.parts.find(p => p.name === 'tip')!.parent, 'arm')
164
+ })
165
+ })
@@ -0,0 +1,174 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import type { FigureJoint } from '@hatiolab/figure-model'
5
+
6
+ import { axisName, gizmoPart, JOINT_GIZMO } from '../client/modeller/joint-gizmo.ts'
7
+ import type { FigureDraft, PartModel } from '../client/modeller/figure-source.ts'
8
+
9
+ /*
10
+ * 관절이 도는 자리를 저작면에 그리는 부채꼴.
11
+ *
12
+ * 저작자가 축을 고를 때 보는 것이라, **잘못 그리면 틀린 쪽을 가리킨다** — 아무것도 안 그리는
13
+ * 것보다 나쁘다. 그래서 그릴 수 없는 축은 그리지 않는다.
14
+ */
15
+
16
+ const DRAFT: FigureDraft = { version: 2, figureType: 'ARM', width: 400, height: 400, depth: 1200, detailLevel: 'M' }
17
+
18
+ const ARM: PartModel = {
19
+ type: 'figure-part',
20
+ name: 'arm',
21
+ primitive: 'cube',
22
+ left: 150,
23
+ top: 150,
24
+ width: 100,
25
+ height: 100,
26
+ zPos: 200,
27
+ depth: 500,
28
+ token: 'palette.primary'
29
+ }
30
+
31
+ const joint = (over: Partial<FigureJoint> = {}): FigureJoint => ({
32
+ name: 'arm-joint',
33
+ child: 'arm',
34
+ type: 'revolute',
35
+ origin: { x: 0, y: 200, z: 0 },
36
+ axis: { x: 0, y: 1, z: 0 },
37
+ limits: { min: -90, max: 90 },
38
+ ...over
39
+ })
40
+
41
+ /** 부채꼴 점들이 도는 각. 0 도에서 시작해 어디까지 벌어졌나. */
42
+ function angles(path: { x: number; y: number }[]) {
43
+ const arc = path.filter(point => Math.hypot(point.x, point.y) > 1)
44
+ return arc.map(point => Math.round((Math.atan2(point.y, point.x) * 180) / Math.PI))
45
+ }
46
+
47
+ describe('관절 부채꼴', () => {
48
+ it('축 벡터를 이름으로 옮긴다', () => {
49
+ assert.equal(axisName({ x: 0, y: 1, z: 0 }), '+y')
50
+ assert.equal(axisName({ x: -1, y: 0, z: 0 }), '-x')
51
+ assert.equal(axisName({ x: 0, y: 0, z: 1 }), '+z')
52
+ })
53
+
54
+ it('비스듬한 축은 이름이 없고, 그리지도 않는다 — 아무 쪽이나 고르면 틀린 쪽을 가리킨다', () => {
55
+ const slanted = { x: 0.7, y: 0.7, z: 0 }
56
+ assert.equal(axisName(slanted), undefined)
57
+ assert.equal(gizmoPart(joint({ axis: slanted }), ARM, DRAFT), undefined)
58
+ })
59
+
60
+ it('가동 범위만큼 벌어지고, 가운데 점이 하나 있다', () => {
61
+ const part = gizmoPart(joint(), ARM, DRAFT)!
62
+ const path = part.path!
63
+
64
+ assert.equal(part.name, JOINT_GIZMO)
65
+ assert.deepEqual(path[0], { x: 0, y: 0 }, '부채꼴은 가운데에서 시작한다')
66
+
67
+ const turned = angles(path)
68
+ const span = Math.abs(turned[turned.length - 1]! - turned[0]!)
69
+ assert.equal(span, 180, '−90 에서 90 까지면 180 도 벌어진다')
70
+ })
71
+
72
+ it('무한 회전은 온전한 원이라 가운데 점이 없다', () => {
73
+ const part = gizmoPart(joint({ type: 'continuous', limits: undefined }), ARM, DRAFT)!
74
+ const path = part.path!
75
+
76
+ assert.ok(path.every(point => Math.hypot(point.x, point.y) > 1), '가운데 점이 없다')
77
+ assert.ok(path.length >= 60, '한 바퀴를 6도씩 그린다')
78
+ })
79
+
80
+ it('반지름은 도는 부품의 크기를 따라간다 — 부품보다 작으면 무엇이 도는지 안 읽힌다', () => {
81
+ const big = gizmoPart(joint(), ARM, DRAFT)!
82
+ const small = gizmoPart(joint(), { ...ARM, width: 40, height: 40, depth: 40 }, DRAFT)!
83
+
84
+ assert.equal(big.width, 2 * Math.round(500 * 0.6))
85
+ assert.equal(small.width, 2 * 60, '작은 부품에도 눈에 보이는 최소 크기가 있다')
86
+ })
87
+
88
+ it('관절의 기준점에 선다', () => {
89
+ const part = gizmoPart(joint({ origin: { x: 50, y: 300, z: -20 } }), ARM, DRAFT)!
90
+ /* 저작면은 왼쪽 위 기준이고 z 는 밑면이다. 기준점이 가운데로 가야 맞다. */
91
+ assert.equal(part.left + part.width / 2, DRAFT.width / 2 + 50)
92
+ assert.equal(part.top + part.height / 2, DRAFT.height / 2 - 20)
93
+ assert.equal(part.zPos + part.depth / 2, 300)
94
+ })
95
+
96
+ it('저작면에만 있고, 씬이 집지 않는다', () => {
97
+ const part = gizmoPart(joint(), ARM, DRAFT)!
98
+ assert.equal(part.locked, true)
99
+ assert.equal(part.transparent, true)
100
+ })
101
+
102
+ /*
103
+ * 가리키는 쪽이 맞나 — **이것이 이 부채꼴의 전부다.**
104
+ *
105
+ * 반대로 벌어진 부채꼴은 없느니만 못하다. 저작자가 그것을 보고 축을 고르기 때문이다. 축마다
106
+ * 씬의 회전과 단면의 각이 도는 쪽이 갈려서, 여섯 축을 하나씩 런타임이 도는 식(로드리게스)과
107
+ * 맞대 본다.
108
+ */
109
+ describe('벌어진 쪽이 실제로 도는 쪽과 같다', () => {
110
+ const AXES: [string, { x: number; y: number; z: number }][] = [
111
+ ['+x', { x: 1, y: 0, z: 0 }],
112
+ ['-x', { x: -1, y: 0, z: 0 }],
113
+ ['+y', { x: 0, y: 1, z: 0 }],
114
+ ['-y', { x: 0, y: -1, z: 0 }],
115
+ ['+z', { x: 0, y: 0, z: 1 }],
116
+ ['-z', { x: 0, y: 0, z: -1 }]
117
+ ]
118
+
119
+ /** 로드리게스 — 런타임이 관절을 돌리는 식(`setFromAxisAngle`)과 같은 회전이다. */
120
+ function turned(v: { x: number; y: number; z: number }, axis: { x: number; y: number; z: number }, degrees: number) {
121
+ const t = (degrees * Math.PI) / 180
122
+ const c = Math.cos(t)
123
+ const s = Math.sin(t)
124
+ const dot = axis.x * v.x + axis.y * v.y + axis.z * v.z
125
+ const cross = {
126
+ x: axis.y * v.z - axis.z * v.y,
127
+ y: axis.z * v.x - axis.x * v.z,
128
+ z: axis.x * v.y - axis.y * v.x
129
+ }
130
+ return {
131
+ x: v.x * c + cross.x * s + axis.x * dot * (1 - c),
132
+ y: v.y * c + cross.y * s + axis.y * dot * (1 - c),
133
+ z: v.z * c + cross.z * s + axis.z * dot * (1 - c)
134
+ }
135
+ }
136
+
137
+ /** 단면의 점을 부품 회전까지 태워 figure 좌표로. 단면은 부품의 x–z 평면에 눕는다. */
138
+ function inFigure(part: PartModel, point: { x: number; y: number }) {
139
+ const v = { x: point.x, y: 0, z: point.y }
140
+ const rx = part.rotationX ?? 0
141
+ const rz = part.rotationY ?? 0
142
+ const afterZ = { x: v.x * Math.cos(rz) - v.y * Math.sin(rz), y: v.x * Math.sin(rz) + v.y * Math.cos(rz), z: v.z }
143
+ return { x: afterZ.x, y: afterZ.y * Math.cos(rx) - afterZ.z * Math.sin(rx), z: afterZ.y * Math.sin(rx) + afterZ.z * Math.cos(rx) }
144
+ }
145
+
146
+ for (const [name, axis] of AXES) {
147
+ it(`${name} 축`, () => {
148
+ const limits = { min: -90, max: 90 }
149
+ const part = gizmoPart(joint({ axis, limits }), ARM, DRAFT)!
150
+ /* 가운데 점 하나에 호가 이어진다. 한 바퀴가 아니면 늘 그렇다. */
151
+ const arc = part.path!.slice(1)
152
+ const at = (degrees: number) =>
153
+ inFigure(part, arc[Math.round(((degrees - limits.min) / (limits.max - limits.min)) * (arc.length - 1))]!)
154
+
155
+ /* 조각이 6 도씩이라 48 도가 점 하나에 정확히 떨어진다 — 사이 값을 쓰면 반올림이 섞인다. */
156
+ const zero = at(0)
157
+ const swung = at(48)
158
+ const runtime = turned(zero, axis, 48)
159
+ const flipped = turned(zero, axis, -48)
160
+
161
+ const off = Math.hypot(swung.x - runtime.x, swung.y - runtime.y, swung.z - runtime.z)
162
+ const offFlipped = Math.hypot(swung.x - flipped.x, swung.y - flipped.y, swung.z - flipped.z)
163
+
164
+ assert.ok(off < 0.5, `${name}: 부채꼴이 ${off.toFixed(1)}mm 어긋난다 — 반대로 재면 ${offFlipped.toFixed(1)}mm`)
165
+ })
166
+ }
167
+ })
168
+
169
+ it('수평축 관절은 눕는다 — 도는 평면이 세로다', () => {
170
+ const part = gizmoPart(joint({ axis: { x: 1, y: 0, z: 0 } }), ARM, DRAFT)!
171
+ /* 씬은 라디안을 쓴다. z 90 도가 `rotationY` 로 간다(`figure-source.ts`). */
172
+ assert.equal(Math.round(((part.rotationY ?? 0) * 180) / Math.PI), 90)
173
+ })
174
+ })
@@ -0,0 +1,71 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import { poseAsDefaults, poseChanges } from '../client/modeller/pose-defaults.ts'
5
+ import type { FigureDraft } from '../client/modeller/figure-source.ts'
6
+
7
+ /*
8
+ * 미리보기에서 세운 자세를 기본값으로 앉힌다.
9
+ *
10
+ * 관절 도형의 휴지 자세(값 0)는 곧게 선 기둥이라, 기본값이 없으면 목록에서도 도면에서도 그
11
+ * 모양으로 선다. 옮겨 적는 일을 손으로 하면 여섯 축에 여섯 번이고, 옮기는 사이 화면의 자세와
12
+ * 적은 수가 갈린다.
13
+ */
14
+
15
+ const DRAFT: FigureDraft = {
16
+ version: 2,
17
+ figureType: 'ARM',
18
+ width: 900,
19
+ height: 900,
20
+ depth: 1900,
21
+ detailLevel: 'L',
22
+ parameters: [
23
+ { name: 'shoulder', range: { unit: 'deg', min: -120, max: 120 }, default: 0, clip: { channels: [] } },
24
+ { name: 'elbow', range: { unit: 'deg', min: -150, max: 150 }, default: 0, clip: { channels: [] } },
25
+ /* 기본값을 안 적은 파라미터. 형식은 그때 범위의 처음에 선다. */
26
+ { name: 'hoist', range: { unit: 'mm', min: 0, max: 1800 }, clip: { channels: [] } }
27
+ ]
28
+ }
29
+
30
+ describe('이 자세를 기본으로', () => {
31
+ it('민 것만 앉히고 나머지는 건드리지 않는다', () => {
32
+ const next = poseAsDefaults(DRAFT, { shoulder: -35 })
33
+
34
+ assert.equal(next.parameters![0]!.default, -35)
35
+ assert.equal(next.parameters![1], DRAFT.parameters![1], '안 민 파라미터는 같은 객체 그대로다')
36
+ assert.equal(next.parameters![2], DRAFT.parameters![2])
37
+ })
38
+
39
+ it('이미 그 값이면 초안을 그대로 돌려준다 — 고친 것이 없는데 「저장 안 됨」이 붙지 않게', () => {
40
+ assert.equal(poseAsDefaults(DRAFT, { shoulder: 0 }), DRAFT)
41
+ assert.equal(poseAsDefaults(DRAFT, {}), DRAFT)
42
+ /* 기본값을 안 적은 파라미터는 범위의 처음에 서 있다. 그 값을 다시 적지 않는다. */
43
+ assert.equal(poseAsDefaults(DRAFT, { hoist: 0 }), DRAFT)
44
+ })
45
+
46
+ it('기본값을 안 적었던 파라미터에도 앉는다', () => {
47
+ const next = poseAsDefaults(DRAFT, { hoist: 1200 })
48
+ assert.equal(next.parameters![2]!.default, 1200)
49
+ })
50
+
51
+ it('범위 밖은 끝에서 문다 — 다른 길로 들어온 값도 형식을 지나야 한다', () => {
52
+ const next = poseAsDefaults(DRAFT, { elbow: 400 })
53
+ assert.equal(next.parameters![1]!.default, 150)
54
+ })
55
+
56
+ it('수가 아닌 값은 안 말한 것으로 친다', () => {
57
+ for (const bad of [NaN, Infinity, undefined as unknown as number]) {
58
+ assert.equal(poseAsDefaults(DRAFT, { shoulder: bad }), DRAFT, String(bad))
59
+ }
60
+ })
61
+
62
+ it('무엇이 달라지는지 먼저 말할 수 있다 — 단추를 띄울지 이것으로 정한다', () => {
63
+ assert.deepEqual(poseChanges(DRAFT, { shoulder: -35, elbow: 0 }), [{ name: 'shoulder', from: 0, to: -35 }])
64
+ assert.deepEqual(poseChanges(DRAFT, { shoulder: 0 }), [])
65
+ assert.deepEqual(poseChanges(undefined, { shoulder: -35 }), [])
66
+ })
67
+
68
+ it('없는 파라미터 이름은 무시한다 — 후보를 보다 남은 값이 섞여도 초안을 늘리지 않는다', () => {
69
+ assert.equal(poseAsDefaults(DRAFT, { 'no-such-thing': 42 }), DRAFT)
70
+ })
71
+ })
@@ -0,0 +1,27 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import { previewBoardDepth } from '../client/modeller/figure-view.ts'
5
+
6
+ describe('preview board depth', () => {
7
+ it('a floor figure gets room for its tallest instance, not the ceiling', () => {
8
+ // AGV: base 460 tall, shown up to twice. The board was 4400 and the camera aimed at 2200.
9
+ assert.equal(previewBoardDepth({ placement: 'floor', base: { y: 460 } }, 4000, 2), 1012)
10
+ })
11
+
12
+ it('a figure with no placement stands on the floor', () => {
13
+ assert.equal(previewBoardDepth({ base: { y: 460 } }, 4000, 2), 1012)
14
+ })
15
+
16
+ it('a center figure has no default height, so it stands on the floor too', () => {
17
+ assert.equal(previewBoardDepth({ placement: 'center', base: { y: 460 } }, 4000, 2), 1012)
18
+ })
19
+
20
+ it('a ceiling figure gets room up to the ceiling it hangs from', () => {
21
+ assert.equal(previewBoardDepth({ placement: 'ceiling', base: { y: 460 } }, 4000, 2), 4400)
22
+ })
23
+
24
+ it('a ceiling figure taller than the ceiling still fits', () => {
25
+ assert.equal(previewBoardDepth({ placement: 'ceiling', base: { y: 2500 } }, 4000, 2), 5500)
26
+ })
27
+ })
@@ -0,0 +1,53 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import { capacityFrom, MEASURED, SCENE_BUDGET } from '../client/modeller/scene-budget.ts'
5
+
6
+ /*
7
+ * 「몇 개까지 편한가」의 분모.
8
+ *
9
+ * 이 수는 **잰 값이다**(2026-09-18, Apple M5). 시험이 지키는 것은 셈이 아니라 그 사실이다 —
10
+ * 분모가 조용히 목표치로 돌아가거나, 잰 기계와 날짜가 화면에서 빠지면 사람이 어디서나 참인
11
+ * 수로 읽는다.
12
+ */
13
+
14
+ describe('한 화면이 감당하는 양', () => {
15
+ it('분모는 화면에 보이는 부품 묶음이고, 잰 기계와 날짜를 달고 있다', () => {
16
+ assert.equal(SCENE_BUDGET.groupsOnScreen, 4000)
17
+ assert.equal(MEASURED.gpu, 'Apple M5')
18
+ assert.equal(MEASURED.on, '2026-09-18')
19
+ assert.equal(MEASURED.fps, 60)
20
+ })
21
+
22
+ it('묶음으로 갈리면 묶음 수로 나눈다 — 그림자를 다시 곱하지 않는다', () => {
23
+ /*
24
+ 실측이 그림자를 켠 채 잰 값이라 배수가 이미 안에 있다. 전에는 여기서 2 를 곱했고,
25
+ 그 곱을 남겨 두면 같은 값을 두 번 세어 개수가 절반으로 나온다.
26
+ */
27
+ const robot = capacityFrom(9, 200)!
28
+ assert.equal(robot.bound, 'groups')
29
+ assert.equal(robot.per, 9)
30
+ assert.equal(robot.instances, Math.floor(4000 / 9))
31
+ })
32
+
33
+ it('삼각형이 먼저 차면 그쪽으로 갈린다', () => {
34
+ const heavy = capacityFrom(2, 100_000)!
35
+ assert.equal(heavy.bound, 'triangles')
36
+ assert.equal(heavy.instances, 5)
37
+ })
38
+
39
+ it('잴 것이 없으면 답하지 않는다 — 「무한히 놓을 수 있다」가 아니다', () => {
40
+ assert.equal(capacityFrom(0, 0), undefined)
41
+ })
42
+
43
+ /*
44
+ 실측한 두 도형이 이 분모로 되돌아오는지 본다. 분모를 누가 고치면 이 둘이 먼저 어긋난다.
45
+ */
46
+ it('잰 두 도형이 잰 수와 맞다', () => {
47
+ /* 묶음 9 짜리 6 축 로봇: 600 대에서 59.5fps, 700 대에서 56.7fps 였다. */
48
+ assert.ok(capacityFrom(9, 200)!.instances <= 600, '잰 값보다 많이 놓으라고 하면 안 된다')
49
+ /* 묶음 2 짜리 컨베이어: 1,600 대에서 59.3fps, 2,500 대에서 36.8fps 였다. */
50
+ const conveyor = capacityFrom(2, 100)!
51
+ assert.ok(conveyor.instances >= 1600 && conveyor.instances <= 2500)
52
+ })
53
+ })
@@ -12,6 +12,7 @@
12
12
  "figure.button.delete-figure": "delete",
13
13
  "figure.button.discard": "discard",
14
14
  "figure.button.duplicate-part": "duplicate",
15
+ "figure.button.fit-base-to-parts": "fit to parts",
15
16
  "figure.button.mirror-x": "mirror X",
16
17
  "figure.button.mirror-z": "mirror Z",
17
18
  "figure.button.move-part-down": "move down",
@@ -19,6 +20,7 @@
19
20
  "figure.button.new-figure": "new figure",
20
21
  "figure.button.pivot-clear": "part centre",
21
22
  "figure.button.pivot-set": "set",
23
+ "figure.button.pose-as-default": "make this the default pose",
22
24
  "figure.button.preview-fix": "preview fix · {part} {axis} face: {from} → {to}",
23
25
  "figure.button.redo": "redo",
24
26
  "figure.button.release-figure": "release",
@@ -82,6 +84,7 @@
82
84
  "figure.label.axis-scale": "scale",
83
85
  "figure.label.axis-scale-short": "scale",
84
86
  "figure.label.axis-width": "width",
87
+ "figure.label.base-box": "base box (mm)",
85
88
  "figure.label.budget-animations": "moving parts",
86
89
  "figure.label.budget-groups": "material groups",
87
90
  "figure.label.budget-parts": "part count",
@@ -96,6 +99,8 @@
96
99
  "figure.label.channel-path": "moves",
97
100
  "figure.label.channel-pivot": "pivot",
98
101
  "figure.label.channel-target": "part",
102
+ "figure.label.channel-target-joints": "joints",
103
+ "figure.label.channel-target-parts": "parts",
99
104
  "figure.label.channels": "channels",
100
105
  "figure.label.clip-name": "clip name",
101
106
  "figure.label.clips": "animation (plays over time)",
@@ -137,6 +142,21 @@
137
142
  "figure.label.intensity": "intensity",
138
143
  "figure.label.interpolation-linear": "smooth",
139
144
  "figure.label.interpolation-step": "stepped",
145
+ "figure.label.joint": "joint",
146
+ "figure.label.joint-axis": "axis",
147
+ "figure.label.joint-limit-max": "max",
148
+ "figure.label.joint-limit-min": "min",
149
+ "figure.label.joint-limits-deg": "limits (degrees)",
150
+ "figure.label.joint-limits-mm": "limits (mm)",
151
+ "figure.label.joint-origin": "origin",
152
+ "figure.label.joint-parent": "attached to",
153
+ "figure.label.joint-parent-none": "none (figure frame)",
154
+ "figure.label.joint-type": "movement",
155
+ "figure.label.joint-type-continuous": "turn without limits",
156
+ "figure.label.joint-type-none": "fixed",
157
+ "figure.label.joint-type-prismatic": "slide",
158
+ "figure.label.joint-type-revolute": "turn within limits",
159
+ "figure.label.joint-value": "joint value",
140
160
  "figure.label.keep-round": "section",
141
161
  "figure.label.key-at": "at (s)",
142
162
  "figure.label.key-at-value": "at (0–1)",
@@ -245,6 +265,8 @@
245
265
  "figure.label.triangles": "triangles",
246
266
  "figure.label.triangles-at-100": "triangles for 100",
247
267
  "figure.label.triangles-at-10k": "triangles at 10k instances",
268
+ "figure.label.unit-none": "no unit",
269
+ "figure.label.unit-type-it": "type it…",
248
270
  "figure.label.weight": "drawing cost",
249
271
  "figure.label.what-this-does": "what this figure does",
250
272
  "figure.text.accepts-anything": "empty means anything",
@@ -261,8 +283,10 @@
261
283
  "figure.text.animation-needs-a-named-part": "add a part first.",
262
284
  "figure.text.assistant-proposed-a-figure": "the proposal is shown on the right.",
263
285
  "figure.text.axes-not-measurable-yet": "left off the chart because they cannot be measured: {axes}. the reasons are below.",
264
- "figure.text.bound-by-drawCalls": "draw calls reach the limit first ({per} per instance, {budget} per drawing).",
286
+ "figure.text.base-box-explained": "The box that sets the scale when the figure is placed on a drawing. A part reaching outside it blocks release. Changing the size leaves the parts where they are.",
287
+ "figure.text.bound-by-groups": "Part groups fill up first — this figure is {per} groups, and one screen draws {budget} groups at 60fps.",
265
288
  "figure.text.bound-by-triangles": "triangles reach the limit first ({per} per instance, {budget} per drawing).",
289
+ "figure.text.budget-measured-on": "Measured on {gpu}, {on}. A figure standing still costs nothing — only what is on screen and redrawn counts.",
266
290
  "figure.text.budget-not-measured-yet": "this target has not been measured yet.",
267
291
  "figure.text.cannot-release-now": "cannot be released yet",
268
292
  "figure.text.canvas-appears-once-a-part-exists": "pick a shape on the left to add the first part.",
@@ -279,6 +303,7 @@
279
303
  "figure.text.change-capabilities": "capabilities",
280
304
  "figure.text.change-changed": "a changed part",
281
305
  "figure.text.change-detail": "the detail level",
306
+ "figure.text.change-joints": "joints",
282
307
  "figure.text.change-placement": "placement",
283
308
  "figure.text.change-removed": "a part being removed",
284
309
  "figure.text.change-styleKit": "the style kit",
@@ -290,6 +315,7 @@
290
315
  "figure.text.clip-needs-a-name": "enter a name.",
291
316
  "figure.text.collapse-parts-pane": "collapse the part list",
292
317
  "figure.text.collapse-properties-pane": "collapse the property pane",
318
+ "figure.text.contract-not-compared": "Could not check whether this change breaks what is already placed — {why}",
293
319
  "figure.text.coordinate-ground-explained": "draws the origin and a grid on the floor",
294
320
  "figure.text.delete-figure-confirm": "delete {name}? drawings already using this type will no longer find it.",
295
321
  "figure.text.designate-in-properties": "nothing designated yet. select a part and set its role in the properties tab.",
@@ -306,6 +332,7 @@
306
332
  "figure.text.figure-type-example": "e.g. OHT, AGV_LIFTER",
307
333
  "figure.text.figure-type-placeholder": "Figure type identifier (e.g. OHT)",
308
334
  "figure.text.find-by-this-tag": "find by this tag",
335
+ "figure.text.fit-base-explained": "Shrinks the box to the smallest one holding every part. The centre stays put, so a part sitting off-centre leaves room on the other side.",
309
336
  "figure.text.fits-n-instances": "up to {n}",
310
337
  "figure.text.floor-explained": "overlaps the coordinate ground, so it is usually off",
311
338
  "figure.text.follow-camera-explained": "turn off to fix the shadow direction",
@@ -316,9 +343,16 @@
316
343
  "figure.text.hemisphere-light-explained": "even light from sky and ground. it brightens shaded faces.",
317
344
  "figure.text.hide-all-parts": "hide all",
318
345
  "figure.text.hide-this-part": "hide this part while you work. not saved",
319
- "figure.text.how-many-fit": "how many fit on one drawing without slowing it down",
346
+ "figure.text.how-many-fit": "how many can be on screen before it slows down",
320
347
  "figure.text.instances-unit": "instances",
321
348
  "figure.text.interpolation-explained": "how the gap between keys is filled.",
349
+ "figure.text.joint-channel-value-deg": "key values are joint angles in degrees.",
350
+ "figure.text.joint-channel-value-mm": "key values are joint travel in mm.",
351
+ "figure.text.joint-driven-by": "parameter \"{name}\" moves this joint. check it in the preview.",
352
+ "figure.text.joint-explained": "when the part it is attached to moves, this part moves with it. a joint sets the axis this part turns about or slides along relative to that part, and its limits. a parameter created with the joint sets its value.",
353
+ "figure.text.joint-limits-must-include-zero": "the limits must include 0. the pose as drawn is 0.",
354
+ "figure.text.joint-not-driven": "no parameter moves this joint. add one in the animation tab.",
355
+ "figure.text.joint-value-beyond-limits": "outside the joint limits.",
322
356
  "figure.text.keep-round-cost": "1 draw call",
323
357
  "figure.text.keep-round-merged": "merged into a group",
324
358
  "figure.text.keep-round-off": "off",
@@ -368,6 +402,7 @@
368
402
  "figure.text.picked-changes-do-not-validate": "the selected changes do not make a valid figure",
369
403
  "figure.text.pivot-defaults-to-the-part-centre": "left unset, the part turns about its own centre. coordinates are local to the part.",
370
404
  "figure.text.placement-explained": "sets the anchor plane in 3D scene (floor: AGV/conveyor, ceiling: OHT/hoist, center: sensor/drone).",
405
+ "figure.text.pose-as-default-explained": "Writes the pose you have set as the parameters’ defaults, so the figure stands like this when nobody gives it a value.",
371
406
  "figure.text.preview-blocked-by-errors": "cannot show the preview because of format errors. see the maturity tab for the reason.",
372
407
  "figure.text.preview-needs-a-part": "add a part to see how it looks placed on a drawing.",
373
408
  "figure.text.proposal-feedback-note": "optional note for the next suggestion (this session only)",