@things-factory/figure-ui 10.1.116 → 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/modeller/editor.ts +2 -2
- package/client/modeller/fasten-geometry.ts +1 -1
- package/client/modeller/figure-links.ts +33 -11
- package/client/modeller/joint-axis.ts +36 -0
- package/client/modeller/joint-drag.ts +27 -7
- package/client/modeller/joint-fan.ts +2 -1
- package/client/modeller/links.ts +43 -10
- package/dist-client/modeller/clips.d.ts +1 -1
- package/dist-client/modeller/editor.js +2 -2
- 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 +31 -12
- 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 +26 -6
- 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 +16 -1
- package/dist-client/modeller/links.js +39 -9
- package/dist-client/modeller/links.js.map +1 -1
- package/dist-client/route.d.ts +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/test/command-preview.test.ts +2 -2
- package/test/joint-frames.test.ts +293 -0
- package/translations/en.json +1 -1
- package/translations/ja.json +1 -1
- package/translations/ko.json +1 -1
- package/translations/ms.json +1 -1
- package/translations/zh.json +1 -1
|
@@ -46,7 +46,7 @@ import { roomRefitRowsOf } from './occupancy.js'
|
|
|
46
46
|
import { editRefusalOf, inDeclaredRoom, inputRangeRefusalOf, narrowingRetryOf, partRefusalOf, placementRefusalOf, recipeRefusalOf, removalNoticeOf, sizeRangeRefusalOf } from './refusals.js'
|
|
47
47
|
import type { FigureRenderPart } from './renderer.js'
|
|
48
48
|
import type { FigureBudgetLine } from './maturity.js'
|
|
49
|
-
import { fieldWordsOf, followOf, measureNow, partSummaryOf, instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './links.js'
|
|
49
|
+
import { endPlaceKeyOf, fieldWordsOf, followOf, measureNow, partSummaryOf, instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './links.js'
|
|
50
50
|
import { jointFanOf } from './joint-fan.js'
|
|
51
51
|
import { jointDragOf } from './joint-drag.js'
|
|
52
52
|
import type { FirstSave } from './first-save.js'
|
|
@@ -1386,7 +1386,7 @@ export class FigureEditor extends LitElement {
|
|
|
1386
1386
|
const fastened = new Map(
|
|
1387
1387
|
(asset?.joints ?? []).map(j => [
|
|
1388
1388
|
j.body1.part,
|
|
1389
|
-
'plane' in j.body0 ? i18next.t('figure.label.mounting-plane') : `${partLabelOf(j.body0.part)} · ${i18next.t(
|
|
1389
|
+
'plane' in j.body0 ? i18next.t('figure.label.mounting-plane') : `${partLabelOf(j.body0.part)} · ${i18next.t(endPlaceKeyOf(j.body0))}`
|
|
1390
1390
|
])
|
|
1391
1391
|
)
|
|
1392
1392
|
const rows = new Map<string, FigurePartRow>()
|
|
@@ -139,7 +139,7 @@ export function overhangOf(view: FaceView, at: Record<Axis, number>): { touching
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
/** Whether the part turns about a joint: then 가운데를 면에 is offered -- a roller spinning in place. */
|
|
142
|
-
export const turnsInPlace = (asset: FigureAsset, part: string) => (asset.joints ?? []).some(j => j.body1.part === part && (j.type === 'revolute' || j.body1.face === 'centre'))
|
|
142
|
+
export const turnsInPlace = (asset: FigureAsset, part: string) => (asset.joints ?? []).some(j => j.body1.part === part && (j.type === 'revolute' || ('face' in j.body1 && j.body1.face === 'centre')))
|
|
143
143
|
|
|
144
144
|
/**
|
|
145
145
|
* How the face lies on the screen: X across when it is one of the face's axes, else Z; the other down the screen -- up
|
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
leadersFor,
|
|
34
34
|
motionOf,
|
|
35
35
|
motionFormOf,
|
|
36
|
+
motionFormRefusalOf,
|
|
37
|
+
endPlaceKeyOf,
|
|
36
38
|
motionJoint,
|
|
37
39
|
placeFacesOf,
|
|
38
40
|
placeIdFor,
|
|
@@ -54,6 +56,7 @@ import './figure-fasten-drawing.js'
|
|
|
54
56
|
import type { FastenChange } from './figure-fasten-drawing.js'
|
|
55
57
|
import { faceViewOf, modeOf, myFaceFor, turnsInPlace, type FastenMode } from './fasten-geometry.js'
|
|
56
58
|
import { HelpToggles } from './help.js'
|
|
59
|
+
import { jointAxisWord } from './joint-axis.js'
|
|
57
60
|
|
|
58
61
|
const FACES = Object.keys(FIGURE_FACES) as FigureFace[]
|
|
59
62
|
/** The target list's entry for the plane the figure stands by. */
|
|
@@ -434,17 +437,22 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
434
437
|
* What holds the part, said as its state -- 「아랫면을 바닥에 유지 중」 -- with the ways to change it: open the fastening,
|
|
435
438
|
* or take it off. Right after "follow size, attached" this is the result, not a refusal (designer 2026-09-27).
|
|
436
439
|
*/
|
|
437
|
-
private
|
|
440
|
+
private heldWords(asset: FigureAsset) {
|
|
438
441
|
const joint = (asset.joints ?? []).find(j => j.body1.part === this.partId)
|
|
439
|
-
const face = joint ? i18next.t(
|
|
442
|
+
const face = joint ? i18next.t(endPlaceKeyOf(joint.body1)) : ''
|
|
440
443
|
const gap = typeof joint?.origin?.gap === 'number' ? joint.origin.gap : 0
|
|
441
444
|
const where = !joint
|
|
442
445
|
? i18next.t('figure.text.held-by-rule')
|
|
443
446
|
: 'plane' in joint.body0
|
|
444
447
|
? i18next.t('figure.text.held-on-plane', { face, place: i18next.t(`figure.label.resize-place-${asset.placement ?? 'floor'}`), gap: String(gap) })
|
|
445
|
-
: i18next.t('figure.text.held-on-part', { face, other: joint.body0.part, otherFace: i18next.t(
|
|
448
|
+
: i18next.t('figure.text.held-on-part', { face, other: joint.body0.part, otherFace: i18next.t(endPlaceKeyOf(joint.body0)) })
|
|
449
|
+
return where
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private renderHeld(asset: FigureAsset) {
|
|
453
|
+
const joint = (asset.joints ?? []).find(j => j.body1.part === this.partId)
|
|
446
454
|
return html`<div resize-held>
|
|
447
|
-
<p><md-icon>link</md-icon> ${
|
|
455
|
+
<p><md-icon>link</md-icon> ${this.heldWords(asset)}</p>
|
|
448
456
|
<button act open-fastening @click=${() => this.openFastening(asset)}>
|
|
449
457
|
<md-icon>link</md-icon>${i18next.t('figure.button.open-fastening')}
|
|
450
458
|
</button>
|
|
@@ -521,6 +529,17 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
521
529
|
|
|
522
530
|
private renderFastening(asset: FigureAsset) {
|
|
523
531
|
const held = heldBy(asset, this.partId)
|
|
532
|
+
/* Placed by an origin (ADR-0107 decision 4): attach writes faces, so the form would move it off its origin. Said, and taken off only. */
|
|
533
|
+
if (fasteningOf(asset, this.partId)?.atOrigin) {
|
|
534
|
+
this.fastenCandidate = undefined
|
|
535
|
+
return html`<div fastened fasten-at-origin>
|
|
536
|
+
<p>${this.heldWords(asset)}</p>
|
|
537
|
+
<button act ?disabled=${this.locked} @click=${() => this.command({ kind: 'detach', part: this.partId })}>
|
|
538
|
+
<md-icon>link_off</md-icon>${i18next.t('figure.button.detach')}
|
|
539
|
+
</button>
|
|
540
|
+
${this.renderDetachWords()}
|
|
541
|
+
</div>`
|
|
542
|
+
}
|
|
524
543
|
const f = this.fasten
|
|
525
544
|
/* Not a part that hangs from this one: fastening a frame to its own roller would close a loop. */
|
|
526
545
|
const targets = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFasten(asset, one))
|
|
@@ -852,7 +871,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
852
871
|
part: this.partId,
|
|
853
872
|
against,
|
|
854
873
|
kind: i18next.t('figure.label.motion-' + now.kind),
|
|
855
|
-
axis: String(now.axis).toUpperCase()
|
|
874
|
+
axis: now.vector ? jointAxisWord(now.vector) : String(now.axis).toUpperCase()
|
|
856
875
|
})}
|
|
857
876
|
</p>
|
|
858
877
|
${ends
|
|
@@ -860,7 +879,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
860
879
|
: nothing}
|
|
861
880
|
<div pair>
|
|
862
881
|
<span>
|
|
863
|
-
${i18next.t('figure.label.motion-' + now.kind)} · ${String(now.axis).toUpperCase()}
|
|
882
|
+
${i18next.t('figure.label.motion-' + now.kind)} · ${now.vector ? jointAxisWord(now.vector) : String(now.axis).toUpperCase()}
|
|
864
883
|
${now.travel
|
|
865
884
|
? html`<small>${i18next.t('figure.label.motion-travel-is', { part: now.travel.part, axis: now.travel.axis.toUpperCase(), plus: now.travel.plus ?? 0 })}</small>`
|
|
866
885
|
: nothing}
|
|
@@ -903,6 +922,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
903
922
|
/* A follower follows a joint that moves the same way; a choice the kind no longer allows is dropped. */
|
|
904
923
|
const leaders = leadersFor(asset, this.partId, m.kind)
|
|
905
924
|
const follow = leaders.some(j => j.id === m.follow) ? m.follow : ''
|
|
925
|
+
const refusal = motionFormRefusalOf(m, follow)
|
|
906
926
|
return html`
|
|
907
927
|
<div pair>
|
|
908
928
|
<select @change=${(e: Event) => this.setKind((e.target as HTMLSelectElement).value as 'slide' | 'turn' | 'continuous')}>
|
|
@@ -910,9 +930,11 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
910
930
|
<option value="turn" ?selected=${m.kind === 'turn'}>${i18next.t('figure.label.motion-turn')}</option>
|
|
911
931
|
<option value="continuous" ?selected=${m.kind === 'continuous'}>${i18next.t('figure.label.motion-continuous')}</option>
|
|
912
932
|
</select>
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
933
|
+
${m.axisVector
|
|
934
|
+
? html`<span motion-axis-vector>${i18next.t('figure.label.motion-axis')} ${jointAxisWord(m.axisVector)}</span>`
|
|
935
|
+
: html`<select @change=${(e: Event) => (this.motion = { ...m, axis: (e.target as HTMLSelectElement).value as Axis })}>
|
|
936
|
+
${AXES.map(a => html`<option value=${a} ?selected=${a === m.axis}>${i18next.t('figure.label.motion-axis')} ${a.toUpperCase()}</option>`)}
|
|
937
|
+
</select>`}
|
|
916
938
|
</div>
|
|
917
939
|
${m.followSum
|
|
918
940
|
? html`<div form>
|
|
@@ -968,8 +990,8 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
968
990
|
</div>`}
|
|
969
991
|
<button
|
|
970
992
|
act
|
|
971
|
-
?disabled=${this.locked ||
|
|
972
|
-
title=${
|
|
993
|
+
?disabled=${this.locked || !!refusal}
|
|
994
|
+
title=${refusal === 'range' ? i18next.t('figure.text.range-needs-room') : ''}
|
|
973
995
|
@click=${() => {
|
|
974
996
|
this.command({ kind: 'set-joint', joint: motionJoint(joint, { ...m, follow }, this.partId) })
|
|
975
997
|
this.editingMotion = false
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A joint's axis as the record keeps it (ADR-0107 decision 1): one of USD's tokens, or any vector in the joint frame
|
|
3
|
+
* (URDF's `axis xyz`). One reader for every place that turns it into a direction or says it.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type JointAxisVector = { x: number; y: number; z: number }
|
|
7
|
+
|
|
8
|
+
const isVector = (axis: unknown): axis is JointAxisVector =>
|
|
9
|
+
!!axis && typeof axis === 'object' && ['x', 'y', 'z'].every(k => typeof (axis as Record<string, unknown>)[k] === 'number' && Number.isFinite((axis as Record<string, number>)[k]))
|
|
10
|
+
|
|
11
|
+
/** The axis as a unit vector in the joint frame: a token's own axis, a vector divided by its length. Nothing for none. */
|
|
12
|
+
export function jointAxisUnit(axis: unknown): [number, number, number] | undefined {
|
|
13
|
+
if (typeof axis === 'string') {
|
|
14
|
+
const k = ['x', 'y', 'z'].indexOf(axis.toLowerCase())
|
|
15
|
+
return k < 0 ? undefined : ([0, 1, 2].map(i => (i === k ? 1 : 0)) as [number, number, number])
|
|
16
|
+
}
|
|
17
|
+
if (!isVector(axis)) return undefined
|
|
18
|
+
const n = Math.hypot(axis.x, axis.y, axis.z)
|
|
19
|
+
return n > 1e-9 ? [axis.x / n, axis.y / n, axis.z / n] : undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The token, lowercase, when the axis is one; nothing for a vector. */
|
|
23
|
+
export function jointAxisToken(axis: unknown): 'x' | 'y' | 'z' | undefined {
|
|
24
|
+
const k = typeof axis === 'string' ? axis.toLowerCase() : ''
|
|
25
|
+
return k === 'x' || k === 'y' || k === 'z' ? k : undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The vector, as written, when the axis is one. */
|
|
29
|
+
export const jointAxisVectorOf = (axis: unknown): JointAxisVector | undefined => (isVector(axis) ? { x: axis.x, y: axis.y, z: axis.z } : undefined)
|
|
30
|
+
|
|
31
|
+
/** The axis said: `X` for a token, `(1, 1, 0)` for a vector as written. */
|
|
32
|
+
export function jointAxisWord(axis: unknown): string {
|
|
33
|
+
const token = jointAxisToken(axis)
|
|
34
|
+
if (token) return token.toUpperCase()
|
|
35
|
+
return isVector(axis) ? `(${axis.x}, ${axis.y}, ${axis.z})` : '?'
|
|
36
|
+
}
|
|
@@ -3,6 +3,7 @@ import { compileFigureAsset, figureMimicTermsOf } from '@hatiolab/figure-model'
|
|
|
3
3
|
import type { FigureAsset, FigureJoint } from '@hatiolab/figure-model'
|
|
4
4
|
|
|
5
5
|
import type { GizmoResult } from './gizmo.js'
|
|
6
|
+
import { jointAxisUnit, jointAxisWord } from './joint-axis.js'
|
|
6
7
|
import type { FigureRenderPart } from './renderer.js'
|
|
7
8
|
|
|
8
9
|
/*
|
|
@@ -11,7 +12,8 @@ import type { FigureRenderPart } from './renderer.js'
|
|
|
11
12
|
*
|
|
12
13
|
* - turning a part on a revolute or continuous joint sets the joint's value -- the turn about the joint's axis;
|
|
13
14
|
* - sliding a part on a prismatic joint along its axis sets its value;
|
|
14
|
-
* - moving a part any other way moves the joint's origin: the gap along the face it sits on, the offset across it
|
|
15
|
+
* - moving a part any other way moves the joint's origin: the gap along the face it sits on, the offset across it;
|
|
16
|
+
* at a parent's origin (ADR-0107 decision 4), its `origin.offset` in the parent's frame.
|
|
15
17
|
*
|
|
16
18
|
* What would break a relation is refused and said: an origin that follows another part's size, a joint that mimics
|
|
17
19
|
* another, a turn on a joint that does not turn.
|
|
@@ -30,9 +32,15 @@ const FACE_SIGN: Record<string, number> = { left: -1, right: 1, bottom: -1, top:
|
|
|
30
32
|
|
|
31
33
|
const matrixOf = (r: number[][]) => new THREE.Matrix4().set(r[0]![0]!, r[0]![1]!, r[0]![2]!, 0, r[1]![0]!, r[1]![1]!, r[1]![2]!, 0, r[2]![0]!, r[2]![1]!, r[2]![2]!, 0, 0, 0, 0, 1)
|
|
32
34
|
const eulerMatrix = (deg: number[]) => new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler(...(deg.map(THREE.MathUtils.degToRad) as [number, number, number]), 'XYZ'))
|
|
33
|
-
const unit = (axis:
|
|
35
|
+
const unit = (axis: unknown) => new THREE.Vector3(...(jointAxisUnit(axis) ?? [0, 0, 0]))
|
|
34
36
|
const round = (v: number, step: number) => Number((Math.round(v / step) * step).toFixed(Math.max(0, -Math.floor(Math.log10(step)))))
|
|
35
37
|
|
|
38
|
+
/** The joint frame's words of ADR-0107, read without the kernel's types (an older kernel has none). */
|
|
39
|
+
const framesOf = (j: FigureJoint) => (j.origin ?? {}) as { offset?: Partial<Record<Axis, unknown>>; rotate?: Partial<Record<Axis, number>> }
|
|
40
|
+
/** `origin.rotate`, URDF's rpy: Rz·Ry·Rx about the fixed axes. */
|
|
41
|
+
const rotateMatrix = (j: FigureJoint) => eulerMatrixOrder(AXES.map(a => framesOf(j).rotate?.[a] ?? 0), 'ZYX')
|
|
42
|
+
const eulerMatrixOrder = (deg: number[], order: THREE.EulerOrder) => new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler(...(deg.map(THREE.MathUtils.degToRad) as [number, number, number]), order))
|
|
43
|
+
|
|
36
44
|
/** The part's own quarter turn in the joint frame, as the record says it. */
|
|
37
45
|
const turnMatrix = (j: FigureJoint) => eulerMatrix(AXES.map(a => j.origin?.turn?.[a] ?? 0))
|
|
38
46
|
|
|
@@ -65,7 +73,7 @@ export function jointDragOf(asset: FigureAsset, id: string, part: FigureRenderPa
|
|
|
65
73
|
const twist = 2 * Math.atan2(change.x * axis.x + change.y * axis.y + change.z * axis.z, change.w)
|
|
66
74
|
/* Turned about another axis: the joint cannot make that turn, and nothing is written for it. */
|
|
67
75
|
const whole = 2 * Math.acos(Math.min(1, Math.abs(change.w)))
|
|
68
|
-
if (Math.abs(twist) < whole * 0.5) return { kind: 'refused', reason: 'off-axis', joint: j.id, axis: j.axis
|
|
76
|
+
if (Math.abs(twist) < whole * 0.5) return { kind: 'refused', reason: 'off-axis', joint: j.id, axis: jointAxisWord(j.axis) }
|
|
69
77
|
let value = now + THREE.MathUtils.radToDeg(twist)
|
|
70
78
|
if (endless) value = ((((value + 180) % 360) + 360) % 360) - 180
|
|
71
79
|
else value = Math.min(Math.max(value, j.lowerLimit!), j.upperLimit!)
|
|
@@ -92,12 +100,24 @@ export function jointDragOf(asset: FigureAsset, id: string, part: FigureRenderPa
|
|
|
92
100
|
}
|
|
93
101
|
}
|
|
94
102
|
|
|
95
|
-
/* Moving the joint's origin: the change in the frame
|
|
96
|
-
const local = moved.clone().applyMatrix4(frame.clone().invert())
|
|
103
|
+
/* Moving the joint's origin: the change in the frame it is placed in, before `origin.rotate` turns the joint frame. */
|
|
104
|
+
const local = moved.clone().applyMatrix4(frame.clone().multiply(rotateMatrix(j).invert()).invert())
|
|
105
|
+
const origin: NonNullable<FigureJoint['origin']> = { ...(j.origin ?? {}) }
|
|
106
|
+
if (!('plane' in j.body0) && !('face' in j.body0)) {
|
|
107
|
+
/* At the parent's origin there is no face: the frame moves by its offset, along the parent's axes. */
|
|
108
|
+
const offset = { ...(framesOf(j).offset ?? {}) }
|
|
109
|
+
for (const axis of AXES) {
|
|
110
|
+
const d = local[axis]
|
|
111
|
+
if (Math.abs(d) < 0.5) continue
|
|
112
|
+
const was = offset[axis] ?? 0
|
|
113
|
+
if (typeof was !== 'number') return { kind: 'refused', reason: 'origin-follows', joint: j.id }
|
|
114
|
+
offset[axis] = round(was + d, 1)
|
|
115
|
+
}
|
|
116
|
+
return { kind: 'joint', joint: { ...j, origin: { ...origin, offset } } as FigureJoint }
|
|
117
|
+
}
|
|
97
118
|
const normal: Axis = 'plane' in j.body0 ? 'y' : FACE_AXIS[j.body0.face]!
|
|
98
119
|
/* Which way a larger gap moves the part: out of the face it meets (on the plane: up when it stands, down when it hangs). */
|
|
99
|
-
const outward = 'plane' in j.body0 ? (j.body1.face === 'bottom' ? 1 : -1) : ((j.origin?.facing ?? 'meet') === 'meet' ? 1 : -1) * FACE_SIGN[j.body0.face]!
|
|
100
|
-
const origin: NonNullable<FigureJoint['origin']> = { ...(j.origin ?? {}) }
|
|
120
|
+
const outward = 'plane' in j.body0 ? ('face' in j.body1 && j.body1.face === 'bottom' ? 1 : -1) : ((j.origin?.facing ?? 'meet') === 'meet' ? 1 : -1) * FACE_SIGN[j.body0.face]!
|
|
101
121
|
const along = local[normal]
|
|
102
122
|
if (Math.abs(along) >= 0.5) {
|
|
103
123
|
const gap = origin.gap ?? 0
|
|
@@ -3,6 +3,7 @@ import type { FigureAsset } from '@hatiolab/figure-model'
|
|
|
3
3
|
|
|
4
4
|
import type { FigureRenderPart } from './renderer.js'
|
|
5
5
|
import { sectionShape } from './drawn-shapes.js'
|
|
6
|
+
import { jointAxisUnit } from './joint-axis.js'
|
|
6
7
|
|
|
7
8
|
/*
|
|
8
9
|
* Where the selected part turns, and how far -- a translucent fan on the editing board. An axis name says nothing about which way a part swings; on a six-axis arm the author went to the preview and
|
|
@@ -111,7 +112,7 @@ function turnOf(asset: FigureAsset, part: string, values: Record<string, unknown
|
|
|
111
112
|
/* In the child's own frame: the joint is where its pose puts the seat's origin, the axis turned back by its pose. */
|
|
112
113
|
const back = (v: number[]) => [0, 1, 2].map(i => pose.r[0]![i]! * v[0]! + pose.r[1]![i]! * v[1]! + pose.r[2]![i]! * v[2]!)
|
|
113
114
|
const pivot = back(pose.t.map(v => -v))
|
|
114
|
-
const axis = norm(back(
|
|
115
|
+
const axis = norm(back(jointAxisUnit(record.axis) ?? [0, 0, 0]))
|
|
115
116
|
if (!axis) return null
|
|
116
117
|
return { carrier, pivot, axis, toward: [0, 0, 0], control, turnedPart: child }
|
|
117
118
|
}
|
package/client/modeller/links.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { FIGURE_FACES, figureShapeFaces, figurePartsExtentOf, compileFigureAsset
|
|
|
2
2
|
import type { FigureFollow, FigureSizeRange } from '@hatiolab/figure-model'
|
|
3
3
|
import type { FigureAsset, FigureBindingPlace, FigureFace, FigureJoint } from '@hatiolab/figure-model'
|
|
4
4
|
import { startingShapeKindOf } from './starting-shape.js'
|
|
5
|
+
import { jointAxisToken, jointAxisVectorOf, type JointAxisVector } from './joint-axis.js'
|
|
5
6
|
|
|
6
7
|
/*
|
|
7
8
|
* What a part is tied to — the readers behind the parts' size response, fastening and motion sections. Kept apart from the element so they can be tested without a browser.
|
|
@@ -52,6 +53,9 @@ export function heldBy(asset: FigureAsset, part: string): { part: string } | { p
|
|
|
52
53
|
return to ? { part: to } : null
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
/** The word for where a joint end sits: its face's, or the part's origin for an end placed by it (ADR-0107 decision 4). */
|
|
57
|
+
export const endPlaceKeyOf = (end: object) => ('face' in end ? `figure.label.face-${(end as { face: string }).face}` : 'figure.label.joint-origin')
|
|
58
|
+
|
|
55
59
|
/** Whether a dimension of this part can follow something — box-shaped, whose three sizes are X, Y and Z. */
|
|
56
60
|
export function canFollow(asset: FigureAsset, part: string): boolean {
|
|
57
61
|
return followableAxes(asset, part).length > 0
|
|
@@ -231,20 +235,24 @@ export function rowAxesOf(asset: FigureAsset, part: string): Axis[] {
|
|
|
231
235
|
/* A part that moves may be a row too: every copy on the one control, from its own place (designer 2026-09-27). */
|
|
232
236
|
if (!j || j.repeat || !('part' in j.body0)) return []
|
|
233
237
|
if (joints.some(o => 'part' in o.body0 && o.body0.part === part)) return []
|
|
234
|
-
|
|
238
|
+
/* Placed by an origin: no face to lay the row across, and the kernel refuses it. */
|
|
239
|
+
const by = j.body1
|
|
240
|
+
if (!('face' in j.body0) || !('face' in by)) return []
|
|
241
|
+
return AXES.filter(axis => axis !== FIGURE_FACES[by.face])
|
|
235
242
|
}
|
|
236
243
|
|
|
237
244
|
/**
|
|
238
245
|
* Why a part cannot be made into a row here, in the terms `rowAxesOf` refuses it: said, not hidden (ADR-0097 §5.5).
|
|
239
246
|
* Nothing when it can, or when it already is a row (that row is shown where it stands).
|
|
240
247
|
*/
|
|
241
|
-
export function rowBlockOf(asset: FigureAsset, part: string): 'unfastened' | 'on-plane' | 'holds-parts' | undefined {
|
|
248
|
+
export function rowBlockOf(asset: FigureAsset, part: string): 'unfastened' | 'on-plane' | 'holds-parts' | 'at-origin' | undefined {
|
|
242
249
|
const joints = asset.joints ?? []
|
|
243
250
|
const j = joints.find(o => o.body1.part === part)
|
|
244
251
|
if (!j) return 'unfastened'
|
|
245
252
|
if (j.repeat) return undefined
|
|
246
253
|
if (!('part' in j.body0)) return 'on-plane'
|
|
247
254
|
if (joints.some(o => 'part' in o.body0 && o.body0.part === part)) return 'holds-parts'
|
|
255
|
+
if (!('face' in j.body0) || !('face' in j.body1)) return 'at-origin'
|
|
248
256
|
return undefined
|
|
249
257
|
}
|
|
250
258
|
|
|
@@ -332,12 +340,15 @@ export function fasteningOf(
|
|
|
332
340
|
twist: number
|
|
333
341
|
align: Partial<Record<Axis, Alignment>>
|
|
334
342
|
alignMm: Partial<Record<Axis, number>>
|
|
343
|
+
/** Placed by a part's origin (ADR-0107 decision 4): no face to fasten by, and the form does not write it. */
|
|
344
|
+
atOrigin?: true
|
|
335
345
|
} | null {
|
|
336
346
|
const record = asset.joints?.find(j => j.body1.part === part)
|
|
337
347
|
if (!record) return null
|
|
338
348
|
const toPlane = 'plane' in record.body0
|
|
339
349
|
const target = 'part' in record.body0 ? record.body0.part : ''
|
|
340
|
-
const targetFace = '
|
|
350
|
+
const targetFace = 'face' in record.body0 ? record.body0.face : 'top'
|
|
351
|
+
const atOrigin = !('plane' in record.body0) && (!('face' in record.body0) || !('face' in record.body1))
|
|
341
352
|
const twist = record.origin?.twist ?? 0
|
|
342
353
|
/* Where across the face: as the record says it, and where it says nothing, where the part stands now. */
|
|
343
354
|
const kept = placeKeptOf(asset, part, toPlane ? 'plane' : { part: target, face: targetFace }, !!twist)
|
|
@@ -357,14 +368,15 @@ export function fasteningOf(
|
|
|
357
368
|
}
|
|
358
369
|
const gap = record.origin?.gap
|
|
359
370
|
return {
|
|
360
|
-
face: record.body1.face,
|
|
371
|
+
face: 'face' in record.body1 ? record.body1.face : 'centre',
|
|
361
372
|
toPlane,
|
|
362
373
|
target,
|
|
363
374
|
targetFace,
|
|
364
375
|
facing: record.origin?.facing ?? 'meet',
|
|
365
376
|
gap: typeof gap === 'number' ? gap : 0,
|
|
366
377
|
twist,
|
|
367
|
-
...kept
|
|
378
|
+
...kept,
|
|
379
|
+
...(atOrigin ? { atOrigin: true as const } : {})
|
|
368
380
|
}
|
|
369
381
|
}
|
|
370
382
|
|
|
@@ -456,6 +468,8 @@ export function positionIsShare(asset: FigureAsset, part: string, axis: Axis): b
|
|
|
456
468
|
export interface Motion {
|
|
457
469
|
kind: 'slide' | 'turn' | 'continuous'
|
|
458
470
|
axis: Axis | '?'
|
|
471
|
+
/** The record's axis when it is a vector, as written (the graph holds it turned into the parent's frame). */
|
|
472
|
+
vector?: JointAxisVector
|
|
459
473
|
control?: { id: string; unit?: string; min?: number; max?: number }
|
|
460
474
|
/** A joint that moves with another (URDF's mimic): the leader's value times `multiplier`, plus `offset`. */
|
|
461
475
|
/** What it follows, as a sum of leaders each with its multiplier, plus an offset (one leader is a sum of one). */
|
|
@@ -469,7 +483,9 @@ export function motionOf(asset: FigureAsset, part: string): Motion | undefined {
|
|
|
469
483
|
const node = m.nodes.find(n => n.id === `${part}.motion`)
|
|
470
484
|
if (!node) return undefined
|
|
471
485
|
const record = asset.joints?.find(j => j.body1.part === part)
|
|
472
|
-
|
|
486
|
+
/* A follower has no limits of its own: its range is its leaders', not a turn without end. */
|
|
487
|
+
const endless = record?.type === 'revolute' && record.lowerLimit === undefined && record.upperLimit === undefined && !record.mimic
|
|
488
|
+
const vector = jointAxisVectorOf(record?.axis)
|
|
473
489
|
const kind = endless ? 'continuous' : node.op === 'axis-turn@1' ? 'turn' : 'slide'
|
|
474
490
|
const unit = node.args.slice(0, 3).map(ref => m.constants.find(c => c.id === ref)?.value)
|
|
475
491
|
const axis = AXES[unit.findIndex(v => v !== undefined && v !== 0)] ?? '?'
|
|
@@ -490,13 +506,15 @@ export function motionOf(asset: FigureAsset, part: string): Motion | undefined {
|
|
|
490
506
|
}
|
|
491
507
|
const one = record?.travel && 'source' in record.travel ? record.travel : undefined
|
|
492
508
|
const travel = one?.source && 'part' in one.source ? { part: one.source.part, axis: one.source.axis, ...(one.plus ? { plus: one.plus } : {}) } : undefined
|
|
493
|
-
return { kind, axis, control: control(quantity), ...(record?.mimic ? { follows: figureMimicTermsOf(record.mimic)! } : {}), ...(travel ? { travel } : {}) }
|
|
509
|
+
return { kind, axis, ...(vector ? { vector } : {}), control: control(quantity), ...(record?.mimic ? { follows: figureMimicTermsOf(record.mimic)! } : {}), ...(travel ? { travel } : {}) }
|
|
494
510
|
}
|
|
495
511
|
|
|
496
512
|
/** What the motion form holds: how the part moves, and either its own control or the joint it moves with. */
|
|
497
513
|
export interface MotionForm {
|
|
498
514
|
kind: 'slide' | 'turn' | 'continuous'
|
|
499
515
|
axis: Axis
|
|
516
|
+
/** An axis that is a vector (ADR-0107): said, not chosen here, and written back unchanged. */
|
|
517
|
+
axisVector?: JointAxisVector
|
|
500
518
|
id: string
|
|
501
519
|
min: number
|
|
502
520
|
max: number
|
|
@@ -531,10 +549,13 @@ export function motionFormOf(joint: FigureJoint): MotionForm {
|
|
|
531
549
|
const travelSum = joint.travel && 'sum' in joint.travel ? joint.travel : undefined
|
|
532
550
|
const terms = figureMimicTermsOf(joint.mimic)
|
|
533
551
|
const single = terms?.sum.length === 1 ? terms.sum[0] : undefined
|
|
552
|
+
const axisVector = jointAxisVectorOf(joint.axis)
|
|
534
553
|
return {
|
|
535
554
|
kind,
|
|
536
|
-
axis: (joint.axis ?? '
|
|
537
|
-
|
|
555
|
+
axis: jointAxisToken(joint.axis) ?? 'x',
|
|
556
|
+
...(axisVector ? { axisVector } : {}),
|
|
557
|
+
/* A moving joint with no state record is driven by the input named after it (figure-model `stateIdOf`): keep that name, so an edit does not rename the control a board or a controller writes to. */
|
|
558
|
+
id: joint.state?.id ?? (joint.type !== 'fixed' && !joint.mimic ? joint.id : ''),
|
|
538
559
|
min: travel || travelSum ? 0 : (joint.lowerLimit ?? (kind === 'slide' ? 0 : -90)),
|
|
539
560
|
max: travel || travelSum ? 500 : (joint.upperLimit ?? (kind === 'slide' ? 500 : 90)),
|
|
540
561
|
follow: single?.joint ?? '',
|
|
@@ -569,7 +590,7 @@ export function motionJoint(joint: FigureJoint, m: MotionForm, part: string): Fi
|
|
|
569
590
|
/* A row keeps being a row when it is given a motion: dropped here, the row went with it (designer 2026-09-27). */
|
|
570
591
|
...(joint.repeat ? { repeat: joint.repeat } : {}),
|
|
571
592
|
type: m.kind === 'slide' ? ('prismatic' as const) : ('revolute' as const),
|
|
572
|
-
axis: m.axis.toUpperCase() as '
|
|
593
|
+
axis: (m.axisVector ?? m.axis.toUpperCase()) as FigureJoint['axis']
|
|
573
594
|
}
|
|
574
595
|
if (m.follow) return { ...moving, mimic: { joint: m.follow, multiplier: m.multiplier, ...(m.offset ? { offset: m.offset } : {}) } }
|
|
575
596
|
if (m.followSum) return { ...moving, mimic: { sum: m.followSum, ...(m.offset ? { offset: m.offset } : {}) } }
|
|
@@ -581,6 +602,18 @@ export function motionJoint(joint: FigureJoint, m: MotionForm, part: string): Fi
|
|
|
581
602
|
return { ...moving, ...(m.kind === 'continuous' ? {} : { lowerLimit: m.min, upperLimit: m.max }), state: { id: m.id || `${part}-${m.kind}` } }
|
|
582
603
|
}
|
|
583
604
|
|
|
605
|
+
/**
|
|
606
|
+
* Why the motion form cannot be saved as it stands, as the kernel would refuse it: a follower's multiplier that is not
|
|
607
|
+
* a number or is 0, or a range whose most is not above its least. A range need not hold 0 -- the control starts at the
|
|
608
|
+
* nearest end (a lift that rests at 200 mm).
|
|
609
|
+
*/
|
|
610
|
+
export function motionFormRefusalOf(m: MotionForm, follow: string): 'multiplier' | 'range' | undefined {
|
|
611
|
+
if (m.followSum) return undefined
|
|
612
|
+
if (follow) return Number.isFinite(m.multiplier) && m.multiplier !== 0 ? undefined : 'multiplier'
|
|
613
|
+
if (m.kind === 'continuous' || (m.kind === 'slide' && (m.travel || m.travelSum))) return undefined
|
|
614
|
+
return Number.isFinite(m.min) && Number.isFinite(m.max) && m.max > m.min ? undefined : 'range'
|
|
615
|
+
}
|
|
616
|
+
|
|
584
617
|
/** The select's value for what a dimension follows now. */
|
|
585
618
|
export function choiceOf(follow: Follow): string {
|
|
586
619
|
switch (follow.of) {
|
|
@@ -37,7 +37,7 @@ import { END_FACE } from './section-controls.js';
|
|
|
37
37
|
import { nudgeOf, placementOf } from './stage-model.js';
|
|
38
38
|
import { roomRefitRowsOf } from './occupancy.js';
|
|
39
39
|
import { editRefusalOf, inDeclaredRoom, inputRangeRefusalOf, narrowingRetryOf, partRefusalOf, placementRefusalOf, recipeRefusalOf, removalNoticeOf, sizeRangeRefusalOf } from './refusals.js';
|
|
40
|
-
import { fieldWordsOf, followOf, measureNow, partSummaryOf, instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './links.js';
|
|
40
|
+
import { endPlaceKeyOf, fieldWordsOf, followOf, measureNow, partSummaryOf, instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './links.js';
|
|
41
41
|
import { jointFanOf } from './joint-fan.js';
|
|
42
42
|
import { jointDragOf } from './joint-drag.js';
|
|
43
43
|
import { liveOverSession, NOTHING_LIVE, scrubbed, scrubStep } from './live.js';
|
|
@@ -1411,7 +1411,7 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
1411
1411
|
/* Where each part is fastened, straight above it: `part · face`, or the mounting plane (ADR-0097 §5.5). */
|
|
1412
1412
|
const fastened = new Map((asset?.joints ?? []).map(j => [
|
|
1413
1413
|
j.body1.part,
|
|
1414
|
-
'plane' in j.body0 ? i18next.t('figure.label.mounting-plane') : `${partLabelOf(j.body0.part)} · ${i18next.t(
|
|
1414
|
+
'plane' in j.body0 ? i18next.t('figure.label.mounting-plane') : `${partLabelOf(j.body0.part)} · ${i18next.t(endPlaceKeyOf(j.body0))}`
|
|
1415
1415
|
]));
|
|
1416
1416
|
const rows = new Map();
|
|
1417
1417
|
for (const g of geometry) {
|