@things-factory/figure-ui 10.1.114 → 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/canvas.ts +6 -0
- package/client/modeller/editor.ts +4 -9
- package/client/modeller/figure-links.ts +20 -7
- package/client/modeller/figure-preview.ts +34 -10
- package/client/modeller/joint-drag.ts +3 -3
- package/client/modeller/links.ts +28 -11
- package/client/modeller/preview-model.ts +8 -0
- package/client/modeller/recipes.ts +6 -1
- package/client/pages/figure-list-page.ts +4 -4
- package/client/pages/figure-modeller-page.ts +7 -1
- 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/canvas.js +7 -0
- package/dist-client/modeller/canvas.js.map +1 -1
- package/dist-client/modeller/clips.d.ts +1 -1
- package/dist-client/modeller/editor.d.ts +3 -2
- package/dist-client/modeller/editor.js +4 -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/figure-preview.d.ts +2 -0
- package/dist-client/modeller/figure-preview.js +38 -10
- package/dist-client/modeller/figure-preview.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/preview-model.d.ts +5 -0
- package/dist-client/modeller/preview-model.js +7 -0
- package/dist-client/modeller/preview-model.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/pages/figure-modeller-page.d.ts +1 -0
- package/dist-client/pages/figure-modeller-page.js +7 -1
- package/dist-client/pages/figure-modeller-page.js.map +1 -1
- package/dist-client/route.d.ts +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 +12 -2
- package/test/preview.test.ts +39 -1
- 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[]> {
|
|
@@ -280,6 +280,12 @@ export class FigureCanvas extends LitElement {
|
|
|
280
280
|
const root = layer?.root
|
|
281
281
|
if (!layer || !root?.on || this.viewOnly) return
|
|
282
282
|
root.on('selected', (after: unknown) => {
|
|
283
|
+
/*
|
|
284
|
+
A canvas taken off the screen disposes its scene, and the scene empties its selection as it goes. Told out,
|
|
285
|
+
that emptied the editor's choice whenever the middle turned to the preview (U03): the listener lit set on this
|
|
286
|
+
element hears it even after it left the page. A canvas not on the page has nothing to say about the choice.
|
|
287
|
+
*/
|
|
288
|
+
if (!this.isConnected) return
|
|
283
289
|
const name = (after as SceneComponent[] | undefined)?.[0]?.state?.name
|
|
284
290
|
const id = typeof name === 'string' ? name : ''
|
|
285
291
|
if (ownerOf(id) !== this.selected) this.dispatchEvent(new CustomEvent('part-selected', { detail: id, bubbles: true, composed: true }))
|
|
@@ -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>
|
|
@@ -2046,6 +2040,7 @@ export class FigureEditor extends LitElement {
|
|
|
2046
2040
|
.asset=${asset}
|
|
2047
2041
|
.context=${view.context ?? ''}
|
|
2048
2042
|
.view=${this.view}
|
|
2043
|
+
.selected=${this.selected}
|
|
2049
2044
|
@configure-size=${async (e: CustomEvent<{ part: string }>) => {
|
|
2050
2045
|
this.middle = 'edit'
|
|
2051
2046
|
this.selected = e.detail.part
|
|
@@ -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) })
|
|
@@ -8,7 +8,7 @@ import { create, registerFigure, unregisterFigure } from '@hatiolab/things-scene
|
|
|
8
8
|
|
|
9
9
|
import { DEFAULT_VIEW, previewBoardDepth, sceneLook, WORKBENCH, type ViewSettings } from './figure-view.js'
|
|
10
10
|
import { dialShape } from './angle-dial.js'
|
|
11
|
-
import { authoredSizeOf, PREVIEW_FACTOR, PREVIEW_WAYS, previewClampsOf, previewInstances } from './preview-model.js'
|
|
11
|
+
import { authoredSizeOf, PREVIEW_FACTOR, PREVIEW_WAYS, previewClampsOf, previewInstances, setupPartOf } from './preview-model.js'
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* The figure placed on a board.
|
|
@@ -52,7 +52,25 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
52
52
|
width: 100%;
|
|
53
53
|
height: 100%;
|
|
54
54
|
}
|
|
55
|
-
|
|
55
|
+
/*
|
|
56
|
+
The size help and the posing controls share the stage's bottom edge. Each stood in its own corner, so on a narrow
|
|
57
|
+
stage (the 1024 width, both side panels open) the controls lay over the help's button. One row that wraps keeps both
|
|
58
|
+
whole: side by side when there is room, the controls above the help when there is not.
|
|
59
|
+
*/
|
|
60
|
+
div[foot] {
|
|
61
|
+
position: absolute;
|
|
62
|
+
left: 8px;
|
|
63
|
+
right: 8px;
|
|
64
|
+
bottom: 8px;
|
|
65
|
+
display: flex;
|
|
66
|
+
flex-wrap: wrap-reverse;
|
|
67
|
+
justify-content: space-between;
|
|
68
|
+
align-items: flex-end;
|
|
69
|
+
gap: 8px;
|
|
70
|
+
pointer-events: none;
|
|
71
|
+
}
|
|
72
|
+
div[foot] > * { pointer-events: auto; }
|
|
73
|
+
div[setup] { max-width: min(320px, 100%); padding: 12px; border-radius: 8px; background: rgba(28,28,30,.9); color: #fff; display: grid; gap: 8px; }
|
|
56
74
|
div[setup] p { margin: 0; line-height: 1.5; }
|
|
57
75
|
div[setup] select, div[setup] button { font: inherit; padding: 8px; min-width: 0; }
|
|
58
76
|
/* Which is which: side by side means nothing without a legend. */
|
|
@@ -76,9 +94,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
76
94
|
color: #ffcc80;
|
|
77
95
|
}
|
|
78
96
|
div[clips] {
|
|
79
|
-
|
|
80
|
-
right: 8px;
|
|
81
|
-
bottom: 8px;
|
|
97
|
+
margin-left: auto;
|
|
82
98
|
display: flex;
|
|
83
99
|
flex-direction: column;
|
|
84
100
|
gap: 6px;
|
|
@@ -162,6 +178,8 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
162
178
|
/** Clip speeds and control values given here -- the preview's, not the figure's. */
|
|
163
179
|
@state() private driving: Record<string, number> = {}
|
|
164
180
|
@state() private giving: Record<string, number> = {}
|
|
181
|
+
/** The part chosen in the editor. The size help starts on it, so the help speaks of the part the author was working on. */
|
|
182
|
+
@property() selected = ''
|
|
165
183
|
@state() private setupPart = ''
|
|
166
184
|
|
|
167
185
|
/** What is being tried here now -- clip speeds and control values -- for the AI dock to hear with a request; none when nothing is. */
|
|
@@ -186,6 +204,8 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
186
204
|
} catch {
|
|
187
205
|
clampsByWay = {}
|
|
188
206
|
}
|
|
207
|
+
/* The part the help speaks of: the one picked in it, else the one chosen in the editor, else the first. */
|
|
208
|
+
const setupPart = setupPartOf(this.setupPart, this.selected, asset?.appearance.map(p => p.target) ?? [])
|
|
189
209
|
return html`<div stage></div>
|
|
190
210
|
${this.cannot
|
|
191
211
|
? html`<div cannot>${this.cannot}</div>`
|
|
@@ -200,14 +220,14 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
200
220
|
</span>`
|
|
201
221
|
})}
|
|
202
222
|
</div>
|
|
203
|
-
|
|
223
|
+
<div foot>${asset?.appearance.length ? html`<div setup>
|
|
204
224
|
<p>${i18next.t('figure.text.preview-setup')}</p>
|
|
205
225
|
<select aria-label=${i18next.t('figure.label.resize-part')} @change=${(e: Event) => this.setupPart = (e.target as HTMLSelectElement).value}>
|
|
206
|
-
${asset.appearance.map(p => html`<option value=${p.target} ?selected=${p.target ===
|
|
226
|
+
${asset.appearance.map(p => html`<option value=${p.target} ?selected=${p.target === setupPart}>${p.target}</option>`)}
|
|
207
227
|
</select>
|
|
208
|
-
<button @click=${() => this.dispatchEvent(new CustomEvent('configure-size', { detail: { part:
|
|
228
|
+
<button @click=${() => this.dispatchEvent(new CustomEvent('configure-size', { detail: { part: setupPart }, bubbles: true, composed: true }))}>${i18next.t('figure.button.configure-size')}</button>
|
|
209
229
|
</div>` : nothing}
|
|
210
|
-
${controls.length + clips.length ? this.renderDrivers(asset!, controls, clips) : nothing}
|
|
230
|
+
${controls.length + clips.length ? this.renderDrivers(asset!, controls, clips) : nothing}</div>`}`
|
|
211
231
|
}
|
|
212
232
|
|
|
213
233
|
private renderDrivers(asset: FigureAsset, controls: FigureAsset['document']['model']['inputs'], clips: string[]) {
|
|
@@ -237,7 +257,11 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
237
257
|
</label>`
|
|
238
258
|
})}
|
|
239
259
|
${clips.map(clip => {
|
|
240
|
-
|
|
260
|
+
/*
|
|
261
|
+
A clip nothing has driven stands still, here as on a board: the instances get a speed only from `drive`. The
|
|
262
|
+
slider showed 1.00 over rollers that did not turn (2026-10-03). It shows what plays -- 0 until moved.
|
|
263
|
+
*/
|
|
264
|
+
const value = this.driving[clip] ?? 0
|
|
241
265
|
return html`<label>
|
|
242
266
|
<span>${clip} <span drive>${i18next.t('figure.label.playback-speed')}</span></span>
|
|
243
267
|
<span></span>
|
|
@@ -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}` } }
|
|
@@ -74,3 +74,11 @@ export function previewClampsOf(asset: FigureAsset, size: { x: number; y: number
|
|
|
74
74
|
])
|
|
75
75
|
)
|
|
76
76
|
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The part the preview's size help speaks of: the one picked in the help, else the one chosen in the editor, else the
|
|
80
|
+
* first that has a size rule. A choice that is not among the parts (removed, or a part with no rule) is passed over.
|
|
81
|
+
*/
|
|
82
|
+
export function setupPartOf(picked: string, chosen: string, targets: string[]): string {
|
|
83
|
+
return [picked, chosen].find(id => !!id && targets.includes(id)) ?? targets[0] ?? ''
|
|
84
|
+
}
|
|
@@ -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
|
}
|
|
@@ -472,8 +472,14 @@ export class FigureModellerPage extends PageView {
|
|
|
472
472
|
return answer
|
|
473
473
|
}
|
|
474
474
|
get context() {
|
|
475
|
+
const view = this.session.read()
|
|
475
476
|
return {
|
|
476
|
-
title:
|
|
477
|
+
title: view.name ?? 'Figure',
|
|
478
|
+
/*
|
|
479
|
+
A figure that has an id was opened from the catalog (or saved there), so the rail marks the catalog. Without this
|
|
480
|
+
the rail matches the page name and marks "new figure" while an existing one is being edited.
|
|
481
|
+
*/
|
|
482
|
+
menu: view.id ? 'figure-list' : undefined,
|
|
477
483
|
actions: [],
|
|
478
484
|
/*
|
|
479
485
|
No heading line: the canvas is what this page is for, and the name is already in the properties' first section
|
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
|
+
}
|