@things-factory/figure-ui 10.1.50 → 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/modeller/figure-v3-links.ts +94 -11
- package/client/modeller/v3-editor.ts +5 -0
- package/client/modeller/v3-links.ts +55 -11
- package/client/pages/figure-v3-modeller-page.ts +11 -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-links.d.ts +9 -1
- package/dist-client/modeller/figure-v3-links.js +95 -12
- 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-properties.d.ts +1 -1
- package/dist-client/modeller/v3-clips.d.ts +1 -1
- package/dist-client/modeller/v3-editor.js +5 -0
- package/dist-client/modeller/v3-editor.js.map +1 -1
- package/dist-client/modeller/v3-links.d.ts +31 -5
- package/dist-client/modeller/v3-links.js +45 -11
- package/dist-client/modeller/v3-links.js.map +1 -1
- package/dist-client/pages/figure-v3-modeller-page.js +11 -1
- package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/test/browser/modeller.check.mjs +24 -0
- package/test/v3-first-save.test.ts +9 -0
- package/test/v3-links.test.ts +59 -7
- package/translations/en.json +8 -1
- package/translations/ja.json +8 -1
- package/translations/ko.json +8 -1
- package/translations/ms.json +8 -1
- package/translations/zh.json +8 -1
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
alignOf,
|
|
14
14
|
AXES,
|
|
15
15
|
canFasten,
|
|
16
|
+
facesOf,
|
|
16
17
|
canFollow,
|
|
17
18
|
dimensionNow,
|
|
18
19
|
followableAxes,
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
placeFacesOf,
|
|
29
30
|
placeIdFor,
|
|
30
31
|
placesOn,
|
|
32
|
+
rowAxesOf,
|
|
31
33
|
positionsWorkedOut,
|
|
32
34
|
type Alignment,
|
|
33
35
|
type Axis,
|
|
@@ -39,6 +41,21 @@ import { HelpToggles } from './v3-help.js'
|
|
|
39
41
|
|
|
40
42
|
const FACES = Object.keys(V3_FACES) as V3Face[]
|
|
41
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
|
+
|
|
42
59
|
/**
|
|
43
60
|
* What a part is tied to — its size response, what it is fastened to, how it moves (parity rows ④ ⑤ ⑥).
|
|
44
61
|
*
|
|
@@ -84,9 +101,23 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
84
101
|
/** Which ties this shows: the part's shape (size and placement, in 속성), its places (자리·능력) or its motion (움직임). */
|
|
85
102
|
@property() section: 'shape' | 'places' | 'motion' = 'shape'
|
|
86
103
|
|
|
87
|
-
@state() private fasten =
|
|
88
|
-
@state() private motion: MotionForm =
|
|
89
|
-
@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
|
+
}
|
|
90
121
|
|
|
91
122
|
render() {
|
|
92
123
|
const asset = this.asset
|
|
@@ -107,6 +138,7 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
107
138
|
${this.renderPositions(asset)}
|
|
108
139
|
${canFasten(asset, this.partId) ? this.renderFastening(asset) : html`<p hint>${i18next.t('figure.text.links-need-a-box')}</p>`}
|
|
109
140
|
</section>
|
|
141
|
+
${this.renderRow(asset)}
|
|
110
142
|
`
|
|
111
143
|
}
|
|
112
144
|
|
|
@@ -259,6 +291,13 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
259
291
|
const to = fastenedTo(asset, this.partId)
|
|
260
292
|
const f = this.fasten
|
|
261
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
|
|
262
301
|
return html`
|
|
263
302
|
${to
|
|
264
303
|
? html`<div pair>
|
|
@@ -271,7 +310,7 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
271
310
|
<div form>
|
|
272
311
|
<span>${i18next.t('figure.label.my-face')}</span>
|
|
273
312
|
<select @change=${(e: Event) => (this.fasten = { ...f, face: (e.target as HTMLSelectElement).value as V3Face })}>
|
|
274
|
-
${
|
|
313
|
+
${mineFaces.map(face => html`<option value=${face} ?selected=${face === myFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
|
|
275
314
|
</select>
|
|
276
315
|
</div>
|
|
277
316
|
<label toggle>
|
|
@@ -288,7 +327,7 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
288
327
|
</select>
|
|
289
328
|
<span>${i18next.t('figure.label.target-face')}</span>
|
|
290
329
|
<select @change=${(e: Event) => (this.fasten = { ...f, targetFace: (e.target as HTMLSelectElement).value as V3Face })}>
|
|
291
|
-
${
|
|
330
|
+
${theirFaces.map(face => html`<option value=${face} ?selected=${face === targetFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
|
|
292
331
|
</select>
|
|
293
332
|
<span>${i18next.t('figure.label.facing-meet')}</span>
|
|
294
333
|
<select @change=${(e: Event) => (this.fasten = { ...f, facing: (e.target as HTMLSelectElement).value as 'meet' | 'flush' })}>
|
|
@@ -296,10 +335,12 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
296
335
|
<option value="flush" ?selected=${f.facing === 'flush'}>${i18next.t('figure.label.facing-flush')}</option>
|
|
297
336
|
</select>
|
|
298
337
|
</div>`}
|
|
299
|
-
${this.renderAlignment()}
|
|
338
|
+
${this.renderAlignment(f.toPlane || f.target ? alignAxesOf(asset, this.partId, aim, !!f.twist) : [])}
|
|
300
339
|
<div form>
|
|
301
340
|
<span>${i18next.t('figure.label.gap')}</span>
|
|
302
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 })} />
|
|
303
344
|
</div>
|
|
304
345
|
<button
|
|
305
346
|
act
|
|
@@ -308,11 +349,12 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
308
349
|
this.command({
|
|
309
350
|
kind: 'attach',
|
|
310
351
|
part: this.partId,
|
|
311
|
-
face:
|
|
312
|
-
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 },
|
|
313
354
|
...(f.toPlane ? {} : { facing: f.facing }),
|
|
314
355
|
...(f.gap ? { gap: f.gap } : {}),
|
|
315
|
-
|
|
356
|
+
twist: f.twist,
|
|
357
|
+
...(align ? { align } : {}),
|
|
316
358
|
replace: !!to
|
|
317
359
|
} as V3AuthoringAction)}
|
|
318
360
|
>
|
|
@@ -325,12 +367,12 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
325
367
|
* Where on the target face the part sits, on the two axes across it: centred, level with the target's edge on
|
|
326
368
|
* either side, or a distance off centre. A door hung level with the frame's front edge turns about that edge.
|
|
327
369
|
*/
|
|
328
|
-
private renderAlignment() {
|
|
370
|
+
private renderAlignment(axes: { axis: Axis; edges: boolean }[]) {
|
|
329
371
|
const f = this.fasten
|
|
330
372
|
const set = (axis: Axis, how: Alignment) => (this.fasten = { ...f, align: { ...f.align, [axis]: how } })
|
|
331
373
|
const setMm = (axis: Axis, mm: number) => (this.fasten = { ...f, alignMm: { ...f.alignMm, [axis]: mm } })
|
|
332
374
|
return html`<div form>
|
|
333
|
-
${
|
|
375
|
+
${axes.map(({ axis, edges }) => {
|
|
334
376
|
const how = f.align[axis] === 'min' || f.align[axis] === 'max' ? (edges ? f.align[axis]! : 'centre') : (f.align[axis] ?? 'centre')
|
|
335
377
|
return html`<span>${i18next.t('figure.label.align-' + axis)}</span>
|
|
336
378
|
<select @change=${(e: Event) => set(axis, (e.target as HTMLSelectElement).value as Alignment)}>
|
|
@@ -348,6 +390,47 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
348
390
|
</div>`
|
|
349
391
|
}
|
|
350
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
|
+
|
|
351
434
|
private renderMotion(asset: V3Asset) {
|
|
352
435
|
const now = motionOf(asset, this.partId)
|
|
353
436
|
const joint = asset.joints?.find(j => j.body1.part === this.partId)
|
|
@@ -855,6 +855,11 @@ export class FigureV3Editor extends LitElement {
|
|
|
855
855
|
})
|
|
856
856
|
: i18next.t(fits ? 'figure.text.repeat-fits-length' : 'figure.text.repeat-fixed-count')}
|
|
857
857
|
</p>
|
|
858
|
+
${asset.joints?.some(j => j.body1.part === this.selected && j.repeat)
|
|
859
|
+
? html`<button act ?disabled=${unavailable} @click=${() => this.run(() => this.session!.command({ kind: 'remove-repeat', part: this.selected }))}>
|
|
860
|
+
<md-icon>close</md-icon>${i18next.t('figure.button.remove-repeat')}
|
|
861
|
+
</button>`
|
|
862
|
+
: nothing}
|
|
858
863
|
</section>`
|
|
859
864
|
}
|
|
860
865
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { V3_FACES, compileV3Graph, defaultInstanceSizeRange, foldInstanceSizeRange, v3AttachmentsOf, v3BindingsOf, v3InstanceSizeLimitsOf } from '@hatiolab/figure-model'
|
|
1
|
+
import { V3_FACES, v3ShapeFaces, compileV3Graph, defaultInstanceSizeRange, foldInstanceSizeRange, v3AttachmentsOf, v3BindingsOf, v3InstanceSizeLimitsOf } from '@hatiolab/figure-model'
|
|
2
2
|
import type { V3SizeRange } from '@hatiolab/figure-model'
|
|
3
3
|
import type { V3Asset, V3BindingPlace, V3Face, V3Joint } from '@hatiolab/figure-model'
|
|
4
4
|
|
|
@@ -115,29 +115,73 @@ export function dimensionNow(asset: V3Asset, part: string, axis: Axis): number |
|
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
/**
|
|
119
|
-
|
|
118
|
+
/**
|
|
119
|
+
* The flat faces a part's shape declares, by name -- figure-model's shape definitions are the one place they are
|
|
120
|
+
* written (a portal's opening sides as well as its outer faces). A sphere has none.
|
|
121
|
+
*/
|
|
122
|
+
export function facesOf(asset: V3Asset, part: string): string[] {
|
|
123
|
+
const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
|
|
124
|
+
return Object.keys((shape && v3ShapeFaces(shape.op)) ?? {})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The axis a face of a part faces along, from the same definitions. */
|
|
128
|
+
export function faceAxisOf(asset: V3Asset, part: string, face: string): Axis | undefined {
|
|
120
129
|
const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
|
|
121
|
-
return
|
|
130
|
+
return (shape && v3ShapeFaces(shape.op)?.[face]?.axis) as Axis | undefined
|
|
122
131
|
}
|
|
123
132
|
|
|
133
|
+
/** Whether `attach` can take this part: any part whose shape has a flat face. */
|
|
134
|
+
export function canFasten(asset: V3Asset, part: string): boolean {
|
|
135
|
+
return facesOf(asset, part).length > 0
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const OUTER: Record<Axis, [string, string]> = { x: ['left', 'right'], y: ['bottom', 'top'], z: ['back', 'front'] }
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The axes a part can be made a row along (r3 §8, figure-model `make-repeat`): across the face it is fastened by, when
|
|
142
|
+
* it is fastened fixed to another part and holds nothing. None for a part that is loose, moves, stands on the floor or
|
|
143
|
+
* already repeats -- the command would refuse those, so they are not offered.
|
|
144
|
+
*/
|
|
145
|
+
export function rowAxesOf(asset: V3Asset, part: string): Axis[] {
|
|
146
|
+
const joints = asset.joints ?? []
|
|
147
|
+
const j = joints.find(o => o.body1.part === part)
|
|
148
|
+
if (!j || j.type !== 'fixed' || j.repeat || !('part' in j.body0)) return []
|
|
149
|
+
if (joints.some(o => 'part' in o.body0 && o.body0.part === part)) return []
|
|
150
|
+
return AXES.filter(axis => axis !== V3_FACES[j.body1.face])
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The row a part is made into, as its fastening's record keeps it. */
|
|
154
|
+
export const rowOf = (asset: V3Asset, part: string) => asset.joints?.find(o => o.body1.part === part)?.repeat
|
|
155
|
+
|
|
124
156
|
/** Where a part sits across the face it is fastened to, on one axis: centred, level with an edge, or a distance off centre. */
|
|
125
157
|
export type Alignment = 'centre' | 'min' | 'max' | 'mm'
|
|
126
158
|
|
|
127
159
|
/**
|
|
128
|
-
* The axes across the face a part is fastened to — the two a fastening does not set by itself.
|
|
129
|
-
*
|
|
160
|
+
* The axes across the face a part is fastened to — the two a fastening does not set by itself. Edges to line up with
|
|
161
|
+
* are offered where both parts have their two outer faces on that axis; the plane the figure is mounted on has none,
|
|
162
|
+
* so on it (X and Z) only centred or a distance is offered.
|
|
130
163
|
*/
|
|
131
|
-
export function alignAxesOf(target:
|
|
164
|
+
export function alignAxesOf(asset: V3Asset, part: string, target: { part: string; face: string } | 'plane', twisted = false): { axis: Axis; edges: boolean }[] {
|
|
132
165
|
if (target === 'plane') return (['x', 'z'] as Axis[]).map(axis => ({ axis, edges: false }))
|
|
133
|
-
const along =
|
|
134
|
-
|
|
166
|
+
const along = faceAxisOf(asset, target.part, target.face)
|
|
167
|
+
if (!along) return []
|
|
168
|
+
const theirs = new Set(facesOf(asset, target.part)),
|
|
169
|
+
mine = new Set(facesOf(asset, part))
|
|
170
|
+
/* A part twisted about the face's normal has no side level with the target's edges. */
|
|
171
|
+
return AXES.filter(axis => axis !== along).map(axis => ({ axis, edges: !twisted && OUTER[axis].every(f => theirs.has(f) && mine.has(f)) }))
|
|
135
172
|
}
|
|
136
173
|
|
|
137
174
|
/** The `align` of an attach command from the form's choices: only the axes across that face, and centred ones left out. */
|
|
138
|
-
export function alignOf(
|
|
175
|
+
export function alignOf(
|
|
176
|
+
asset: V3Asset,
|
|
177
|
+
part: string,
|
|
178
|
+
target: { part: string; face: string } | 'plane',
|
|
179
|
+
how: Partial<Record<Axis, Alignment>>,
|
|
180
|
+
mm: Partial<Record<Axis, number>>,
|
|
181
|
+
twisted = false
|
|
182
|
+
) {
|
|
139
183
|
const align: Partial<Record<Axis, 'min' | 'max' | { mm: number }>> = {}
|
|
140
|
-
for (const { axis, edges } of alignAxesOf(target)) {
|
|
184
|
+
for (const { axis, edges } of alignAxesOf(asset, part, target, twisted)) {
|
|
141
185
|
const one = how[axis] ?? 'centre'
|
|
142
186
|
if (one === 'mm' && mm[axis]) align[axis] = { mm: mm[axis]! }
|
|
143
187
|
else if ((one === 'min' || one === 'max') && edges) align[axis] = one
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { html, css } from 'lit'
|
|
2
2
|
import { customElement, state } from 'lit/decorators.js'
|
|
3
|
-
import { PageView } from '@operato/shell'
|
|
3
|
+
import { navigate, PageView } from '@operato/shell'
|
|
4
|
+
import { modellerPathOf } from '../modeller/modeller-route.js'
|
|
4
5
|
import { createV3ProductSession } from '../modeller/v3-product-session.js'
|
|
5
6
|
import { v3ProductStore } from '../graphql/v3-store.js'
|
|
6
7
|
import { i18next } from '@operato/i18n'
|
|
@@ -144,6 +145,15 @@ export class FigureV3ModellerPage extends PageView {
|
|
|
144
145
|
this.updateContext()
|
|
145
146
|
}
|
|
146
147
|
if (!view.saving) this.announce()
|
|
148
|
+
/*
|
|
149
|
+
A new figure's first save gives it an id: the address becomes the figure's own, in place of the history entry.
|
|
150
|
+
Left at the bare modeller address, opening the modeller again from the menu was taken for the same page and kept
|
|
151
|
+
the saved figure -- its type showing where a new figure's was expected (2026-09-25).
|
|
152
|
+
*/
|
|
153
|
+
if (view.id && !this.routeId && !view.saving) {
|
|
154
|
+
this.routeId = view.id
|
|
155
|
+
navigate(modellerPathOf({ id: view.id }), true)
|
|
156
|
+
}
|
|
147
157
|
/* 저장이 끝난 순간 행을 다시 읽는다 — 발행된 도형을 고쳐 저장하면 서버가 초안으로 내린다. */
|
|
148
158
|
if (this.wasDirty && !view.dirty && !view.saving && view.id) this.readRow(view.id)
|
|
149
159
|
this.wasDirty = !!view.dirty
|
|
@@ -15,7 +15,7 @@ export declare class FigureV3Capability extends FigureV3Capability_base {
|
|
|
15
15
|
/** The selected part, whose places are marked. */
|
|
16
16
|
selected: string;
|
|
17
17
|
locked: boolean;
|
|
18
|
-
render(): import("lit-html").TemplateResult<1
|
|
18
|
+
render(): typeof nothing | import("lit-html").TemplateResult<1>;
|
|
19
19
|
private setRung;
|
|
20
20
|
private pick;
|
|
21
21
|
}
|
|
@@ -18,7 +18,7 @@ export declare class FigureV3Clips extends FigureV3Clips_base {
|
|
|
18
18
|
/** Where each clip is scrubbed to, in seconds. */
|
|
19
19
|
private at;
|
|
20
20
|
private adding;
|
|
21
|
-
render(): import("lit-html").TemplateResult<1
|
|
21
|
+
render(): typeof nothing | import("lit-html").TemplateResult<1>;
|
|
22
22
|
private renderClip;
|
|
23
23
|
private renderTrack;
|
|
24
24
|
private renderAdd;
|
|
@@ -22,7 +22,7 @@ export declare class FigureV3Controls extends FigureV3Controls_base {
|
|
|
22
22
|
private open;
|
|
23
23
|
/** Values in hand while a slider is dragged: the dial and the number follow them before the release commits. */
|
|
24
24
|
private live;
|
|
25
|
-
render(): import("lit-html").TemplateResult<1
|
|
25
|
+
render(): typeof nothing | import("lit-html").TemplateResult<1>;
|
|
26
26
|
private renderControl;
|
|
27
27
|
private renderDial;
|
|
28
28
|
private renderSettings;
|
|
@@ -30,7 +30,9 @@ export declare class FigureV3Links extends FigureV3Links_base {
|
|
|
30
30
|
private fasten;
|
|
31
31
|
private motion;
|
|
32
32
|
private place;
|
|
33
|
-
|
|
33
|
+
private row;
|
|
34
|
+
willUpdate(changed: Map<string, unknown>): void;
|
|
35
|
+
render(): typeof nothing | import("lit-html").TemplateResult<1>;
|
|
34
36
|
/** Where things are held on this part: a seat things rest on, or a grip things are held in (parity row ⑤). */
|
|
35
37
|
private renderPlaces;
|
|
36
38
|
/** The figure holds things: `Capacity` and `Holdable`, the ladder's middle rung. One part edit, one undo step. */
|
|
@@ -48,6 +50,12 @@ export declare class FigureV3Links extends FigureV3Links_base {
|
|
|
48
50
|
* either side, or a distance off centre. A door hung level with the frame's front edge turns about that edge.
|
|
49
51
|
*/
|
|
50
52
|
private renderAlignment;
|
|
53
|
+
/**
|
|
54
|
+
* Making the part a row across what it is fastened to (figure-model `make-repeat`): along one of the axes across its
|
|
55
|
+
* face, copies a pitch apart filling the parent's length, or a count spread over it. Once made, the row's own section
|
|
56
|
+
* (the editor's) shows it and takes it away.
|
|
57
|
+
*/
|
|
58
|
+
private renderRow;
|
|
51
59
|
private renderMotion;
|
|
52
60
|
private setKind;
|
|
53
61
|
private command;
|
|
@@ -4,10 +4,24 @@ import { css, html, LitElement, nothing } from 'lit';
|
|
|
4
4
|
import { customElement, property, state } from 'lit/decorators.js';
|
|
5
5
|
import { i18next, localize } from '@operato/i18n';
|
|
6
6
|
import { V3_FACES } from '@hatiolab/figure-model';
|
|
7
|
-
import { acceptsOf, alignAxesOf, alignOf, AXES, canFasten, canFollow, dimensionNow, followableAxes, positionIsShare, choiceOf, fastenedTo, followOf, hasInstanceSize, holdsThings, leadersFor, motionOf, motionJoint, placeFacesOf, placeIdFor, placesOn, positionsWorkedOut } from './v3-links.js';
|
|
7
|
+
import { acceptsOf, alignAxesOf, alignOf, AXES, canFasten, facesOf, canFollow, dimensionNow, followableAxes, positionIsShare, choiceOf, fastenedTo, followOf, hasInstanceSize, holdsThings, leadersFor, motionOf, motionJoint, placeFacesOf, placeIdFor, placesOn, rowAxesOf, positionsWorkedOut } from './v3-links.js';
|
|
8
8
|
import { SidePanelStyles } from './side-panel-styles.js';
|
|
9
9
|
import { HelpToggles } from './v3-help.js';
|
|
10
10
|
const FACES = Object.keys(V3_FACES);
|
|
11
|
+
const freshFasten = () => ({
|
|
12
|
+
face: 'bottom',
|
|
13
|
+
toPlane: false,
|
|
14
|
+
target: '',
|
|
15
|
+
targetFace: 'top',
|
|
16
|
+
facing: 'meet',
|
|
17
|
+
gap: 0,
|
|
18
|
+
twist: 0,
|
|
19
|
+
align: {},
|
|
20
|
+
alignMm: {}
|
|
21
|
+
});
|
|
22
|
+
const freshMotion = () => ({ kind: 'slide', axis: 'x', id: '', min: 0, max: 500, follow: '', multiplier: 1, offset: 0 });
|
|
23
|
+
const freshRow = () => ({ axis: '', pitch: 200, fills: true, count: 5 });
|
|
24
|
+
const freshPlace = () => ({ role: 'grip', face: 'top', label: '', accepts: '', capacity: 1 });
|
|
11
25
|
/**
|
|
12
26
|
* What a part is tied to — its size response, what it is fastened to, how it moves (parity rows ④ ⑤ ⑥).
|
|
13
27
|
*
|
|
@@ -30,9 +44,10 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
30
44
|
this.locked = false;
|
|
31
45
|
/** Which ties this shows: the part's shape (size and placement, in 속성), its places (자리·능력) or its motion (움직임). */
|
|
32
46
|
this.section = 'shape';
|
|
33
|
-
this.fasten =
|
|
34
|
-
this.motion =
|
|
35
|
-
this.place =
|
|
47
|
+
this.fasten = freshFasten();
|
|
48
|
+
this.motion = freshMotion();
|
|
49
|
+
this.place = freshPlace();
|
|
50
|
+
this.row = freshRow();
|
|
36
51
|
}
|
|
37
52
|
static { this.styles = [SidePanelStyles, css `
|
|
38
53
|
:host {
|
|
@@ -52,6 +67,18 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
52
67
|
font-weight: 600;
|
|
53
68
|
}
|
|
54
69
|
`]; }
|
|
70
|
+
/*
|
|
71
|
+
The forms describe the next command for the selected part, so another part starts from the defaults: carrying the
|
|
72
|
+
last part's choices over fastened a shutter curtain level with a right edge nobody chose for it (2026-09-25).
|
|
73
|
+
*/
|
|
74
|
+
willUpdate(changed) {
|
|
75
|
+
if (changed.has('partId') && changed.get('partId') !== undefined) {
|
|
76
|
+
this.fasten = freshFasten();
|
|
77
|
+
this.motion = freshMotion();
|
|
78
|
+
this.place = freshPlace();
|
|
79
|
+
this.row = freshRow();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
55
82
|
render() {
|
|
56
83
|
const asset = this.asset;
|
|
57
84
|
if (!asset || !this.partId)
|
|
@@ -72,6 +99,7 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
72
99
|
${this.renderPositions(asset)}
|
|
73
100
|
${canFasten(asset, this.partId) ? this.renderFastening(asset) : html `<p hint>${i18next.t('figure.text.links-need-a-box')}</p>`}
|
|
74
101
|
</section>
|
|
102
|
+
${this.renderRow(asset)}
|
|
75
103
|
`;
|
|
76
104
|
}
|
|
77
105
|
/** Where things are held on this part: a seat things rest on, or a grip things are held in (parity row ⑤). */
|
|
@@ -209,6 +237,13 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
209
237
|
const to = fastenedTo(asset, this.partId);
|
|
210
238
|
const f = this.fasten;
|
|
211
239
|
const targets = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFasten(asset, one));
|
|
240
|
+
/* The faces are the ones each shape declares: a part is fastened by one of its outer faces, to any face of the target. */
|
|
241
|
+
const mineFaces = facesOf(asset, this.partId).filter(face => FACES.includes(face));
|
|
242
|
+
const myFace = mineFaces.includes(f.face) ? f.face : mineFaces[0];
|
|
243
|
+
const theirFaces = f.target ? facesOf(asset, f.target) : FACES;
|
|
244
|
+
const targetFace = theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0];
|
|
245
|
+
const aim = f.toPlane ? 'plane' : { part: f.target, face: targetFace };
|
|
246
|
+
const align = f.target || f.toPlane ? alignOf(asset, this.partId, aim, f.align, f.alignMm, !!f.twist) : undefined;
|
|
212
247
|
return html `
|
|
213
248
|
${to
|
|
214
249
|
? html `<div pair>
|
|
@@ -221,7 +256,7 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
221
256
|
<div form>
|
|
222
257
|
<span>${i18next.t('figure.label.my-face')}</span>
|
|
223
258
|
<select @change=${(e) => (this.fasten = { ...f, face: e.target.value })}>
|
|
224
|
-
${
|
|
259
|
+
${mineFaces.map(face => html `<option value=${face} ?selected=${face === myFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
|
|
225
260
|
</select>
|
|
226
261
|
</div>
|
|
227
262
|
<label toggle>
|
|
@@ -238,7 +273,7 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
238
273
|
</select>
|
|
239
274
|
<span>${i18next.t('figure.label.target-face')}</span>
|
|
240
275
|
<select @change=${(e) => (this.fasten = { ...f, targetFace: e.target.value })}>
|
|
241
|
-
${
|
|
276
|
+
${theirFaces.map(face => html `<option value=${face} ?selected=${face === targetFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
|
|
242
277
|
</select>
|
|
243
278
|
<span>${i18next.t('figure.label.facing-meet')}</span>
|
|
244
279
|
<select @change=${(e) => (this.fasten = { ...f, facing: e.target.value })}>
|
|
@@ -246,10 +281,12 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
246
281
|
<option value="flush" ?selected=${f.facing === 'flush'}>${i18next.t('figure.label.facing-flush')}</option>
|
|
247
282
|
</select>
|
|
248
283
|
</div>`}
|
|
249
|
-
${this.renderAlignment()}
|
|
284
|
+
${this.renderAlignment(f.toPlane || f.target ? alignAxesOf(asset, this.partId, aim, !!f.twist) : [])}
|
|
250
285
|
<div form>
|
|
251
286
|
<span>${i18next.t('figure.label.gap')}</span>
|
|
252
287
|
<input type="number" step="any" .value=${String(f.gap)} @change=${(e) => (this.fasten = { ...f, gap: Number(e.target.value) || 0 })} />
|
|
288
|
+
<span>${i18next.t('figure.label.twist')}</span>
|
|
289
|
+
<input type="number" step="any" .value=${String(f.twist)} @change=${(e) => (this.fasten = { ...f, twist: Number(e.target.value) || 0 })} />
|
|
253
290
|
</div>
|
|
254
291
|
<button
|
|
255
292
|
act
|
|
@@ -257,11 +294,12 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
257
294
|
@click=${() => this.command({
|
|
258
295
|
kind: 'attach',
|
|
259
296
|
part: this.partId,
|
|
260
|
-
face:
|
|
261
|
-
to: f.toPlane ? { plane: 'mounting-plane' } : { part: f.target, face:
|
|
297
|
+
face: myFace,
|
|
298
|
+
to: f.toPlane ? { plane: 'mounting-plane' } : { part: f.target, face: targetFace },
|
|
262
299
|
...(f.toPlane ? {} : { facing: f.facing }),
|
|
263
300
|
...(f.gap ? { gap: f.gap } : {}),
|
|
264
|
-
|
|
301
|
+
twist: f.twist,
|
|
302
|
+
...(align ? { align } : {}),
|
|
265
303
|
replace: !!to
|
|
266
304
|
})}
|
|
267
305
|
>
|
|
@@ -273,12 +311,12 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
273
311
|
* Where on the target face the part sits, on the two axes across it: centred, level with the target's edge on
|
|
274
312
|
* either side, or a distance off centre. A door hung level with the frame's front edge turns about that edge.
|
|
275
313
|
*/
|
|
276
|
-
renderAlignment() {
|
|
314
|
+
renderAlignment(axes) {
|
|
277
315
|
const f = this.fasten;
|
|
278
316
|
const set = (axis, how) => (this.fasten = { ...f, align: { ...f.align, [axis]: how } });
|
|
279
317
|
const setMm = (axis, mm) => (this.fasten = { ...f, alignMm: { ...f.alignMm, [axis]: mm } });
|
|
280
318
|
return html `<div form>
|
|
281
|
-
${
|
|
319
|
+
${axes.map(({ axis, edges }) => {
|
|
282
320
|
const how = f.align[axis] === 'min' || f.align[axis] === 'max' ? (edges ? f.align[axis] : 'centre') : (f.align[axis] ?? 'centre');
|
|
283
321
|
return html `<span>${i18next.t('figure.label.align-' + axis)}</span>
|
|
284
322
|
<select @change=${(e) => set(axis, e.target.value)}>
|
|
@@ -295,6 +333,47 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
295
333
|
})}
|
|
296
334
|
</div>`;
|
|
297
335
|
}
|
|
336
|
+
/**
|
|
337
|
+
* Making the part a row across what it is fastened to (figure-model `make-repeat`): along one of the axes across its
|
|
338
|
+
* face, copies a pitch apart filling the parent's length, or a count spread over it. Once made, the row's own section
|
|
339
|
+
* (the editor's) shows it and takes it away.
|
|
340
|
+
*/
|
|
341
|
+
renderRow(asset) {
|
|
342
|
+
const axes = rowAxesOf(asset, this.partId);
|
|
343
|
+
if (!axes.length)
|
|
344
|
+
return nothing;
|
|
345
|
+
const r = this.row;
|
|
346
|
+
const axis = axes.includes(r.axis) ? r.axis : axes[0];
|
|
347
|
+
const ok = r.pitch > 0 && (r.fills || (Number.isSafeInteger(r.count) && r.count >= 1));
|
|
348
|
+
return html `<section>
|
|
349
|
+
<h3>${i18next.t('figure.label.repeat')}</h3>
|
|
350
|
+
<div form>
|
|
351
|
+
<span>${i18next.t('figure.label.repeat-axis')}</span>
|
|
352
|
+
<select @change=${(e) => (this.row = { ...r, axis: e.target.value })}>
|
|
353
|
+
${axes.map(a => html `<option value=${a} ?selected=${a === axis}>${a.toUpperCase()}</option>`)}
|
|
354
|
+
</select>
|
|
355
|
+
<span>${i18next.t('figure.label.repeat-pitch')} (mm)</span>
|
|
356
|
+
<input type="number" step="any" min="0" .value=${String(r.pitch)} @change=${(e) => (this.row = { ...r, pitch: Number(e.target.value) })} />
|
|
357
|
+
</div>
|
|
358
|
+
<label toggle>
|
|
359
|
+
<input type="checkbox" .checked=${r.fills} @change=${(e) => (this.row = { ...r, fills: e.target.checked })} />
|
|
360
|
+
${i18next.t('figure.label.repeat-fills')}
|
|
361
|
+
</label>
|
|
362
|
+
${r.fills
|
|
363
|
+
? nothing
|
|
364
|
+
: html `<div form>
|
|
365
|
+
<span>${i18next.t('figure.label.repeat-count')}</span>
|
|
366
|
+
<input type="number" step="1" min="1" .value=${String(r.count)} @change=${(e) => (this.row = { ...r, count: Number(e.target.value) })} />
|
|
367
|
+
</div>`}
|
|
368
|
+
<button
|
|
369
|
+
act
|
|
370
|
+
?disabled=${this.locked || !ok}
|
|
371
|
+
@click=${() => this.command({ kind: 'make-repeat', part: this.partId, axis, pitch: r.pitch, ...(r.fills ? {} : { count: r.count }) })}
|
|
372
|
+
>
|
|
373
|
+
<md-icon>view_week</md-icon>${i18next.t('figure.button.make-repeat')}
|
|
374
|
+
</button>
|
|
375
|
+
</section>`;
|
|
376
|
+
}
|
|
298
377
|
renderMotion(asset) {
|
|
299
378
|
const now = motionOf(asset, this.partId);
|
|
300
379
|
const joint = asset.joints?.find(j => j.body1.part === this.partId);
|
|
@@ -412,6 +491,10 @@ __decorate([
|
|
|
412
491
|
state(),
|
|
413
492
|
__metadata("design:type", Object)
|
|
414
493
|
], FigureV3Links.prototype, "place", void 0);
|
|
494
|
+
__decorate([
|
|
495
|
+
state(),
|
|
496
|
+
__metadata("design:type", Object)
|
|
497
|
+
], FigureV3Links.prototype, "row", void 0);
|
|
415
498
|
FigureV3Links = __decorate([
|
|
416
499
|
customElement('figure-v3-links')
|
|
417
500
|
], FigureV3Links);
|