@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
|
@@ -16,13 +16,41 @@ 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
|
+
ContractChange,
|
|
24
|
+
FigurePart,
|
|
25
|
+
FigurePlacement,
|
|
26
|
+
JointType,
|
|
27
|
+
MaterialPreset,
|
|
28
|
+
PartRole,
|
|
29
|
+
PrimitiveKind,
|
|
30
|
+
Vec3
|
|
31
|
+
} from '@hatiolab/figure-model'
|
|
21
32
|
import { activePalette } from '@hatiolab/things-scene'
|
|
22
33
|
|
|
23
34
|
import { partToFigure, partFromFigure } from './figure-source.js'
|
|
24
35
|
import type { FigureDraft, PartModel } from './figure-source.js'
|
|
25
36
|
import { canMirror } from './part-edits.js'
|
|
37
|
+
import { jointOf, parentChoices, patchJoint, setJointType } from './joint-edits.js'
|
|
38
|
+
import { baseFittingParts, baseOf, resizeBase } from './base-box.js'
|
|
39
|
+
import type { BaseSize } from './base-box.js'
|
|
40
|
+
|
|
41
|
+
/** Axis choices for a joint. Written as a direction the author reads, stored as a vector. */
|
|
42
|
+
const JOINT_AXES: readonly { key: string; axis: Vec3 }[] = [
|
|
43
|
+
{ key: '+x', axis: { x: 1, y: 0, z: 0 } },
|
|
44
|
+
{ key: '-x', axis: { x: -1, y: 0, z: 0 } },
|
|
45
|
+
{ key: '+y', axis: { x: 0, y: 1, z: 0 } },
|
|
46
|
+
{ key: '-y', axis: { x: 0, y: -1, z: 0 } },
|
|
47
|
+
{ key: '+z', axis: { x: 0, y: 0, z: 1 } },
|
|
48
|
+
{ key: '-z', axis: { x: 0, y: 0, z: -1 } }
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
function axisKeyOf(axis: Vec3): string | undefined {
|
|
52
|
+
return JOINT_AXES.find(one => one.axis.x === axis.x && one.axis.y === axis.y && one.axis.z === axis.z)?.key
|
|
53
|
+
}
|
|
26
54
|
|
|
27
55
|
/**
|
|
28
56
|
* 고른 부품의 속성.
|
|
@@ -291,9 +319,60 @@ export class FigureInspector extends localize(i18next)(LitElement) {
|
|
|
291
319
|
grid-template-columns: repeat(3, 1fr);
|
|
292
320
|
gap: 4px;
|
|
293
321
|
}
|
|
322
|
+
/*
|
|
323
|
+
값이 둘인 줄은 셋으로 나누지 않는다. 가동 범위를 세 칸짜리 줄에 넣었더니 칸이 폭의 1/3 이라
|
|
324
|
+
「최소」라는 말과 -120 이 한 칸에 못 들어가 숫자가 잘렸다.
|
|
325
|
+
*/
|
|
326
|
+
div[double] {
|
|
327
|
+
display: grid;
|
|
328
|
+
grid-template-columns: repeat(2, 1fr);
|
|
329
|
+
gap: 4px;
|
|
330
|
+
}
|
|
331
|
+
/* 칸 아래에 붙는 한 줄짜리 행동. 칸과 같은 폭으로 둬야 한 묶음으로 읽힌다. */
|
|
332
|
+
button[fit] {
|
|
333
|
+
display: inline-flex;
|
|
334
|
+
align-items: center;
|
|
335
|
+
justify-content: center;
|
|
336
|
+
gap: 4px;
|
|
337
|
+
width: 100%;
|
|
338
|
+
margin-top: 4px;
|
|
339
|
+
padding: 3px 8px;
|
|
340
|
+
font: inherit;
|
|
341
|
+
font-size: 0.72rem;
|
|
342
|
+
color: var(--md-sys-color-on-surface);
|
|
343
|
+
background-color: var(--md-sys-color-surface-container);
|
|
344
|
+
border: 1px solid var(--md-sys-color-outline-variant);
|
|
345
|
+
border-radius: 6px;
|
|
346
|
+
cursor: pointer;
|
|
347
|
+
}
|
|
348
|
+
/* 고친 칸 바로 아래에 붙는 말. 막는 것은 오류 색, 알리는 것은 조용한 색. */
|
|
349
|
+
p[contract] {
|
|
350
|
+
display: flex;
|
|
351
|
+
gap: 4px;
|
|
352
|
+
margin: 4px 0 0;
|
|
353
|
+
font-size: 0.68rem;
|
|
354
|
+
line-height: 1.4;
|
|
355
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
356
|
+
}
|
|
357
|
+
p[contract][blocking] {
|
|
358
|
+
color: var(--md-sys-color-error);
|
|
359
|
+
}
|
|
360
|
+
p[contract] md-icon {
|
|
361
|
+
--md-icon-size: 14px;
|
|
362
|
+
flex: none;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
button[fit]:hover {
|
|
366
|
+
background-color: var(--md-sys-color-surface-container-high);
|
|
367
|
+
}
|
|
368
|
+
button[fit] md-icon {
|
|
369
|
+
--md-icon-size: 15px;
|
|
370
|
+
}
|
|
294
371
|
div[field] + div[field],
|
|
295
372
|
div[triple] + div[field],
|
|
296
373
|
div[field] + div[triple],
|
|
374
|
+
div[double] + div[field],
|
|
375
|
+
div[field] + div[double],
|
|
297
376
|
div[inline-field] + div[inline-field],
|
|
298
377
|
div[inline-field] + div[field],
|
|
299
378
|
div[field] + div[inline-field],
|
|
@@ -790,6 +869,16 @@ export class FigureInspector extends localize(i18next)(LitElement) {
|
|
|
790
869
|
@property({ type: Object }) draft?: FigureDraft
|
|
791
870
|
/** 초안 위의 부품들. 씬 모델 그대로다. */
|
|
792
871
|
@property({ type: Array }) parts: PartModel[] = []
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* 이 고침이 이미 놓인 인스턴스에 무엇을 하나 — 발행된 도형에만 있다(ADR-0063 결정 3).
|
|
875
|
+
*
|
|
876
|
+
* 발행 문에서만 말하면 저작자는 열 군데를 고친 뒤에 듣는다. 고치는 칸 옆에서 말한다.
|
|
877
|
+
*/
|
|
878
|
+
@property({ type: Array }) contract: ContractChange[] = []
|
|
879
|
+
|
|
880
|
+
/** 견주지 못한 까닭. 빈 목록과 뜻이 다르다 — 「깨지 않는다」가 아니라 「모른다」다. */
|
|
881
|
+
@property({ type: String }) contractFailure?: string
|
|
793
882
|
@property({ type: Number }) selected = -1
|
|
794
883
|
|
|
795
884
|
/** `part` 는 HTMLElement 의 속성(DOMTokenList)이다 — 가리면 형이 어긋난다. */
|
|
@@ -820,7 +909,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
|
|
|
820
909
|
묶음은 아예 나오지 않는다 — 해 둬도 아무 일도 안 일어나는 칸을 보여 주지 않는다.
|
|
821
910
|
*/
|
|
822
911
|
return html`
|
|
823
|
-
${this.renderFigure(true)} ${this.renderIdentity(shown)} ${this.renderRole(shown)}
|
|
912
|
+
${this.renderFigure(true)} ${this.renderIdentity(shown)} ${this.renderRole(shown)} ${this.renderJoint(shown)}
|
|
824
913
|
${this.renderPlacement(shown)} ${this.renderShape(shown)} ${this.renderMaterial(shown)}
|
|
825
914
|
${this.renderBehaviour(shown)}
|
|
826
915
|
`
|
|
@@ -884,6 +973,185 @@ export class FigureInspector extends localize(i18next)(LitElement) {
|
|
|
884
973
|
`
|
|
885
974
|
}
|
|
886
975
|
|
|
976
|
+
/**
|
|
977
|
+
* How this part hangs from another and moves relative to it (ADR-0066).
|
|
978
|
+
*
|
|
979
|
+
* The parent is written on the part. The joint says how the part moves relative to that parent, and the
|
|
980
|
+
* parameter it comes with is what drives it; the preview shows that parameter as a slider.
|
|
981
|
+
*/
|
|
982
|
+
private renderJoint(part: FigurePart) {
|
|
983
|
+
const joint = jointOf(this.draft, part.name)
|
|
984
|
+
const choices = parentChoices(this.parts, part.name)
|
|
985
|
+
const unit = joint?.type === 'prismatic' ? 'mm' : 'deg'
|
|
986
|
+
const axisKey = joint ? axisKeyOf(joint.axis) : undefined
|
|
987
|
+
const driver = joint
|
|
988
|
+
? this.draft?.parameters?.find(one => one.clip?.channels?.some(channel => channel.target === joint.name))
|
|
989
|
+
: undefined
|
|
990
|
+
|
|
991
|
+
return html`
|
|
992
|
+
<section>
|
|
993
|
+
<h3>${i18next.t('figure.label.joint')}${this.help('figure.text.joint-explained')}</h3>
|
|
994
|
+
|
|
995
|
+
<div inline-field>
|
|
996
|
+
<label>${i18next.t('figure.label.joint-parent')}</label>
|
|
997
|
+
<select @change=${(e: Event) => this.setParent((e.target as HTMLSelectElement).value)}>
|
|
998
|
+
<option value="" ?selected=${part.parent === undefined}>${i18next.t('figure.label.joint-parent-none')}</option>
|
|
999
|
+
${part.parent !== undefined && !choices.includes(part.parent)
|
|
1000
|
+
? html`<option value=${part.parent} selected>${part.parent}</option>`
|
|
1001
|
+
: nothing}
|
|
1002
|
+
${choices.map(name => html`<option value=${name} ?selected=${name === part.parent}>${name}</option>`)}
|
|
1003
|
+
</select>
|
|
1004
|
+
</div>
|
|
1005
|
+
|
|
1006
|
+
<div inline-field>
|
|
1007
|
+
<label>${i18next.t('figure.label.joint-type')}</label>
|
|
1008
|
+
<select @change=${(e: Event) => this.setJointType(part, (e.target as HTMLSelectElement).value)}>
|
|
1009
|
+
<option value="" ?selected=${!joint}>${i18next.t('figure.label.joint-type-none')}</option>
|
|
1010
|
+
${(JOINT_TYPES as readonly JointType[]).map(
|
|
1011
|
+
type => html`<option value=${type} ?selected=${joint?.type === type}>${i18next.t('figure.label.joint-type-' + type)}</option>`
|
|
1012
|
+
)}
|
|
1013
|
+
</select>
|
|
1014
|
+
</div>
|
|
1015
|
+
|
|
1016
|
+
${joint
|
|
1017
|
+
? html`
|
|
1018
|
+
<div inline-field>
|
|
1019
|
+
<label>${i18next.t('figure.label.joint-axis')}</label>
|
|
1020
|
+
<select @change=${(e: Event) => this.setJointAxis(joint.name, (e.target as HTMLSelectElement).value)}>
|
|
1021
|
+
${axisKey === undefined
|
|
1022
|
+
? html`<option value="" selected>${joint.axis.x}, ${joint.axis.y}, ${joint.axis.z}</option>`
|
|
1023
|
+
: nothing}
|
|
1024
|
+
${JOINT_AXES.map(one => html`<option value=${one.key} ?selected=${one.key === axisKey}>${one.key.toUpperCase()}</option>`)}
|
|
1025
|
+
</select>
|
|
1026
|
+
</div>
|
|
1027
|
+
|
|
1028
|
+
<div vector-row>
|
|
1029
|
+
<label>${i18next.t('figure.label.joint-origin')}</label>
|
|
1030
|
+
<div triple>
|
|
1031
|
+
${(['x', 'y', 'z'] as const).map(axis =>
|
|
1032
|
+
this.number(axis.toUpperCase(), joint.origin[axis], value =>
|
|
1033
|
+
this.tellDraft(patchJoint(this.draft!, joint.name, { origin: { ...joint.origin, [axis]: value } }))
|
|
1034
|
+
)
|
|
1035
|
+
)}
|
|
1036
|
+
</div>
|
|
1037
|
+
</div>
|
|
1038
|
+
|
|
1039
|
+
${joint.limits
|
|
1040
|
+
? html`
|
|
1041
|
+
<div vector-row>
|
|
1042
|
+
<label>${i18next.t('figure.label.joint-limits-' + unit)}</label>
|
|
1043
|
+
<div double>
|
|
1044
|
+
${this.number(i18next.t('figure.label.joint-limit-min'), joint.limits.min, value =>
|
|
1045
|
+
this.tellDraft(patchJoint(this.draft!, joint.name, { limits: { ...joint.limits!, min: value } }))
|
|
1046
|
+
)}
|
|
1047
|
+
${this.number(i18next.t('figure.label.joint-limit-max'), joint.limits.max, value =>
|
|
1048
|
+
this.tellDraft(patchJoint(this.draft!, joint.name, { limits: { ...joint.limits!, max: value } }))
|
|
1049
|
+
)}
|
|
1050
|
+
</div>
|
|
1051
|
+
</div>
|
|
1052
|
+
${joint.limits.min > 0 || joint.limits.max < 0
|
|
1053
|
+
? html`<p warn>${i18next.t('figure.text.joint-limits-must-include-zero')}</p>`
|
|
1054
|
+
: nothing}
|
|
1055
|
+
`
|
|
1056
|
+
: nothing}
|
|
1057
|
+
|
|
1058
|
+
<p quiet>
|
|
1059
|
+
${driver
|
|
1060
|
+
? i18next.t('figure.text.joint-driven-by', { name: driver.label ?? driver.name })
|
|
1061
|
+
: i18next.t('figure.text.joint-not-driven')}
|
|
1062
|
+
</p>
|
|
1063
|
+
`
|
|
1064
|
+
: nothing}
|
|
1065
|
+
</section>
|
|
1066
|
+
`
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
private setParent(name: string) {
|
|
1070
|
+
this.change(part => {
|
|
1071
|
+
const { parent: _dropped, ...rest } = part
|
|
1072
|
+
return name === '' ? (rest as FigurePart) : { ...rest, parent: name }
|
|
1073
|
+
})
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* 기준 상자를 고친다 — 부품은 저장되는 자리에 그대로 둔 채(`base-box.ts`).
|
|
1078
|
+
*
|
|
1079
|
+
* 초안과 부품이 함께 바뀌므로 둘을 한 번에 올린다. 따로 올리면 되돌리기가 둘로 갈리고, 그 사이
|
|
1080
|
+
* 한 번만 되돌린 화면은 부품이 반쯤 밀린 모습이 된다.
|
|
1081
|
+
*/
|
|
1082
|
+
/**
|
|
1083
|
+
* 기준 상자를 고친 것이 **이미 놓인 인스턴스에 무엇을 하나** — 고친 칸 바로 아래에서 말한다.
|
|
1084
|
+
*
|
|
1085
|
+
* 막는 것과 알리는 것을 가른다. 비율이 달라지면 놓인 것이 찌그러지므로 발행이 막히고, 크기만
|
|
1086
|
+
* 달라지는 것은 실척이 어긋날 뿐이라 알리기만 한다. 까닭은 형식이 적은 것을 그대로 옮긴다 —
|
|
1087
|
+
* 여기서 다시 쓰면 두 벌이 되고 곧 갈린다.
|
|
1088
|
+
*/
|
|
1089
|
+
private renderBaseContract() {
|
|
1090
|
+
if (this.contractFailure) {
|
|
1091
|
+
return html`<p contract>
|
|
1092
|
+
<md-icon>help</md-icon>
|
|
1093
|
+
<span>${i18next.t('figure.text.contract-not-compared', { why: this.contractFailure })}</span>
|
|
1094
|
+
</p>`
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
const mine = this.contract.filter(one => one.code === 'base-ratio-changed' || one.code === 'base-size-changed')
|
|
1098
|
+
if (mine.length === 0) return nothing
|
|
1099
|
+
|
|
1100
|
+
return html`${mine.map(
|
|
1101
|
+
one => html`<p contract ?blocking=${one.blocking}>
|
|
1102
|
+
<md-icon>${one.blocking ? 'block' : 'info'}</md-icon>
|
|
1103
|
+
<span><b>${one.message}</b> ${one.why}</span>
|
|
1104
|
+
</p>`
|
|
1105
|
+
)}`
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
private resizeBase(patch: Partial<BaseSize>) {
|
|
1109
|
+
if (!this.draft) return
|
|
1110
|
+
|
|
1111
|
+
const next = resizeBase(this.draft, this.parts, { ...baseOf(this.draft), ...patch })
|
|
1112
|
+
if (next.draft === this.draft) return
|
|
1113
|
+
|
|
1114
|
+
this.dispatchEvent(
|
|
1115
|
+
new CustomEvent('resize-base', {
|
|
1116
|
+
detail: { draft: next.draft, parts: next.parts },
|
|
1117
|
+
bubbles: true,
|
|
1118
|
+
composed: true
|
|
1119
|
+
})
|
|
1120
|
+
)
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/** 부품 전부를 감싸는 가장 작은 상자로. 맞출 것이 없으면 아무 일도 안 한다. */
|
|
1124
|
+
private fitBaseToParts() {
|
|
1125
|
+
if (!this.draft) return
|
|
1126
|
+
const fitted = baseFittingParts(this.draft, this.parts)
|
|
1127
|
+
if (fitted) this.resizeBase(fitted)
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
private setJointType(part: FigurePart, value: string) {
|
|
1131
|
+
if (!this.draft) return
|
|
1132
|
+
|
|
1133
|
+
/* 붙은 부품을 함께 준다 — 새 관절이 둘이 맞닿는 자리에 서려면 그쪽 치수를 알아야 한다. */
|
|
1134
|
+
const parent = part.parent ? this.parts.find(one => one.name === part.parent) : undefined
|
|
1135
|
+
this.tellDraft(
|
|
1136
|
+
setJointType(
|
|
1137
|
+
this.draft,
|
|
1138
|
+
part,
|
|
1139
|
+
value === '' ? undefined : (value as JointType),
|
|
1140
|
+
parent ? partToFigure(parent, this.draft) : undefined
|
|
1141
|
+
)
|
|
1142
|
+
)
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
private setJointAxis(name: string, key: string) {
|
|
1146
|
+
const choice = JOINT_AXES.find(one => one.key === key)
|
|
1147
|
+
if (!choice || !this.draft) return
|
|
1148
|
+
this.tellDraft(patchJoint(this.draft, name, { axis: { ...choice.axis } }))
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
private tellDraft(draft: FigureDraft) {
|
|
1152
|
+
this.dispatchEvent(new CustomEvent('update-draft', { detail: { draft }, bubbles: true, composed: true }))
|
|
1153
|
+
}
|
|
1154
|
+
|
|
887
1155
|
/**
|
|
888
1156
|
* 역할 하나를 켜고 끈다.
|
|
889
1157
|
*
|
|
@@ -1007,6 +1275,27 @@ export class FigureInspector extends localize(i18next)(LitElement) {
|
|
|
1007
1275
|
${this.helpHint('figure.text.placement-explained')}
|
|
1008
1276
|
</div>
|
|
1009
1277
|
|
|
1278
|
+
<!--
|
|
1279
|
+
기준 상자 — **고치는 길이 화면에 있어야 한다.** 부품이 이 상자를 넘으면 발행이 막히고,
|
|
1280
|
+
고치는 방법은 상자 숫자를 키우는 것이다(ADR-0044 ④). 그 칸이 없어서 저작자는 넘쳤다는
|
|
1281
|
+
말만 듣고 고칠 자리를 못 찾았다.
|
|
1282
|
+
-->
|
|
1283
|
+
<div field>
|
|
1284
|
+
<label>${i18next.t('figure.label.base-box')}${this.helpButton('figure.text.base-box-explained')}</label>
|
|
1285
|
+
<div triple>
|
|
1286
|
+
${this.number('X', this.draft?.width ?? 0, value => this.resizeBase({ width: value }), 1)}
|
|
1287
|
+
${this.number('Y', this.draft?.depth ?? 0, value => this.resizeBase({ depth: value }), 1)}
|
|
1288
|
+
${this.number('Z', this.draft?.height ?? 0, value => this.resizeBase({ height: value }), 1)}
|
|
1289
|
+
</div>
|
|
1290
|
+
${this.parts.length > 0
|
|
1291
|
+
? html`<button fit @click=${() => this.fitBaseToParts()} title=${i18next.t('figure.text.fit-base-explained')}>
|
|
1292
|
+
<md-icon>fit_screen</md-icon>${i18next.t('figure.button.fit-base-to-parts')}
|
|
1293
|
+
</button>`
|
|
1294
|
+
: nothing}
|
|
1295
|
+
${this.renderBaseContract()}
|
|
1296
|
+
${this.helpHint('figure.text.base-box-explained')}
|
|
1297
|
+
</div>
|
|
1298
|
+
|
|
1010
1299
|
<div inline-field>
|
|
1011
1300
|
<label>${i18next.t('figure.label.category')}${this.helpButton('figure.text.category-explained')}</label>
|
|
1012
1301
|
<input
|
|
@@ -6,8 +6,10 @@ 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
|
+
import { poseChanges } from './pose-defaults.js'
|
|
12
|
+
import type { FigureDraft } from './figure-source.js'
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
15
|
* 저작 중인 것을 **도면에 놓았을 때**의 모습.
|
|
@@ -144,6 +146,24 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
144
146
|
align-items: center;
|
|
145
147
|
gap: 8px;
|
|
146
148
|
}
|
|
149
|
+
/*
|
|
150
|
+
막대 위에 놓는 단추. 자세를 만든 자리에 있어야 「지금 이것을 기본으로」가 읽힌다.
|
|
151
|
+
앉힐 것이 있을 때만 나오므로, 늘 서 있는 칸이 아니다.
|
|
152
|
+
*/
|
|
153
|
+
div[clips] button[pose] {
|
|
154
|
+
align-self: stretch;
|
|
155
|
+
padding: 4px 8px;
|
|
156
|
+
font: inherit;
|
|
157
|
+
font-size: 0.7rem;
|
|
158
|
+
color: rgba(255, 255, 255, 0.92);
|
|
159
|
+
background-color: rgba(255, 255, 255, 0.14);
|
|
160
|
+
border: 1px solid rgba(255, 255, 255, 0.22);
|
|
161
|
+
border-radius: 5px;
|
|
162
|
+
cursor: pointer;
|
|
163
|
+
}
|
|
164
|
+
div[clips] button[pose]:hover {
|
|
165
|
+
background-color: rgba(255, 255, 255, 0.24);
|
|
166
|
+
}
|
|
147
167
|
div[clips] span[drive] {
|
|
148
168
|
opacity: 0.6;
|
|
149
169
|
font-size: 0.66rem;
|
|
@@ -176,6 +196,14 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
176
196
|
/** 저작 중인 것. 저장 형식으로 받는다 — 도면이 소비하는 것이 이 형식이다. */
|
|
177
197
|
@property({ type: Object }) source?: FigureSource
|
|
178
198
|
|
|
199
|
+
/**
|
|
200
|
+
* 저작 중인 초안. **기본값이 지금 무엇인지 알려고만 쥔다** — 고치지 않는다.
|
|
201
|
+
*
|
|
202
|
+
* `source` 로도 파라미터를 읽을 수 있지만, 후보를 미리 볼 때는 `source` 가 후보의 것이고
|
|
203
|
+
* 초안은 열려 있는 도형의 것이다. 「이 자세를 기본으로」는 열려 있는 도형에만 뜻이 있다.
|
|
204
|
+
*/
|
|
205
|
+
@property({ type: Object }) draft?: FigureDraft
|
|
206
|
+
|
|
179
207
|
/**
|
|
180
208
|
* 보기 설정. 저장되지 않는다(`figure-view.ts`).
|
|
181
209
|
*
|
|
@@ -226,8 +254,23 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
226
254
|
clips: NonNullable<FigureSource['animations']>,
|
|
227
255
|
parameters: NonNullable<FigureSource['parameters']>
|
|
228
256
|
) {
|
|
257
|
+
/*
|
|
258
|
+
지금 세운 자세를 기본값으로 앉히는 자리. **막대 옆에 둔다** — 자세를 만든 곳이 여기라,
|
|
259
|
+
값을 읽어 파라미터 칸으로 옮겨 적는 왕복이 없어진다. 앉힐 것이 없으면 아예 안 나온다.
|
|
260
|
+
*/
|
|
261
|
+
const changes = poseChanges(this.draft, this.giving)
|
|
262
|
+
|
|
229
263
|
return html`
|
|
230
264
|
<div clips>
|
|
265
|
+
${changes.length > 0
|
|
266
|
+
? html`<button
|
|
267
|
+
pose
|
|
268
|
+
title=${i18next.t('figure.text.pose-as-default-explained')}
|
|
269
|
+
@click=${() => this.poseAsDefault()}
|
|
270
|
+
>
|
|
271
|
+
${i18next.t('figure.button.pose-as-default')}
|
|
272
|
+
</button>`
|
|
273
|
+
: ''}
|
|
231
274
|
${parameters.map(parameter => {
|
|
232
275
|
const range = parameter.range
|
|
233
276
|
const value = this.giving[parameter.name] ?? parameter.default ?? range?.min ?? 0
|
|
@@ -292,6 +335,18 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
292
335
|
this.tellInstances('parameters', this.giving)
|
|
293
336
|
}
|
|
294
337
|
|
|
338
|
+
/**
|
|
339
|
+
* 세운 자세를 기본값으로 — **셈은 여기서 하지 않는다.**
|
|
340
|
+
*
|
|
341
|
+
* 초안을 쥐고 있는 것은 페이지이고, 되돌리기와 「저장 안 됨」도 거기서 매긴다. 여기서
|
|
342
|
+
* 초안을 만들어 보내면 그 셋이 두 곳으로 갈린다. 민 값만 올린다.
|
|
343
|
+
*/
|
|
344
|
+
private poseAsDefault(): void {
|
|
345
|
+
this.dispatchEvent(
|
|
346
|
+
new CustomEvent('pose-as-default', { detail: { values: { ...this.giving } }, bubbles: true, composed: true })
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
|
|
295
350
|
private tellInstances(key: 'clips' | 'parameters', values: Record<string, number>): void {
|
|
296
351
|
for (const component of this.scene?.root?.components ?? []) {
|
|
297
352
|
component.set?.(key, { ...values })
|
|
@@ -435,7 +490,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
435
490
|
어느 경우에도 담기는 경계만 잡는다. 바닥 기반은 가장 큰 것의 키까지, 천정 기반은
|
|
436
491
|
천장까지다. 배치를 높이로 옮기는 계산을 여기서 다시 쓰면 그것이 두 벌째가 된다.
|
|
437
492
|
*/
|
|
438
|
-
depth:
|
|
493
|
+
depth: previewBoardDepth(source, view.ceilingHeight, biggest)
|
|
439
494
|
}
|
|
440
495
|
|
|
441
496
|
this.scene = create({
|
|
@@ -9,7 +9,7 @@ import { ScrollbarStyles } from '@operato/styles'
|
|
|
9
9
|
import { blocksRelease, compile, costCurveSvg, costOf, scoreOf, sizingReport, validate } from '@hatiolab/figure-model'
|
|
10
10
|
import type { FigureCost, FigureScore, FigureSource } from '@hatiolab/figure-model'
|
|
11
11
|
|
|
12
|
-
import { capacityOf } from './scene-budget.js'
|
|
12
|
+
import { capacityOf, MEASURED } from './scene-budget.js'
|
|
13
13
|
import { maturityOf } from './maturity.js'
|
|
14
14
|
import type { MaturityAxis } from './maturity.js'
|
|
15
15
|
import type { FigureCapacity } from './scene-budget.js'
|
|
@@ -507,8 +507,9 @@ export class FigureReport extends localize(i18next)(LitElement) {
|
|
|
507
507
|
* 직접 답하지 않는다 — 형상이 선언된 한도 안인지를 말할 뿐이다. 그래서 개수를
|
|
508
508
|
* 머리에 올리고 등급은 옆의 badge 로 내린다.
|
|
509
509
|
*
|
|
510
|
-
* 근거를 함께 낸다.
|
|
511
|
-
*
|
|
510
|
+
* 근거를 함께 낸다. 묶음으로 갈리는 개수의 분모는 **실측**이고(2026-09-18, Apple M5), 잰
|
|
511
|
+
* 기계와 날짜를 함께 적는다 — 적지 않으면 어디서나 참인 수로 읽힌다. 삼각형 쪽은 아직 설계
|
|
512
|
+
* 문서의 목표치라 그렇다고 적는다.
|
|
512
513
|
*/
|
|
513
514
|
private renderVerdict() {
|
|
514
515
|
const score = this.score
|
|
@@ -544,7 +545,11 @@ export class FigureReport extends localize(i18next)(LitElement) {
|
|
|
544
545
|
per: capacity.per.toLocaleString(),
|
|
545
546
|
budget: capacity.budget.toLocaleString()
|
|
546
547
|
})}
|
|
547
|
-
<em
|
|
548
|
+
<em>
|
|
549
|
+
${capacity.bound === 'groups'
|
|
550
|
+
? i18next.t('figure.text.budget-measured-on', { gpu: MEASURED.gpu, on: MEASURED.on })
|
|
551
|
+
: i18next.t('figure.text.budget-not-measured-yet')}
|
|
552
|
+
</em>
|
|
548
553
|
</p>
|
|
549
554
|
</section>
|
|
550
555
|
`
|
|
@@ -10,7 +10,7 @@ import { css, html, LitElement } from 'lit'
|
|
|
10
10
|
import { customElement, property, state } from 'lit/decorators.js'
|
|
11
11
|
|
|
12
12
|
import { i18next, localize } from '@operato/i18n'
|
|
13
|
-
import type { FigureSource } from '@hatiolab/figure-model'
|
|
13
|
+
import type { ContractChange, FigureSource } from '@hatiolab/figure-model'
|
|
14
14
|
|
|
15
15
|
import type { FigureDraft, PartModel } from './figure-source.js'
|
|
16
16
|
import { DEFAULT_VIEW } from './figure-view.js'
|
|
@@ -159,6 +159,12 @@ export class FigureSide extends localize(i18next)(LitElement) {
|
|
|
159
159
|
@property({ type: Object }) draft?: FigureDraft
|
|
160
160
|
@property({ type: Array }) parts: PartModel[] = []
|
|
161
161
|
@property({ type: Object }) source?: FigureSource
|
|
162
|
+
|
|
163
|
+
/** 이 고침이 이미 놓인 인스턴스에 무엇을 하나(ADR-0063 결정 3). 발행 전 도형에는 비어 있다. */
|
|
164
|
+
@property({ type: Array }) contract: ContractChange[] = []
|
|
165
|
+
|
|
166
|
+
/** 견주지 못한 까닭. 있으면 화면이 「확인하지 못했다」로 말한다. */
|
|
167
|
+
@property({ type: String }) contractFailure?: string
|
|
162
168
|
@property({ type: Number }) selected = -1
|
|
163
169
|
/** 저작 화면의 보기 설정. 자산이 아니라 작업대다 — 저장되지 않는다. */
|
|
164
170
|
@property({ type: Object }) view: ViewSettings = DEFAULT_VIEW
|
|
@@ -198,6 +204,8 @@ export class FigureSide extends localize(i18next)(LitElement) {
|
|
|
198
204
|
.figureTags=${this.figureTags}
|
|
199
205
|
.draft=${this.draft}
|
|
200
206
|
.parts=${this.parts}
|
|
207
|
+
.contract=${this.contract}
|
|
208
|
+
.contractFailure=${this.contractFailure}
|
|
201
209
|
.selected=${this.selected}
|
|
202
210
|
></figure-inspector>
|
|
203
211
|
|
|
@@ -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
|
/** 부품 컴포넌트의 상태. 씬의 용어 그대로다. */
|
|
@@ -168,6 +174,14 @@ export interface PartModel {
|
|
|
168
174
|
*/
|
|
169
175
|
hidden?: boolean
|
|
170
176
|
|
|
177
|
+
/**
|
|
178
|
+
* 씬이 이것을 **집지 않는다** — 클릭·마퀴·기즈모 어디에도 안 걸린다.
|
|
179
|
+
*
|
|
180
|
+
* 저작면에만 서는 것(관절 부채꼴)이 쓴다. 저장 형식에는 없는 칸이라 `toFigureSource` 가
|
|
181
|
+
* 싣지 않는다.
|
|
182
|
+
*/
|
|
183
|
+
locked?: boolean
|
|
184
|
+
|
|
171
185
|
/** 색 토큰 이름 (`palette.primary` 등). */
|
|
172
186
|
token?: string
|
|
173
187
|
/*
|
|
@@ -202,6 +216,8 @@ export interface PartModel {
|
|
|
202
216
|
* 청사진에서는 `groups` 가 아니라 `anchors` 로 간다.
|
|
203
217
|
*/
|
|
204
218
|
capability?: FigurePart['capability']
|
|
219
|
+
/** The part this one is attached to (ADR-0066). The one place a parent is written. */
|
|
220
|
+
parent?: FigurePart['parent']
|
|
205
221
|
}
|
|
206
222
|
|
|
207
223
|
const RAD_TO_DEG = 180 / Math.PI
|
|
@@ -293,7 +309,8 @@ export function toFigureSource(draft: FigureDraft, parts: PartModel[]): FigureSo
|
|
|
293
309
|
parts: parts.map(part => partTo(part, half)),
|
|
294
310
|
animations: draft.animations,
|
|
295
311
|
parameters: draft.parameters,
|
|
296
|
-
capabilities: draft.capabilities
|
|
312
|
+
capabilities: draft.capabilities,
|
|
313
|
+
joints: draft.joints?.length ? draft.joints : undefined
|
|
297
314
|
}) as FigureSource
|
|
298
315
|
}
|
|
299
316
|
|
|
@@ -342,7 +359,8 @@ function partTo(part: PartModel, half: Vec3): FigurePart {
|
|
|
342
359
|
anchor: part.anchor,
|
|
343
360
|
repeat: part.repeat,
|
|
344
361
|
label: part.label,
|
|
345
|
-
capability: part.capability
|
|
362
|
+
capability: part.capability,
|
|
363
|
+
parent: part.parent
|
|
346
364
|
}) as FigurePart
|
|
347
365
|
}
|
|
348
366
|
|
|
@@ -362,7 +380,8 @@ export function fromFigureSource(source: FigureSource): { draft: FigureDraft; pa
|
|
|
362
380
|
/* Opened as stored. A legacy `drive: 'hold'` clip is refused by the format now; stored ones were migrated. */
|
|
363
381
|
animations: source.animations?.length ? source.animations : undefined,
|
|
364
382
|
parameters: source.parameters?.length ? source.parameters : undefined,
|
|
365
|
-
capabilities: source.capabilities
|
|
383
|
+
capabilities: source.capabilities,
|
|
384
|
+
joints: source.joints?.length ? source.joints : undefined
|
|
366
385
|
}) as FigureDraft
|
|
367
386
|
|
|
368
387
|
return { draft, parts: source.parts.map(part => partFrom(part, half)) }
|
|
@@ -412,6 +431,7 @@ function partFrom(part: FigurePart, half: Vec3): PartModel {
|
|
|
412
431
|
anchor: part.anchor,
|
|
413
432
|
repeat: part.repeat,
|
|
414
433
|
label: part.label,
|
|
415
|
-
capability: part.capability
|
|
434
|
+
capability: part.capability,
|
|
435
|
+
parent: part.parent
|
|
416
436
|
}) as PartModel
|
|
417
437
|
}
|
|
@@ -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
|
+
}
|