@things-factory/figure-ui 10.1.49 → 10.1.51

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 (97) hide show
  1. package/client/graphql/v3-store.ts +2 -1
  2. package/client/modeller/figure-history-panel.ts +9 -1
  3. package/client/modeller/figure-v3-first-save.ts +98 -0
  4. package/client/modeller/figure-v3-links.ts +177 -31
  5. package/client/modeller/figure-v3-parts.ts +1 -0
  6. package/client/modeller/figure-v3-properties.ts +2 -1
  7. package/client/modeller/v3-canvas.ts +61 -7
  8. package/client/modeller/v3-editor.ts +96 -21
  9. package/client/modeller/v3-first-save.ts +34 -0
  10. package/client/modeller/v3-gizmo.ts +52 -3
  11. package/client/modeller/v3-joint-drag.ts +116 -0
  12. package/client/modeller/v3-joint-fan.ts +24 -11
  13. package/client/modeller/v3-keys.ts +12 -1
  14. package/client/modeller/v3-links.ts +119 -8
  15. package/client/modeller/v3-live.ts +11 -0
  16. package/client/modeller/v3-product-session.ts +4 -2
  17. package/client/modeller/v3-refusals.ts +21 -0
  18. package/client/pages/figure-v3-modeller-page.ts +57 -8
  19. package/dist-client/graphql/v3-store.js +2 -1
  20. package/dist-client/graphql/v3-store.js.map +1 -1
  21. package/dist-client/modeller/figure-history-panel.js +9 -1
  22. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  23. package/dist-client/modeller/figure-v3-capability.d.ts +1 -1
  24. package/dist-client/modeller/figure-v3-clips.d.ts +1 -1
  25. package/dist-client/modeller/figure-v3-controls.d.ts +1 -1
  26. package/dist-client/modeller/figure-v3-first-save.d.ts +20 -0
  27. package/dist-client/modeller/figure-v3-first-save.js +118 -0
  28. package/dist-client/modeller/figure-v3-first-save.js.map +1 -0
  29. package/dist-client/modeller/figure-v3-links.d.ts +14 -1
  30. package/dist-client/modeller/figure-v3-links.js +169 -28
  31. package/dist-client/modeller/figure-v3-links.js.map +1 -1
  32. package/dist-client/modeller/figure-v3-material.d.ts +1 -1
  33. package/dist-client/modeller/figure-v3-parts.js +1 -0
  34. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  35. package/dist-client/modeller/figure-v3-properties.d.ts +1 -1
  36. package/dist-client/modeller/figure-v3-properties.js +2 -1
  37. package/dist-client/modeller/figure-v3-properties.js.map +1 -1
  38. package/dist-client/modeller/v3-canvas.d.ts +11 -0
  39. package/dist-client/modeller/v3-canvas.js +61 -7
  40. package/dist-client/modeller/v3-canvas.js.map +1 -1
  41. package/dist-client/modeller/v3-clips.d.ts +1 -1
  42. package/dist-client/modeller/v3-editor.d.ts +13 -1
  43. package/dist-client/modeller/v3-editor.js +96 -21
  44. package/dist-client/modeller/v3-editor.js.map +1 -1
  45. package/dist-client/modeller/v3-first-save.d.ts +17 -0
  46. package/dist-client/modeller/v3-first-save.js +27 -0
  47. package/dist-client/modeller/v3-first-save.js.map +1 -0
  48. package/dist-client/modeller/v3-gizmo.d.ts +18 -0
  49. package/dist-client/modeller/v3-gizmo.js +45 -2
  50. package/dist-client/modeller/v3-gizmo.js.map +1 -1
  51. package/dist-client/modeller/v3-joint-drag.d.ts +19 -0
  52. package/dist-client/modeller/v3-joint-drag.js +97 -0
  53. package/dist-client/modeller/v3-joint-drag.js.map +1 -0
  54. package/dist-client/modeller/v3-joint-fan.js +24 -11
  55. package/dist-client/modeller/v3-joint-fan.js.map +1 -1
  56. package/dist-client/modeller/v3-keys.d.ts +9 -1
  57. package/dist-client/modeller/v3-keys.js +3 -0
  58. package/dist-client/modeller/v3-keys.js.map +1 -1
  59. package/dist-client/modeller/v3-links.d.ts +72 -3
  60. package/dist-client/modeller/v3-links.js +89 -6
  61. package/dist-client/modeller/v3-links.js.map +1 -1
  62. package/dist-client/modeller/v3-live.d.ts +6 -0
  63. package/dist-client/modeller/v3-live.js +10 -0
  64. package/dist-client/modeller/v3-live.js.map +1 -1
  65. package/dist-client/modeller/v3-product-session.d.ts +4 -1
  66. package/dist-client/modeller/v3-product-session.js +2 -2
  67. package/dist-client/modeller/v3-product-session.js.map +1 -1
  68. package/dist-client/modeller/v3-refusals.d.ts +4 -0
  69. package/dist-client/modeller/v3-refusals.js +21 -0
  70. package/dist-client/modeller/v3-refusals.js.map +1 -0
  71. package/dist-client/pages/figure-v3-modeller-page.d.ts +4 -0
  72. package/dist-client/pages/figure-v3-modeller-page.js +56 -8
  73. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  74. package/dist-client/tsconfig.tsbuildinfo +1 -1
  75. package/package.json +7 -5
  76. package/test/browser/build.mjs +62 -0
  77. package/test/browser/modeller-page.js +198 -0
  78. package/test/browser/modeller.check.mjs +358 -0
  79. package/test/browser/serve.mjs +43 -0
  80. package/test/joints.ts +33 -0
  81. package/test/v3-clips.test.ts +3 -2
  82. package/test/v3-controls.test.ts +3 -2
  83. package/test/v3-fields.test.ts +3 -2
  84. package/test/v3-first-save.test.ts +84 -0
  85. package/test/v3-gizmo.test.ts +38 -5
  86. package/test/v3-joint-drag.test.ts +93 -0
  87. package/test/v3-joint-fan.test.ts +16 -6
  88. package/test/v3-keys.test.ts +15 -2
  89. package/test/v3-links.test.ts +114 -14
  90. package/test/v3-live.test.ts +23 -4
  91. package/test/v3-maturity.test.ts +2 -6
  92. package/test/v3-refusals.test.ts +28 -0
  93. package/translations/en.json +40 -5
  94. package/translations/ja.json +39 -4
  95. package/translations/ko.json +39 -4
  96. package/translations/ms.json +39 -4
  97. package/translations/zh.json +39 -4
@@ -0,0 +1,43 @@
1
+ /*
2
+ * Opens the built page in a headless browser: a tiny server for page.js, and puppeteer on it at a desktop size.
3
+ */
4
+ import { createServer } from 'node:http'
5
+ import { readFileSync } from 'node:fs'
6
+ import puppeteer from 'puppeteer'
7
+
8
+ const HTML = `<!DOCTYPE html>
9
+ <html><head><meta charset="utf-8"><style>
10
+ html, body { margin: 0; height: 100%; }
11
+ body { display: flex; font-family: sans-serif; }
12
+ figure-v3-editor { flex: 1; min-width: 0; min-height: 0; }
13
+ </style></head>
14
+ <body><script type="module" src="/page.js"></script></body></html>`
15
+
16
+ export async function openPage(bundle, { width = 1400, height = 900 } = {}) {
17
+ const js = readFileSync(bundle)
18
+ const server = createServer((req, res) => {
19
+ if (req.url === '/page.js') {
20
+ res.writeHead(200, { 'Content-Type': 'application/javascript' })
21
+ res.end(js)
22
+ } else {
23
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
24
+ res.end(HTML)
25
+ }
26
+ })
27
+ await new Promise(done => server.listen(0, done))
28
+ const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] })
29
+ const page = await browser.newPage()
30
+ await page.setViewport({ width, height })
31
+ const errors = []
32
+ page.on('pageerror', e => errors.push(String(e)))
33
+ await page.goto(`http://localhost:${server.address().port}/`)
34
+ await page.waitForFunction(() => !!window.harness, { timeout: 30000 })
35
+ return {
36
+ page,
37
+ errors,
38
+ async close() {
39
+ await browser.close()
40
+ server.close()
41
+ }
42
+ }
43
+ }
package/test/joints.ts ADDED
@@ -0,0 +1,33 @@
1
+ /*
2
+ * Moving a part in a test, as the modeller does it now (ADR-0093): the joint above the part is given a turn or a
3
+ * slide. A part fastened to nothing is stood on the floor first -- a joint has a parent.
4
+ */
5
+ import { applyV3Authoring } from '@hatiolab/figure-model'
6
+ import type { V3Asset, V3Joint } from '@hatiolab/figure-model'
7
+
8
+ export function moving(
9
+ asset: V3Asset,
10
+ part: string,
11
+ spec: { kind: 'slide' | 'turn'; axis: 'x' | 'y' | 'z'; id: string; min: number; max: number; start?: number }
12
+ ): V3Asset {
13
+ /* Stood on the floor where it is: its X and Z kept as offsets on the plane. */
14
+ const at = (axis: 'x' | 'z') => ({ mm: asset.designInputs[`${part}.position.${axis}`] ?? 0 })
15
+ const above: V3Joint = asset.joints?.find(j => j.body1.part === part) ?? {
16
+ id: part,
17
+ type: 'fixed',
18
+ body0: { plane: 'mounting-plane' },
19
+ body1: { part, face: 'bottom' },
20
+ origin: { align: { x: at('x'), z: at('z') } }
21
+ }
22
+ return applyV3Authoring(asset, {
23
+ kind: 'set-joint',
24
+ joint: {
25
+ ...above,
26
+ type: spec.kind === 'slide' ? 'prismatic' : 'revolute',
27
+ axis: spec.axis.toUpperCase() as 'X' | 'Y' | 'Z',
28
+ lowerLimit: spec.min,
29
+ upperLimit: spec.max,
30
+ state: { id: spec.id, ...(spec.start !== undefined ? { start: spec.start } : {}) }
31
+ }
32
+ })
33
+ }
@@ -12,15 +12,16 @@ import { addV3Box, applyV3Authoring, createV3Asset, editV3Part } from '@hatiolab
12
12
  import type { V3Asset } from '@hatiolab/figure-model'
13
13
 
14
14
  import { clipsOf, poseAt, trackEdit, undrivenOf, withKey, withoutKey } from '../client/modeller/v3-clips.ts'
15
+ import { moving } from './joints.ts'
15
16
 
16
17
  const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
17
18
 
18
19
  const machine = (): V3Asset => {
19
20
  let asset = createV3Asset('Machine')
20
21
  asset = addV3Box(asset, { id: 'block', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
21
- asset = applyV3Authoring(asset, { kind: 'add-motion', part: 'block', motion: { kind: 'slide', axis: 'x' }, state: { id: 'shift', unit: 'mm', min: 0, max: 60, start: 0 } })
22
+ asset = moving(asset, 'block', { kind: 'slide', axis: 'x', id: 'shift', min: 0, max: 60, start: 0 })
22
23
  asset = addV3Box(asset, { id: 'arm', size: { x: 40, y: 200, z: 40 }, position: { x: 0, y: 200, z: 0 } })
23
- return applyV3Authoring(asset, { kind: 'add-motion', part: 'arm', motion: { kind: 'turn', axis: 'y' }, state: { id: 'spin', unit: 'deg', min: -180, max: 180, start: 0 } })
24
+ return moving(asset, 'arm', { kind: 'turn', axis: 'y', id: 'spin', min: -180, max: 180, start: 0 })
24
25
  }
25
26
 
26
27
  test('클립마다 길이와 트랙을, 트랙마다 키를 초로 읽는다', () => {
@@ -13,15 +13,16 @@ import { addV3Box, applyV3Authoring, createV3Asset, editV3Part } from '@hatiolab
13
13
  import type { V3Asset } from '@hatiolab/figure-model'
14
14
 
15
15
  import { controlsOf, poseAsDefault, poseDiffers } from '../client/modeller/v3-controls.ts'
16
+ import { moving } from './joints.ts'
16
17
 
17
18
  const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
18
19
 
19
20
  const slider = (): V3Asset => {
20
21
  let asset = createV3Asset('Slider')
21
22
  asset = addV3Box(asset, { id: 'block', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
22
- asset = applyV3Authoring(asset, { kind: 'add-motion', part: 'block', motion: { kind: 'slide', axis: 'x' }, state: { id: 'shift', unit: 'mm', min: 0, max: 60, start: 20 } })
23
+ asset = moving(asset, 'block', { kind: 'slide', axis: 'x', id: 'shift', min: 0, max: 60, start: 20 })
23
24
  asset = addV3Box(asset, { id: 'arm', size: { x: 40, y: 200, z: 40 }, position: { x: 0, y: 200, z: 0 } })
24
- return applyV3Authoring(asset, { kind: 'add-motion', part: 'arm', motion: { kind: 'turn', axis: 'y' }, state: { id: 'spin', unit: 'deg', min: -90, max: 90, start: 0 } })
25
+ return moving(asset, 'arm', { kind: 'turn', axis: 'y', id: 'spin', min: -90, max: 90, start: 0 })
25
26
  }
26
27
 
27
28
  test('조작값마다 이름 · 단위 · 범위 · 지금 값 · 시작 값 · 이동 시간을 읽는다', () => {
@@ -101,7 +101,8 @@ test('pitch 는 한 곳에서 하나의 값이고, 늘 set-repeat 로 바꿔 최
101
101
  test('기즈모로 끈 변환 부품은 규칙을 지킨 채 옮겨지고, 관절·붙이기가 정하는 부품은 까닭을 말한다 — 튕겨 돌아가지 않는다', () => {
102
102
  const editor = readFileSync(fileURLToPath(new URL('../client/modeller/v3-editor.ts', import.meta.url)), 'utf8')
103
103
  const moved = editor.slice(editor.indexOf(' private transformed('), editor.indexOf(' /** 맞추기'))
104
- assert.ok(moved.includes("this.session!.command({ kind: 'nudge-part', part: id,"), '사용자 2026-09-24: 「기즈모 컨트롤도 전혀 작동이 안되는군」')
105
- assert.ok(moved.includes("'figure.text.gizmo-joint-places'") && moved.includes("'figure.text.gizmo-fastening-places'") && moved.includes("'figure.text.gizmo-size-worked-out'"))
104
+ assert.ok(moved.includes("kind: 'nudge-part',"), '사용자 2026-09-24: 「기즈모 컨트롤도 전혀 작동이 안되는군」')
105
+ assert.ok(moved.includes('...(Object.keys(grow).length ? { grow } : {})'), '크기 끌기도 배율로 — 「되는 듯하다가 다시 원래 크기로 돌아간다」')
106
+ assert.ok(moved.includes("'figure.text.gizmo-joint-places'") && moved.includes("'figure.text.gizmo-fastening-places'"))
106
107
  })
107
108
 
@@ -0,0 +1,84 @@
1
+ /*
2
+ * **처음 저장은 타입과 이름을 묻는다** — V2 의 저장 창. V3 로 옮기며 빠져서 새 도형이 기계 이름으로 저장됐다(2026-09-25).
3
+ */
4
+ import { test } from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+
7
+ import { placeholderType } from '@hatiolab/figure-model'
8
+ import { firstSaveRefusalOf, releasableType, typeOf } from '../client/modeller/v3-first-save.ts'
9
+ import { createV3ProductSession, type V3ProductStore } from '../client/modeller/v3-product-session.ts'
10
+
11
+ test('치는 대로 타입의 모양이 된다 — 대문자 · 숫자 · 밑줄', () => {
12
+ assert.equal(typeOf(' agv lifter '), 'AGV_LIFTER')
13
+ assert.equal(typeOf('oht-2'), 'OHT_2')
14
+ })
15
+
16
+ test('발행할 수 없는 이름은 미리 말한다 — 임시 이름 · 형식 · 길이', () => {
17
+ assert.equal(releasableType('AGV_LIFTER'), true)
18
+ assert.equal(releasableType(placeholderType()), false, '서버가 붙이는 임시 이름')
19
+ assert.equal(releasableType('AB'), false, '3 자 미만')
20
+ assert.equal(releasableType('A__B'), false, '밑줄 둘')
21
+ assert.equal(releasableType('_OHT'), false)
22
+ })
23
+
24
+ test('비었거나 이미 쓰는 이름이면 저장하지 않는다', () => {
25
+ assert.deepEqual(firstSaveRefusalOf('', ['OHT']), { key: 'figure.text.enter-a-type-name' })
26
+ assert.deepEqual(firstSaveRefusalOf('OHT', ['OHT']), { key: 'figure.text.type-name-already-taken', type: 'OHT' })
27
+ assert.equal(firstSaveRefusalOf('AGV', ['OHT']), null)
28
+ })
29
+
30
+ /** A store that remembers what it was asked. */
31
+ function recording() {
32
+ const requests: Parameters<V3ProductStore['save']>[0][] = []
33
+ const store: V3ProductStore = {
34
+ load: async id => ({ id, source: saved ?? '' }),
35
+ save: async request => {
36
+ requests.push(request)
37
+ saved = request.source
38
+ return { id: request.id ?? 'new-id', source: request.source }
39
+ }
40
+ }
41
+ let saved: string | undefined
42
+ return { store, requests }
43
+ }
44
+
45
+ test('타입은 새 도형을 만드는 저장에만 실린다 — 이미 있는 도형의 저장은 타입을 보내지 않는다', async () => {
46
+ const { store, requests } = recording()
47
+ const session = createV3ProductSession(store)
48
+ session.startNew()
49
+ await session.save({ type: 'AGV_LIFTER' })
50
+ assert.equal(requests[0]!.type, 'AGV_LIFTER')
51
+ assert.equal(requests[0]!.id, undefined, '만드는 저장')
52
+
53
+ session.command({ kind: 'rename', name: '리프터' })
54
+ await session.save({ type: 'OTHER' })
55
+ assert.equal(requests[1]!.id, 'new-id')
56
+ assert.equal('type' in requests[1]!, false, '있는 도형에는 보내지 않는다')
57
+ })
58
+
59
+ test('타입을 주지 않으면 싣지 않는다 — 서버가 임시 이름을 붙인다', async () => {
60
+ const { store, requests } = recording()
61
+ const session = createV3ProductSession(store)
62
+ session.startNew()
63
+ await session.save()
64
+ assert.equal('type' in requests[0]!, false)
65
+ })
66
+
67
+ test('저장 전에 적은 타입은 처음 저장의 물음에 실려 온다 — 칸이 막혀 있어 적어도 사라졌다 (09-25)', async () => {
68
+ const { readFileSync } = await import('node:fs')
69
+ const page = readFileSync(new URL('../client/pages/figure-v3-modeller-page.ts', import.meta.url), 'utf8')
70
+ const field = page.slice(page.indexOf('<label slot="type" type-field>'), page.indexOf('</label>', page.indexOf('<label slot="type" type-field>')))
71
+ assert.doesNotMatch(field, /disabled=\$\{!view\.id\}/, '저장 전에도 칸에 적을 수 있다')
72
+ const rename = page.slice(page.indexOf('private renameType'), page.indexOf('private fitToParts'))
73
+ assert.match(rename, /if \(!id\) \{\s*this\.row = \{ \.\.\.this\.row, type: wanted \|\| undefined \}/, '저장 전에는 행 대신 화면이 타입을 쥔다')
74
+ assert.match(page, /<figure-v3-first-save \.type=\$\{this\.row\.type \?\? ''\}/, '물음은 그 타입으로 열린다')
75
+ })
76
+
77
+ test('처음 저장한 도형은 주소가 제 것이 된다 — 모델러를 다시 열면 새 도형이 열린다 (2026-09-25)', async () => {
78
+ const { readFileSync } = await import('node:fs')
79
+ const page = readFileSync(new URL('../client/pages/figure-v3-modeller-page.ts', import.meta.url), 'utf8')
80
+ const changed = page.slice(page.indexOf('private changed = () => {'), page.indexOf('private async readRow'))
81
+ assert.match(changed, /if \(view\.id && !this\.routeId && !view\.saving\) \{\s*this\.routeId = view\.id\s*navigate\(modellerPathOf\(\{ id: view\.id \}\), true\)/, '저장으로 id 를 얻으면 그 주소로 바꾼다(기록은 덮어쓴다)')
82
+ /* The early return that kept the old figure reads the route id; it now matches only the figure the address names. */
83
+ assert.match(page, /if \(this\.session\.read\(\)\.context && id === this\.routeId && !changes\.active\) return/)
84
+ })
@@ -12,6 +12,7 @@ import { fileURLToPath } from 'node:url'
12
12
  import { addV3Box, addV3Primitive, applyV3Authoring, compileV3Asset, createV3Asset, createV3EditorWorkspace } from '@hatiolab/figure-model'
13
13
 
14
14
  import { gizmoChanges, gizmoReachOf } from '../client/modeller/v3-gizmo.ts'
15
+ import { moving } from './joints.ts'
15
16
 
16
17
  const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
17
18
  const probe = () => {
@@ -28,11 +29,14 @@ const apply = (asset: ReturnType<typeof probe>, changes: ReturnType<typeof gizmo
28
29
  const pose = (asset: ReturnType<typeof probe>, id: string) =>
29
30
  (compileV3Asset(asset).evaluate().geometry.find(g => (g as { id: string }).id === id) as unknown as { pose: { t: number[]; r: number[][] } }).pose
30
31
 
31
- test('만든 상자는 셋 다, 원기둥은 크기를 뺀 둘을 낸다', () => {
32
+ test('만든 상자는 셋 다, 원기둥도 셋 다 — 원기둥의 크기는 반지름과 길이로', () => {
32
33
  assert.deepEqual(gizmoReachOf(probe() as never, 'body'), {
33
34
  translate: true, rotate: true, scale: true, sizes: ['body.size.x', 'body.size.y', 'body.size.z']
34
35
  })
35
- assert.deepEqual(gizmoReachOf(probe() as never, 'drum'), { translate: true, rotate: true, scale: false })
36
+ const drum = gizmoReachOf(probe() as never, 'drum')
37
+ assert.equal(drum.scale, true, '사용자 2026-09-24: 크기 기즈모가 원기둥에서 먹지 않았다')
38
+ assert.equal(drum.round?.across.length, 1)
39
+ assert.equal(drum.round?.along.length, 1)
36
40
  })
37
41
 
38
42
  test('편집기의 도형 팔레트로 만든 상자 · 둥근 상자도 크기를 낸다 — 입력 이름이 dimension.* 이다', () => {
@@ -80,7 +84,7 @@ test('끈 것은 세션의 한 걸음이다 — 한 번의 되돌리기로 돌
80
84
  const editor = read('../client/modeller/v3-editor.ts')
81
85
  const moved = editor.slice(editor.indexOf(' private transformed('), editor.indexOf(' /** 맞추기'))
82
86
  assert.ok(moved.includes('this.run(() => this.session!.apply(changes))'), 'one apply, one step')
83
- assert.ok(moved.includes("this.session!.command({ kind: 'nudge-part'"), 'a nudge is one command, one step')
87
+ assert.ok(moved.includes("kind: 'nudge-part',"), 'a nudge is one command, one step')
84
88
  })
85
89
 
86
90
  test('캔버스 — 기즈모 · W E R · 스냅은 씬의 편집 모드가 한다, V2 처럼', () => {
@@ -102,6 +106,35 @@ test('떼어 낸 부품은 다시 옮길 수 있다 — 움직임이 있는 부
102
106
  const fastened = applyV3Authoring(asset, { kind: 'attach', part: 'lid', face: 'bottom', to: { part: 'body', face: 'top' } })
103
107
  const free = applyV3Authoring(fastened, { kind: 'detach', part: 'lid' })
104
108
  assert.equal(gizmoReachOf(free as never, 'lid').translate, true)
105
- const moving = applyV3Authoring(free, { kind: 'add-motion', part: 'lid', motion: { kind: 'slide', axis: 'x' }, state: { id: 'lid-slide', unit: 'mm', min: 0, max: 100, start: 0 } })
106
- assert.equal(gizmoReachOf(moving as never, 'lid').translate, false)
109
+ const slid = moving(free, 'lid', { kind: 'slide', axis: 'x', id: 'lid-slide', min: 0, max: 100, start: 0 })
110
+ assert.equal(gizmoReachOf(slid as never, 'lid').translate, false, 'not its own numbers: a drag on a joint edits the joint (v3-joint-drag)')
111
+ })
112
+
113
+ test('끄는 동안은 모아 두고 놓을 때 한 번 적는다 — 크기 기즈모가 편집기와 싸우지 않는다', () => {
114
+ const canvas = read('../client/modeller/v3-canvas.ts')
115
+ const watch = canvas.slice(canvas.indexOf(' private watchScene()'), canvas.indexOf(' /** Components the gizmo changed'))
116
+ assert.ok(watch.includes('this.pending.set(name, origin!)') && watch.includes('if (!this.pointerDown) this.commitSoon(80)'), '사용자 2026-09-24: 「기즈모 컨트롤 중에 scale은 제대로 작동하지 않는군」')
117
+ assert.ok(!watch.includes("new CustomEvent('part-transformed'"), '움직일 때마다 알리지 않는다')
118
+ assert.ok(canvas.includes('private onPointerUp = () => {') && canvas.includes('this.commitSoon(0)'))
119
+ })
120
+
121
+
122
+ test('원기둥 · 구도 크기 기즈모가 먹는다 — 단면은 원으로(V2 의 keepRound)', async () => {
123
+ const { addV3Primitive, createV3Asset } = await import('@hatiolab/figure-model')
124
+ const { paletteRecipe } = await import('../client/modeller/v3-palette.ts')
125
+ const { gizmoChanges, gizmoReachOf } = await import('../client/modeller/v3-gizmo.ts')
126
+ let asset = createV3Asset('Round') as any
127
+ asset = addV3Primitive(asset, paletteRecipe('cylinder', 'drum') as any)
128
+ asset = addV3Primitive(asset, paletteRecipe('sphere', 'ball') as any)
129
+ const values = { ...asset.designInputs }
130
+ const drum = gizmoReachOf(asset, 'drum')
131
+ assert.equal(drum.scale, true, '원기둥도 크기를 받는다')
132
+ const [radius] = drum.round!.across, [length] = drum.round!.along
133
+ const changes = gizmoChanges('drum', drum, { scale: [1.5, 2, 1] }, values)
134
+ const put = Object.fromEntries(changes.map((c: any) => [c.input, c.value]))
135
+ assert.equal(put[radius!], Math.round(values[radius!] * 1.5), 'X 로 끈 만큼 반지름 — Z 도 같은 반지름이라 원이다')
136
+ assert.equal(put[length!], Math.round(values[length!] * 2))
137
+ const ball = gizmoReachOf(asset, 'ball')
138
+ const radii = gizmoChanges('ball', ball, { scale: [1, 1, 0.5] }, values).map((c: any) => c.value / values[c.input])
139
+ assert.equal(new Set(radii.map((r: number) => r.toFixed(2))).size, 1, '구의 세 반지름이 한 배율로 — 타원체가 되지 않는다')
107
140
  })
@@ -0,0 +1,93 @@
1
+ /*
2
+ * **기즈모로 관절에 달린 부품을 끌면 관절을 고친다** (ADR-0093) — 도는 관절은 값, 미끄러지는 관절은 값, 그 밖의 이동은
3
+ * 관절이 놓인 자리(간격 · 면 위 오프셋). 관계를 끊는 끌기는 이유와 함께 거절한다.
4
+ */
5
+ import { test } from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+
8
+ import { addV3Box, applyV3Authoring, compileV3Asset, createV3Asset } from '@hatiolab/figure-model'
9
+ import type { V3Asset, V3Joint } from '@hatiolab/figure-model'
10
+ import { jointDragOf } from '../client/modeller/v3-joint-drag.ts'
11
+
12
+ /** A post on the floor, and a link on its top. */
13
+ function column(joint: Partial<V3Joint>): V3Asset {
14
+ let asset = createV3Asset('Column')
15
+ asset = addV3Box(asset, { id: 'post', size: { x: 100, y: 200, z: 100 }, position: { x: 0, y: 100, z: 0 } })
16
+ asset = addV3Box(asset, { id: 'link', size: { x: 60, y: 300, z: 60 }, position: { x: 0, y: 350, z: 0 } })
17
+ asset = applyV3Authoring(asset, { kind: 'attach', part: 'post', face: 'bottom', to: { plane: 'mounting-plane' } })
18
+ return applyV3Authoring(asset, {
19
+ kind: 'set-joint',
20
+ joint: { id: 'bend', type: 'fixed', body0: { part: 'post', face: 'top' }, body1: { part: 'link', face: 'bottom' }, ...joint } as V3Joint
21
+ })
22
+ }
23
+
24
+ const drawn = (asset: V3Asset, id: string, state: Record<string, number> = {}) =>
25
+ compileV3Asset(asset).evaluate(state).geometry.find(g => g.id === id) as never as { id: string; pose: { t: number[]; r: number[][] } }
26
+
27
+ /** The link turned `deg` about Z, as the gizmo would report it (degrees, XYZ). */
28
+ const turnedZ = (deg: number) => [0, 0, deg] as [number, number, number]
29
+
30
+ test('돌리면 관절값 — 관절 축 둘레로 돈 만큼, 범위 안으로', () => {
31
+ const asset = column({ type: 'revolute', axis: 'Z', lowerLimit: -90, upperLimit: 90 })
32
+ const part = drawn(asset, 'link')
33
+ assert.deepEqual(jointDragOf(asset, 'link', part as never, { rotation: turnedZ(30) }, {}), { kind: 'value', input: 'bend', unit: 'deg', value: 30 })
34
+ assert.deepEqual(jointDragOf(asset, 'link', part as never, { rotation: turnedZ(120) }, {}), { kind: 'value', input: 'bend', unit: 'deg', value: 90 }, '범위 끝에서 멈춘다')
35
+ /* Already at 40: a further 30 makes 70. */
36
+ const at40 = drawn(asset, 'link', { bend: 40 })
37
+ assert.deepEqual(jointDragOf(asset, 'link', at40 as never, { rotation: turnedZ(70) }, { bend: 40 }), { kind: 'value', input: 'bend', unit: 'deg', value: 70 })
38
+ })
39
+
40
+ test('끝없이 도는 관절은 −180..180 으로 감는다', () => {
41
+ const asset = column({ type: 'revolute', axis: 'Y' })
42
+ const at170 = drawn(asset, 'link', { bend: 170 })
43
+ const drag = jointDragOf(asset, 'link', at170 as never, { rotation: [0, 190, 0] }, { bend: 170 })
44
+ assert.equal(drag?.kind, 'value')
45
+ assert.ok(Math.abs((drag as { value: number }).value - -170) < 0.2, JSON.stringify(drag))
46
+ })
47
+
48
+ test('미끄러지는 관절은 축을 따라 옮긴 거리가 값', () => {
49
+ const asset = column({ type: 'prismatic', axis: 'X', lowerLimit: -100, upperLimit: 100 })
50
+ const part = drawn(asset, 'link')
51
+ const to = [part.pose.t[0]! + 40, part.pose.t[1]!, part.pose.t[2]!] as [number, number, number]
52
+ assert.deepEqual(jointDragOf(asset, 'link', part as never, { position: to }, {}), { kind: 'value', input: 'bend', unit: 'mm', value: 40 })
53
+ })
54
+
55
+ test('고정 관절에 달린 부품을 옮기면 관절이 놓인 자리 — 면을 따라는 간격, 면 위로는 오프셋', () => {
56
+ const asset = column({})
57
+ const part = drawn(asset, 'link')
58
+ const up = jointDragOf(asset, 'link', part as never, { position: [part.pose.t[0]!, part.pose.t[1]! + 25, part.pose.t[2]!] }, {})
59
+ assert.deepEqual(up, { kind: 'joint', joint: { ...asset.joints!.find(j => j.id === 'bend')!, origin: { gap: 25 } } })
60
+ const aside = jointDragOf(asset, 'link', part as never, { position: [part.pose.t[0]! + 30, part.pose.t[1]!, part.pose.t[2]!] }, {})
61
+ assert.deepEqual((aside as { joint: V3Joint }).joint.origin, { align: { x: { mm: 30 } } })
62
+ })
63
+
64
+ test('관계를 끊는 끌기는 거절하고 까닭을 댄다', () => {
65
+ const edge = column({ origin: { align: { x: 'min' } } })
66
+ const part = drawn(edge, 'link')
67
+ assert.deepEqual(jointDragOf(edge, 'link', part as never, { position: [part.pose.t[0]! + 30, part.pose.t[1]!, part.pose.t[2]!] }, {}), { kind: 'refused', reason: 'origin-follows', joint: 'bend' }, '모서리에 맞춘 자리는 그 모서리를 따른다')
68
+ const bend = column({ type: 'revolute', axis: 'Z', lowerLimit: -90, upperLimit: 90 })
69
+ assert.deepEqual(jointDragOf(bend, 'link', drawn(bend, 'link') as never, { rotation: [25, 0, 0] }, {}), { kind: 'refused', reason: 'off-axis', joint: 'bend', axis: 'Z' }, '다른 축으로 돌린 것은 적지 않는다')
70
+ const still = column({})
71
+ assert.deepEqual(jointDragOf(still, 'link', drawn(still, 'link') as never, { rotation: turnedZ(20) }, {}), { kind: 'refused', reason: 'fixed-turn', joint: 'bend' })
72
+ })
73
+
74
+ test('관절이 없는 부품은 이 길로 오지 않는다 — 제 숫자를 쓰는 길', () => {
75
+ let asset = createV3Asset('Free')
76
+ asset = addV3Box(asset, { id: 'box', size: { x: 10, y: 10, z: 10 }, position: { x: 0, y: 5, z: 0 } })
77
+ assert.equal(jointDragOf(asset, 'box', drawn(asset, 'box') as never, { position: [10, 5, 0] }, {}), null)
78
+ })
79
+
80
+ test('크기 끌기는 관절이 아니라 부품의 치수 — 따라가는 치수는 두고 제 치수만 쓴다 (사용자 09-25: 「R 모드에서 크기가 변하지 않는다」)', async () => {
81
+ const { gizmoChanges, gizmoReachOf } = await import('../client/modeller/v3-gizmo.ts')
82
+ let asset = column({ type: 'revolute', axis: 'Z', lowerLimit: -90, upperLimit: 90 })
83
+ /* A square section: the link's depth follows its width. */
84
+ asset = applyV3Authoring(asset, { kind: 'link-dimension', part: 'link', axis: 'z', source: { of: 'part', part: 'link', axis: 'x' } })
85
+ const part = drawn(asset, 'link')
86
+ const scaled = { position: [part.pose.t[0]!, part.pose.t[1]! + 60, part.pose.t[2]!] as [number, number, number], scale: [1, 1.4, 1] as [number, number, number] }
87
+ assert.equal(jointDragOf(asset, 'link', part as never, scaled, {}), null, '관절 자리로 받지 않는다')
88
+ const reach = gizmoReachOf(asset as never, 'link')
89
+ assert.deepEqual(reach.ownSizes, ['link.size.x', 'link.size.y', null], '깊이는 너비를 따른다')
90
+ const sizes = Object.fromEntries(asset.document.model.inputs.map(i => [i.id, asset.designInputs[i.id]!]))
91
+ assert.deepEqual(gizmoChanges('link', reach, { scale: scaled.scale }, sizes), [{ kind: 'set-design', input: 'link.size.y', unit: 'mm', value: 420 }])
92
+ assert.deepEqual(gizmoChanges('link', reach, { scale: [1, 1, 1.5] }, sizes), [], '따라가는 깊이만 끌면 아무것도 쓰지 않는다 — 편집기가 까닭을 말한다')
93
+ })
@@ -12,6 +12,7 @@ import { addV3Box, applyV3Authoring, compileV3Asset, convertV2ToV3, createV3Asse
12
12
  import type { V3Asset } from '@hatiolab/figure-model'
13
13
 
14
14
  import { JOINT_FAN, jointFanOf } from '../client/modeller/v3-joint-fan.ts'
15
+ import { moving } from './joints.ts'
15
16
 
16
17
  const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
17
18
 
@@ -19,16 +20,22 @@ function arm(): V3Asset {
19
20
  let asset = createV3Asset('Probe')
20
21
  asset = addV3Box(asset, { id: 'base', size: { x: 200, y: 100, z: 200 }, position: { x: 0, y: 50, z: 0 } })
21
22
  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
+ /* On the base's top, level with its left edge: it turns about that edge, at x = -100. */
24
+ asset = applyV3Authoring(asset, { kind: 'attach', part: 'arm', face: 'bottom', to: { part: 'base', face: 'top' }, align: { x: 'min' } })
25
+ return moving(asset, 'arm', { kind: 'turn', axis: 'y', id: 'arm.turn', min: -45, max: 90 })
23
26
  }
27
+ const PIVOT = [-100, 100, 0]
24
28
 
25
29
  /** 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
30
  const world = (fan: { pose: { t: number[]; r: number[][] } }, p: { x: number; y: number }) =>
27
31
  [0, 1, 2].map(i => fan.pose.t[i]! + fan.pose.r[i]![0]! * p.x + fan.pose.r[i]![2]! * p.y)
28
32
 
33
+ /** Which way a point lies from the joint, in the plane it turns in. */
29
34
  const direction = (v: number[]) => {
30
- const l = Math.hypot(v[0]!, v[2]!)
31
- return [v[0]! / l, v[2]! / l]
35
+ const x = v[0]! - PIVOT[0]!,
36
+ z = v[2]! - PIVOT[2]!
37
+ const l = Math.hypot(x, z)
38
+ return [x / l, z / l]
32
39
  }
33
40
 
34
41
  test('a part that turns gets a fan from the lowest value to the highest, where the part really goes', () => {
@@ -61,18 +68,21 @@ test('a fan is as large as what turns, and never smaller than 60 mm', () => {
61
68
  })
62
69
 
63
70
  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)
71
+ /* The base does not turn; it shows the joint that sits on it, the arm's -- one joint, one fan (the elbow rule). */
72
+ const onBase = jointFanOf(arm(), 'base') as unknown as { shape: { profile: unknown[] } }
73
+ const ofArm = jointFanOf(arm(), 'arm') as unknown as { shape: { profile: unknown[] } }
74
+ assert.deepEqual(onBase?.shape.profile, ofArm.shape.profile)
65
75
  assert.equal(jointFanOf(arm(), ''), null)
66
76
  let slider = createV3Asset('Probe')
67
77
  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 } })
78
+ slider = moving(slider, 'block', { kind: 'slide', axis: 'x', id: 'shift', min: 0, max: 60 })
69
79
  assert.equal(jointFanOf(slider, 'block'), null)
70
80
  })
71
81
 
72
82
  test('the editor draws it for the selected part, with the pose in hand; the canvas never lets it be picked', () => {
73
83
  const editor = read('../client/modeller/v3-editor.ts')
74
84
  assert.ok(editor.includes('.fan=${this.fanOf(asset as V3Asset | undefined)}'))
75
- assert.ok(editor.includes('return jointFanOf(asset, this.selected, this.live.values)'))
85
+ assert.ok(editor.includes('return jointFanOf(asset, this.selected, { ...this.currentInputs(), ...this.live.values })'), 'the fan reads the pose the session holds')
76
86
  const canvas = read('../client/modeller/v3-canvas.ts')
77
87
  assert.ok(canvas.includes('{ ...fan, capturable: false, locked: true }'))
78
88
  })
@@ -32,6 +32,19 @@ test('칸에 치는 키는 칸의 것 — 이름 칸의 Backspace 는 글자를
32
32
 
33
33
  test('편집기가 창의 키를 듣고, 떼어지면 놓는다', () => {
34
34
  const editor = readFileSync(fileURLToPath(new URL('../client/modeller/v3-editor.ts', import.meta.url)), 'utf8')
35
- assert.ok(editor.includes("window.addEventListener('keydown', this.onKey)") && editor.includes("window.removeEventListener('keydown', this.onKey)"))
36
- assert.ok(editor.includes('target: e.composedPath()[0] as HTMLElement'), '그림자 DOM 안의 칸도 칸으로 안다')
35
+ assert.ok(editor.includes("window.addEventListener('keydown', this.onKey, true)") && editor.includes("window.removeEventListener('keydown', this.onKey, true)"))
36
+ })
37
+
38
+ test('W · E · R 은 gizmo 의 모드 — 부품 목록에서 골라 포커스가 캔버스 밖에 있어도', () => {
39
+ assert.equal(keyActionOf({ key: 'w' }, 'arm'), 'translate')
40
+ assert.equal(keyActionOf({ key: 'E' }, 'arm'), 'rotate')
41
+ assert.equal(keyActionOf({ key: 'r' }, 'arm'), 'scale')
42
+ assert.equal(keyActionOf({ key: 'w' }, ''), null, '고른 부품이 없으면 붙일 곳이 없다')
43
+ assert.equal(keyActionOf({ key: 'r', ctrlKey: true }, 'arm'), null, 'Ctrl+R 은 브라우저의 것')
44
+ assert.equal(keyActionOf({ key: 'w', target: { tagName: 'INPUT' } }, 'arm'), null, '칸에 치는 w 는 글자')
45
+ })
46
+
47
+ test('캔버스에서 누른 W · E · R 은 씬이 이미 받았다 — 또 넘기면 gizmo 가 꺼진다', () => {
48
+ for (const key of ['w', 'e', 'r']) assert.equal(keyActionOf({ key, inScene: true }, 'arm'), null, key)
49
+ assert.equal(keyActionOf({ key: 'Delete', inScene: true }, 'arm'), 'remove', '다른 키는 그대로')
37
50
  })