@things-factory/figure-ui 10.1.151 → 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 (45) hide show
  1. package/client/modeller/editor.ts +15 -23
  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.d.ts +0 -6
  7. package/dist-client/modeller/editor.js +16 -24
  8. package/dist-client/modeller/editor.js.map +1 -1
  9. package/dist-client/modeller/fasten-geometry.d.ts +13 -0
  10. package/dist-client/modeller/fasten-geometry.js +29 -0
  11. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  12. package/dist-client/modeller/figure-links.d.ts +9 -2
  13. package/dist-client/modeller/figure-links.js +35 -14
  14. package/dist-client/modeller/figure-links.js.map +1 -1
  15. package/dist-client/modeller/mesh-import.d.ts +0 -2
  16. package/dist-client/modeller/mesh-import.js +0 -11
  17. package/dist-client/modeller/mesh-import.js.map +1 -1
  18. package/dist-client/modeller/product-session.d.ts +4 -28
  19. package/dist-client/modeller/refusals.d.ts +2 -1
  20. package/dist-client/modeller/refusals.js +17 -1
  21. package/dist-client/modeller/refusals.js.map +1 -1
  22. package/dist-client/tsconfig.tsbuildinfo +1 -1
  23. package/package.json +4 -4
  24. package/test/browser/build.mjs +7 -2
  25. package/test/browser/editing.check.mjs +14 -0
  26. package/test/browser/fix-alignment.check.mjs +101 -0
  27. package/test/browser/links.check.mjs +34 -5
  28. package/test/browser/mesh-select.check.mjs +91 -0
  29. package/test/browser/modeller-page.js +36 -0
  30. package/test/browser/modeller-revision.check.mjs +18 -6
  31. package/test/browser/part-ids.check.mjs +24 -6
  32. package/test/browser/parts.check.mjs +39 -2
  33. package/test/browser/row-preview.check.mjs +81 -0
  34. package/test/browser/screen.check.mjs +2 -1
  35. package/test/fields.test.ts +5 -9
  36. package/test/fix-alignment.test.ts +32 -0
  37. package/test/mesh-import.test.ts +1 -8
  38. package/test/parts-wiring.test.ts +1 -5
  39. package/test/refusals.test.ts +28 -0
  40. package/translations/en.json +8 -0
  41. package/translations/es.json +8 -0
  42. package/translations/ja.json +8 -0
  43. package/translations/ko.json +8 -0
  44. package/translations/ms.json +8 -0
  45. 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'
@@ -750,14 +750,6 @@ export class FigureEditor extends LitElement {
750
750
  private shown(): FigureRevision | undefined {
751
751
  return this.session?.revisionNow()
752
752
  }
753
- /**
754
- * The figure's parts by id, one per authored part as the parts list shows them. Not the drawn bodies: a row's copies
755
- * are drawn under ids of their own, so a part made into a row was missing from those and its id was offered to the
756
- * next part, which figure-model refused (DUPLICATE_PART).
757
- */
758
- private partIds(): Set<string> {
759
- return new Set(this.session?.revisionNow()?.asset.appearance.map(look => look.target) ?? [])
760
- }
761
753
  /** A value in hand: an input's value (design or pose) or a part edit. Nothing is recorded. */
762
754
  private hold(values: Record<string, number>, edit?: FigurePartEdit) {
763
755
  this.live = { values: { ...this.live.values, ...values }, ...(edit ? { edit } : this.live.edit ? { edit: this.live.edit } : {}) }
@@ -811,10 +803,8 @@ export class FigureEditor extends LitElement {
811
803
  private add(kind: FigurePaletteKind) {
812
804
  if (!this.session) return
813
805
  this.run(() => {
814
- const ids = this.partIds()
815
- let n = 1
816
- while (ids.has(`${kind}-${n}`)) n++
817
- 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))
818
808
  this.session!.add(part)
819
809
  this.selected = part.id
820
810
  })
@@ -846,8 +836,7 @@ export class FigureEditor extends LitElement {
846
836
  if (!choice) return
847
837
  const node = upload.summary.nodes.find(n => n.index === (choice as { node?: number }).node)
848
838
  this.run(() => {
849
- const taken = this.partIds()
850
- 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, ''))
851
840
  this.session!.add(meshPartOf(upload, choice as { node?: number }, id))
852
841
  this.selected = id
853
842
  })
@@ -858,9 +847,8 @@ export class FigureEditor extends LitElement {
858
847
  */
859
848
  private duplicatePart(id: string) {
860
849
  this.run(() => {
861
- const before = this.partIds()
862
- this.session!.edit({ kind: 'duplicate-part', target: id })
863
- this.selected = [...this.partIds()].find(part => !before.has(part)) ?? 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
864
852
  })
865
853
  }
866
854
  private movePart(id: string, to: number) {
@@ -1040,9 +1028,7 @@ export class FigureEditor extends LitElement {
1040
1028
  private mirror(axis: 'x' | 'z') {
1041
1029
  this.run(() => {
1042
1030
  const id = this.selected
1043
- const before = this.partIds()
1044
- this.session!.edit({ kind: 'duplicate-part', target: id })
1045
- const copy = [...this.partIds()].find(part => !before.has(part))
1031
+ const copy = this.session!.edit({ kind: 'duplicate-part', target: id }).made?.[0]
1046
1032
  if (!copy) return
1047
1033
  this.session!.apply(mirrorChanges(this.shown()!, id, copy, axis))
1048
1034
  this.selected = copy
@@ -1665,8 +1651,14 @@ export class FigureEditor extends LitElement {
1665
1651
  return
1666
1652
  }
1667
1653
  const view = this.session.read()
1668
- 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
1669
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
1670
1662
  this.previewTimer = setTimeout(() => this.computePreview(candidate), PREVIEW_SETTLE_MS)
1671
1663
  }
1672
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
@@ -146,12 +146,6 @@ export declare class FigureEditor extends LitElement {
146
146
  * defaults put every other joint back to its saved angle while one slider moved (user, 2026-09-24).
147
147
  */
148
148
  private shown;
149
- /**
150
- * The figure's parts by id, one per authored part as the parts list shows them. Not the drawn bodies: a row's copies
151
- * are drawn under ids of their own, so a part made into a row was missing from those and its id was offered to the
152
- * next part, which figure-model refused (DUPLICATE_PART).
153
- */
154
- private partIds;
155
149
  /** A value in hand: an input's value (design or pose) or a part edit. Nothing is recorded. */
156
150
  private hold;
157
151
  private closeSectionEditor;
@@ -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';
@@ -737,14 +737,6 @@ let FigureEditor = class FigureEditor extends LitElement {
737
737
  shown() {
738
738
  return this.session?.revisionNow();
739
739
  }
740
- /**
741
- * The figure's parts by id, one per authored part as the parts list shows them. Not the drawn bodies: a row's copies
742
- * are drawn under ids of their own, so a part made into a row was missing from those and its id was offered to the
743
- * next part, which figure-model refused (DUPLICATE_PART).
744
- */
745
- partIds() {
746
- return new Set(this.session?.revisionNow()?.asset.appearance.map(look => look.target) ?? []);
747
- }
748
740
  /** A value in hand: an input's value (design or pose) or a part edit. Nothing is recorded. */
749
741
  hold(values, edit) {
750
742
  this.live = { values: { ...this.live.values, ...values }, ...(edit ? { edit } : this.live.edit ? { edit: this.live.edit } : {}) };
@@ -802,11 +794,8 @@ let FigureEditor = class FigureEditor extends LitElement {
802
794
  if (!this.session)
803
795
  return;
804
796
  this.run(() => {
805
- const ids = this.partIds();
806
- let n = 1;
807
- while (ids.has(`${kind}-${n}`))
808
- n++;
809
- 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));
810
799
  this.session.add(part);
811
800
  this.selected = part.id;
812
801
  });
@@ -840,8 +829,7 @@ let FigureEditor = class FigureEditor extends LitElement {
840
829
  return;
841
830
  const node = upload.summary.nodes.find(n => n.index === choice.node);
842
831
  this.run(() => {
843
- const taken = this.partIds();
844
- 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, ''));
845
833
  this.session.add(meshPartOf(upload, choice, id));
846
834
  this.selected = id;
847
835
  });
@@ -852,9 +840,8 @@ let FigureEditor = class FigureEditor extends LitElement {
852
840
  */
853
841
  duplicatePart(id) {
854
842
  this.run(() => {
855
- const before = this.partIds();
856
- this.session.edit({ kind: 'duplicate-part', target: id });
857
- this.selected = [...this.partIds()].find(part => !before.has(part)) ?? 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;
858
845
  });
859
846
  }
860
847
  movePart(id, to) {
@@ -1053,9 +1040,7 @@ let FigureEditor = class FigureEditor extends LitElement {
1053
1040
  mirror(axis) {
1054
1041
  this.run(() => {
1055
1042
  const id = this.selected;
1056
- const before = this.partIds();
1057
- this.session.edit({ kind: 'duplicate-part', target: id });
1058
- const copy = [...this.partIds()].find(part => !before.has(part));
1043
+ const copy = this.session.edit({ kind: 'duplicate-part', target: id }).made?.[0];
1059
1044
  if (!copy)
1060
1045
  return;
1061
1046
  this.session.apply(mirrorChanges(this.shown(), id, copy, axis));
@@ -1679,9 +1664,16 @@ let FigureEditor = class FigureEditor extends LitElement {
1679
1664
  return;
1680
1665
  }
1681
1666
  const view = this.session.read();
1682
- 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;
1683
1669
  if (same)
1684
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;
1685
1677
  this.previewTimer = setTimeout(() => this.computePreview(candidate), PREVIEW_SETTLE_MS);
1686
1678
  }
1687
1679
  computePreview(candidate) {