@things-factory/figure-ui 10.1.44 → 10.1.46
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/modeller/figure-v3-links.ts +394 -0
- package/client/modeller/modeller-route.ts +6 -21
- package/client/modeller/v3-editor.ts +47 -5
- package/client/modeller/v3-gizmo.ts +13 -2
- package/client/modeller/v3-links.ts +170 -0
- package/client/pages/figure-v3-modeller-page.ts +2 -5
- package/client/route.ts +2 -6
- package/dist-client/modeller/figure-v3-links.d.ts +47 -0
- package/dist-client/modeller/figure-v3-links.js +379 -0
- package/dist-client/modeller/figure-v3-links.js.map +1 -0
- package/dist-client/modeller/modeller-route.d.ts +4 -11
- package/dist-client/modeller/modeller-route.js +5 -22
- package/dist-client/modeller/modeller-route.js.map +1 -1
- package/dist-client/modeller/v3-editor.d.ts +5 -0
- package/dist-client/modeller/v3-editor.js +49 -4
- package/dist-client/modeller/v3-editor.js.map +1 -1
- package/dist-client/modeller/v3-gizmo.js +11 -1
- package/dist-client/modeller/v3-gizmo.js.map +1 -1
- package/dist-client/modeller/v3-links.d.ts +67 -0
- package/dist-client/modeller/v3-links.js +141 -0
- package/dist-client/modeller/v3-links.js.map +1 -0
- package/dist-client/pages/figure-v3-modeller-page.d.ts +2 -5
- package/dist-client/pages/figure-v3-modeller-page.js +2 -5
- package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
- package/dist-client/route.d.ts +1 -1
- package/dist-client/route.js +2 -5
- package/dist-client/route.js.map +1 -1
- package/dist-client/tsconfig.scratch-fm.tsbuildinfo +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/test/ai-proposal-contract.test.ts +0 -88
- package/test/figure-source.test.ts +0 -102
- package/test/i18n-prefix-guard.test.ts +0 -24
- package/test/modeller-route.test.ts +14 -25
- package/test/part-edits.test.ts +0 -7
- package/test/scene-budget.test.ts +0 -7
- package/test/v3-gizmo.test.ts +10 -0
- package/test/v3-links.test.ts +163 -0
- package/things-factory.config.js +2 -5
- package/translations/en.json +21 -1
- package/translations/ja.json +21 -1
- package/translations/ko.json +21 -1
- package/translations/ms.json +21 -1
- package/translations/zh.json +21 -1
- package/client/modeller/angle-dial.ts +0 -70
- package/client/modeller/base-box.ts +0 -175
- package/client/modeller/figure-animations.ts +0 -1249
- package/client/modeller/figure-canvas.ts +0 -567
- package/client/modeller/figure-capabilities.ts +0 -379
- package/client/modeller/figure-inspector.ts +0 -2208
- package/client/modeller/figure-parts.ts +0 -479
- package/client/modeller/figure-preview.ts +0 -598
- package/client/modeller/figure-report.ts +0 -966
- package/client/modeller/figure-settings.ts +0 -328
- package/client/modeller/figure-side.ts +0 -272
- package/client/modeller/figure-thumbnail.ts +0 -123
- package/client/modeller/figure-view.ts +0 -152
- package/client/modeller/joint-edits.ts +0 -262
- package/client/modeller/joint-gizmo.ts +0 -180
- package/client/modeller/maturity.ts +0 -288
- package/client/modeller/new-figure-entry.ts +0 -4
- package/client/modeller/pose-defaults.ts +0 -67
- package/client/pages/figure-modeller-page.ts +0 -2082
- package/dist-client/modeller/angle-dial.d.ts +0 -32
- package/dist-client/modeller/angle-dial.js +0 -41
- package/dist-client/modeller/angle-dial.js.map +0 -1
- package/dist-client/modeller/base-box.d.ts +0 -71
- package/dist-client/modeller/base-box.js +0 -110
- package/dist-client/modeller/base-box.js.map +0 -1
- package/dist-client/modeller/figure-animations.d.ts +0 -159
- package/dist-client/modeller/figure-animations.js +0 -1132
- package/dist-client/modeller/figure-animations.js.map +0 -1
- package/dist-client/modeller/figure-canvas.d.ts +0 -150
- package/dist-client/modeller/figure-canvas.js +0 -506
- package/dist-client/modeller/figure-canvas.js.map +0 -1
- package/dist-client/modeller/figure-capabilities.d.ts +0 -42
- package/dist-client/modeller/figure-capabilities.js +0 -349
- package/dist-client/modeller/figure-capabilities.js.map +0 -1
- package/dist-client/modeller/figure-inspector.d.ts +0 -329
- package/dist-client/modeller/figure-inspector.js +0 -2095
- package/dist-client/modeller/figure-inspector.js.map +0 -1
- package/dist-client/modeller/figure-parts.d.ts +0 -64
- package/dist-client/modeller/figure-parts.js +0 -478
- package/dist-client/modeller/figure-parts.js.map +0 -1
- package/dist-client/modeller/figure-preview.d.ts +0 -138
- package/dist-client/modeller/figure-preview.js +0 -549
- package/dist-client/modeller/figure-preview.js.map +0 -1
- package/dist-client/modeller/figure-report.d.ts +0 -165
- package/dist-client/modeller/figure-report.js +0 -952
- package/dist-client/modeller/figure-report.js.map +0 -1
- package/dist-client/modeller/figure-settings.d.ts +0 -39
- package/dist-client/modeller/figure-settings.js +0 -317
- package/dist-client/modeller/figure-settings.js.map +0 -1
- package/dist-client/modeller/figure-side.d.ts +0 -78
- package/dist-client/modeller/figure-side.js +0 -317
- package/dist-client/modeller/figure-side.js.map +0 -1
- package/dist-client/modeller/figure-thumbnail.d.ts +0 -3
- package/dist-client/modeller/figure-thumbnail.js +0 -95
- package/dist-client/modeller/figure-thumbnail.js.map +0 -1
- package/dist-client/modeller/figure-view.d.ts +0 -118
- package/dist-client/modeller/figure-view.js +0 -78
- package/dist-client/modeller/figure-view.js.map +0 -1
- package/dist-client/modeller/joint-edits.d.ts +0 -49
- package/dist-client/modeller/joint-edits.js +0 -223
- package/dist-client/modeller/joint-edits.js.map +0 -1
- package/dist-client/modeller/joint-gizmo.d.ts +0 -34
- package/dist-client/modeller/joint-gizmo.js +0 -158
- package/dist-client/modeller/joint-gizmo.js.map +0 -1
- package/dist-client/modeller/maturity.d.ts +0 -55
- package/dist-client/modeller/maturity.js +0 -212
- package/dist-client/modeller/maturity.js.map +0 -1
- package/dist-client/modeller/new-figure-entry.d.ts +0 -2
- package/dist-client/modeller/new-figure-entry.js +0 -5
- package/dist-client/modeller/new-figure-entry.js.map +0 -1
- package/dist-client/modeller/pose-defaults.d.ts +0 -9
- package/dist-client/modeller/pose-defaults.js +0 -35
- package/dist-client/modeller/pose-defaults.js.map +0 -1
- package/dist-client/pages/figure-modeller-page.d.ts +0 -419
- package/dist-client/pages/figure-modeller-page.js +0 -2060
- package/dist-client/pages/figure-modeller-page.js.map +0 -1
- package/test/angle-dial.test.ts +0 -78
- package/test/base-box.test.ts +0 -235
- package/test/joint-edits.test.ts +0 -165
- package/test/joint-gizmo.test.ts +0 -174
- package/test/joint-needle.test.ts +0 -162
- package/test/modeller-undo-redo.test.ts +0 -33
- package/test/new-figure-entry.test.ts +0 -15
- package/test/pose-defaults.test.ts +0 -71
- package/test/preview-board-depth.test.ts +0 -27
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import '@material/web/icon/icon.js'
|
|
2
|
+
|
|
3
|
+
import { css, html, LitElement, nothing } from 'lit'
|
|
4
|
+
import { customElement, property, state } from 'lit/decorators.js'
|
|
5
|
+
|
|
6
|
+
import { i18next, localize } from '@operato/i18n'
|
|
7
|
+
import { V3_FACES } from '@hatiolab/figure-model'
|
|
8
|
+
import type { V3Asset, V3AuthoringAction, V3DimensionSource, V3Face } from '@hatiolab/figure-model'
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
acceptsOf,
|
|
12
|
+
AXES,
|
|
13
|
+
canFasten,
|
|
14
|
+
canFollow,
|
|
15
|
+
choiceOf,
|
|
16
|
+
fastenedTo,
|
|
17
|
+
followOf,
|
|
18
|
+
hasInstanceSize,
|
|
19
|
+
holdsThings,
|
|
20
|
+
motionOf,
|
|
21
|
+
placeFacesOf,
|
|
22
|
+
placeIdFor,
|
|
23
|
+
placesOn,
|
|
24
|
+
positionsWorkedOut,
|
|
25
|
+
type Axis,
|
|
26
|
+
type Follow
|
|
27
|
+
} from './v3-links.js'
|
|
28
|
+
|
|
29
|
+
const FACES = Object.keys(V3_FACES) as V3Face[]
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* What a part is tied to — its size response, what it is fastened to, how it moves (parity rows ④ ⑤ ⑥).
|
|
33
|
+
*
|
|
34
|
+
* 「Build Once, Fit Anywhere」 lives here: a dimension that follows the instance size or another part is what lets
|
|
35
|
+
* a figure be placed at any size, and a part fastened to the face it rests on is what keeps it there when that
|
|
36
|
+
* happens. The release gate refuses a figure that falls apart when resized, and tells the author to fasten the
|
|
37
|
+
* parts that come away — this is where they do it. Motion is the V2 joint: a slide or a turn driven by a control.
|
|
38
|
+
*
|
|
39
|
+
* The V2 inspector has these as its dimensions, placement and joint sections, in that order; so does this. Every
|
|
40
|
+
* change is one figure-model authoring command, sent as `v3-command` and run by the editor through the session — one
|
|
41
|
+
* step of the undo history. Choices the command would refuse (a round body fastened, a size following an instance
|
|
42
|
+
* size the figure does not declare) are not offered, so the author is not sent into a refusal.
|
|
43
|
+
*/
|
|
44
|
+
@customElement('figure-v3-links')
|
|
45
|
+
export class FigureV3Links extends localize(i18next)(LitElement) {
|
|
46
|
+
static styles = css`
|
|
47
|
+
:host {
|
|
48
|
+
display: grid;
|
|
49
|
+
gap: 10px;
|
|
50
|
+
}
|
|
51
|
+
h3 {
|
|
52
|
+
font-size: var(--ui-t-body, 13px);
|
|
53
|
+
margin: 12px 0 0;
|
|
54
|
+
}
|
|
55
|
+
div[row] {
|
|
56
|
+
display: grid;
|
|
57
|
+
grid-template-columns: 24px minmax(0, 1fr);
|
|
58
|
+
gap: 6px;
|
|
59
|
+
align-items: center;
|
|
60
|
+
}
|
|
61
|
+
div[form] {
|
|
62
|
+
display: grid;
|
|
63
|
+
grid-template-columns: 96px minmax(0, 1fr);
|
|
64
|
+
gap: 6px;
|
|
65
|
+
align-items: center;
|
|
66
|
+
}
|
|
67
|
+
div[pair] {
|
|
68
|
+
display: flex;
|
|
69
|
+
gap: 6px;
|
|
70
|
+
}
|
|
71
|
+
div[pair] > * {
|
|
72
|
+
flex: 1;
|
|
73
|
+
min-width: 0;
|
|
74
|
+
}
|
|
75
|
+
select,
|
|
76
|
+
input,
|
|
77
|
+
button {
|
|
78
|
+
font: inherit;
|
|
79
|
+
min-width: 0;
|
|
80
|
+
}
|
|
81
|
+
button[act] {
|
|
82
|
+
justify-self: start;
|
|
83
|
+
display: inline-flex;
|
|
84
|
+
align-items: center;
|
|
85
|
+
gap: 4px;
|
|
86
|
+
padding: 4px 10px;
|
|
87
|
+
border: none;
|
|
88
|
+
border-radius: 999em;
|
|
89
|
+
cursor: pointer;
|
|
90
|
+
color: var(--md-sys-color-on-secondary-container);
|
|
91
|
+
background: var(--md-sys-color-secondary-container);
|
|
92
|
+
}
|
|
93
|
+
button[act]:disabled {
|
|
94
|
+
opacity: 0.5;
|
|
95
|
+
cursor: default;
|
|
96
|
+
}
|
|
97
|
+
button[act] md-icon {
|
|
98
|
+
font-size: 16px;
|
|
99
|
+
width: 16px;
|
|
100
|
+
height: 16px;
|
|
101
|
+
}
|
|
102
|
+
small,
|
|
103
|
+
p[hint] {
|
|
104
|
+
margin: 0;
|
|
105
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
106
|
+
}
|
|
107
|
+
b[bound] {
|
|
108
|
+
font-weight: 600;
|
|
109
|
+
}
|
|
110
|
+
label[toggle] {
|
|
111
|
+
display: flex;
|
|
112
|
+
align-items: center;
|
|
113
|
+
gap: 6px;
|
|
114
|
+
}
|
|
115
|
+
`
|
|
116
|
+
|
|
117
|
+
/** The asset being edited. */
|
|
118
|
+
@property({ attribute: false }) asset?: V3Asset
|
|
119
|
+
/** The selected part. */
|
|
120
|
+
@property() partId = ''
|
|
121
|
+
/** The part's own extent along its axes now, in mm — what a dimension is fixed at when it stops following. */
|
|
122
|
+
@property({ attribute: false }) dims?: [number, number, number]
|
|
123
|
+
@property({ type: Boolean }) locked = false
|
|
124
|
+
|
|
125
|
+
@state() private fasten = { face: 'bottom' as V3Face, toPlane: false, target: '', targetFace: 'top' as V3Face, facing: 'meet' as 'meet' | 'flush', gap: 0 }
|
|
126
|
+
@state() private motion = { kind: 'slide' as 'slide' | 'turn', axis: 'x' as Axis, id: '', min: 0, max: 500 }
|
|
127
|
+
@state() private place = { role: 'grip' as 'seat' | 'grip', face: 'top' as V3Face, label: '', accepts: '', capacity: 1 }
|
|
128
|
+
|
|
129
|
+
render() {
|
|
130
|
+
const asset = this.asset
|
|
131
|
+
if (!asset || !this.partId) return nothing
|
|
132
|
+
return html`
|
|
133
|
+
<h3>${i18next.t('figure.label.dimensions')}</h3>
|
|
134
|
+
${canFollow(asset, this.partId) ? this.renderDimensions(asset) : html`<p hint>${i18next.t('figure.text.links-need-a-box')}</p>`}
|
|
135
|
+
<h3>${i18next.t('figure.label.placement')}</h3>
|
|
136
|
+
${this.renderPositions(asset)}
|
|
137
|
+
${canFasten(asset, this.partId) ? this.renderFastening(asset) : html`<p hint>${i18next.t('figure.text.links-need-a-box')}</p>`}
|
|
138
|
+
<h3>${i18next.t('figure.label.motion')}</h3>
|
|
139
|
+
${this.renderMotion(asset)}
|
|
140
|
+
<h3>${i18next.t('figure.label.places')}</h3>
|
|
141
|
+
${placeFacesOf(asset, this.partId).length ? this.renderPlaces(asset) : html`<p hint>${i18next.t('figure.text.places-need-a-box')}</p>`}
|
|
142
|
+
`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Where things are held on this part: a seat things rest on, or a grip things are held in (parity row ⑤). */
|
|
146
|
+
private renderPlaces(asset: V3Asset) {
|
|
147
|
+
const faces = placeFacesOf(asset, this.partId)
|
|
148
|
+
const p = this.place
|
|
149
|
+
const face = faces.includes(p.face) ? p.face : faces[0]!
|
|
150
|
+
const accepts = acceptsOf(p.accepts)
|
|
151
|
+
const capacityOk = Number.isSafeInteger(p.capacity) && p.capacity >= 1
|
|
152
|
+
const roleName = (role: string) => i18next.t('figure.label.place-' + role)
|
|
153
|
+
return html`
|
|
154
|
+
${placesOn(asset, this.partId).map(
|
|
155
|
+
one => html`<div pair>
|
|
156
|
+
<span>
|
|
157
|
+
<b bound>${one.label}</b>
|
|
158
|
+
<small>${roleName(one.role)} · ${one.face ? i18next.t('figure.label.face-' + one.face) : ''} · ${one.accepts.join(', ')} · ${i18next.t('figure.label.place-holds', { count: one.capacity })}</small>
|
|
159
|
+
</span>
|
|
160
|
+
<button act ?disabled=${this.locked} @click=${() => this.command({ kind: 'remove-binding', id: one.id })}>
|
|
161
|
+
<md-icon>close</md-icon>${i18next.t('figure.button.remove-place')}
|
|
162
|
+
</button>
|
|
163
|
+
</div>`
|
|
164
|
+
)}
|
|
165
|
+
<p hint>${i18next.t('figure.text.place-explained')}</p>
|
|
166
|
+
${holdsThings(asset)
|
|
167
|
+
? nothing
|
|
168
|
+
: html`<p hint>${i18next.t('figure.text.place-needs-holding')}</p>
|
|
169
|
+
<button act ?disabled=${this.locked} @click=${() => this.declareHolding(asset)}>
|
|
170
|
+
<md-icon>inventory_2</md-icon>${i18next.t('figure.button.declare-holding')}
|
|
171
|
+
</button>`}
|
|
172
|
+
<div pair>
|
|
173
|
+
<select @change=${(e: Event) => (this.place = { ...p, role: (e.target as HTMLSelectElement).value as 'seat' | 'grip' })}>
|
|
174
|
+
<option value="grip" ?selected=${p.role === 'grip'}>${roleName('grip')}</option>
|
|
175
|
+
<option value="seat" ?selected=${p.role === 'seat'}>${roleName('seat')}</option>
|
|
176
|
+
</select>
|
|
177
|
+
<select @change=${(e: Event) => (this.place = { ...p, face: (e.target as HTMLSelectElement).value as V3Face })}>
|
|
178
|
+
${faces.map(one => html`<option value=${one} ?selected=${one === face}>${i18next.t('figure.label.face-' + one)}</option>`)}
|
|
179
|
+
</select>
|
|
180
|
+
</div>
|
|
181
|
+
<div form>
|
|
182
|
+
<span>${i18next.t('figure.label.place-name')}</span>
|
|
183
|
+
<input .value=${p.label} placeholder=${roleName(p.role)} @change=${(e: Event) => (this.place = { ...p, label: (e.target as HTMLInputElement).value })} />
|
|
184
|
+
<span>${i18next.t('figure.label.place-accepts')}</span>
|
|
185
|
+
<input .value=${p.accepts} placeholder="BOX, TOTE" @input=${(e: Event) => (this.place = { ...p, accepts: (e.target as HTMLInputElement).value })} />
|
|
186
|
+
<span>${i18next.t('figure.label.place-capacity')}</span>
|
|
187
|
+
<input type="number" min="1" step="1" .value=${String(p.capacity)} @change=${(e: Event) => (this.place = { ...p, capacity: (e.target as HTMLInputElement).valueAsNumber })} />
|
|
188
|
+
</div>
|
|
189
|
+
${accepts.length ? nothing : html`<small>${i18next.t('figure.text.place-accepts-needed')}</small>`}
|
|
190
|
+
<button
|
|
191
|
+
act
|
|
192
|
+
?disabled=${this.locked || !accepts.length || !capacityOk}
|
|
193
|
+
@click=${() =>
|
|
194
|
+
this.command({
|
|
195
|
+
kind: 'add-binding',
|
|
196
|
+
id: placeIdFor(asset, this.partId, p.role),
|
|
197
|
+
label: p.label.trim() || roleName(p.role),
|
|
198
|
+
role: p.role,
|
|
199
|
+
part: this.partId,
|
|
200
|
+
face,
|
|
201
|
+
accepts,
|
|
202
|
+
capacity: p.capacity
|
|
203
|
+
})}
|
|
204
|
+
>
|
|
205
|
+
<md-icon>back_hand</md-icon>${i18next.t('figure.button.add-place')}
|
|
206
|
+
</button>
|
|
207
|
+
`
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The figure holds things: `Capacity` and `Holdable`, the ladder's middle rung. One part edit, one undo step. */
|
|
211
|
+
private declareHolding(asset: V3Asset) {
|
|
212
|
+
const names = [...new Set([...(asset.document.capabilities.names ?? []), 'Capacity', 'Holdable'])]
|
|
213
|
+
this.dispatchEvent(new CustomEvent('v3-part-edit', { detail: { edit: { kind: 'set-capabilities', names } }, bubbles: true, composed: true }))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private renderDimensions(asset: V3Asset) {
|
|
217
|
+
const others = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFollow(asset, one))
|
|
218
|
+
return AXES.map(axis => {
|
|
219
|
+
const now = followOf(asset, this.partId, axis)
|
|
220
|
+
const value = choiceOf(now)
|
|
221
|
+
return html`<div row>
|
|
222
|
+
<b>${axis.toUpperCase()}</b>
|
|
223
|
+
<select ?disabled=${this.locked || now.of === 'expression'} @change=${(e: Event) => this.follow(axis, (e.target as HTMLSelectElement).value, now)}>
|
|
224
|
+
<option value="own" ?selected=${value === 'own'}>${i18next.t('figure.label.follows-nothing')}</option>
|
|
225
|
+
${AXES.filter(one => hasInstanceSize(asset, one)).map(
|
|
226
|
+
one => html`<option value=${`instance:${one}`} ?selected=${value === `instance:${one}`}>
|
|
227
|
+
${i18next.t('figure.label.follows-instance')} · ${one.toUpperCase()}
|
|
228
|
+
</option>`
|
|
229
|
+
)}
|
|
230
|
+
${others.flatMap(other =>
|
|
231
|
+
AXES.map(
|
|
232
|
+
one => html`<option value=${`part:${other}:${one}`} ?selected=${value === `part:${other}:${one}`}>${other} · ${one.toUpperCase()}</option>`
|
|
233
|
+
)
|
|
234
|
+
)}
|
|
235
|
+
${now.of === 'expression' || now.of === 'input'
|
|
236
|
+
? html`<option value=${value} selected>${now.of === 'input' ? now.input : i18next.t('figure.label.follows-expression')}</option>`
|
|
237
|
+
: nothing}
|
|
238
|
+
</select>
|
|
239
|
+
</div>`
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private follow(axis: Axis, choice: string, now: Follow) {
|
|
244
|
+
const following = now.of !== 'own'
|
|
245
|
+
if (choice === 'own') {
|
|
246
|
+
if (!following) return
|
|
247
|
+
const mm = Math.max(1, Math.round(this.dims?.[AXES.indexOf(axis)] ?? 100))
|
|
248
|
+
this.command({ kind: 'unlink-dimension', part: this.partId, axis, mm })
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
const [of, a, b] = choice.split(':')
|
|
252
|
+
const source: V3DimensionSource = of === 'instance' ? { of: 'instance', axis: a as Axis } : { of: 'part', part: a!, axis: b as Axis }
|
|
253
|
+
this.command({ kind: 'link-dimension', part: this.partId, axis, source, replace: following })
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Positions worked out from other values — V2's anchor rules, as the converter wrote them — each with the way to
|
|
258
|
+
* keep it where it is. This is the way out the release gate names when a resize leaves a part in the air.
|
|
259
|
+
*/
|
|
260
|
+
private renderPositions(asset: V3Asset) {
|
|
261
|
+
return positionsWorkedOut(asset, this.partId).map(
|
|
262
|
+
axis => html`<div pair>
|
|
263
|
+
<span>${axis.toUpperCase()} · ${i18next.t('figure.label.position-worked-out')}</span>
|
|
264
|
+
<button act ?disabled=${this.locked} @click=${() => this.command({ kind: 'fix-position', part: this.partId, axis })}>
|
|
265
|
+
<md-icon>push_pin</md-icon>${i18next.t('figure.button.fix-position')}
|
|
266
|
+
</button>
|
|
267
|
+
</div>`
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private renderFastening(asset: V3Asset) {
|
|
272
|
+
const to = fastenedTo(asset, this.partId)
|
|
273
|
+
const f = this.fasten
|
|
274
|
+
const targets = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFasten(asset, one))
|
|
275
|
+
return html`
|
|
276
|
+
${to
|
|
277
|
+
? html`<div pair>
|
|
278
|
+
<span>${i18next.t('figure.label.fastened-to')}: <b bound>${to}</b></span>
|
|
279
|
+
<button act ?disabled=${this.locked} @click=${() => this.command({ kind: 'detach', part: this.partId })}>
|
|
280
|
+
<md-icon>link_off</md-icon>${i18next.t('figure.button.detach')}
|
|
281
|
+
</button>
|
|
282
|
+
</div>`
|
|
283
|
+
: html`<p hint>${i18next.t('figure.text.fastening-explained')}</p>`}
|
|
284
|
+
<div form>
|
|
285
|
+
<span>${i18next.t('figure.label.my-face')}</span>
|
|
286
|
+
<select @change=${(e: Event) => (this.fasten = { ...f, face: (e.target as HTMLSelectElement).value as V3Face })}>
|
|
287
|
+
${FACES.map(face => html`<option value=${face} ?selected=${face === f.face}>${i18next.t('figure.label.face-' + face)}</option>`)}
|
|
288
|
+
</select>
|
|
289
|
+
</div>
|
|
290
|
+
<label toggle>
|
|
291
|
+
<input type="checkbox" .checked=${f.toPlane} @change=${(e: Event) => (this.fasten = { ...f, toPlane: (e.target as HTMLInputElement).checked })} />
|
|
292
|
+
${i18next.t('figure.label.stands-on-plane')}
|
|
293
|
+
</label>
|
|
294
|
+
${f.toPlane
|
|
295
|
+
? nothing
|
|
296
|
+
: html`<div form>
|
|
297
|
+
<span>${i18next.t('figure.label.target-part')}</span>
|
|
298
|
+
<select @change=${(e: Event) => (this.fasten = { ...f, target: (e.target as HTMLSelectElement).value })}>
|
|
299
|
+
<option value="" ?selected=${!f.target}>—</option>
|
|
300
|
+
${targets.map(one => html`<option value=${one} ?selected=${one === f.target}>${one}</option>`)}
|
|
301
|
+
</select>
|
|
302
|
+
<span>${i18next.t('figure.label.target-face')}</span>
|
|
303
|
+
<select @change=${(e: Event) => (this.fasten = { ...f, targetFace: (e.target as HTMLSelectElement).value as V3Face })}>
|
|
304
|
+
${FACES.map(face => html`<option value=${face} ?selected=${face === f.targetFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
|
|
305
|
+
</select>
|
|
306
|
+
<span>${i18next.t('figure.label.facing-meet')}</span>
|
|
307
|
+
<select @change=${(e: Event) => (this.fasten = { ...f, facing: (e.target as HTMLSelectElement).value as 'meet' | 'flush' })}>
|
|
308
|
+
<option value="meet" ?selected=${f.facing === 'meet'}>${i18next.t('figure.label.facing-meet')}</option>
|
|
309
|
+
<option value="flush" ?selected=${f.facing === 'flush'}>${i18next.t('figure.label.facing-flush')}</option>
|
|
310
|
+
</select>
|
|
311
|
+
</div>`}
|
|
312
|
+
<div form>
|
|
313
|
+
<span>${i18next.t('figure.label.gap')}</span>
|
|
314
|
+
<input type="number" step="any" .value=${String(f.gap)} @change=${(e: Event) => (this.fasten = { ...f, gap: Number((e.target as HTMLInputElement).value) || 0 })} />
|
|
315
|
+
</div>
|
|
316
|
+
<button
|
|
317
|
+
act
|
|
318
|
+
?disabled=${this.locked || (!f.toPlane && !f.target)}
|
|
319
|
+
@click=${() =>
|
|
320
|
+
this.command({
|
|
321
|
+
kind: 'attach',
|
|
322
|
+
part: this.partId,
|
|
323
|
+
face: f.face,
|
|
324
|
+
to: f.toPlane ? { plane: 'mounting-plane' } : { part: f.target, face: f.targetFace },
|
|
325
|
+
...(f.toPlane ? {} : { facing: f.facing }),
|
|
326
|
+
...(f.gap ? { gap: f.gap } : {}),
|
|
327
|
+
replace: !!to
|
|
328
|
+
} as V3AuthoringAction)}
|
|
329
|
+
>
|
|
330
|
+
<md-icon>link</md-icon>${i18next.t('figure.button.fasten')}
|
|
331
|
+
</button>
|
|
332
|
+
`
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private renderMotion(asset: V3Asset) {
|
|
336
|
+
const now = motionOf(asset, this.partId)
|
|
337
|
+
if (now) {
|
|
338
|
+
return html`<div pair>
|
|
339
|
+
<span>
|
|
340
|
+
${i18next.t('figure.label.motion-' + now.kind)} · ${String(now.axis).toUpperCase()}
|
|
341
|
+
${now.control ? html`<small>${now.control.id} (${now.control.unit}) ${now.control.min} … ${now.control.max}</small>` : nothing}
|
|
342
|
+
</span>
|
|
343
|
+
<button act ?disabled=${this.locked} @click=${() => this.command({ kind: 'remove-motion', part: this.partId })}>
|
|
344
|
+
<md-icon>close</md-icon>${i18next.t('figure.button.remove-motion')}
|
|
345
|
+
</button>
|
|
346
|
+
</div>`
|
|
347
|
+
}
|
|
348
|
+
const m = this.motion
|
|
349
|
+
const unit = m.kind === 'turn' ? 'deg' : 'mm'
|
|
350
|
+
return html`
|
|
351
|
+
<p hint>${i18next.t('figure.text.motion-explained')}</p>
|
|
352
|
+
<div pair>
|
|
353
|
+
<select @change=${(e: Event) => this.setKind((e.target as HTMLSelectElement).value as 'slide' | 'turn')}>
|
|
354
|
+
<option value="slide" ?selected=${m.kind === 'slide'}>${i18next.t('figure.label.motion-slide')}</option>
|
|
355
|
+
<option value="turn" ?selected=${m.kind === 'turn'}>${i18next.t('figure.label.motion-turn')}</option>
|
|
356
|
+
</select>
|
|
357
|
+
<select @change=${(e: Event) => (this.motion = { ...m, axis: (e.target as HTMLSelectElement).value as Axis })}>
|
|
358
|
+
${AXES.map(a => html`<option value=${a} ?selected=${a === m.axis}>${i18next.t('figure.label.motion-axis')} ${a.toUpperCase()}</option>`)}
|
|
359
|
+
</select>
|
|
360
|
+
</div>
|
|
361
|
+
<div form>
|
|
362
|
+
<span>${i18next.t('figure.label.control-id')}</span>
|
|
363
|
+
<input .value=${m.id} placeholder=${`${this.partId}-${m.kind}`} @change=${(e: Event) => (this.motion = { ...m, id: (e.target as HTMLInputElement).value.trim() })} />
|
|
364
|
+
<span>${i18next.t('figure.label.control-range')} (${unit})</span>
|
|
365
|
+
<div pair>
|
|
366
|
+
<input type="number" step="any" .value=${String(m.min)} @change=${(e: Event) => (this.motion = { ...m, min: Number((e.target as HTMLInputElement).value) })} />
|
|
367
|
+
<input type="number" step="any" .value=${String(m.max)} @change=${(e: Event) => (this.motion = { ...m, max: Number((e.target as HTMLInputElement).value) })} />
|
|
368
|
+
</div>
|
|
369
|
+
</div>
|
|
370
|
+
<button
|
|
371
|
+
act
|
|
372
|
+
?disabled=${this.locked || !(m.max > m.min)}
|
|
373
|
+
@click=${() =>
|
|
374
|
+
this.command({
|
|
375
|
+
kind: 'add-motion',
|
|
376
|
+
part: this.partId,
|
|
377
|
+
motion: { kind: m.kind, axis: m.axis },
|
|
378
|
+
state: { id: m.id || `${this.partId}-${m.kind}`, unit, min: m.min, max: m.max, start: m.min }
|
|
379
|
+
})}
|
|
380
|
+
>
|
|
381
|
+
<md-icon>animation</md-icon>${i18next.t('figure.button.add-motion')}
|
|
382
|
+
</button>
|
|
383
|
+
`
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private setKind(kind: 'slide' | 'turn') {
|
|
387
|
+
this.motion = kind === 'turn' ? { ...this.motion, kind, min: -90, max: 90 } : { ...this.motion, kind, min: 0, max: 500 }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private command(action: V3AuthoringAction) {
|
|
391
|
+
this.dispatchEvent(new CustomEvent('v3-command', { detail: { action }, bubbles: true, composed: true }))
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
@@ -1,25 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* page's address (2026-09-24). A new figure starts in V3: new V2 figures would only add to what the cutover has to
|
|
6
|
-
* convert (ADR-0087). A figure is opened in V3 only when the server says it is V3 — an unknown format goes to the
|
|
7
|
-
* V2 page, which reports what it could not read.
|
|
2
|
+
* The one modeller. Every figure is V3 since ADR-0087 step 5 (2026-09-24, user decision: V2 is not kept), so the
|
|
3
|
+
* catalogue, a new figure and an old address all open the same page. `figure-v3-modeller` stays as an alias so a
|
|
4
|
+
* link written before the switch still lands.
|
|
8
5
|
*/
|
|
9
|
-
export const
|
|
10
|
-
export const V2_MODELLER = 'figure-modeller'
|
|
6
|
+
export const MODELLER = 'figure-modeller'
|
|
11
7
|
|
|
12
|
-
export function modellerPathOf(figure?: { id?: string
|
|
13
|
-
|
|
14
|
-
return figure.format === 'v3' ? `${V3_MODELLER}/${figure.id}` : `${V2_MODELLER}/${figure.id}`
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** The version a V3 asset stamps on itself. The V2 page forwards such a figure rather than failing on it. */
|
|
18
|
-
export function isV3Source(source?: string | null): boolean {
|
|
19
|
-
if (!source) return false
|
|
20
|
-
try {
|
|
21
|
-
return (JSON.parse(source) as { version?: unknown })?.version === 'figure-v3-asset-1'
|
|
22
|
-
} catch {
|
|
23
|
-
return false
|
|
24
|
-
}
|
|
8
|
+
export function modellerPathOf(figure?: { id?: string }): string {
|
|
9
|
+
return figure?.id ? `${MODELLER}/${figure.id}` : MODELLER
|
|
25
10
|
}
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { LitElement, html, css, type PropertyValues } from 'lit'
|
|
2
2
|
import { i18next } from '@operato/i18n'
|
|
3
3
|
import { customElement, property, state } from 'lit/decorators.js'
|
|
4
|
-
import type { V3InputChange, V3PrimitiveCreation } from '@hatiolab/figure-model'
|
|
4
|
+
import type { V3AuthoringAction, V3InputChange, V3PrimitiveCreation } from '@hatiolab/figure-model'
|
|
5
5
|
import type { V3ProductSession } from './v3-product-session.js'
|
|
6
6
|
import { captureV3Thumbnail } from './v3-thumbnail.js'
|
|
7
7
|
import { drawableV3Parts } from './v3-renderer.js'
|
|
8
8
|
import './v3-canvas.js'
|
|
9
9
|
import './figure-v3-parts.js'
|
|
10
10
|
import './figure-v3-material.js'
|
|
11
|
+
import './figure-v3-links.js'
|
|
11
12
|
import { gizmoChanges, gizmoReachOf, type GizmoReach, type GizmoResult } from './v3-gizmo.js'
|
|
12
|
-
import { alignChanges, canMirror, mirrorChanges, type AlignTool } from './v3-align.js'
|
|
13
|
+
import { alignChanges, boxOf, canMirror, mirrorChanges, type AlignTool } from './v3-align.js'
|
|
13
14
|
import type { V3PartRow } from './figure-v3-parts.js'
|
|
14
15
|
import type { FigureV3Canvas } from './v3-canvas.js'
|
|
15
16
|
|
|
@@ -420,6 +421,47 @@ export class FigureV3Editor extends LitElement {
|
|
|
420
421
|
: String(error)
|
|
421
422
|
}
|
|
422
423
|
}
|
|
424
|
+
/** Size response, fastening and motion for the selected part (parity ④ ⑤ ⑥), as authoring commands. */
|
|
425
|
+
private renderLinks(unavailable: boolean) {
|
|
426
|
+
let asset
|
|
427
|
+
try {
|
|
428
|
+
asset = JSON.parse(this.session!.source())
|
|
429
|
+
} catch {
|
|
430
|
+
return null
|
|
431
|
+
}
|
|
432
|
+
let dims: [number, number, number] | undefined
|
|
433
|
+
try {
|
|
434
|
+
dims = boxOf(asset, this.selected)?.dims
|
|
435
|
+
} catch {
|
|
436
|
+
dims = undefined
|
|
437
|
+
}
|
|
438
|
+
return html`<figure-v3-links
|
|
439
|
+
.asset=${asset}
|
|
440
|
+
.partId=${this.selected}
|
|
441
|
+
.dims=${dims}
|
|
442
|
+
?locked=${unavailable}
|
|
443
|
+
@v3-command=${(e: CustomEvent) => this.linkCommand(e.detail.action)}
|
|
444
|
+
@v3-part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
|
|
445
|
+
></figure-v3-links>`
|
|
446
|
+
}
|
|
447
|
+
/** One command, one step of the history. A refusal a person can meet here is said in their words. */
|
|
448
|
+
private linkCommand(action: V3AuthoringAction) {
|
|
449
|
+
try {
|
|
450
|
+
this.session!.command(action)
|
|
451
|
+
this.error = ''
|
|
452
|
+
this.changed()
|
|
453
|
+
} catch (error) {
|
|
454
|
+
const code = (error as { code?: string })?.code
|
|
455
|
+
this.error =
|
|
456
|
+
code === 'DIMENSION_CYCLE'
|
|
457
|
+
? i18next.t('figure.text.follow-would-loop')
|
|
458
|
+
: code === 'FASTEN_CYCLE' || code === 'ATTACH_CYCLE'
|
|
459
|
+
? i18next.t('figure.text.fasten-would-loop')
|
|
460
|
+
: error instanceof Error
|
|
461
|
+
? error.message
|
|
462
|
+
: String(error)
|
|
463
|
+
}
|
|
464
|
+
}
|
|
423
465
|
private changeField(field: { id: string; unit: V3InputChange['unit']; role?: string }, event: Event) {
|
|
424
466
|
const input = event.target as HTMLInputElement,
|
|
425
467
|
value = input.valueAsNumber
|
|
@@ -508,7 +550,7 @@ export class FigureV3Editor extends LitElement {
|
|
|
508
550
|
${fields.map(field => html`<label><span>${fieldLabel(field.id)} (${field.unit})${field.role === 'state' ? ' · 동작' : ''}</span><input aria-label=${field.id} type="number" step="any" .value=${String(field.value)} ?disabled=${unavailable} @change=${(e: Event) => this.changeField(field, e)} /></label>`)}
|
|
509
551
|
${
|
|
510
552
|
part
|
|
511
|
-
? html
|
|
553
|
+
? html`${this.renderLinks(unavailable)}<figure-v3-material
|
|
512
554
|
.body=${part}
|
|
513
555
|
.palette=${view.palette ?? {}}
|
|
514
556
|
?locked=${unavailable}
|
|
@@ -518,8 +560,8 @@ export class FigureV3Editor extends LitElement {
|
|
|
518
560
|
}
|
|
519
561
|
${
|
|
520
562
|
view.bindings.length
|
|
521
|
-
? html`<h3
|
|
522
|
-
${view.bindings.map(b => html`<p>${b.label} · ${b.role}</p>`)}`
|
|
563
|
+
? html`<h3>${i18next.t('figure.label.places')}</h3>
|
|
564
|
+
${view.bindings.map(b => html`<p>${b.label} · ${i18next.t('figure.label.place-' + b.role)}</p>`)}`
|
|
523
565
|
: null
|
|
524
566
|
}
|
|
525
567
|
</aside>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { v3AttachmentsOf } from '@hatiolab/figure-model'
|
|
2
|
+
import type { V3Asset, V3InputChange } from '@hatiolab/figure-model'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* What the gizmo may write for a part — the V2 gizmo's move · turn · size, on a V3 asset (parity row ③).
|
|
@@ -40,7 +41,17 @@ export function gizmoReachOf(asset: AssetLike, part: string): GizmoReach {
|
|
|
40
41
|
const allDesign = (ids: string[]) => ids.every(id => design.has(id))
|
|
41
42
|
const pose = nodes.find(node => node.id === `${part}.pose`)
|
|
42
43
|
const placed = nodes.find(node => node.id === part && node.op === 'place@1')
|
|
43
|
-
const
|
|
44
|
+
const direct = !!pose && pose.op === 'rigid@1' && pose.params?.to === 'asset' && placed?.args[1] === `${part}.poseValue`
|
|
45
|
+
/*
|
|
46
|
+
A part taken off another (detach) stands on a seat into the asset frame with its own position numbers back. It
|
|
47
|
+
can be moved like a part never fastened — unless it moves by itself, when where it is drawn is not its position.
|
|
48
|
+
*/
|
|
49
|
+
const reseated =
|
|
50
|
+
!!pose &&
|
|
51
|
+
pose.op === 'rigid@1' &&
|
|
52
|
+
!nodes.some(node => node.id === `${part}.motion`) &&
|
|
53
|
+
v3AttachmentsOf(asset as unknown as V3Asset)[part] === null
|
|
54
|
+
const posed = direct || reseated
|
|
44
55
|
const shape = nodes.find(node => node.id === `${part}.shape`)
|
|
45
56
|
const extent = shape && BOXES.has(shape.op) ? shape.args.slice(0, 3) : []
|
|
46
57
|
const sizes =
|