@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.
Files changed (104) hide show
  1. package/client/graphql/v3-store.ts +2 -1
  2. package/client/modeller/figure-history-panel.ts +9 -1
  3. package/client/modeller/figure-v3-first-save.ts +98 -0
  4. package/client/modeller/figure-v3-links.ts +89 -26
  5. package/client/modeller/figure-v3-parts.ts +86 -10
  6. package/client/modeller/figure-v3-properties.ts +2 -1
  7. package/client/modeller/side-panel-styles.ts +8 -0
  8. package/client/modeller/v3-canvas.ts +86 -12
  9. package/client/modeller/v3-editor.ts +244 -37
  10. package/client/modeller/v3-fields.ts +15 -1
  11. package/client/modeller/v3-first-save.ts +34 -0
  12. package/client/modeller/v3-gizmo.ts +52 -3
  13. package/client/modeller/v3-joint-drag.ts +116 -0
  14. package/client/modeller/v3-joint-fan.ts +24 -11
  15. package/client/modeller/v3-keys.ts +12 -1
  16. package/client/modeller/v3-links.ts +72 -5
  17. package/client/modeller/v3-live.ts +11 -0
  18. package/client/modeller/v3-product-session.ts +4 -2
  19. package/client/modeller/v3-refusals.ts +21 -0
  20. package/client/modeller/v3-stage-model.ts +24 -0
  21. package/client/pages/figure-v3-modeller-page.ts +46 -7
  22. package/dist-client/graphql/v3-store.js +2 -1
  23. package/dist-client/graphql/v3-store.js.map +1 -1
  24. package/dist-client/modeller/figure-history-panel.js +9 -1
  25. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  26. package/dist-client/modeller/figure-v3-first-save.d.ts +20 -0
  27. package/dist-client/modeller/figure-v3-first-save.js +118 -0
  28. package/dist-client/modeller/figure-v3-first-save.js.map +1 -0
  29. package/dist-client/modeller/figure-v3-links.d.ts +5 -0
  30. package/dist-client/modeller/figure-v3-links.js +81 -23
  31. package/dist-client/modeller/figure-v3-links.js.map +1 -1
  32. package/dist-client/modeller/figure-v3-parts.d.ts +13 -0
  33. package/dist-client/modeller/figure-v3-parts.js +81 -10
  34. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  35. package/dist-client/modeller/figure-v3-properties.js +2 -1
  36. package/dist-client/modeller/figure-v3-properties.js.map +1 -1
  37. package/dist-client/modeller/side-panel-styles.js +8 -0
  38. package/dist-client/modeller/side-panel-styles.js.map +1 -1
  39. package/dist-client/modeller/v3-canvas.d.ts +13 -0
  40. package/dist-client/modeller/v3-canvas.js +88 -12
  41. package/dist-client/modeller/v3-canvas.js.map +1 -1
  42. package/dist-client/modeller/v3-clips.d.ts +1 -1
  43. package/dist-client/modeller/v3-editor.d.ts +32 -1
  44. package/dist-client/modeller/v3-editor.js +245 -35
  45. package/dist-client/modeller/v3-editor.js.map +1 -1
  46. package/dist-client/modeller/v3-fields.d.ts +6 -0
  47. package/dist-client/modeller/v3-fields.js +14 -2
  48. package/dist-client/modeller/v3-fields.js.map +1 -1
  49. package/dist-client/modeller/v3-first-save.d.ts +17 -0
  50. package/dist-client/modeller/v3-first-save.js +27 -0
  51. package/dist-client/modeller/v3-first-save.js.map +1 -0
  52. package/dist-client/modeller/v3-gizmo.d.ts +18 -0
  53. package/dist-client/modeller/v3-gizmo.js +45 -2
  54. package/dist-client/modeller/v3-gizmo.js.map +1 -1
  55. package/dist-client/modeller/v3-joint-drag.d.ts +19 -0
  56. package/dist-client/modeller/v3-joint-drag.js +97 -0
  57. package/dist-client/modeller/v3-joint-drag.js.map +1 -0
  58. package/dist-client/modeller/v3-joint-fan.js +24 -11
  59. package/dist-client/modeller/v3-joint-fan.js.map +1 -1
  60. package/dist-client/modeller/v3-keys.d.ts +9 -1
  61. package/dist-client/modeller/v3-keys.js +3 -0
  62. package/dist-client/modeller/v3-keys.js.map +1 -1
  63. package/dist-client/modeller/v3-links.d.ts +45 -2
  64. package/dist-client/modeller/v3-links.js +52 -3
  65. package/dist-client/modeller/v3-links.js.map +1 -1
  66. package/dist-client/modeller/v3-live.d.ts +6 -0
  67. package/dist-client/modeller/v3-live.js +10 -0
  68. package/dist-client/modeller/v3-live.js.map +1 -1
  69. package/dist-client/modeller/v3-product-session.d.ts +4 -1
  70. package/dist-client/modeller/v3-product-session.js +2 -2
  71. package/dist-client/modeller/v3-product-session.js.map +1 -1
  72. package/dist-client/modeller/v3-refusals.d.ts +4 -0
  73. package/dist-client/modeller/v3-refusals.js +21 -0
  74. package/dist-client/modeller/v3-refusals.js.map +1 -0
  75. package/dist-client/modeller/v3-stage-model.d.ts +8 -0
  76. package/dist-client/modeller/v3-stage-model.js +26 -0
  77. package/dist-client/modeller/v3-stage-model.js.map +1 -1
  78. package/dist-client/pages/figure-v3-modeller-page.d.ts +4 -0
  79. package/dist-client/pages/figure-v3-modeller-page.js +45 -7
  80. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  81. package/dist-client/tsconfig.tsbuildinfo +1 -1
  82. package/package.json +7 -5
  83. package/test/browser/build.mjs +62 -0
  84. package/test/browser/modeller-page.js +198 -0
  85. package/test/browser/modeller.check.mjs +334 -0
  86. package/test/browser/serve.mjs +43 -0
  87. package/test/joints.ts +33 -0
  88. package/test/v3-clips.test.ts +3 -2
  89. package/test/v3-controls.test.ts +3 -2
  90. package/test/v3-fields.test.ts +38 -1
  91. package/test/v3-first-save.test.ts +75 -0
  92. package/test/v3-gizmo.test.ts +40 -5
  93. package/test/v3-joint-drag.test.ts +93 -0
  94. package/test/v3-joint-fan.test.ts +16 -6
  95. package/test/v3-keys.test.ts +15 -2
  96. package/test/v3-links.test.ts +62 -14
  97. package/test/v3-live.test.ts +23 -4
  98. package/test/v3-maturity.test.ts +3 -7
  99. package/test/v3-refusals.test.ts +28 -0
  100. package/translations/en.json +43 -5
  101. package/translations/ja.json +42 -4
  102. package/translations/ko.json +42 -4
  103. package/translations/ms.json +42 -4
  104. package/translations/zh.json +42 -4
@@ -4,6 +4,7 @@ import { LitElement, html, css, nothing } from 'lit';
4
4
  import { i18next } from '@operato/i18n';
5
5
  import '@operato/layout';
6
6
  import { customElement, property, state } from 'lit/decorators.js';
7
+ import { PART_LIMIT, v3PartCountOf } from '@hatiolab/figure-model';
7
8
  import { captureV3Thumbnail } from './v3-thumbnail.js';
8
9
  import { drawableV3Parts } from './v3-renderer.js';
9
10
  import './v3-canvas.js';
@@ -18,13 +19,17 @@ import { DEFAULT_VIEW } from './figure-view.js';
18
19
  import './figure-v3-capability.js';
19
20
  import { gizmoChanges, gizmoReachOf } from './v3-gizmo.js';
20
21
  import { keyActionOf } from './v3-keys.js';
21
- import { groupFields, partLabelOf, repeatOf } from './v3-fields.js';
22
+ import { groupFields, ownerOf, partLabelOf, repeatOf } from './v3-fields.js';
22
23
  import { SidePanelStyles } from './side-panel-styles.js';
24
+ import { ScrollbarStyles } from '@operato/styles';
23
25
  import { alignChanges, boxOf, canMirror, mirrorChanges } from './v3-align.js';
24
26
  import { paletteRecipe } from './v3-palette.js';
27
+ import { nudgeOf } from './v3-stage-model.js';
28
+ import { sizeRangeRefusalOf } from './v3-refusals.js';
25
29
  import { instanceRangeOf, rangeDiffers, turnsAboutAJoint } from './v3-links.js';
26
30
  import { jointFanOf } from './v3-joint-fan.js';
27
- import { isLive, livePartsOf, NOTHING_LIVE, scrubbed, scrubStep } from './v3-live.js';
31
+ import { jointDragOf } from './v3-joint-drag.js';
32
+ import { liveOverSession, NOTHING_LIVE, scrubbed, scrubStep } from './v3-live.js';
28
33
  /** Product editor surface. The injected session is shared by page persistence and AI commands. */
29
34
  let FigureV3Editor = class FigureV3Editor extends LitElement {
30
35
  constructor() {
@@ -48,16 +53,26 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
48
53
  /** A value in hand -- typed, held on a spinner, scrubbed, dragged -- drawn at once and recorded on release. */
49
54
  this.live = NOTHING_LIVE;
50
55
  this.lastContext = null;
51
- /** The modeller's keys (`v3-keys.ts`): undo, redo, duplicate, remove, back to the whole figure. */
56
+ /** The modeller's keys (`v3-keys.ts`): undo, redo, duplicate, remove, back to the whole figure, the gizmo's modes. */
52
57
  this.onKey = (e) => {
53
58
  const view = this.session?.read();
54
59
  if (!view || view.loading || view.saving || !view.context || !this.isConnected)
55
60
  return;
56
- const action = keyActionOf({ key: e.key, ctrlKey: e.ctrlKey, metaKey: e.metaKey, shiftKey: e.shiftKey, altKey: e.altKey, target: e.composedPath()[0] }, this.selected);
61
+ const canvas = this.renderRoot.querySelector('figure-v3-canvas');
62
+ const path = e.composedPath();
63
+ const action = keyActionOf({ key: e.key, ctrlKey: e.ctrlKey, metaKey: e.metaKey, shiftKey: e.shiftKey, altKey: e.altKey, target: path[0], inScene: !!canvas && path.includes(canvas) }, this.selected);
57
64
  if (!action)
58
65
  return;
59
66
  e.preventDefault();
60
- if (action === 'undo')
67
+ /*
68
+ The session's history is the only one. With the canvas focused the scene took Ctrl+Z as well, undoing on its own
69
+ board the last value the canvas had written -- which the canvas then told as a drag: an undo of a drag on a
70
+ converted part wrote the drag again (2026-09-25). Heard first (capture) and kept from the scene.
71
+ */
72
+ e.stopPropagation();
73
+ if (action === 'translate' || action === 'rotate' || action === 'scale')
74
+ canvas?.gizmo(action);
75
+ else if (action === 'undo')
61
76
  view.canUndo && this.run(() => this.session.undo());
62
77
  else if (action === 'redo')
63
78
  view.canRedo && this.run(() => this.session.redo());
@@ -70,7 +85,8 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
70
85
  };
71
86
  }
72
87
  static { FigureV3Editor_1 = this; }
73
- static { this.styles = [SidePanelStyles, css `
88
+ /* The operato scrollbar, as every scrolling panel of the app has it -- the side panel scrolled with the browser's own. */
89
+ static { this.styles = [ScrollbarStyles, SidePanelStyles, css `
74
90
  :host {
75
91
  display: flex;
76
92
  flex-direction: column;
@@ -266,10 +282,10 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
266
282
  }
267
283
  connectedCallback() {
268
284
  super.connectedCallback();
269
- window.addEventListener('keydown', this.onKey);
285
+ window.addEventListener('keydown', this.onKey, true);
270
286
  }
271
287
  disconnectedCallback() {
272
- window.removeEventListener('keydown', this.onKey);
288
+ window.removeEventListener('keydown', this.onKey, true);
273
289
  super.disconnectedCallback();
274
290
  }
275
291
  willUpdate(_changed) {
@@ -280,7 +296,7 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
280
296
  this.error = '';
281
297
  this.hiddenParts = new Set();
282
298
  }
283
- if (view && !view.geometry.some(g => g.id === this.selected))
299
+ if (view && !view.geometry.some(g => ownerOf(g.id) === this.selected))
284
300
  this.selected = '';
285
301
  }
286
302
  run(action) {
@@ -290,7 +306,7 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
290
306
  this.changed();
291
307
  }
292
308
  catch (error) {
293
- this.error = error instanceof Error ? error.message : String(error);
309
+ this.error = sizeRangeRefusalOf(error, (key, params) => i18next.t(key, params)) ?? (error instanceof Error ? error.message : String(error));
294
310
  }
295
311
  finally {
296
312
  /* Committed or refused, the value in hand is gone: the figure as it stands is drawn again. */
@@ -299,8 +315,8 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
299
315
  }
300
316
  /** What the canvas draws: the figure with the value in hand when there is one, and without the parts hidden on the workbench. */
301
317
  drawnParts(geometry, asset) {
302
- const parts = (isLive(this.live) && asset ? livePartsOf(asset, this.live) : null) ?? drawableV3Parts(geometry ?? []);
303
- return parts.filter(body => !this.hiddenParts.has(body.id));
318
+ const parts = (asset ? liveOverSession(asset, this.currentInputs(), this.live) : null) ?? drawableV3Parts(geometry ?? []);
319
+ return parts.filter(body => !this.hiddenParts.has(ownerOf(body.id)));
304
320
  }
305
321
  /**
306
322
  * Where the selected part turns and how far, with the pose as it is drawn now -- a value in hand included, so the
@@ -310,12 +326,21 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
310
326
  if (!asset || !this.selected || this.hiddenParts.has(this.selected))
311
327
  return null;
312
328
  try {
313
- return jointFanOf(asset, this.selected, this.live.values);
329
+ return jointFanOf(asset, this.selected, { ...this.currentInputs(), ...this.live.values });
314
330
  }
315
331
  catch {
316
332
  return null;
317
333
  }
318
334
  }
335
+ /**
336
+ * Every input as the session holds it now -- the pose the author has set with the controls, and design values. The
337
+ * asset's source carries only the saved defaults, so drawing a value in hand over it put every other joint back to its
338
+ * saved angle while one slider moved, and the arm jumped when released (user, 2026-09-24).
339
+ */
340
+ currentInputs() {
341
+ const view = this.session?.read();
342
+ return view ? Object.fromEntries(view.fields.map(field => [field.id, field.value])) : {};
343
+ }
319
344
  /** A value in hand: an input's value (design or pose) or a part edit. Nothing is recorded. */
320
345
  hold(values, edit) {
321
346
  this.live = { values: { ...this.live.values, ...values }, ...(edit ? { edit } : this.live.edit ? { edit: this.live.edit } : {}) };
@@ -333,9 +358,21 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
333
358
  not from the one on screen, which stands wherever the author left it turned. A picture that cannot
334
359
  be taken is not an error: the save goes on without it (see `v3-thumbnail.ts`).
335
360
  */
361
+ let type;
362
+ if (!this.session.read().id && this.firstSave) {
363
+ const answer = await this.firstSave(this.session.read().name ?? '');
364
+ if (!answer)
365
+ return;
366
+ /* The name is the figure's own, in the asset: one step of the history, like typing it in the properties. */
367
+ if (answer.name !== this.session.read().name) {
368
+ this.session.command({ kind: 'rename', name: answer.name });
369
+ this.changed();
370
+ }
371
+ type = answer.type;
372
+ }
336
373
  const view = this.session.read();
337
374
  const thumbnail = await captureV3Thumbnail(drawableV3Parts(view.geometry ?? []), view.palette ?? {});
338
- const promise = this.session.save({ thumbnail });
375
+ const promise = this.session.save({ thumbnail, type });
339
376
  this.changed();
340
377
  await promise;
341
378
  this.error = '';
@@ -403,7 +440,8 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
403
440
  /** What the gizmo may write for the selected part — read off the asset being edited. */
404
441
  reachOf(id) {
405
442
  const none = { translate: false, rotate: false, scale: false };
406
- if (!id || !this.session)
443
+ /* A repeat is moved and sized through its row, not by a gizmo on one copy. */
444
+ if (!id || !this.session || !this.session.read().geometry.some(g => g.id === id))
407
445
  return none;
408
446
  try {
409
447
  return gizmoReachOf(JSON.parse(this.session.source()), id);
@@ -415,12 +453,74 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
415
453
  /** A drag, written back as the part's own inputs — one step of the history, like typing the numbers. */
416
454
  transformed(id, result) {
417
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());
458
+ const drawn = view.geometry.find(g => g.id === id);
459
+ const drag = drawn ? jointDragOf(asset, ownerOf(id), drawn, result, this.currentInputs()) : null;
460
+ if (drag) {
461
+ if (drag.kind === 'value')
462
+ this.run(() => this.session.apply([{ kind: 'set-state', input: drag.input, unit: drag.unit, value: drag.value }]));
463
+ else if (drag.kind === 'joint')
464
+ this.run(() => this.session.command({ kind: 'set-joint', joint: drag.joint }));
465
+ else {
466
+ this.error = i18next.t(`figure.text.gizmo-joint-${drag.reason}`, { joint: drag.joint, leader: drag.leader ?? '', axis: drag.axis ?? '' });
467
+ this.requestUpdate();
468
+ }
469
+ return;
470
+ }
418
471
  const sizes = Object.fromEntries(view.fields.map(field => [field.id, field.value]));
419
- const changes = gizmoChanges(id, this.reachOf(id), result, sizes);
420
- if (changes.length)
472
+ /*
473
+ A part a joint places, scaled: only its measures change. The shift of its centre that an anchored scale makes is
474
+ the joint's to give -- the face on the joint stays there by itself (user, 2026-09-25: 「'R'모드에서 크기가 변하지 않는다」;
475
+ the shift had been taken for a move of the joint, and the size was dropped).
476
+ */
477
+ const jointed = !!asset.joints?.some(j => j.body1.part === ownerOf(id));
478
+ const changes = gizmoChanges(id, this.reachOf(id), jointed && result.scale ? { scale: result.scale } : result, sizes);
479
+ if (changes.length) {
421
480
  this.run(() => this.session.apply(changes));
422
- else
481
+ return;
482
+ }
483
+ if (jointed && result.scale) {
484
+ /* Only measures that follow another were pulled: the relation says their size, and it is not broken here. */
485
+ const axes = ['x', 'y', 'z'].filter((_, i) => Math.abs(result.scale[i] - 1) > 1e-4);
486
+ this.error = i18next.t('figure.text.gizmo-size-follows', { part: partLabelOf(id), axis: axes.join(', ').toUpperCase() });
487
+ return this.requestUpdate();
488
+ }
489
+ /*
490
+ A part whose place is worked out (a converted one) has no numbers for the gizmo to write: the drag is added beside
491
+ its rule (`nudge-part`). A part a joint or a fastening places, or a size that is worked out, is refused -- said in
492
+ words, not by the part silently springing back (user, 2026-09-24: 「기즈모 컨트롤도 전혀 작동이 안되는군」).
493
+ */
494
+ const body = view.geometry.find(g => g.id === id);
495
+ if (!body)
496
+ return this.requestUpdate();
497
+ const { by, turn } = nudgeOf(body, result);
498
+ /* A size worked out from the figure's: the drag's factor beside its rule, so it still follows (V2 scaled any part). */
499
+ const grow = {};
500
+ if (result.scale)
501
+ ['x', 'y', 'z'].forEach((axis, i) => Math.abs(result.scale[i] - 1) > 1e-6 && (grow[axis] = Math.round(result.scale[i] * 1e6) / 1e6));
502
+ if (!Object.keys(by).length && !Object.keys(turn).length && !Object.keys(grow).length)
503
+ return this.requestUpdate();
504
+ try {
505
+ this.session.command({
506
+ kind: 'nudge-part',
507
+ part: id,
508
+ ...(Object.keys(by).length ? { by } : {}),
509
+ ...(Object.keys(turn).length ? { turn } : {}),
510
+ ...(Object.keys(grow).length ? { grow } : {})
511
+ });
512
+ this.error = '';
513
+ this.changed();
514
+ }
515
+ catch (error) {
516
+ const message = error instanceof Error ? error.message : String(error);
517
+ this.error = /carried by a joint/.test(message)
518
+ ? i18next.t('figure.text.gizmo-joint-places', { part: partLabelOf(id) })
519
+ : /is fastened to/.test(message)
520
+ ? i18next.t('figure.text.gizmo-fastening-places', { part: partLabelOf(id) })
521
+ : message;
423
522
  this.requestUpdate();
523
+ }
424
524
  }
425
525
  /** 맞추기 — one tool, one step of the history, through the same inputs as the gizmo. */
426
526
  align(tool) {
@@ -486,7 +586,7 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
486
586
  */
487
587
  renderRepeatNote(geometry) {
488
588
  const repeat = repeatOf(this.selected, geometry);
489
- return repeat ? html `<p hint>${i18next.t('figure.text.repeat-member', { name: repeat.name, count: repeat.count, index: repeat.index })}</p>` : nothing;
589
+ return repeat?.index ? html `<p hint>${i18next.t('figure.text.repeat-member', { name: repeat.name, count: repeat.count, index: repeat.index })}</p>` : nothing;
490
590
  }
491
591
  renamePart(input) {
492
592
  const name = input.value.trim();
@@ -579,9 +679,7 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
579
679
  ? i18next.t('figure.text.follow-would-loop')
580
680
  : code === 'FASTEN_CYCLE' || code === 'ATTACH_CYCLE'
581
681
  ? i18next.t('figure.text.fasten-would-loop')
582
- : error instanceof Error
583
- ? error.message
584
- : String(error);
682
+ : sizeRangeRefusalOf(error, (key, params) => i18next.t(key, params)) ?? (error instanceof Error ? error.message : String(error));
585
683
  }
586
684
  }
587
685
  changeField(field, event) {
@@ -691,9 +789,118 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
691
789
  <md-icon>aspect_ratio</md-icon>${i18next.t('figure.button.scale-uniformly')}
692
790
  </button>`;
693
791
  }
792
+ /**
793
+ * The parts list, one row per part the author made: a repeat is one row with how many copies it makes now (V2 listed
794
+ * its parts the same way; user, 2026-09-24: the conveyor's sixteen rollers were sixteen rows).
795
+ */
796
+ partRows(geometry) {
797
+ const rows = new Map();
798
+ for (const g of geometry) {
799
+ const id = ownerOf(g.id);
800
+ const row = rows.get(id);
801
+ if (row) {
802
+ row.copies = (row.copies ?? 1) + 1;
803
+ continue;
804
+ }
805
+ rows.set(id, {
806
+ id,
807
+ provider: g.shape?.provider ?? '',
808
+ hidden: this.hiddenParts.has(id),
809
+ invisible: g.visible === false,
810
+ ...(id !== g.id ? { copies: 1 } : {})
811
+ });
812
+ }
813
+ return [...rows.values()];
814
+ }
815
+ /** The detail level's part budget for the list's head, from figure-model's count (a repeat once). */
816
+ partBudgetOf(asset) {
817
+ if (!asset)
818
+ return undefined;
819
+ try {
820
+ return { level: asset.detailLevel ?? null, used: v3PartCountOf(asset), limit: asset.detailLevel ? PART_LIMIT[asset.detailLevel] : null };
821
+ }
822
+ catch {
823
+ return undefined;
824
+ }
825
+ }
826
+ /**
827
+ * A repeat: which way its copies run, how many there are now, and how far apart -- V2's `repeat: { axis, pitch }`.
828
+ * The pitch of a row that fills its length is edited as a number (`set-repeat` makes it the part's own the first
829
+ * time); a row of a fixed count says its pitch cannot be set yet (figure-model refuses it the same way).
830
+ */
831
+ renderRepeat(asset, geometry, unavailable) {
832
+ const repeat = repeatOf(this.selected, geometry);
833
+ if (!asset || !repeat || repeat.index)
834
+ return nothing;
835
+ const model = asset.document.model;
836
+ const layout = model.nodes.find(n => n.id === `${this.selected}.layout`);
837
+ const axis = model.nodes.find(n => n.id === `${this.selected}.repeat`)?.params?.axis ?? '?';
838
+ const own = `${this.selected}.repeat.pitch`;
839
+ const pitch = asset.designInputs[own] ?? model.constants.find(c => c.id === layout?.args[4])?.value;
840
+ const fits = layout?.op === 'fit-pitch@1';
841
+ /* What the pitch gives, in numbers: this many copies at the length it is authored, and how long it may be placed. */
842
+ const lengthRef = layout?.args[0] ?? '';
843
+ const length = asset.designInputs[lengthRef];
844
+ const longest = model.inputs.find(i => i.id === lengthRef)?.max;
845
+ const limit = model.constants.find(c => c.id === layout?.args[6])?.value;
846
+ const field = { id: own, unit: 'mm', role: 'design', value: pitch ?? 0 };
847
+ return html `<section>
848
+ <h3>${i18next.t('figure.label.repeat')}</h3>
849
+ <div inline-field><label>${i18next.t('figure.label.repeat-axis')}</label><span value>${axis.toUpperCase()}</span></div>
850
+ <div inline-field><label>${i18next.t('figure.label.repeat-count')}</label><span value>${repeat.count}</span></div>
851
+ <div inline-field>
852
+ <label>${i18next.t('figure.label.repeat-gap')} <span unit>mm</span></label>
853
+ <input
854
+ type="number"
855
+ step="any"
856
+ aria-label=${own}
857
+ .value=${String(pitch ?? '')}
858
+ ?disabled=${unavailable || !fits}
859
+ @change=${(e) => this.setPitch(field, e)}
860
+ />
861
+ </div>
862
+ <p hint>
863
+ ${fits && length !== undefined && longest !== undefined && limit !== undefined
864
+ ? i18next.t('figure.text.repeat-what-pitch-gives', {
865
+ length: length.toLocaleString(),
866
+ count: repeat.count,
867
+ longest: longest.toLocaleString(),
868
+ limit
869
+ })
870
+ : i18next.t(fits ? 'figure.text.repeat-fits-length' : 'figure.text.repeat-fixed-count')}
871
+ </p>
872
+ </section>`;
873
+ }
874
+ /**
875
+ * A pitch always goes through `set-repeat`: the command moves the longest length with it (at most `limit` copies),
876
+ * which a plain input edit would not. Refused only when the authored length would already hold more copies than
877
+ * that -- said with the numbers.
878
+ */
879
+ setPitch(field, event) {
880
+ const input = event.target;
881
+ const pitch = input.valueAsNumber;
882
+ if (!Number.isFinite(pitch) || pitch === field.value)
883
+ return;
884
+ try {
885
+ this.session.command({ kind: 'set-repeat', part: this.selected, pitch });
886
+ this.error = '';
887
+ this.changed();
888
+ }
889
+ catch (error) {
890
+ input.value = String(field.value);
891
+ const code = error?.code;
892
+ const message = error instanceof Error ? error.message : String(error);
893
+ const found = /\((\d[\d.]*) mm\) a pitch under ([\d.]+) mm makes more than (\d+)/.exec(message);
894
+ this.error =
895
+ code === 'INPUT_RANGE' && found
896
+ ? i18next.t('figure.text.repeat-pitch-too-small', { length: Number(found[1]).toLocaleString(), min: found[2], limit: found[3] })
897
+ : message;
898
+ }
899
+ }
694
900
  /** Choose a part, from the list, the canvas or a place row. A figure-level tab gives way to the part's properties. */
695
901
  choose(id) {
696
- this.selected = id;
902
+ /* A copy on the canvas selects its repeat: the part the author made. */
903
+ this.selected = id ? ownerOf(id) : id;
697
904
  if (id && (this.tab === 'verdict' || this.tab === 'view' || this.tab === 'history'))
698
905
  this.tab = 'properties';
699
906
  }
@@ -731,11 +938,12 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
731
938
  if (!view)
732
939
  return html `<p>${i18next.t('figure.text.opening-model')}</p>`;
733
940
  const unavailable = view.loading || !!view.error || view.saving || !view.context;
734
- const part = view.geometry.find(g => g.id === this.selected);
941
+ const part = view.geometry.find(g => ownerOf(g.id) === this.selected);
735
942
  /* The controls (state inputs) have their own place in the motion tab; these are the design values. */
736
943
  const design = view.fields.filter(i => i.role !== 'state');
737
- const own = design.filter(i => this.selected && i.id.startsWith(this.selected + '.'));
738
- const common = design.filter(i => !view.geometry.some(g => i.id.startsWith(g.id + '.')));
944
+ /* A repeat's own row values (its pitch) have their own section, not the shape's. */
945
+ const own = design.filter(i => this.selected && i.id.startsWith(this.selected + '.') && !i.id.startsWith(this.selected + '.repeat.'));
946
+ const common = design.filter(i => !view.geometry.some(g => i.id.startsWith(ownerOf(g.id) + '.')));
739
947
  const status = view.loading
740
948
  ? 'figure.text.loading-figure'
741
949
  : view.saving
@@ -769,18 +977,15 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
769
977
  slot="start"
770
978
  .budget=${this.budget}
771
979
  @show-maturity=${() => (this.tab = 'verdict')}
772
- .parts=${view.geometry.map((g) => ({
773
- id: g.id,
774
- provider: g.shape?.provider ?? '',
775
- hidden: this.hiddenParts.has(g.id),
776
- invisible: g.visible === false
777
- }))}
980
+ .parts=${this.partRows(view.geometry)}
981
+ .partBudget=${this.partBudgetOf(asset)}
982
+ @set-detail-level=${(e) => this.linkCommand({ kind: 'set-detail-level', level: e.detail.level })}
778
983
  .selected=${this.selected}
779
984
  ?locked=${unavailable}
780
985
  @add-part=${(e) => this.add(e.detail.kind)}
781
986
  @select-part=${(e) => this.choose(e.detail.id)}
782
987
  @toggle-part-hidden=${(e) => this.toggleHiddenPart(e.detail.id)}
783
- @set-all-parts-hidden=${(e) => (this.hiddenParts = e.detail.hidden ? new Set(view.geometry.map(g => g.id)) : new Set())}
988
+ @set-all-parts-hidden=${(e) => (this.hiddenParts = e.detail.hidden ? new Set(view.geometry.map(g => ownerOf(g.id))) : new Set())}
784
989
  @move-part-up=${(e) => this.movePart(e.detail.id, e.detail.to)}
785
990
  @move-part-down=${(e) => this.movePart(e.detail.id, e.detail.to)}
786
991
  @duplicate-part=${(e) => this.duplicatePart(e.detail.id)}
@@ -834,10 +1039,11 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
834
1039
  </div>
835
1040
  ${this.renderRepeatNote(view.geometry)}
836
1041
  </section>
837
- ${this.renderGroups(groupFields(design, this.selected), 'figure.label.group-placement-size', 'figure.label.group-shape', unavailable, this.renderTools(unavailable))}
1042
+ ${this.renderRepeat(asset, view.geometry, unavailable)}
1043
+ ${this.renderGroups(groupFields(own, this.selected), 'figure.label.group-placement-size', 'figure.label.group-shape', unavailable, this.renderTools(unavailable))}
838
1044
  ${this.renderLinks(unavailable, 'shape')}
839
1045
  <figure-v3-material
840
- .body=${part}
1046
+ .body=${{ ...part, id: this.selected }}
841
1047
  .palette=${view.palette ?? {}}
842
1048
  ?locked=${unavailable}
843
1049
  @v3-part-edit=${(e) => this.run(() => this.session.edit(e.detail.edit))}
@@ -939,6 +1145,10 @@ __decorate([
939
1145
  property({ attribute: false }),
940
1146
  __metadata("design:type", Object)
941
1147
  ], FigureV3Editor.prototype, "budget", void 0);
1148
+ __decorate([
1149
+ property({ attribute: false }),
1150
+ __metadata("design:type", Function)
1151
+ ], FigureV3Editor.prototype, "firstSave", void 0);
942
1152
  __decorate([
943
1153
  state(),
944
1154
  __metadata("design:type", Object)