@things-factory/figure-ui 10.1.26 → 10.1.27
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/figure-animations.ts +172 -24
- package/client/modeller/figure-inspector.ts +143 -3
- package/client/modeller/figure-preview.ts +2 -2
- package/client/modeller/figure-source.ts +16 -4
- package/client/modeller/figure-view.ts +20 -0
- package/client/modeller/joint-edits.ts +201 -0
- package/dist-client/modeller/figure-animations.d.ts +14 -0
- package/dist-client/modeller/figure-animations.js +151 -17
- package/dist-client/modeller/figure-animations.js.map +1 -1
- package/dist-client/modeller/figure-inspector.d.ts +11 -0
- package/dist-client/modeller/figure-inspector.js +116 -2
- package/dist-client/modeller/figure-inspector.js.map +1 -1
- package/dist-client/modeller/figure-preview.js +2 -2
- package/dist-client/modeller/figure-preview.js.map +1 -1
- package/dist-client/modeller/figure-source.d.ts +8 -0
- package/dist-client/modeller/figure-source.js +8 -4
- package/dist-client/modeller/figure-source.js.map +1 -1
- package/dist-client/modeller/figure-view.d.ts +16 -0
- package/dist-client/modeller/figure-view.js +15 -0
- package/dist-client/modeller/figure-view.js.map +1 -1
- package/dist-client/modeller/joint-edits.d.ts +29 -0
- package/dist-client/modeller/joint-edits.js +175 -0
- package/dist-client/modeller/joint-edits.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/test/figure-source.test.ts +32 -0
- package/test/joint-edits.test.ts +130 -0
- package/test/preview-board-depth.test.ts +27 -0
- package/translations/en.json +24 -0
- package/translations/ja.json +24 -0
- package/translations/ko.json +24 -0
- package/translations/ms.json +24 -0
- package/translations/zh.json +24 -0
|
@@ -6,7 +6,7 @@ import { i18next, localize } from '@operato/i18n'
|
|
|
6
6
|
import { ScrollbarStyles } from '@operato/styles'
|
|
7
7
|
|
|
8
8
|
import { AXES, CHANNEL_PATHS, INTERPOLATIONS, LIMITS } from '@hatiolab/figure-model'
|
|
9
|
-
import type { ChannelPath, Vec3 } from '@hatiolab/figure-model'
|
|
9
|
+
import type { ChannelPath, JointChannel, PartChannel, Vec3 } from '@hatiolab/figure-model'
|
|
10
10
|
|
|
11
11
|
import type { FigureDraft, PartModel } from './figure-source.js'
|
|
12
12
|
|
|
@@ -44,7 +44,13 @@ import type { FigureDraft, PartModel } from './figure-source.js'
|
|
|
44
44
|
type Clip = NonNullable<FigureDraft['animations']>[number]
|
|
45
45
|
type Parameter = NonNullable<FigureDraft['parameters']>[number]
|
|
46
46
|
type Channel = Clip['channels'][number]
|
|
47
|
-
type Keyframe =
|
|
47
|
+
type Keyframe = PartChannel['keys'][number]
|
|
48
|
+
type JointKey = JointChannel['keys'][number]
|
|
49
|
+
|
|
50
|
+
/** A channel that drives a joint names the joint and carries one number per key (ADR-0066). */
|
|
51
|
+
function isJointChannel(channel: Channel): channel is JointChannel {
|
|
52
|
+
return channel.path === undefined
|
|
53
|
+
}
|
|
48
54
|
|
|
49
55
|
/** 어느 목록의 몇째인가. 채널·키 편집기가 둘을 함께 쓴다. */
|
|
50
56
|
type MotionKind = 'clip' | 'parameter'
|
|
@@ -357,6 +363,11 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
357
363
|
return this.parts.map(part => part.name).filter(name => !!name)
|
|
358
364
|
}
|
|
359
365
|
|
|
366
|
+
/** Joints a channel can drive instead of a part (ADR-0066). */
|
|
367
|
+
private get joints(): NonNullable<FigureDraft['joints']> {
|
|
368
|
+
return this.draft?.joints ?? []
|
|
369
|
+
}
|
|
370
|
+
|
|
360
371
|
/**
|
|
361
372
|
* 몇 부품이 움직이나 — 채널이 가리키는 **서로 다른 부품**의 수.
|
|
362
373
|
*
|
|
@@ -594,24 +605,48 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
594
605
|
* 검증은 저장할 때 말하고 여기서는 **그 자리에서** 보여 준다.
|
|
595
606
|
*/
|
|
596
607
|
private renderChannel(kind: MotionKind, channel: Channel, at: number, j: number): TemplateResult {
|
|
597
|
-
const
|
|
608
|
+
const joint = this.joints.find(one => one.name === channel.target)
|
|
609
|
+
const orphan = !this.partNames.includes(channel.target) && !joint
|
|
610
|
+
const target = html`
|
|
611
|
+
<div row>
|
|
612
|
+
<label>${i18next.t('figure.label.channel-target')}</label>
|
|
613
|
+
<select
|
|
614
|
+
.value=${live(channel.target ?? '')}
|
|
615
|
+
@change=${(e: Event) => this.retarget(kind, at, j, (e.target as HTMLSelectElement).value)}
|
|
616
|
+
>
|
|
617
|
+
${orphan ? html`<option value=${channel.target}>${channel.target}</option>` : nothing}
|
|
618
|
+
<optgroup label=${i18next.t('figure.label.channel-target-parts')}>
|
|
619
|
+
${this.partNames.map(name => html`<option value=${name}>${name}</option>`)}
|
|
620
|
+
</optgroup>
|
|
621
|
+
${this.joints.length > 0
|
|
622
|
+
? html`<optgroup label=${i18next.t('figure.label.channel-target-joints')}>
|
|
623
|
+
${this.joints.map(one => html`<option value=${one.name}>${one.name}</option>`)}
|
|
624
|
+
</optgroup>`
|
|
625
|
+
: nothing}
|
|
626
|
+
</select>
|
|
627
|
+
<button plain @click=${() => this.removeChannel(kind, at, j)} title=${i18next.t('figure.button.remove')}>
|
|
628
|
+
<md-icon>close</md-icon>
|
|
629
|
+
</button>
|
|
630
|
+
</div>
|
|
631
|
+
`
|
|
632
|
+
|
|
633
|
+
/* A joint channel has no path or pivot: the joint says what moves. Each key is the joint coordinate. */
|
|
634
|
+
if (isJointChannel(channel)) {
|
|
635
|
+
const unit = joint?.type === 'prismatic' ? 'mm' : 'deg'
|
|
636
|
+
return html`
|
|
637
|
+
<div channel ?orphan=${orphan}>
|
|
638
|
+
${target} ${orphan ? html`<p warn>${i18next.t('figure.text.channel-target-is-gone')}</p>` : nothing}
|
|
639
|
+
<p quiet>${i18next.t('figure.text.joint-channel-value-' + unit)}</p>
|
|
640
|
+
${this.renderJointKeys(kind, channel, at, j, joint?.limits)}
|
|
641
|
+
</div>
|
|
642
|
+
`
|
|
643
|
+
}
|
|
644
|
+
|
|
598
645
|
const turning = channel.path === 'rotation'
|
|
599
646
|
|
|
600
647
|
return html`
|
|
601
648
|
<div channel ?orphan=${orphan}>
|
|
602
|
-
|
|
603
|
-
<label>${i18next.t('figure.label.channel-target')}</label>
|
|
604
|
-
<select
|
|
605
|
-
.value=${live(channel.target ?? '')}
|
|
606
|
-
@change=${(e: Event) => this.setChannel(kind, at, j, { target: (e.target as HTMLSelectElement).value })}
|
|
607
|
-
>
|
|
608
|
-
${orphan ? html`<option value=${channel.target}>${channel.target}</option>` : nothing}
|
|
609
|
-
${this.partNames.map(name => html`<option value=${name}>${name}</option>`)}
|
|
610
|
-
</select>
|
|
611
|
-
<button plain @click=${() => this.removeChannel(kind, at, j)} title=${i18next.t('figure.button.remove')}>
|
|
612
|
-
<md-icon>close</md-icon>
|
|
613
|
-
</button>
|
|
614
|
-
</div>
|
|
649
|
+
${target}
|
|
615
650
|
|
|
616
651
|
${orphan ? html`<p warn>${i18next.t('figure.text.channel-target-is-gone')}</p>` : nothing}
|
|
617
652
|
|
|
@@ -649,7 +684,7 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
649
684
|
* 그래서 경로가 rotation 일 때만 낸다. 언제나 내면 저작자가 옮김·배율에도 적고, 검증이
|
|
650
685
|
* 저장할 때 「쓰이지 않는다」고 알린다. 안 보이면 적을 일이 없다.
|
|
651
686
|
*/
|
|
652
|
-
private renderPivot(kind: MotionKind, channel:
|
|
687
|
+
private renderPivot(kind: MotionKind, channel: PartChannel, at: number, j: number): TemplateResult {
|
|
653
688
|
const pivot = channel.pivot
|
|
654
689
|
const on = pivot !== undefined
|
|
655
690
|
|
|
@@ -691,7 +726,76 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
691
726
|
* 시각이 오름차순이어야 하는 것도 형식의 규칙이다. 여기서는 어긋난 줄을 붉게 내고 값을
|
|
692
727
|
* 고치지는 않는다 — 저작자가 3 을 1 로 고치던 중일 수 있다.
|
|
693
728
|
*/
|
|
694
|
-
|
|
729
|
+
/**
|
|
730
|
+
* Keys of a joint channel: a position and one joint coordinate each. A value outside the joint's limits
|
|
731
|
+
* is marked and left as typed; the format refuses it on save.
|
|
732
|
+
*/
|
|
733
|
+
private renderJointKeys(
|
|
734
|
+
kind: MotionKind,
|
|
735
|
+
channel: JointChannel,
|
|
736
|
+
at: number,
|
|
737
|
+
j: number,
|
|
738
|
+
limits?: { min: number; max: number }
|
|
739
|
+
): TemplateResult {
|
|
740
|
+
const keys = channel.keys ?? []
|
|
741
|
+
const onValues = kind === 'parameter'
|
|
742
|
+
const beyond = (key: JointKey) => !!limits && (key.value < limits.min || key.value > limits.max)
|
|
743
|
+
|
|
744
|
+
return html`
|
|
745
|
+
<table keys>
|
|
746
|
+
<tr>
|
|
747
|
+
<th>${i18next.t(onValues ? 'figure.label.key-at-value' : 'figure.label.key-at')}</th>
|
|
748
|
+
<th>${i18next.t('figure.label.joint-value')}</th>
|
|
749
|
+
<th></th>
|
|
750
|
+
</tr>
|
|
751
|
+
${keys.map((key, k) => {
|
|
752
|
+
const backwards = k > 0 && key.at <= (keys[k - 1]!.at ?? 0)
|
|
753
|
+
return html`
|
|
754
|
+
<tr>
|
|
755
|
+
<td>
|
|
756
|
+
<input
|
|
757
|
+
type="number"
|
|
758
|
+
step=${onValues ? '0.05' : '0.1'}
|
|
759
|
+
min="0"
|
|
760
|
+
max=${onValues ? '1' : nothing}
|
|
761
|
+
?bad=${backwards || key.at < 0 || (onValues && key.at > 1)}
|
|
762
|
+
.value=${live(String(key.at))}
|
|
763
|
+
@change=${(e: Event) =>
|
|
764
|
+
this.setJointKey(kind, at, j, k, { at: Math.max(0, Number((e.target as HTMLInputElement).value) || 0) })}
|
|
765
|
+
/>
|
|
766
|
+
</td>
|
|
767
|
+
<td>
|
|
768
|
+
<input
|
|
769
|
+
type="number"
|
|
770
|
+
step="1"
|
|
771
|
+
?bad=${beyond(key)}
|
|
772
|
+
.value=${live(String(key.value))}
|
|
773
|
+
title=${beyond(key) ? i18next.t('figure.text.joint-value-beyond-limits') : ''}
|
|
774
|
+
@change=${(e: Event) => this.setJointKey(kind, at, j, k, { value: Number((e.target as HTMLInputElement).value) || 0 })}
|
|
775
|
+
/>
|
|
776
|
+
</td>
|
|
777
|
+
<td drop>
|
|
778
|
+
<button
|
|
779
|
+
plain
|
|
780
|
+
?disabled=${keys.length <= 2}
|
|
781
|
+
title=${keys.length <= 2 ? i18next.t('figure.text.two-keys-at-least') : i18next.t('figure.button.remove')}
|
|
782
|
+
@click=${() => this.removeKey(kind, at, j, k)}
|
|
783
|
+
>
|
|
784
|
+
<md-icon>close</md-icon>
|
|
785
|
+
</button>
|
|
786
|
+
</td>
|
|
787
|
+
</tr>
|
|
788
|
+
`
|
|
789
|
+
})}
|
|
790
|
+
</table>
|
|
791
|
+
${keys.some(beyond) ? html`<p warn>${i18next.t('figure.text.joint-value-beyond-limits')}</p>` : nothing}
|
|
792
|
+
<button @click=${() => this.addKey(kind, at, j)}>
|
|
793
|
+
<md-icon>add</md-icon>${i18next.t('figure.button.add-key')}
|
|
794
|
+
</button>
|
|
795
|
+
`
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
private renderKeys(kind: MotionKind, channel: PartChannel, at: number, j: number): TemplateResult {
|
|
695
799
|
const keys = channel.keys ?? []
|
|
696
800
|
/*
|
|
697
801
|
Keys on a parameter curve sit on the 0..1 value span (ADR-0051). A key above 1 is unreachable and
|
|
@@ -948,11 +1052,36 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
948
1052
|
])
|
|
949
1053
|
}
|
|
950
1054
|
|
|
951
|
-
|
|
1055
|
+
/**
|
|
1056
|
+
* Points a channel at another part or joint. Crossing between the two changes the channel's shape: a
|
|
1057
|
+
* joint channel has no path and one number per key, a part channel a path and a vector. The key positions
|
|
1058
|
+
* are kept, since they are the author's timing; values restart at the rest pose.
|
|
1059
|
+
*/
|
|
1060
|
+
private retarget(kind: MotionKind, at: number, j: number, target: string): void {
|
|
952
1061
|
const channel = this.channelsOf(kind, at)[j]
|
|
953
1062
|
if (!channel) return
|
|
1063
|
+
const toJoint = this.joints.some(one => one.name === target)
|
|
1064
|
+
|
|
1065
|
+
if (toJoint && !isJointChannel(channel)) {
|
|
1066
|
+
const next: JointChannel = { target, keys: channel.keys.map(key => ({ at: key.at, value: 0 })) }
|
|
1067
|
+
if (channel.interpolation !== undefined) next.interpolation = channel.interpolation
|
|
1068
|
+
this.patchChannel(kind, at, j, next)
|
|
1069
|
+
return
|
|
1070
|
+
}
|
|
1071
|
+
if (!toJoint && isJointChannel(channel)) {
|
|
1072
|
+
const next: PartChannel = { target, path: 'translation', keys: channel.keys.map(key => ({ at: key.at, value: startValue('translation') })) }
|
|
1073
|
+
if (channel.interpolation !== undefined) next.interpolation = channel.interpolation
|
|
1074
|
+
this.patchChannel(kind, at, j, next)
|
|
1075
|
+
return
|
|
1076
|
+
}
|
|
1077
|
+
this.patchChannel(kind, at, j, { ...channel, target } as Channel)
|
|
1078
|
+
}
|
|
954
1079
|
|
|
955
|
-
|
|
1080
|
+
private setChannel(kind: MotionKind, at: number, j: number, patch: Partial<PartChannel>): void {
|
|
1081
|
+
const channel = this.channelsOf(kind, at)[j]
|
|
1082
|
+
if (!channel || isJointChannel(channel)) return
|
|
1083
|
+
|
|
1084
|
+
const next: PartChannel = { ...channel, ...patch }
|
|
956
1085
|
/* `undefined` 를 얹는 것은 지우는 뜻이다. 남겨 두면 저장 형식에 빈 칸이 실린다. */
|
|
957
1086
|
if ('pivot' in patch && patch.pivot === undefined) delete next.pivot
|
|
958
1087
|
this.patchChannel(kind, at, j, next)
|
|
@@ -967,10 +1096,10 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
967
1096
|
*/
|
|
968
1097
|
private setPath(kind: MotionKind, at: number, j: number, path: ChannelPath): void {
|
|
969
1098
|
const channel = this.channelsOf(kind, at)[j]
|
|
970
|
-
if (!channel) return
|
|
1099
|
+
if (!channel || isJointChannel(channel)) return
|
|
971
1100
|
|
|
972
1101
|
const crossing = (channel.path === 'scale') !== (path === 'scale')
|
|
973
|
-
const next:
|
|
1102
|
+
const next: PartChannel = {
|
|
974
1103
|
...channel,
|
|
975
1104
|
path,
|
|
976
1105
|
keys: crossing ? channel.keys.map(key => ({ at: key.at, value: startValue(path) })) : channel.keys
|
|
@@ -994,6 +1123,19 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
994
1123
|
const channel = this.channelsOf(kind, at)[j]
|
|
995
1124
|
if (!channel) return
|
|
996
1125
|
|
|
1126
|
+
if (isJointChannel(channel)) {
|
|
1127
|
+
const keys = channel.keys
|
|
1128
|
+
const end = keys[keys.length - 1]
|
|
1129
|
+
if (kind === 'parameter' && keys.length >= 2) {
|
|
1130
|
+
const before = keys[keys.length - 2]!
|
|
1131
|
+
const middle: JointKey = { at: (before.at + end!.at) / 2, value: before.value }
|
|
1132
|
+
this.patchChannel(kind, at, j, { ...channel, keys: [...keys.slice(0, -1), middle, end!] })
|
|
1133
|
+
return
|
|
1134
|
+
}
|
|
1135
|
+
this.patchChannel(kind, at, j, { ...channel, keys: [...keys, { at: (end?.at ?? 0) + 1, value: end?.value ?? 0 }] })
|
|
1136
|
+
return
|
|
1137
|
+
}
|
|
1138
|
+
|
|
997
1139
|
const last = channel.keys[channel.keys.length - 1]
|
|
998
1140
|
|
|
999
1141
|
/*
|
|
@@ -1010,9 +1152,15 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
1010
1152
|
this.patchChannel(kind, at, j, { ...channel, keys: [...channel.keys, key] })
|
|
1011
1153
|
}
|
|
1012
1154
|
|
|
1155
|
+
private setJointKey(kind: MotionKind, at: number, j: number, k: number, patch: Partial<JointKey>): void {
|
|
1156
|
+
const channel = this.channelsOf(kind, at)[j]
|
|
1157
|
+
if (!channel || !isJointChannel(channel)) return
|
|
1158
|
+
this.patchChannel(kind, at, j, { ...channel, keys: channel.keys.map((key, i) => (i === k ? { ...key, ...patch } : key)) })
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1013
1161
|
private setKey(kind: MotionKind, at: number, j: number, k: number, patch: Partial<Keyframe>): void {
|
|
1014
1162
|
const channel = this.channelsOf(kind, at)[j]
|
|
1015
|
-
if (!channel) return
|
|
1163
|
+
if (!channel || isJointChannel(channel)) return
|
|
1016
1164
|
this.patchChannel(kind, at, j, {
|
|
1017
1165
|
...channel,
|
|
1018
1166
|
keys: channel.keys.map((key, i) => (i === k ? { ...key, ...patch } : key))
|
|
@@ -1023,6 +1171,6 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
1023
1171
|
private removeKey(kind: MotionKind, at: number, j: number, k: number): void {
|
|
1024
1172
|
const channel = this.channelsOf(kind, at)[j]
|
|
1025
1173
|
if (!channel || channel.keys.length <= 2) return
|
|
1026
|
-
this.patchChannel(kind, at, j, { ...channel, keys: channel.keys.filter((_, i) => i !== k) })
|
|
1174
|
+
this.patchChannel(kind, at, j, { ...channel, keys: (channel.keys as { at: number }[]).filter((_, i) => i !== k) } as Channel)
|
|
1027
1175
|
}
|
|
1028
1176
|
}
|
|
@@ -16,13 +16,38 @@ import {
|
|
|
16
16
|
FIGURE_PLACEMENTS,
|
|
17
17
|
trianglesOf
|
|
18
18
|
} from '@hatiolab/figure-model'
|
|
19
|
-
import { ANCHOR_RULES, PART_ROLES, REPEAT_LIMIT, anchorOfPart, repeatPlanOfPart } from '@hatiolab/figure-model'
|
|
20
|
-
import type {
|
|
19
|
+
import { ANCHOR_RULES, JOINT_TYPES, PART_ROLES, REPEAT_LIMIT, anchorOfPart, repeatPlanOfPart } from '@hatiolab/figure-model'
|
|
20
|
+
import type {
|
|
21
|
+
AnchorRule,
|
|
22
|
+
Axis,
|
|
23
|
+
FigurePart,
|
|
24
|
+
FigurePlacement,
|
|
25
|
+
JointType,
|
|
26
|
+
MaterialPreset,
|
|
27
|
+
PartRole,
|
|
28
|
+
PrimitiveKind,
|
|
29
|
+
Vec3
|
|
30
|
+
} from '@hatiolab/figure-model'
|
|
21
31
|
import { activePalette } from '@hatiolab/things-scene'
|
|
22
32
|
|
|
23
33
|
import { partToFigure, partFromFigure } from './figure-source.js'
|
|
24
34
|
import type { FigureDraft, PartModel } from './figure-source.js'
|
|
25
35
|
import { canMirror } from './part-edits.js'
|
|
36
|
+
import { jointOf, parentChoices, patchJoint, setJointType } from './joint-edits.js'
|
|
37
|
+
|
|
38
|
+
/** Axis choices for a joint. Written as a direction the author reads, stored as a vector. */
|
|
39
|
+
const JOINT_AXES: readonly { key: string; axis: Vec3 }[] = [
|
|
40
|
+
{ key: '+x', axis: { x: 1, y: 0, z: 0 } },
|
|
41
|
+
{ key: '-x', axis: { x: -1, y: 0, z: 0 } },
|
|
42
|
+
{ key: '+y', axis: { x: 0, y: 1, z: 0 } },
|
|
43
|
+
{ key: '-y', axis: { x: 0, y: -1, z: 0 } },
|
|
44
|
+
{ key: '+z', axis: { x: 0, y: 0, z: 1 } },
|
|
45
|
+
{ key: '-z', axis: { x: 0, y: 0, z: -1 } }
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
function axisKeyOf(axis: Vec3): string | undefined {
|
|
49
|
+
return JOINT_AXES.find(one => one.axis.x === axis.x && one.axis.y === axis.y && one.axis.z === axis.z)?.key
|
|
50
|
+
}
|
|
26
51
|
|
|
27
52
|
/**
|
|
28
53
|
* 고른 부품의 속성.
|
|
@@ -820,7 +845,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
|
|
|
820
845
|
묶음은 아예 나오지 않는다 — 해 둬도 아무 일도 안 일어나는 칸을 보여 주지 않는다.
|
|
821
846
|
*/
|
|
822
847
|
return html`
|
|
823
|
-
${this.renderFigure(true)} ${this.renderIdentity(shown)} ${this.renderRole(shown)}
|
|
848
|
+
${this.renderFigure(true)} ${this.renderIdentity(shown)} ${this.renderRole(shown)} ${this.renderJoint(shown)}
|
|
824
849
|
${this.renderPlacement(shown)} ${this.renderShape(shown)} ${this.renderMaterial(shown)}
|
|
825
850
|
${this.renderBehaviour(shown)}
|
|
826
851
|
`
|
|
@@ -884,6 +909,121 @@ export class FigureInspector extends localize(i18next)(LitElement) {
|
|
|
884
909
|
`
|
|
885
910
|
}
|
|
886
911
|
|
|
912
|
+
/**
|
|
913
|
+
* How this part hangs from another and moves relative to it (ADR-0066).
|
|
914
|
+
*
|
|
915
|
+
* The parent is written on the part. The joint says how the part moves relative to that parent, and the
|
|
916
|
+
* parameter it comes with is what drives it; the preview shows that parameter as a slider.
|
|
917
|
+
*/
|
|
918
|
+
private renderJoint(part: FigurePart) {
|
|
919
|
+
const joint = jointOf(this.draft, part.name)
|
|
920
|
+
const choices = parentChoices(this.parts, part.name)
|
|
921
|
+
const unit = joint?.type === 'prismatic' ? 'mm' : 'deg'
|
|
922
|
+
const axisKey = joint ? axisKeyOf(joint.axis) : undefined
|
|
923
|
+
const driver = joint
|
|
924
|
+
? this.draft?.parameters?.find(one => one.clip?.channels?.some(channel => channel.target === joint.name))
|
|
925
|
+
: undefined
|
|
926
|
+
|
|
927
|
+
return html`
|
|
928
|
+
<section>
|
|
929
|
+
<h3>${i18next.t('figure.label.joint')}${this.help('figure.text.joint-explained')}</h3>
|
|
930
|
+
|
|
931
|
+
<div inline-field>
|
|
932
|
+
<label>${i18next.t('figure.label.joint-parent')}</label>
|
|
933
|
+
<select @change=${(e: Event) => this.setParent((e.target as HTMLSelectElement).value)}>
|
|
934
|
+
<option value="" ?selected=${part.parent === undefined}>${i18next.t('figure.label.joint-parent-none')}</option>
|
|
935
|
+
${part.parent !== undefined && !choices.includes(part.parent)
|
|
936
|
+
? html`<option value=${part.parent} selected>${part.parent}</option>`
|
|
937
|
+
: nothing}
|
|
938
|
+
${choices.map(name => html`<option value=${name} ?selected=${name === part.parent}>${name}</option>`)}
|
|
939
|
+
</select>
|
|
940
|
+
</div>
|
|
941
|
+
|
|
942
|
+
<div inline-field>
|
|
943
|
+
<label>${i18next.t('figure.label.joint-type')}</label>
|
|
944
|
+
<select @change=${(e: Event) => this.setJointType(part, (e.target as HTMLSelectElement).value)}>
|
|
945
|
+
<option value="" ?selected=${!joint}>${i18next.t('figure.label.joint-type-none')}</option>
|
|
946
|
+
${(JOINT_TYPES as readonly JointType[]).map(
|
|
947
|
+
type => html`<option value=${type} ?selected=${joint?.type === type}>${i18next.t('figure.label.joint-type-' + type)}</option>`
|
|
948
|
+
)}
|
|
949
|
+
</select>
|
|
950
|
+
</div>
|
|
951
|
+
|
|
952
|
+
${joint
|
|
953
|
+
? html`
|
|
954
|
+
<div inline-field>
|
|
955
|
+
<label>${i18next.t('figure.label.joint-axis')}</label>
|
|
956
|
+
<select @change=${(e: Event) => this.setJointAxis(joint.name, (e.target as HTMLSelectElement).value)}>
|
|
957
|
+
${axisKey === undefined
|
|
958
|
+
? html`<option value="" selected>${joint.axis.x}, ${joint.axis.y}, ${joint.axis.z}</option>`
|
|
959
|
+
: nothing}
|
|
960
|
+
${JOINT_AXES.map(one => html`<option value=${one.key} ?selected=${one.key === axisKey}>${one.key.toUpperCase()}</option>`)}
|
|
961
|
+
</select>
|
|
962
|
+
</div>
|
|
963
|
+
|
|
964
|
+
<div vector-row>
|
|
965
|
+
<label>${i18next.t('figure.label.joint-origin')}</label>
|
|
966
|
+
<div triple>
|
|
967
|
+
${(['x', 'y', 'z'] as const).map(axis =>
|
|
968
|
+
this.number(axis.toUpperCase(), joint.origin[axis], value =>
|
|
969
|
+
this.tellDraft(patchJoint(this.draft!, joint.name, { origin: { ...joint.origin, [axis]: value } }))
|
|
970
|
+
)
|
|
971
|
+
)}
|
|
972
|
+
</div>
|
|
973
|
+
</div>
|
|
974
|
+
|
|
975
|
+
${joint.limits
|
|
976
|
+
? html`
|
|
977
|
+
<div vector-row>
|
|
978
|
+
<label>${i18next.t('figure.label.joint-limits-' + unit)}</label>
|
|
979
|
+
<div triple>
|
|
980
|
+
${this.number(i18next.t('figure.label.joint-limit-min'), joint.limits.min, value =>
|
|
981
|
+
this.tellDraft(patchJoint(this.draft!, joint.name, { limits: { ...joint.limits!, min: value } }))
|
|
982
|
+
)}
|
|
983
|
+
${this.number(i18next.t('figure.label.joint-limit-max'), joint.limits.max, value =>
|
|
984
|
+
this.tellDraft(patchJoint(this.draft!, joint.name, { limits: { ...joint.limits!, max: value } }))
|
|
985
|
+
)}
|
|
986
|
+
</div>
|
|
987
|
+
</div>
|
|
988
|
+
${joint.limits.min > 0 || joint.limits.max < 0
|
|
989
|
+
? html`<p warn>${i18next.t('figure.text.joint-limits-must-include-zero')}</p>`
|
|
990
|
+
: nothing}
|
|
991
|
+
`
|
|
992
|
+
: nothing}
|
|
993
|
+
|
|
994
|
+
<p quiet>
|
|
995
|
+
${driver
|
|
996
|
+
? i18next.t('figure.text.joint-driven-by', { name: driver.label ?? driver.name })
|
|
997
|
+
: i18next.t('figure.text.joint-not-driven')}
|
|
998
|
+
</p>
|
|
999
|
+
`
|
|
1000
|
+
: nothing}
|
|
1001
|
+
</section>
|
|
1002
|
+
`
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
private setParent(name: string) {
|
|
1006
|
+
this.change(part => {
|
|
1007
|
+
const { parent: _dropped, ...rest } = part
|
|
1008
|
+
return name === '' ? (rest as FigurePart) : { ...rest, parent: name }
|
|
1009
|
+
})
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
private setJointType(part: FigurePart, value: string) {
|
|
1013
|
+
if (!this.draft) return
|
|
1014
|
+
this.tellDraft(setJointType(this.draft, part, value === '' ? undefined : (value as JointType)))
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
private setJointAxis(name: string, key: string) {
|
|
1018
|
+
const choice = JOINT_AXES.find(one => one.key === key)
|
|
1019
|
+
if (!choice || !this.draft) return
|
|
1020
|
+
this.tellDraft(patchJoint(this.draft, name, { axis: { ...choice.axis } }))
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
private tellDraft(draft: FigureDraft) {
|
|
1024
|
+
this.dispatchEvent(new CustomEvent('update-draft', { detail: { draft }, bubbles: true, composed: true }))
|
|
1025
|
+
}
|
|
1026
|
+
|
|
887
1027
|
/**
|
|
888
1028
|
* 역할 하나를 켜고 끈다.
|
|
889
1029
|
*
|
|
@@ -6,7 +6,7 @@ import { compile, validate } from '@hatiolab/figure-model'
|
|
|
6
6
|
import type { FigureSource } from '@hatiolab/figure-model'
|
|
7
7
|
import { create, registerFigure } from '@hatiolab/things-scene'
|
|
8
8
|
|
|
9
|
-
import { DEFAULT_VIEW, sceneLook, WORKBENCH } from './figure-view.js'
|
|
9
|
+
import { DEFAULT_VIEW, previewBoardDepth, sceneLook, WORKBENCH } from './figure-view.js'
|
|
10
10
|
import type { ViewSettings } from './figure-view.js'
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -435,7 +435,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
435
435
|
어느 경우에도 담기는 경계만 잡는다. 바닥 기반은 가장 큰 것의 키까지, 천정 기반은
|
|
436
436
|
천장까지다. 배치를 높이로 옮기는 계산을 여기서 다시 쓰면 그것이 두 벌째가 된다.
|
|
437
437
|
*/
|
|
438
|
-
depth:
|
|
438
|
+
depth: previewBoardDepth(source, view.ceilingHeight, biggest)
|
|
439
439
|
}
|
|
440
440
|
|
|
441
441
|
this.scene = create({
|
|
@@ -128,6 +128,12 @@ export interface FigureDraft {
|
|
|
128
128
|
* 주나**를 말하는 다른 칸이다.
|
|
129
129
|
*/
|
|
130
130
|
capabilities?: FigureSource['capabilities']
|
|
131
|
+
/**
|
|
132
|
+
* How parts move relative to their parents (ADR-0066). Kept in figure coordinates as stored, the same
|
|
133
|
+
* terms the inspector shows. Carried even when nothing edits them, so opening and saving never drops
|
|
134
|
+
* them.
|
|
135
|
+
*/
|
|
136
|
+
joints?: FigureSource['joints']
|
|
131
137
|
}
|
|
132
138
|
|
|
133
139
|
/** 부품 컴포넌트의 상태. 씬의 용어 그대로다. */
|
|
@@ -202,6 +208,8 @@ export interface PartModel {
|
|
|
202
208
|
* 청사진에서는 `groups` 가 아니라 `anchors` 로 간다.
|
|
203
209
|
*/
|
|
204
210
|
capability?: FigurePart['capability']
|
|
211
|
+
/** The part this one is attached to (ADR-0066). The one place a parent is written. */
|
|
212
|
+
parent?: FigurePart['parent']
|
|
205
213
|
}
|
|
206
214
|
|
|
207
215
|
const RAD_TO_DEG = 180 / Math.PI
|
|
@@ -293,7 +301,8 @@ export function toFigureSource(draft: FigureDraft, parts: PartModel[]): FigureSo
|
|
|
293
301
|
parts: parts.map(part => partTo(part, half)),
|
|
294
302
|
animations: draft.animations,
|
|
295
303
|
parameters: draft.parameters,
|
|
296
|
-
capabilities: draft.capabilities
|
|
304
|
+
capabilities: draft.capabilities,
|
|
305
|
+
joints: draft.joints?.length ? draft.joints : undefined
|
|
297
306
|
}) as FigureSource
|
|
298
307
|
}
|
|
299
308
|
|
|
@@ -342,7 +351,8 @@ function partTo(part: PartModel, half: Vec3): FigurePart {
|
|
|
342
351
|
anchor: part.anchor,
|
|
343
352
|
repeat: part.repeat,
|
|
344
353
|
label: part.label,
|
|
345
|
-
capability: part.capability
|
|
354
|
+
capability: part.capability,
|
|
355
|
+
parent: part.parent
|
|
346
356
|
}) as FigurePart
|
|
347
357
|
}
|
|
348
358
|
|
|
@@ -362,7 +372,8 @@ export function fromFigureSource(source: FigureSource): { draft: FigureDraft; pa
|
|
|
362
372
|
/* Opened as stored. A legacy `drive: 'hold'` clip is refused by the format now; stored ones were migrated. */
|
|
363
373
|
animations: source.animations?.length ? source.animations : undefined,
|
|
364
374
|
parameters: source.parameters?.length ? source.parameters : undefined,
|
|
365
|
-
capabilities: source.capabilities
|
|
375
|
+
capabilities: source.capabilities,
|
|
376
|
+
joints: source.joints?.length ? source.joints : undefined
|
|
366
377
|
}) as FigureDraft
|
|
367
378
|
|
|
368
379
|
return { draft, parts: source.parts.map(part => partFrom(part, half)) }
|
|
@@ -412,6 +423,7 @@ function partFrom(part: FigurePart, half: Vec3): PartModel {
|
|
|
412
423
|
anchor: part.anchor,
|
|
413
424
|
repeat: part.repeat,
|
|
414
425
|
label: part.label,
|
|
415
|
-
capability: part.capability
|
|
426
|
+
capability: part.capability,
|
|
427
|
+
parent: part.parent
|
|
416
428
|
}) as PartModel
|
|
417
429
|
}
|
|
@@ -130,3 +130,23 @@ export const DEFAULT_VIEW: ViewSettings = {
|
|
|
130
130
|
dirShadowEnabled: true,
|
|
131
131
|
hemiIntensity: 4.0
|
|
132
132
|
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* How tall the preview board is: enough room for the tallest instance where the figure actually stands.
|
|
136
|
+
*
|
|
137
|
+
* A floor or center figure stands on the floor, so the tallest instance is the whole height. Only a
|
|
138
|
+
* ceiling figure needs the room up to the ceiling. The board used to reach the ceiling for every figure,
|
|
139
|
+
* which made a 920 tall AGV preview 4400 tall.
|
|
140
|
+
*
|
|
141
|
+
* This is an envelope, not a placement rule: where each instance goes is still the scene's
|
|
142
|
+
* (`FigureRealObject.effectiveZPos`), and the camera fits what the scene drew.
|
|
143
|
+
*/
|
|
144
|
+
export function previewBoardDepth(
|
|
145
|
+
figure: { placement?: 'floor' | 'ceiling' | 'center'; base: { y: number } },
|
|
146
|
+
ceilingHeight: number,
|
|
147
|
+
factor: number
|
|
148
|
+
): number {
|
|
149
|
+
const tallest = figure.base.y * factor
|
|
150
|
+
const reach = figure.placement === 'ceiling' ? Math.max(ceilingHeight, tallest) : tallest
|
|
151
|
+
return Math.round(reach * 1.1)
|
|
152
|
+
}
|