@things-factory/figure-ui 10.1.48 → 10.1.50
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 +89 -26
- package/client/modeller/figure-v3-parts.ts +86 -10
- package/client/modeller/figure-v3-properties.ts +2 -1
- package/client/modeller/side-panel-styles.ts +8 -0
- package/client/modeller/v3-canvas.ts +86 -12
- package/client/modeller/v3-editor.ts +244 -37
- package/client/modeller/v3-fields.ts +15 -1
- 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 +72 -5
- 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/modeller/v3-stage-model.ts +24 -0
- package/client/pages/figure-v3-modeller-page.ts +46 -7
- 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-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 +5 -0
- package/dist-client/modeller/figure-v3-links.js +81 -23
- package/dist-client/modeller/figure-v3-links.js.map +1 -1
- package/dist-client/modeller/figure-v3-parts.d.ts +13 -0
- package/dist-client/modeller/figure-v3-parts.js +81 -10
- package/dist-client/modeller/figure-v3-parts.js.map +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/side-panel-styles.js +8 -0
- package/dist-client/modeller/side-panel-styles.js.map +1 -1
- package/dist-client/modeller/v3-canvas.d.ts +13 -0
- package/dist-client/modeller/v3-canvas.js +88 -12
- 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 +32 -1
- package/dist-client/modeller/v3-editor.js +245 -35
- package/dist-client/modeller/v3-editor.js.map +1 -1
- package/dist-client/modeller/v3-fields.d.ts +6 -0
- package/dist-client/modeller/v3-fields.js +14 -2
- package/dist-client/modeller/v3-fields.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 +45 -2
- package/dist-client/modeller/v3-links.js +52 -3
- 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/modeller/v3-stage-model.d.ts +8 -0
- package/dist-client/modeller/v3-stage-model.js +26 -0
- package/dist-client/modeller/v3-stage-model.js.map +1 -1
- package/dist-client/pages/figure-v3-modeller-page.d.ts +4 -0
- package/dist-client/pages/figure-v3-modeller-page.js +45 -7
- 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 +334 -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 +38 -1
- package/test/v3-first-save.test.ts +75 -0
- package/test/v3-gizmo.test.ts +40 -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 +62 -14
- package/test/v3-live.test.ts +23 -4
- package/test/v3-maturity.test.ts +3 -7
- package/test/v3-refusals.test.ts +28 -0
- package/translations/en.json +43 -5
- package/translations/ja.json +42 -4
- package/translations/ko.json +42 -4
- package/translations/ms.json +42 -4
- package/translations/zh.json +42 -4
|
@@ -8,6 +8,7 @@ import type { V3RenderPart } from './v3-renderer.js'
|
|
|
8
8
|
import { DEFAULT_VIEW, sceneLook, WORKBENCH, type ViewSettings } from './figure-view.js'
|
|
9
9
|
import { boardFor, changedKeys, componentOf, gizmoResultOf, type Board } from './v3-stage-model.js'
|
|
10
10
|
import { JOINT_FAN } from './v3-joint-fan.js'
|
|
11
|
+
import { ownerOf } from './v3-fields.js'
|
|
11
12
|
|
|
12
13
|
/*
|
|
13
14
|
* The V3 modeller's canvas -- a things-scene board in editing mode, as the V2 modeller's was.
|
|
@@ -36,6 +37,7 @@ interface SceneLayer extends SceneComponent {
|
|
|
36
37
|
removeComponent?: (component: SceneComponent) => void
|
|
37
38
|
invalidate?: () => void
|
|
38
39
|
fitCameraToModel?: () => void
|
|
40
|
+
setGizmoMode?: (mode: 'translate' | 'rotate' | 'scale') => void
|
|
39
41
|
app?: unknown
|
|
40
42
|
}
|
|
41
43
|
interface SceneHandle {
|
|
@@ -152,6 +154,11 @@ export class FigureV3Canvas extends LitElement {
|
|
|
152
154
|
this.scene?.root?.fitCameraToModel?.()
|
|
153
155
|
}
|
|
154
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
|
+
|
|
155
162
|
/**
|
|
156
163
|
* Stand the board up for this figure, or bring the one standing up to date. A new board only for another figure or
|
|
157
164
|
* one that has outgrown it -- standing up again loses the selection and the camera.
|
|
@@ -160,7 +167,12 @@ export class FigureV3Canvas extends LitElement {
|
|
|
160
167
|
if (!this.stage) return
|
|
161
168
|
const needed = boardFor(this.parts, this.board)
|
|
162
169
|
const outgrown = !this.board || needed.width > this.board.width || needed.height > this.board.height || needed.depth > this.board.depth
|
|
163
|
-
if (this.scene && this.standingFor === this.context
|
|
170
|
+
if (this.scene && this.standingFor === this.context) {
|
|
171
|
+
/*
|
|
172
|
+
A part dragged past the board's edge grows the board where it stands. Standing it up again lost the selection,
|
|
173
|
+
the gizmo and the camera after every such drag -- the gizmo seemed not to work at all (user, 2026-09-24).
|
|
174
|
+
*/
|
|
175
|
+
if (outgrown) this.grow(needed)
|
|
164
176
|
this.sync()
|
|
165
177
|
return
|
|
166
178
|
}
|
|
@@ -214,6 +226,19 @@ export class FigureV3Canvas extends LitElement {
|
|
|
214
226
|
return found
|
|
215
227
|
}
|
|
216
228
|
|
|
229
|
+
/** A larger board under the same scene: its size, and the grid that suits it. Components are re-placed by `sync`. */
|
|
230
|
+
private grow(needed: Board & { depth: number }) {
|
|
231
|
+
const layer = this.scene?.root
|
|
232
|
+
this.board = needed
|
|
233
|
+
if (!layer) return
|
|
234
|
+
this.writing = true
|
|
235
|
+
try {
|
|
236
|
+
layer.set({ width: needed.width, height: needed.height, depth: needed.depth, gridStep: niceGridStep(Math.max(needed.width, needed.height)) })
|
|
237
|
+
} finally {
|
|
238
|
+
this.writing = false
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
217
242
|
/** Bring the components to the evaluation: remove the gone, write only what differs, add the new. */
|
|
218
243
|
private sync() {
|
|
219
244
|
const layer = this.scene?.root
|
|
@@ -251,34 +276,78 @@ export class FigureV3Canvas extends LitElement {
|
|
|
251
276
|
root.on('selected', (after: unknown) => {
|
|
252
277
|
const name = (after as SceneComponent[] | undefined)?.[0]?.state?.name
|
|
253
278
|
const id = typeof name === 'string' ? name : ''
|
|
254
|
-
if (id !== this.selected) this.dispatchEvent(new CustomEvent('part-selected', { detail: id, bubbles: true, composed: true }))
|
|
279
|
+
if (ownerOf(id) !== this.selected) this.dispatchEvent(new CustomEvent('part-selected', { detail: id, bubbles: true, composed: true }))
|
|
255
280
|
})
|
|
256
281
|
/*
|
|
257
282
|
A change comes up to the board with the component in the last argument's `origin` (the scene appends
|
|
258
283
|
{ origin, name, deliverer } as it bubbles). Reading the first argument as the component does nothing (V2).
|
|
259
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
|
+
*/
|
|
260
292
|
layer.on?.('change', (...args: unknown[]) => {
|
|
261
293
|
if (this.writing) return
|
|
262
294
|
const origin = (args[args.length - 1] as { origin?: SceneComponent } | undefined)?.origin
|
|
263
295
|
const name = origin?.state?.name
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
if (!result.position && !result.rotation && !result.scale) return
|
|
268
|
-
this.dispatchEvent(new CustomEvent('part-transformed', { detail: { id: part.id, result }, bubbles: true, composed: true }))
|
|
269
|
-
/* After the editor has applied (or refused) it: a refused change springs back to where the inputs put it. */
|
|
270
|
-
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)
|
|
271
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())
|
|
272
340
|
}
|
|
273
341
|
|
|
274
342
|
private showSelected() {
|
|
275
343
|
const layer = this.scene?.root
|
|
276
344
|
const root = layer?.root
|
|
277
345
|
if (!layer || !root) return
|
|
278
|
-
|
|
346
|
+
/* A repeat is selected whole: every copy of it (the editor selects the part the author made). */
|
|
347
|
+
const wanted = this.selected ? [...this.standing()].filter(([name]) => ownerOf(name) === this.selected).map(([, component]) => component) : []
|
|
279
348
|
const now = root.selected ?? []
|
|
280
|
-
if (
|
|
281
|
-
root.selected =
|
|
349
|
+
if (now.length === wanted.length && wanted.every((one, i) => now[i] === one)) return
|
|
350
|
+
root.selected = wanted
|
|
282
351
|
layer.invalidate?.()
|
|
283
352
|
}
|
|
284
353
|
|
|
@@ -291,6 +360,11 @@ export class FigureV3Canvas extends LitElement {
|
|
|
291
360
|
}
|
|
292
361
|
|
|
293
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)
|
|
294
368
|
this.scene?.dispose?.()
|
|
295
369
|
this.scene = undefined
|
|
296
370
|
this.standingFor = undefined
|
|
@@ -3,6 +3,7 @@ import { i18next } from '@operato/i18n'
|
|
|
3
3
|
import type { OxThreePane } from '@operato/layout'
|
|
4
4
|
import '@operato/layout'
|
|
5
5
|
import { customElement, property, state } from 'lit/decorators.js'
|
|
6
|
+
import { PART_LIMIT, v3PartCountOf } from '@hatiolab/figure-model'
|
|
6
7
|
import type { V3Asset, V3AuthoringAction, V3InputChange, V3PartEdit, V3PrimitiveCreation } from '@hatiolab/figure-model'
|
|
7
8
|
import type { V3ProductSession } from './v3-product-session.js'
|
|
8
9
|
import { captureV3Thumbnail } from './v3-thumbnail.js'
|
|
@@ -19,16 +20,22 @@ import { DEFAULT_VIEW, type ViewSettings } from './figure-view.js'
|
|
|
19
20
|
import './figure-v3-capability.js'
|
|
20
21
|
import { gizmoChanges, gizmoReachOf, type GizmoReach, type GizmoResult } from './v3-gizmo.js'
|
|
21
22
|
import { keyActionOf } from './v3-keys.js'
|
|
22
|
-
import { groupFields, partLabelOf, repeatOf, type FieldLike, type FieldGroup } from './v3-fields.js'
|
|
23
|
+
import { groupFields, ownerOf, partLabelOf, repeatOf, type FieldLike, type FieldGroup } from './v3-fields.js'
|
|
23
24
|
import { SidePanelStyles } from './side-panel-styles.js'
|
|
25
|
+
import { ScrollbarStyles } from '@operato/styles'
|
|
24
26
|
import { alignChanges, boxOf, canMirror, mirrorChanges, type AlignTool } from './v3-align.js'
|
|
25
27
|
import type { V3PaletteKind, V3PartRow } from './figure-v3-parts.js'
|
|
26
28
|
import { paletteRecipe } from './v3-palette.js'
|
|
27
29
|
import type { FigureV3Canvas } from './v3-canvas.js'
|
|
30
|
+
import { nudgeOf } from './v3-stage-model.js'
|
|
31
|
+
import { sizeRangeRefusalOf } from './v3-refusals.js'
|
|
32
|
+
import type { V3RenderPart } from './v3-renderer.js'
|
|
28
33
|
import type { V3BudgetLine } from './v3-maturity.js'
|
|
29
34
|
import { instanceRangeOf, rangeDiffers, turnsAboutAJoint } from './v3-links.js'
|
|
30
35
|
import { jointFanOf } from './v3-joint-fan.js'
|
|
31
|
-
import {
|
|
36
|
+
import { jointDragOf } from './v3-joint-drag.js'
|
|
37
|
+
import type { FirstSave } from './v3-first-save.js'
|
|
38
|
+
import { isLive, livePartsOf, liveOverSession, NOTHING_LIVE, scrubbed, scrubStep, type LiveValues } from './v3-live.js'
|
|
32
39
|
|
|
33
40
|
|
|
34
41
|
/**
|
|
@@ -41,7 +48,8 @@ type SideTab = 'properties' | 'capability' | 'motion' | 'verdict' | 'view' | 'hi
|
|
|
41
48
|
/** Product editor surface. The injected session is shared by page persistence and AI commands. */
|
|
42
49
|
@customElement('figure-v3-editor')
|
|
43
50
|
export class FigureV3Editor extends LitElement {
|
|
44
|
-
|
|
51
|
+
/* The operato scrollbar, as every scrolling panel of the app has it -- the side panel scrolled with the browser's own. */
|
|
52
|
+
static styles = [ScrollbarStyles, SidePanelStyles, css`
|
|
45
53
|
:host {
|
|
46
54
|
display: flex;
|
|
47
55
|
flex-direction: column;
|
|
@@ -223,6 +231,11 @@ export class FigureV3Editor extends LitElement {
|
|
|
223
231
|
@property({ type: Number }) instances?: number
|
|
224
232
|
/** The cost line the parts list shows, from the maturity report. */
|
|
225
233
|
@property({ attribute: false }) budget?: V3BudgetLine
|
|
234
|
+
/**
|
|
235
|
+
* Asks the type and name of a figure saved for the first time (the host has the catalogue's names); undefined
|
|
236
|
+
* when the author cancels, and then nothing is saved. Without it a new figure is saved under a temporary type.
|
|
237
|
+
*/
|
|
238
|
+
@property({ attribute: false }) firstSave?: (name: string) => Promise<FirstSave | undefined>
|
|
226
239
|
@state() private selected = ''
|
|
227
240
|
@state() private error = ''
|
|
228
241
|
@state() private tab: SideTab = 'properties'
|
|
@@ -259,14 +272,26 @@ export class FigureV3Editor extends LitElement {
|
|
|
259
272
|
/** A value in hand -- typed, held on a spinner, scrubbed, dragged -- drawn at once and recorded on release. */
|
|
260
273
|
@state() private live: LiveValues = NOTHING_LIVE
|
|
261
274
|
private lastContext: string | null = null
|
|
262
|
-
/** The modeller's keys (`v3-keys.ts`): undo, redo, duplicate, remove, back to the whole figure. */
|
|
275
|
+
/** The modeller's keys (`v3-keys.ts`): undo, redo, duplicate, remove, back to the whole figure, the gizmo's modes. */
|
|
263
276
|
private onKey = (e: KeyboardEvent) => {
|
|
264
277
|
const view = this.session?.read()
|
|
265
278
|
if (!view || view.loading || view.saving || !view.context || !this.isConnected) return
|
|
266
|
-
const
|
|
279
|
+
const canvas = this.renderRoot.querySelector<FigureV3Canvas>('figure-v3-canvas')
|
|
280
|
+
const path = e.composedPath()
|
|
281
|
+
const action = keyActionOf(
|
|
282
|
+
{ key: e.key, ctrlKey: e.ctrlKey, metaKey: e.metaKey, shiftKey: e.shiftKey, altKey: e.altKey, target: path[0] as HTMLElement, inScene: !!canvas && path.includes(canvas) },
|
|
283
|
+
this.selected
|
|
284
|
+
)
|
|
267
285
|
if (!action) return
|
|
268
286
|
e.preventDefault()
|
|
269
|
-
|
|
287
|
+
/*
|
|
288
|
+
The session's history is the only one. With the canvas focused the scene took Ctrl+Z as well, undoing on its own
|
|
289
|
+
board the last value the canvas had written -- which the canvas then told as a drag: an undo of a drag on a
|
|
290
|
+
converted part wrote the drag again (2026-09-25). Heard first (capture) and kept from the scene.
|
|
291
|
+
*/
|
|
292
|
+
e.stopPropagation()
|
|
293
|
+
if (action === 'translate' || action === 'rotate' || action === 'scale') canvas?.gizmo(action)
|
|
294
|
+
else if (action === 'undo') view.canUndo && this.run(() => this.session!.undo())
|
|
270
295
|
else if (action === 'redo') view.canRedo && this.run(() => this.session!.redo())
|
|
271
296
|
else if (action === 'duplicate') this.duplicatePart(this.selected)
|
|
272
297
|
else if (action === 'remove') this.removePart(this.selected)
|
|
@@ -274,10 +299,10 @@ export class FigureV3Editor extends LitElement {
|
|
|
274
299
|
}
|
|
275
300
|
connectedCallback() {
|
|
276
301
|
super.connectedCallback()
|
|
277
|
-
window.addEventListener('keydown', this.onKey)
|
|
302
|
+
window.addEventListener('keydown', this.onKey, true)
|
|
278
303
|
}
|
|
279
304
|
disconnectedCallback() {
|
|
280
|
-
window.removeEventListener('keydown', this.onKey)
|
|
305
|
+
window.removeEventListener('keydown', this.onKey, true)
|
|
281
306
|
super.disconnectedCallback()
|
|
282
307
|
}
|
|
283
308
|
protected willUpdate(_changed: PropertyValues) {
|
|
@@ -288,7 +313,7 @@ export class FigureV3Editor extends LitElement {
|
|
|
288
313
|
this.error = ''
|
|
289
314
|
this.hiddenParts = new Set()
|
|
290
315
|
}
|
|
291
|
-
if (view && !view.geometry.some(g => g.id === this.selected)) this.selected = ''
|
|
316
|
+
if (view && !view.geometry.some(g => ownerOf(g.id) === this.selected)) this.selected = ''
|
|
292
317
|
}
|
|
293
318
|
private run(action: () => unknown) {
|
|
294
319
|
try {
|
|
@@ -296,7 +321,7 @@ export class FigureV3Editor extends LitElement {
|
|
|
296
321
|
this.error = ''
|
|
297
322
|
this.changed()
|
|
298
323
|
} catch (error) {
|
|
299
|
-
this.error = error instanceof Error ? error.message : String(error)
|
|
324
|
+
this.error = sizeRangeRefusalOf(error, (key, params) => i18next.t(key, params)) ?? (error instanceof Error ? error.message : String(error))
|
|
300
325
|
} finally {
|
|
301
326
|
/* Committed or refused, the value in hand is gone: the figure as it stands is drawn again. */
|
|
302
327
|
this.live = NOTHING_LIVE
|
|
@@ -304,8 +329,8 @@ export class FigureV3Editor extends LitElement {
|
|
|
304
329
|
}
|
|
305
330
|
/** What the canvas draws: the figure with the value in hand when there is one, and without the parts hidden on the workbench. */
|
|
306
331
|
private drawnParts(geometry: Parameters<typeof drawableV3Parts>[0] | undefined, asset: unknown) {
|
|
307
|
-
const parts = (
|
|
308
|
-
return parts.filter(body => !this.hiddenParts.has((body as { id: string }).id))
|
|
332
|
+
const parts = (asset ? liveOverSession(asset as V3Asset, this.currentInputs(), this.live) : null) ?? drawableV3Parts(geometry ?? [])
|
|
333
|
+
return parts.filter(body => !this.hiddenParts.has(ownerOf((body as { id: string }).id)))
|
|
309
334
|
}
|
|
310
335
|
/**
|
|
311
336
|
* Where the selected part turns and how far, with the pose as it is drawn now -- a value in hand included, so the
|
|
@@ -314,11 +339,20 @@ export class FigureV3Editor extends LitElement {
|
|
|
314
339
|
private fanOf(asset: V3Asset | undefined) {
|
|
315
340
|
if (!asset || !this.selected || this.hiddenParts.has(this.selected)) return null
|
|
316
341
|
try {
|
|
317
|
-
return jointFanOf(asset, this.selected, this.live.values)
|
|
342
|
+
return jointFanOf(asset, this.selected, { ...this.currentInputs(), ...this.live.values })
|
|
318
343
|
} catch {
|
|
319
344
|
return null
|
|
320
345
|
}
|
|
321
346
|
}
|
|
347
|
+
/**
|
|
348
|
+
* Every input as the session holds it now -- the pose the author has set with the controls, and design values. The
|
|
349
|
+
* asset's source carries only the saved defaults, so drawing a value in hand over it put every other joint back to its
|
|
350
|
+
* saved angle while one slider moved, and the arm jumped when released (user, 2026-09-24).
|
|
351
|
+
*/
|
|
352
|
+
private currentInputs(): Record<string, number> {
|
|
353
|
+
const view = this.session?.read()
|
|
354
|
+
return view ? Object.fromEntries(view.fields.map(field => [field.id, field.value as number])) : {}
|
|
355
|
+
}
|
|
322
356
|
/** A value in hand: an input's value (design or pose) or a part edit. Nothing is recorded. */
|
|
323
357
|
private hold(values: Record<string, number>, edit?: V3PartEdit) {
|
|
324
358
|
this.live = { values: { ...this.live.values, ...values }, ...(edit ? { edit } : this.live.edit ? { edit: this.live.edit } : {}) }
|
|
@@ -335,9 +369,20 @@ export class FigureV3Editor extends LitElement {
|
|
|
335
369
|
not from the one on screen, which stands wherever the author left it turned. A picture that cannot
|
|
336
370
|
be taken is not an error: the save goes on without it (see `v3-thumbnail.ts`).
|
|
337
371
|
*/
|
|
372
|
+
let type: string | undefined
|
|
373
|
+
if (!this.session.read().id && this.firstSave) {
|
|
374
|
+
const answer = await this.firstSave(this.session.read().name ?? '')
|
|
375
|
+
if (!answer) return
|
|
376
|
+
/* The name is the figure's own, in the asset: one step of the history, like typing it in the properties. */
|
|
377
|
+
if (answer.name !== this.session.read().name) {
|
|
378
|
+
this.session.command({ kind: 'rename', name: answer.name })
|
|
379
|
+
this.changed()
|
|
380
|
+
}
|
|
381
|
+
type = answer.type
|
|
382
|
+
}
|
|
338
383
|
const view = this.session.read()
|
|
339
384
|
const thumbnail = await captureV3Thumbnail(drawableV3Parts(view.geometry ?? []), view.palette ?? {})
|
|
340
|
-
const promise = this.session.save({ thumbnail })
|
|
385
|
+
const promise = this.session.save({ thumbnail, type })
|
|
341
386
|
this.changed()
|
|
342
387
|
await promise
|
|
343
388
|
this.error = ''
|
|
@@ -397,7 +442,8 @@ export class FigureV3Editor extends LitElement {
|
|
|
397
442
|
/** What the gizmo may write for the selected part — read off the asset being edited. */
|
|
398
443
|
private reachOf(id: string): GizmoReach {
|
|
399
444
|
const none = { translate: false, rotate: false, scale: false }
|
|
400
|
-
|
|
445
|
+
/* A repeat is moved and sized through its row, not by a gizmo on one copy. */
|
|
446
|
+
if (!id || !this.session || !this.session.read().geometry.some(g => g.id === id)) return none
|
|
401
447
|
try {
|
|
402
448
|
return gizmoReachOf(JSON.parse(this.session.source()), id)
|
|
403
449
|
} catch {
|
|
@@ -407,10 +453,68 @@ export class FigureV3Editor extends LitElement {
|
|
|
407
453
|
/** A drag, written back as the part's own inputs — one step of the history, like typing the numbers. */
|
|
408
454
|
private transformed(id: string, result: GizmoResult) {
|
|
409
455
|
const view = this.session!.read()
|
|
456
|
+
/* A part hanging from a joint: the drag edits the joint -- its value, or where it sits (ADR-0093). */
|
|
457
|
+
const asset = JSON.parse(this.session!.source()) as V3Asset
|
|
458
|
+
const drawn = view.geometry.find(g => g.id === id) as unknown as V3RenderPart | undefined
|
|
459
|
+
const drag = drawn ? jointDragOf(asset, ownerOf(id), drawn, result, this.currentInputs()) : null
|
|
460
|
+
if (drag) {
|
|
461
|
+
if (drag.kind === 'value') this.run(() => this.session!.apply([{ kind: 'set-state', input: drag.input, unit: drag.unit, value: drag.value }]))
|
|
462
|
+
else if (drag.kind === 'joint') this.run(() => this.session!.command({ kind: 'set-joint', joint: drag.joint }))
|
|
463
|
+
else {
|
|
464
|
+
this.error = i18next.t(`figure.text.gizmo-joint-${drag.reason}`, { joint: drag.joint, leader: drag.leader ?? '', axis: drag.axis ?? '' })
|
|
465
|
+
this.requestUpdate()
|
|
466
|
+
}
|
|
467
|
+
return
|
|
468
|
+
}
|
|
410
469
|
const sizes = Object.fromEntries(view.fields.map(field => [field.id, field.value as number]))
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
470
|
+
/*
|
|
471
|
+
A part a joint places, scaled: only its measures change. The shift of its centre that an anchored scale makes is
|
|
472
|
+
the joint's to give -- the face on the joint stays there by itself (user, 2026-09-25: 「'R'모드에서 크기가 변하지 않는다」;
|
|
473
|
+
the shift had been taken for a move of the joint, and the size was dropped).
|
|
474
|
+
*/
|
|
475
|
+
const jointed = !!asset.joints?.some(j => j.body1.part === ownerOf(id))
|
|
476
|
+
const changes = gizmoChanges(id, this.reachOf(id), jointed && result.scale ? { scale: result.scale } : result, sizes)
|
|
477
|
+
if (changes.length) {
|
|
478
|
+
this.run(() => this.session!.apply(changes))
|
|
479
|
+
return
|
|
480
|
+
}
|
|
481
|
+
if (jointed && result.scale) {
|
|
482
|
+
/* Only measures that follow another were pulled: the relation says their size, and it is not broken here. */
|
|
483
|
+
const axes = (['x', 'y', 'z'] as const).filter((_, i) => Math.abs(result.scale![i]! - 1) > 1e-4)
|
|
484
|
+
this.error = i18next.t('figure.text.gizmo-size-follows', { part: partLabelOf(id), axis: axes.join(', ').toUpperCase() })
|
|
485
|
+
return this.requestUpdate()
|
|
486
|
+
}
|
|
487
|
+
/*
|
|
488
|
+
A part whose place is worked out (a converted one) has no numbers for the gizmo to write: the drag is added beside
|
|
489
|
+
its rule (`nudge-part`). A part a joint or a fastening places, or a size that is worked out, is refused -- said in
|
|
490
|
+
words, not by the part silently springing back (user, 2026-09-24: 「기즈모 컨트롤도 전혀 작동이 안되는군」).
|
|
491
|
+
*/
|
|
492
|
+
const body = view.geometry.find(g => g.id === id) as unknown as V3RenderPart | undefined
|
|
493
|
+
if (!body) return this.requestUpdate()
|
|
494
|
+
const { by, turn } = nudgeOf(body, result)
|
|
495
|
+
/* A size worked out from the figure's: the drag's factor beside its rule, so it still follows (V2 scaled any part). */
|
|
496
|
+
const grow: Partial<Record<'x' | 'y' | 'z', number>> = {}
|
|
497
|
+
if (result.scale) (['x', 'y', 'z'] as const).forEach((axis, i) => Math.abs(result.scale![i]! - 1) > 1e-6 && (grow[axis] = Math.round(result.scale![i]! * 1e6) / 1e6))
|
|
498
|
+
if (!Object.keys(by).length && !Object.keys(turn).length && !Object.keys(grow).length) return this.requestUpdate()
|
|
499
|
+
try {
|
|
500
|
+
this.session!.command({
|
|
501
|
+
kind: 'nudge-part',
|
|
502
|
+
part: id,
|
|
503
|
+
...(Object.keys(by).length ? { by } : {}),
|
|
504
|
+
...(Object.keys(turn).length ? { turn } : {}),
|
|
505
|
+
...(Object.keys(grow).length ? { grow } : {})
|
|
506
|
+
})
|
|
507
|
+
this.error = ''
|
|
508
|
+
this.changed()
|
|
509
|
+
} catch (error) {
|
|
510
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
511
|
+
this.error = /carried by a joint/.test(message)
|
|
512
|
+
? i18next.t('figure.text.gizmo-joint-places', { part: partLabelOf(id) })
|
|
513
|
+
: /is fastened to/.test(message)
|
|
514
|
+
? i18next.t('figure.text.gizmo-fastening-places', { part: partLabelOf(id) })
|
|
515
|
+
: message
|
|
516
|
+
this.requestUpdate()
|
|
517
|
+
}
|
|
414
518
|
}
|
|
415
519
|
/** 맞추기 — one tool, one step of the history, through the same inputs as the gizmo. */
|
|
416
520
|
private align(tool: AlignTool) {
|
|
@@ -473,7 +577,7 @@ export class FigureV3Editor extends LitElement {
|
|
|
473
577
|
*/
|
|
474
578
|
private renderRepeatNote(geometry: readonly unknown[] | undefined) {
|
|
475
579
|
const repeat = repeatOf(this.selected, geometry)
|
|
476
|
-
return repeat ? html`<p hint>${i18next.t('figure.text.repeat-member', { name: repeat.name, count: repeat.count, index: repeat.index })}</p>` : nothing
|
|
580
|
+
return repeat?.index ? html`<p hint>${i18next.t('figure.text.repeat-member', { name: repeat.name, count: repeat.count, index: repeat.index })}</p>` : nothing
|
|
477
581
|
}
|
|
478
582
|
private renamePart(input: HTMLInputElement) {
|
|
479
583
|
const name = input.value.trim()
|
|
@@ -561,9 +665,7 @@ export class FigureV3Editor extends LitElement {
|
|
|
561
665
|
? i18next.t('figure.text.follow-would-loop')
|
|
562
666
|
: code === 'FASTEN_CYCLE' || code === 'ATTACH_CYCLE'
|
|
563
667
|
? i18next.t('figure.text.fasten-would-loop')
|
|
564
|
-
: error instanceof Error
|
|
565
|
-
? error.message
|
|
566
|
-
: String(error)
|
|
668
|
+
: sizeRangeRefusalOf(error, (key, params) => i18next.t(key, params)) ?? (error instanceof Error ? error.message : String(error))
|
|
567
669
|
}
|
|
568
670
|
}
|
|
569
671
|
private changeField(field: { id: string; unit: V3InputChange['unit']; role?: string }, event: Event) {
|
|
@@ -672,9 +774,117 @@ export class FigureV3Editor extends LitElement {
|
|
|
672
774
|
<md-icon>aspect_ratio</md-icon>${i18next.t('figure.button.scale-uniformly')}
|
|
673
775
|
</button>`
|
|
674
776
|
}
|
|
777
|
+
/**
|
|
778
|
+
* The parts list, one row per part the author made: a repeat is one row with how many copies it makes now (V2 listed
|
|
779
|
+
* its parts the same way; user, 2026-09-24: the conveyor's sixteen rollers were sixteen rows).
|
|
780
|
+
*/
|
|
781
|
+
private partRows(geometry: readonly { id: string }[]): V3PartRow[] {
|
|
782
|
+
const rows = new Map<string, V3PartRow>()
|
|
783
|
+
for (const g of geometry) {
|
|
784
|
+
const id = ownerOf(g.id)
|
|
785
|
+
const row = rows.get(id)
|
|
786
|
+
if (row) {
|
|
787
|
+
row.copies = (row.copies ?? 1) + 1
|
|
788
|
+
continue
|
|
789
|
+
}
|
|
790
|
+
rows.set(id, {
|
|
791
|
+
id,
|
|
792
|
+
provider: (g as { shape?: { provider?: string } }).shape?.provider ?? '',
|
|
793
|
+
hidden: this.hiddenParts.has(id),
|
|
794
|
+
invisible: (g as { visible?: boolean }).visible === false,
|
|
795
|
+
...(id !== g.id ? { copies: 1 } : {})
|
|
796
|
+
})
|
|
797
|
+
}
|
|
798
|
+
return [...rows.values()]
|
|
799
|
+
}
|
|
800
|
+
/** The detail level's part budget for the list's head, from figure-model's count (a repeat once). */
|
|
801
|
+
private partBudgetOf(asset: V3Asset | undefined) {
|
|
802
|
+
if (!asset) return undefined
|
|
803
|
+
try {
|
|
804
|
+
return { level: asset.detailLevel ?? null, used: v3PartCountOf(asset), limit: asset.detailLevel ? PART_LIMIT[asset.detailLevel] : null }
|
|
805
|
+
} catch {
|
|
806
|
+
return undefined
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* A repeat: which way its copies run, how many there are now, and how far apart -- V2's `repeat: { axis, pitch }`.
|
|
811
|
+
* The pitch of a row that fills its length is edited as a number (`set-repeat` makes it the part's own the first
|
|
812
|
+
* time); a row of a fixed count says its pitch cannot be set yet (figure-model refuses it the same way).
|
|
813
|
+
*/
|
|
814
|
+
private renderRepeat(asset: V3Asset | undefined, geometry: readonly { id: string }[], unavailable: boolean) {
|
|
815
|
+
const repeat = repeatOf(this.selected, geometry)
|
|
816
|
+
if (!asset || !repeat || repeat.index) return nothing
|
|
817
|
+
const model = asset.document.model as unknown as {
|
|
818
|
+
nodes: { id: string; op: string; args: string[]; params?: Record<string, string> }[]
|
|
819
|
+
constants: { id: string; value: number }[]
|
|
820
|
+
inputs: { id: string; max?: number }[]
|
|
821
|
+
}
|
|
822
|
+
const layout = model.nodes.find(n => n.id === `${this.selected}.layout`)
|
|
823
|
+
const axis = model.nodes.find(n => n.id === `${this.selected}.repeat`)?.params?.axis ?? '?'
|
|
824
|
+
const own = `${this.selected}.repeat.pitch`
|
|
825
|
+
const pitch = asset.designInputs[own] ?? model.constants.find(c => c.id === layout?.args[4])?.value
|
|
826
|
+
const fits = layout?.op === 'fit-pitch@1'
|
|
827
|
+
/* What the pitch gives, in numbers: this many copies at the length it is authored, and how long it may be placed. */
|
|
828
|
+
const lengthRef = layout?.args[0] ?? ''
|
|
829
|
+
const length = asset.designInputs[lengthRef]
|
|
830
|
+
const longest = model.inputs.find(i => i.id === lengthRef)?.max
|
|
831
|
+
const limit = model.constants.find(c => c.id === layout?.args[6])?.value
|
|
832
|
+
const field = { id: own, unit: 'mm' as const, role: 'design', value: pitch ?? 0 }
|
|
833
|
+
return html`<section>
|
|
834
|
+
<h3>${i18next.t('figure.label.repeat')}</h3>
|
|
835
|
+
<div inline-field><label>${i18next.t('figure.label.repeat-axis')}</label><span value>${axis.toUpperCase()}</span></div>
|
|
836
|
+
<div inline-field><label>${i18next.t('figure.label.repeat-count')}</label><span value>${repeat.count}</span></div>
|
|
837
|
+
<div inline-field>
|
|
838
|
+
<label>${i18next.t('figure.label.repeat-gap')} <span unit>mm</span></label>
|
|
839
|
+
<input
|
|
840
|
+
type="number"
|
|
841
|
+
step="any"
|
|
842
|
+
aria-label=${own}
|
|
843
|
+
.value=${String(pitch ?? '')}
|
|
844
|
+
?disabled=${unavailable || !fits}
|
|
845
|
+
@change=${(e: Event) => this.setPitch(field, e)}
|
|
846
|
+
/>
|
|
847
|
+
</div>
|
|
848
|
+
<p hint>
|
|
849
|
+
${fits && length !== undefined && longest !== undefined && limit !== undefined
|
|
850
|
+
? i18next.t('figure.text.repeat-what-pitch-gives', {
|
|
851
|
+
length: length.toLocaleString(),
|
|
852
|
+
count: repeat.count,
|
|
853
|
+
longest: longest.toLocaleString(),
|
|
854
|
+
limit
|
|
855
|
+
})
|
|
856
|
+
: i18next.t(fits ? 'figure.text.repeat-fits-length' : 'figure.text.repeat-fixed-count')}
|
|
857
|
+
</p>
|
|
858
|
+
</section>`
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* A pitch always goes through `set-repeat`: the command moves the longest length with it (at most `limit` copies),
|
|
862
|
+
* which a plain input edit would not. Refused only when the authored length would already hold more copies than
|
|
863
|
+
* that -- said with the numbers.
|
|
864
|
+
*/
|
|
865
|
+
private setPitch(field: { id: string; value: number }, event: Event) {
|
|
866
|
+
const input = event.target as HTMLInputElement
|
|
867
|
+
const pitch = input.valueAsNumber
|
|
868
|
+
if (!Number.isFinite(pitch) || pitch === field.value) return
|
|
869
|
+
try {
|
|
870
|
+
this.session!.command({ kind: 'set-repeat', part: this.selected, pitch })
|
|
871
|
+
this.error = ''
|
|
872
|
+
this.changed()
|
|
873
|
+
} catch (error) {
|
|
874
|
+
input.value = String(field.value)
|
|
875
|
+
const code = (error as { code?: string })?.code
|
|
876
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
877
|
+
const found = /\((\d[\d.]*) mm\) a pitch under ([\d.]+) mm makes more than (\d+)/.exec(message)
|
|
878
|
+
this.error =
|
|
879
|
+
code === 'INPUT_RANGE' && found
|
|
880
|
+
? i18next.t('figure.text.repeat-pitch-too-small', { length: Number(found[1]).toLocaleString(), min: found[2], limit: found[3] })
|
|
881
|
+
: message
|
|
882
|
+
}
|
|
883
|
+
}
|
|
675
884
|
/** Choose a part, from the list, the canvas or a place row. A figure-level tab gives way to the part's properties. */
|
|
676
885
|
private choose(id: string) {
|
|
677
|
-
|
|
886
|
+
/* A copy on the canvas selects its repeat: the part the author made. */
|
|
887
|
+
this.selected = id ? ownerOf(id) : id
|
|
678
888
|
if (id && (this.tab === 'verdict' || this.tab === 'view' || this.tab === 'history')) this.tab = 'properties'
|
|
679
889
|
}
|
|
680
890
|
private asset(): unknown {
|
|
@@ -707,11 +917,12 @@ export class FigureV3Editor extends LitElement {
|
|
|
707
917
|
const view = this.session?.read()
|
|
708
918
|
if (!view) return html`<p>${i18next.t('figure.text.opening-model')}</p>`
|
|
709
919
|
const unavailable = view.loading || !!view.error || view.saving || !view.context
|
|
710
|
-
const part = view.geometry.find(g => g.id === this.selected)
|
|
920
|
+
const part = view.geometry.find(g => ownerOf(g.id) === this.selected)
|
|
711
921
|
/* The controls (state inputs) have their own place in the motion tab; these are the design values. */
|
|
712
922
|
const design = view.fields.filter(i => i.role !== 'state')
|
|
713
|
-
|
|
714
|
-
const
|
|
923
|
+
/* A repeat's own row values (its pitch) have their own section, not the shape's. */
|
|
924
|
+
const own = design.filter(i => this.selected && i.id.startsWith(this.selected + '.') && !i.id.startsWith(this.selected + '.repeat.'))
|
|
925
|
+
const common = design.filter(i => !view.geometry.some(g => i.id.startsWith(ownerOf(g.id) + '.')))
|
|
715
926
|
const status = view.loading
|
|
716
927
|
? 'figure.text.loading-figure'
|
|
717
928
|
: view.saving
|
|
@@ -747,21 +958,16 @@ export class FigureV3Editor extends LitElement {
|
|
|
747
958
|
slot="start"
|
|
748
959
|
.budget=${this.budget}
|
|
749
960
|
@show-maturity=${() => (this.tab = 'verdict')}
|
|
750
|
-
.parts=${view.geometry
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
provider: (g as { shape?: { provider?: string } }).shape?.provider ?? '',
|
|
754
|
-
hidden: this.hiddenParts.has(g.id),
|
|
755
|
-
invisible: (g as { visible?: boolean }).visible === false
|
|
756
|
-
})
|
|
757
|
-
)}
|
|
961
|
+
.parts=${this.partRows(view.geometry)}
|
|
962
|
+
.partBudget=${this.partBudgetOf(asset as V3Asset | undefined)}
|
|
963
|
+
@set-detail-level=${(e: CustomEvent) => this.linkCommand({ kind: 'set-detail-level', level: e.detail.level })}
|
|
758
964
|
.selected=${this.selected}
|
|
759
965
|
?locked=${unavailable}
|
|
760
966
|
@add-part=${(e: CustomEvent) => this.add(e.detail.kind)}
|
|
761
967
|
@select-part=${(e: CustomEvent) => this.choose(e.detail.id)}
|
|
762
968
|
@toggle-part-hidden=${(e: CustomEvent) => this.toggleHiddenPart(e.detail.id)}
|
|
763
969
|
@set-all-parts-hidden=${(e: CustomEvent) =>
|
|
764
|
-
(this.hiddenParts = e.detail.hidden ? new Set(view.geometry.map(g => g.id)) : new Set())}
|
|
970
|
+
(this.hiddenParts = e.detail.hidden ? new Set(view.geometry.map(g => ownerOf(g.id))) : new Set())}
|
|
765
971
|
@move-part-up=${(e: CustomEvent) => this.movePart(e.detail.id, e.detail.to)}
|
|
766
972
|
@move-part-down=${(e: CustomEvent) => this.movePart(e.detail.id, e.detail.to)}
|
|
767
973
|
@duplicate-part=${(e: CustomEvent) => this.duplicatePart(e.detail.id)}
|
|
@@ -815,10 +1021,11 @@ export class FigureV3Editor extends LitElement {
|
|
|
815
1021
|
</div>
|
|
816
1022
|
${this.renderRepeatNote(view.geometry)}
|
|
817
1023
|
</section>
|
|
818
|
-
${this.
|
|
1024
|
+
${this.renderRepeat(asset as V3Asset | undefined, view.geometry, unavailable)}
|
|
1025
|
+
${this.renderGroups(groupFields(own, this.selected), 'figure.label.group-placement-size', 'figure.label.group-shape', unavailable, this.renderTools(unavailable))}
|
|
819
1026
|
${this.renderLinks(unavailable, 'shape')}
|
|
820
1027
|
<figure-v3-material
|
|
821
|
-
.body=${part}
|
|
1028
|
+
.body=${{ ...part, id: this.selected }}
|
|
822
1029
|
.palette=${view.palette ?? {}}
|
|
823
1030
|
?locked=${unavailable}
|
|
824
1031
|
@v3-part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
|
|
@@ -66,7 +66,11 @@ export function partLabelOf(id: string): string {
|
|
|
66
66
|
*/
|
|
67
67
|
export function repeatOf(id: string, geometry: readonly unknown[] | undefined): { name: string; count: number; index: number } | null {
|
|
68
68
|
const path = pathOf(id)
|
|
69
|
-
if (!path)
|
|
69
|
+
if (!path) {
|
|
70
|
+
/* The repeat itself, by its name: how many copies it makes now. */
|
|
71
|
+
const copies = (geometry ?? []).filter(g => pathOf((g as { id?: string }).id ?? '')?.[0] === `${id}.repeat`).length
|
|
72
|
+
return copies ? { name: id, count: copies, index: 0 } : null
|
|
73
|
+
}
|
|
70
74
|
const count = (geometry ?? []).filter(g => pathOf((g as { id?: string }).id ?? '')?.[0] === path[0]).length
|
|
71
75
|
const index = path.find((step): step is number => typeof step === 'number')
|
|
72
76
|
return { name: String(path[0]).replace(/\.repeat$/, ''), count, index: (index ?? 0) + 1 }
|
|
@@ -82,3 +86,13 @@ function pathOf(id: string): unknown[] | null {
|
|
|
82
86
|
}
|
|
83
87
|
}
|
|
84
88
|
|
|
89
|
+
/**
|
|
90
|
+
* The authored part a body belongs to: a repeat's copy belongs to its repeat (`roller`), any other body is its own. The
|
|
91
|
+
* editor selects, lists, hides and names parts by this -- what the author made, not every body the kernel draws
|
|
92
|
+
* (user, 2026-09-24: the conveyor's sixteen rollers were sixteen rows).
|
|
93
|
+
*/
|
|
94
|
+
export function ownerOf(id: string): string {
|
|
95
|
+
const path = pathOf(id)
|
|
96
|
+
return path ? String(path[0]).replace(/\.repeat$/, '') : id
|
|
97
|
+
}
|
|
98
|
+
|