@things-factory/figure-ui 10.1.5 → 10.1.7

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 (79) hide show
  1. package/client/graphql/index.ts +36 -3
  2. package/client/modeller/figure-animations.ts +712 -0
  3. package/client/modeller/figure-ask.ts +5 -5
  4. package/client/modeller/figure-canvas.ts +1 -1
  5. package/client/modeller/figure-capabilities.ts +372 -0
  6. package/client/modeller/figure-history-panel.ts +171 -18
  7. package/client/modeller/figure-inspector.ts +439 -76
  8. package/client/modeller/figure-parts.ts +15 -15
  9. package/client/modeller/figure-preview.ts +2 -2
  10. package/client/modeller/figure-report.ts +102 -37
  11. package/client/modeller/figure-settings.ts +20 -20
  12. package/client/modeller/figure-side.ts +91 -15
  13. package/client/modeller/figure-source.test.ts +15 -1
  14. package/client/modeller/figure-source.ts +31 -15
  15. package/client/modeller/figure-thumbnail.ts +49 -4
  16. package/client/modeller/maturity.ts +10 -10
  17. package/client/pages/figure-list-page.ts +204 -66
  18. package/client/pages/figure-modeller-page.ts +158 -42
  19. package/client/types.ts +31 -0
  20. package/client/viewparts/figure-thumb.ts +250 -0
  21. package/dist-client/graphql/index.d.ts +8 -1
  22. package/dist-client/graphql/index.js +33 -3
  23. package/dist-client/graphql/index.js.map +1 -1
  24. package/dist-client/modeller/figure-animations.d.ts +93 -0
  25. package/dist-client/modeller/figure-animations.js +655 -0
  26. package/dist-client/modeller/figure-animations.js.map +1 -0
  27. package/dist-client/modeller/figure-ask.js +5 -5
  28. package/dist-client/modeller/figure-ask.js.map +1 -1
  29. package/dist-client/modeller/figure-canvas.js +1 -1
  30. package/dist-client/modeller/figure-canvas.js.map +1 -1
  31. package/dist-client/modeller/figure-capabilities.d.ts +42 -0
  32. package/dist-client/modeller/figure-capabilities.js +344 -0
  33. package/dist-client/modeller/figure-capabilities.js.map +1 -0
  34. package/dist-client/modeller/figure-history-panel.d.ts +15 -1
  35. package/dist-client/modeller/figure-history-panel.js +165 -18
  36. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  37. package/dist-client/modeller/figure-inspector.d.ts +98 -0
  38. package/dist-client/modeller/figure-inspector.js +425 -75
  39. package/dist-client/modeller/figure-inspector.js.map +1 -1
  40. package/dist-client/modeller/figure-parts.js +15 -15
  41. package/dist-client/modeller/figure-parts.js.map +1 -1
  42. package/dist-client/modeller/figure-preview.js +2 -2
  43. package/dist-client/modeller/figure-preview.js.map +1 -1
  44. package/dist-client/modeller/figure-report.d.ts +18 -1
  45. package/dist-client/modeller/figure-report.js +105 -37
  46. package/dist-client/modeller/figure-report.js.map +1 -1
  47. package/dist-client/modeller/figure-settings.js +19 -19
  48. package/dist-client/modeller/figure-settings.js.map +1 -1
  49. package/dist-client/modeller/figure-side.d.ts +24 -0
  50. package/dist-client/modeller/figure-side.js +94 -14
  51. package/dist-client/modeller/figure-side.js.map +1 -1
  52. package/dist-client/modeller/figure-source.d.ts +15 -8
  53. package/dist-client/modeller/figure-source.js +16 -7
  54. package/dist-client/modeller/figure-source.js.map +1 -1
  55. package/dist-client/modeller/figure-thumbnail.js +40 -4
  56. package/dist-client/modeller/figure-thumbnail.js.map +1 -1
  57. package/dist-client/modeller/maturity.js +10 -10
  58. package/dist-client/modeller/maturity.js.map +1 -1
  59. package/dist-client/pages/figure-list-page.d.ts +11 -4
  60. package/dist-client/pages/figure-list-page.js +201 -63
  61. package/dist-client/pages/figure-list-page.js.map +1 -1
  62. package/dist-client/pages/figure-modeller-page.d.ts +33 -0
  63. package/dist-client/pages/figure-modeller-page.js +156 -42
  64. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  65. package/dist-client/tsconfig.tsbuildinfo +1 -1
  66. package/dist-client/types.d.ts +29 -0
  67. package/dist-client/types.js.map +1 -1
  68. package/dist-client/viewparts/figure-thumb.d.ts +29 -0
  69. package/dist-client/viewparts/figure-thumb.js +242 -0
  70. package/dist-client/viewparts/figure-thumb.js.map +1 -0
  71. package/dist-server/tsconfig.tsbuildinfo +1 -1
  72. package/package.json +6 -5
  73. package/test/i18n-prefix-guard.test.ts +171 -0
  74. package/test/korean-register-guard.test.ts +83 -0
  75. package/translations/en.json +351 -264
  76. package/translations/ja.json +276 -264
  77. package/translations/ko.json +351 -264
  78. package/translations/ms.json +276 -264
  79. package/translations/zh.json +276 -264
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.5",
3
+ "version": "10.1.7",
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.12",
30
- "@hatiolab/things-scene": "^10.1.6",
29
+ "@hatiolab/figure-model": "^0.1.15",
30
+ "@hatiolab/things-scene": "^10.1.10",
31
31
  "@material/web": "^2.0.0",
32
32
  "@operato/data-grist": "^10.0.0",
33
33
  "@operato/graphql": "^10.0.0",
@@ -36,8 +36,9 @@
36
36
  "@operato/p13n": "^10.0.0",
37
37
  "@operato/shell": "^10.0.0",
38
38
  "@operato/styles": "^10.0.0",
39
- "@things-factory/figure-service": "^10.1.4",
39
+ "@operato/utils": "^10.0.0",
40
+ "@things-factory/figure-service": "^10.1.7",
40
41
  "three": "^0.185.1"
41
42
  },
42
- "gitHead": "d7e60939822ef18e5311311746b2abd28f169173"
43
+ "gitHead": "9a036f83f5751f9e4af9180e9a0018f716a4ba27"
43
44
  }
@@ -0,0 +1,171 @@
1
+ /*
2
+ * **번역 키는 모듈 접두 아래 있어야 한다** (2026-09-10).
3
+ *
4
+ * ── 무엇이 있었나 ───────────────────────────────────────────────────────────
5
+ * things-factory 는 모듈마다 `translations/*.json` 을 두고 **한 평면에 병합한다.** 같은 키가
6
+ * 여럿이면 마지막이 이기고 아무 경고도 없다. 그래서 내 파일을 읽어도 화면에 뜨는 값을 알 수
7
+ * 없다 — 내가 적은 값이 아니라 이긴 값이 뜬다.
8
+ *
9
+ * 실제로 그랬다. figure 앱의 발행 단추가 「배포」로 떴다(board-ui 의 `button.release` 가 이겼다).
10
+ * 그때 figure 의 266개 키 중 아홉이 **다른 뜻으로** 겹쳐 있었다 — 모델러가 자기 부품을 board 의
11
+ * 낱말인 「품목」이라 부르고 있었고 아무것도 그것을 말해 주지 않았다.
12
+ *
13
+ * ── 규약 ────────────────────────────────────────────────────────────────────
14
+ * `<모듈>.<통>.<이름>` 이다. board-ai 가 세웠다 — 그쪽 키 58개 전부가 그 모양이다.
15
+ *
16
+ * figure.label.anchor ✓
17
+ * label.anchor ✗ 일반 통에 남으면 다음 모듈과 또 부딪힌다
18
+ * button.release-figure ✗ 뒤에 붙여도 `button.` 통 안이다
19
+ *
20
+ * ── 내 것이 아닌 통 ────────────────────────────────────────────────────────
21
+ * `field.` 는 shell 이 정의하는 공용 칸 이름(`updated_at` · `updater` · `thumbnail`)이다. 목록
22
+ * 화면들이 같은 낱말로 같은 뜻을 쓰므로 내 접두 아래로 옮기면 **figure 만 다른 말을 한다.**
23
+ * 읽기만 하고 소유하지 않는다.
24
+ */
25
+ import { test } from 'node:test'
26
+ import assert from 'node:assert/strict'
27
+ import { readFileSync, readdirSync } from 'node:fs'
28
+ import { fileURLToPath } from 'node:url'
29
+ import { join } from 'node:path'
30
+
31
+ import { CHANNEL_PATHS, CLIP_DRIVES, INTERPOLATIONS } from '@hatiolab/figure-model'
32
+
33
+ const HERE = fileURLToPath(new URL('.', import.meta.url))
34
+ const TRANSLATIONS = join(HERE, '../translations')
35
+ const CLIENT = join(HERE, '../client')
36
+
37
+ /** 남이 소유한 통. 읽기만 한다. */
38
+ const NOT_MINE = ['field.']
39
+
40
+ function screens(dir: string, into: string[] = []): string[] {
41
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
42
+ const path = join(dir, entry.name)
43
+ if (entry.isDirectory()) screens(path, into)
44
+ else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts')) into.push(path)
45
+ }
46
+ return into
47
+ }
48
+
49
+ const FILES = screens(CLIENT)
50
+ const KO = JSON.parse(readFileSync(join(TRANSLATIONS, 'ko.json'), 'utf-8')) as Record<string, string>
51
+
52
+ test('번역 파일의 모든 키가 figure. 접두 아래 있다', () => {
53
+ const loose = Object.keys(KO).filter(key => !key.startsWith('figure.'))
54
+
55
+ assert.deepEqual(
56
+ loose,
57
+ [],
58
+ `일반 통에 남은 키다. 다른 모듈이 같은 이름을 쓰면 조용히 진다:\n ${loose.join('\n ')}`
59
+ )
60
+ })
61
+
62
+ test('통은 label · text · button · title 넷이다 — 모듈 접두 다음 마디', () => {
63
+ const buckets = new Set(Object.keys(KO).map(key => key.split('.')[1]))
64
+ const odd = [...buckets].filter(one => !['label', 'text', 'button', 'title'].includes(one!))
65
+
66
+ assert.deepEqual(odd, [], `모르는 통: ${odd.join(' · ')}`)
67
+ })
68
+
69
+ test('화면이 부르는 키가 전부 접두를 지나간다', () => {
70
+ /*
71
+ 문자열을 통째로 적은 자리만 본다. `'figure.button.' + name` 처럼 이어 붙이는 자리는 앞머리가
72
+ 이미 접두를 지나가므로 같은 정규식에 걸린다.
73
+ */
74
+ const loose: string[] = []
75
+ for (const path of FILES) {
76
+ const text = readFileSync(path, 'utf-8')
77
+ for (const [at, line] of text.split('\n').entries()) {
78
+ const call = /i18next\.t\(\s*['"`]([\w.-]+)/.exec(line)
79
+ if (!call) continue
80
+ const key = call[1]!
81
+ if (key.startsWith('figure.') || NOT_MINE.some(one => key.startsWith(one))) continue
82
+ loose.push(`${path.slice(CLIENT.length + 1)}:${at + 1} ${key}`)
83
+ }
84
+ }
85
+
86
+ assert.deepEqual(loose, [], `접두 없이 부르는 자리다:\n ${loose.join('\n ')}`)
87
+ })
88
+
89
+ test('통째로 부르는 키가 번역 파일에 있다 — 없으면 화면에 키가 그대로 뜬다', () => {
90
+ const missing: string[] = []
91
+ for (const path of FILES) {
92
+ const text = readFileSync(path, 'utf-8')
93
+ for (const match of text.matchAll(/(?:i18next\.t|help)\(\s*['"`](figure\.[\w.-]+)['"`]/g)) {
94
+ const key = match[1]!
95
+ /*
96
+ 앞머리는 통째 키가 아니다. `i18next.t('figure.label.role-' + one)` 처럼 이어 붙이는
97
+ 자리가 같은 정규식에 걸리고, 그것은 `-` 나 `.` 로 끝난다. 그 값들까지 전수로 보려면
98
+ 코드에서 열거 배열을 읽어야 하므로 여기서는 짝이 갈린 것만 아래에서 못 박는다.
99
+ */
100
+ if (/[-.]$/.test(key)) continue
101
+ if (!(key in KO)) missing.push(`${path.slice(CLIENT.length + 1)} ${key}`)
102
+ }
103
+ }
104
+
105
+ assert.deepEqual(missing, [], `부르는 키가 번역에 없다:\n ${missing.join('\n ')}`)
106
+ })
107
+
108
+ /*
109
+ 이어 붙이는 자리는 값을 코드에서 읽어야 알 수 있어 여기서 전수 검사하지 않는다. 대신 **통이
110
+ 갈린 짝**을 못 박는다 — 사다리 이름은 `label`, 그 설명은 `text` 다. 한 번 어긋났고, 그때
111
+ 사다리 설명 세 줄이 화면에 키로 떴다. 넓은 앞머리(`figure.text.`)가 미사용 검사를 덮어
112
+ 통과시켰다.
113
+ */
114
+ test('★ 통이 갈린 짝 — 사다리 이름과 설명', () => {
115
+ for (const rung of ['none', 'hold', 'flow']) {
116
+ assert.ok(`figure.label.rung-${rung}` in KO, `figure.label.rung-${rung} 이 없다`)
117
+ assert.ok(`figure.text.rung-${rung}-note` in KO, `figure.text.rung-${rung}-note 이 없다`)
118
+ }
119
+
120
+ const source = readFileSync(join(CLIENT, 'modeller/figure-capabilities.ts'), 'utf-8')
121
+ assert.ok(source.includes('figure.label.rung-${which}'), '사다리 이름을 label 통에서 찾지 않는다')
122
+ assert.ok(source.includes('figure.text.rung-${which}-note'), '사다리 설명을 text 통에서 찾지 않는다')
123
+ })
124
+
125
+ /*
126
+ 애니메이션 탭도 열거 값을 키에 이어 붙인다. 사다리와 **같은 모양의 함정**이라 같은 못을 박는다.
127
+
128
+ 갈린 통이 둘이다 — 구동은 이름(`label`)과 설명(`text.…-note`), 경로는 이름(`label`)과
129
+ 단위 설명(`text.…-unit`). 한쪽만 넣으면 화면에 키가 그대로 뜨고, 넓은 앞머리가 위의
130
+ 미사용 검사를 덮어 통과시킨다.
131
+
132
+ 열거 값을 여기 다시 적지 않고 형식에서 읽는다. 적어 두면 형식에 값을 하나 더할 때
133
+ 검사가 조용히 통과한다.
134
+ */
135
+ test('★ 열거 값마다 짝이 다 있다 — 구동 · 경로 · 보간', () => {
136
+ const missing: string[] = []
137
+ const want = (key: string) => {
138
+ if (!(key in KO)) missing.push(key)
139
+ }
140
+
141
+ for (const drive of CLIP_DRIVES) {
142
+ want(`figure.label.drive-${drive}`)
143
+ want(`figure.text.drive-${drive}-note`)
144
+ }
145
+ for (const path of CHANNEL_PATHS) {
146
+ want(`figure.label.path-${path}`)
147
+ want(`figure.text.path-${path}-unit`)
148
+ }
149
+ for (const one of INTERPOLATIONS) {
150
+ want(`figure.label.interpolation-${one}`)
151
+ }
152
+
153
+ assert.deepEqual(missing, [], `이어 붙여 부르는 키가 번역에 없다:\n ${missing.join('\n ')}`)
154
+ })
155
+
156
+ test('★ 애니메이션 탭이 그 통에서 찾는다 — 이름은 label, 설명은 text', () => {
157
+ const source = readFileSync(join(CLIENT, 'modeller/figure-animations.ts'), 'utf-8')
158
+
159
+ assert.ok(source.includes("'figure.label.drive-' + drive"), '구동 이름을 label 통에서 찾지 않는다')
160
+ assert.ok(source.includes("'figure.text.drive-'"), '구동 설명을 text 통에서 찾지 않는다')
161
+ assert.ok(source.includes("'figure.label.path-' + path"), '경로 이름을 label 통에서 찾지 않는다')
162
+ assert.ok(source.includes("'figure.text.path-' + channel.path + '-unit'"), '단위 설명을 text 통에서 찾지 않는다')
163
+ })
164
+
165
+ test('★ 이 가드가 실제로 읽고 있다 — 0이면 검사가 죽은 것이다', () => {
166
+ assert.ok(Object.keys(KO).length > 250, `읽은 키가 ${Object.keys(KO).length}개다`)
167
+ assert.ok(FILES.length > 10, `읽은 화면이 ${FILES.length}개다`)
168
+
169
+ // 한 번 걸렸던 모양을 넣어 본다.
170
+ assert.ok(!/^figure\./.test('label.anchor'), '접두 없는 키를 지금은 통과시킨다')
171
+ })
@@ -0,0 +1,83 @@
1
+ /*
2
+ * **화면 한국어에는 비유·구어·의인화를 쓰지 않는다** (2026-09-10).
3
+ *
4
+ * ── 무엇이 있었나 ───────────────────────────────────────────────────────────
5
+ * 성숙도 탭의 빈 화면이 「무게와 점수를 재는 자리입니다」라고 적혀 있었다. 사용자가 물었다 —
6
+ * 「무게가 뭐야 저울 달아? 점수는 뭔데 무슨 점수?」 이 형식에는 질량도 물리도 없다. 그 묶음이
7
+ * 표시하는 것은 삼각형 수 · draw call · 서로 다른 형상 · 서로 다른 재질, 전부 그리는 값이다.
8
+ * 3D 저작 도구에서 「무게」는 물리 속성으로 읽힌다. 안 가르치는 정도가 아니라 틀린 것을 가르쳤다.
9
+ *
10
+ * 고치면서 또 걸렸다. 「재어 … 말해 주는 자리입니다」 — 「잰다」는 구어이고(측정한다), 화면이
11
+ * 「말해 준다」는 의인화다. 한 번 지적받고 고친 문장에서 두 번 더 걸렸다.
12
+ *
13
+ * ── 왜 검사로 만드나 ────────────────────────────────────────────────────────
14
+ * 규칙은 이미 있다. 지키지 못한 것은 규칙을 몰라서가 아니라, 초안을 쓸 때 확인하지 않고 지적을
15
+ * 받은 뒤에 확인했기 때문이다. 같은 자리에서 반복되는 것은 주의력에 맡겨 둔 탓이므로 검사로
16
+ * 옮긴다. 이 검사는 매번 실행되고 내 주의력은 그렇지 않다.
17
+ *
18
+ * ── 무엇을 막나 ─────────────────────────────────────────────────────────────
19
+ * 화면에 그대로 나가는 글, 즉 `translations/*.json` 의 값만 본다. 코드 주석과 대화는 같은 규칙을
20
+ * 받지만 여기서 막지 않는다 — 화면 문구가 고객에게 보이는 것이라 우선순위가 다르다.
21
+ *
22
+ * 목록으로 전부 막을 수는 없다. 여기 있는 것은 **실제로 이 저장소에서 걸린 것들**이고, 새로
23
+ * 걸릴 때마다 한 줄씩 늘린다. 목록이 규칙 자체는 아니다 — 규칙은
24
+ * `feedback_korean_plain_technical` 이고, 판단 기준은 「그 낱말이 기술 문서에 인쇄돼 있는 것을
25
+ * 본 적이 있는가」다.
26
+ */
27
+ import { test } from 'node:test'
28
+ import assert from 'node:assert/strict'
29
+ import { readFileSync } from 'node:fs'
30
+ import { fileURLToPath } from 'node:url'
31
+
32
+ const KO = fileURLToPath(new URL('../translations/ko.json', import.meta.url))
33
+
34
+ /** 걸러낼 표현과, 그 자리에 쓸 말. */
35
+ const BANNED: readonly { readonly pattern: RegExp; readonly instead: string; readonly why: string }[] = [
36
+ { pattern: /잽니다|잰다|재는 자리|재어|재 봅니다/, instead: '측정합니다 · 측정한다', why: '구어' },
37
+ { pattern: /말해 주|알려 주는 자리|가르쳐 줍니다/, instead: '표시합니다', why: '화면을 사람처럼 말하게 한다' },
38
+ { pattern: /무게/, instead: '그리는 비용 · 삼각형 수', why: '3D 도구에서 물리 질량으로 읽힌다' },
39
+ { pattern: /앉습니다|앉는다|앉을 자리/, instead: '놓습니다 · 놓을 자리', why: '의인화' },
40
+ { pattern: /던집니다|던진다/, instead: '오류를 냅니다', why: '목적어가 없어 문장이 성립하지 않는다' },
41
+ { pattern: /먹습니다|먹는다|먹히지/, instead: '소비합니다', why: '구어' },
42
+ { pattern: /훑습니다|훑는다/, instead: '순회합니다', why: '구어' },
43
+ { pattern: /갈립니다|갈린다/, instead: '달라집니다', why: '구어' },
44
+ { pattern: /건드립니다|건드린다|손봅니다/, instead: '수정합니다', why: '구어' },
45
+ { pattern: /못 박|박습니다/, instead: '명시합니다', why: '구어' },
46
+ { pattern: /굴러갑니다|굴러간다/, instead: '실행됩니다', why: '구어' },
47
+ { pattern: /샙니다|샌다|굳습니다/, instead: '누락됩니다 · 고정됩니다', why: '구어' },
48
+ { pattern: /뭉툭/, instead: '부정확 · 모호', why: '구어' },
49
+ { pattern: /오버헤드/, instead: '느려집니다 · 비용', why: '무엇이 어떻게 되는지 말하지 않는다' }
50
+ ]
51
+
52
+ test('화면 문구에 비유·구어·의인화가 없다', () => {
53
+ const ko = JSON.parse(readFileSync(KO, 'utf-8')) as Record<string, string>
54
+
55
+ const caught: string[] = []
56
+ for (const [key, value] of Object.entries(ko)) {
57
+ for (const { pattern, instead, why } of BANNED) {
58
+ if (pattern.test(value)) {
59
+ caught.push(`${key}\n "${value}"\n ${why} → ${instead}`)
60
+ }
61
+ }
62
+ }
63
+
64
+ assert.deepEqual(caught, [], `화면에 나가는 글이다. 고치고 나서 통과시킨다.\n\n ${caught.join('\n\n ')}\n`)
65
+ })
66
+
67
+ /*
68
+ 검사가 죽으면 조용히 통과한다. 파일을 못 읽거나 목록이 비면 위 시험은 언제나 초록이고,
69
+ 그것을 「문제가 없다」로 읽게 된다.
70
+ */
71
+ test('★ 이 가드가 실제로 문구를 읽고 있다 — 0이면 검사가 죽은 것이다', () => {
72
+ const ko = JSON.parse(readFileSync(KO, 'utf-8')) as Record<string, string>
73
+
74
+ assert.ok(Object.keys(ko).length > 100, `읽은 문구가 ${Object.keys(ko).length}개다`)
75
+ assert.ok(BANNED.length > 10, '목록이 비었다')
76
+
77
+ // 실제로 걸리는 문장을 넣어 본다. 안 걸리면 정규식이 죽은 것이다.
78
+ const sample = '무게와 점수를 재는 자리입니다'
79
+ assert.ok(
80
+ BANNED.some(({ pattern }) => pattern.test(sample)),
81
+ '한 번 걸렸던 문장을 지금은 못 잡는다'
82
+ )
83
+ })