@things-factory/figure-ui 10.1.49 → 10.1.51
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/v3-store.ts +2 -1
- package/client/modeller/figure-history-panel.ts +9 -1
- package/client/modeller/figure-v3-first-save.ts +98 -0
- package/client/modeller/figure-v3-links.ts +177 -31
- package/client/modeller/figure-v3-parts.ts +1 -0
- package/client/modeller/figure-v3-properties.ts +2 -1
- package/client/modeller/v3-canvas.ts +61 -7
- package/client/modeller/v3-editor.ts +96 -21
- package/client/modeller/v3-first-save.ts +34 -0
- package/client/modeller/v3-gizmo.ts +52 -3
- package/client/modeller/v3-joint-drag.ts +116 -0
- package/client/modeller/v3-joint-fan.ts +24 -11
- package/client/modeller/v3-keys.ts +12 -1
- package/client/modeller/v3-links.ts +119 -8
- package/client/modeller/v3-live.ts +11 -0
- package/client/modeller/v3-product-session.ts +4 -2
- package/client/modeller/v3-refusals.ts +21 -0
- package/client/pages/figure-v3-modeller-page.ts +57 -8
- package/dist-client/graphql/v3-store.js +2 -1
- package/dist-client/graphql/v3-store.js.map +1 -1
- package/dist-client/modeller/figure-history-panel.js +9 -1
- package/dist-client/modeller/figure-history-panel.js.map +1 -1
- package/dist-client/modeller/figure-v3-capability.d.ts +1 -1
- package/dist-client/modeller/figure-v3-clips.d.ts +1 -1
- package/dist-client/modeller/figure-v3-controls.d.ts +1 -1
- package/dist-client/modeller/figure-v3-first-save.d.ts +20 -0
- package/dist-client/modeller/figure-v3-first-save.js +118 -0
- package/dist-client/modeller/figure-v3-first-save.js.map +1 -0
- package/dist-client/modeller/figure-v3-links.d.ts +14 -1
- package/dist-client/modeller/figure-v3-links.js +169 -28
- package/dist-client/modeller/figure-v3-links.js.map +1 -1
- package/dist-client/modeller/figure-v3-material.d.ts +1 -1
- package/dist-client/modeller/figure-v3-parts.js +1 -0
- package/dist-client/modeller/figure-v3-parts.js.map +1 -1
- package/dist-client/modeller/figure-v3-properties.d.ts +1 -1
- package/dist-client/modeller/figure-v3-properties.js +2 -1
- package/dist-client/modeller/figure-v3-properties.js.map +1 -1
- package/dist-client/modeller/v3-canvas.d.ts +11 -0
- package/dist-client/modeller/v3-canvas.js +61 -7
- package/dist-client/modeller/v3-canvas.js.map +1 -1
- package/dist-client/modeller/v3-clips.d.ts +1 -1
- package/dist-client/modeller/v3-editor.d.ts +13 -1
- package/dist-client/modeller/v3-editor.js +96 -21
- package/dist-client/modeller/v3-editor.js.map +1 -1
- package/dist-client/modeller/v3-first-save.d.ts +17 -0
- package/dist-client/modeller/v3-first-save.js +27 -0
- package/dist-client/modeller/v3-first-save.js.map +1 -0
- package/dist-client/modeller/v3-gizmo.d.ts +18 -0
- package/dist-client/modeller/v3-gizmo.js +45 -2
- package/dist-client/modeller/v3-gizmo.js.map +1 -1
- package/dist-client/modeller/v3-joint-drag.d.ts +19 -0
- package/dist-client/modeller/v3-joint-drag.js +97 -0
- package/dist-client/modeller/v3-joint-drag.js.map +1 -0
- package/dist-client/modeller/v3-joint-fan.js +24 -11
- package/dist-client/modeller/v3-joint-fan.js.map +1 -1
- package/dist-client/modeller/v3-keys.d.ts +9 -1
- package/dist-client/modeller/v3-keys.js +3 -0
- package/dist-client/modeller/v3-keys.js.map +1 -1
- package/dist-client/modeller/v3-links.d.ts +72 -3
- package/dist-client/modeller/v3-links.js +89 -6
- package/dist-client/modeller/v3-links.js.map +1 -1
- package/dist-client/modeller/v3-live.d.ts +6 -0
- package/dist-client/modeller/v3-live.js +10 -0
- package/dist-client/modeller/v3-live.js.map +1 -1
- package/dist-client/modeller/v3-product-session.d.ts +4 -1
- package/dist-client/modeller/v3-product-session.js +2 -2
- package/dist-client/modeller/v3-product-session.js.map +1 -1
- package/dist-client/modeller/v3-refusals.d.ts +4 -0
- package/dist-client/modeller/v3-refusals.js +21 -0
- package/dist-client/modeller/v3-refusals.js.map +1 -0
- package/dist-client/pages/figure-v3-modeller-page.d.ts +4 -0
- package/dist-client/pages/figure-v3-modeller-page.js +56 -8
- package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +7 -5
- package/test/browser/build.mjs +62 -0
- package/test/browser/modeller-page.js +198 -0
- package/test/browser/modeller.check.mjs +358 -0
- package/test/browser/serve.mjs +43 -0
- package/test/joints.ts +33 -0
- package/test/v3-clips.test.ts +3 -2
- package/test/v3-controls.test.ts +3 -2
- package/test/v3-fields.test.ts +3 -2
- package/test/v3-first-save.test.ts +84 -0
- package/test/v3-gizmo.test.ts +38 -5
- package/test/v3-joint-drag.test.ts +93 -0
- package/test/v3-joint-fan.test.ts +16 -6
- package/test/v3-keys.test.ts +15 -2
- package/test/v3-links.test.ts +114 -14
- package/test/v3-live.test.ts +23 -4
- package/test/v3-maturity.test.ts +2 -6
- package/test/v3-refusals.test.ts +28 -0
- package/translations/en.json +40 -5
- package/translations/ja.json +39 -4
- package/translations/ko.json +39 -4
- package/translations/ms.json +39 -4
- package/translations/zh.json +39 -4
|
@@ -13,13 +13,14 @@ export const v3ProductStore: V3ProductStore = {
|
|
|
13
13
|
async save(request) {
|
|
14
14
|
const response = await client.mutate({
|
|
15
15
|
mutation: gql`
|
|
16
|
-
mutation SaveFigureV3($source: String!, $id: String, $expectedSource: String, $creationId: String, $thumbnail: String) {
|
|
16
|
+
mutation SaveFigureV3($source: String!, $id: String, $expectedSource: String, $creationId: String, $thumbnail: String, $type: String) {
|
|
17
17
|
saveFigureV3(
|
|
18
18
|
source: $source
|
|
19
19
|
id: $id
|
|
20
20
|
expectedSource: $expectedSource
|
|
21
21
|
creationId: $creationId
|
|
22
22
|
thumbnail: $thumbnail
|
|
23
|
+
type: $type
|
|
23
24
|
) {
|
|
24
25
|
id
|
|
25
26
|
source
|
|
@@ -9,6 +9,7 @@ import { ScrollbarStyles } from '@operato/styles'
|
|
|
9
9
|
|
|
10
10
|
import { fetchFigureVersions, inspectFigure, releaseFigure, revertFigureVersion } from '../graphql/index.js'
|
|
11
11
|
import type { FigureGateFinding, FigureGateFix, FigureGateFixPlan, FigureInspection, FigureVersion } from '../types.js'
|
|
12
|
+
import { OxPrompt } from '@operato/popup'
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* 이력 — **발행하는 자리이자, 발행된 판들을 보는 자리.**
|
|
@@ -558,7 +559,14 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
|
|
|
558
559
|
|
|
559
560
|
private async revert(version: number) {
|
|
560
561
|
/* 되돌아갈 수 없는 조작은 아니지만(판본은 남는다), 지금 화면의 것이 덮인다 — 이름으로 확인한다. */
|
|
561
|
-
|
|
562
|
+
const agreed = await OxPrompt.open({
|
|
563
|
+
type: 'warning',
|
|
564
|
+
title: i18next.t('figure.button.revert'),
|
|
565
|
+
text: i18next.t('figure.text.revert-to-version-confirm', { version }),
|
|
566
|
+
confirmButton: { text: i18next.t('figure.button.revert') },
|
|
567
|
+
cancelButton: { text: i18next.t('figure.button.keep-editing') }
|
|
568
|
+
})
|
|
569
|
+
if (!agreed) {
|
|
562
570
|
return
|
|
563
571
|
}
|
|
564
572
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { LitElement, html, css, nothing } from 'lit'
|
|
2
|
+
import { customElement, property, state } from 'lit/decorators.js'
|
|
3
|
+
import { i18next } from '@operato/i18n'
|
|
4
|
+
import { closePopup } from '@operato/popup'
|
|
5
|
+
import '@operato/popup/ox-popup-actions.js'
|
|
6
|
+
import { CommonPopupStyles, FormFieldStyles } from '@operato/styles'
|
|
7
|
+
import '@material/web/button/filled-button.js'
|
|
8
|
+
import '@material/web/button/text-button.js'
|
|
9
|
+
|
|
10
|
+
import { firstSaveRefusalOf, releasableType, typeOf, type FirstSave } from './v3-first-save.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The first save of a new figure: its type and its name, asked once (V2's save dialog, in the suite's popup look).
|
|
14
|
+
* Closes with the answer, or with nothing when cancelled -- then nothing is saved.
|
|
15
|
+
*/
|
|
16
|
+
@customElement('figure-v3-first-save')
|
|
17
|
+
export class FigureV3FirstSave extends LitElement {
|
|
18
|
+
static styles = [
|
|
19
|
+
FormFieldStyles,
|
|
20
|
+
CommonPopupStyles,
|
|
21
|
+
css`
|
|
22
|
+
/* The popup's height is a share of the window (small is 40%): on a low window the buttons must still be reached. */
|
|
23
|
+
:host {
|
|
24
|
+
overflow-y: auto;
|
|
25
|
+
}
|
|
26
|
+
div[field] {
|
|
27
|
+
display: grid;
|
|
28
|
+
gap: 4px;
|
|
29
|
+
}
|
|
30
|
+
div[field] label {
|
|
31
|
+
font-size: var(--popup-font-size-small);
|
|
32
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
33
|
+
}
|
|
34
|
+
div[field] input {
|
|
35
|
+
box-sizing: border-box;
|
|
36
|
+
width: 100%;
|
|
37
|
+
}
|
|
38
|
+
input[type-field] {
|
|
39
|
+
font-family: var(--mono-font, monospace);
|
|
40
|
+
}
|
|
41
|
+
`
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
/** The type the figure has so far -- empty for a temporary one, which is not offered. */
|
|
45
|
+
@property() type = ''
|
|
46
|
+
@property() name = ''
|
|
47
|
+
/** Type names already used in this domain. */
|
|
48
|
+
@property({ attribute: false }) taken: string[] = []
|
|
49
|
+
@state() private refusal: { key: string; type?: string } | null = null
|
|
50
|
+
|
|
51
|
+
render() {
|
|
52
|
+
return html`
|
|
53
|
+
<div class="what">${i18next.t('figure.text.save-new-figure-desc')}</div>
|
|
54
|
+
<div field>
|
|
55
|
+
<label for="type">${i18next.t('figure.label.figure-type')}</label>
|
|
56
|
+
<input
|
|
57
|
+
id="type"
|
|
58
|
+
type-field
|
|
59
|
+
autofocus
|
|
60
|
+
.value=${this.type}
|
|
61
|
+
placeholder=${i18next.t('figure.text.figure-type-example')}
|
|
62
|
+
@input=${(e: Event) => {
|
|
63
|
+
this.type = typeOf((e.target as HTMLInputElement).value)
|
|
64
|
+
;(e.target as HTMLInputElement).value = this.type
|
|
65
|
+
this.refusal = null
|
|
66
|
+
}}
|
|
67
|
+
@keydown=${this.onKey}
|
|
68
|
+
/>
|
|
69
|
+
${this.type && !releasableType(this.type) ? html`<div class="note">${i18next.t('figure.text.type-name-will-not-release')}</div>` : nothing}
|
|
70
|
+
</div>
|
|
71
|
+
<div field>
|
|
72
|
+
<label for="name">${i18next.t('figure.label.name')}</label>
|
|
73
|
+
<input
|
|
74
|
+
id="name"
|
|
75
|
+
.value=${this.name}
|
|
76
|
+
placeholder=${i18next.t('figure.text.figure-name-example')}
|
|
77
|
+
@input=${(e: Event) => (this.name = (e.target as HTMLInputElement).value)}
|
|
78
|
+
@keydown=${this.onKey}
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
${this.refusal ? html`<div class="failure" role="alert">${i18next.t(this.refusal.key, { type: this.refusal.type })}</div>` : nothing}
|
|
82
|
+
<ox-popup-actions>
|
|
83
|
+
<md-text-button slot="cancel" @click=${() => closePopup(this)}>${i18next.t('figure.button.cancel')}</md-text-button>
|
|
84
|
+
<md-filled-button slot="confirm" ?disabled=${!this.type} @click=${this.confirm}>${i18next.t('figure.button.save')}</md-filled-button>
|
|
85
|
+
</ox-popup-actions>
|
|
86
|
+
`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private onKey = (e: KeyboardEvent) => {
|
|
90
|
+
if (e.key === 'Enter' && !e.isComposing) this.confirm()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private confirm = () => {
|
|
94
|
+
this.refusal = firstSaveRefusalOf(this.type, this.taken)
|
|
95
|
+
if (this.refusal) return
|
|
96
|
+
closePopup(this, { type: this.type, name: this.name.trim() || this.type } satisfies FirstSave)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -9,8 +9,11 @@ import type { V3Asset, V3AuthoringAction, V3DimensionSource, V3Face } from '@hat
|
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
11
|
acceptsOf,
|
|
12
|
+
alignAxesOf,
|
|
13
|
+
alignOf,
|
|
12
14
|
AXES,
|
|
13
15
|
canFasten,
|
|
16
|
+
facesOf,
|
|
14
17
|
canFollow,
|
|
15
18
|
dimensionNow,
|
|
16
19
|
followableAxes,
|
|
@@ -20,19 +23,39 @@ import {
|
|
|
20
23
|
followOf,
|
|
21
24
|
hasInstanceSize,
|
|
22
25
|
holdsThings,
|
|
26
|
+
leadersFor,
|
|
23
27
|
motionOf,
|
|
28
|
+
motionJoint,
|
|
24
29
|
placeFacesOf,
|
|
25
30
|
placeIdFor,
|
|
26
31
|
placesOn,
|
|
32
|
+
rowAxesOf,
|
|
27
33
|
positionsWorkedOut,
|
|
34
|
+
type Alignment,
|
|
28
35
|
type Axis,
|
|
29
|
-
type Follow
|
|
36
|
+
type Follow,
|
|
37
|
+
type MotionForm
|
|
30
38
|
} from './v3-links.js'
|
|
31
39
|
import { SidePanelStyles } from './side-panel-styles.js'
|
|
32
40
|
import { HelpToggles } from './v3-help.js'
|
|
33
41
|
|
|
34
42
|
const FACES = Object.keys(V3_FACES) as V3Face[]
|
|
35
43
|
|
|
44
|
+
const freshFasten = () => ({
|
|
45
|
+
face: 'bottom' as V3Face,
|
|
46
|
+
toPlane: false,
|
|
47
|
+
target: '',
|
|
48
|
+
targetFace: 'top' as V3Face,
|
|
49
|
+
facing: 'meet' as 'meet' | 'flush',
|
|
50
|
+
gap: 0,
|
|
51
|
+
twist: 0,
|
|
52
|
+
align: {} as Partial<Record<Axis, Alignment>>,
|
|
53
|
+
alignMm: {} as Partial<Record<Axis, number>>
|
|
54
|
+
})
|
|
55
|
+
const freshMotion = (): MotionForm => ({ kind: 'slide', axis: 'x', id: '', min: 0, max: 500, follow: '', multiplier: 1, offset: 0 })
|
|
56
|
+
const freshRow = () => ({ axis: '' as Axis | '', pitch: 200, fills: true, count: 5 })
|
|
57
|
+
const freshPlace = () => ({ role: 'grip' as 'seat' | 'grip', face: 'top' as V3Face, label: '', accepts: '', capacity: 1 })
|
|
58
|
+
|
|
36
59
|
/**
|
|
37
60
|
* What a part is tied to — its size response, what it is fastened to, how it moves (parity rows ④ ⑤ ⑥).
|
|
38
61
|
*
|
|
@@ -78,9 +101,23 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
78
101
|
/** Which ties this shows: the part's shape (size and placement, in 속성), its places (자리·능력) or its motion (움직임). */
|
|
79
102
|
@property() section: 'shape' | 'places' | 'motion' = 'shape'
|
|
80
103
|
|
|
81
|
-
@state() private fasten =
|
|
82
|
-
@state() private motion
|
|
83
|
-
@state() private place =
|
|
104
|
+
@state() private fasten = freshFasten()
|
|
105
|
+
@state() private motion: MotionForm = freshMotion()
|
|
106
|
+
@state() private place = freshPlace()
|
|
107
|
+
@state() private row = freshRow()
|
|
108
|
+
|
|
109
|
+
/*
|
|
110
|
+
The forms describe the next command for the selected part, so another part starts from the defaults: carrying the
|
|
111
|
+
last part's choices over fastened a shutter curtain level with a right edge nobody chose for it (2026-09-25).
|
|
112
|
+
*/
|
|
113
|
+
willUpdate(changed: Map<string, unknown>) {
|
|
114
|
+
if (changed.has('partId') && changed.get('partId') !== undefined) {
|
|
115
|
+
this.fasten = freshFasten()
|
|
116
|
+
this.motion = freshMotion()
|
|
117
|
+
this.place = freshPlace()
|
|
118
|
+
this.row = freshRow()
|
|
119
|
+
}
|
|
120
|
+
}
|
|
84
121
|
|
|
85
122
|
render() {
|
|
86
123
|
const asset = this.asset
|
|
@@ -101,6 +138,7 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
101
138
|
${this.renderPositions(asset)}
|
|
102
139
|
${canFasten(asset, this.partId) ? this.renderFastening(asset) : html`<p hint>${i18next.t('figure.text.links-need-a-box')}</p>`}
|
|
103
140
|
</section>
|
|
141
|
+
${this.renderRow(asset)}
|
|
104
142
|
`
|
|
105
143
|
}
|
|
106
144
|
|
|
@@ -253,6 +291,13 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
253
291
|
const to = fastenedTo(asset, this.partId)
|
|
254
292
|
const f = this.fasten
|
|
255
293
|
const targets = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFasten(asset, one))
|
|
294
|
+
/* The faces are the ones each shape declares: a part is fastened by one of its outer faces, to any face of the target. */
|
|
295
|
+
const mineFaces = facesOf(asset, this.partId).filter(face => FACES.includes(face as V3Face))
|
|
296
|
+
const myFace = mineFaces.includes(f.face) ? f.face : (mineFaces[0] as V3Face)
|
|
297
|
+
const theirFaces = f.target ? facesOf(asset, f.target) : FACES
|
|
298
|
+
const targetFace = theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0]!
|
|
299
|
+
const aim = f.toPlane ? ('plane' as const) : { part: f.target, face: targetFace }
|
|
300
|
+
const align = f.target || f.toPlane ? alignOf(asset, this.partId, aim, f.align, f.alignMm, !!f.twist) : undefined
|
|
256
301
|
return html`
|
|
257
302
|
${to
|
|
258
303
|
? html`<div pair>
|
|
@@ -265,7 +310,7 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
265
310
|
<div form>
|
|
266
311
|
<span>${i18next.t('figure.label.my-face')}</span>
|
|
267
312
|
<select @change=${(e: Event) => (this.fasten = { ...f, face: (e.target as HTMLSelectElement).value as V3Face })}>
|
|
268
|
-
${
|
|
313
|
+
${mineFaces.map(face => html`<option value=${face} ?selected=${face === myFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
|
|
269
314
|
</select>
|
|
270
315
|
</div>
|
|
271
316
|
<label toggle>
|
|
@@ -282,7 +327,7 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
282
327
|
</select>
|
|
283
328
|
<span>${i18next.t('figure.label.target-face')}</span>
|
|
284
329
|
<select @change=${(e: Event) => (this.fasten = { ...f, targetFace: (e.target as HTMLSelectElement).value as V3Face })}>
|
|
285
|
-
${
|
|
330
|
+
${theirFaces.map(face => html`<option value=${face} ?selected=${face === targetFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
|
|
286
331
|
</select>
|
|
287
332
|
<span>${i18next.t('figure.label.facing-meet')}</span>
|
|
288
333
|
<select @change=${(e: Event) => (this.fasten = { ...f, facing: (e.target as HTMLSelectElement).value as 'meet' | 'flush' })}>
|
|
@@ -290,9 +335,12 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
290
335
|
<option value="flush" ?selected=${f.facing === 'flush'}>${i18next.t('figure.label.facing-flush')}</option>
|
|
291
336
|
</select>
|
|
292
337
|
</div>`}
|
|
338
|
+
${this.renderAlignment(f.toPlane || f.target ? alignAxesOf(asset, this.partId, aim, !!f.twist) : [])}
|
|
293
339
|
<div form>
|
|
294
340
|
<span>${i18next.t('figure.label.gap')}</span>
|
|
295
341
|
<input type="number" step="any" .value=${String(f.gap)} @change=${(e: Event) => (this.fasten = { ...f, gap: Number((e.target as HTMLInputElement).value) || 0 })} />
|
|
342
|
+
<span>${i18next.t('figure.label.twist')}</span>
|
|
343
|
+
<input type="number" step="any" .value=${String(f.twist)} @change=${(e: Event) => (this.fasten = { ...f, twist: Number((e.target as HTMLInputElement).value) || 0 })} />
|
|
296
344
|
</div>
|
|
297
345
|
<button
|
|
298
346
|
act
|
|
@@ -301,10 +349,12 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
301
349
|
this.command({
|
|
302
350
|
kind: 'attach',
|
|
303
351
|
part: this.partId,
|
|
304
|
-
face:
|
|
305
|
-
to: f.toPlane ? { plane: 'mounting-plane' } : { part: f.target, face:
|
|
352
|
+
face: myFace,
|
|
353
|
+
to: f.toPlane ? { plane: 'mounting-plane' } : { part: f.target, face: targetFace },
|
|
306
354
|
...(f.toPlane ? {} : { facing: f.facing }),
|
|
307
355
|
...(f.gap ? { gap: f.gap } : {}),
|
|
356
|
+
twist: f.twist,
|
|
357
|
+
...(align ? { align } : {}),
|
|
308
358
|
replace: !!to
|
|
309
359
|
} as V3AuthoringAction)}
|
|
310
360
|
>
|
|
@@ -313,58 +363,154 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
313
363
|
`
|
|
314
364
|
}
|
|
315
365
|
|
|
366
|
+
/**
|
|
367
|
+
* Where on the target face the part sits, on the two axes across it: centred, level with the target's edge on
|
|
368
|
+
* either side, or a distance off centre. A door hung level with the frame's front edge turns about that edge.
|
|
369
|
+
*/
|
|
370
|
+
private renderAlignment(axes: { axis: Axis; edges: boolean }[]) {
|
|
371
|
+
const f = this.fasten
|
|
372
|
+
const set = (axis: Axis, how: Alignment) => (this.fasten = { ...f, align: { ...f.align, [axis]: how } })
|
|
373
|
+
const setMm = (axis: Axis, mm: number) => (this.fasten = { ...f, alignMm: { ...f.alignMm, [axis]: mm } })
|
|
374
|
+
return html`<div form>
|
|
375
|
+
${axes.map(({ axis, edges }) => {
|
|
376
|
+
const how = f.align[axis] === 'min' || f.align[axis] === 'max' ? (edges ? f.align[axis]! : 'centre') : (f.align[axis] ?? 'centre')
|
|
377
|
+
return html`<span>${i18next.t('figure.label.align-' + axis)}</span>
|
|
378
|
+
<select @change=${(e: Event) => set(axis, (e.target as HTMLSelectElement).value as Alignment)}>
|
|
379
|
+
<option value="centre" ?selected=${how === 'centre'}>${i18next.t('figure.label.align-centre')}</option>
|
|
380
|
+
${edges
|
|
381
|
+
? html`<option value="min" ?selected=${how === 'min'}>${i18next.t(`figure.label.align-${axis}-min`)}</option>
|
|
382
|
+
<option value="max" ?selected=${how === 'max'}>${i18next.t(`figure.label.align-${axis}-max`)}</option>`
|
|
383
|
+
: nothing}
|
|
384
|
+
<option value="mm" ?selected=${how === 'mm'}>${i18next.t('figure.label.align-mm')}</option>
|
|
385
|
+
</select>
|
|
386
|
+
${how === 'mm'
|
|
387
|
+
? html`<span></span><input type="number" step="any" aria-label=${i18next.t('figure.label.align-mm')} .value=${String(f.alignMm[axis] ?? 0)} @change=${(e: Event) => setMm(axis, Number((e.target as HTMLInputElement).value) || 0)} />`
|
|
388
|
+
: nothing}`
|
|
389
|
+
})}
|
|
390
|
+
</div>`
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Making the part a row across what it is fastened to (figure-model `make-repeat`): along one of the axes across its
|
|
395
|
+
* face, copies a pitch apart filling the parent's length, or a count spread over it. Once made, the row's own section
|
|
396
|
+
* (the editor's) shows it and takes it away.
|
|
397
|
+
*/
|
|
398
|
+
private renderRow(asset: V3Asset) {
|
|
399
|
+
const axes = rowAxesOf(asset, this.partId)
|
|
400
|
+
if (!axes.length) return nothing
|
|
401
|
+
const r = this.row
|
|
402
|
+
const axis = axes.includes(r.axis as Axis) ? (r.axis as Axis) : axes[0]!
|
|
403
|
+
const ok = r.pitch > 0 && (r.fills || (Number.isSafeInteger(r.count) && r.count >= 1))
|
|
404
|
+
return html`<section>
|
|
405
|
+
<h3>${i18next.t('figure.label.repeat')}</h3>
|
|
406
|
+
<div form>
|
|
407
|
+
<span>${i18next.t('figure.label.repeat-axis')}</span>
|
|
408
|
+
<select @change=${(e: Event) => (this.row = { ...r, axis: (e.target as HTMLSelectElement).value as Axis })}>
|
|
409
|
+
${axes.map(a => html`<option value=${a} ?selected=${a === axis}>${a.toUpperCase()}</option>`)}
|
|
410
|
+
</select>
|
|
411
|
+
<span>${i18next.t('figure.label.repeat-pitch')} (mm)</span>
|
|
412
|
+
<input type="number" step="any" min="0" .value=${String(r.pitch)} @change=${(e: Event) => (this.row = { ...r, pitch: Number((e.target as HTMLInputElement).value) })} />
|
|
413
|
+
</div>
|
|
414
|
+
<label toggle>
|
|
415
|
+
<input type="checkbox" .checked=${r.fills} @change=${(e: Event) => (this.row = { ...r, fills: (e.target as HTMLInputElement).checked })} />
|
|
416
|
+
${i18next.t('figure.label.repeat-fills')}
|
|
417
|
+
</label>
|
|
418
|
+
${r.fills
|
|
419
|
+
? nothing
|
|
420
|
+
: html`<div form>
|
|
421
|
+
<span>${i18next.t('figure.label.repeat-count')}</span>
|
|
422
|
+
<input type="number" step="1" min="1" .value=${String(r.count)} @change=${(e: Event) => (this.row = { ...r, count: Number((e.target as HTMLInputElement).value) })} />
|
|
423
|
+
</div>`}
|
|
424
|
+
<button
|
|
425
|
+
act
|
|
426
|
+
?disabled=${this.locked || !ok}
|
|
427
|
+
@click=${() => this.command({ kind: 'make-repeat', part: this.partId, axis, pitch: r.pitch, ...(r.fills ? {} : { count: r.count }) } as V3AuthoringAction)}
|
|
428
|
+
>
|
|
429
|
+
<md-icon>view_week</md-icon>${i18next.t('figure.button.make-repeat')}
|
|
430
|
+
</button>
|
|
431
|
+
</section>`
|
|
432
|
+
}
|
|
433
|
+
|
|
316
434
|
private renderMotion(asset: V3Asset) {
|
|
317
435
|
const now = motionOf(asset, this.partId)
|
|
436
|
+
const joint = asset.joints?.find(j => j.body1.part === this.partId)
|
|
318
437
|
if (now) {
|
|
319
438
|
return html`<div pair>
|
|
320
439
|
<span>
|
|
321
440
|
${i18next.t('figure.label.motion-' + now.kind)} · ${String(now.axis).toUpperCase()}
|
|
322
|
-
${now.
|
|
441
|
+
${now.follows
|
|
442
|
+
? html`<small>${i18next.t('figure.label.motion-follows', { joint: now.follows.joint, multiplier: now.follows.multiplier, offset: now.follows.offset ?? 0 })}</small>`
|
|
443
|
+
: now.control
|
|
444
|
+
? html`<small>${now.control.id} (${now.control.unit}) ${now.control.min} … ${now.control.max}</small>`
|
|
445
|
+
: nothing}
|
|
323
446
|
</span>
|
|
324
|
-
<button
|
|
447
|
+
<button
|
|
448
|
+
act
|
|
449
|
+
?disabled=${this.locked || !joint}
|
|
450
|
+
@click=${() => joint && this.command({ kind: 'set-joint', joint: { id: joint.id, type: 'fixed', body0: joint.body0, body1: joint.body1, ...(joint.origin ? { origin: joint.origin } : {}) } })}
|
|
451
|
+
>
|
|
325
452
|
<md-icon>close</md-icon>${i18next.t('figure.button.remove-motion')}
|
|
326
453
|
</button>
|
|
327
454
|
</div>`
|
|
328
455
|
}
|
|
456
|
+
/* A joint needs a parent (URDF): a part fastened to nothing is fastened first. */
|
|
457
|
+
if (!joint) return html`<p hint>${i18next.t('figure.text.fasten-before-moving')}</p>`
|
|
329
458
|
const m = this.motion
|
|
330
|
-
const unit = m.kind === '
|
|
459
|
+
const unit = m.kind === 'slide' ? 'mm' : 'deg'
|
|
460
|
+
/* A follower follows a joint that moves the same way; a choice the kind no longer allows is dropped. */
|
|
461
|
+
const leaders = leadersFor(asset, this.partId, m.kind)
|
|
462
|
+
const follow = leaders.some(j => j.id === m.follow) ? m.follow : ''
|
|
331
463
|
return html`
|
|
332
464
|
<div pair>
|
|
333
|
-
<select @change=${(e: Event) => this.setKind((e.target as HTMLSelectElement).value as 'slide' | 'turn')}>
|
|
465
|
+
<select @change=${(e: Event) => this.setKind((e.target as HTMLSelectElement).value as 'slide' | 'turn' | 'continuous')}>
|
|
334
466
|
<option value="slide" ?selected=${m.kind === 'slide'}>${i18next.t('figure.label.motion-slide')}</option>
|
|
335
467
|
<option value="turn" ?selected=${m.kind === 'turn'}>${i18next.t('figure.label.motion-turn')}</option>
|
|
468
|
+
<option value="continuous" ?selected=${m.kind === 'continuous'}>${i18next.t('figure.label.motion-continuous')}</option>
|
|
336
469
|
</select>
|
|
337
470
|
<select @change=${(e: Event) => (this.motion = { ...m, axis: (e.target as HTMLSelectElement).value as Axis })}>
|
|
338
471
|
${AXES.map(a => html`<option value=${a} ?selected=${a === m.axis}>${i18next.t('figure.label.motion-axis')} ${a.toUpperCase()}</option>`)}
|
|
339
472
|
</select>
|
|
340
473
|
</div>
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
474
|
+
${leaders.length
|
|
475
|
+
? html`<div form>
|
|
476
|
+
<span>${i18next.t('figure.label.motion-follow')}</span>
|
|
477
|
+
<select @change=${(e: Event) => (this.motion = { ...m, follow: (e.target as HTMLSelectElement).value })}>
|
|
478
|
+
<option value="" ?selected=${!follow}>${i18next.t('figure.label.motion-own-control')}</option>
|
|
479
|
+
${leaders.map(j => html`<option value=${j.id} ?selected=${j.id === follow}>${j.body1.part}${j.state ? ` · ${j.state.id}` : ''}</option>`)}
|
|
480
|
+
</select>
|
|
481
|
+
</div>`
|
|
482
|
+
: nothing}
|
|
483
|
+
${follow
|
|
484
|
+
? html`<div form>
|
|
485
|
+
<span>${i18next.t('figure.label.motion-multiplier')}</span>
|
|
486
|
+
<input type="number" step="any" .value=${String(m.multiplier)} @change=${(e: Event) => (this.motion = { ...m, multiplier: Number((e.target as HTMLInputElement).value) })} />
|
|
487
|
+
<span>${i18next.t('figure.label.motion-offset')} (${unit})</span>
|
|
488
|
+
<input type="number" step="any" .value=${String(m.offset)} @change=${(e: Event) => (this.motion = { ...m, offset: Number((e.target as HTMLInputElement).value) || 0 })} />
|
|
489
|
+
</div>`
|
|
490
|
+
: html`<div form>
|
|
491
|
+
<span>${i18next.t('figure.label.control-id')}</span>
|
|
492
|
+
<input .value=${m.id} placeholder=${`${this.partId}-${m.kind}`} @change=${(e: Event) => (this.motion = { ...m, id: (e.target as HTMLInputElement).value.trim() })} />
|
|
493
|
+
${m.kind === 'continuous'
|
|
494
|
+
? nothing
|
|
495
|
+
: html`<span>${i18next.t('figure.label.control-range')} (${unit})</span>
|
|
496
|
+
<div pair>
|
|
497
|
+
<input type="number" step="any" .value=${String(m.min)} @change=${(e: Event) => (this.motion = { ...m, min: Number((e.target as HTMLInputElement).value) })} />
|
|
498
|
+
<input type="number" step="any" .value=${String(m.max)} @change=${(e: Event) => (this.motion = { ...m, max: Number((e.target as HTMLInputElement).value) })} />
|
|
499
|
+
</div>`}
|
|
500
|
+
</div>`}
|
|
350
501
|
<button
|
|
351
502
|
act
|
|
352
|
-
?disabled=${this.locked || !(m.max > m.min)}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
kind: 'add-motion',
|
|
356
|
-
part: this.partId,
|
|
357
|
-
motion: { kind: m.kind, axis: m.axis },
|
|
358
|
-
state: { id: m.id || `${this.partId}-${m.kind}`, unit, min: m.min, max: m.max, start: m.min }
|
|
359
|
-
})}
|
|
503
|
+
?disabled=${this.locked || (follow ? !(Number.isFinite(m.multiplier) && m.multiplier !== 0) : m.kind !== 'continuous' && !(m.max > m.min && m.min <= 0 && 0 <= m.max))}
|
|
504
|
+
title=${!follow && m.kind !== 'continuous' && !(m.min <= 0 && 0 <= m.max) ? i18next.t('figure.text.joint-range-holds-zero') : ''}
|
|
505
|
+
@click=${() => this.command({ kind: 'set-joint', joint: motionJoint(joint, { ...m, follow }, this.partId) })}
|
|
360
506
|
>
|
|
361
507
|
<md-icon>animation</md-icon>${i18next.t('figure.button.add-motion')}
|
|
362
508
|
</button>
|
|
363
509
|
`
|
|
364
510
|
}
|
|
365
511
|
|
|
366
|
-
private setKind(kind: 'slide' | 'turn') {
|
|
367
|
-
this.motion = kind === '
|
|
512
|
+
private setKind(kind: 'slide' | 'turn' | 'continuous') {
|
|
513
|
+
this.motion = kind === 'slide' ? { ...this.motion, kind, min: 0, max: 500 } : { ...this.motion, kind, min: -90, max: 90 }
|
|
368
514
|
}
|
|
369
515
|
|
|
370
516
|
private command(action: V3AuthoringAction) {
|
|
@@ -420,6 +420,7 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
|
|
|
420
420
|
<select
|
|
421
421
|
id="detail-level"
|
|
422
422
|
title=${i18next.t('figure.text.detail-level-explained')}
|
|
423
|
+
.value=${b.level ?? ''}
|
|
423
424
|
?disabled=${this.locked}
|
|
424
425
|
@change=${(e: Event) => {
|
|
425
426
|
const value = (e.target as HTMLSelectElement).value
|
|
@@ -10,6 +10,7 @@ import type { V3AuthoringAction, V3Face } from '@hatiolab/figure-model'
|
|
|
10
10
|
import { occupancyFromParts } from './v3-occupancy.js'
|
|
11
11
|
import { tagsOf, v3FigureFacts, type Placement, type V3FigureFacts } from './v3-figure-facts.js'
|
|
12
12
|
import { SidePanelStyles } from './side-panel-styles.js'
|
|
13
|
+
import { ScrollbarStyles } from '@operato/styles'
|
|
13
14
|
|
|
14
15
|
const PLACEMENTS: Placement[] = ['floor', 'ceiling', 'center']
|
|
15
16
|
const FACES = Object.keys(V3_FACES) as V3Face[]
|
|
@@ -32,7 +33,7 @@ const FACES = Object.keys(V3_FACES) as V3Face[]
|
|
|
32
33
|
*/
|
|
33
34
|
@customElement('figure-v3-properties')
|
|
34
35
|
export class FigureV3Properties extends localize(i18next)(LitElement) {
|
|
35
|
-
static styles = [SidePanelStyles, css`
|
|
36
|
+
static styles = [ScrollbarStyles, SidePanelStyles, css`
|
|
36
37
|
:host {
|
|
37
38
|
display: block;
|
|
38
39
|
/* It stands inside the properties tab's panel, which gives the margin; a second one indented it past the type. */
|
|
@@ -37,6 +37,7 @@ interface SceneLayer extends SceneComponent {
|
|
|
37
37
|
removeComponent?: (component: SceneComponent) => void
|
|
38
38
|
invalidate?: () => void
|
|
39
39
|
fitCameraToModel?: () => void
|
|
40
|
+
setGizmoMode?: (mode: 'translate' | 'rotate' | 'scale') => void
|
|
40
41
|
app?: unknown
|
|
41
42
|
}
|
|
42
43
|
interface SceneHandle {
|
|
@@ -153,6 +154,11 @@ export class FigureV3Canvas extends LitElement {
|
|
|
153
154
|
this.scene?.root?.fitCameraToModel?.()
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
/** The gizmo in a mode on the selected part, as W, E and R do with the canvas focused -- the scene's toggle. */
|
|
158
|
+
gizmo(mode: 'translate' | 'rotate' | 'scale') {
|
|
159
|
+
this.scene?.root?.setGizmoMode?.(mode)
|
|
160
|
+
}
|
|
161
|
+
|
|
156
162
|
/**
|
|
157
163
|
* Stand the board up for this figure, or bring the one standing up to date. A new board only for another figure or
|
|
158
164
|
* one that has outgrown it -- standing up again loses the selection and the camera.
|
|
@@ -276,18 +282,61 @@ export class FigureV3Canvas extends LitElement {
|
|
|
276
282
|
A change comes up to the board with the component in the last argument's `origin` (the scene appends
|
|
277
283
|
{ origin, name, deliverer } as it bubbles). Reading the first argument as the component does nothing (V2).
|
|
278
284
|
*/
|
|
285
|
+
/*
|
|
286
|
+
A drag is written once, when it ends. The scene's gizmo writes the component on every move -- a size anchored on
|
|
287
|
+
the opposite face, from the scale it captured when the drag began -- and writing each move back through the
|
|
288
|
+
editor (and the evaluation back onto the component) fought it: the scale gizmo did not work (user, 2026-09-24).
|
|
289
|
+
While a pointer is down the changes are only kept; on release the last one is told, and the board is brought to
|
|
290
|
+
the evaluation. A change with no pointer down (the arrow keys) is told after a short pause.
|
|
291
|
+
*/
|
|
279
292
|
layer.on?.('change', (...args: unknown[]) => {
|
|
280
293
|
if (this.writing) return
|
|
281
294
|
const origin = (args[args.length - 1] as { origin?: SceneComponent } | undefined)?.origin
|
|
282
295
|
const name = origin?.state?.name
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
if (!result.position && !result.rotation && !result.scale) return
|
|
287
|
-
this.dispatchEvent(new CustomEvent('part-transformed', { detail: { id: part.id, result }, bubbles: true, composed: true }))
|
|
288
|
-
/* After the editor has applied (or refused) it: a refused change springs back to where the inputs put it. */
|
|
289
|
-
setTimeout(() => this.sync())
|
|
296
|
+
if (typeof name !== 'string' || !this.parts.some(p => p.id === name)) return
|
|
297
|
+
this.pending.set(name, origin!)
|
|
298
|
+
if (!this.pointerDown) this.commitSoon(80)
|
|
290
299
|
})
|
|
300
|
+
this.stage.addEventListener('pointerdown', this.onPointerDown, true)
|
|
301
|
+
window.addEventListener('pointerup', this.onPointerUp, true)
|
|
302
|
+
window.addEventListener('pointercancel', this.onPointerUp, true)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Components the gizmo changed since the last commit, by part name. */
|
|
306
|
+
private pending = new Map<string, SceneComponent>()
|
|
307
|
+
private pointerDown = false
|
|
308
|
+
private commitTimer?: ReturnType<typeof setTimeout>
|
|
309
|
+
private onPointerDown = () => {
|
|
310
|
+
this.pointerDown = true
|
|
311
|
+
}
|
|
312
|
+
private onPointerUp = () => {
|
|
313
|
+
if (!this.pointerDown) return
|
|
314
|
+
this.pointerDown = false
|
|
315
|
+
/* After the scene's own drag end (it settles the depth and rebuilds the geometry on release). */
|
|
316
|
+
this.commitSoon(0)
|
|
317
|
+
}
|
|
318
|
+
private commitSoon(delay: number) {
|
|
319
|
+
clearTimeout(this.commitTimer)
|
|
320
|
+
this.commitTimer = setTimeout(() => this.commit(), delay)
|
|
321
|
+
}
|
|
322
|
+
/** Tell each changed part once, in the inputs' terms; then put the board where the inputs say (a refusal springs back). */
|
|
323
|
+
private commit() {
|
|
324
|
+
/*
|
|
325
|
+
Not while a pointer is down: a pause set by a change just before the press (the arrow keys, the scene settling
|
|
326
|
+
the pick) fired in the middle of the drag and wrote where the part was then -- a drag on a converted part became
|
|
327
|
+
two steps, the first one a few millimetres in (browser check, 2026-09-25). What is pending waits for the release.
|
|
328
|
+
*/
|
|
329
|
+
if (this.pointerDown || !this.board || !this.pending.size) return
|
|
330
|
+
const changed = [...this.pending]
|
|
331
|
+
this.pending.clear()
|
|
332
|
+
for (const [name, component] of changed) {
|
|
333
|
+
const part = this.parts.find(p => p.id === name)
|
|
334
|
+
if (!part) continue
|
|
335
|
+
const result = gizmoResultOf(part, component.state, this.board)
|
|
336
|
+
if (!result.position && !result.rotation && !result.scale) continue
|
|
337
|
+
this.dispatchEvent(new CustomEvent('part-transformed', { detail: { id: part.id, result }, bubbles: true, composed: true }))
|
|
338
|
+
}
|
|
339
|
+
setTimeout(() => this.sync())
|
|
291
340
|
}
|
|
292
341
|
|
|
293
342
|
private showSelected() {
|
|
@@ -311,6 +360,11 @@ export class FigureV3Canvas extends LitElement {
|
|
|
311
360
|
}
|
|
312
361
|
|
|
313
362
|
private teardown() {
|
|
363
|
+
clearTimeout(this.commitTimer)
|
|
364
|
+
this.pending.clear()
|
|
365
|
+
this.stage?.removeEventListener('pointerdown', this.onPointerDown, true)
|
|
366
|
+
window.removeEventListener('pointerup', this.onPointerUp, true)
|
|
367
|
+
window.removeEventListener('pointercancel', this.onPointerUp, true)
|
|
314
368
|
this.scene?.dispose?.()
|
|
315
369
|
this.scene = undefined
|
|
316
370
|
this.standingFor = undefined
|