@things-factory/figure-ui 10.1.115 → 10.1.116
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 +6 -1
- package/client/modeller/editor.ts +3 -9
- package/client/modeller/figure-links.ts +20 -7
- package/client/modeller/joint-drag.ts +3 -3
- package/client/modeller/links.ts +28 -11
- package/client/modeller/recipes.ts +6 -1
- package/client/pages/figure-list-page.ts +4 -4
- package/client/types.ts +1 -1
- package/client/viewparts/detail-motion.ts +11 -0
- package/client/viewparts/figure-asset-viewer.ts +177 -0
- package/client/viewparts/figure-detail-dock.ts +187 -0
- package/dist-client/graphql/index.d.ts +5 -0
- package/dist-client/graphql/index.js +5 -1
- package/dist-client/graphql/index.js.map +1 -1
- package/dist-client/modeller/editor.d.ts +3 -2
- package/dist-client/modeller/editor.js +3 -9
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/figure-links.js +35 -22
- package/dist-client/modeller/figure-links.js.map +1 -1
- package/dist-client/modeller/joint-drag.js +3 -3
- package/dist-client/modeller/joint-drag.js.map +1 -1
- package/dist-client/modeller/links.d.ts +18 -3
- package/dist-client/modeller/links.js +21 -10
- package/dist-client/modeller/links.js.map +1 -1
- package/dist-client/modeller/recipes.js +5 -1
- package/dist-client/modeller/recipes.js.map +1 -1
- package/dist-client/pages/figure-list-page.d.ts +2 -3
- package/dist-client/pages/figure-list-page.js +4 -4
- package/dist-client/pages/figure-list-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/types.d.ts +1 -1
- package/dist-client/types.js.map +1 -1
- package/dist-client/viewparts/detail-motion.d.ts +6 -0
- package/dist-client/viewparts/detail-motion.js +9 -0
- package/dist-client/viewparts/detail-motion.js.map +1 -0
- package/dist-client/viewparts/figure-asset-viewer.d.ts +31 -0
- package/dist-client/viewparts/figure-asset-viewer.js +198 -0
- package/dist-client/viewparts/figure-asset-viewer.js.map +1 -0
- package/dist-client/viewparts/figure-detail-dock.d.ts +36 -0
- package/dist-client/viewparts/figure-detail-dock.js +224 -0
- package/dist-client/viewparts/figure-detail-dock.js.map +1 -0
- package/package.json +6 -6
- package/test/browser/detail-page.js +34 -0
- package/test/browser/serve-detail.mjs +10 -0
- package/test/detail-motion.test.ts +26 -0
- package/test/links.test.ts +20 -1
- package/test/modeller-route.test.ts +4 -2
- package/test/verdict.test.ts +3 -1
- package/translations/en.json +19 -1
- package/translations/ja.json +19 -1
- package/translations/ko.json +19 -1
- package/translations/ms.json +19 -1
- package/translations/zh.json +19 -1
package/client/graphql/index.ts
CHANGED
|
@@ -383,6 +383,11 @@ export async function inspectFigureDraft(source: string, id?: string): Promise<F
|
|
|
383
383
|
* 전 초안은 서버가 모르므로 판정도 화면에서 한다 — 형식의 `contractChanges` 를 같이 쓴다.
|
|
384
384
|
*/
|
|
385
385
|
export async function fetchReleasedSource(id: string): Promise<string | undefined> {
|
|
386
|
+
return (await fetchReleasedFigure(id))?.source
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Read-only catalogue preview: source and version come from the same released snapshot. */
|
|
390
|
+
export async function fetchReleasedFigure(id: string): Promise<{ version: number; source: string } | undefined> {
|
|
386
391
|
const response = await client.query({
|
|
387
392
|
query: gql`
|
|
388
393
|
query FigureReleased($id: String!) {
|
|
@@ -396,7 +401,7 @@ export async function fetchReleasedSource(id: string): Promise<string | undefine
|
|
|
396
401
|
fetchPolicy: 'network-only'
|
|
397
402
|
})
|
|
398
403
|
|
|
399
|
-
return unwrap<{ source
|
|
404
|
+
return unwrap<{ version: number; source: string } | undefined>(response, 'figureReleased') ?? undefined
|
|
400
405
|
}
|
|
401
406
|
|
|
402
407
|
export async function fetchFigureVersions(id: string): Promise<FigureVersion[]> {
|
|
@@ -1807,8 +1807,9 @@ export class FigureEditor extends LitElement {
|
|
|
1807
1807
|
}
|
|
1808
1808
|
}
|
|
1809
1809
|
/**
|
|
1810
|
-
* A new figure's first question, on the canvas where the work will be (designer 2026-09-27, U04): a basic shape
|
|
1811
|
-
*
|
|
1810
|
+
* A new figure's first question, on the canvas where the work will be (designer 2026-09-27, U04): a basic shape
|
|
1811
|
+
* or an AI draft when the page offers one. Recipes require a host part and are reached from the parts panel after
|
|
1812
|
+
* that part exists. Nothing is added until one is chosen; the type, the space, the
|
|
1812
1813
|
* mounting face and the tags are not asked for first.
|
|
1813
1814
|
*/
|
|
1814
1815
|
/** The section editor over the canvas, the figure behind it drawing what the working points make. */
|
|
@@ -1886,13 +1887,6 @@ export class FigureEditor extends LitElement {
|
|
|
1886
1887
|
</div>
|
|
1887
1888
|
<small>${i18next.t('figure.text.start-shape-more')}</small>
|
|
1888
1889
|
</section>
|
|
1889
|
-
<section>
|
|
1890
|
-
<h3>${i18next.t('figure.label.start-recipe')}</h3>
|
|
1891
|
-
<button start-button start-recipe ?disabled=${!this.templates.length} @click=${() => ((this.catalogOpen = true), (this.recipeError = ''))}>
|
|
1892
|
-
${i18next.t('figure.button.start-recipe')}
|
|
1893
|
-
</button>
|
|
1894
|
-
${this.templates.length ? nothing : html`<small>${i18next.t('figure.text.start-no-recipes')}</small>`}
|
|
1895
|
-
</section>
|
|
1896
1890
|
${this.aiDraft
|
|
1897
1891
|
? html`<section>
|
|
1898
1892
|
<h3>${i18next.t('figure.label.start-ai')}</h3>
|
|
@@ -864,8 +864,10 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
864
864
|
${now.travel
|
|
865
865
|
? html`<small>${i18next.t('figure.label.motion-travel-is', { part: now.travel.part, axis: now.travel.axis.toUpperCase(), plus: now.travel.plus ?? 0 })}</small>`
|
|
866
866
|
: nothing}
|
|
867
|
-
${now.follows
|
|
868
|
-
? html`<small>${i18next.t('figure.label.motion-follows', { joint: now.follows.joint, multiplier: now.follows.multiplier, offset: now.follows.offset
|
|
867
|
+
${now.follows?.sum.length === 1
|
|
868
|
+
? html`<small>${i18next.t('figure.label.motion-follows', { joint: now.follows.sum[0]!.joint, multiplier: now.follows.sum[0]!.multiplier, offset: now.follows.offset })}</small>`
|
|
869
|
+
: now.follows
|
|
870
|
+
? html`<small>${i18next.t('figure.label.motion-follow')}: ${now.follows.sum.map(t => `${t.joint} × ${t.multiplier}`).join(' + ')} + ${now.follows.offset}</small>`
|
|
869
871
|
: now.control
|
|
870
872
|
? html`<small>${now.control.id} (${now.control.unit}) ${now.control.min} … ${now.control.max}</small>`
|
|
871
873
|
: nothing}
|
|
@@ -912,7 +914,14 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
912
914
|
${AXES.map(a => html`<option value=${a} ?selected=${a === m.axis}>${i18next.t('figure.label.motion-axis')} ${a.toUpperCase()}</option>`)}
|
|
913
915
|
</select>
|
|
914
916
|
</div>
|
|
915
|
-
${
|
|
917
|
+
${m.followSum
|
|
918
|
+
? html`<div form>
|
|
919
|
+
<span>${i18next.t('figure.label.motion-follow')}</span>
|
|
920
|
+
<span>${m.followSum.map(t => `${t.joint} × ${t.multiplier}`).join(' + ')}</span>
|
|
921
|
+
<span>${i18next.t('figure.label.motion-offset')} (${unit})</span>
|
|
922
|
+
<input type="number" step="any" .value=${String(m.offset)} @change=${(e: Event) => (this.motion = { ...m, offset: Number((e.target as HTMLInputElement).value) || 0 })} />
|
|
923
|
+
</div>`
|
|
924
|
+
: leaders.length
|
|
916
925
|
? html`<div form>
|
|
917
926
|
<span>${i18next.t('figure.label.motion-follow')}</span>
|
|
918
927
|
<select @change=${(e: Event) => (this.motion = { ...m, follow: (e.target as HTMLSelectElement).value })}>
|
|
@@ -921,7 +930,9 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
921
930
|
</select>
|
|
922
931
|
</div>`
|
|
923
932
|
: nothing}
|
|
924
|
-
${
|
|
933
|
+
${m.followSum
|
|
934
|
+
? nothing
|
|
935
|
+
: follow
|
|
925
936
|
? html`<div form>
|
|
926
937
|
<span>${i18next.t('figure.label.motion-multiplier')}</span>
|
|
927
938
|
<input type="number" step="any" .value=${String(m.multiplier)} @change=${(e: Event) => (this.motion = { ...m, multiplier: Number((e.target as HTMLInputElement).value) })} />
|
|
@@ -932,7 +943,9 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
932
943
|
${m.kind === 'slide'
|
|
933
944
|
? html`<span>${i18next.t('figure.label.motion-travel')}</span>
|
|
934
945
|
<select @change=${(e: Event) => (this.motion = { ...m, travel: (e.target as HTMLSelectElement).value })}>
|
|
935
|
-
|
|
946
|
+
${m.travelSum
|
|
947
|
+
? html`<option value="" ?selected=${!m.travel}>${m.travelSum.sum.map(t => `${t.from.of === 'input' ? t.from.input : `${t.from.of === 'part' ? t.from.part : i18next.t('figure.label.motion-travel')} · ${t.from.axis.toUpperCase()}`}${t.times !== undefined && t.times !== 1 ? ` × ${t.times}` : ''}`).join(' + ')}</option>`
|
|
948
|
+
: html`<option value="" ?selected=${!m.travel}>${i18next.t('figure.label.motion-travel-mm')}</option>`}
|
|
936
949
|
${asset.appearance
|
|
937
950
|
.map(look => look.target)
|
|
938
951
|
.filter(one => canFollow(asset, one))
|
|
@@ -945,7 +958,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
945
958
|
: nothing}
|
|
946
959
|
<span>${i18next.t('figure.label.control-id')}</span>
|
|
947
960
|
<input .value=${m.id} placeholder=${`${this.partId}-${m.kind}`} @change=${(e: Event) => (this.motion = { ...m, id: (e.target as HTMLInputElement).value.trim() })} />
|
|
948
|
-
${m.kind === 'continuous' || (m.kind === 'slide' && m.travel)
|
|
961
|
+
${m.kind === 'continuous' || (m.kind === 'slide' && (m.travel || m.travelSum))
|
|
949
962
|
? nothing
|
|
950
963
|
: html`<span>${i18next.t('figure.label.control-range')} (${unit})</span>
|
|
951
964
|
<div pair>
|
|
@@ -955,7 +968,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
955
968
|
</div>`}
|
|
956
969
|
<button
|
|
957
970
|
act
|
|
958
|
-
?disabled=${this.locked || (follow ? !(Number.isFinite(m.multiplier) && m.multiplier !== 0) : m.kind !== 'continuous' && !(m.kind === 'slide' && m.travel) && !(m.max > m.min && m.min <= 0 && 0 <= m.max))}
|
|
971
|
+
?disabled=${this.locked || (m.followSum ? false : follow ? !(Number.isFinite(m.multiplier) && m.multiplier !== 0) : m.kind !== 'continuous' && !(m.kind === 'slide' && (m.travel || m.travelSum)) && !(m.max > m.min && m.min <= 0 && 0 <= m.max))}
|
|
959
972
|
title=${!follow && m.kind !== 'continuous' && !(m.min <= 0 && 0 <= m.max) ? i18next.t('figure.text.joint-range-holds-zero') : ''}
|
|
960
973
|
@click=${() => {
|
|
961
974
|
this.command({ kind: 'set-joint', joint: motionJoint(joint, { ...m, follow }, this.partId) })
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as THREE from 'three'
|
|
2
|
-
import { compileFigureAsset } from '@hatiolab/figure-model'
|
|
2
|
+
import { compileFigureAsset, figureMimicTermsOf } from '@hatiolab/figure-model'
|
|
3
3
|
import type { FigureAsset, FigureJoint } from '@hatiolab/figure-model'
|
|
4
4
|
|
|
5
5
|
import type { GizmoResult } from './gizmo.js'
|
|
@@ -56,7 +56,7 @@ export function jointDragOf(asset: FigureAsset, id: string, part: FigureRenderPa
|
|
|
56
56
|
|
|
57
57
|
if (result.rotation) {
|
|
58
58
|
if (!turns) return { kind: 'refused', reason: j.type === 'prismatic' ? 'turned-slide' : 'fixed-turn', joint: j.id }
|
|
59
|
-
if (j.mimic) return { kind: 'refused', reason: 'mimic', joint: j.id, leader: j.mimic.joint }
|
|
59
|
+
if (j.mimic) return { kind: 'refused', reason: 'mimic', joint: j.id, leader: figureMimicTermsOf(j.mimic)!.sum.map(t => t.joint).join(', ') }
|
|
60
60
|
/* The turn about the joint's axis (the twist of the change): the only turn the joint can make. */
|
|
61
61
|
const before = new THREE.Quaternion().setFromRotationMatrix(matrixOf(part.pose.r as number[][]))
|
|
62
62
|
const after = new THREE.Quaternion().setFromRotationMatrix(eulerMatrix(result.rotation))
|
|
@@ -77,7 +77,7 @@ export function jointDragOf(asset: FigureAsset, id: string, part: FigureRenderPa
|
|
|
77
77
|
const frame = jointFrameRotation(j, part, turns ? now : 0)
|
|
78
78
|
|
|
79
79
|
if (j.type === 'prismatic') {
|
|
80
|
-
if (j.mimic) return { kind: 'refused', reason: 'mimic', joint: j.id, leader: j.mimic.joint }
|
|
80
|
+
if (j.mimic) return { kind: 'refused', reason: 'mimic', joint: j.id, leader: figureMimicTermsOf(j.mimic)!.sum.map(t => t.joint).join(', ') }
|
|
81
81
|
/* How far one unit of the value moves the part along its axis, measured on the figure itself (a ratio travels a length). */
|
|
82
82
|
const along = unit(j.axis!).applyMatrix4(frame).normalize()
|
|
83
83
|
const at = (v: number) => {
|
package/client/modeller/links.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FIGURE_FACES, figureShapeFaces, figurePartsExtentOf, compileFigureAsset, compileFigureGraph, figureFollowOf, proposeFigureProportion, defaultInstanceSizeRange, foldInstanceSizeRange, figureAttachmentsOf, figureBindingsOf, figureInstanceSizeLimitsOf, figureFollowableAxesOf, figureShapeProviderOf } from '@hatiolab/figure-model'
|
|
1
|
+
import { FIGURE_FACES, figureShapeFaces, figurePartsExtentOf, compileFigureAsset, compileFigureGraph, figureFollowOf, proposeFigureProportion, defaultInstanceSizeRange, foldInstanceSizeRange, figureAttachmentsOf, figureBindingsOf, figureInstanceSizeLimitsOf, figureFollowableAxesOf, figureShapeProviderOf, figureMimicTermsOf } from '@hatiolab/figure-model'
|
|
2
2
|
import type { FigureFollow, FigureSizeRange } from '@hatiolab/figure-model'
|
|
3
3
|
import type { FigureAsset, FigureBindingPlace, FigureFace, FigureJoint } from '@hatiolab/figure-model'
|
|
4
4
|
import { startingShapeKindOf } from './starting-shape.js'
|
|
@@ -458,7 +458,8 @@ export interface Motion {
|
|
|
458
458
|
axis: Axis | '?'
|
|
459
459
|
control?: { id: string; unit?: string; min?: number; max?: number }
|
|
460
460
|
/** A joint that moves with another (URDF's mimic): the leader's value times `multiplier`, plus `offset`. */
|
|
461
|
-
follows
|
|
461
|
+
/** What it follows, as a sum of leaders each with its multiplier, plus an offset (one leader is a sum of one). */
|
|
462
|
+
follows?: { sum: Array<{ joint: string; multiplier: number }>; offset: number }
|
|
462
463
|
/** A slide whose distance is a part's length (plus a difference): the control is its share. */
|
|
463
464
|
travel?: { part: string; axis: string; plus?: number }
|
|
464
465
|
}
|
|
@@ -487,8 +488,9 @@ export function motionOf(asset: FigureAsset, part: string): Motion | undefined {
|
|
|
487
488
|
}
|
|
488
489
|
return undefined
|
|
489
490
|
}
|
|
490
|
-
const
|
|
491
|
-
|
|
491
|
+
const one = record?.travel && 'source' in record.travel ? record.travel : undefined
|
|
492
|
+
const travel = one?.source && 'part' in one.source ? { part: one.source.part, axis: one.source.axis, ...(one.plus ? { plus: one.plus } : {}) } : undefined
|
|
493
|
+
return { kind, axis, control: control(quantity), ...(record?.mimic ? { follows: figureMimicTermsOf(record.mimic)! } : {}), ...(travel ? { travel } : {}) }
|
|
492
494
|
}
|
|
493
495
|
|
|
494
496
|
/** What the motion form holds: how the part moves, and either its own control or the joint it moves with. */
|
|
@@ -508,6 +510,13 @@ export interface MotionForm {
|
|
|
508
510
|
*/
|
|
509
511
|
travel?: string
|
|
510
512
|
travelPlus?: number
|
|
513
|
+
/**
|
|
514
|
+
* Kept as they are and not edited here: a follower of several leaders (`{ sum, offset }`, the offset still in
|
|
515
|
+
* `offset`) and a stroke that is a sum of lengths. The form says them and writes them back unchanged, so saving
|
|
516
|
+
* a range or an axis does not turn a follower into a joint of its own or drop its stroke.
|
|
517
|
+
*/
|
|
518
|
+
followSum?: Array<{ joint: string; multiplier: number }>
|
|
519
|
+
travelSum?: Extract<NonNullable<FigureJoint['travel']>, { sum: unknown }>
|
|
511
520
|
}
|
|
512
521
|
|
|
513
522
|
/**
|
|
@@ -517,18 +526,24 @@ export interface MotionForm {
|
|
|
517
526
|
*/
|
|
518
527
|
export function motionFormOf(joint: FigureJoint): MotionForm {
|
|
519
528
|
const kind: MotionForm['kind'] = joint.type === 'prismatic' ? 'slide' : joint.lowerLimit === undefined && joint.upperLimit === undefined && !joint.mimic ? 'continuous' : 'turn'
|
|
520
|
-
const
|
|
529
|
+
const one = joint.travel && 'source' in joint.travel ? joint.travel : undefined
|
|
530
|
+
const travel = one?.source && 'part' in one.source ? `${one.source.part}:${one.source.axis}` : ''
|
|
531
|
+
const travelSum = joint.travel && 'sum' in joint.travel ? joint.travel : undefined
|
|
532
|
+
const terms = figureMimicTermsOf(joint.mimic)
|
|
533
|
+
const single = terms?.sum.length === 1 ? terms.sum[0] : undefined
|
|
521
534
|
return {
|
|
522
535
|
kind,
|
|
523
536
|
axis: (joint.axis ?? 'X').toLowerCase() as Axis,
|
|
524
537
|
id: joint.state?.id ?? '',
|
|
525
|
-
min: travel ? 0 : (joint.lowerLimit ?? (kind === 'slide' ? 0 : -90)),
|
|
526
|
-
max: travel ? 500 : (joint.upperLimit ?? (kind === 'slide' ? 500 : 90)),
|
|
527
|
-
follow:
|
|
528
|
-
multiplier:
|
|
529
|
-
offset:
|
|
538
|
+
min: travel || travelSum ? 0 : (joint.lowerLimit ?? (kind === 'slide' ? 0 : -90)),
|
|
539
|
+
max: travel || travelSum ? 500 : (joint.upperLimit ?? (kind === 'slide' ? 500 : 90)),
|
|
540
|
+
follow: single?.joint ?? '',
|
|
541
|
+
multiplier: single?.multiplier ?? 1,
|
|
542
|
+
offset: terms?.offset ?? 0,
|
|
530
543
|
travel,
|
|
531
|
-
travelPlus:
|
|
544
|
+
travelPlus: one?.plus ?? 0,
|
|
545
|
+
...(terms && !single ? { followSum: terms.sum } : {}),
|
|
546
|
+
...(travelSum ? { travelSum } : {})
|
|
532
547
|
}
|
|
533
548
|
}
|
|
534
549
|
|
|
@@ -557,6 +572,8 @@ export function motionJoint(joint: FigureJoint, m: MotionForm, part: string): Fi
|
|
|
557
572
|
axis: m.axis.toUpperCase() as 'X' | 'Y' | 'Z'
|
|
558
573
|
}
|
|
559
574
|
if (m.follow) return { ...moving, mimic: { joint: m.follow, multiplier: m.multiplier, ...(m.offset ? { offset: m.offset } : {}) } }
|
|
575
|
+
if (m.followSum) return { ...moving, mimic: { sum: m.followSum, ...(m.offset ? { offset: m.offset } : {}) } }
|
|
576
|
+
if (m.kind === 'slide' && m.travelSum && !m.travel) return { ...moving, travel: m.travelSum, state: { id: m.id || `${part}-${m.kind}` } }
|
|
560
577
|
if (m.kind === 'slide' && m.travel) {
|
|
561
578
|
const [of, axis] = m.travel.split(':')
|
|
562
579
|
return { ...moving, travel: { source: { of: 'part', part: of!, axis: axis as Axis }, ...(m.travelPlus ? { plus: m.travelPlus } : {}) }, state: { id: m.id || `${part}-${m.kind}` } }
|
|
@@ -39,7 +39,7 @@ export function recipeWorkings(recipe: FigureRecipe): Array<{ key: string; words
|
|
|
39
39
|
for (const m of d.moves)
|
|
40
40
|
lines.push(
|
|
41
41
|
m.follows
|
|
42
|
-
? { key: 'figure.text.recipe-moves-with', words: { part: recipePartName(m.part), leader:
|
|
42
|
+
? { key: 'figure.text.recipe-moves-with', words: { part: recipePartName(m.part), leader: recipeLeaders(m.follows).map(recipePartName).join(', ') } }
|
|
43
43
|
: { key: m.kind === 'slide' ? 'figure.text.recipe-slides' : 'figure.text.recipe-turns', words: { part: recipePartName(m.part) } }
|
|
44
44
|
)
|
|
45
45
|
if (d.controls.length) lines.push({ key: 'figure.text.recipe-controls', words: { count: d.controls.length } })
|
|
@@ -48,6 +48,11 @@ export function recipeWorkings(recipe: FigureRecipe): Array<{ key: string; words
|
|
|
48
48
|
return lines
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/** Released kernels return one leader; newer kernels can return an affine sum. */
|
|
52
|
+
function recipeLeaders(follows: string | { sum: Array<{ joint: string }> }): string[] {
|
|
53
|
+
return typeof follows === 'string' ? [follows] : follows.sum.map(term => term.joint)
|
|
54
|
+
}
|
|
55
|
+
|
|
51
56
|
/** A recipe's words in the language on screen: that language, else English, else the first it has. */
|
|
52
57
|
export function recipeWord(words: Record<string, string> | undefined, language: string): string {
|
|
53
58
|
if (!words) return ''
|
|
@@ -16,6 +16,7 @@ import { capacityFrom } from '../modeller/scene-budget.js'
|
|
|
16
16
|
import { deleteFigure, fetchFigureList } from '../graphql/index.js'
|
|
17
17
|
import type { Figure } from '../types.js'
|
|
18
18
|
import { modellerPathOf } from '../modeller/modeller-route.js'
|
|
19
|
+
import { openFigureDetail } from '../viewparts/figure-detail-dock.js'
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* 이 값 아래면 점수를 색으로 눈에 띄게 한다.
|
|
@@ -62,9 +63,8 @@ const FigureListPageBase = p13n(localize(i18next)(PageView))
|
|
|
62
63
|
/**
|
|
63
64
|
* Figure 카탈로그.
|
|
64
65
|
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* 수는 고른 뒤에 따지는 값이라 표 모드의 칸으로 남기고 저작면에서 본다.
|
|
66
|
+
* 카드를 고르면 상세 패널에서 저장본과 발행판을 살펴본다. 크기와 움직임을 시험한 뒤
|
|
67
|
+
* 「편집 화면 열기」로 저작면에 들어간다. 목록 질의는 자산 원본을 싣지 않는다.
|
|
68
68
|
*
|
|
69
69
|
* ## 왜 손으로 만든 카드가 아니라 격자인가
|
|
70
70
|
*
|
|
@@ -531,7 +531,7 @@ export class FigureListPage extends FigureListPageBase {
|
|
|
531
531
|
handlers: {
|
|
532
532
|
click: (_columns: any, _data: any, _column: any, record: Figure) => {
|
|
533
533
|
if (record?.id) {
|
|
534
|
-
|
|
534
|
+
openFigureDetail(record)
|
|
535
535
|
}
|
|
536
536
|
}
|
|
537
537
|
}
|
package/client/types.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* 화면이 다루는 모양. 도형 자산의 타입은 `@hatiolab/figure-model` 이 정한다 — 여기서 다시 적지 않는다.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
/** 서버가 돌려주는 Figure 한 벌. `source` 는
|
|
5
|
+
/** 서버가 돌려주는 Figure 한 벌. `source` 는 상세 또는 저작면을 열 때만 받는다. */
|
|
6
6
|
export interface Figure {
|
|
7
7
|
id: string
|
|
8
8
|
/** 컴포넌트 타입 이름. **만든 뒤에 고칠 수 없다.** */
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { FigureAsset } from '@hatiolab/figure-model'
|
|
2
|
+
|
|
3
|
+
/** External values win over drivers; release only the controls owned by the chosen clip. */
|
|
4
|
+
export function parametersForClip(asset: FigureAsset, values: Record<string, number>, clip: string) {
|
|
5
|
+
const driven = new Set((asset.drivers ?? []).filter(d => d.clip === clip).map(d => d.state))
|
|
6
|
+
return Object.fromEntries(Object.entries(values).filter(([id]) => !driven.has(id)))
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function clipForControl(asset: FigureAsset, id: string) {
|
|
10
|
+
return asset.drivers?.find(d => d.state === id)?.clip
|
|
11
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { css, html, LitElement, nothing, type PropertyValues } from 'lit'
|
|
2
|
+
import { customElement, property, query, state } from 'lit/decorators.js'
|
|
3
|
+
import { i18next, localize } from '@operato/i18n'
|
|
4
|
+
import { compileFigureAsset, FIGURE_INSTANCE_SIZE_INPUTS, type FigureAsset } from '@hatiolab/figure-model'
|
|
5
|
+
import { create, registerFigure, unregisterFigure } from '@hatiolab/things-scene'
|
|
6
|
+
import { authoredSizeOf } from '../modeller/preview-model.js'
|
|
7
|
+
import { controlsOf } from '../modeller/controls.js'
|
|
8
|
+
import { DEFAULT_VIEW, previewBoardDepth, sceneLook } from '../modeller/figure-view.js'
|
|
9
|
+
import { clipForControl, parametersForClip } from './detail-motion.js'
|
|
10
|
+
|
|
11
|
+
type Axis = 'x' | 'y' | 'z'
|
|
12
|
+
interface Scene {
|
|
13
|
+
dispose?(): void
|
|
14
|
+
resize?(): void
|
|
15
|
+
fit?(type?: string): void
|
|
16
|
+
root?: {
|
|
17
|
+
fitCameraToModel?(): void
|
|
18
|
+
components?: Array<{ set(key: string, value: unknown): void }>
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A single board instance. Sizes and poses are transient, and never change the asset or call a mutation. */
|
|
23
|
+
@customElement('figure-asset-viewer')
|
|
24
|
+
export class FigureAssetViewer extends localize(i18next)(LitElement) {
|
|
25
|
+
static styles = css`
|
|
26
|
+
:host { display: block; min-width: 0; }
|
|
27
|
+
[viewport] { height: 300px; position: relative; background: #858585; }
|
|
28
|
+
[stage] { position: absolute; inset: 0; width: 100%; height: 100%; }
|
|
29
|
+
section { padding: 12px 16px; border-bottom: 1px solid var(--md-sys-color-outline-variant); }
|
|
30
|
+
h3 { margin: 0 0 10px; font-size: var(--ui-t-body, 13px); }
|
|
31
|
+
label { display: grid; grid-template-columns: 72px minmax(0, 1fr) 90px; align-items: center; gap: 8px; margin: 8px 0; }
|
|
32
|
+
input { min-width: 0; width: 100%; box-sizing: border-box; }
|
|
33
|
+
input[type=number] { padding: 6px; }
|
|
34
|
+
input, button { font: inherit; color: inherit; }
|
|
35
|
+
button { padding: 8px 12px; border: 1px solid var(--md-sys-color-outline); border-radius: 8px; background: var(--md-sys-color-surface); cursor: pointer; }
|
|
36
|
+
[clips] { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
37
|
+
p { font-size: 12px; line-height: 1.5; }
|
|
38
|
+
[error] { color: var(--md-sys-color-error); }
|
|
39
|
+
`
|
|
40
|
+
@property({ attribute: false }) asset?: FigureAsset
|
|
41
|
+
@query('[stage]') private stage!: HTMLElement
|
|
42
|
+
@state() private sizes: Record<Axis, number> = { x: 1, y: 1, z: 1 }
|
|
43
|
+
@state() private values: Record<string, number> = {}
|
|
44
|
+
@state() private playing = ''
|
|
45
|
+
@state() private error = ''
|
|
46
|
+
private scene?: Scene
|
|
47
|
+
private registered?: string
|
|
48
|
+
private observer?: ResizeObserver
|
|
49
|
+
private needsScene = false
|
|
50
|
+
private played = new Set<string>()
|
|
51
|
+
|
|
52
|
+
connectedCallback() {
|
|
53
|
+
super.connectedCallback()
|
|
54
|
+
this.needsScene = true
|
|
55
|
+
if (this.hasUpdated) this.observer?.observe(this.stage)
|
|
56
|
+
this.requestUpdate()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
protected updated(changed: PropertyValues) {
|
|
60
|
+
// Reconnection and a new asset can arrive in the same update. Build exactly once.
|
|
61
|
+
if (changed.has('asset') || this.needsScene) {
|
|
62
|
+
this.needsScene = false
|
|
63
|
+
this.standUp()
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
firstUpdated() {
|
|
67
|
+
this.observer = new ResizeObserver(() => this.scene?.resize?.())
|
|
68
|
+
this.observer.observe(this.stage)
|
|
69
|
+
}
|
|
70
|
+
disconnectedCallback() {
|
|
71
|
+
super.disconnectedCallback()
|
|
72
|
+
this.observer?.disconnect()
|
|
73
|
+
this.teardown()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
render() {
|
|
77
|
+
const asset = this.asset
|
|
78
|
+
return html`<div viewport><div stage></div></div>
|
|
79
|
+
${this.error ? html`<p error role="alert">${this.error}</p>` : nothing}
|
|
80
|
+
${asset ? html`<section>
|
|
81
|
+
<h3>${i18next.t('figure.label.detail-size')}</h3>
|
|
82
|
+
${(['x', 'y', 'z'] as const).map(axis => {
|
|
83
|
+
const input = asset.document.model.inputs.find(input => input.id === FIGURE_INSTANCE_SIZE_INPUTS[axis])
|
|
84
|
+
return html`<label><span>${axis.toUpperCase()} · mm</span>
|
|
85
|
+
<input type="range" aria-label=${`${axis.toUpperCase()} mm`} min=${input?.min ?? this.sizes[axis]} max=${input?.max ?? this.sizes[axis]} step="any"
|
|
86
|
+
.value=${String(this.sizes[axis])} ?disabled=${!input || input.min === input.max}
|
|
87
|
+
@input=${(e: Event) => this.resize(axis, (e.target as HTMLInputElement).valueAsNumber)}>
|
|
88
|
+
<input type="number" aria-label=${`${axis.toUpperCase()} mm value`} min=${input?.min ?? this.sizes[axis]} max=${input?.max ?? this.sizes[axis]} step="any"
|
|
89
|
+
.value=${String(this.sizes[axis])} ?disabled=${!input || input.min === input.max}
|
|
90
|
+
@change=${(e: Event) => this.resize(axis, (e.target as HTMLInputElement).valueAsNumber)}>
|
|
91
|
+
</label>`
|
|
92
|
+
})}
|
|
93
|
+
<button @click=${() => this.reset()}>${i18next.t('figure.button.detail-reset')}</button>
|
|
94
|
+
<p>${i18next.t('figure.text.detail-transient')}</p>
|
|
95
|
+
</section>
|
|
96
|
+
<section><h3>${i18next.t('figure.label.motion')}</h3>
|
|
97
|
+
${controlsOf(asset, this.values).map(c => html`<label><span>${c.label} · ${c.unit}</span>
|
|
98
|
+
<input type="range" aria-label=${c.label} min=${c.min} max=${c.max} step=${c.step || 'any'} .value=${String(c.value)}
|
|
99
|
+
?disabled=${!!this.playing && clipForControl(asset, c.id) === this.playing}
|
|
100
|
+
@input=${(e: Event) => this.pose(c.id, (e.target as HTMLInputElement).valueAsNumber)}>
|
|
101
|
+
<output>${this.playing && clipForControl(asset, c.id) === this.playing ? i18next.t('figure.text.detail-playing')
|
|
102
|
+
: this.played.has(clipForControl(asset, c.id) ?? '') && !(c.id in this.values) ? i18next.t('figure.text.detail-paused') : Math.round(c.value * 100) / 100}</output></label>`)}
|
|
103
|
+
<div clips>${[...new Set((asset.drivers ?? []).map(d => d.clip))].map(name => html`<button aria-pressed=${String(this.playing === name)}
|
|
104
|
+
@click=${() => this.play(name)}>${this.playing === name ? '⏸' : '▶'} ${name}</button>`)}</div>
|
|
105
|
+
${!controlsOf(asset).length && ![...new Set((asset.drivers ?? []).map(d => d.clip))].length ? html`<p>${i18next.t('figure.text.detail-no-motion')}</p>` : nothing}
|
|
106
|
+
</section>` : nothing}`
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private tell(key: string, value: unknown) {
|
|
110
|
+
this.scene?.root?.components?.[0]?.set(key, value)
|
|
111
|
+
}
|
|
112
|
+
private resize(axis: Axis, value: number) {
|
|
113
|
+
const input = this.asset?.document.model.inputs.find(input => input.id === FIGURE_INSTANCE_SIZE_INPUTS[axis])
|
|
114
|
+
if (!input || !Number.isFinite(value)) { this.requestUpdate(); return }
|
|
115
|
+
const used = Math.min(input.max, Math.max(input.min, value))
|
|
116
|
+
this.sizes = { ...this.sizes, [axis]: used }
|
|
117
|
+
this.tell(axis === 'x' ? 'width' : axis === 'y' ? 'depth' : 'height', used)
|
|
118
|
+
this.scene?.root?.fitCameraToModel?.()
|
|
119
|
+
}
|
|
120
|
+
private pose(id: string, value: number) {
|
|
121
|
+
if (!Number.isFinite(value)) return
|
|
122
|
+
this.values = { ...this.values, [id]: value }
|
|
123
|
+
this.tell('parameters', this.values)
|
|
124
|
+
}
|
|
125
|
+
private play(name: string) {
|
|
126
|
+
this.playing = this.playing === name ? '' : name
|
|
127
|
+
if (this.playing && this.asset) {
|
|
128
|
+
this.played.add(name)
|
|
129
|
+
this.values = parametersForClip(this.asset, this.values, name)
|
|
130
|
+
this.tell('parameters', this.values)
|
|
131
|
+
}
|
|
132
|
+
this.tell('clips', Object.fromEntries([...new Set((this.asset?.drivers ?? []).map(d => d.clip))].map(key => [key, key === this.playing ? 1 : 0])))
|
|
133
|
+
}
|
|
134
|
+
private reset() {
|
|
135
|
+
if (!this.asset) return
|
|
136
|
+
// A new instance also resets driver clocks. Clearing speeds alone freezes the last pose.
|
|
137
|
+
this.standUp()
|
|
138
|
+
}
|
|
139
|
+
private standUp() {
|
|
140
|
+
this.teardown()
|
|
141
|
+
this.error = ''
|
|
142
|
+
const asset = this.asset
|
|
143
|
+
if (!asset) return
|
|
144
|
+
this.values = {}
|
|
145
|
+
this.playing = ''
|
|
146
|
+
this.played.clear()
|
|
147
|
+
try {
|
|
148
|
+
compileFigureAsset(asset)
|
|
149
|
+
if (!asset.appearance.length) throw Error(i18next.t('figure.text.preview-needs-a-part'))
|
|
150
|
+
this.sizes = authoredSizeOf(asset)
|
|
151
|
+
const type = `figure-detail-${crypto.randomUUID()}`
|
|
152
|
+
registerFigure(type, asset)
|
|
153
|
+
this.registered = type
|
|
154
|
+
const size = this.sizes
|
|
155
|
+
this.scene = create({ target: this.stage, mode: 0, model: {
|
|
156
|
+
type: 'model-layer', width: size.x * 2, height: size.z * 2,
|
|
157
|
+
depth: previewBoardDepth({ placement: asset.placement, base: { y: size.y } }, DEFAULT_VIEW.ceilingHeight, 1),
|
|
158
|
+
threed: true, sky: 'color', ...sceneLook(DEFAULT_VIEW),
|
|
159
|
+
placement: DEFAULT_VIEW.placement, heights: { floor: 0, ceiling: DEFAULT_VIEW.ceilingHeight },
|
|
160
|
+
pixelRatio: Math.min(3, (window.devicePixelRatio || 1) * 2),
|
|
161
|
+
components: [{ type, id: 'detail-instance', left: size.x / 2, top: size.z / 2, width: size.x, height: size.z, depth: size.y }]
|
|
162
|
+
} }) as Scene
|
|
163
|
+
this.scene.resize?.()
|
|
164
|
+
this.scene.fit?.('ratio')
|
|
165
|
+
this.scene.root?.fitCameraToModel?.()
|
|
166
|
+
} catch (error) {
|
|
167
|
+
this.error = (error as Error).message
|
|
168
|
+
this.teardown()
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
private teardown() {
|
|
172
|
+
this.scene?.dispose?.()
|
|
173
|
+
this.scene = undefined
|
|
174
|
+
if (this.registered) unregisterFigure(this.registered)
|
|
175
|
+
this.registered = undefined
|
|
176
|
+
}
|
|
177
|
+
}
|