@things-factory/figure-ui 10.1.48 → 10.1.49

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 (34) hide show
  1. package/client/modeller/figure-v3-parts.ts +85 -10
  2. package/client/modeller/side-panel-styles.ts +8 -0
  3. package/client/modeller/v3-canvas.ts +25 -5
  4. package/client/modeller/v3-editor.ts +159 -22
  5. package/client/modeller/v3-fields.ts +15 -1
  6. package/client/modeller/v3-stage-model.ts +24 -0
  7. package/dist-client/modeller/figure-v3-parts.d.ts +13 -0
  8. package/dist-client/modeller/figure-v3-parts.js +80 -10
  9. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  10. package/dist-client/modeller/side-panel-styles.js +8 -0
  11. package/dist-client/modeller/side-panel-styles.js.map +1 -1
  12. package/dist-client/modeller/v3-canvas.d.ts +2 -0
  13. package/dist-client/modeller/v3-canvas.js +27 -5
  14. package/dist-client/modeller/v3-canvas.js.map +1 -1
  15. package/dist-client/modeller/v3-clips.d.ts +1 -1
  16. package/dist-client/modeller/v3-editor.d.ts +19 -0
  17. package/dist-client/modeller/v3-editor.js +160 -20
  18. package/dist-client/modeller/v3-editor.js.map +1 -1
  19. package/dist-client/modeller/v3-fields.d.ts +6 -0
  20. package/dist-client/modeller/v3-fields.js +14 -2
  21. package/dist-client/modeller/v3-fields.js.map +1 -1
  22. package/dist-client/modeller/v3-stage-model.d.ts +8 -0
  23. package/dist-client/modeller/v3-stage-model.js +26 -0
  24. package/dist-client/modeller/v3-stage-model.js.map +1 -1
  25. package/dist-client/tsconfig.tsbuildinfo +1 -1
  26. package/package.json +5 -5
  27. package/test/v3-fields.test.ts +37 -1
  28. package/test/v3-gizmo.test.ts +3 -1
  29. package/test/v3-maturity.test.ts +1 -1
  30. package/translations/en.json +11 -1
  31. package/translations/ja.json +11 -1
  32. package/translations/ko.json +11 -1
  33. package/translations/ms.json +11 -1
  34. package/translations/zh.json +11 -1
@@ -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,10 +19,11 @@ 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';
23
24
  import { alignChanges, boxOf, canMirror, mirrorChanges } from './v3-align.js';
24
25
  import { paletteRecipe } from './v3-palette.js';
26
+ import { nudgeOf } from './v3-stage-model.js';
25
27
  import { instanceRangeOf, rangeDiffers, turnsAboutAJoint } from './v3-links.js';
26
28
  import { jointFanOf } from './v3-joint-fan.js';
27
29
  import { isLive, livePartsOf, NOTHING_LIVE, scrubbed, scrubStep } from './v3-live.js';
@@ -280,7 +282,7 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
280
282
  this.error = '';
281
283
  this.hiddenParts = new Set();
282
284
  }
283
- if (view && !view.geometry.some(g => g.id === this.selected))
285
+ if (view && !view.geometry.some(g => ownerOf(g.id) === this.selected))
284
286
  this.selected = '';
285
287
  }
286
288
  run(action) {
@@ -300,7 +302,7 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
300
302
  /** What the canvas draws: the figure with the value in hand when there is one, and without the parts hidden on the workbench. */
301
303
  drawnParts(geometry, asset) {
302
304
  const parts = (isLive(this.live) && asset ? livePartsOf(asset, this.live) : null) ?? drawableV3Parts(geometry ?? []);
303
- return parts.filter(body => !this.hiddenParts.has(body.id));
305
+ return parts.filter(body => !this.hiddenParts.has(ownerOf(body.id)));
304
306
  }
305
307
  /**
306
308
  * Where the selected part turns and how far, with the pose as it is drawn now -- a value in hand included, so the
@@ -403,7 +405,8 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
403
405
  /** What the gizmo may write for the selected part — read off the asset being edited. */
404
406
  reachOf(id) {
405
407
  const none = { translate: false, rotate: false, scale: false };
406
- if (!id || !this.session)
408
+ /* A repeat is moved and sized through its row, not by a gizmo on one copy. */
409
+ if (!id || !this.session || !this.session.read().geometry.some(g => g.id === id))
407
410
  return none;
408
411
  try {
409
412
  return gizmoReachOf(JSON.parse(this.session.source()), id);
@@ -417,10 +420,39 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
417
420
  const view = this.session.read();
418
421
  const sizes = Object.fromEntries(view.fields.map(field => [field.id, field.value]));
419
422
  const changes = gizmoChanges(id, this.reachOf(id), result, sizes);
420
- if (changes.length)
423
+ if (changes.length) {
421
424
  this.run(() => this.session.apply(changes));
422
- else
425
+ return;
426
+ }
427
+ /*
428
+ A part whose place is worked out (a converted one) has no numbers for the gizmo to write: the drag is added beside
429
+ its rule (`nudge-part`). A part a joint or a fastening places, or a size that is worked out, is refused -- said in
430
+ words, not by the part silently springing back (user, 2026-09-24: 「기즈모 컨트롤도 전혀 작동이 안되는군」).
431
+ */
432
+ const body = view.geometry.find(g => g.id === id);
433
+ if (!body)
434
+ return this.requestUpdate();
435
+ if (result.scale && !result.position && !result.rotation) {
436
+ this.error = i18next.t('figure.text.gizmo-size-worked-out', { part: partLabelOf(id) });
437
+ return this.requestUpdate();
438
+ }
439
+ const { by, turn } = nudgeOf(body, result);
440
+ if (!Object.keys(by).length && !Object.keys(turn).length)
441
+ return this.requestUpdate();
442
+ try {
443
+ this.session.command({ kind: 'nudge-part', part: id, ...(Object.keys(by).length ? { by } : {}), ...(Object.keys(turn).length ? { turn } : {}) });
444
+ this.error = '';
445
+ this.changed();
446
+ }
447
+ catch (error) {
448
+ const message = error instanceof Error ? error.message : String(error);
449
+ this.error = /carried by a joint/.test(message)
450
+ ? i18next.t('figure.text.gizmo-joint-places', { part: partLabelOf(id) })
451
+ : /is fastened to/.test(message)
452
+ ? i18next.t('figure.text.gizmo-fastening-places', { part: partLabelOf(id) })
453
+ : message;
423
454
  this.requestUpdate();
455
+ }
424
456
  }
425
457
  /** 맞추기 — one tool, one step of the history, through the same inputs as the gizmo. */
426
458
  align(tool) {
@@ -486,7 +518,7 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
486
518
  */
487
519
  renderRepeatNote(geometry) {
488
520
  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;
521
+ return repeat?.index ? html `<p hint>${i18next.t('figure.text.repeat-member', { name: repeat.name, count: repeat.count, index: repeat.index })}</p>` : nothing;
490
522
  }
491
523
  renamePart(input) {
492
524
  const name = input.value.trim();
@@ -691,9 +723,118 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
691
723
  <md-icon>aspect_ratio</md-icon>${i18next.t('figure.button.scale-uniformly')}
692
724
  </button>`;
693
725
  }
726
+ /**
727
+ * The parts list, one row per part the author made: a repeat is one row with how many copies it makes now (V2 listed
728
+ * its parts the same way; user, 2026-09-24: the conveyor's sixteen rollers were sixteen rows).
729
+ */
730
+ partRows(geometry) {
731
+ const rows = new Map();
732
+ for (const g of geometry) {
733
+ const id = ownerOf(g.id);
734
+ const row = rows.get(id);
735
+ if (row) {
736
+ row.copies = (row.copies ?? 1) + 1;
737
+ continue;
738
+ }
739
+ rows.set(id, {
740
+ id,
741
+ provider: g.shape?.provider ?? '',
742
+ hidden: this.hiddenParts.has(id),
743
+ invisible: g.visible === false,
744
+ ...(id !== g.id ? { copies: 1 } : {})
745
+ });
746
+ }
747
+ return [...rows.values()];
748
+ }
749
+ /** The detail level's part budget for the list's head, from figure-model's count (a repeat once). */
750
+ partBudgetOf(asset) {
751
+ if (!asset)
752
+ return undefined;
753
+ try {
754
+ return { level: asset.detailLevel ?? null, used: v3PartCountOf(asset), limit: asset.detailLevel ? PART_LIMIT[asset.detailLevel] : null };
755
+ }
756
+ catch {
757
+ return undefined;
758
+ }
759
+ }
760
+ /**
761
+ * A repeat: which way its copies run, how many there are now, and how far apart -- V2's `repeat: { axis, pitch }`.
762
+ * The pitch of a row that fills its length is edited as a number (`set-repeat` makes it the part's own the first
763
+ * time); a row of a fixed count says its pitch cannot be set yet (figure-model refuses it the same way).
764
+ */
765
+ renderRepeat(asset, geometry, unavailable) {
766
+ const repeat = repeatOf(this.selected, geometry);
767
+ if (!asset || !repeat || repeat.index)
768
+ return nothing;
769
+ const model = asset.document.model;
770
+ const layout = model.nodes.find(n => n.id === `${this.selected}.layout`);
771
+ const axis = model.nodes.find(n => n.id === `${this.selected}.repeat`)?.params?.axis ?? '?';
772
+ const own = `${this.selected}.repeat.pitch`;
773
+ const pitch = asset.designInputs[own] ?? model.constants.find(c => c.id === layout?.args[4])?.value;
774
+ const fits = layout?.op === 'fit-pitch@1';
775
+ /* What the pitch gives, in numbers: this many copies at the length it is authored, and how long it may be placed. */
776
+ const lengthRef = layout?.args[0] ?? '';
777
+ const length = asset.designInputs[lengthRef];
778
+ const longest = model.inputs.find(i => i.id === lengthRef)?.max;
779
+ const limit = model.constants.find(c => c.id === layout?.args[6])?.value;
780
+ const field = { id: own, unit: 'mm', role: 'design', value: pitch ?? 0 };
781
+ return html `<section>
782
+ <h3>${i18next.t('figure.label.repeat')}</h3>
783
+ <div inline-field><label>${i18next.t('figure.label.repeat-axis')}</label><span value>${axis.toUpperCase()}</span></div>
784
+ <div inline-field><label>${i18next.t('figure.label.repeat-count')}</label><span value>${repeat.count}</span></div>
785
+ <div inline-field>
786
+ <label>${i18next.t('figure.label.repeat-gap')} <span unit>mm</span></label>
787
+ <input
788
+ type="number"
789
+ step="any"
790
+ aria-label=${own}
791
+ .value=${String(pitch ?? '')}
792
+ ?disabled=${unavailable || !fits}
793
+ @change=${(e) => this.setPitch(field, e)}
794
+ />
795
+ </div>
796
+ <p hint>
797
+ ${fits && length !== undefined && longest !== undefined && limit !== undefined
798
+ ? i18next.t('figure.text.repeat-what-pitch-gives', {
799
+ length: length.toLocaleString(),
800
+ count: repeat.count,
801
+ longest: longest.toLocaleString(),
802
+ limit
803
+ })
804
+ : i18next.t(fits ? 'figure.text.repeat-fits-length' : 'figure.text.repeat-fixed-count')}
805
+ </p>
806
+ </section>`;
807
+ }
808
+ /**
809
+ * A pitch always goes through `set-repeat`: the command moves the longest length with it (at most `limit` copies),
810
+ * which a plain input edit would not. Refused only when the authored length would already hold more copies than
811
+ * that -- said with the numbers.
812
+ */
813
+ setPitch(field, event) {
814
+ const input = event.target;
815
+ const pitch = input.valueAsNumber;
816
+ if (!Number.isFinite(pitch) || pitch === field.value)
817
+ return;
818
+ try {
819
+ this.session.command({ kind: 'set-repeat', part: this.selected, pitch });
820
+ this.error = '';
821
+ this.changed();
822
+ }
823
+ catch (error) {
824
+ input.value = String(field.value);
825
+ const code = error?.code;
826
+ const message = error instanceof Error ? error.message : String(error);
827
+ const found = /\((\d[\d.]*) mm\) a pitch under ([\d.]+) mm makes more than (\d+)/.exec(message);
828
+ this.error =
829
+ code === 'INPUT_RANGE' && found
830
+ ? i18next.t('figure.text.repeat-pitch-too-small', { length: Number(found[1]).toLocaleString(), min: found[2], limit: found[3] })
831
+ : message;
832
+ }
833
+ }
694
834
  /** Choose a part, from the list, the canvas or a place row. A figure-level tab gives way to the part's properties. */
695
835
  choose(id) {
696
- this.selected = id;
836
+ /* A copy on the canvas selects its repeat: the part the author made. */
837
+ this.selected = id ? ownerOf(id) : id;
697
838
  if (id && (this.tab === 'verdict' || this.tab === 'view' || this.tab === 'history'))
698
839
  this.tab = 'properties';
699
840
  }
@@ -731,11 +872,12 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
731
872
  if (!view)
732
873
  return html `<p>${i18next.t('figure.text.opening-model')}</p>`;
733
874
  const unavailable = view.loading || !!view.error || view.saving || !view.context;
734
- const part = view.geometry.find(g => g.id === this.selected);
875
+ const part = view.geometry.find(g => ownerOf(g.id) === this.selected);
735
876
  /* The controls (state inputs) have their own place in the motion tab; these are the design values. */
736
877
  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 + '.')));
878
+ /* A repeat's own row values (its pitch) have their own section, not the shape's. */
879
+ const own = design.filter(i => this.selected && i.id.startsWith(this.selected + '.') && !i.id.startsWith(this.selected + '.repeat.'));
880
+ const common = design.filter(i => !view.geometry.some(g => i.id.startsWith(ownerOf(g.id) + '.')));
739
881
  const status = view.loading
740
882
  ? 'figure.text.loading-figure'
741
883
  : view.saving
@@ -769,18 +911,15 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
769
911
  slot="start"
770
912
  .budget=${this.budget}
771
913
  @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
- }))}
914
+ .parts=${this.partRows(view.geometry)}
915
+ .partBudget=${this.partBudgetOf(asset)}
916
+ @set-detail-level=${(e) => this.linkCommand({ kind: 'set-detail-level', level: e.detail.level })}
778
917
  .selected=${this.selected}
779
918
  ?locked=${unavailable}
780
919
  @add-part=${(e) => this.add(e.detail.kind)}
781
920
  @select-part=${(e) => this.choose(e.detail.id)}
782
921
  @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())}
922
+ @set-all-parts-hidden=${(e) => (this.hiddenParts = e.detail.hidden ? new Set(view.geometry.map(g => ownerOf(g.id))) : new Set())}
784
923
  @move-part-up=${(e) => this.movePart(e.detail.id, e.detail.to)}
785
924
  @move-part-down=${(e) => this.movePart(e.detail.id, e.detail.to)}
786
925
  @duplicate-part=${(e) => this.duplicatePart(e.detail.id)}
@@ -834,10 +973,11 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
834
973
  </div>
835
974
  ${this.renderRepeatNote(view.geometry)}
836
975
  </section>
837
- ${this.renderGroups(groupFields(design, this.selected), 'figure.label.group-placement-size', 'figure.label.group-shape', unavailable, this.renderTools(unavailable))}
976
+ ${this.renderRepeat(asset, view.geometry, unavailable)}
977
+ ${this.renderGroups(groupFields(own, this.selected), 'figure.label.group-placement-size', 'figure.label.group-shape', unavailable, this.renderTools(unavailable))}
838
978
  ${this.renderLinks(unavailable, 'shape')}
839
979
  <figure-v3-material
840
- .body=${part}
980
+ .body=${{ ...part, id: this.selected }}
841
981
  .palette=${view.palette ?? {}}
842
982
  ?locked=${unavailable}
843
983
  @v3-part-edit=${(e) => this.run(() => this.session.edit(e.detail.edit))}