@things-factory/figure-ui 10.1.26 → 10.1.28
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 +23 -0
- package/client/modeller/base-box.ts +104 -0
- package/client/modeller/figure-animations.ts +253 -32
- package/client/modeller/figure-canvas.ts +18 -3
- package/client/modeller/figure-inspector.ts +292 -3
- package/client/modeller/figure-preview.ts +57 -2
- package/client/modeller/figure-report.ts +9 -4
- package/client/modeller/figure-side.ts +9 -1
- package/client/modeller/figure-source.ts +24 -4
- package/client/modeller/figure-view.ts +20 -0
- package/client/modeller/joint-edits.ts +262 -0
- package/client/modeller/joint-gizmo.ts +118 -0
- package/client/modeller/pose-defaults.ts +67 -0
- package/client/modeller/proposal.ts +9 -3
- package/client/modeller/scene-budget.ts +31 -40
- package/client/pages/figure-list-page.ts +4 -9
- package/client/pages/figure-modeller-page.ts +124 -41
- package/dist-client/graphql/index.d.ts +7 -0
- package/dist-client/graphql/index.js +21 -0
- package/dist-client/graphql/index.js.map +1 -1
- package/dist-client/modeller/base-box.d.ts +47 -0
- package/dist-client/modeller/base-box.js +52 -0
- package/dist-client/modeller/base-box.js.map +1 -0
- package/dist-client/modeller/figure-animations.d.ts +29 -0
- package/dist-client/modeller/figure-animations.js +228 -25
- package/dist-client/modeller/figure-animations.js.map +1 -1
- package/dist-client/modeller/figure-canvas.d.ts +9 -0
- package/dist-client/modeller/figure-canvas.js +14 -3
- package/dist-client/modeller/figure-canvas.js.map +1 -1
- package/dist-client/modeller/figure-inspector.d.ts +37 -0
- package/dist-client/modeller/figure-inspector.js +253 -2
- package/dist-client/modeller/figure-inspector.js.map +1 -1
- package/dist-client/modeller/figure-preview.d.ts +15 -0
- package/dist-client/modeller/figure-preview.js +48 -2
- package/dist-client/modeller/figure-preview.js.map +1 -1
- package/dist-client/modeller/figure-report.d.ts +3 -2
- package/dist-client/modeller/figure-report.js +9 -4
- package/dist-client/modeller/figure-report.js.map +1 -1
- package/dist-client/modeller/figure-side.d.ts +5 -1
- package/dist-client/modeller/figure-side.js +12 -0
- package/dist-client/modeller/figure-side.js.map +1 -1
- package/dist-client/modeller/figure-source.d.ts +15 -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 +49 -0
- package/dist-client/modeller/joint-edits.js +223 -0
- package/dist-client/modeller/joint-edits.js.map +1 -0
- package/dist-client/modeller/joint-gizmo.d.ts +31 -0
- package/dist-client/modeller/joint-gizmo.js +104 -0
- package/dist-client/modeller/joint-gizmo.js.map +1 -0
- package/dist-client/modeller/pose-defaults.d.ts +9 -0
- package/dist-client/modeller/pose-defaults.js +35 -0
- package/dist-client/modeller/pose-defaults.js.map +1 -0
- package/dist-client/modeller/proposal.d.ts +1 -1
- package/dist-client/modeller/proposal.js +8 -3
- package/dist-client/modeller/proposal.js.map +1 -1
- package/dist-client/modeller/scene-budget.d.ts +31 -34
- package/dist-client/modeller/scene-budget.js +28 -37
- package/dist-client/modeller/scene-budget.js.map +1 -1
- package/dist-client/pages/figure-list-page.js +4 -9
- package/dist-client/pages/figure-list-page.js.map +1 -1
- package/dist-client/pages/figure-modeller-page.d.ts +43 -0
- package/dist-client/pages/figure-modeller-page.js +121 -40
- package/dist-client/pages/figure-modeller-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/test/ai-proposal-contract.test.ts +16 -11
- package/test/base-box.test.ts +154 -0
- package/test/figure-source.test.ts +32 -0
- package/test/joint-edits.test.ts +165 -0
- package/test/joint-gizmo.test.ts +174 -0
- package/test/pose-defaults.test.ts +71 -0
- package/test/preview-board-depth.test.ts +27 -0
- package/test/scene-budget.test.ts +53 -0
- package/translations/en.json +37 -2
- package/translations/ja.json +37 -2
- package/translations/ko.json +37 -2
- package/translations/ms.json +37 -2
- package/translations/zh.json +37 -2
package/client/graphql/index.ts
CHANGED
|
@@ -323,6 +323,29 @@ export async function inspectFigure(id: string): Promise<FigureInspection> {
|
|
|
323
323
|
}
|
|
324
324
|
|
|
325
325
|
/** 발행된 판들 — 최근 것부터 열 개. */
|
|
326
|
+
/**
|
|
327
|
+
* 직전 발행판의 정본. 한 번도 발행 안 했으면 `undefined`.
|
|
328
|
+
*
|
|
329
|
+
* 저작 중에 「이 고침이 이미 놓인 것을 깨나」를 그 자리에서 보여 주려면 견줄 것이 필요하다. 저장
|
|
330
|
+
* 전 초안은 서버가 모르므로 판정도 화면에서 한다 — 형식의 `contractChanges` 를 같이 쓴다.
|
|
331
|
+
*/
|
|
332
|
+
export async function fetchReleasedSource(id: string): Promise<string | undefined> {
|
|
333
|
+
const response = await client.query({
|
|
334
|
+
query: gql`
|
|
335
|
+
query FigureReleased($id: String!) {
|
|
336
|
+
figureReleased(id: $id) {
|
|
337
|
+
version
|
|
338
|
+
source
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
`,
|
|
342
|
+
variables: { id },
|
|
343
|
+
fetchPolicy: 'network-only'
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
return unwrap<{ source?: string }>(response, 'figureReleased')?.source ?? undefined
|
|
347
|
+
}
|
|
348
|
+
|
|
326
349
|
export async function fetchFigureVersions(id: string): Promise<FigureVersion[]> {
|
|
327
350
|
const response = await client.query({
|
|
328
351
|
query: gql`
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright © HatioLab Inc. All rights reserved.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { FigureDraft, PartModel } from './figure-source.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 기준 상자를 고친다 — **부품은 제자리에 둔 채.**
|
|
9
|
+
*
|
|
10
|
+
* ## 왜 화면에 있어야 하나
|
|
11
|
+
*
|
|
12
|
+
* 기준 상자는 도면에 놓일 때의 배율을 정하는 수다(ADR-0044 ①). 부품이 그 상자를 넘으면 발행이
|
|
13
|
+
* 막히고, 고치는 길은 「상자 숫자 하나를 키우는 것」인데 **화면에 그 칸이 없었다.** 저작자는
|
|
14
|
+
* 넘쳤다는 말만 듣고 고칠 자리를 못 찾았다.
|
|
15
|
+
*
|
|
16
|
+
* ## 상자를 키우면 부품이 움직인다 — 그것을 되돌린다
|
|
17
|
+
*
|
|
18
|
+
* 저작면의 부품 자리는 판의 왼쪽 위에서 잰 값이고, 저장 형식의 자리는 **상자의 한가운데**에서 잰
|
|
19
|
+
* 값이다(`toFigureSource` 가 절반을 뺀다). 그래서 상자만 키우면 한가운데가 옮겨 가고 부품이 전부
|
|
20
|
+
* 반대로 밀린다. 여기서 늘어난 절반만큼 부품을 함께 옮겨, 저장되는 자리가 한 값도 안 바뀌게 한다.
|
|
21
|
+
*
|
|
22
|
+
* ## 「부품에 맞추기」는 한가운데를 지킨다
|
|
23
|
+
*
|
|
24
|
+
* 부품을 감싸는 가장 작은 상자를 만들 때, 부품 쪽을 **옮기지 않는다.** 한가운데에서 가장 먼
|
|
25
|
+
* 부품까지의 거리를 양쪽으로 잡는다. 부품이 한쪽으로 치우쳐 있으면 반대쪽에 여유가 남는데,
|
|
26
|
+
* 그것이 사실이다 — 치우친 것을 가운데로 끌어오면 저작자가 놓은 자리가 소리 없이 달라지고,
|
|
27
|
+
* 발행된 도형에서는 문(port)의 자리가 함께 움직인다.
|
|
28
|
+
*
|
|
29
|
+
* 높이는 밑면이 바닥이라 한쪽만 늘린다. 바닥 아래로 내려간 부품이 있으면 그 부품은 상자 밖에
|
|
30
|
+
* 남는다 — 그것을 담으려면 부품을 올려야 하고, 그것은 저작자가 정할 일이다. 발행 관문이 이미
|
|
31
|
+
* `part-outside-base` 로 말한다.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export interface BaseSize {
|
|
35
|
+
/** 저작면의 가로 — 형식의 `base.x`. */
|
|
36
|
+
width: number
|
|
37
|
+
/** 저작면의 세로 — 형식의 `base.z`. */
|
|
38
|
+
height: number
|
|
39
|
+
/** 높이 — 형식의 `base.y`. */
|
|
40
|
+
depth: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 상자는 밀리미터 하나보다 작아질 수 없다. 0 이 되면 배율이 0 으로 나뉜다. */
|
|
44
|
+
const LEAST = 1
|
|
45
|
+
|
|
46
|
+
const round = (value: number) => Math.round(value)
|
|
47
|
+
|
|
48
|
+
export const baseOf = (draft: FigureDraft): BaseSize => ({
|
|
49
|
+
width: draft.width,
|
|
50
|
+
height: draft.height,
|
|
51
|
+
depth: draft.depth
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
/** 부품 전부를 담는 가장 작은 상자. 부품이 없으면 잴 것이 없다. */
|
|
55
|
+
export function baseFittingParts(draft: FigureDraft, parts: readonly PartModel[]): BaseSize | undefined {
|
|
56
|
+
if (parts.length === 0) return undefined
|
|
57
|
+
|
|
58
|
+
const middle = { x: draft.width / 2, y: draft.height / 2 }
|
|
59
|
+
let half = { x: 0, y: 0 }
|
|
60
|
+
let tallest = 0
|
|
61
|
+
|
|
62
|
+
for (const part of parts) {
|
|
63
|
+
half = {
|
|
64
|
+
x: Math.max(half.x, Math.abs(part.left - middle.x), Math.abs(part.left + part.width - middle.x)),
|
|
65
|
+
y: Math.max(half.y, Math.abs(part.top - middle.y), Math.abs(part.top + part.height - middle.y))
|
|
66
|
+
}
|
|
67
|
+
tallest = Math.max(tallest, part.zPos + part.depth)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
width: Math.max(LEAST, round(half.x * 2)),
|
|
72
|
+
height: Math.max(LEAST, round(half.y * 2)),
|
|
73
|
+
depth: Math.max(LEAST, round(tallest))
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 상자를 이 크기로. 부품은 저장되는 자리가 그대로이도록 함께 옮긴다.
|
|
79
|
+
*
|
|
80
|
+
* 달라질 것이 없으면 **받은 것을 그대로 돌려준다** — 고친 것이 없는데 「저장 안 됨」이 붙지 않게.
|
|
81
|
+
*/
|
|
82
|
+
export function resizeBase(
|
|
83
|
+
draft: FigureDraft,
|
|
84
|
+
parts: readonly PartModel[],
|
|
85
|
+
next: BaseSize
|
|
86
|
+
): { draft: FigureDraft; parts: readonly PartModel[] } {
|
|
87
|
+
const size = {
|
|
88
|
+
width: Math.max(LEAST, round(next.width)),
|
|
89
|
+
height: Math.max(LEAST, round(next.height)),
|
|
90
|
+
depth: Math.max(LEAST, round(next.depth))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (size.width === draft.width && size.height === draft.height && size.depth === draft.depth) {
|
|
94
|
+
return { draft, parts }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const shift = { x: (size.width - draft.width) / 2, y: (size.height - draft.height) / 2 }
|
|
98
|
+
const moved =
|
|
99
|
+
shift.x === 0 && shift.y === 0
|
|
100
|
+
? parts
|
|
101
|
+
: parts.map(part => ({ ...part, left: part.left + shift.x, top: part.top + shift.y }))
|
|
102
|
+
|
|
103
|
+
return { draft: { ...draft, ...size }, parts: moved }
|
|
104
|
+
}
|
|
@@ -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'
|
|
@@ -124,6 +130,18 @@ function freeName(taken: readonly { name: string }[], stem: string): string {
|
|
|
124
130
|
for (let n = 2; ; n++) if (!used.has(`${stem}-${n}`)) return `${stem}-${n}`
|
|
125
131
|
}
|
|
126
132
|
|
|
133
|
+
/**
|
|
134
|
+
* 파라미터 범위에 적히는 단위 — **권하는 것이지 가두는 것이 아니다.**
|
|
135
|
+
*
|
|
136
|
+
* 관절이 만드는 파라미터가 `deg` 와 `mm` 을 쓰고(회전과 직선), 그리퍼·문 같은 값은 `%` 를 쓴다.
|
|
137
|
+
* 속도는 `mm/s` 와 `rpm`, 시간은 `s` 다. 이 여섯이 지금 쓰이는 전부이고, 목록에 없는 말을 적는
|
|
138
|
+
* 길은 그대로 열려 있다.
|
|
139
|
+
*/
|
|
140
|
+
const PARAMETER_UNITS = ['deg', 'mm', '%', 'mm/s', 'rpm', 's'] as const
|
|
141
|
+
|
|
142
|
+
/** 목록 대신 손으로 적겠다는 표. `select` 의 값으로 쓴다. */
|
|
143
|
+
const UNIT_TYPE_IT = '__type-it'
|
|
144
|
+
|
|
127
145
|
@customElement('figure-animations')
|
|
128
146
|
export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
129
147
|
static styles = [
|
|
@@ -219,6 +237,21 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
219
237
|
min-width: 3.2em;
|
|
220
238
|
color: var(--md-sys-color-on-surface-variant);
|
|
221
239
|
}
|
|
240
|
+
/*
|
|
241
|
+
칸이 줄어들 줄 알아야 한다. 숫자 입력칸은 제 기본 너비(20자 남짓)를 갖고 있고 flex 는
|
|
242
|
+
기본 크기 아래로는 안 줄이므로, 범위처럼 한 줄에 셋이 서는 줄은 패널 밖으로 밀려
|
|
243
|
+
오른쪽 칸이 잘렸다.
|
|
244
|
+
*/
|
|
245
|
+
div[row] > input {
|
|
246
|
+
flex: 1 1 0;
|
|
247
|
+
min-width: 0;
|
|
248
|
+
}
|
|
249
|
+
/* 단위는 「mm」 정도의 짧은 말이라 숫자 칸보다 좁게 둔다. 고르는 칸도 같은 폭이다. */
|
|
250
|
+
div[row] > input[unit],
|
|
251
|
+
div[row] > select[unit] {
|
|
252
|
+
flex: 0 1 5.5em;
|
|
253
|
+
min-width: 0;
|
|
254
|
+
}
|
|
222
255
|
|
|
223
256
|
/* ── keyframe ─────────────────────────────────────────────────── */
|
|
224
257
|
|
|
@@ -335,6 +368,9 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
335
368
|
@state() private open = ''
|
|
336
369
|
@state() private recipeTarget = ''
|
|
337
370
|
|
|
371
|
+
/** 단위를 손으로 적는 중인 파라미터. 목록에 없는 말을 적으려고 연 칸이다. */
|
|
372
|
+
@state() private typingUnit = new Set<number>()
|
|
373
|
+
|
|
338
374
|
private get clips(): Clip[] {
|
|
339
375
|
return this.draft?.animations ?? []
|
|
340
376
|
}
|
|
@@ -357,6 +393,11 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
357
393
|
return this.parts.map(part => part.name).filter(name => !!name)
|
|
358
394
|
}
|
|
359
395
|
|
|
396
|
+
/** Joints a channel can drive instead of a part (ADR-0066). */
|
|
397
|
+
private get joints(): NonNullable<FigureDraft['joints']> {
|
|
398
|
+
return this.draft?.joints ?? []
|
|
399
|
+
}
|
|
400
|
+
|
|
360
401
|
/**
|
|
361
402
|
* 몇 부품이 움직이나 — 채널이 가리키는 **서로 다른 부품**의 수.
|
|
362
403
|
*
|
|
@@ -531,14 +572,7 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
531
572
|
aria-label="max"
|
|
532
573
|
@change=${(e: Event) => this.setRange(at, { max: Number((e.target as HTMLInputElement).value) || 0 })}
|
|
533
574
|
/>
|
|
534
|
-
|
|
535
|
-
unit
|
|
536
|
-
.value=${live(range.unit ?? '')}
|
|
537
|
-
placeholder="mm"
|
|
538
|
-
aria-label="unit"
|
|
539
|
-
title=${i18next.t('figure.text.unit-is-a-word-not-a-rule')}
|
|
540
|
-
@change=${(e: Event) => this.setRange(at, { unit: (e.target as HTMLInputElement).value.trim() })}
|
|
541
|
-
/>
|
|
575
|
+
${this.renderUnit(at, range.unit ?? '')}
|
|
542
576
|
</div>
|
|
543
577
|
${backwards ? html`<p warn>${i18next.t('figure.text.range-needs-room')}</p>` : nothing}
|
|
544
578
|
|
|
@@ -594,24 +628,48 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
594
628
|
* 검증은 저장할 때 말하고 여기서는 **그 자리에서** 보여 준다.
|
|
595
629
|
*/
|
|
596
630
|
private renderChannel(kind: MotionKind, channel: Channel, at: number, j: number): TemplateResult {
|
|
597
|
-
const
|
|
631
|
+
const joint = this.joints.find(one => one.name === channel.target)
|
|
632
|
+
const orphan = !this.partNames.includes(channel.target) && !joint
|
|
633
|
+
const target = html`
|
|
634
|
+
<div row>
|
|
635
|
+
<label>${i18next.t('figure.label.channel-target')}</label>
|
|
636
|
+
<select
|
|
637
|
+
.value=${live(channel.target ?? '')}
|
|
638
|
+
@change=${(e: Event) => this.retarget(kind, at, j, (e.target as HTMLSelectElement).value)}
|
|
639
|
+
>
|
|
640
|
+
${orphan ? html`<option value=${channel.target}>${channel.target}</option>` : nothing}
|
|
641
|
+
<optgroup label=${i18next.t('figure.label.channel-target-parts')}>
|
|
642
|
+
${this.partNames.map(name => html`<option value=${name}>${name}</option>`)}
|
|
643
|
+
</optgroup>
|
|
644
|
+
${this.joints.length > 0
|
|
645
|
+
? html`<optgroup label=${i18next.t('figure.label.channel-target-joints')}>
|
|
646
|
+
${this.joints.map(one => html`<option value=${one.name}>${one.name}</option>`)}
|
|
647
|
+
</optgroup>`
|
|
648
|
+
: nothing}
|
|
649
|
+
</select>
|
|
650
|
+
<button plain @click=${() => this.removeChannel(kind, at, j)} title=${i18next.t('figure.button.remove')}>
|
|
651
|
+
<md-icon>close</md-icon>
|
|
652
|
+
</button>
|
|
653
|
+
</div>
|
|
654
|
+
`
|
|
655
|
+
|
|
656
|
+
/* A joint channel has no path or pivot: the joint says what moves. Each key is the joint coordinate. */
|
|
657
|
+
if (isJointChannel(channel)) {
|
|
658
|
+
const unit = joint?.type === 'prismatic' ? 'mm' : 'deg'
|
|
659
|
+
return html`
|
|
660
|
+
<div channel ?orphan=${orphan}>
|
|
661
|
+
${target} ${orphan ? html`<p warn>${i18next.t('figure.text.channel-target-is-gone')}</p>` : nothing}
|
|
662
|
+
<p quiet>${i18next.t('figure.text.joint-channel-value-' + unit)}</p>
|
|
663
|
+
${this.renderJointKeys(kind, channel, at, j, joint?.limits)}
|
|
664
|
+
</div>
|
|
665
|
+
`
|
|
666
|
+
}
|
|
667
|
+
|
|
598
668
|
const turning = channel.path === 'rotation'
|
|
599
669
|
|
|
600
670
|
return html`
|
|
601
671
|
<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>
|
|
672
|
+
${target}
|
|
615
673
|
|
|
616
674
|
${orphan ? html`<p warn>${i18next.t('figure.text.channel-target-is-gone')}</p>` : nothing}
|
|
617
675
|
|
|
@@ -649,7 +707,7 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
649
707
|
* 그래서 경로가 rotation 일 때만 낸다. 언제나 내면 저작자가 옮김·배율에도 적고, 검증이
|
|
650
708
|
* 저장할 때 「쓰이지 않는다」고 알린다. 안 보이면 적을 일이 없다.
|
|
651
709
|
*/
|
|
652
|
-
private renderPivot(kind: MotionKind, channel:
|
|
710
|
+
private renderPivot(kind: MotionKind, channel: PartChannel, at: number, j: number): TemplateResult {
|
|
653
711
|
const pivot = channel.pivot
|
|
654
712
|
const on = pivot !== undefined
|
|
655
713
|
|
|
@@ -691,7 +749,76 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
691
749
|
* 시각이 오름차순이어야 하는 것도 형식의 규칙이다. 여기서는 어긋난 줄을 붉게 내고 값을
|
|
692
750
|
* 고치지는 않는다 — 저작자가 3 을 1 로 고치던 중일 수 있다.
|
|
693
751
|
*/
|
|
694
|
-
|
|
752
|
+
/**
|
|
753
|
+
* Keys of a joint channel: a position and one joint coordinate each. A value outside the joint's limits
|
|
754
|
+
* is marked and left as typed; the format refuses it on save.
|
|
755
|
+
*/
|
|
756
|
+
private renderJointKeys(
|
|
757
|
+
kind: MotionKind,
|
|
758
|
+
channel: JointChannel,
|
|
759
|
+
at: number,
|
|
760
|
+
j: number,
|
|
761
|
+
limits?: { min: number; max: number }
|
|
762
|
+
): TemplateResult {
|
|
763
|
+
const keys = channel.keys ?? []
|
|
764
|
+
const onValues = kind === 'parameter'
|
|
765
|
+
const beyond = (key: JointKey) => !!limits && (key.value < limits.min || key.value > limits.max)
|
|
766
|
+
|
|
767
|
+
return html`
|
|
768
|
+
<table keys>
|
|
769
|
+
<tr>
|
|
770
|
+
<th>${i18next.t(onValues ? 'figure.label.key-at-value' : 'figure.label.key-at')}</th>
|
|
771
|
+
<th>${i18next.t('figure.label.joint-value')}</th>
|
|
772
|
+
<th></th>
|
|
773
|
+
</tr>
|
|
774
|
+
${keys.map((key, k) => {
|
|
775
|
+
const backwards = k > 0 && key.at <= (keys[k - 1]!.at ?? 0)
|
|
776
|
+
return html`
|
|
777
|
+
<tr>
|
|
778
|
+
<td>
|
|
779
|
+
<input
|
|
780
|
+
type="number"
|
|
781
|
+
step=${onValues ? '0.05' : '0.1'}
|
|
782
|
+
min="0"
|
|
783
|
+
max=${onValues ? '1' : nothing}
|
|
784
|
+
?bad=${backwards || key.at < 0 || (onValues && key.at > 1)}
|
|
785
|
+
.value=${live(String(key.at))}
|
|
786
|
+
@change=${(e: Event) =>
|
|
787
|
+
this.setJointKey(kind, at, j, k, { at: Math.max(0, Number((e.target as HTMLInputElement).value) || 0) })}
|
|
788
|
+
/>
|
|
789
|
+
</td>
|
|
790
|
+
<td>
|
|
791
|
+
<input
|
|
792
|
+
type="number"
|
|
793
|
+
step="1"
|
|
794
|
+
?bad=${beyond(key)}
|
|
795
|
+
.value=${live(String(key.value))}
|
|
796
|
+
title=${beyond(key) ? i18next.t('figure.text.joint-value-beyond-limits') : ''}
|
|
797
|
+
@change=${(e: Event) => this.setJointKey(kind, at, j, k, { value: Number((e.target as HTMLInputElement).value) || 0 })}
|
|
798
|
+
/>
|
|
799
|
+
</td>
|
|
800
|
+
<td drop>
|
|
801
|
+
<button
|
|
802
|
+
plain
|
|
803
|
+
?disabled=${keys.length <= 2}
|
|
804
|
+
title=${keys.length <= 2 ? i18next.t('figure.text.two-keys-at-least') : i18next.t('figure.button.remove')}
|
|
805
|
+
@click=${() => this.removeKey(kind, at, j, k)}
|
|
806
|
+
>
|
|
807
|
+
<md-icon>close</md-icon>
|
|
808
|
+
</button>
|
|
809
|
+
</td>
|
|
810
|
+
</tr>
|
|
811
|
+
`
|
|
812
|
+
})}
|
|
813
|
+
</table>
|
|
814
|
+
${keys.some(beyond) ? html`<p warn>${i18next.t('figure.text.joint-value-beyond-limits')}</p>` : nothing}
|
|
815
|
+
<button @click=${() => this.addKey(kind, at, j)}>
|
|
816
|
+
<md-icon>add</md-icon>${i18next.t('figure.button.add-key')}
|
|
817
|
+
</button>
|
|
818
|
+
`
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
private renderKeys(kind: MotionKind, channel: PartChannel, at: number, j: number): TemplateResult {
|
|
695
822
|
const keys = channel.keys ?? []
|
|
696
823
|
/*
|
|
697
824
|
Keys on a parameter curve sit on the 0..1 value span (ADR-0051). A key above 1 is unreachable and
|
|
@@ -905,6 +1032,56 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
905
1032
|
this.patchParameter(at, { name: name.trim() })
|
|
906
1033
|
}
|
|
907
1034
|
|
|
1035
|
+
/**
|
|
1036
|
+
* 단위 칸 — **목록이 보이는 채로, 적는 길도 열어 둔다.**
|
|
1037
|
+
*
|
|
1038
|
+
* 처음에는 `datalist` 를 붙였다. 그것은 브라우저가 쥐고 있는 기능이라 **눌러 보기 전에는 목록이
|
|
1039
|
+
* 있는지조차 안 보인다** — 화살표도 없고, 어디에 어떻게 뜨는지도 브라우저가 정한다. 고를 수
|
|
1040
|
+
* 있다는 것을 알리는 것이 이 칸의 목적이므로 `select` 로 바꿨다.
|
|
1041
|
+
*
|
|
1042
|
+
* 형식은 단위를 규칙으로 쓰지 않으므로(표시용 말이다) 목록에 가두지 않는다. 마지막 줄이
|
|
1043
|
+
* 「직접 적기」이고, 목록에 없는 말이 이미 적혀 있으면 처음부터 적는 칸으로 연다.
|
|
1044
|
+
*/
|
|
1045
|
+
private renderUnit(at: number, unit: string) {
|
|
1046
|
+
const listed = (PARAMETER_UNITS as readonly string[]).includes(unit)
|
|
1047
|
+
const typing = this.typingUnit.has(at) || (unit !== '' && !listed)
|
|
1048
|
+
|
|
1049
|
+
if (typing) {
|
|
1050
|
+
return html`<input
|
|
1051
|
+
unit
|
|
1052
|
+
.value=${live(unit)}
|
|
1053
|
+
placeholder="mm"
|
|
1054
|
+
aria-label="unit"
|
|
1055
|
+
title=${i18next.t('figure.text.unit-is-a-word-not-a-rule')}
|
|
1056
|
+
@change=${(e: Event) => this.setRange(at, { unit: (e.target as HTMLInputElement).value.trim() })}
|
|
1057
|
+
/>`
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
return html`<select
|
|
1061
|
+
unit
|
|
1062
|
+
aria-label="unit"
|
|
1063
|
+
title=${i18next.t('figure.text.unit-is-a-word-not-a-rule')}
|
|
1064
|
+
@change=${(e: Event) => this.chooseUnit(at, (e.target as HTMLSelectElement).value)}
|
|
1065
|
+
>
|
|
1066
|
+
<option value="" ?selected=${unit === ''}>${i18next.t('figure.label.unit-none')}</option>
|
|
1067
|
+
${PARAMETER_UNITS.map(one => html`<option value=${one} ?selected=${one === unit}>${one}</option>`)}
|
|
1068
|
+
<option value=${UNIT_TYPE_IT}>${i18next.t('figure.label.unit-type-it')}</option>
|
|
1069
|
+
</select>`
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/** 고른 것을 넣는다. 「직접 적기」는 값이 아니라 **칸을 바꾸는 것**이다. */
|
|
1073
|
+
private chooseUnit(at: number, value: string): void {
|
|
1074
|
+
if (value === UNIT_TYPE_IT) {
|
|
1075
|
+
this.typingUnit = new Set([...this.typingUnit, at])
|
|
1076
|
+
return
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const next = new Set(this.typingUnit)
|
|
1080
|
+
next.delete(at)
|
|
1081
|
+
this.typingUnit = next
|
|
1082
|
+
this.setRange(at, { unit: value })
|
|
1083
|
+
}
|
|
1084
|
+
|
|
908
1085
|
private setRange(at: number, patch: Partial<Parameter['range']>): void {
|
|
909
1086
|
const parameter = this.parameters[at]
|
|
910
1087
|
if (!parameter) return
|
|
@@ -948,11 +1125,36 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
948
1125
|
])
|
|
949
1126
|
}
|
|
950
1127
|
|
|
951
|
-
|
|
1128
|
+
/**
|
|
1129
|
+
* Points a channel at another part or joint. Crossing between the two changes the channel's shape: a
|
|
1130
|
+
* joint channel has no path and one number per key, a part channel a path and a vector. The key positions
|
|
1131
|
+
* are kept, since they are the author's timing; values restart at the rest pose.
|
|
1132
|
+
*/
|
|
1133
|
+
private retarget(kind: MotionKind, at: number, j: number, target: string): void {
|
|
952
1134
|
const channel = this.channelsOf(kind, at)[j]
|
|
953
1135
|
if (!channel) return
|
|
1136
|
+
const toJoint = this.joints.some(one => one.name === target)
|
|
954
1137
|
|
|
955
|
-
|
|
1138
|
+
if (toJoint && !isJointChannel(channel)) {
|
|
1139
|
+
const next: JointChannel = { target, keys: channel.keys.map(key => ({ at: key.at, value: 0 })) }
|
|
1140
|
+
if (channel.interpolation !== undefined) next.interpolation = channel.interpolation
|
|
1141
|
+
this.patchChannel(kind, at, j, next)
|
|
1142
|
+
return
|
|
1143
|
+
}
|
|
1144
|
+
if (!toJoint && isJointChannel(channel)) {
|
|
1145
|
+
const next: PartChannel = { target, path: 'translation', keys: channel.keys.map(key => ({ at: key.at, value: startValue('translation') })) }
|
|
1146
|
+
if (channel.interpolation !== undefined) next.interpolation = channel.interpolation
|
|
1147
|
+
this.patchChannel(kind, at, j, next)
|
|
1148
|
+
return
|
|
1149
|
+
}
|
|
1150
|
+
this.patchChannel(kind, at, j, { ...channel, target } as Channel)
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
private setChannel(kind: MotionKind, at: number, j: number, patch: Partial<PartChannel>): void {
|
|
1154
|
+
const channel = this.channelsOf(kind, at)[j]
|
|
1155
|
+
if (!channel || isJointChannel(channel)) return
|
|
1156
|
+
|
|
1157
|
+
const next: PartChannel = { ...channel, ...patch }
|
|
956
1158
|
/* `undefined` 를 얹는 것은 지우는 뜻이다. 남겨 두면 저장 형식에 빈 칸이 실린다. */
|
|
957
1159
|
if ('pivot' in patch && patch.pivot === undefined) delete next.pivot
|
|
958
1160
|
this.patchChannel(kind, at, j, next)
|
|
@@ -967,10 +1169,10 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
967
1169
|
*/
|
|
968
1170
|
private setPath(kind: MotionKind, at: number, j: number, path: ChannelPath): void {
|
|
969
1171
|
const channel = this.channelsOf(kind, at)[j]
|
|
970
|
-
if (!channel) return
|
|
1172
|
+
if (!channel || isJointChannel(channel)) return
|
|
971
1173
|
|
|
972
1174
|
const crossing = (channel.path === 'scale') !== (path === 'scale')
|
|
973
|
-
const next:
|
|
1175
|
+
const next: PartChannel = {
|
|
974
1176
|
...channel,
|
|
975
1177
|
path,
|
|
976
1178
|
keys: crossing ? channel.keys.map(key => ({ at: key.at, value: startValue(path) })) : channel.keys
|
|
@@ -994,6 +1196,19 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
994
1196
|
const channel = this.channelsOf(kind, at)[j]
|
|
995
1197
|
if (!channel) return
|
|
996
1198
|
|
|
1199
|
+
if (isJointChannel(channel)) {
|
|
1200
|
+
const keys = channel.keys
|
|
1201
|
+
const end = keys[keys.length - 1]
|
|
1202
|
+
if (kind === 'parameter' && keys.length >= 2) {
|
|
1203
|
+
const before = keys[keys.length - 2]!
|
|
1204
|
+
const middle: JointKey = { at: (before.at + end!.at) / 2, value: before.value }
|
|
1205
|
+
this.patchChannel(kind, at, j, { ...channel, keys: [...keys.slice(0, -1), middle, end!] })
|
|
1206
|
+
return
|
|
1207
|
+
}
|
|
1208
|
+
this.patchChannel(kind, at, j, { ...channel, keys: [...keys, { at: (end?.at ?? 0) + 1, value: end?.value ?? 0 }] })
|
|
1209
|
+
return
|
|
1210
|
+
}
|
|
1211
|
+
|
|
997
1212
|
const last = channel.keys[channel.keys.length - 1]
|
|
998
1213
|
|
|
999
1214
|
/*
|
|
@@ -1010,9 +1225,15 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
1010
1225
|
this.patchChannel(kind, at, j, { ...channel, keys: [...channel.keys, key] })
|
|
1011
1226
|
}
|
|
1012
1227
|
|
|
1228
|
+
private setJointKey(kind: MotionKind, at: number, j: number, k: number, patch: Partial<JointKey>): void {
|
|
1229
|
+
const channel = this.channelsOf(kind, at)[j]
|
|
1230
|
+
if (!channel || !isJointChannel(channel)) return
|
|
1231
|
+
this.patchChannel(kind, at, j, { ...channel, keys: channel.keys.map((key, i) => (i === k ? { ...key, ...patch } : key)) })
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1013
1234
|
private setKey(kind: MotionKind, at: number, j: number, k: number, patch: Partial<Keyframe>): void {
|
|
1014
1235
|
const channel = this.channelsOf(kind, at)[j]
|
|
1015
|
-
if (!channel) return
|
|
1236
|
+
if (!channel || isJointChannel(channel)) return
|
|
1016
1237
|
this.patchChannel(kind, at, j, {
|
|
1017
1238
|
...channel,
|
|
1018
1239
|
keys: channel.keys.map((key, i) => (i === k ? { ...key, ...patch } : key))
|
|
@@ -1023,6 +1244,6 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
1023
1244
|
private removeKey(kind: MotionKind, at: number, j: number, k: number): void {
|
|
1024
1245
|
const channel = this.channelsOf(kind, at)[j]
|
|
1025
1246
|
if (!channel || channel.keys.length <= 2) return
|
|
1026
|
-
this.patchChannel(kind, at, j, { ...channel, keys: channel.keys.filter((_, i) => i !== k) })
|
|
1247
|
+
this.patchChannel(kind, at, j, { ...channel, keys: (channel.keys as { at: number }[]).filter((_, i) => i !== k) } as Channel)
|
|
1027
1248
|
}
|
|
1028
1249
|
}
|
|
@@ -192,6 +192,14 @@ export class FigureCanvas extends localize(i18next)(LitElement) {
|
|
|
192
192
|
/** 지금 고른 부품 이름. 목록에서 고른 것을 캔버스에 알린다. */
|
|
193
193
|
@property({ type: String }) picked?: string
|
|
194
194
|
|
|
195
|
+
/**
|
|
196
|
+
* 관절이 도는 자리를 그리는 부채꼴(`joint-gizmo.ts`). 저작면에만 있고 저장되지 않는다.
|
|
197
|
+
*
|
|
198
|
+
* 부품과 같은 길로 세운다 — 씬에 겹쳐 그리는 통로를 따로 뚫지 않는다. `locked` 라 클릭도
|
|
199
|
+
* 마퀴도 집지 않는다.
|
|
200
|
+
*/
|
|
201
|
+
@property({ type: Object }) gizmo?: PartModel
|
|
202
|
+
|
|
195
203
|
/**
|
|
196
204
|
* 보기 전용 — 씬에서 일어난 일을 밖에 알리지 않는다.
|
|
197
205
|
*
|
|
@@ -278,6 +286,7 @@ export class FigureCanvas extends localize(i18next)(LitElement) {
|
|
|
278
286
|
|
|
279
287
|
if (changed.has('revision')) this.standing = undefined
|
|
280
288
|
if (changed.has('draft') || changed.has('parts') || changed.has('figureId') || changed.has('revision')) this.standUp()
|
|
289
|
+
else if (changed.has('gizmo')) this.syncParts()
|
|
281
290
|
if (changed.has('view')) this.applyView()
|
|
282
291
|
if (changed.has('picked')) this.showPicked()
|
|
283
292
|
}
|
|
@@ -358,7 +367,7 @@ export class FigureCanvas extends localize(i18next)(LitElement) {
|
|
|
358
367
|
없어야 한다: 좌표 모눈과 겹쳐 싸우고 원점 아래쪽을 가린다.
|
|
359
368
|
*/
|
|
360
369
|
fillStyle: PAPER,
|
|
361
|
-
components: this.
|
|
370
|
+
components: this.standing3d().map(part => ({ ...part }))
|
|
362
371
|
},
|
|
363
372
|
// **편집 모드**로 만든다. 도면의 3D 모델러가 그 모드에서 카메라 시점 버튼 ·
|
|
364
373
|
// 기즈모 모드 · 도움말 · 단축키를 띄운다.
|
|
@@ -449,11 +458,17 @@ export class FigureCanvas extends localize(i18next)(LitElement) {
|
|
|
449
458
|
* 다시 세우면 고른 것과 카메라가 날아가서, 숫자 하나 고칠 때마다 화면이 처음으로
|
|
450
459
|
* 돌아간다.
|
|
451
460
|
*/
|
|
461
|
+
/** 판에 세울 것 전부 — 저작한 부품과, 있으면 관절 부채꼴. */
|
|
462
|
+
private standing3d(): PartModel[] {
|
|
463
|
+
return this.gizmo ? [...this.parts, this.gizmo] : this.parts
|
|
464
|
+
}
|
|
465
|
+
|
|
452
466
|
private syncParts() {
|
|
453
467
|
const root = this.scene?.root
|
|
454
468
|
if (!root) return
|
|
455
469
|
|
|
456
|
-
const
|
|
470
|
+
const all = this.standing3d()
|
|
471
|
+
const wanted = new Map(all.map(part => [part.name, part]))
|
|
457
472
|
const standing = this.partsOnCanvas()
|
|
458
473
|
|
|
459
474
|
// 없어진 것을 제거한다
|
|
@@ -464,7 +479,7 @@ export class FigureCanvas extends localize(i18next)(LitElement) {
|
|
|
464
479
|
}
|
|
465
480
|
}
|
|
466
481
|
|
|
467
|
-
for (const part of
|
|
482
|
+
for (const part of all) {
|
|
468
483
|
const found = standing.get(part.name)
|
|
469
484
|
if (found) {
|
|
470
485
|
/*
|