@things-factory/figure-ui 10.1.25 → 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/graphql/index.ts +4 -1
- package/client/modeller/figure-animations.ts +229 -29
- package/client/modeller/figure-history-panel.ts +2 -2
- package/client/modeller/figure-inspector.ts +151 -5
- package/client/modeller/figure-preview.ts +5 -5
- package/client/modeller/figure-source.ts +33 -45
- package/client/modeller/figure-view.ts +20 -0
- package/client/modeller/joint-edits.ts +201 -0
- package/client/modeller/proposal-session.ts +8 -2
- package/client/pages/figure-modeller-page.ts +50 -15
- package/dist-client/graphql/index.js +4 -1
- package/dist-client/graphql/index.js.map +1 -1
- package/dist-client/modeller/figure-animations.d.ts +19 -0
- package/dist-client/modeller/figure-animations.js +206 -22
- package/dist-client/modeller/figure-animations.js.map +1 -1
- package/dist-client/modeller/figure-history-panel.js +2 -2
- package/dist-client/modeller/figure-history-panel.js.map +1 -1
- package/dist-client/modeller/figure-inspector.d.ts +11 -0
- package/dist-client/modeller/figure-inspector.js +124 -4
- package/dist-client/modeller/figure-inspector.js.map +1 -1
- package/dist-client/modeller/figure-preview.js +5 -5
- package/dist-client/modeller/figure-preview.js.map +1 -1
- package/dist-client/modeller/figure-source.d.ts +12 -1
- package/dist-client/modeller/figure-source.js +13 -40
- 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/modeller/proposal-session.d.ts +5 -0
- package/dist-client/modeller/proposal-session.js +3 -2
- package/dist-client/modeller/proposal-session.js.map +1 -1
- package/dist-client/pages/figure-modeller-page.d.ts +2 -22
- package/dist-client/pages/figure-modeller-page.js +46 -15
- package/dist-client/pages/figure-modeller-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -0
- package/package.json +3 -3
- package/test/ai-proposal-contract.test.ts +3 -2
- package/test/figure-source.test.ts +43 -57
- package/test/i18n-prefix-guard.test.ts +2 -3
- package/test/joint-edits.test.ts +130 -0
- package/test/preview-board-depth.test.ts +27 -0
- package/translations/en.json +119 -80
- package/translations/ja.json +208 -58
- package/translations/ko.json +139 -100
- package/translations/ms.json +206 -56
- package/translations/zh.json +207 -57
package/client/graphql/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import gql from 'graphql-tag'
|
|
2
|
+
import { i18next } from '@operato/i18n'
|
|
2
3
|
import { client } from '@operato/graphql'
|
|
3
4
|
|
|
4
5
|
import type {
|
|
@@ -29,7 +30,9 @@ function unwrap<T>(response: { data?: Record<string, unknown>; errors?: readonly
|
|
|
29
30
|
if (said) throw new Error(said)
|
|
30
31
|
|
|
31
32
|
if (!response.data) {
|
|
32
|
-
|
|
33
|
+
/* The field name stays off the screen because the user cannot act on it; it goes to the console. */
|
|
34
|
+
console.error(`[figure-ui] the server returned no data for ${field}`)
|
|
35
|
+
throw new Error(i18next.t('figure.text.server-returned-no-data'))
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
return response.data[field] as T
|
|
@@ -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
|
*
|
|
@@ -380,7 +391,6 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
380
391
|
return html`
|
|
381
392
|
<section>
|
|
382
393
|
<h3>${i18next.t('figure.label.animation')}</h3>
|
|
383
|
-
<p quiet>${i18next.t('figure.text.animation-is-driven-by-a-fact')}</p>
|
|
384
394
|
</section>
|
|
385
395
|
|
|
386
396
|
${this.renderRecipes()}
|
|
@@ -488,6 +498,7 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
488
498
|
const nameless = !parameter.name?.trim()
|
|
489
499
|
const range = parameter.range ?? NEW_RANGE
|
|
490
500
|
const backwards = !(range.max > range.min)
|
|
501
|
+
const slowness = parameter.clip?.duration ?? 0
|
|
491
502
|
|
|
492
503
|
return html`
|
|
493
504
|
<div clip ?on=${on}>
|
|
@@ -552,6 +563,21 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
552
563
|
/>
|
|
553
564
|
</div>
|
|
554
565
|
|
|
566
|
+
<div row>
|
|
567
|
+
<label>${i18next.t('figure.label.parameter-duration')}</label>
|
|
568
|
+
<input
|
|
569
|
+
type="number"
|
|
570
|
+
step="0.1"
|
|
571
|
+
min="0"
|
|
572
|
+
?bad=${slowness < 0}
|
|
573
|
+
.value=${live(parameter.clip?.duration === undefined ? '' : String(parameter.clip.duration))}
|
|
574
|
+
placeholder="0"
|
|
575
|
+
aria-label="duration"
|
|
576
|
+
@change=${(e: Event) => this.setDuration(at, (e.target as HTMLInputElement).value)}
|
|
577
|
+
/>
|
|
578
|
+
</div>
|
|
579
|
+
<p quiet>${i18next.t('figure.text.parameter-duration-empty-is-instant')}</p>
|
|
580
|
+
|
|
555
581
|
${on ? this.renderChannels('parameter', at) : nothing}
|
|
556
582
|
</div>
|
|
557
583
|
`
|
|
@@ -579,24 +605,48 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
579
605
|
* 검증은 저장할 때 말하고 여기서는 **그 자리에서** 보여 준다.
|
|
580
606
|
*/
|
|
581
607
|
private renderChannel(kind: MotionKind, channel: Channel, at: number, j: number): TemplateResult {
|
|
582
|
-
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
|
+
|
|
583
645
|
const turning = channel.path === 'rotation'
|
|
584
646
|
|
|
585
647
|
return html`
|
|
586
648
|
<div channel ?orphan=${orphan}>
|
|
587
|
-
|
|
588
|
-
<label>${i18next.t('figure.label.channel-target')}</label>
|
|
589
|
-
<select
|
|
590
|
-
.value=${live(channel.target ?? '')}
|
|
591
|
-
@change=${(e: Event) => this.setChannel(kind, at, j, { target: (e.target as HTMLSelectElement).value })}
|
|
592
|
-
>
|
|
593
|
-
${orphan ? html`<option value=${channel.target}>${channel.target}</option>` : nothing}
|
|
594
|
-
${this.partNames.map(name => html`<option value=${name}>${name}</option>`)}
|
|
595
|
-
</select>
|
|
596
|
-
<button plain @click=${() => this.removeChannel(kind, at, j)} title=${i18next.t('figure.button.remove')}>
|
|
597
|
-
<md-icon>close</md-icon>
|
|
598
|
-
</button>
|
|
599
|
-
</div>
|
|
649
|
+
${target}
|
|
600
650
|
|
|
601
651
|
${orphan ? html`<p warn>${i18next.t('figure.text.channel-target-is-gone')}</p>` : nothing}
|
|
602
652
|
|
|
@@ -634,7 +684,7 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
634
684
|
* 그래서 경로가 rotation 일 때만 낸다. 언제나 내면 저작자가 옮김·배율에도 적고, 검증이
|
|
635
685
|
* 저장할 때 「쓰이지 않는다」고 알린다. 안 보이면 적을 일이 없다.
|
|
636
686
|
*/
|
|
637
|
-
private renderPivot(kind: MotionKind, channel:
|
|
687
|
+
private renderPivot(kind: MotionKind, channel: PartChannel, at: number, j: number): TemplateResult {
|
|
638
688
|
const pivot = channel.pivot
|
|
639
689
|
const on = pivot !== undefined
|
|
640
690
|
|
|
@@ -676,13 +726,89 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
676
726
|
* 시각이 오름차순이어야 하는 것도 형식의 규칙이다. 여기서는 어긋난 줄을 붉게 내고 값을
|
|
677
727
|
* 고치지는 않는다 — 저작자가 3 을 1 로 고치던 중일 수 있다.
|
|
678
728
|
*/
|
|
679
|
-
|
|
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 {
|
|
680
799
|
const keys = channel.keys ?? []
|
|
800
|
+
/*
|
|
801
|
+
Keys on a parameter curve sit on the 0..1 value span (ADR-0051). A key above 1 is unreachable and
|
|
802
|
+
the format refuses it. The screen marks it and leaves the number alone: rewriting what the author
|
|
803
|
+
typed would hide what they wrote.
|
|
804
|
+
*/
|
|
805
|
+
const onValues = kind === 'parameter'
|
|
806
|
+
const outside = (key: Keyframe) => onValues && key.at > 1
|
|
681
807
|
|
|
682
808
|
return html`
|
|
683
809
|
<table keys>
|
|
684
810
|
<tr>
|
|
685
|
-
<th>${i18next.t('figure.label.key-at')}</th>
|
|
811
|
+
<th>${i18next.t(onValues ? 'figure.label.key-at-value' : 'figure.label.key-at')}</th>
|
|
686
812
|
${AXES.map(axis => html`<th>${axis}</th>`)}
|
|
687
813
|
<th></th>
|
|
688
814
|
</tr>
|
|
@@ -693,11 +819,16 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
693
819
|
<td>
|
|
694
820
|
<input
|
|
695
821
|
type="number"
|
|
696
|
-
step
|
|
822
|
+
step=${onValues ? '0.05' : '0.1'}
|
|
697
823
|
min="0"
|
|
698
|
-
|
|
824
|
+
max=${onValues ? '1' : nothing}
|
|
825
|
+
?bad=${backwards || key.at < 0 || outside(key)}
|
|
699
826
|
.value=${live(String(key.at))}
|
|
700
|
-
title=${backwards
|
|
827
|
+
title=${backwards
|
|
828
|
+
? i18next.t('figure.text.keys-must-rise')
|
|
829
|
+
: outside(key)
|
|
830
|
+
? i18next.t('figure.text.parameter-key-within-span')
|
|
831
|
+
: ''}
|
|
701
832
|
@change=${(e: Event) =>
|
|
702
833
|
this.setKey(kind, at, j, k, { at: Math.max(0, Number((e.target as HTMLInputElement).value) || 0) })}
|
|
703
834
|
/>
|
|
@@ -736,6 +867,7 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
736
867
|
${keys.some((key, k) => k > 0 && key.at <= (keys[k - 1]!.at ?? 0))
|
|
737
868
|
? html`<p warn>${i18next.t('figure.text.keys-must-rise')}</p>`
|
|
738
869
|
: nothing}
|
|
870
|
+
${keys.some(outside) ? html`<p warn>${i18next.t('figure.text.parameter-key-within-span')}</p>` : nothing}
|
|
739
871
|
|
|
740
872
|
<button @click=${() => this.addKey(kind, at, j)}>
|
|
741
873
|
<md-icon>add</md-icon>${i18next.t('figure.button.add-key')}
|
|
@@ -883,6 +1015,19 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
883
1015
|
this.patchParameter(at, { range: { ...(parameter.range ?? NEW_RANGE), ...patch } })
|
|
884
1016
|
}
|
|
885
1017
|
|
|
1018
|
+
/**
|
|
1019
|
+
* Transition time. Clearing the input removes the field, which means instant. 0 is instant too, but
|
|
1020
|
+
* a stored 0 and a missing field are different sources, so an empty input is never saved as 0.
|
|
1021
|
+
*/
|
|
1022
|
+
private setDuration(at: number, raw: string): void {
|
|
1023
|
+
const parameter = this.parameters[at]
|
|
1024
|
+
if (!parameter) return
|
|
1025
|
+
const { duration: _dropped, ...rest } = parameter.clip
|
|
1026
|
+
const text = raw.trim()
|
|
1027
|
+
const clip = text === '' || !Number.isFinite(Number(text)) ? rest : { ...rest, duration: Number(text) }
|
|
1028
|
+
this.patchParameter(at, { clip })
|
|
1029
|
+
}
|
|
1030
|
+
|
|
886
1031
|
private setDefault(at: number, value: number): void {
|
|
887
1032
|
this.patchParameter(at, { default: value })
|
|
888
1033
|
}
|
|
@@ -907,11 +1052,36 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
907
1052
|
])
|
|
908
1053
|
}
|
|
909
1054
|
|
|
910
|
-
|
|
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 {
|
|
911
1061
|
const channel = this.channelsOf(kind, at)[j]
|
|
912
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
|
+
}
|
|
1079
|
+
|
|
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
|
|
913
1083
|
|
|
914
|
-
const next:
|
|
1084
|
+
const next: PartChannel = { ...channel, ...patch }
|
|
915
1085
|
/* `undefined` 를 얹는 것은 지우는 뜻이다. 남겨 두면 저장 형식에 빈 칸이 실린다. */
|
|
916
1086
|
if ('pivot' in patch && patch.pivot === undefined) delete next.pivot
|
|
917
1087
|
this.patchChannel(kind, at, j, next)
|
|
@@ -926,10 +1096,10 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
926
1096
|
*/
|
|
927
1097
|
private setPath(kind: MotionKind, at: number, j: number, path: ChannelPath): void {
|
|
928
1098
|
const channel = this.channelsOf(kind, at)[j]
|
|
929
|
-
if (!channel) return
|
|
1099
|
+
if (!channel || isJointChannel(channel)) return
|
|
930
1100
|
|
|
931
1101
|
const crossing = (channel.path === 'scale') !== (path === 'scale')
|
|
932
|
-
const next:
|
|
1102
|
+
const next: PartChannel = {
|
|
933
1103
|
...channel,
|
|
934
1104
|
path,
|
|
935
1105
|
keys: crossing ? channel.keys.map(key => ({ at: key.at, value: startValue(path) })) : channel.keys
|
|
@@ -953,14 +1123,44 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
953
1123
|
const channel = this.channelsOf(kind, at)[j]
|
|
954
1124
|
if (!channel) return
|
|
955
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
|
+
|
|
956
1139
|
const last = channel.keys[channel.keys.length - 1]
|
|
1140
|
+
|
|
1141
|
+
/*
|
|
1142
|
+
A parameter curve ends at 1, so appending a key would make it unreachable. Insert it between the last two keys.
|
|
1143
|
+
*/
|
|
1144
|
+
if (kind === 'parameter' && channel.keys.length >= 2) {
|
|
1145
|
+
const before = channel.keys[channel.keys.length - 2]!
|
|
1146
|
+
const middle: Keyframe = { at: (before.at + last!.at) / 2, value: { ...before.value } }
|
|
1147
|
+
this.patchChannel(kind, at, j, { ...channel, keys: [...channel.keys.slice(0, -1), middle, last!] })
|
|
1148
|
+
return
|
|
1149
|
+
}
|
|
1150
|
+
|
|
957
1151
|
const key: Keyframe = { at: (last?.at ?? 0) + 1, value: { ...(last?.value ?? startValue(channel.path)) } }
|
|
958
1152
|
this.patchChannel(kind, at, j, { ...channel, keys: [...channel.keys, key] })
|
|
959
1153
|
}
|
|
960
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
|
+
|
|
961
1161
|
private setKey(kind: MotionKind, at: number, j: number, k: number, patch: Partial<Keyframe>): void {
|
|
962
1162
|
const channel = this.channelsOf(kind, at)[j]
|
|
963
|
-
if (!channel) return
|
|
1163
|
+
if (!channel || isJointChannel(channel)) return
|
|
964
1164
|
this.patchChannel(kind, at, j, {
|
|
965
1165
|
...channel,
|
|
966
1166
|
keys: channel.keys.map((key, i) => (i === k ? { ...key, ...patch } : key))
|
|
@@ -971,6 +1171,6 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
971
1171
|
private removeKey(kind: MotionKind, at: number, j: number, k: number): void {
|
|
972
1172
|
const channel = this.channelsOf(kind, at)[j]
|
|
973
1173
|
if (!channel || channel.keys.length <= 2) return
|
|
974
|
-
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)
|
|
975
1175
|
}
|
|
976
1176
|
}
|
|
@@ -438,11 +438,11 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
|
|
|
438
438
|
${(finding.fixes ?? []).map(fix => html`
|
|
439
439
|
${fix.safe ? html`
|
|
440
440
|
<button apply-fix @click=${() => this.applyFix(fix)}>
|
|
441
|
-
|
|
441
|
+
${i18next.t('figure.button.apply-fix', { part: fix.part, axis: fix.axis.toUpperCase(), from: fix.from ?? 'auto', to: fix.to })}
|
|
442
442
|
</button>
|
|
443
443
|
` : nothing}
|
|
444
444
|
<button preview-fix @click=${() => this.previewFix(fix)}>
|
|
445
|
-
|
|
445
|
+
${i18next.t('figure.button.preview-fix', { part: fix.part, axis: fix.axis.toUpperCase(), from: fix.from ?? 'auto', to: fix.to })}
|
|
446
446
|
</button>
|
|
447
447
|
`)}
|
|
448
448
|
</li>
|
|
@@ -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
|
*
|
|
@@ -1704,8 +1844,14 @@ export class FigureInspector extends localize(i18next)(LitElement) {
|
|
|
1704
1844
|
}
|
|
1705
1845
|
|
|
1706
1846
|
private renderAnchors(part: FigurePart) {
|
|
1847
|
+
/*
|
|
1848
|
+
The scene draft stores the box height as depth, so format y is draft.depth and z is draft.height.
|
|
1849
|
+
This used to pass them swapped, and every automatic anchor still came out the same: the
|
|
1850
|
+
nearest-face rule reads the sign of a centred position, which does not depend on the box size.
|
|
1851
|
+
It is fixed so the next rule that does read the box does not inherit the mistake.
|
|
1852
|
+
*/
|
|
1707
1853
|
const base = this.draft
|
|
1708
|
-
? { x: this.draft.width, y: this.draft.
|
|
1854
|
+
? { x: this.draft.width, y: this.draft.depth, z: this.draft.height }
|
|
1709
1855
|
: { x: 0, y: 0, z: 0 }
|
|
1710
1856
|
const nameOf = (rule: string, axis: Axis) =>
|
|
1711
1857
|
`${i18next.t(rule === 'min' || rule === 'max' ? `figure.label.anchor-${rule}-${axis}` : `figure.label.anchor-${rule}`)} (${rule})`
|
|
@@ -1748,7 +1894,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
|
|
|
1748
1894
|
*/
|
|
1749
1895
|
return html`
|
|
1750
1896
|
<div anchor-row>
|
|
1751
|
-
<label>${axis.toUpperCase()}
|
|
1897
|
+
<label>${i18next.t('figure.label.axis-name', { axis: axis.toUpperCase() })}</label>
|
|
1752
1898
|
<div anchor-options>
|
|
1753
1899
|
${(['auto', 'scale', 'min', 'center', 'max', 'span'] as const).map(rule => {
|
|
1754
1900
|
const selected = rule === 'auto' ? !said : rule === said
|
|
@@ -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
|
/**
|
|
@@ -47,7 +47,7 @@ export const PREVIEW_FACTOR = 2
|
|
|
47
47
|
* 안 담겨서 **네 칸씩 두 줄**로 놓는다.
|
|
48
48
|
*/
|
|
49
49
|
export const PREVIEW_WAYS: readonly { name: string; axes: readonly ('x' | 'y' | 'z')[] }[] = [
|
|
50
|
-
{ name: '
|
|
50
|
+
{ name: 'base', axes: [] },
|
|
51
51
|
{ name: 'x', axes: ['x'] },
|
|
52
52
|
{ name: 'y', axes: ['y'] },
|
|
53
53
|
{ name: 'z', axes: ['z'] },
|
|
@@ -208,7 +208,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
208
208
|
${this.cannot
|
|
209
209
|
? html`<div cannot>${this.cannot}</div>`
|
|
210
210
|
: html`<div legend>
|
|
211
|
-
${PREVIEW_WAYS.map(way => html`<span>${way.name}</span>`)}
|
|
211
|
+
${PREVIEW_WAYS.map(way => html`<span>${way.axes.length === 0 ? i18next.t('figure.label.preview-base') : way.name}</span>`)}
|
|
212
212
|
</div>
|
|
213
213
|
${clips.length + parameters.length > 0 ? this.renderDrivers(clips, parameters) : ''}`}
|
|
214
214
|
`
|
|
@@ -253,7 +253,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
253
253
|
|
|
254
254
|
return html`
|
|
255
255
|
<label>
|
|
256
|
-
<span>${clip.name} <span drive
|
|
256
|
+
<span>${clip.name} <span drive>${i18next.t('figure.label.playback-speed')}</span></span>
|
|
257
257
|
<input
|
|
258
258
|
type="range"
|
|
259
259
|
min="0"
|
|
@@ -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({
|