@things-factory/figure-ui 10.1.115 → 10.1.117
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/index.ts +6 -1
- package/client/modeller/editor.ts +5 -11
- package/client/modeller/fasten-geometry.ts +1 -1
- package/client/modeller/figure-links.ts +52 -17
- package/client/modeller/joint-axis.ts +36 -0
- package/client/modeller/joint-drag.ts +30 -10
- package/client/modeller/joint-fan.ts +2 -1
- package/client/modeller/links.ts +70 -20
- package/client/modeller/recipes.ts +6 -1
- package/client/pages/figure-list-page.ts +4 -4
- package/client/types.ts +1 -1
- package/client/viewparts/detail-motion.ts +11 -0
- package/client/viewparts/figure-asset-viewer.ts +177 -0
- package/client/viewparts/figure-detail-dock.ts +187 -0
- package/dist-client/graphql/index.d.ts +5 -0
- package/dist-client/graphql/index.js +5 -1
- package/dist-client/graphql/index.js.map +1 -1
- package/dist-client/modeller/clips.d.ts +1 -1
- package/dist-client/modeller/editor.d.ts +3 -2
- package/dist-client/modeller/editor.js +5 -11
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/fasten-geometry.js +1 -1
- package/dist-client/modeller/fasten-geometry.js.map +1 -1
- package/dist-client/modeller/figure-links.d.ts +1 -0
- package/dist-client/modeller/figure-links.js +65 -33
- package/dist-client/modeller/figure-links.js.map +1 -1
- package/dist-client/modeller/joint-axis.d.ts +13 -0
- package/dist-client/modeller/joint-axis.js +31 -0
- package/dist-client/modeller/joint-axis.js.map +1 -0
- package/dist-client/modeller/joint-drag.js +29 -9
- package/dist-client/modeller/joint-drag.js.map +1 -1
- package/dist-client/modeller/joint-fan.js +2 -1
- package/dist-client/modeller/joint-fan.js.map +1 -1
- package/dist-client/modeller/links.d.ts +34 -4
- package/dist-client/modeller/links.js +59 -18
- package/dist-client/modeller/links.js.map +1 -1
- package/dist-client/modeller/recipes.js +5 -1
- package/dist-client/modeller/recipes.js.map +1 -1
- package/dist-client/pages/figure-list-page.d.ts +2 -3
- package/dist-client/pages/figure-list-page.js +4 -4
- package/dist-client/pages/figure-list-page.js.map +1 -1
- package/dist-client/route.d.ts +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/types.d.ts +1 -1
- package/dist-client/types.js.map +1 -1
- package/dist-client/viewparts/detail-motion.d.ts +6 -0
- package/dist-client/viewparts/detail-motion.js +9 -0
- package/dist-client/viewparts/detail-motion.js.map +1 -0
- package/dist-client/viewparts/figure-asset-viewer.d.ts +31 -0
- package/dist-client/viewparts/figure-asset-viewer.js +198 -0
- package/dist-client/viewparts/figure-asset-viewer.js.map +1 -0
- package/dist-client/viewparts/figure-detail-dock.d.ts +36 -0
- package/dist-client/viewparts/figure-detail-dock.js +224 -0
- package/dist-client/viewparts/figure-detail-dock.js.map +1 -0
- package/package.json +6 -6
- package/test/browser/detail-page.js +34 -0
- package/test/browser/serve-detail.mjs +10 -0
- package/test/command-preview.test.ts +2 -2
- package/test/detail-motion.test.ts +26 -0
- package/test/joint-frames.test.ts +293 -0
- package/test/links.test.ts +20 -1
- package/test/modeller-route.test.ts +4 -2
- package/test/verdict.test.ts +3 -1
- package/translations/en.json +20 -2
- package/translations/ja.json +20 -2
- package/translations/ko.json +20 -2
- package/translations/ms.json +20 -2
- package/translations/zh.json +20 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/figure-ui",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.117",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"@things-factory:registry": "https://registry.npmjs.org"
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
"clean": "npm run clean:server && npm run clean:client"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@hatiolab/figure-model": "^0.
|
|
31
|
-
"@hatiolab/things-scene": "^10.1.
|
|
30
|
+
"@hatiolab/figure-model": "^0.3.3",
|
|
31
|
+
"@hatiolab/things-scene": "^10.1.72",
|
|
32
32
|
"@material/web": "^2.0.0",
|
|
33
33
|
"@operato/data-grist": "^10.0.0",
|
|
34
34
|
"@operato/graphql": "^10.0.0",
|
|
@@ -40,9 +40,9 @@
|
|
|
40
40
|
"@operato/shell": "^10.0.0",
|
|
41
41
|
"@operato/styles": "^10.0.0",
|
|
42
42
|
"@operato/utils": "^10.0.0",
|
|
43
|
-
"@things-factory/ai-assistant": "^10.1.
|
|
44
|
-
"@things-factory/figure-service": "^10.1.
|
|
43
|
+
"@things-factory/ai-assistant": "^10.1.116",
|
|
44
|
+
"@things-factory/figure-service": "^10.1.117",
|
|
45
45
|
"three": "^0.185.1"
|
|
46
46
|
},
|
|
47
|
-
"gitHead": "
|
|
47
|
+
"gitHead": "cab2426c1c6e63b53a0a2087eef28c9c55df71dd"
|
|
48
48
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/* Isolated product detail: fixtures are in memory, never saved to the running app. */
|
|
2
|
+
import 'figure-ui-client/viewparts/figure-detail-dock.js'
|
|
3
|
+
import { i18next } from '@operato/i18n'
|
|
4
|
+
import { addFigureBox, createFigureAsset, editFigurePart, applyFigureAuthoring, serializeFigureAsset } from '@hatiolab/figure-model'
|
|
5
|
+
|
|
6
|
+
async function start() {
|
|
7
|
+
i18next.addResourceBundle('ko', 'translations', await fetch('/ko.json').then(r => r.json()), true, true)
|
|
8
|
+
await i18next.changeLanguage('ko')
|
|
9
|
+
let asset = addFigureBox(createFigureAsset('클립 시험'), { id: 'block', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
|
|
10
|
+
asset = applyFigureAuthoring(asset, { kind: 'set-joint', joint: {
|
|
11
|
+
id: 'block', type: 'prismatic', body0: { plane: 'mounting-plane' }, body1: { part: 'block', face: 'bottom' }, axis: 'X', lowerLimit: 0, upperLimit: 200, state: { id: 'shift', start: 0 }
|
|
12
|
+
} })
|
|
13
|
+
asset = editFigurePart(asset, { kind: 'set-driver', state: 'shift', clip: 'cycle', duration: 4, keys: [{ seconds: 0, value: 0 }, { seconds: 2, value: 200 }, { seconds: 4, value: 0 }] })
|
|
14
|
+
const source = serializeFigureAsset(asset)
|
|
15
|
+
const dock = document.createElement('figure-detail-dock')
|
|
16
|
+
let fail = true
|
|
17
|
+
dock.load = async id => {
|
|
18
|
+
if (id === 'retry' && fail) { fail = false; throw Error('test connection failure') }
|
|
19
|
+
if (id === 'slow') await new Promise(done => setTimeout(done, 1200))
|
|
20
|
+
return { saved: { id, name: id === 'slow' ? '늦은 이전 모델' : '클립 시험', type: 'DETAIL_TEST', state: 'draft', source }, released: id === 'invalid' ? { version: 1, source: 'broken json' } : undefined }
|
|
21
|
+
}
|
|
22
|
+
const controls = document.createElement('nav')
|
|
23
|
+
for (const [label, id] of [['클립 모델 열기','motion'],['연결 오류 시험','retry'],['깨진 발행판 시험','invalid'],['느린 모델 열기','slow']]) {
|
|
24
|
+
const button = document.createElement('button'); button.textContent = label
|
|
25
|
+
button.onclick = () => void dock.show(id); controls.append(button)
|
|
26
|
+
}
|
|
27
|
+
document.body.append(controls,dock)
|
|
28
|
+
await dock.show('motion')
|
|
29
|
+
const original = document.createElement('output'); original.setAttribute('aria-label','원본 보존')
|
|
30
|
+
const check = document.createElement('button'); check.textContent='원본 확인'
|
|
31
|
+
check.onclick=()=>original.textContent=serializeFigureAsset(asset)===source?'원본 그대로':'원본 변경됨'
|
|
32
|
+
controls.append(check,original)
|
|
33
|
+
}
|
|
34
|
+
start()
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createServer } from 'node:http'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { dirname, resolve } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { buildPage } from './build.mjs'
|
|
6
|
+
const here=dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
const bundle=await buildPage('detail-page.js')
|
|
8
|
+
const html=`<!doctype html><html><head><meta charset="utf-8"><title>Figure 상세 검증</title><link rel="stylesheet" href="/theme.css"><style>body{margin:0;font-family:sans-serif;display:flex;height:100vh}nav{flex:1;padding:24px;display:flex;flex-direction:column;gap:12px}nav button{padding:12px}figure-detail-dock{position:static!important;height:100%!important}output{display:block}</style></head><body><script type="module" src="/page.js"></script></body></html>`
|
|
9
|
+
const routes={ '/page.js':[bundle,'text/javascript'], '/ko.json':[resolve(here,'../../translations/ko.json'),'application/json'], '/theme.css':[resolve(here,'../../client/harness-theme/light.css'),'text/css'] }
|
|
10
|
+
createServer((req,res)=>{const match=routes[req.url];res.writeHead(200,{'Content-Type':match?.[1]??'text/html; charset=utf-8'});res.end(match?readFileSync(match[0]):html)}).listen(3394,'127.0.0.1',()=>console.log('Detail fixture: http://localhost:3394'))
|
|
@@ -104,7 +104,7 @@ test('the details fold, and what they hold that is not the default stays said --
|
|
|
104
104
|
/* ADR-0097 §5.5 (Q2): the list stays flat; a part says what it is fastened to, beside its name. */
|
|
105
105
|
test('the part list names what each part is fastened to, without reordering or nesting it', () => {
|
|
106
106
|
const editor = read('../client/modeller/editor.ts')
|
|
107
|
-
assert.ok(editor.includes("'plane' in j.body0 ? i18next.t('figure.label.mounting-plane') : `${partLabelOf(j.body0.part)} · ${i18next.t(
|
|
107
|
+
assert.ok(editor.includes("'plane' in j.body0 ? i18next.t('figure.label.mounting-plane') : `${partLabelOf(j.body0.part)} · ${i18next.t(endPlaceKeyOf(j.body0))}`"))
|
|
108
108
|
assert.ok(editor.includes('...(fastened.has(id) ? { fastenedTo: fastened.get(id)! } : {})'))
|
|
109
109
|
const parts = read('../client/modeller/figure-parts.ts')
|
|
110
110
|
assert.ok(parts.includes('<small fastened-to title=${i18next.t(\'figure.label.fastened-to\')}>${part.fastenedTo}</small>'))
|
|
@@ -122,7 +122,7 @@ test('detaching says what it would do: stays, what stays fastened to it, the cli
|
|
|
122
122
|
/* Staying where it is is as expected: nothing is said then (user 2026-10-01); a move, a child kept, a clip lost are. */
|
|
123
123
|
assert.ok(editor.includes(': /* It stays where it is: as expected, so nothing is said (user 2026-10-01). */ []'), 'nothing said when it stays')
|
|
124
124
|
const links = read('../client/modeller/figure-links.ts')
|
|
125
|
-
assert.equal(links.match(/\$\{this\.renderDetachWords\(\)\}/g)?.length,
|
|
125
|
+
assert.equal(links.match(/\$\{this\.renderDetachWords\(\)\}/g)?.length, 3, 'beside every button that detaches (a part at an origin has its own)')
|
|
126
126
|
for (const lang of ['en', 'ja', 'ko', 'ms', 'zh']) {
|
|
127
127
|
const t = JSON.parse(read(`../translations/${lang}.json`))
|
|
128
128
|
for (const key of ['detach-moves', 'detach-keeps-children', 'detach-clips', 'detach-refused']) assert.ok(t[`figure.text.${key}`], `${lang} ${key}`)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { addFigureBox, createFigureAsset, editFigurePart, serializeFigureAsset } from '@hatiolab/figure-model'
|
|
4
|
+
import { moving } from './joints.ts'
|
|
5
|
+
import { clipForControl, parametersForClip } from '../client/viewparts/detail-motion.ts'
|
|
6
|
+
|
|
7
|
+
function machine() {
|
|
8
|
+
let asset = addFigureBox(createFigureAsset('Motion probe'), { id: 'block', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
|
|
9
|
+
asset = moving(asset, 'block', { kind: 'slide', axis: 'x', id: 'shift', min: 0, max: 200, start: 0 })
|
|
10
|
+
return editFigurePart(asset, { kind: 'set-driver', state: 'shift', clip: 'cycle', duration: 4, keys: [{ seconds: 0, value: 0 }, { seconds: 2, value: 200 }, { seconds: 4, value: 0 }] })
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
test('playing a clip releases manual values that would mask its drivers, retaining unrelated controls and the source', () => {
|
|
14
|
+
const asset = machine()
|
|
15
|
+
const source = serializeFigureAsset(asset)
|
|
16
|
+
const values = { shift: 75, other: 42 }
|
|
17
|
+
assert.deepEqual(parametersForClip(asset, values, 'cycle'), { other: 42 })
|
|
18
|
+
assert.deepEqual(values, { shift: 75, other: 42 })
|
|
19
|
+
assert.equal(serializeFigureAsset(asset), source)
|
|
20
|
+
assert.equal(clipForControl(asset, 'shift'), 'cycle')
|
|
21
|
+
assert.equal(clipForControl(asset, 'other'), undefined)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('choosing a different clip cannot clear another clip’s manual control', () => {
|
|
25
|
+
assert.deepEqual(parametersForClip(machine(), { shift: 75 }, 'other'), { shift: 75 })
|
|
26
|
+
})
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* **관절 틀 (ADR-0107 결정 1·4) 을 화면이 읽고 그대로 돌려 쓴다** — 어느 방향이든 되는 축, 부품 기준점에 놓인 관절 끝,
|
|
3
|
+
* `origin.pivot` · `offset` · `rotate`. 움직임 폼 · 붙이기 폼 · 기즈모 끌기 · 움직임 없애기가 이 값들을 바꾸지 않고, 저장하고
|
|
4
|
+
* 다시 열어도 그대로다. 그 틀이 없는 커널(0.3.2)에서는 옛 모양만 같은 길로 본다.
|
|
5
|
+
*/
|
|
6
|
+
import { test } from 'node:test'
|
|
7
|
+
import assert from 'node:assert/strict'
|
|
8
|
+
|
|
9
|
+
import { addFigureBox, applyFigureAuthoring, compileFigureAsset, createFigureAsset, parseFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
|
|
10
|
+
import type { FigureAsset, FigureAuthoringAction, FigureJoint } from '@hatiolab/figure-model'
|
|
11
|
+
|
|
12
|
+
import { jointAxisUnit, jointAxisWord } from '../client/modeller/joint-axis.ts'
|
|
13
|
+
import { alignOf, endPlaceKeyOf, fasteningOf, fixedJointOf, motionFormOf, motionFormRefusalOf, motionJoint, motionOf, rowAxesOf, rowBlockOf } from '../client/modeller/links.ts'
|
|
14
|
+
import { jointDragOf } from '../client/modeller/joint-drag.ts'
|
|
15
|
+
import { jointFanOf } from '../client/modeller/joint-fan.ts'
|
|
16
|
+
|
|
17
|
+
type Dynamic = any
|
|
18
|
+
const set = (asset: FigureAsset, joint: Dynamic) => applyFigureAuthoring(asset, { kind: 'set-joint', joint } as FigureAuthoringAction)
|
|
19
|
+
const box = (asset: FigureAsset, id: string, x: number, y: number, z: number, at = 0) => addFigureBox(asset, { id, size: { x, y, z }, position: { x: 0, y: at, z: 0 } })
|
|
20
|
+
const drawn = (asset: FigureAsset, id: string, state: Record<string, number> = {}) =>
|
|
21
|
+
compileFigureAsset(asset).evaluate(state).geometry.find(g => g.id === id) as never as { id: string; pose: { t: number[]; r: number[][] } }
|
|
22
|
+
|
|
23
|
+
/** Whether this kernel knows the joint frames: an axis that is a vector is refused by one that does not. */
|
|
24
|
+
const FRAMES = (() => {
|
|
25
|
+
try {
|
|
26
|
+
let a = box(createFigureAsset('probe'), 'p', 10, 10, 10, 5)
|
|
27
|
+
a = set(a, { id: 'p', type: 'revolute', body0: { plane: 'mounting-plane' }, body1: { part: 'p', face: 'bottom' }, axis: { x: 0, y: 1, z: 0 } })
|
|
28
|
+
return !!a.joints?.some(j => typeof j.axis === 'object')
|
|
29
|
+
} catch {
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
})()
|
|
33
|
+
const framesOnly = { skip: FRAMES ? false : 'the kernel has no ADR-0107 joint frames' }
|
|
34
|
+
|
|
35
|
+
const SUMMED = { sum: [{ from: { of: 'part', part: 'link', axis: 'y' }, times: 0.5 }], plus: 10 }
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A base on the floor; a link turning on its top about (1, 1, 0), by the middle of its bottom, turned and moved; a tool at
|
|
39
|
+
* the link's origin, its frame moved by a summed offset and turned; a cap at the tool's origin by its centre, held.
|
|
40
|
+
*/
|
|
41
|
+
function framed(): FigureAsset {
|
|
42
|
+
let a = box(createFigureAsset('Frames'), 'base', 300, 100, 300, 50)
|
|
43
|
+
a = box(a, 'link', 100, 400, 100, 300)
|
|
44
|
+
a = box(a, 'tool', 60, 120, 40)
|
|
45
|
+
a = box(a, 'cap', 20, 20, 20)
|
|
46
|
+
a = set(a, { id: 'base', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'base', face: 'bottom' } })
|
|
47
|
+
a = set(a, { id: 'link', type: 'revolute', body0: { part: 'base', face: 'top' }, body1: { part: 'link', face: 'bottom' }, origin: { pivot: 'child-face', offset: { x: 20 }, rotate: { x: 10 } }, axis: { x: 1, y: 1, z: 0 }, lowerLimit: -90, upperLimit: 90 })
|
|
48
|
+
a = set(a, { id: 'tool', type: 'revolute', body0: { part: 'link', at: 'origin' }, body1: { part: 'tool', at: 'origin' }, origin: { offset: { x: 120, y: SUMMED, z: -30 }, rotate: { x: 15, y: -30, z: 60 } }, axis: 'Z', lowerLimit: -45, upperLimit: 45 })
|
|
49
|
+
return set(a, { id: 'cap', type: 'fixed', body0: { part: 'tool', at: 'origin' }, body1: { part: 'cap', face: 'centre' }, origin: { offset: { y: 40 } } })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The old shapes only: a token axis, face ends, a gap and an edge. */
|
|
53
|
+
function plain(): FigureAsset {
|
|
54
|
+
let a = box(createFigureAsset('Plain'), 'base', 300, 100, 300, 50)
|
|
55
|
+
a = box(a, 'link', 100, 400, 100, 300)
|
|
56
|
+
a = set(a, { id: 'base', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'base', face: 'bottom' } })
|
|
57
|
+
return set(a, { id: 'link', type: 'revolute', body0: { part: 'base', face: 'top' }, body1: { part: 'link', face: 'bottom' }, origin: { gap: 5, align: { x: 'min' } }, axis: 'Y', lowerLimit: -90, upperLimit: 90 })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const reopen = (asset: FigureAsset) => parseFigureAsset(serializeFigureAsset(asset) as unknown as string)
|
|
61
|
+
const jointOf = (asset: FigureAsset, id: string) => asset.joints!.find(j => j.id === id)! as Dynamic
|
|
62
|
+
/** What the frames say of a joint: what no form path may change. */
|
|
63
|
+
const frameOf = (j: Dynamic, keepType = true) => ({
|
|
64
|
+
...(keepType ? { type: j.type, axis: j.axis } : {}),
|
|
65
|
+
body0: j.body0,
|
|
66
|
+
body1: j.body1,
|
|
67
|
+
pivot: j.origin?.pivot,
|
|
68
|
+
offset: j.origin?.offset,
|
|
69
|
+
rotate: j.origin?.rotate
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
/** The fastening form's 붙이기, as figure-links builds it from what `fasteningOf` reads; nothing where it writes none. */
|
|
73
|
+
function fasteningSave(asset: FigureAsset, part: string): FigureAuthoringAction | null {
|
|
74
|
+
const f = fasteningOf(asset, part) as Dynamic
|
|
75
|
+
if (!f || f.atOrigin) return null
|
|
76
|
+
const aim = f.toPlane ? ('plane' as const) : { part: f.target, face: f.targetFace }
|
|
77
|
+
const align = alignOf(asset, part, aim, f.align, f.alignMm, !!f.twist, f.alignInput)
|
|
78
|
+
return {
|
|
79
|
+
kind: 'attach',
|
|
80
|
+
part,
|
|
81
|
+
face: f.face,
|
|
82
|
+
to: f.toPlane ? { plane: 'mounting-plane' } : { part: f.target, face: f.targetFace },
|
|
83
|
+
...(f.toPlane ? {} : { facing: f.facing }),
|
|
84
|
+
...(f.gap ? { gap: f.gap } : {}),
|
|
85
|
+
twist: f.twist,
|
|
86
|
+
...(align ? { align } : {}),
|
|
87
|
+
replace: true
|
|
88
|
+
} as FigureAuthoringAction
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Every form path that writes a joint, each applied, saved and opened again. */
|
|
92
|
+
function roundTrips(asset: FigureAsset, id: string) {
|
|
93
|
+
const j = jointOf(asset, id)
|
|
94
|
+
const part = j.body1.part
|
|
95
|
+
const out: Record<string, FigureJoint> = {}
|
|
96
|
+
if (j.type !== 'fixed') out.motion = jointOf(reopen(set(asset, motionJoint(j, motionFormOf(j), part))), id)
|
|
97
|
+
const fasten = fasteningSave(asset, part)
|
|
98
|
+
if (fasten) out.fastening = jointOf(reopen(applyFigureAuthoring(asset, fasten)), id)
|
|
99
|
+
out.fixed = jointOf(reopen(set(asset, fixedJointOf(j))), id)
|
|
100
|
+
const at = drawn(asset, part)
|
|
101
|
+
/* 30 along Z: the figure's, or at an origin the parent's own Z, as the offset is measured. */
|
|
102
|
+
const z = 'at' in j.body0 ? [0, 1, 2].map(i => drawn(asset, j.body0.part).pose.r[i]![2]!) : [0, 0, 1]
|
|
103
|
+
const by = z.map(v => v * 30)
|
|
104
|
+
const drag = jointDragOf(asset, part, at as never, { position: at.pose.t.map((v, i) => v + by[i]!) as [number, number, number] }, {})
|
|
105
|
+
assert.equal(drag?.kind, 'joint', `${id}: ${JSON.stringify(drag)}`)
|
|
106
|
+
const dragged = reopen(set(asset, (drag as { joint: FigureJoint }).joint))
|
|
107
|
+
out.drag = jointOf(dragged, id)
|
|
108
|
+
/* The part lands where it was dragged to. */
|
|
109
|
+
const landed = drawn(dragged, part).pose.t
|
|
110
|
+
assert.ok(Math.max(...by.map((d, i) => Math.abs(landed[i]! - at.pose.t[i]! - d))) < 0.2, `${id}: landed ${landed}, from ${at.pose.t}`)
|
|
111
|
+
return out
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
test('the axis reader: a token is its axis, a vector its unit vector, unrounded; said as written', () => {
|
|
115
|
+
assert.deepEqual(jointAxisUnit('Y'), [0, 1, 0])
|
|
116
|
+
assert.deepEqual(jointAxisUnit('z'), [0, 0, 1])
|
|
117
|
+
assert.deepEqual(jointAxisUnit({ x: 0, y: 3, z: 4 }), [0, 0.6, 0.8])
|
|
118
|
+
const tilted = jointAxisUnit({ x: 1, y: 1, z: 0 })!
|
|
119
|
+
assert.ok(Math.abs(tilted[0] - Math.SQRT1_2) < 1e-15 && tilted[0] === tilted[1] && tilted[2] === 0, `not rounded to a token: ${tilted}`)
|
|
120
|
+
assert.equal(jointAxisUnit({ x: 0, y: 0, z: 0 }), undefined)
|
|
121
|
+
assert.equal(jointAxisUnit(undefined), undefined)
|
|
122
|
+
assert.equal(jointAxisWord('X'), 'X')
|
|
123
|
+
assert.equal(jointAxisWord({ x: 1, y: 1, z: 0 }), '(1, 1, 0)')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('a follower is not a turn without end: it has no limits because its leader gives its range', () => {
|
|
127
|
+
let a = box(createFigureAsset('Pair'), 'a', 100, 100, 100, 50)
|
|
128
|
+
a = box(a, 'b', 50, 50, 50, 125)
|
|
129
|
+
a = set(a, { id: 'a', type: 'revolute', body0: { plane: 'mounting-plane' }, body1: { part: 'a', face: 'bottom' }, axis: 'Y', lowerLimit: -90, upperLimit: 90 })
|
|
130
|
+
a = set(a, { id: 'b', type: 'revolute', body0: { part: 'a', face: 'top' }, body1: { part: 'b', face: 'bottom' }, axis: 'Y', mimic: { joint: 'a', multiplier: -1 } })
|
|
131
|
+
assert.equal(motionOf(a, 'b')?.kind, 'turn')
|
|
132
|
+
assert.equal(motionFormOf(jointOf(a, 'b')).kind, 'turn')
|
|
133
|
+
assert.equal(motionOf(a, 'a')?.kind, 'turn')
|
|
134
|
+
const free = set(a, { id: 'a', type: 'revolute', body0: { plane: 'mounting-plane' }, body1: { part: 'a', face: 'bottom' }, axis: 'Y' })
|
|
135
|
+
assert.equal(motionOf(free, 'a')?.kind, 'continuous', 'a joint of its own without limits still turns without end')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('a range need not hold 0; it needs its most above its least', () => {
|
|
139
|
+
const form = motionFormOf(jointOf(plain(), 'link'))
|
|
140
|
+
assert.equal(motionFormRefusalOf({ ...form, min: 10, max: 90 }, ''), undefined)
|
|
141
|
+
assert.equal(motionFormRefusalOf({ ...form, min: -90, max: -10 }, ''), undefined)
|
|
142
|
+
assert.equal(motionFormRefusalOf({ ...form, min: 90, max: 10 }, ''), 'range')
|
|
143
|
+
assert.equal(motionFormRefusalOf({ ...form, min: 10, max: 10 }, ''), 'range')
|
|
144
|
+
assert.equal(motionFormRefusalOf({ ...form, min: NaN, max: 10 }, ''), 'range')
|
|
145
|
+
assert.equal(motionFormRefusalOf({ ...form, multiplier: 0 }, 'other'), 'multiplier')
|
|
146
|
+
/* A new control takes it and starts at the nearest end. */
|
|
147
|
+
const still = set(plain(), fixedJointOf(jointOf(plain(), 'link')))
|
|
148
|
+
const fixed = jointOf(still, 'link')
|
|
149
|
+
const lifted = set(still, motionJoint(fixed, { ...motionFormOf(fixed), kind: 'turn', min: 10, max: 90 }, 'link'))
|
|
150
|
+
assert.equal(lifted.stateDefaults[jointOf(lifted, 'link').state.id], 10)
|
|
151
|
+
/* An existing control keeps its name and its stored value: a range that leaves that value out is the kernel's refusal (R1). */
|
|
152
|
+
const j = jointOf(plain(), 'link')
|
|
153
|
+
assert.throws(() => set(plain(), motionJoint(j, { ...motionFormOf(j), min: 10, max: 90 }, 'link')), /INPUT_RANGE/)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('old shapes: every form path writes the joint back as it was, saved and reopened', () => {
|
|
157
|
+
const asset = plain()
|
|
158
|
+
const was = frameOf(jointOf(asset, 'link'))
|
|
159
|
+
const paths = roundTrips(asset, 'link')
|
|
160
|
+
assert.deepEqual(frameOf(paths.motion), was, 'motion form')
|
|
161
|
+
assert.deepEqual(frameOf(paths.fastening), was, 'fastening form')
|
|
162
|
+
assert.deepEqual(frameOf(paths.fixed, false), frameOf(was, false), 'remove motion')
|
|
163
|
+
assert.deepEqual(frameOf(paths.drag), was, 'drag')
|
|
164
|
+
assert.deepEqual((paths.drag.origin as Dynamic).align, { x: 'min', z: { mm: 30 } }, 'the edge kept')
|
|
165
|
+
assert.equal(paths.drag.origin?.gap, 5)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('a vector axis: the form keeps it, the summary says it, saved and reopened it is the same vector', framesOnly, () => {
|
|
169
|
+
const asset = framed()
|
|
170
|
+
const form = motionFormOf(jointOf(asset, 'link'))
|
|
171
|
+
assert.deepEqual(form.axisVector, { x: 1, y: 1, z: 0 })
|
|
172
|
+
assert.deepEqual(motionOf(asset, 'link')?.vector, { x: 1, y: 1, z: 0 })
|
|
173
|
+
/* Changing the range leaves the axis alone. */
|
|
174
|
+
const saved = reopen(set(asset, motionJoint(jointOf(asset, 'link'), { ...form, min: -30, max: 60 }, 'link')))
|
|
175
|
+
assert.deepEqual(jointOf(saved, 'link').axis, { x: 1, y: 1, z: 0 })
|
|
176
|
+
assert.equal(jointOf(saved, 'link').upperLimit, 60)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('frames: every form path keeps the vector axis, pivot, offset (summed too), rotate and origin ends, saved and reopened', framesOnly, () => {
|
|
180
|
+
const asset = framed()
|
|
181
|
+
for (const id of ['link', 'tool', 'cap']) {
|
|
182
|
+
const was = jointOf(asset, id)
|
|
183
|
+
const paths = roundTrips(asset, id)
|
|
184
|
+
if (paths.motion) assert.deepEqual(frameOf(paths.motion), frameOf(was), `${id}: motion form`)
|
|
185
|
+
if (paths.fastening) assert.deepEqual(frameOf(paths.fastening), frameOf(was), `${id}: fastening form`)
|
|
186
|
+
assert.deepEqual(frameOf(paths.fixed, false), frameOf(was, false), `${id}: remove motion`)
|
|
187
|
+
const dragged = frameOf(paths.drag)
|
|
188
|
+
if ('at' in was.body0) {
|
|
189
|
+
/* At an origin the drag moves the frame by its offset, in the parent's (turned) frame; the summed term stays. */
|
|
190
|
+
assert.deepEqual(dragged, { ...frameOf(was), offset: { ...was.origin.offset, z: (was.origin.offset.z ?? 0) + 30 } }, `${id}: drag`)
|
|
191
|
+
} else assert.deepEqual(dragged, frameOf(was), `${id}: drag`)
|
|
192
|
+
}
|
|
193
|
+
/* A summed offset term is not a number to add to: dragged across it, the drag is refused; along the others it stays. */
|
|
194
|
+
const tool = drawn(asset, 'tool')
|
|
195
|
+
const up = jointDragOf(asset, 'tool', tool as never, { position: [tool.pose.t[0]!, tool.pose.t[1]! + 30, tool.pose.t[2]!] }, {})
|
|
196
|
+
assert.deepEqual(up, { kind: 'refused', reason: 'origin-follows', joint: 'tool' })
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('placed by an origin: said as the origin, no row offered, and the fastening form writes nothing over it', framesOnly, () => {
|
|
200
|
+
const asset = framed()
|
|
201
|
+
assert.equal(endPlaceKeyOf(jointOf(asset, 'tool').body0), 'figure.label.joint-origin')
|
|
202
|
+
assert.equal(endPlaceKeyOf(jointOf(asset, 'link').body0), 'figure.label.face-top')
|
|
203
|
+
assert.equal((fasteningOf(asset, 'tool') as Dynamic).atOrigin, true)
|
|
204
|
+
assert.equal((fasteningOf(asset, 'cap') as Dynamic).atOrigin, true)
|
|
205
|
+
assert.equal((fasteningOf(asset, 'cap') as Dynamic).face, 'centre')
|
|
206
|
+
assert.equal((fasteningOf(asset, 'link') as Dynamic).atOrigin, undefined)
|
|
207
|
+
assert.equal(fasteningSave(asset, 'tool'), null)
|
|
208
|
+
assert.deepEqual(rowAxesOf(asset, 'cap'), [])
|
|
209
|
+
assert.equal(rowBlockOf(asset, 'cap'), 'at-origin')
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test('a drag about a vector axis sets the value; off it, it is refused and the vector said', framesOnly, () => {
|
|
213
|
+
let a = box(createFigureAsset('Tilt'), 'post', 100, 200, 100, 100)
|
|
214
|
+
a = box(a, 'head', 60, 60, 60, 230)
|
|
215
|
+
a = set(a, { id: 'post', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'post', face: 'bottom' } })
|
|
216
|
+
a = set(a, { id: 'head', type: 'revolute', body0: { part: 'post', face: 'top' }, body1: { part: 'head', face: 'bottom' }, axis: { x: 0, y: 3, z: 4 }, lowerLimit: -90, upperLimit: 90 })
|
|
217
|
+
const at = drawn(a, 'head')
|
|
218
|
+
/* 30° about (0, 0.6, 0.8), as XYZ degrees. */
|
|
219
|
+
const q = [0, 0.6, 0.8].map(v => v * Math.sin(Math.PI / 12))
|
|
220
|
+
const w = Math.cos(Math.PI / 12)
|
|
221
|
+
const [x, y, z] = q as [number, number, number]
|
|
222
|
+
const rx = Math.atan2(2 * (w * x - y * z), 1 - 2 * (x * x + y * y))
|
|
223
|
+
const ry = Math.asin(Math.max(-1, Math.min(1, 2 * (w * y + x * z))))
|
|
224
|
+
const rz = Math.atan2(2 * (w * z - x * y), 1 - 2 * (y * y + z * z))
|
|
225
|
+
const deg = [rx, ry, rz].map(r => (r * 180) / Math.PI) as [number, number, number]
|
|
226
|
+
assert.deepEqual(jointDragOf(a, 'head', at as never, { rotation: deg }, {}), { kind: 'value', input: 'head', unit: 'deg', value: 30 })
|
|
227
|
+
assert.deepEqual(jointDragOf(a, 'head', at as never, { rotation: [40, 0, 0] }, {}), { kind: 'refused', reason: 'off-axis', joint: 'head', axis: '(0, 3, 4)' })
|
|
228
|
+
/* A slide along it: the distance along the vector. */
|
|
229
|
+
const slide = set(a, { ...jointOf(a, 'head'), type: 'prismatic', lowerLimit: -100, upperLimit: 100 })
|
|
230
|
+
const s = drawn(slide, 'head')
|
|
231
|
+
assert.deepEqual(jointDragOf(slide, 'head', s as never, { position: [s.pose.t[0]!, s.pose.t[1]! + 30, s.pose.t[2]! + 40] }, {}), { kind: 'value', input: 'head', unit: 'mm', value: 50 })
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test('a rotated joint frame: a turn about its turned axis is its value, and a move is measured before the rotation', framesOnly, () => {
|
|
235
|
+
let a = box(createFigureAsset('Rotated'), 'post', 100, 200, 100, 100)
|
|
236
|
+
a = box(a, 'link', 60, 300, 60, 350)
|
|
237
|
+
a = set(a, { id: 'post', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'post', face: 'bottom' } })
|
|
238
|
+
a = set(a, { id: 'bend', type: 'revolute', body0: { part: 'post', face: 'top' }, body1: { part: 'link', face: 'bottom' }, origin: { rotate: { x: 90 } }, axis: 'Z', lowerLimit: -90, upperLimit: 90 })
|
|
239
|
+
/* Turned 90° about X, the frame's Z is the figure's -Y: 20° about -Y is 20 on the joint. */
|
|
240
|
+
const at = drawn(a, 'link', { bend: 10 })
|
|
241
|
+
const before = at.pose.r
|
|
242
|
+
const turnY = (deg: number) => {
|
|
243
|
+
const c = Math.cos((deg * Math.PI) / 180),
|
|
244
|
+
s = Math.sin((deg * Math.PI) / 180)
|
|
245
|
+
return [[c, 0, s], [0, 1, 0], [-s, 0, c]]
|
|
246
|
+
}
|
|
247
|
+
const r = turnY(-20).map(row => [0, 1, 2].map(j => row.reduce((sum, v, k) => sum + v * before[k]![j]!, 0)))
|
|
248
|
+
/* As XYZ Euler degrees. */
|
|
249
|
+
const ry = Math.asin(Math.max(-1, Math.min(1, r[0]![2]!)))
|
|
250
|
+
const rx = Math.atan2(-r[1]![2]!, r[2]![2]!)
|
|
251
|
+
const rz = Math.atan2(-r[0]![1]!, r[0]![0]!)
|
|
252
|
+
const drag = jointDragOf(a, 'link', at as never, { rotation: [rx, ry, rz].map(v => (v * 180) / Math.PI) as [number, number, number] }, { bend: 10 })
|
|
253
|
+
assert.deepEqual(drag, { kind: 'value', input: 'bend', unit: 'deg', value: 30 })
|
|
254
|
+
/* Held still, moved 30 along X: an offset across the post's top on X, not along the turned frame. */
|
|
255
|
+
const still = set(a, { ...jointOf(a, 'bend'), type: 'fixed', axis: undefined, lowerLimit: undefined, upperLimit: undefined })
|
|
256
|
+
const s = drawn(still, 'link')
|
|
257
|
+
const moved = jointDragOf(still, 'link', s as never, { position: [s.pose.t[0]!, s.pose.t[1]!, s.pose.t[2]! + 30] }, {}) as { joint: Dynamic }
|
|
258
|
+
assert.deepEqual(moved.joint.origin, { rotate: { x: 90 }, align: { z: { mm: 30 } } })
|
|
259
|
+
const up = jointDragOf(still, 'link', s as never, { position: [s.pose.t[0]!, s.pose.t[1]! + 25, s.pose.t[2]!] }, {}) as { joint: Dynamic }
|
|
260
|
+
assert.deepEqual(up.joint.origin, { rotate: { x: 90 }, gap: 25 })
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
test('a fan stands about a vector axis, as it does about the same token', framesOnly, () => {
|
|
264
|
+
let a = box(createFigureAsset('Fan'), 'base', 200, 100, 200, 50)
|
|
265
|
+
a = box(a, 'arm', 400, 50, 50, 125)
|
|
266
|
+
a = set(a, { id: 'base', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'base', face: 'bottom' } })
|
|
267
|
+
const arm = (axis: unknown) => set(a, { id: 'arm', type: 'revolute', body0: { part: 'base', face: 'top' }, body1: { part: 'arm', face: 'bottom' }, axis, lowerLimit: -45, upperLimit: 90 })
|
|
268
|
+
const token = jointFanOf(arm('Y'), 'arm') as never as { pose: { t: number[]; r: number[][] } }
|
|
269
|
+
const vector = jointFanOf(arm({ x: 0, y: 2, z: 0 }), 'arm') as never as { pose: { t: number[]; r: number[][] } }
|
|
270
|
+
assert.ok(token && vector, 'a fan for both')
|
|
271
|
+
const off = (p: number[][], q: number[][]) => Math.max(...p.flatMap((row, i) => row.map((v, j) => Math.abs(v - q[i]![j]!))))
|
|
272
|
+
assert.ok(off(token.pose.r, vector.pose.r) < 1e-9 && off([token.pose.t], [vector.pose.t]) < 1e-9)
|
|
273
|
+
const tilted = jointFanOf(arm({ x: 0, y: 3, z: 4 }), 'arm') as never as { pose: { r: number[][] } }
|
|
274
|
+
assert.ok(tilted, 'a tilted axis has a fan too')
|
|
275
|
+
/* The fan lies in its local X–Z: its local Y is the axis. */
|
|
276
|
+
const normal = [0, 1, 2].map(i => tilted.pose.r[i]![1]!)
|
|
277
|
+
assert.ok(Math.abs(Math.abs(normal[1]! * 0.6 + normal[2]! * 0.8) - 1) < 1e-9, `normal ${normal}`)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test('editing a motion keeps the control named after the joint when it has no state record', async () => {
|
|
281
|
+
const { motionFormOf, motionJoint } = await import('../client/modeller/links.ts')
|
|
282
|
+
const { applyFigureAuthoring, addFigureBox, createFigureAsset } = await import('@hatiolab/figure-model')
|
|
283
|
+
let a = createFigureAsset('Hinge')
|
|
284
|
+
a = addFigureBox(a, { id: 'base', size: { x: 200, y: 100, z: 200 }, position: { x: 0, y: 0, z: 0 } })
|
|
285
|
+
a = addFigureBox(a, { id: 'lid', size: { x: 200, y: 20, z: 200 }, position: { x: 0, y: 0, z: 0 } })
|
|
286
|
+
a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { id: 'base', type: 'fixed', body0: { plane: 'mounting-plane' }, body1: { part: 'base', face: 'bottom' } } })
|
|
287
|
+
a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { id: 'lid', type: 'revolute', body0: { part: 'base', face: 'top' }, body1: { part: 'lid', face: 'bottom' }, axis: 'X', lowerLimit: -90, upperLimit: 90 } })
|
|
288
|
+
const lid = a.joints!.find(j => j.id === 'lid')!
|
|
289
|
+
const form = motionFormOf(lid)
|
|
290
|
+
assert.equal(form.id, 'lid', 'the form starts from the input the joint is driven by')
|
|
291
|
+
const edited = applyFigureAuthoring(a, { kind: 'set-joint', joint: motionJoint(lid, { ...form, max: 80 }, 'lid') })
|
|
292
|
+
assert.deepEqual(edited.document.model.inputs.filter(i => i.role === 'state').map(i => i.id), ['lid'], 'the control keeps its name after the edit')
|
|
293
|
+
})
|
package/test/links.test.ts
CHANGED
|
@@ -371,7 +371,7 @@ test('따라 움직이는 관절 — 같은 방식으로 움직이고 제 조작
|
|
|
371
371
|
assert.equal(x('left', 40) - x('left', 0), 40)
|
|
372
372
|
assert.equal(x('right', 40) - x('right', 0), -40, '오른손은 거울로 따라 닫힌다')
|
|
373
373
|
const { motionOf } = await import('../client/modeller/links.ts')
|
|
374
|
-
assert.deepEqual(motionOf(asset, 'right')?.follows, { joint: 'left', multiplier: -1 }, '화면은 무엇을 따르는지 읽는다')
|
|
374
|
+
assert.deepEqual(motionOf(asset, 'right')?.follows, { sum: [{ joint: 'left', multiplier: -1 }], offset: 0 }, '화면은 무엇을 따르는지 읽는다')
|
|
375
375
|
})
|
|
376
376
|
|
|
377
377
|
test('면은 도형이 정한다 — 문틀(portal)은 바깥 여섯에 개구부 셋, 공은 없음, 원뿔대는 위아래뿐 (2026-09-25)', async () => {
|
|
@@ -661,3 +661,22 @@ test('붙이기 칸은 부품 제 붙임으로 열린다 — 가운데로 붙은
|
|
|
661
661
|
assert.ok(panel.includes('fasteningOf(this.asset, this.partId)'), '부품을 고르면 제 붙임으로 연다')
|
|
662
662
|
assert.ok(panel.includes("...(f.toPlane ? [] : (['centre'] as const))"), '내 면에 가운데가 있다')
|
|
663
663
|
})
|
|
664
|
+
|
|
665
|
+
test('a follower of several leaders and a stroke that is a sum of lengths are written back unchanged by the motion form (ADR-0107)', async () => {
|
|
666
|
+
const { motionFormOf, motionJoint, motionOf } = await import('../client/modeller/links.ts')
|
|
667
|
+
const { palletizerRobotFigure } = await import('@hatiolab/figure-model')
|
|
668
|
+
const { asset } = palletizerRobotFigure()
|
|
669
|
+
const wrist = asset.joints!.find(j => j.id === 'wrist')!
|
|
670
|
+
const form = motionFormOf(wrist)
|
|
671
|
+
assert.equal(form.follow, '', 'no single leader to pick')
|
|
672
|
+
assert.deepEqual(form.followSum, [{ joint: 'j2', multiplier: -1 }, { joint: 'j3', multiplier: -1 }])
|
|
673
|
+
const saved = motionJoint(wrist, { ...form, axis: form.axis }, 'wrist')
|
|
674
|
+
assert.deepEqual(saved.mimic, { sum: [{ joint: 'j2', multiplier: -1 }, { joint: 'j3', multiplier: -1 }] }, 'saving the form keeps the follower a follower')
|
|
675
|
+
assert.equal(saved.state, undefined, 'and gives it no control of its own')
|
|
676
|
+
assert.deepEqual(motionOf(asset, 'wrist')?.follows, { sum: [{ joint: 'j2', multiplier: -1 }, { joint: 'j3', multiplier: -1 }], offset: 0 })
|
|
677
|
+
|
|
678
|
+
const reach = { ...wrist, id: 'reach', type: 'prismatic' as const, mimic: undefined, axis: 'X' as const, travel: { sum: [{ from: { of: 'part' as const, part: 'base', axis: 'x' as const } }, { from: { of: 'part' as const, part: 'turret', axis: 'x' as const }, times: 0.5 }] } }
|
|
679
|
+
const slide = motionFormOf(reach)
|
|
680
|
+
assert.equal(slide.travel, '')
|
|
681
|
+
assert.deepEqual(motionJoint(reach, slide, 'wrist').travel, reach.travel, 'saving keeps the summed stroke')
|
|
682
|
+
})
|
|
@@ -23,9 +23,11 @@ test('one modeller page at one address: the route and the shell table agree', ()
|
|
|
23
23
|
assert.equal(config.match(/tagname: 'figure-modeller-page', page: 'figure-modeller'/g)?.length, 1)
|
|
24
24
|
})
|
|
25
25
|
|
|
26
|
-
test('the catalogue opens
|
|
26
|
+
test('the catalogue opens details first; editing is an explicit action in the detail dock', () => {
|
|
27
27
|
const list = read('../client/pages/figure-list-page.ts')
|
|
28
|
-
assert.match(list, /
|
|
28
|
+
assert.match(list, /openFigureDetail\(record\)/)
|
|
29
|
+
const detail = read('../client/viewparts/figure-detail-dock.ts')
|
|
30
|
+
assert.match(detail, /navigate\(modellerPathOf\(saved\)\)/)
|
|
29
31
|
})
|
|
30
32
|
|
|
31
33
|
test('reloading or closing the tab with unsaved work asks first — the shell does not ask canDeactivate then (09-26)', () => {
|
package/test/verdict.test.ts
CHANGED
|
@@ -12,7 +12,7 @@ import assert from 'node:assert/strict'
|
|
|
12
12
|
import { readFileSync } from 'node:fs'
|
|
13
13
|
import { fileURLToPath } from 'node:url'
|
|
14
14
|
|
|
15
|
-
import { addFigureBox, createFigureAsset, figureCostOf } from '@hatiolab/figure-model'
|
|
15
|
+
import { addFigureBox, createFigureAsset, editFigurePart, figureCostOf } from '@hatiolab/figure-model'
|
|
16
16
|
|
|
17
17
|
import { capacityFrom, SCENE_BUDGET } from '../client/modeller/scene-budget.ts'
|
|
18
18
|
|
|
@@ -25,6 +25,8 @@ test('개수는 목록 카드와 같은 셈이다 — 원가를 같은 capacityF
|
|
|
25
25
|
let asset = createFigureAsset('Probe')
|
|
26
26
|
asset = addFigureBox(asset, { id: 'a', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
|
|
27
27
|
asset = addFigureBox(asset, { id: 'b', size: { x: 100, y: 100, z: 100 }, position: { x: 200, y: 50, z: 0 } })
|
|
28
|
+
// The kernel batches static parts with the same material. Use two colours to test two draw groups.
|
|
29
|
+
asset = editFigurePart(asset, { kind: 'set-material', target: 'b', material: { token: 'palette.accent' }, palette: { 'palette.accent': '#f28c28' } })
|
|
28
30
|
const cost = figureCostOf(asset)
|
|
29
31
|
|
|
30
32
|
assert.equal(cost.groups, 2, '전제: 그려지는 부품 둘이 묶음 둘')
|
package/translations/en.json
CHANGED
|
@@ -61,7 +61,6 @@
|
|
|
61
61
|
"figure.text.fasten-before-moving": "fasten it to another part or the floor first; then it can move.",
|
|
62
62
|
"figure.text.motion-summary": "{part} · against {against} · {kind} along {axis}",
|
|
63
63
|
"figure.text.motion-ends-shown": "The canvas shows the poses at both ends of the range ({min} … {max} {unit}), translucent. What lies between is not checked.",
|
|
64
|
-
"figure.text.joint-range-holds-zero": "the range must hold 0, the rest pose.",
|
|
65
64
|
"figure.text.gizmo-joint-mimic": "the {joint} joint follows {leader}. move {leader}.",
|
|
66
65
|
"figure.text.gizmo-joint-fixed-turn": "the {joint} joint does not turn. give it a turn in the motion tab.",
|
|
67
66
|
"figure.text.gizmo-joint-turned-slide": "the {joint} joint only slides. use the move handle.",
|
|
@@ -707,6 +706,7 @@
|
|
|
707
706
|
"figure.text.row-count-spreads": "A set count is spread evenly over the length. Check the spacing in the preview.",
|
|
708
707
|
"figure.text.row-blocked-unfastened": "Fasten {part} to another part to arrange it in a row along that part.",
|
|
709
708
|
"figure.text.row-blocked-on-plane": "Fasten it to another part, not the mounting plane, to arrange it in a row along that part.",
|
|
709
|
+
"figure.text.row-blocked-at-origin": "It stands at another part's origin. Fasten it to a face of that part to arrange it in a row along it.",
|
|
710
710
|
"figure.text.row-blocked-holds-parts": "Parts are fastened to this part, so it cannot be arranged in a row. Arrange it first; parts fastened after ride every copy.",
|
|
711
711
|
"figure.button.go-fasten": "go to fastening",
|
|
712
712
|
"figure.text.row-count-pitch": "{count} × {part} · along {axis} · pitch {pitch} mm (centre to centre)",
|
|
@@ -965,5 +965,23 @@
|
|
|
965
965
|
"figure.label.face-mapping-choose": "Choose",
|
|
966
966
|
"figure.label.face-mapping-detach": "Let go (stays where it is)",
|
|
967
967
|
"figure.button.face-mapping-cancel": "Cancel",
|
|
968
|
-
"figure.button.face-mapping-apply": "Move and apply"
|
|
968
|
+
"figure.button.face-mapping-apply": "Move and apply",
|
|
969
|
+
"figure.title.detail": "Figure details",
|
|
970
|
+
"figure.text.detail-loading": "Loading model…",
|
|
971
|
+
"figure.button.detail-retry": "Retry",
|
|
972
|
+
"figure.text.detail-load-failed": "Could not load the model. Check your connection and access, then retry.",
|
|
973
|
+
"figure.text.detail-no-source": "No saved model is available.",
|
|
974
|
+
"figure.text.detail-invalid-source": "This model format could not be read. Open the editor to inspect it.",
|
|
975
|
+
"figure.label.detail-source": "Model to view",
|
|
976
|
+
"figure.label.detail-released": "Released version {version}",
|
|
977
|
+
"figure.label.detail-saved": "Saved model",
|
|
978
|
+
"figure.text.detail-unreleased": "Not released yet. Showing the saved draft.",
|
|
979
|
+
"figure.button.detail-edit": "Open editor",
|
|
980
|
+
"figure.label.detail-size": "Placement size",
|
|
981
|
+
"figure.button.detail-reset": "Reset size and pose",
|
|
982
|
+
"figure.text.detail-transient": "Try sizes and motion here. The saved model stays unchanged.",
|
|
983
|
+
"figure.text.detail-no-motion": "No motion is defined.",
|
|
984
|
+
"figure.text.detail-playing": "Playing",
|
|
985
|
+
"figure.label.detail-summary": "Drawing cost of the selected model",
|
|
986
|
+
"figure.text.detail-paused": "Paused"
|
|
969
987
|
}
|
package/translations/ja.json
CHANGED
|
@@ -61,7 +61,6 @@
|
|
|
61
61
|
"figure.text.fasten-before-moving": "先に他の部品か床に取り付けると、動きを付けられます。",
|
|
62
62
|
"figure.text.motion-summary": "{part} · {against} 基準 · {axis} 軸 {kind}",
|
|
63
63
|
"figure.text.motion-ends-shown": "キャンバスに範囲の両端({min} … {max} {unit})の姿勢を半透明で表示します。その間は確認しません。",
|
|
64
|
-
"figure.text.joint-range-holds-zero": "範囲に 0(基本姿勢)を含めてください。",
|
|
65
64
|
"figure.text.gizmo-joint-mimic": "{joint} 関節は {leader} に従います。{leader} を動かしてください。",
|
|
66
65
|
"figure.text.gizmo-joint-fixed-turn": "{joint} 関節は回りません。動きタブで回転を付けてください。",
|
|
67
66
|
"figure.text.gizmo-joint-turned-slide": "{joint} 関節はスライドのみです。移動ハンドルを使ってください。",
|
|
@@ -707,6 +706,7 @@
|
|
|
707
706
|
"figure.text.row-count-spreads": "個数を決めると長さの中で均等に広がります。間隔はプレビューで確認してください。",
|
|
708
707
|
"figure.text.row-blocked-unfastened": "{part} を別の部品に取り付けると、その部品に沿って列に並べられます。",
|
|
709
708
|
"figure.text.row-blocked-on-plane": "取付平面ではなく別の部品に取り付けると、その部品に沿って列に並べられます。",
|
|
709
|
+
"figure.text.row-blocked-at-origin": "別の部品の基準点に置かれているため、列に並べられません。その部品の面に取り付けると列に並べられます。",
|
|
710
710
|
"figure.text.row-blocked-holds-parts": "この部品に取り付いた部品があるため列に並べられません。先に列に並べてから取り付けると、コピーごとに付きます。",
|
|
711
711
|
"figure.button.go-fasten": "取り付けへ",
|
|
712
712
|
"figure.text.row-count-pitch": "{part} {count} 個 · {axis} 方向 · pitch {pitch} mm(中心間隔)",
|
|
@@ -965,5 +965,23 @@
|
|
|
965
965
|
"figure.label.face-mapping-choose": "移動先",
|
|
966
966
|
"figure.label.face-mapping-detach": "外しておく(今の位置のまま)",
|
|
967
967
|
"figure.button.face-mapping-cancel": "やめる",
|
|
968
|
-
"figure.button.face-mapping-apply": "移して適用"
|
|
968
|
+
"figure.button.face-mapping-apply": "移して適用",
|
|
969
|
+
"figure.title.detail": "Figure の詳細",
|
|
970
|
+
"figure.text.detail-loading": "モデルを読み込み中…",
|
|
971
|
+
"figure.button.detail-retry": "再試行",
|
|
972
|
+
"figure.text.detail-load-failed": "モデルを読み込めませんでした。接続とアクセス権を確認して再試行してください。",
|
|
973
|
+
"figure.text.detail-no-source": "保存されたモデルがありません。",
|
|
974
|
+
"figure.text.detail-invalid-source": "このモデルの形式を読み取れません。編集画面で確認してください。",
|
|
975
|
+
"figure.label.detail-source": "表示するモデル",
|
|
976
|
+
"figure.label.detail-released": "公開版 {version}",
|
|
977
|
+
"figure.label.detail-saved": "保存版",
|
|
978
|
+
"figure.text.detail-unreleased": "まだ公開されていません。保存された下書きを表示します。",
|
|
979
|
+
"figure.button.detail-edit": "編集画面を開く",
|
|
980
|
+
"figure.label.detail-size": "配置サイズ",
|
|
981
|
+
"figure.button.detail-reset": "基準サイズと姿勢に戻す",
|
|
982
|
+
"figure.text.detail-transient": "サイズと動きはここでのみ試します。保存されたモデルは変更されません。",
|
|
983
|
+
"figure.text.detail-no-motion": "登録された動きがありません。",
|
|
984
|
+
"figure.text.detail-playing": "再生中",
|
|
985
|
+
"figure.label.detail-summary": "選択したモデルの描画コスト",
|
|
986
|
+
"figure.text.detail-paused": "一時停止"
|
|
969
987
|
}
|
package/translations/ko.json
CHANGED
|
@@ -61,7 +61,6 @@
|
|
|
61
61
|
"figure.text.fasten-before-moving": "먼저 다른 부품이나 바닥에 붙여야 움직임을 줄 수 있습니다.",
|
|
62
62
|
"figure.text.motion-summary": "{part} · {against} 기준 · {axis}축 {kind}",
|
|
63
63
|
"figure.text.motion-ends-shown": "캔버스에 범위 양 끝({min} … {max} {unit})의 자세를 반투명으로 보입니다. 그 사이는 확인하지 않습니다.",
|
|
64
|
-
"figure.text.joint-range-holds-zero": "범위에 0이 들어가야 합니다. 0이 기본 자세입니다.",
|
|
65
64
|
"figure.text.gizmo-joint-mimic": "{joint} 관절은 {leader} 관절을 따라 움직입니다. {leader}을(를) 움직이세요.",
|
|
66
65
|
"figure.text.gizmo-joint-fixed-turn": "{joint} 관절은 돌지 않습니다. 돌리려면 움직임 탭에서 회전을 주세요.",
|
|
67
66
|
"figure.text.gizmo-joint-turned-slide": "{joint} 관절은 미끄러지기만 합니다. 이동 손잡이로 움직이세요.",
|
|
@@ -707,6 +706,7 @@
|
|
|
707
706
|
"figure.text.row-count-spreads": "개수를 정하면 길이 안에 고르게 벌어집니다. 간격은 미리보기에서 확인하세요.",
|
|
708
707
|
"figure.text.row-blocked-unfastened": "{part}을(를) 다른 부품에 붙이면 그 부품을 따라 줄로 배열할 수 있습니다.",
|
|
709
708
|
"figure.text.row-blocked-on-plane": "장착 평면이 아니라 다른 부품에 붙여야 그 부품을 따라 줄로 배열할 수 있습니다.",
|
|
709
|
+
"figure.text.row-blocked-at-origin": "다른 부품의 기준점에 놓여 있어 줄로 배열할 수 없습니다. 그 부품의 면에 붙이면 줄로 배열할 수 있습니다.",
|
|
710
710
|
"figure.text.row-blocked-holds-parts": "이 부품에 붙은 부품이 있어 줄로 배열할 수 없습니다. 먼저 줄로 배열한 뒤 붙이면 복사본마다 함께 붙습니다.",
|
|
711
711
|
"figure.button.go-fasten": "붙이기로 가기",
|
|
712
712
|
"figure.text.row-count-pitch": "{part} {count}개 · {axis} 방향 · pitch {pitch} mm (중심 간격)",
|
|
@@ -965,5 +965,23 @@
|
|
|
965
965
|
"figure.label.face-mapping-choose": "옮길 곳",
|
|
966
966
|
"figure.label.face-mapping-detach": "떼어 두기(지금 자리에 그대로)",
|
|
967
967
|
"figure.button.face-mapping-cancel": "그만두기",
|
|
968
|
-
"figure.button.face-mapping-apply": "옮기고 적용"
|
|
968
|
+
"figure.button.face-mapping-apply": "옮기고 적용",
|
|
969
|
+
"figure.title.detail": "Figure 상세",
|
|
970
|
+
"figure.text.detail-loading": "모델을 불러오는 중…",
|
|
971
|
+
"figure.button.detail-retry": "다시 시도",
|
|
972
|
+
"figure.text.detail-load-failed": "모델을 불러오지 못했습니다. 연결과 접근 권한을 확인하고 다시 시도하세요.",
|
|
973
|
+
"figure.text.detail-no-source": "저장된 모델이 없습니다.",
|
|
974
|
+
"figure.text.detail-invalid-source": "이 모델의 형식을 읽을 수 없습니다. 편집 화면에서 확인하세요.",
|
|
975
|
+
"figure.label.detail-source": "보는 모델",
|
|
976
|
+
"figure.label.detail-released": "발행판 {version}",
|
|
977
|
+
"figure.label.detail-saved": "저장본",
|
|
978
|
+
"figure.text.detail-unreleased": "아직 발행되지 않았습니다. 저장된 초안을 보여줍니다.",
|
|
979
|
+
"figure.button.detail-edit": "편집 화면 열기",
|
|
980
|
+
"figure.label.detail-size": "배치 크기",
|
|
981
|
+
"figure.button.detail-reset": "기준 크기와 자세로",
|
|
982
|
+
"figure.text.detail-transient": "크기와 움직임은 여기서만 시험합니다. 저장된 모델은 바뀌지 않습니다.",
|
|
983
|
+
"figure.text.detail-no-motion": "등록된 움직임이 없습니다.",
|
|
984
|
+
"figure.text.detail-playing": "재생 중",
|
|
985
|
+
"figure.label.detail-summary": "선택한 모델의 그리기 비용",
|
|
986
|
+
"figure.text.detail-paused": "일시정지"
|
|
969
987
|
}
|