@things-factory/figure-ui 10.1.150 → 10.1.152

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.
Files changed (44) hide show
  1. package/client/modeller/editor.ts +15 -15
  2. package/client/modeller/fasten-geometry.ts +32 -0
  3. package/client/modeller/figure-links.ts +32 -14
  4. package/client/modeller/mesh-import.ts +0 -10
  5. package/client/modeller/refusals.ts +14 -1
  6. package/dist-client/modeller/editor.js +16 -16
  7. package/dist-client/modeller/editor.js.map +1 -1
  8. package/dist-client/modeller/fasten-geometry.d.ts +13 -0
  9. package/dist-client/modeller/fasten-geometry.js +29 -0
  10. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  11. package/dist-client/modeller/figure-links.d.ts +9 -2
  12. package/dist-client/modeller/figure-links.js +35 -14
  13. package/dist-client/modeller/figure-links.js.map +1 -1
  14. package/dist-client/modeller/mesh-import.d.ts +0 -2
  15. package/dist-client/modeller/mesh-import.js +0 -11
  16. package/dist-client/modeller/mesh-import.js.map +1 -1
  17. package/dist-client/modeller/product-session.d.ts +4 -28
  18. package/dist-client/modeller/refusals.d.ts +2 -1
  19. package/dist-client/modeller/refusals.js +17 -1
  20. package/dist-client/modeller/refusals.js.map +1 -1
  21. package/dist-client/tsconfig.tsbuildinfo +1 -1
  22. package/package.json +6 -6
  23. package/test/browser/build.mjs +7 -2
  24. package/test/browser/editing.check.mjs +14 -0
  25. package/test/browser/fix-alignment.check.mjs +101 -0
  26. package/test/browser/links.check.mjs +34 -5
  27. package/test/browser/mesh-select.check.mjs +91 -0
  28. package/test/browser/modeller-page.js +36 -0
  29. package/test/browser/modeller-revision.check.mjs +18 -6
  30. package/test/browser/part-ids.check.mjs +104 -0
  31. package/test/browser/parts.check.mjs +39 -2
  32. package/test/browser/row-preview.check.mjs +81 -0
  33. package/test/browser/screen.check.mjs +2 -1
  34. package/test/fields.test.ts +5 -9
  35. package/test/fix-alignment.test.ts +32 -0
  36. package/test/mesh-import.test.ts +1 -8
  37. package/test/parts-wiring.test.ts +1 -5
  38. package/test/refusals.test.ts +28 -0
  39. package/translations/en.json +8 -0
  40. package/translations/es.json +8 -0
  41. package/translations/ja.json +8 -0
  42. package/translations/ko.json +8 -0
  43. package/translations/ms.json +8 -0
  44. package/translations/zh.json +8 -0
@@ -6,7 +6,7 @@ import { notify, openPopup } from '@operato/layout'
6
6
  import { OxPrompt } from '@operato/popup'
7
7
  import { customElement, property, state } from 'lit/decorators.js'
8
8
  import { live } from 'lit/directives/live.js'
9
- import { PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figureRecipeIdFor, figureSizeEditChanges, figureScaleAnchorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, type FigurePlaneContact, type FigureClipLoss, figurePartFaceOf, type FigureFaceArea, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang, figureRoomOnlyInputsOf, figureRoomRefitOf } from '@hatiolab/figure-model'
9
+ import { PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figurePartIdFor, figureRecipeIdFor, figureSizeEditChanges, figureScaleAnchorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, type FigurePlaneContact, type FigureClipLoss, figurePartFaceOf, type FigureFaceArea, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang, figureRoomOnlyInputsOf, figureRoomRefitOf } from '@hatiolab/figure-model'
10
10
  import type { RecipeTemplate } from './recipes.js'
11
11
  import './figure-recipe-catalog.js'
12
12
  import type { FigureAsset, FigureAuthoringAction, FigureEvaluatedShape, FigureInputChange, FigurePartEdit, FigurePrimitiveCreation, FigureProposalStep, FigureRecipe, FigureRecipeValue, FigurePlacement, FigureFaceMappingNeed, FigureFaceRemap } from '@hatiolab/figure-model'
@@ -35,7 +35,7 @@ import { ScrollbarStyles } from '@operato/styles'
35
35
  import { alignChanges, canMirror, mirrorChanges, type AlignTool } from './align.js'
36
36
  import type { FigurePaletteKind, FigurePartRow } from './figure-parts.js'
37
37
  import { paletteRecipe } from './palette.js'
38
- import { meshChoicesOf, meshPartIdOf, meshPartOf } from './mesh-import.js'
38
+ import { meshChoicesOf, meshPartOf } from './mesh-import.js'
39
39
  import './figure-mesh-choice.js'
40
40
  import { uploadFigureMesh } from '../graphql/index.js'
41
41
  import { slotMarksOf } from './slot-layout-host.js'
@@ -803,10 +803,8 @@ export class FigureEditor extends LitElement {
803
803
  private add(kind: FigurePaletteKind) {
804
804
  if (!this.session) return
805
805
  this.run(() => {
806
- const ids = new Set(this.session!.read().geometry.map(g => g.id))
807
- let n = 1
808
- while (ids.has(`${kind}-${n}`)) n++
809
- const part = paletteRecipe(kind, `${kind}-${n}`)
806
+ /* The id is figure-model's to choose: what is in use is its rule, not a list the screen keeps. */
807
+ const part = paletteRecipe(kind, figurePartIdFor(this.session!.revisionNow()!.asset, kind))
810
808
  this.session!.add(part)
811
809
  this.selected = part.id
812
810
  })
@@ -838,8 +836,7 @@ export class FigureEditor extends LitElement {
838
836
  if (!choice) return
839
837
  const node = upload.summary.nodes.find(n => n.index === (choice as { node?: number }).node)
840
838
  this.run(() => {
841
- const taken = new Set(this.session!.read().geometry.map(g => g.id))
842
- const id = meshPartIdOf(node?.name ?? file.name.replace(/\.glb$/i, ''), taken)
839
+ const id = figurePartIdFor(this.session!.revisionNow()!.asset, node?.name ?? file.name.replace(/\.glb$/i, ''))
843
840
  this.session!.add(meshPartOf(upload, choice as { node?: number }, id))
844
841
  this.selected = id
845
842
  })
@@ -850,9 +847,8 @@ export class FigureEditor extends LitElement {
850
847
  */
851
848
  private duplicatePart(id: string) {
852
849
  this.run(() => {
853
- const before = new Set(this.session!.read().geometry.map(g => g.id))
854
- this.session!.edit({ kind: 'duplicate-part', target: id })
855
- this.selected = this.session!.read().geometry.find(g => !before.has(g.id))?.id ?? id
850
+ /* The copy is the part the edit made, as figure-model named it. */
851
+ this.selected = this.session!.edit({ kind: 'duplicate-part', target: id }).made?.[0] ?? id
856
852
  })
857
853
  }
858
854
  private movePart(id: string, to: number) {
@@ -1032,9 +1028,7 @@ export class FigureEditor extends LitElement {
1032
1028
  private mirror(axis: 'x' | 'z') {
1033
1029
  this.run(() => {
1034
1030
  const id = this.selected
1035
- const before = new Set(this.session!.read().geometry.map(g => g.id))
1036
- this.session!.edit({ kind: 'duplicate-part', target: id })
1037
- const copy = this.session!.read().geometry.find(g => !before.has(g.id))?.id
1031
+ const copy = this.session!.edit({ kind: 'duplicate-part', target: id }).made?.[0]
1038
1032
  if (!copy) return
1039
1033
  this.session!.apply(mirrorChanges(this.shown()!, id, copy, axis))
1040
1034
  this.selected = copy
@@ -1657,8 +1651,14 @@ export class FigureEditor extends LitElement {
1657
1651
  return
1658
1652
  }
1659
1653
  const view = this.session.read()
1660
- const same = this.preview && this.preview.key === JSON.stringify(candidate) && this.preview.context === view.context && this.preview.part === this.selected && 'revision' in this.preview && this.preview.revision === view.revision
1654
+ const key = JSON.stringify(candidate)
1655
+ const same = this.preview && this.preview.key === key && this.preview.context === view.context && this.preview.part === this.selected && 'revision' in this.preview && this.preview.revision === view.revision
1661
1656
  if (same) return
1657
+ /*
1658
+ What another command would do is not said of this one while this one is worked out: a row's pitch refused stayed
1659
+ on screen for the count chosen after it (:3300, 2026-10-11).
1660
+ */
1661
+ if (this.preview && this.preview.key !== key) this.preview = undefined
1662
1662
  this.previewTimer = setTimeout(() => this.computePreview(candidate), PREVIEW_SETTLE_MS)
1663
1663
  }
1664
1664
  private computePreview(candidate: { action: FigureAuthoringAction; face?: { part: string; face: string } }) {
@@ -125,6 +125,38 @@ export function alignmentAt(d: number, face: number | undefined, part: number, e
125
125
  return { how: 'mm', mm: Math.round(d) }
126
126
  }
127
127
 
128
+ /**
129
+ * The alignment that brings the footprint inside the face, for 정렬·간격 고치기: on each axis it comes past an edge, the
130
+ * least move -- to that edge from inside (`min` / `max`, or that distance where edges are not offered) -- or the centre
131
+ * when the part is wider than the face and cannot be inside. `inside` when nothing of it is past an edge: what is out
132
+ * is something fastened on it. `unmeasured` when the face has no size to measure against.
133
+ */
134
+ export function insideAlignmentOf(
135
+ view: FaceView | undefined,
136
+ align: Partial<Record<Axis, Alignment>>,
137
+ alignMm: Partial<Record<Axis, number>>
138
+ ): { align: Partial<Record<Axis, Alignment>>; alignMm: Partial<Record<Axis, number>>; moved: Axis[] } | { cannot: 'inside' | 'unmeasured' } {
139
+ if (!view || view.axes.some(a => view.face[a] === undefined)) return { cannot: 'unmeasured' }
140
+ const next = { align: { ...align }, alignMm: { ...alignMm }, moved: [] as Axis[] }
141
+ for (const a of view.axes) {
142
+ const face = view.face[a]!,
143
+ part = view.part[a]
144
+ const at = offsetOf(align[a], alignMm[a], face, part)
145
+ const low = -face / 2 - (at - part / 2) > 0.5,
146
+ high = at + part / 2 - face / 2 > 0.5
147
+ if (!low && !high) continue
148
+ next.moved.push(a)
149
+ if (part > face) {
150
+ next.align[a] = 'centre'
151
+ continue
152
+ }
153
+ const edge = ((low ? -1 : 1) * (face - part)) / 2
154
+ if (view.edges[a]) next.align[a] = low ? 'min' : 'max'
155
+ else ((next.align[a] = 'mm'), (next.alignMm[a] = Math.round(edge)))
156
+ }
157
+ return next.moved.length ? next : { cannot: 'inside' }
158
+ }
159
+
128
160
  /** How far the footprint comes past the face's edges, per side, and whether it touches the face at all. */
129
161
  export function overhangOf(view: FaceView, at: Record<Axis, number>): { touching: boolean; sides: Array<{ axis: Axis; side: 'low' | 'high'; mm: number }> } {
130
162
  const sides: Array<{ axis: Axis; side: 'low' | 'high'; mm: number }> = []
@@ -6,7 +6,7 @@ import './figure-slot-layout-panel.js'
6
6
  import { customElement, property, state } from 'lit/decorators.js'
7
7
 
8
8
  import { i18next, localize } from '@operato/i18n'
9
- import { FIGURE_FACES } from '@hatiolab/figure-model'
9
+ import { FIGURE_FACES, figureFaceOverhangOf } from '@hatiolab/figure-model'
10
10
  import type { FigureAsset, FigureAuthoringAction, FigureDimensionSource, FigureFace } from '@hatiolab/figure-model'
11
11
 
12
12
  import {
@@ -56,7 +56,7 @@ import {
56
56
  import { SidePanelStyles } from './side-panel-styles.js'
57
57
  import './figure-fasten-drawing.js'
58
58
  import type { FastenChange } from './figure-fasten-drawing.js'
59
- import { faceViewOf, modeOf, myFaceFor, turnsInPlace, type FastenMode } from './fasten-geometry.js'
59
+ import { faceViewOf, insideAlignmentOf, modeOf, myFaceFor, turnsInPlace, type FaceView, type FastenMode } from './fasten-geometry.js'
60
60
  import type { FigureRevision } from './revision.js'
61
61
  import { HelpToggles } from './help.js'
62
62
  import { jointAxisWord } from './joint-axis.js'
@@ -186,6 +186,8 @@ export class FigureLinks extends localize(i18next)(LitElement) {
186
186
  /** The fastening section, marked for a moment when "open attachment settings" brings it into view. */
187
187
  @state() private spotlit = false
188
188
  @state() private fasten = freshFasten()
189
+ /** Why 정렬·간격 고치기 moved nothing, said under it; empty once it moved the part or the part changed. */
190
+ @state() private fixNote = ''
189
191
  @state() private motion: MotionForm = freshMotion()
190
192
  @state() private place = freshPlace()
191
193
  /** How places are made here: one at a time, or by the figure's rule. Unset: by the rule when the figure has one. */
@@ -201,7 +203,9 @@ export class FigureLinks extends localize(i18next)(LitElement) {
201
203
  /* The part the forms were loaded for. A panel made with a part already chosen loaded nothing, and said "editing the
202
204
  fastening it has" over an empty target (2026-09-27, :3391, the doors). */
203
205
  private loadedFor?: string
204
- willUpdate(_changed: Map<string, unknown>) {
206
+ willUpdate(changed: Map<string, unknown>) {
207
+ /* What 고치기 said is about the figure as it was: another step or another part, and it no longer holds. */
208
+ if (changed.has('asset') || changed.has('partId')) this.fixNote = ''
205
209
  if (this.asset && this.partId && this.partId !== this.loadedFor) {
206
210
  this.loadedFor = this.partId
207
211
  /* A part's own fastening is not another part's choices: the form opens on it (walk 1, 2026-09-26). */
@@ -430,16 +434,27 @@ export class FigureLinks extends localize(i18next)(LitElement) {
430
434
  * holding places instead).
431
435
  */
432
436
  /** The face the part sits on, in view with the focus on it: where the place across the face is dragged or keyed. */
433
- private async showWhere() {
434
- this.working = 'fasten'
435
- await this.updateComplete
436
- /* The drawing draws itself after this panel does: its "where" is there only once it has. */
437
- const drawing = this.renderRoot.querySelector<HTMLElement & { updateComplete?: Promise<unknown> }>('figure-fasten-drawing')
438
- await drawing?.updateComplete
439
- const where = drawing?.shadowRoot?.querySelector<HTMLElement>('div[where]')
440
- if (!where) return
441
- where.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
442
- where.focus({ preventScroll: true })
437
+ /**
438
+ * 정렬·간격 고치기: the part moved inside the face it is fastened on, as one step -- the least move on each side it is
439
+ * out, or centred when it is wider than the face. When nothing of it can be moved, why is said under the button, not
440
+ * nothing at all (:3300, 2026-10-11: the button only scrolled to the drawing, which was already in view).
441
+ */
442
+ private async fixAlignment(view: FaceView | undefined, target: string) {
443
+ /* Whether it is out is the kernel's measure (a round face is not measured there); the drawing's sizes say how far to move. */
444
+ const own = this.revision ? figureFaceOverhangOf(this.revision.asset, this.partId, this.revision.inputs) : undefined
445
+ const fixed =
446
+ own?.state === 'unknown'
447
+ ? ({ cannot: 'unmeasured' } as const)
448
+ : own?.state === 'out' || own?.state === 'possible'
449
+ ? insideAlignmentOf(view, this.fasten.align, this.fasten.alignMm)
450
+ : ({ cannot: 'inside' } as const)
451
+ if ('cannot' in fixed) {
452
+ this.fixNote = i18next.t(`figure.text.fix-alignment-${fixed.cannot}`, { part: this.partId, on: target })
453
+ return
454
+ }
455
+ this.fixNote = ''
456
+ this.fasten = { ...this.fasten, align: fixed.align, alignMm: fixed.alignMm }
457
+ await this.onFastenRelease(true)
443
458
  }
444
459
 
445
460
  /** The fastening form, open on this part and in view: for "fasten it first" from elsewhere. */
@@ -600,7 +615,10 @@ export class FigureLinks extends localize(i18next)(LitElement) {
600
615
  ? html`<div overhang role="status">
601
616
  ${this.overhangWords.map(line => html`<p>${line}</p>`)}
602
617
  ${current
603
- ? html`<button act @click=${() => this.showWhere()}><md-icon>tune</md-icon>${i18next.t('figure.button.fix-alignment')}</button>`
618
+ ? html`<button act fix-alignment ?disabled=${this.locked} @click=${() => this.fixAlignment(view, held ? f.target : '')}>
619
+ <md-icon>tune</md-icon>${i18next.t('figure.button.fix-alignment')}
620
+ </button>
621
+ ${this.fixNote ? html`<p hint fix-note>${this.fixNote}</p>` : nothing}`
604
622
  : nothing}
605
623
  </div>`
606
624
  : nothing}
@@ -38,16 +38,6 @@ export function meshChoicesOf(upload: MeshUpload): Array<{ node?: number; name:
38
38
  ]
39
39
  }
40
40
 
41
- /** A part id from a node's name: letter-led, letters, digits, `_` and `-`; another number when it is taken. */
42
- export function meshPartIdOf(name: string, taken: ReadonlySet<string>): string {
43
- const cleaned = name.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^[^a-zA-Z]+/, '').replace(/-+$/, '')
44
- const base = cleaned || 'mesh'
45
- if (!taken.has(base)) return base
46
- let n = 2
47
- while (taken.has(`${base}-${n}`)) n++
48
- return `${base}-${n}`
49
- }
50
-
51
41
  /**
52
42
  * The part: its bounds and counts as the server read them, its size the reach along its longest axis (so it is 1:1),
53
43
  * and -- for a node -- standing where the node stood in the file, so a crane taken apart node by node stands as it did.
@@ -101,6 +101,9 @@ export function placedRefusalOf(error: unknown, part: string, say: Say): string
101
101
 
102
102
  /** A part name refused: not a name, already used, or another part is named under the part. The rule is the kernel's. */
103
103
  export function partNameRefusalOf(error: unknown, say: Say): string | null {
104
+ /* A new part named as one already is: the same sentence as a part renamed so. */
105
+ const takenId = detailsOf(error, 'part-id-taken')
106
+ if (takenId) return say('figure.text.part-name-taken', { name: takenId.part })
104
107
  if (codeOf(error) !== 'PART_ID') return null
105
108
  const invalid = detailsOf(error, 'name-invalid')
106
109
  if (invalid) return say('figure.text.part-name-invalid', { name: invalid.name })
@@ -236,7 +239,8 @@ export function inputRangeRefusalOf(error: unknown, say: Say): string | null {
236
239
 
237
240
  /**
238
241
  * A row past its limits: a pitch so fine it would hold more copies than a row takes, a row that would expand to more
239
- * bodies than a figure draws, or a number of copies outside what a row may have.
242
+ * bodies than a figure draws, a number of copies outside what a row may have or more than its length holds apart, a
243
+ * row inside a row along the same axis, or a part whose face something holds made into a row.
240
244
  */
241
245
  export function rowLimitRefusalOf(error: unknown, say: Say): string | null {
242
246
  const copies = detailsOf(error, 'copies-over-limit')
@@ -245,9 +249,18 @@ export function rowLimitRefusalOf(error: unknown, say: Say): string | null {
245
249
  if (bodies) return say('figure.text.row-geometry-over-limit', { count: bodies.count.toLocaleString(), limit: bodies.limit.toLocaleString() })
246
250
  const count = detailsOf(error, 'row-copies-out-of-range')
247
251
  if (count) return say('figure.text.row-copies-out-of-range', { count: count.count.toLocaleString(), min: String(count.min), max: String(count.max) })
252
+ const fit = detailsOf(error, 'row-does-not-fit')
253
+ if (fit) return say('figure.text.row-does-not-fit', { count: fit.count.toLocaleString(), length: mmOf(fit.length), extent: mmOf(fit.extent) })
254
+ const axis = detailsOf(error, 'row-same-axis')
255
+ if (axis) return say('figure.text.row-same-axis', { part: axis.part, outer: axis.outer, axis: axis.axis.toUpperCase() })
256
+ const held = detailsOf(error, 'surface-on-row')
257
+ if (held) return say(`figure.text.row-face-held-${heldByOf(held.at)}`, { part: held.part })
248
258
  return null
249
259
  }
250
260
 
261
+ /* What holds a face, from the contract path the kernel names it by: the slot layout, a place, the footprint. */
262
+ const heldByOf = (at: string) => (at.startsWith('slotLayout') ? 'slot-layout' : at.startsWith('bindings') ? 'place' : at.startsWith('occupancy') ? 'footprint' : 'other')
263
+
251
264
  /** A row's pitch under which the figure, at the length it is authored, would hold more copies than a row takes. */
252
265
  export function repeatPitchRefusalOf(error: unknown, say: Say): string | null {
253
266
  if (codeOf(error) !== 'INPUT_RANGE') return null
@@ -7,7 +7,7 @@ import { notify, openPopup } from '@operato/layout';
7
7
  import { OxPrompt } from '@operato/popup';
8
8
  import { customElement, property, state } from 'lit/decorators.js';
9
9
  import { live } from 'lit/directives/live.js';
10
- import { PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figureRecipeIdFor, figureSizeEditChanges, figureScaleAnchorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, figurePartFaceOf, figureRowOf, figureFaceOverhangOf, figureRoomOnlyInputsOf, figureRoomRefitOf } from '@hatiolab/figure-model';
10
+ import { PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figurePartIdFor, figureRecipeIdFor, figureSizeEditChanges, figureScaleAnchorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, figurePartFaceOf, figureRowOf, figureFaceOverhangOf, figureRoomOnlyInputsOf, figureRoomRefitOf } from '@hatiolab/figure-model';
11
11
  import './figure-recipe-catalog.js';
12
12
  import { captureFigureThumbnail } from './thumbnail.js';
13
13
  import { drawableFigureParts } from './renderer.js';
@@ -32,7 +32,7 @@ import { SidePanelStyles } from './side-panel-styles.js';
32
32
  import { ScrollbarStyles } from '@operato/styles';
33
33
  import { alignChanges, canMirror, mirrorChanges } from './align.js';
34
34
  import { paletteRecipe } from './palette.js';
35
- import { meshChoicesOf, meshPartIdOf, meshPartOf } from './mesh-import.js';
35
+ import { meshChoicesOf, meshPartOf } from './mesh-import.js';
36
36
  import './figure-mesh-choice.js';
37
37
  import { uploadFigureMesh } from '../graphql/index.js';
38
38
  import { slotMarksOf } from './slot-layout-host.js';
@@ -794,11 +794,8 @@ let FigureEditor = class FigureEditor extends LitElement {
794
794
  if (!this.session)
795
795
  return;
796
796
  this.run(() => {
797
- const ids = new Set(this.session.read().geometry.map(g => g.id));
798
- let n = 1;
799
- while (ids.has(`${kind}-${n}`))
800
- n++;
801
- const part = paletteRecipe(kind, `${kind}-${n}`);
797
+ /* The id is figure-model's to choose: what is in use is its rule, not a list the screen keeps. */
798
+ const part = paletteRecipe(kind, figurePartIdFor(this.session.revisionNow().asset, kind));
802
799
  this.session.add(part);
803
800
  this.selected = part.id;
804
801
  });
@@ -832,8 +829,7 @@ let FigureEditor = class FigureEditor extends LitElement {
832
829
  return;
833
830
  const node = upload.summary.nodes.find(n => n.index === choice.node);
834
831
  this.run(() => {
835
- const taken = new Set(this.session.read().geometry.map(g => g.id));
836
- const id = meshPartIdOf(node?.name ?? file.name.replace(/\.glb$/i, ''), taken);
832
+ const id = figurePartIdFor(this.session.revisionNow().asset, node?.name ?? file.name.replace(/\.glb$/i, ''));
837
833
  this.session.add(meshPartOf(upload, choice, id));
838
834
  this.selected = id;
839
835
  });
@@ -844,9 +840,8 @@ let FigureEditor = class FigureEditor extends LitElement {
844
840
  */
845
841
  duplicatePart(id) {
846
842
  this.run(() => {
847
- const before = new Set(this.session.read().geometry.map(g => g.id));
848
- this.session.edit({ kind: 'duplicate-part', target: id });
849
- this.selected = this.session.read().geometry.find(g => !before.has(g.id))?.id ?? id;
843
+ /* The copy is the part the edit made, as figure-model named it. */
844
+ this.selected = this.session.edit({ kind: 'duplicate-part', target: id }).made?.[0] ?? id;
850
845
  });
851
846
  }
852
847
  movePart(id, to) {
@@ -1045,9 +1040,7 @@ let FigureEditor = class FigureEditor extends LitElement {
1045
1040
  mirror(axis) {
1046
1041
  this.run(() => {
1047
1042
  const id = this.selected;
1048
- const before = new Set(this.session.read().geometry.map(g => g.id));
1049
- this.session.edit({ kind: 'duplicate-part', target: id });
1050
- const copy = this.session.read().geometry.find(g => !before.has(g.id))?.id;
1043
+ const copy = this.session.edit({ kind: 'duplicate-part', target: id }).made?.[0];
1051
1044
  if (!copy)
1052
1045
  return;
1053
1046
  this.session.apply(mirrorChanges(this.shown(), id, copy, axis));
@@ -1671,9 +1664,16 @@ let FigureEditor = class FigureEditor extends LitElement {
1671
1664
  return;
1672
1665
  }
1673
1666
  const view = this.session.read();
1674
- const same = this.preview && this.preview.key === JSON.stringify(candidate) && this.preview.context === view.context && this.preview.part === this.selected && 'revision' in this.preview && this.preview.revision === view.revision;
1667
+ const key = JSON.stringify(candidate);
1668
+ const same = this.preview && this.preview.key === key && this.preview.context === view.context && this.preview.part === this.selected && 'revision' in this.preview && this.preview.revision === view.revision;
1675
1669
  if (same)
1676
1670
  return;
1671
+ /*
1672
+ What another command would do is not said of this one while this one is worked out: a row's pitch refused stayed
1673
+ on screen for the count chosen after it (:3300, 2026-10-11).
1674
+ */
1675
+ if (this.preview && this.preview.key !== key)
1676
+ this.preview = undefined;
1677
1677
  this.previewTimer = setTimeout(() => this.computePreview(candidate), PREVIEW_SETTLE_MS);
1678
1678
  }
1679
1679
  computePreview(candidate) {