@things-factory/figure-ui 10.1.49 → 10.1.50
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.
- package/client/graphql/v3-store.ts +2 -1
- package/client/modeller/figure-history-panel.ts +9 -1
- package/client/modeller/figure-v3-first-save.ts +98 -0
- package/client/modeller/figure-v3-links.ts +89 -26
- package/client/modeller/figure-v3-parts.ts +1 -0
- package/client/modeller/figure-v3-properties.ts +2 -1
- package/client/modeller/v3-canvas.ts +61 -7
- package/client/modeller/v3-editor.ts +91 -21
- package/client/modeller/v3-first-save.ts +34 -0
- package/client/modeller/v3-gizmo.ts +52 -3
- package/client/modeller/v3-joint-drag.ts +116 -0
- package/client/modeller/v3-joint-fan.ts +24 -11
- package/client/modeller/v3-keys.ts +12 -1
- package/client/modeller/v3-links.ts +72 -5
- package/client/modeller/v3-live.ts +11 -0
- package/client/modeller/v3-product-session.ts +4 -2
- package/client/modeller/v3-refusals.ts +21 -0
- package/client/pages/figure-v3-modeller-page.ts +46 -7
- package/dist-client/graphql/v3-store.js +2 -1
- package/dist-client/graphql/v3-store.js.map +1 -1
- package/dist-client/modeller/figure-history-panel.js +9 -1
- package/dist-client/modeller/figure-history-panel.js.map +1 -1
- package/dist-client/modeller/figure-v3-first-save.d.ts +20 -0
- package/dist-client/modeller/figure-v3-first-save.js +118 -0
- package/dist-client/modeller/figure-v3-first-save.js.map +1 -0
- package/dist-client/modeller/figure-v3-links.d.ts +5 -0
- package/dist-client/modeller/figure-v3-links.js +81 -23
- package/dist-client/modeller/figure-v3-links.js.map +1 -1
- package/dist-client/modeller/figure-v3-parts.js +1 -0
- package/dist-client/modeller/figure-v3-parts.js.map +1 -1
- package/dist-client/modeller/figure-v3-properties.js +2 -1
- package/dist-client/modeller/figure-v3-properties.js.map +1 -1
- package/dist-client/modeller/v3-canvas.d.ts +11 -0
- package/dist-client/modeller/v3-canvas.js +61 -7
- package/dist-client/modeller/v3-canvas.js.map +1 -1
- package/dist-client/modeller/v3-editor.d.ts +13 -1
- package/dist-client/modeller/v3-editor.js +91 -21
- package/dist-client/modeller/v3-editor.js.map +1 -1
- package/dist-client/modeller/v3-first-save.d.ts +17 -0
- package/dist-client/modeller/v3-first-save.js +27 -0
- package/dist-client/modeller/v3-first-save.js.map +1 -0
- package/dist-client/modeller/v3-gizmo.d.ts +18 -0
- package/dist-client/modeller/v3-gizmo.js +45 -2
- package/dist-client/modeller/v3-gizmo.js.map +1 -1
- package/dist-client/modeller/v3-joint-drag.d.ts +19 -0
- package/dist-client/modeller/v3-joint-drag.js +97 -0
- package/dist-client/modeller/v3-joint-drag.js.map +1 -0
- package/dist-client/modeller/v3-joint-fan.js +24 -11
- package/dist-client/modeller/v3-joint-fan.js.map +1 -1
- package/dist-client/modeller/v3-keys.d.ts +9 -1
- package/dist-client/modeller/v3-keys.js +3 -0
- package/dist-client/modeller/v3-keys.js.map +1 -1
- package/dist-client/modeller/v3-links.d.ts +45 -2
- package/dist-client/modeller/v3-links.js +52 -3
- package/dist-client/modeller/v3-links.js.map +1 -1
- package/dist-client/modeller/v3-live.d.ts +6 -0
- package/dist-client/modeller/v3-live.js +10 -0
- package/dist-client/modeller/v3-live.js.map +1 -1
- package/dist-client/modeller/v3-product-session.d.ts +4 -1
- package/dist-client/modeller/v3-product-session.js +2 -2
- package/dist-client/modeller/v3-product-session.js.map +1 -1
- package/dist-client/modeller/v3-refusals.d.ts +4 -0
- package/dist-client/modeller/v3-refusals.js +21 -0
- package/dist-client/modeller/v3-refusals.js.map +1 -0
- package/dist-client/pages/figure-v3-modeller-page.d.ts +4 -0
- package/dist-client/pages/figure-v3-modeller-page.js +45 -7
- package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +7 -5
- package/test/browser/build.mjs +62 -0
- package/test/browser/modeller-page.js +198 -0
- package/test/browser/modeller.check.mjs +334 -0
- package/test/browser/serve.mjs +43 -0
- package/test/joints.ts +33 -0
- package/test/v3-clips.test.ts +3 -2
- package/test/v3-controls.test.ts +3 -2
- package/test/v3-fields.test.ts +3 -2
- package/test/v3-first-save.test.ts +75 -0
- package/test/v3-gizmo.test.ts +38 -5
- package/test/v3-joint-drag.test.ts +93 -0
- package/test/v3-joint-fan.test.ts +16 -6
- package/test/v3-keys.test.ts +15 -2
- package/test/v3-links.test.ts +62 -14
- package/test/v3-live.test.ts +23 -4
- package/test/v3-maturity.test.ts +2 -6
- package/test/v3-refusals.test.ts +28 -0
- package/translations/en.json +33 -5
- package/translations/ja.json +32 -4
- package/translations/ko.json +32 -4
- package/translations/ms.json +32 -4
- package/translations/zh.json +32 -4
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
|
+
}
|
package/test/v3-clips.test.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
24
|
+
return moving(asset, 'arm', { kind: 'turn', axis: 'y', id: 'spin', min: -180, max: 180, start: 0 })
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
test('클립마다 길이와 트랙을, 트랙마다 키를 초로 읽는다', () => {
|
package/test/v3-controls.test.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
25
|
+
return moving(asset, 'arm', { kind: 'turn', axis: 'y', id: 'spin', min: -90, max: 90, start: 0 })
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
test('조작값마다 이름 · 단위 · 범위 · 지금 값 · 시작 값 · 이동 시간을 읽는다', () => {
|
package/test/v3-fields.test.ts
CHANGED
|
@@ -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("
|
|
105
|
-
assert.ok(moved.includes(
|
|
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,75 @@
|
|
|
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
|
+
})
|
package/test/v3-gizmo.test.ts
CHANGED
|
@@ -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
|
-
|
|
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("
|
|
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
|
|
106
|
-
assert.equal(gizmoReachOf(
|
|
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
|
-
|
|
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
|
|
31
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
})
|
package/test/v3-keys.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
})
|
package/test/v3-links.test.ts
CHANGED
|
@@ -53,30 +53,23 @@ test('붙이기 — 누구에게 붙었나, 붙일 수 있는 모양인가', ()
|
|
|
53
53
|
test('움직임 — 미끄러짐 · 돌기, 축, 무엇이 모느냐를 읽는다', () => {
|
|
54
54
|
const asset = pair()
|
|
55
55
|
assert.equal(motionOf(asset, 'block'), undefined)
|
|
56
|
-
const sliding =
|
|
57
|
-
kind: 'add-motion',
|
|
58
|
-
part: 'block',
|
|
59
|
-
motion: { kind: 'slide', axis: 'x' },
|
|
60
|
-
state: { id: 'travel', unit: 'mm', min: 0, max: 300, start: 0 }
|
|
61
|
-
})
|
|
56
|
+
const sliding = moving(asset, 'block', { kind: 'slide', axis: 'x', id: 'travel', min: 0, max: 300, start: 0 })
|
|
62
57
|
const motion = motionOf(sliding, 'block')!
|
|
63
58
|
assert.equal(motion.kind, 'slide')
|
|
64
59
|
assert.equal(motion.axis, 'x')
|
|
65
60
|
assert.equal(motion.control?.id, 'travel')
|
|
66
61
|
assert.equal(motion.control?.max, 300)
|
|
67
|
-
const turning =
|
|
68
|
-
kind: 'add-motion',
|
|
69
|
-
part: 'block',
|
|
70
|
-
motion: { kind: 'turn', axis: 'y' },
|
|
71
|
-
state: { id: 'swing', unit: 'deg', min: -90, max: 90, start: 0 }
|
|
72
|
-
})
|
|
62
|
+
const turning = moving(asset, 'block', { kind: 'turn', axis: 'y', id: 'swing', min: -90, max: 90, start: 0 })
|
|
73
63
|
assert.deepEqual([motionOf(turning, 'block')!.kind, motionOf(turning, 'block')!.axis], ['turn', 'y'])
|
|
74
|
-
|
|
64
|
+
const joint = sliding.joints!.find(j => j.body1.part === 'block')!
|
|
65
|
+
const still = applyV3Authoring(sliding, { kind: 'set-joint', joint: { id: joint.id, type: 'fixed', body0: joint.body0, body1: joint.body1 } })
|
|
66
|
+
assert.equal(motionOf(still, 'block'), undefined, 'a fixed joint does not move')
|
|
75
67
|
})
|
|
76
68
|
|
|
77
69
|
import { readFileSync } from 'node:fs'
|
|
78
70
|
import { fileURLToPath } from 'node:url'
|
|
79
71
|
import { choiceOf } from '../client/modeller/v3-links.ts'
|
|
72
|
+
import { moving } from './joints.ts'
|
|
80
73
|
|
|
81
74
|
const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
|
|
82
75
|
|
|
@@ -235,7 +228,7 @@ test('놓일 수 있는 크기 — 남은 만큼 채우던 부품을 비례로
|
|
|
235
228
|
test('관절이 있는 도형에는 한 비율로 키우기를 권하고, 선언하면 규칙으로 보인다(수석 아키텍트 판정 09-24)', () => {
|
|
236
229
|
let asset = sizedPair()
|
|
237
230
|
assert.equal(turnsAboutAJoint(asset), false)
|
|
238
|
-
asset =
|
|
231
|
+
asset = moving(asset, 'flange', { kind: 'turn', axis: 'y', id: 'flange.turn', min: 0, max: 90 })
|
|
239
232
|
assert.equal(turnsAboutAJoint(asset), true)
|
|
240
233
|
const editor = read('../client/modeller/v3-editor.ts')
|
|
241
234
|
const rule = editor.slice(editor.indexOf(' private renderSizingRule('), editor.indexOf(' /** Choose a part,'))
|
|
@@ -244,3 +237,58 @@ test('관절이 있는 도형에는 한 비율로 키우기를 권하고, 선언
|
|
|
244
237
|
assert.ok(rule.includes("this.linkCommand({ kind: 'scale-uniformly' })"), '누르면 선언한다 — 사람이 정한다')
|
|
245
238
|
})
|
|
246
239
|
|
|
240
|
+
|
|
241
|
+
test('붙일 때 면 위 어디에 — 그 면을 가로지르는 두 축만, 바닥에는 모서리가 없다 (문을 틀 모서리에 달기, 09-25)', async () => {
|
|
242
|
+
const { alignAxesOf, alignOf } = await import('../client/modeller/v3-links.ts')
|
|
243
|
+
assert.deepEqual(alignAxesOf('right'), [{ axis: 'y', edges: true }, { axis: 'z', edges: true }])
|
|
244
|
+
assert.deepEqual(alignAxesOf('plane'), [{ axis: 'x', edges: false }, { axis: 'z', edges: false }])
|
|
245
|
+
/* A door on the jamb's right face: bottoms level, fronts level. The x choice is along the face and is not sent. */
|
|
246
|
+
assert.deepEqual(alignOf('right', { x: 'max', y: 'min', z: 'max' }, {}), { y: 'min', z: 'max' })
|
|
247
|
+
assert.deepEqual(alignOf('top', { x: 'mm', z: 'centre' }, { x: 120 }), { x: { mm: 120 } })
|
|
248
|
+
assert.equal(alignOf('plane', { x: 'min' }, {}), undefined, '바닥에는 맞출 모서리가 없다')
|
|
249
|
+
assert.equal(alignOf('top', {}, {}), undefined)
|
|
250
|
+
|
|
251
|
+
/* What the form sends is what hangs the part on the edge: the door's pivot is on the jamb's front edge line. */
|
|
252
|
+
let asset = createV3Asset('Door')
|
|
253
|
+
asset = addV3Box(asset, { id: 'jamb', size: { x: 100, y: 2100, z: 150 }, position: { x: 0, y: 1050, z: 0 } })
|
|
254
|
+
asset = addV3Box(asset, { id: 'leaf', size: { x: 900, y: 2000, z: 40 }, position: { x: 600, y: 1000, z: 0 } })
|
|
255
|
+
asset = applyV3Authoring(asset, { kind: 'attach', part: 'jamb', face: 'bottom', to: { plane: 'mounting-plane' } })
|
|
256
|
+
asset = applyV3Authoring(asset, { kind: 'attach', part: 'leaf', face: 'left', to: { part: 'jamb', face: 'right' }, align: alignOf('right', { y: 'min', z: 'max' }, {}) })
|
|
257
|
+
const { compileV3Asset } = await import('@hatiolab/figure-model')
|
|
258
|
+
const leaf = compileV3Asset(asset).evaluate().geometry.find(g => g.id === 'leaf')!
|
|
259
|
+
assert.deepEqual(leaf.pose.t.map(Math.round), [500, 1000, 55], '문짝 아래가 문틀 아래에, 앞이 문틀 앞에')
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
test('따라 움직이는 관절 — 같은 방식으로 움직이고 제 조작값이 있는 관절만 고르고, 기록에는 배율과 더하기만 (집게 두 손가락, 09-25)', async () => {
|
|
263
|
+
const { leadersFor, motionJoint } = await import('../client/modeller/v3-links.ts')
|
|
264
|
+
const { compileV3Asset } = await import('@hatiolab/figure-model')
|
|
265
|
+
let asset = createV3Asset('Gripper')
|
|
266
|
+
asset = addV3Box(asset, { id: 'palm', size: { x: 240, y: 70, z: 160 }, position: { x: 0, y: 35, z: 0 } })
|
|
267
|
+
for (const id of ['left', 'right']) asset = addV3Box(asset, { id, size: { x: 30, y: 140, z: 100 }, position: { x: 0, y: 200, z: 0 } })
|
|
268
|
+
asset = addV3Box(asset, { id: 'wrist', size: { x: 60, y: 60, z: 60 }, position: { x: 0, y: 400, z: 0 } })
|
|
269
|
+
asset = applyV3Authoring(asset, { kind: 'attach', part: 'palm', face: 'bottom', to: { plane: 'mounting-plane' } })
|
|
270
|
+
asset = applyV3Authoring(asset, { kind: 'attach', part: 'left', face: 'bottom', to: { part: 'palm', face: 'top' }, align: { x: 'min' } })
|
|
271
|
+
asset = applyV3Authoring(asset, { kind: 'attach', part: 'right', face: 'bottom', to: { part: 'palm', face: 'top' }, align: { x: 'max' } })
|
|
272
|
+
asset = applyV3Authoring(asset, { kind: 'attach', part: 'wrist', face: 'top', to: { part: 'palm', face: 'bottom' } })
|
|
273
|
+
const form = { kind: 'slide' as const, axis: 'x' as const, id: 'grip', min: 0, max: 60, follow: '', multiplier: 1, offset: 0 }
|
|
274
|
+
const jointOf = (part: string) => asset.joints!.find(j => j.body1.part === part)!
|
|
275
|
+
asset = applyV3Authoring(asset, { kind: 'set-joint', joint: motionJoint(jointOf('left'), form, 'left') })
|
|
276
|
+
asset = applyV3Authoring(asset, { kind: 'set-joint', joint: motionJoint(jointOf('wrist'), { ...form, kind: 'turn', axis: 'y', id: 'twist', min: -90, max: 90 }, 'wrist') })
|
|
277
|
+
|
|
278
|
+
assert.deepEqual(leadersFor(asset, 'right', 'slide').map(j => j.id), ['left'], '미끄러지는 관절은 미끄러지는 관절을 따른다')
|
|
279
|
+
assert.deepEqual(leadersFor(asset, 'right', 'turn').map(j => j.id), ['wrist'])
|
|
280
|
+
assert.deepEqual(leadersFor(asset, 'left', 'slide'), [], '제 관절은 고르지 않는다')
|
|
281
|
+
|
|
282
|
+
const follower = motionJoint(jointOf('right'), { ...form, id: 'ignored', follow: 'left', multiplier: -1 }, 'right')
|
|
283
|
+
assert.deepEqual(follower.mimic, { joint: 'left', multiplier: -1 })
|
|
284
|
+
assert.equal(follower.state, undefined, '따라가는 관절은 제 조작값이 없다')
|
|
285
|
+
assert.equal(follower.lowerLimit, undefined)
|
|
286
|
+
asset = applyV3Authoring(asset, { kind: 'set-joint', joint: follower })
|
|
287
|
+
assert.deepEqual(leadersFor(asset, 'wrist', 'slide').map(j => j.id), ['left'], '따라가는 관절은 앞장서지 않는다')
|
|
288
|
+
|
|
289
|
+
const x = (id: string, grip: number) => Math.round(compileV3Asset(asset).evaluate({ grip }).geometry.find(g => g.id === id)!.pose.t[0]!)
|
|
290
|
+
assert.equal(x('left', 40) - x('left', 0), 40)
|
|
291
|
+
assert.equal(x('right', 40) - x('right', 0), -40, '오른손은 거울로 따라 닫힌다')
|
|
292
|
+
const { motionOf } = await import('../client/modeller/v3-links.ts')
|
|
293
|
+
assert.deepEqual(motionOf(asset, 'right')?.follows, { joint: 'left', multiplier: -1 }, '화면은 무엇을 따르는지 읽는다')
|
|
294
|
+
})
|
package/test/v3-live.test.ts
CHANGED
|
@@ -7,16 +7,17 @@ import assert from 'node:assert/strict'
|
|
|
7
7
|
import { readFileSync } from 'node:fs'
|
|
8
8
|
import { fileURLToPath } from 'node:url'
|
|
9
9
|
|
|
10
|
-
import { addV3Box, applyV3Authoring, createV3Asset } from '@hatiolab/figure-model'
|
|
10
|
+
import { addV3Box, applyV3Authoring, compileV3Asset, createV3Asset } from '@hatiolab/figure-model'
|
|
11
11
|
|
|
12
|
-
import { liveOverridesOf, livePartsOf, NOTHING_LIVE, scrubbed, scrubStep } from '../client/modeller/v3-live.ts'
|
|
12
|
+
import { liveOverridesOf, liveOverSession, livePartsOf, NOTHING_LIVE, scrubbed, scrubStep } from '../client/modeller/v3-live.ts'
|
|
13
|
+
import { moving } from './joints.ts'
|
|
13
14
|
|
|
14
15
|
const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
|
|
15
16
|
|
|
16
17
|
const slider = () => {
|
|
17
18
|
let asset = createV3Asset('Slider')
|
|
18
19
|
asset = addV3Box(asset, { id: 'block', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
|
|
19
|
-
return
|
|
20
|
+
return moving(asset, 'block', { kind: 'slide', axis: 'x', id: 'shift', min: 0, max: 60, start: 0 })
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
test('끄는 값으로 그 자세를 바로 그린다 — 도형은 그대로', () => {
|
|
@@ -73,7 +74,7 @@ test('속성창 숫자 칸은 치는 동안 알리고, 이름표를 끌 수 있
|
|
|
73
74
|
assert.ok(editor.includes('@input=${(e: Event) => this.typing(field as never, e)}'))
|
|
74
75
|
assert.ok(editor.includes('@pointerdown=${(e: PointerEvent) => this.scrub(group.cells[axis] as never, e, unavailable)}'))
|
|
75
76
|
for (const event of ['@v3-state-live=', '@v3-states-live=', '@v3-part-live=']) assert.ok(editor.includes(event), event)
|
|
76
|
-
assert.ok(editor.includes('
|
|
77
|
+
assert.ok(editor.includes('liveOverSession(asset as V3Asset, this.currentInputs(), this.live)'), '캔버스는 세션 위에 끄는 값을 그린다 (행동은 아래 시험)')
|
|
77
78
|
const run = editor.slice(editor.indexOf(' private run(action'), editor.indexOf(' private hold('))
|
|
78
79
|
assert.ok(run.includes('this.live = NOTHING_LIVE'), '기록하거나 거절되면 끄는 값을 버린다')
|
|
79
80
|
const material = read('../client/modeller/figure-v3-material.ts')
|
|
@@ -97,3 +98,21 @@ test('클립 트랙의 머리는 이름 한 줄, 그 아래 반복 방식과 지
|
|
|
97
98
|
assert.ok(head.includes('width: 30px;'), '지우기는 작은 버튼')
|
|
98
99
|
})
|
|
99
100
|
|
|
101
|
+
test('끄는 동안은 세션이 지금 든 자세 위에 그린다 — 다른 관절이 저장된 각도로 돌아가지 않는다', () => {
|
|
102
|
+
/* Two joints: the author turned one with its control; then drags the other. */
|
|
103
|
+
let asset = slider()
|
|
104
|
+
/* An arm on a post of its own, turning about the post's left edge -- so a swing moves it, and the slide does not. */
|
|
105
|
+
asset = addV3Box(asset, { id: 'post', size: { x: 100, y: 100, z: 100 }, position: { x: 400, y: 50, z: 0 } })
|
|
106
|
+
asset = applyV3Authoring(asset, { kind: 'attach', part: 'post', face: 'bottom', to: { plane: 'mounting-plane' }, align: { x: { mm: 400 } } })
|
|
107
|
+
asset = addV3Box(asset, { id: 'arm', size: { x: 100, y: 20, z: 20 }, position: { x: 400, y: 110, z: 0 } })
|
|
108
|
+
asset = applyV3Authoring(asset, { kind: 'attach', part: 'arm', face: 'bottom', to: { part: 'post', face: 'top' }, align: { x: 'min' } })
|
|
109
|
+
asset = moving(asset, 'arm', { kind: 'turn', axis: 'y', id: 'swing', min: -180, max: 180 })
|
|
110
|
+
const current = { ...asset.designInputs, ...asset.stateDefaults, swing: 90 }
|
|
111
|
+
const armAt = (parts: { id: string; pose: { t: number[] } }[]) => parts.find(p => p.id === 'arm')!.pose.t.map(v => Math.round(v))
|
|
112
|
+
const committed = armAt(compileV3Asset({ ...asset, stateDefaults: { ...asset.stateDefaults, swing: 90 } }).evaluate().geometry as never)
|
|
113
|
+
const dragging = armAt(liveOverSession(asset, current, { values: { shift: 30 } }) as never)
|
|
114
|
+
assert.deepEqual(dragging, committed, '사용자 2026-09-24: 「조작을 끝내면 완전히 다른 위치로 된다」 — swing 90 이 끄는 동안에도 남는다')
|
|
115
|
+
const overSaved = armAt(livePartsOf(asset, { values: { shift: 30 } }) as never)
|
|
116
|
+
assert.notDeepEqual(overSaved, committed, '전제: 저장된 기본값 위에 그리면 swing 이 0 으로 돌아간다')
|
|
117
|
+
})
|
|
118
|
+
|
package/test/v3-maturity.test.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
v3ReportOf
|
|
26
26
|
} from '../client/modeller/v3-maturity.ts'
|
|
27
27
|
import { capacityFrom } from '../client/modeller/scene-budget.ts'
|
|
28
|
+
import { moving } from './joints.ts'
|
|
28
29
|
|
|
29
30
|
const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
|
|
30
31
|
const verdict = read('../client/modeller/figure-v3-verdict.ts')
|
|
@@ -109,12 +110,7 @@ test('상태를 따라 움직이는 부품을 센다 — V2 의 「따로 움직
|
|
|
109
110
|
asset = addV3Box(asset, cube('arm', 200))
|
|
110
111
|
assert.equal(v3ReportOf(asset)!.score.budgets.find(b => b.key === 'independent')!.used, 0, '전제: 움직임이 없으면 0')
|
|
111
112
|
|
|
112
|
-
asset =
|
|
113
|
-
kind: 'add-motion',
|
|
114
|
-
part: 'arm',
|
|
115
|
-
motion: { kind: 'turn', axis: 'y' },
|
|
116
|
-
state: { id: 'arm.turn', unit: 'deg', min: 0, max: 90 }
|
|
117
|
-
})
|
|
113
|
+
asset = moving(asset, 'arm', { kind: 'turn', axis: 'y', id: 'arm.turn', min: 0, max: 90 })
|
|
118
114
|
assert.equal(v3ReportOf(asset)!.score.budgets.find(b => b.key === 'independent')!.used, 1, '도는 팔 하나')
|
|
119
115
|
})
|
|
120
116
|
|