@things-factory/figure-ui 10.1.47 → 10.1.48

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 (44) hide show
  1. package/client/modeller/figure-v3-parts.ts +1 -1
  2. package/client/modeller/v3-canvas.ts +37 -3
  3. package/client/modeller/v3-editor.ts +39 -4
  4. package/client/modeller/v3-fields.ts +22 -0
  5. package/client/modeller/v3-joint-fan.ts +292 -0
  6. package/client/modeller/v3-links.ts +8 -0
  7. package/client/modeller/v3-thumbnail.ts +8 -4
  8. package/dist-client/modeller/figure-v3-capability.d.ts +1 -1
  9. package/dist-client/modeller/figure-v3-clips.d.ts +1 -1
  10. package/dist-client/modeller/figure-v3-controls.d.ts +1 -1
  11. package/dist-client/modeller/figure-v3-links.d.ts +1 -1
  12. package/dist-client/modeller/figure-v3-material.d.ts +1 -1
  13. package/dist-client/modeller/figure-v3-parts.js +1 -1
  14. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  15. package/dist-client/modeller/figure-v3-properties.d.ts +1 -1
  16. package/dist-client/modeller/v3-canvas.d.ts +15 -0
  17. package/dist-client/modeller/v3-canvas.js +45 -3
  18. package/dist-client/modeller/v3-canvas.js.map +1 -1
  19. package/dist-client/modeller/v3-editor.d.ts +15 -0
  20. package/dist-client/modeller/v3-editor.js +42 -4
  21. package/dist-client/modeller/v3-editor.js.map +1 -1
  22. package/dist-client/modeller/v3-fields.d.ts +9 -0
  23. package/dist-client/modeller/v3-fields.js +23 -0
  24. package/dist-client/modeller/v3-fields.js.map +1 -1
  25. package/dist-client/modeller/v3-joint-fan.d.ts +9 -0
  26. package/dist-client/modeller/v3-joint-fan.js +264 -0
  27. package/dist-client/modeller/v3-joint-fan.js.map +1 -0
  28. package/dist-client/modeller/v3-links.d.ts +5 -0
  29. package/dist-client/modeller/v3-links.js +7 -0
  30. package/dist-client/modeller/v3-links.js.map +1 -1
  31. package/dist-client/modeller/v3-thumbnail.js +6 -3
  32. package/dist-client/modeller/v3-thumbnail.js.map +1 -1
  33. package/dist-client/tsconfig.tsbuildinfo +1 -1
  34. package/package.json +5 -5
  35. package/test/v3-fields.test.ts +10 -1
  36. package/test/v3-joint-fan.test.ts +210 -0
  37. package/test/v3-links.test.ts +13 -1
  38. package/test/v3-palette-icons.test.ts +22 -0
  39. package/test/v3-thumbnail.test.ts +29 -0
  40. package/translations/en.json +6 -1
  41. package/translations/ja.json +6 -1
  42. package/translations/ko.json +6 -1
  43. package/translations/ms.json +6 -1
  44. package/translations/zh.json +6 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.47",
3
+ "version": "10.1.48",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -26,8 +26,8 @@
26
26
  "clean": "npm run clean:server && npm run clean:client"
27
27
  },
28
28
  "dependencies": {
29
- "@hatiolab/figure-model": "^0.1.53",
30
- "@hatiolab/things-scene": "^10.1.30",
29
+ "@hatiolab/figure-model": "^0.1.54",
30
+ "@hatiolab/things-scene": "^10.1.32",
31
31
  "@material/web": "^2.0.0",
32
32
  "@operato/data-grist": "^10.0.0",
33
33
  "@operato/graphql": "^10.0.0",
@@ -37,8 +37,8 @@
37
37
  "@operato/shell": "^10.0.0",
38
38
  "@operato/styles": "^10.0.0",
39
39
  "@operato/utils": "^10.0.0",
40
- "@things-factory/figure-service": "^10.1.47",
40
+ "@things-factory/figure-service": "^10.1.48",
41
41
  "three": "^0.185.1"
42
42
  },
43
- "gitHead": "185f20d7d9a15715a0ee2757a6a8189ec36cfb9d"
43
+ "gitHead": "448b0d568a1816d25aa49f3b6233d8086d6011f9"
44
44
  }
@@ -7,7 +7,7 @@ import assert from 'node:assert/strict'
7
7
  import { readFileSync } from 'node:fs'
8
8
  import { fileURLToPath } from 'node:url'
9
9
 
10
- import { groupFields, partLabelOf } from '../client/modeller/v3-fields.ts'
10
+ import { groupFields, partLabelOf, repeatOf } from '../client/modeller/v3-fields.ts'
11
11
 
12
12
  const f = (id: string, value = 1, unit = 'mm') => ({ id, unit, value, role: 'design' })
13
13
 
@@ -60,3 +60,12 @@ test('되풀이한 부품은 되풀이의 이름과 몇 번째인지로 읽는
60
60
  assert.ok(parts.includes('>${partLabelOf(part.id)}'), '목록이 이 이름으로 그린다')
61
61
  })
62
62
 
63
+ test('되풀이한 부품의 속성 이름 칸도 사람의 이름이고, 몇 개 중 몇 번째인지 말한다 — 날 id 를 보이지 않는다', () => {
64
+ const geometry = Array.from({ length: 16 }, (_, i) => ({ id: JSON.stringify(['roller.repeat', i, 'roller.value']) })).concat([{ id: 'deck' }])
65
+ assert.deepEqual(repeatOf('["roller.repeat",14,"roller.value"]', geometry), { name: 'roller', count: 16, index: 15 })
66
+ assert.equal(repeatOf('deck', geometry), null)
67
+ const editor = readFileSync(fileURLToPath(new URL('../client/modeller/v3-editor.ts', import.meta.url)), 'utf8')
68
+ assert.ok(editor.includes('.value=${partLabelOf(this.selected)}'), '사용자 2026-09-24: 「부품 이름 ???」')
69
+ assert.ok(editor.includes('?disabled=${unavailable || !!repeatOf(this.selected, view.geometry)}'), '복사본에는 바꿀 제 이름이 없다')
70
+ })
71
+
@@ -0,0 +1,210 @@
1
+ /*
2
+ * **고른 부품이 어디서 얼마나 도는지** (사용자 2026-09-24: 「로봇암은 편집기에서 원래는 부품을 선택했을 때, 가변 각도가
3
+ * 표현이 되었었는데」). V2 는 관절의 가동 범위를 부채꼴로 저작면에 그렸다. 부채꼴의 두 끝이 제어값의 최솟값과 최댓값에서
4
+ * 부품이 실제로 가는 쪽을 가리키는지를 — 평가해서 — 본다.
5
+ */
6
+ import { test } from 'node:test'
7
+ import assert from 'node:assert/strict'
8
+ import { readFileSync } from 'node:fs'
9
+ import { fileURLToPath } from 'node:url'
10
+
11
+ import { addV3Box, applyV3Authoring, compileV3Asset, convertV2ToV3, createV3Asset } from '@hatiolab/figure-model'
12
+ import type { V3Asset } from '@hatiolab/figure-model'
13
+
14
+ import { JOINT_FAN, jointFanOf } from '../client/modeller/v3-joint-fan.ts'
15
+
16
+ const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
17
+
18
+ function arm(): V3Asset {
19
+ let asset = createV3Asset('Probe')
20
+ asset = addV3Box(asset, { id: 'base', size: { x: 200, y: 100, z: 200 }, position: { x: 0, y: 50, z: 0 } })
21
+ asset = addV3Box(asset, { id: 'arm', size: { x: 400, y: 50, z: 50 }, position: { x: 200, y: 125, z: 0 } })
22
+ return applyV3Authoring(asset, { kind: 'add-motion', part: 'arm', motion: { kind: 'turn', axis: 'y' }, state: { id: 'arm.turn', unit: 'deg', min: -45, max: 90 } })
23
+ }
24
+
25
+ /** The fan's profile point in the figure's frame. The polygon lies in its local X–Z: profile (x, y) is local (x, 0, y). */
26
+ const world = (fan: { pose: { t: number[]; r: number[][] } }, p: { x: number; y: number }) =>
27
+ [0, 1, 2].map(i => fan.pose.t[i]! + fan.pose.r[i]![0]! * p.x + fan.pose.r[i]![2]! * p.y)
28
+
29
+ const direction = (v: number[]) => {
30
+ const l = Math.hypot(v[0]!, v[2]!)
31
+ return [v[0]! / l, v[2]! / l]
32
+ }
33
+
34
+ test('a part that turns gets a fan from the lowest value to the highest, where the part really goes', () => {
35
+ const asset = arm()
36
+ const fan = jointFanOf(asset, 'arm') as unknown as { id: string; shape: { provider: string; profile: { x: number; y: number }[] }; pose: { t: number[]; r: number[][] } }
37
+ assert.ok(fan)
38
+ assert.equal(fan.id, JOINT_FAN)
39
+ assert.equal(fan.shape.provider, 'polygon')
40
+ const profile = fan.shape.profile
41
+ assert.deepEqual(profile[0], { x: 0, y: 0 }, 'not a full turn: the fan starts at the pivot')
42
+
43
+ const armAt = (deg: number) => {
44
+ const body = compileV3Asset({ ...asset, stateDefaults: { ...asset.stateDefaults, 'arm.turn': deg } }).evaluate().geometry.find(g => g.id === 'arm')!
45
+ return direction((body as unknown as { pose: { t: number[] } }).pose.t)
46
+ }
47
+ const first = direction(world(fan, profile[1]!))
48
+ const last = direction(world(fan, profile[profile.length - 1]!))
49
+ for (const [got, want] of [
50
+ [first, armAt(-45)],
51
+ [last, armAt(90)]
52
+ ] as const) {
53
+ assert.ok(Math.abs(got[0]! - want[0]!) < 1e-6 && Math.abs(got[1]! - want[1]!) < 1e-6, `${got} ≈ ${want}`)
54
+ }
55
+ })
56
+
57
+ test('a fan is as large as what turns, and never smaller than 60 mm', () => {
58
+ const big = jointFanOf(arm(), 'arm') as unknown as { shape: { profile: { x: number; y: number }[] } }
59
+ const radius = Math.max(...big.shape.profile.map(p => Math.hypot(p.x, p.y)))
60
+ assert.ok(Math.abs(radius - 240) < 1e-3, `0.6 × 400 — ${radius}`)
61
+ })
62
+
63
+ test('no fan for a part that does not turn, nor for a slide -- a fan in a guessed plane would point the wrong way', () => {
64
+ assert.equal(jointFanOf(arm(), 'base'), null)
65
+ assert.equal(jointFanOf(arm(), ''), null)
66
+ let slider = createV3Asset('Probe')
67
+ slider = addV3Box(slider, { id: 'block', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
68
+ slider = applyV3Authoring(slider, { kind: 'add-motion', part: 'block', motion: { kind: 'slide', axis: 'x' }, state: { id: 'shift', unit: 'mm', min: 0, max: 60 } })
69
+ assert.equal(jointFanOf(slider, 'block'), null)
70
+ })
71
+
72
+ test('the editor draws it for the selected part, with the pose in hand; the canvas never lets it be picked', () => {
73
+ const editor = read('../client/modeller/v3-editor.ts')
74
+ assert.ok(editor.includes('.fan=${this.fanOf(asset as V3Asset | undefined)}'))
75
+ assert.ok(editor.includes('return jointFanOf(asset, this.selected, this.live.values)'))
76
+ const canvas = read('../client/modeller/v3-canvas.ts')
77
+ assert.ok(canvas.includes('{ ...fan, capturable: false, locked: true }'))
78
+ })
79
+
80
+ /* ── Joints the converter wrote (user 2026-09-24: 「로봇암 axis와 관절의 각도 표현을 다시 검증해보라」) ───────── */
81
+
82
+ /** A V2 column: a link on a hinge at the root, a tip on a wrist inside it -- a first joint and a nested one. */
83
+ function column(): V3Asset {
84
+ const source = {
85
+ version: 2,
86
+ type: 'COLUMN',
87
+ base: { x: 200, y: 700, z: 200 },
88
+ parts: [
89
+ { name: 'base', primitive: 'cube', transform: { position: { x: 0, y: 50, z: 0 }, size: { x: 200, y: 100, z: 200 } }, material: { token: 'palette.dark' } },
90
+ { name: 'link', primitive: 'cube', transform: { position: { x: 0, y: 300, z: 0 }, size: { x: 80, y: 400, z: 80 } }, material: { token: 'palette.primary' }, parent: 'base' },
91
+ { name: 'tip', primitive: 'cube', transform: { position: { x: 0, y: 600, z: 0 }, size: { x: 60, y: 200, z: 60 } }, material: { token: 'palette.primary' }, parent: 'link' },
92
+ /* Rides the hinge, centred on the wrist's axis -- the wrist's housing, as ROBOT_ARM's elbow-housing is the elbow's. */
93
+ { name: 'housing', primitive: 'cube', transform: { position: { x: 0, y: 500, z: 0 }, size: { x: 100, y: 100, z: 100 } }, material: { token: 'palette.dark' }, parent: 'link' },
94
+ /* Rides the hinge and holds no pivot. */
95
+ { name: 'badge', primitive: 'cube', transform: { position: { x: 60, y: 300, z: 0 }, size: { x: 10, y: 40, z: 40 } }, material: { token: 'palette.dark' }, parent: 'link' }
96
+ ],
97
+ joints: [
98
+ { name: 'hinge', child: 'link', type: 'revolute', origin: { x: 0, y: 100, z: 0 }, axis: { x: 1, y: 0, z: 0 }, limits: { min: -90, max: 60 } },
99
+ { name: 'wrist', child: 'tip', type: 'revolute', origin: { x: 0, y: 500, z: 0 }, axis: { x: 0, y: 0, z: 1 }, limits: { min: -120, max: 120 } }
100
+ ]
101
+ }
102
+ const result = convertV2ToV3(source, { palette: { 'palette.dark': '#333333', 'palette.primary': '#3366cc' } } as never)
103
+ assert.equal(result.status, 'converted', JSON.stringify((result as { refusals?: unknown }).refusals))
104
+ return (result as unknown as { asset: V3Asset }).asset
105
+ }
106
+
107
+ const sub = (a: number[], b: number[]) => a.map((v, i) => v - b[i]!)
108
+ const dot = (a: number[], b: number[]) => a.reduce((s, v, i) => s + v * b[i]!, 0)
109
+ const angle = (a: number[], b: number[]) => (Math.acos(Math.max(-1, Math.min(1, dot(a, b) / (Math.hypot(...a) * Math.hypot(...b))))) * 180) / Math.PI
110
+ const turnOf = (r1: number[][], r2: number[][]) => {
111
+ let trace = 0
112
+ for (let i = 0; i < 3; i++) for (let k = 0; k < 3; k++) trace += r1[k]![i]! * r2[k]![i]!
113
+ return (Math.acos(Math.max(-1, Math.min(1, (trace - 1) / 2))) * 180) / Math.PI
114
+ }
115
+
116
+ /*
117
+ Checked against the motion, not against the fan's own sampling: where the part's centre really is at the control's
118
+ ends, and how far its orientation really turns, summed in small steps.
119
+ */
120
+ for (const [part, joint] of [
121
+ ['link', 'hinge'],
122
+ ['tip', 'wrist']
123
+ ] as const) {
124
+ test(`${part} on ${joint}: the axis holds the part's plane, the fan's ends are where it goes, the fan opens as far as it turns`, () => {
125
+ const asset = column()
126
+ const fan = jointFanOf(asset, part) as unknown as { pose: { t: number[]; r: number[][] }; shape: { profile: { x: number; y: number }[] } }
127
+ assert.ok(fan, 'a fan -- the first joint of a chain included')
128
+ const input = asset.document.model.inputs.find(i => i.id === joint)!
129
+ const pivot = fan.pose.t
130
+ const axis = [0, 1, 2].map(i => fan.pose.r[i]![1]!)
131
+ const at = (s: number) =>
132
+ compileV3Asset({ ...asset, stateDefaults: { ...asset.stateDefaults, [joint]: s } }).evaluate().geometry.find(g => g.id === part) as unknown as { pose: { t: number[]; r: number[][] } }
133
+ const onFan = (p: { x: number; y: number }) => [0, 1, 2].map(i => pivot[i]! + fan.pose.r[i]![0]! * p.x + fan.pose.r[i]![2]! * p.y)
134
+ const flat = (v: number[]) => sub(v, axis.map(a => a * dot(v, axis)))
135
+
136
+ const axial = [input.min!, 0, input.max!].map(s => dot(sub(at(s).pose.t, pivot), axis))
137
+ assert.ok(Math.max(...axial) - Math.min(...axial) < 1e-6, `the part stays in the fan's plane: ${axial}`)
138
+
139
+ const edges = fan.shape.profile.filter(p => p.x !== 0 || p.y !== 0)
140
+ assert.ok(angle(flat(sub(onFan(edges[0]!), pivot)), flat(sub(at(input.min!).pose.t, pivot))) < 0.01, 'the first edge is where the part is at the lowest value (the profile is kept to a micrometre)')
141
+ assert.ok(angle(flat(sub(onFan(edges[edges.length - 1]!), pivot)), flat(sub(at(input.max!).pose.t, pivot))) < 0.01, 'the last edge, at the highest')
142
+
143
+ let opens = 0,
144
+ turns = 0
145
+ for (let i = 1; i < edges.length; i++) opens += angle(sub(onFan(edges[i - 1]!), pivot), sub(onFan(edges[i]!), pivot))
146
+ for (let i = 1; i <= 60; i++) {
147
+ const s = (k: number) => input.min! + ((input.max! - input.min!) * k) / 60
148
+ turns += turnOf(at(s(i - 1)).pose.r, at(s(i)).pose.r)
149
+ }
150
+ assert.ok(Math.abs(opens - turns) < 0.01 && Math.abs(turns - (input.max! - input.min!)) < 0.01, `opens ${opens}, turns ${turns}`)
151
+ })
152
+ }
153
+
154
+ test('a housing shows the joint it houses, not the one that carries it; a part that holds no pivot shows none', () => {
155
+ const asset = column()
156
+ const turns = (part: string) => (jointFanOf(asset, part) as unknown as { turns?: string } | null)?.turns ?? null
157
+ assert.equal(turns('housing'), 'wrist', 'user 2026-09-24: 「elbow-housing itself is a axis」 -- it rides the hinge, it houses the wrist')
158
+ assert.equal(turns('link'), 'hinge', 'a link with pivots only at its ends shows the joint that turns it')
159
+ assert.equal(turns('tip'), 'wrist')
160
+ assert.equal(turns('badge'), null)
161
+ assert.equal(turns('base'), null)
162
+
163
+ /* The housing's fan is the wrist's: its ends are where the tip goes. */
164
+ const fan = jointFanOf(asset, 'housing') as unknown as { pose: { t: number[]; r: number[][] }; shape: { profile: { x: number; y: number }[] } }
165
+ const axis = [0, 1, 2].map(i => fan.pose.r[i]![1]!)
166
+ const flat = (v: number[]) => sub(v, axis.map(a => a * dot(v, axis)))
167
+ const onFan = (p: { x: number; y: number }) => [0, 1, 2].map(i => fan.pose.t[i]! + fan.pose.r[i]![0]! * p.x + fan.pose.r[i]![2]! * p.y)
168
+ const tipAt = (s: number) => (compileV3Asset({ ...asset, stateDefaults: { ...asset.stateDefaults, wrist: s } }).evaluate().geometry.find(g => g.id === 'tip') as unknown as { pose: { t: number[] } }).pose.t
169
+ const edges = fan.shape.profile.filter(p => p.x !== 0 || p.y !== 0)
170
+ assert.ok(angle(flat(sub(onFan(edges[0]!), fan.pose.t)), flat(sub(tipAt(-120), fan.pose.t))) < 0.01)
171
+ assert.ok(angle(flat(sub(onFan(edges[edges.length - 1]!), fan.pose.t)), flat(sub(tipAt(120), fan.pose.t))) < 0.01)
172
+ })
173
+
174
+ test('one joint, one fan: the housing and the part it turns show the same fan, sized from what turns', () => {
175
+ const asset = column()
176
+ const size = (part: string) => {
177
+ const fan = jointFanOf(asset, part) as unknown as { shape: { profile: { x: number; y: number }[] }; pose: { t: number[] } }
178
+ return { radius: Math.max(...fan.shape.profile.map(p => Math.hypot(p.x, p.y))), pivot: fan.pose.t }
179
+ }
180
+ const housing = size('housing'),
181
+ tip = size('tip')
182
+ assert.ok(Math.abs(housing.radius - tip.radius) < 1e-6, `user 2026-09-24: the elbow housing's fan was smaller than the forearm's -- ${housing.radius} vs ${tip.radius}`)
183
+ assert.deepEqual(housing.pivot, tip.pivot)
184
+ })
185
+
186
+ test('an axis running through a part does not make it that joint’s: a roll along a link shows on the roller, not the link', () => {
187
+ /* ROBOT_ARM's forearm showed the wrist roll, whose axis runs its length (2026-09-24 audit). */
188
+ const source = {
189
+ version: 2,
190
+ type: 'ROLLER',
191
+ base: { x: 200, y: 800, z: 200 },
192
+ parts: [
193
+ { name: 'base', primitive: 'cube', transform: { position: { x: 0, y: 50, z: 0 }, size: { x: 200, y: 100, z: 200 } }, material: { token: 'palette.dark' } },
194
+ { name: 'link', primitive: 'cube', transform: { position: { x: 0, y: 300, z: 0 }, size: { x: 80, y: 400, z: 80 } }, material: { token: 'palette.primary' }, parent: 'base' },
195
+ { name: 'roller', primitive: 'cube', transform: { position: { x: 30, y: 600, z: 0 }, size: { x: 120, y: 200, z: 60 } }, material: { token: 'palette.primary' }, parent: 'link' }
196
+ ],
197
+ joints: [
198
+ { name: 'hinge', child: 'link', type: 'revolute', origin: { x: 0, y: 100, z: 0 }, axis: { x: 1, y: 0, z: 0 }, limits: { min: -90, max: 60 } },
199
+ { name: 'twist', child: 'roller', type: 'revolute', origin: { x: 0, y: 500, z: 0 }, axis: { x: 0, y: 1, z: 0 }, limits: { min: -170, max: 170 } }
200
+ ]
201
+ }
202
+ const result = convertV2ToV3(source, { palette: { 'palette.dark': '#333333', 'palette.primary': '#3366cc' } } as never)
203
+ assert.equal(result.status, 'converted')
204
+ const asset = (result as unknown as { asset: V3Asset }).asset
205
+ const turns = (part: string) => (jointFanOf(asset, part) as unknown as { turns?: string } | null)?.turns ?? null
206
+ assert.equal(turns('link'), 'hinge', 'the twist axis runs through the link, and the link does not twist')
207
+ assert.equal(turns('roller'), 'twist')
208
+ assert.equal(turns('base'), null)
209
+ })
210
+
@@ -10,7 +10,7 @@ import assert from 'node:assert/strict'
10
10
  import { addV3Box, addV3Primitive, applyV3Authoring, createV3Asset } from '@hatiolab/figure-model'
11
11
  import type { V3Asset } from '@hatiolab/figure-model'
12
12
 
13
- import { acceptsOf, canFasten, canFollow, dimensionNow, instanceRangeOf, rangeDiffers, fastenedTo, followableAxes, followOf, hasInstanceSize, positionIsShare, holdsThings, motionOf, placeFacesOf, placeIdFor, placesOn, positionsWorkedOut } from '../client/modeller/v3-links.ts'
13
+ import { acceptsOf, canFasten, canFollow, dimensionNow, instanceRangeOf, rangeDiffers, turnsAboutAJoint, fastenedTo, followableAxes, followOf, hasInstanceSize, positionIsShare, holdsThings, motionOf, placeFacesOf, placeIdFor, placesOn, positionsWorkedOut } from '../client/modeller/v3-links.ts'
14
14
 
15
15
  const pair = (): V3Asset => {
16
16
  let asset = createV3Asset('Pair')
@@ -232,3 +232,15 @@ test('놓일 수 있는 크기 — 남은 만큼 채우던 부품을 비례로
232
232
  assert.ok(editor.includes("this.linkCommand({ kind: 'declare-instance-size', range: range.measured, replace: true })"))
233
233
  })
234
234
 
235
+ test('관절이 있는 도형에는 한 비율로 키우기를 권하고, 선언하면 규칙으로 보인다(수석 아키텍트 판정 09-24)', () => {
236
+ let asset = sizedPair()
237
+ assert.equal(turnsAboutAJoint(asset), false)
238
+ asset = applyV3Authoring(asset, { kind: 'add-motion', part: 'flange', motion: { kind: 'turn', axis: 'y' }, state: { id: 'flange.turn', unit: 'deg', min: 0, max: 90 } })
239
+ assert.equal(turnsAboutAJoint(asset), true)
240
+ const editor = read('../client/modeller/v3-editor.ts')
241
+ const rule = editor.slice(editor.indexOf(' private renderSizingRule('), editor.indexOf(' /** Choose a part,'))
242
+ assert.ok(rule.includes("asset.sizing === 'uniform'") && rule.includes("'figure.label.sizing-uniform'"), '선언하면 규칙을 말한다')
243
+ assert.ok(rule.includes("turnsAboutAJoint(asset) ? html`<p hint>${i18next.t('figure.text.sizing-uniform-proposed')}"), '관절이 있으면 권한다')
244
+ assert.ok(rule.includes("this.linkCommand({ kind: 'scale-uniformly' })"), '누르면 선언한다 — 사람이 정한다')
245
+ })
246
+
@@ -0,0 +1,22 @@
1
+ /*
2
+ * **팔레트 아이콘은 글꼴에 있는 이름만** (사용자 2026-09-24: 「is this icon proper?」 — 벽 칸에 「AL」이 떴다).
3
+ *
4
+ * `md-icon` 은 이름을 합자로 그린다. 글꼴에 없는 이름은 글자 그대로 나오고, 칸에 잘려 「wall」이 「AL」로 보였다.
5
+ * node 에서는 글꼴을 볼 수 없어, :3300 의 글꼴에서 한 칸 폭(24px)으로 그려지는 것을 잰 이름만 여기에 둔다. 새 아이콘은
6
+ * 화면에서 재어 보고 이 목록에 더한다.
7
+ */
8
+ import { test } from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+ import { readFileSync } from 'node:fs'
11
+ import { fileURLToPath } from 'node:url'
12
+
13
+ /** Measured in the app's icon font on :3300, 2026-09-24: each draws one cell wide. `wall` drew 52px -- its letters. */
14
+ const RENDERS = new Set(['deployed_code', 'database', 'crop_square', 'circle', 'change_history', 'hexagon', 'check_box_outline_blank', 'crop_portrait', 'door_front'])
15
+
16
+ test('every shape in the palette has an icon the font draws', () => {
17
+ const parts = readFileSync(fileURLToPath(new URL('../client/modeller/figure-v3-parts.ts', import.meta.url)), 'utf8')
18
+ const recipes = parts.slice(parts.indexOf('const RECIPES'), parts.indexOf(']', parts.indexOf('const RECIPES')))
19
+ const icons = [...recipes.matchAll(/icon: '([a-z_]+)'/g)].map(m => m[1]!)
20
+ assert.ok(icons.length >= 9)
21
+ assert.deepEqual(icons.filter(icon => !RENDERS.has(icon)), [])
22
+ })
@@ -0,0 +1,29 @@
1
+ /*
2
+ * **저장하면 카드 그림이 남는다** (사용자 2026-09-24: 「why you don't save thumbnail when I save the figure?」).
3
+ *
4
+ * 캔버스가 씬 위로 옮겨 갈 때 찍는 길(`snapshot`)이 빠졌고, 찍는 쪽은 없는 메서드를 `?.()` 로 불러 아무 말 없이
5
+ * 그림 없이 저장했다. 브라우저에서만 찍히므로 여기서는 길이 이어져 있는지를 본다: 찍는 쪽이 보기 전용 캔버스를 세워
6
+ * `snapshot` 을 부르고, 캔버스는 씬의 3D 그림(`snapshot3d`)을 내며, 보기 전용이면 편집기에 아무것도 알리지 않는다.
7
+ */
8
+ import { test } from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+ import { readFileSync } from 'node:fs'
11
+ import { fileURLToPath } from 'node:url'
12
+
13
+ const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
14
+
15
+ test('the save takes the card picture from a view-only canvas, through the scene’s 3D snapshot', () => {
16
+ const shooter = read('../client/modeller/v3-thumbnail.ts')
17
+ assert.ok(shooter.includes('shooter.viewOnly = true'))
18
+ assert.ok(shooter.includes('return shooter.snapshot ? await shooter.snapshot(WIDTH, HEIGHT) : undefined'))
19
+ assert.ok(!shooter.includes('thumbnail?.()'), 'no call to a method the canvas does not have')
20
+
21
+ const canvas = read('../client/modeller/v3-canvas.ts')
22
+ assert.ok(canvas.includes('async snapshot(width = 480, height = 360)'))
23
+ assert.ok(canvas.includes("await layer.snapshot3d(width, height, 'image/webp', 0.85)"), 'the 3D view, not the 2D sheet')
24
+ const watch = canvas.slice(canvas.indexOf(' private watchScene()'), canvas.indexOf(' private showSelected()'))
25
+ assert.ok(watch.includes('|| this.viewOnly) return'), 'a view-only canvas tells the editor nothing')
26
+
27
+ const editor = read('../client/modeller/v3-editor.ts')
28
+ assert.ok(editor.includes('const thumbnail = await captureV3Thumbnail('), 'the save asks for it')
29
+ })
@@ -17,6 +17,11 @@
17
17
  "figure.button.fix-position": "fix here",
18
18
  "figure.button.scale-position": "in proportion",
19
19
  "figure.button.remeasure-range": "measure again",
20
+ "figure.label.sizing-rule": "when the size changes",
21
+ "figure.label.sizing-uniform": "one ratio (geometric mean of the three axes)",
22
+ "figure.button.scale-uniformly": "grow by one ratio",
23
+ "figure.text.sizing-uniform-proposed": "this figure has joints. growing it by one ratio keeps the joints together at any size.",
24
+ "figure.text.sizing-uniform-explained": "grows the whole figure by the geometric mean of the three sizes a board gives it. the feet stay on the floor; room left above the figure stays empty.",
20
25
  "figure.button.mirror-x": "mirror X",
21
26
  "figure.button.mirror-z": "mirror Z",
22
27
  "figure.button.move-part-down": "move down",
@@ -53,6 +58,7 @@
53
58
  "figure.text.follow-would-loop": "that dimension already follows this part, so the two cannot follow each other.",
54
59
  "figure.text.links-need-a-box": "size following works on box-shaped parts, fastening on boxes and cylinders.",
55
60
  "figure.text.scale-position-explained": "keeps this place as a share of the figure size — at twice the size it is twice as far.",
61
+ "figure.text.repeat-member": "copy {index} of {count} made by repeating {name}.",
56
62
  "figure.text.instance-range-can-widen": "measured again after the parts changed: {ranges} mm.",
57
63
  "figure.text.mounting-face-needs-space": "declare the space first to choose one.",
58
64
  "figure.text.part-is-referenced": "'{part}' cannot be deleted: another part is fastened to it or follows its size. Undo that link first.",
@@ -280,7 +286,6 @@
280
286
  "figure.label.shapes": "shapes",
281
287
  "figure.label.sizing-fixed": "keeps its size",
282
288
  "figure.label.sizing-repeat": "repeats",
283
- "figure.label.sizing-rule": "when the size changes",
284
289
  "figure.label.sizing-scale": "scales proportionally",
285
290
  "figure.label.sizing-stretch": "stretches on one axis",
286
291
  "figure.label.sky-color": "sky color",
@@ -17,6 +17,11 @@
17
17
  "figure.button.fix-position": "ここに固定",
18
18
  "figure.button.scale-position": "サイズに比例",
19
19
  "figure.button.remeasure-range": "測り直す",
20
+ "figure.label.sizing-rule": "when the size changes",
21
+ "figure.label.sizing-uniform": "一つの比率 (3 軸の幾何平均)",
22
+ "figure.button.scale-uniformly": "一つの比率で拡大",
23
+ "figure.text.sizing-uniform-proposed": "関節のある図形です。一つの比率で拡大すると、どのサイズでも関節が離れません。",
24
+ "figure.text.sizing-uniform-explained": "ボードが与えた 3 軸サイズの幾何平均の比率で図形全体を拡大します。足は床に置き、箱の上の余りは空けたままにします。",
20
25
  "figure.button.mirror-x": "mirror X",
21
26
  "figure.button.mirror-z": "mirror Z",
22
27
  "figure.button.move-part-down": "move down",
@@ -51,6 +56,7 @@
51
56
  "figure.text.follow-would-loop": "その寸法はすでにこの部品に追従しているため、互いに追従させられません。",
52
57
  "figure.text.links-need-a-box": "サイズ追従は箱形、取り付けは箱形と円柱の部品で使えます。",
53
58
  "figure.text.scale-position-explained": "今の位置を図形サイズの割合として保ちます。2 倍のサイズなら 2 倍の距離に置かれます。",
59
+ "figure.text.repeat-member": "{name} を {count} 個繰り返したうちの {index} 番目です。",
54
60
  "figure.text.instance-range-can-widen": "部品が変わったので測り直すと {ranges} mm です。",
55
61
  "figure.text.mounting-face-needs-space": "先に空間を宣言すると選べます。",
56
62
  "figure.text.part-is-referenced": "'{part}' には他の部品が取り付けられているか、そのサイズに従っているため削除できません。先にその連結を外してください。",
@@ -278,7 +284,6 @@
278
284
  "figure.label.shapes": "shapes",
279
285
  "figure.label.sizing-fixed": "keeps its size",
280
286
  "figure.label.sizing-repeat": "repeats",
281
- "figure.label.sizing-rule": "when the size changes",
282
287
  "figure.label.sizing-scale": "scales proportionally",
283
288
  "figure.label.sizing-stretch": "stretches on one axis",
284
289
  "figure.label.sky-color": "sky color",
@@ -17,6 +17,11 @@
17
17
  "figure.button.fix-position": "여기에 고정",
18
18
  "figure.button.scale-position": "크기에 비례",
19
19
  "figure.button.remeasure-range": "다시 재기",
20
+ "figure.label.sizing-rule": "크기 변경 시 동작",
21
+ "figure.label.sizing-uniform": "한 비율로 (세 축의 기하평균)",
22
+ "figure.button.scale-uniformly": "한 비율로 키우기",
23
+ "figure.text.sizing-uniform-proposed": "관절이 있는 도형입니다. 한 비율로 키우면 어떤 크기에서도 관절이 벌어지지 않습니다.",
24
+ "figure.text.sizing-uniform-explained": "보드가 준 세 축 크기의 기하평균 비율로 도형 전체를 키웁니다. 발은 바닥에 두고, 상자 위가 남으면 그대로 비워 둡니다.",
20
25
  "figure.button.mirror-x": "X 대칭",
21
26
  "figure.button.mirror-z": "Z 대칭",
22
27
  "figure.button.move-part-down": "아래로",
@@ -53,6 +58,7 @@
53
58
  "figure.text.follow-would-loop": "그 치수는 이미 이 부품을 따르고 있어서 서로 따르게 할 수 없습니다.",
54
59
  "figure.text.links-need-a-box": "크기 따르기는 상자 모양, 붙이기는 상자·원기둥 부품에서 씁니다.",
55
60
  "figure.text.scale-position-explained": "지금 자리를 도형 크기의 몫으로 둡니다. 두 배 크기면 두 배 거리에 놓입니다.",
61
+ "figure.text.repeat-member": "{name} 되풀이 {count}개 중 {index}번째입니다.",
56
62
  "figure.text.instance-range-can-widen": "부품이 바뀌어 다시 재면 {ranges} mm 입니다.",
57
63
  "figure.text.mounting-face-needs-space": "공간을 먼저 선언해야 고를 수 있습니다.",
58
64
  "figure.text.part-is-referenced": "'{part}' 에 다른 부품이 붙어 있거나 그 크기를 따르고 있어 지울 수 없습니다. 그 연결을 먼저 푸세요.",
@@ -280,7 +286,6 @@
280
286
  "figure.label.shapes": "도형",
281
287
  "figure.label.sizing-fixed": "크기 고정",
282
288
  "figure.label.sizing-repeat": "개수 증가",
283
- "figure.label.sizing-rule": "크기 변경 시 동작",
284
289
  "figure.label.sizing-scale": "비례 확대",
285
290
  "figure.label.sizing-stretch": "한 축만 늘임",
286
291
  "figure.label.sky-color": "배경색",
@@ -17,6 +17,11 @@
17
17
  "figure.button.fix-position": "tetapkan di sini",
18
18
  "figure.button.scale-position": "berkadar",
19
19
  "figure.button.remeasure-range": "ukur semula",
20
+ "figure.label.sizing-rule": "when the size changes",
21
+ "figure.label.sizing-uniform": "satu nisbah (min geometri tiga paksi)",
22
+ "figure.button.scale-uniformly": "besarkan dengan satu nisbah",
23
+ "figure.text.sizing-uniform-proposed": "figura ini mempunyai sendi. membesarkannya dengan satu nisbah memastikan sendi tidak terpisah pada sebarang saiz.",
24
+ "figure.text.sizing-uniform-explained": "membesarkan seluruh figura dengan min geometri tiga saiz yang diberi papan. kaki kekal di lantai; ruang di atas figura dibiarkan kosong.",
20
25
  "figure.button.mirror-x": "mirror X",
21
26
  "figure.button.mirror-z": "mirror Z",
22
27
  "figure.button.move-part-down": "move down",
@@ -51,6 +56,7 @@
51
56
  "figure.text.follow-would-loop": "dimensi itu sudah mengikut bahagian ini, jadi kedua-duanya tidak boleh saling mengikut.",
52
57
  "figure.text.links-need-a-box": "ikut saiz untuk bahagian berbentuk kotak, pasangan untuk kotak dan silinder.",
53
58
  "figure.text.scale-position-explained": "menyimpan kedudukan ini sebagai bahagian saiz figura — pada saiz dua kali, jaraknya dua kali.",
59
+ "figure.text.repeat-member": "salinan ke-{index} daripada {count} yang dibuat dengan mengulang {name}.",
54
60
  "figure.text.instance-range-can-widen": "diukur semula selepas bahagian berubah: {ranges} mm.",
55
61
  "figure.text.mounting-face-needs-space": "isytiharkan ruang dahulu untuk memilih.",
56
62
  "figure.text.part-is-referenced": "'{part}' tidak boleh dipadam: bahagian lain dipasang padanya atau mengikut saiznya. Leraikan pautan itu dahulu.",
@@ -278,7 +284,6 @@
278
284
  "figure.label.shapes": "shapes",
279
285
  "figure.label.sizing-fixed": "keeps its size",
280
286
  "figure.label.sizing-repeat": "repeats",
281
- "figure.label.sizing-rule": "when the size changes",
282
287
  "figure.label.sizing-scale": "scales proportionally",
283
288
  "figure.label.sizing-stretch": "stretches on one axis",
284
289
  "figure.label.sky-color": "sky color",
@@ -17,6 +17,11 @@
17
17
  "figure.button.fix-position": "固定在此",
18
18
  "figure.button.scale-position": "按比例",
19
19
  "figure.button.remeasure-range": "重新测量",
20
+ "figure.label.sizing-rule": "when the size changes",
21
+ "figure.label.sizing-uniform": "单一比例 (三轴几何平均)",
22
+ "figure.button.scale-uniformly": "按单一比例放大",
23
+ "figure.text.sizing-uniform-proposed": "这是带关节的图形。按单一比例放大,任何尺寸下关节都不会分开。",
24
+ "figure.text.sizing-uniform-explained": "按看板给出的三轴尺寸的几何平均比例放大整个图形。底部保持在地面,盒子上方多出的空间保持空着。",
20
25
  "figure.button.mirror-x": "mirror X",
21
26
  "figure.button.mirror-z": "mirror Z",
22
27
  "figure.button.move-part-down": "move down",
@@ -51,6 +56,7 @@
51
56
  "figure.text.follow-would-loop": "该尺寸已跟随此部件,二者不能互相跟随。",
52
57
  "figure.text.links-need-a-box": "尺寸跟随适用于盒形部件,固定适用于盒形和圆柱部件。",
53
58
  "figure.text.scale-position-explained": "把当前位置保持为图形尺寸的比例——尺寸加倍,距离也加倍。",
59
+ "figure.text.repeat-member": "重复 {name} 得到的 {count} 个中的第 {index} 个。",
54
60
  "figure.text.instance-range-can-widen": "部件已变更,重新测量为 {ranges} mm。",
55
61
  "figure.text.mounting-face-needs-space": "请先声明空间,然后才能选择。",
56
62
  "figure.text.part-is-referenced": "无法删除 '{part}':有其他部件固定在它上面或跟随它的尺寸。请先解除该连接。",
@@ -278,7 +284,6 @@
278
284
  "figure.label.shapes": "shapes",
279
285
  "figure.label.sizing-fixed": "keeps its size",
280
286
  "figure.label.sizing-repeat": "repeats",
281
- "figure.label.sizing-rule": "when the size changes",
282
287
  "figure.label.sizing-scale": "scales proportionally",
283
288
  "figure.label.sizing-stretch": "stretches on one axis",
284
289
  "figure.label.sky-color": "sky color",