@things-factory/figure-ui 10.1.57 → 10.1.59

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.
@@ -3,6 +3,7 @@ import { i18next } from '@operato/i18n'
3
3
  import type { OxThreePane } from '@operato/layout'
4
4
  import '@operato/layout'
5
5
  import { customElement, property, state } from 'lit/decorators.js'
6
+ import { live } from 'lit/directives/live.js'
6
7
  import { PART_LIMIT, v3PartCountOf } from '@hatiolab/figure-model'
7
8
  import type { V3Asset, V3AuthoringAction, V3InputChange, V3PartEdit, V3PrimitiveCreation } from '@hatiolab/figure-model'
8
9
  import type { V3ProductSession } from './v3-product-session.js'
@@ -31,7 +32,7 @@ import { nudgeOf } from './v3-stage-model.js'
31
32
  import { narrowingRetryOf, partRefusalOf, sizeRangeRefusalOf } from './v3-refusals.js'
32
33
  import type { V3RenderPart } from './v3-renderer.js'
33
34
  import type { V3BudgetLine } from './v3-maturity.js'
34
- import { instanceRangeOf, instanceRangeToDeclare, rangeDiffers, turnsAboutAJoint } from './v3-links.js'
35
+ import { instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './v3-links.js'
35
36
  import { jointFanOf } from './v3-joint-fan.js'
36
37
  import { jointDragOf } from './v3-joint-drag.js'
37
38
  import type { FirstSave } from './v3-first-save.js'
@@ -764,7 +765,22 @@ export class FigureV3Editor extends LitElement {
764
765
  <h3>${i18next.t('figure.label.instance-range')}</h3>
765
766
  ${(['x', 'y', 'z'] as const).map(
766
767
  axis => html`<div inline-field>
767
- <label>${axis.toUpperCase()}</label><span>${mm(range.declared[axis].min)} – ${mm(range.declared[axis].max)} mm</span>
768
+ <label>${axis.toUpperCase()} <span unit>mm</span></label>
769
+ <div pair>
770
+ ${(['min', 'max'] as const).map(
771
+ bound => html`<input
772
+ aria-label=${`size.${axis}.${bound}`}
773
+ type="number"
774
+ step="any"
775
+ .value=${live(String(Math.round(range.declared[axis][bound])))}
776
+ ?disabled=${unavailable}
777
+ @change=${(e: Event) => {
778
+ const next = rangeWith(range.declared, axis, bound, (e.target as HTMLInputElement).valueAsNumber)
779
+ if (next) this.linkCommand({ kind: 'declare-instance-size', range: next, replace: true })
780
+ }}
781
+ />`
782
+ )}
783
+ </div>
768
784
  </div>`
769
785
  )}
770
786
  ${differs
@@ -788,7 +804,18 @@ export class FigureV3Editor extends LitElement {
788
804
  * it is offered it -- a mechanism does not shear -- and the author decides (chief architect's ruling 2026-09-24).
789
805
  */
790
806
  private renderSizingRule(asset: V3Asset, unavailable: boolean) {
791
- if (asset.sizing === 'uniform') return html`<div inline-field><label>${i18next.t('figure.label.sizing-rule')}</label><span>${i18next.t('figure.label.sizing-uniform')}</span></div>`
807
+ if (asset.sizing === 'uniform') {
808
+ /* One ratio reaches only what reads the size: every other length is offered to follow it, in one step. */
809
+ const follow = proportionToFollow(asset)
810
+ return html`<div inline-field><label>${i18next.t('figure.label.sizing-rule')}</label><span>${i18next.t('figure.label.sizing-uniform')}</span></div>
811
+ ${follow?.count
812
+ ? html`<p hint>${i18next.t('figure.text.proportion-to-follow', { count: follow.count })}</p>
813
+ <button act ?disabled=${unavailable} @click=${() => this.linkCommand({ kind: 'follow-size-in-proportion' })}>
814
+ <md-icon>open_in_full</md-icon>${i18next.t('figure.button.follow-size-in-proportion')}
815
+ </button>`
816
+ : nothing}
817
+ ${follow?.kept.length ? html`<p hint>${i18next.t('figure.text.proportion-kept', { what: follow.kept.join(', ') })}</p>` : nothing}`
818
+ }
792
819
  return html`${turnsAboutAJoint(asset) ? html`<p hint>${i18next.t('figure.text.sizing-uniform-proposed')}</p>` : nothing}
793
820
  <button act ?disabled=${unavailable} title=${i18next.t('figure.text.sizing-uniform-explained')} @click=${() => this.linkCommand({ kind: 'scale-uniformly' })}>
794
821
  <md-icon>aspect_ratio</md-icon>${i18next.t('figure.button.scale-uniformly')}
@@ -842,7 +869,8 @@ export class FigureV3Editor extends LitElement {
842
869
  const layout = model.nodes.find(n => n.id === `${this.selected}.layout`)
843
870
  const axis = model.nodes.find(n => n.id === `${this.selected}.repeat`)?.params?.axis ?? '?'
844
871
  const own = `${this.selected}.repeat.pitch`
845
- const pitch = asset.designInputs[own] ?? model.constants.find(c => c.id === layout?.args[4])?.value
872
+ /* The pitch as it stands: the part's own number, a constant, or what a pitch that follows a length comes to. */
873
+ const pitch = asset.designInputs[own] ?? model.constants.find(c => c.id === layout?.args[4])?.value ?? valueNow(asset, layout?.args[4])
846
874
  const fits = layout?.op === 'fit-pitch@1'
847
875
  /* What the pitch gives, in numbers: this many copies at the length it is authored, and how long it may be placed. */
848
876
  const lengthRef = layout?.args[0] ?? ''
@@ -1,4 +1,4 @@
1
- import { V3_FACES, v3ShapeFaces, v3PartsExtentOf, compileV3Graph, defaultInstanceSizeRange, foldInstanceSizeRange, v3AttachmentsOf, v3BindingsOf, v3InstanceSizeLimitsOf } from '@hatiolab/figure-model'
1
+ import { V3_FACES, v3ShapeFaces, v3PartsExtentOf, compileV3Graph, proposeV3Proportion, defaultInstanceSizeRange, foldInstanceSizeRange, v3AttachmentsOf, v3BindingsOf, v3InstanceSizeLimitsOf } from '@hatiolab/figure-model'
2
2
  import type { V3SizeRange } from '@hatiolab/figure-model'
3
3
  import type { V3Asset, V3BindingPlace, V3Face, V3Joint } from '@hatiolab/figure-model'
4
4
 
@@ -442,6 +442,44 @@ export function instanceRangeToDeclare(asset: V3Asset): { range: V3SizeRange; at
442
442
  }
443
443
 
444
444
  /** Whether measuring again would change the declared range (to a micrometre). */
445
+ /**
446
+ * The declared range with one end typed by the author: the sizes a figure may be placed at are a claim about the
447
+ * product (a gantry spans metres, not 30 cm to 29 m), and measuring only ever widens them. Nothing when the typed
448
+ * value is not a number; whether the figure holds there is the command's to say.
449
+ */
450
+ export function rangeWith(declared: V3SizeRange, axis: Axis, bound: 'min' | 'max', value: number): V3SizeRange | undefined {
451
+ if (!Number.isFinite(value)) return undefined
452
+ const next = structuredClone(declared)
453
+ next[axis][bound] = value
454
+ return next
455
+ }
456
+
457
+ /**
458
+ * For a figure that grows by one ratio: how many of its lengths would follow the ratio, and which it leaves with their
459
+ * own number (not those that follow another length -- they grow with it). Nothing for a figure of another sizing.
460
+ */
461
+ export function proportionToFollow(asset: V3Asset): { count: number; kept: string[] } | undefined {
462
+ if (asset.sizing !== 'uniform') return undefined
463
+ try {
464
+ const plan = proposeV3Proportion(asset)
465
+ return { count: plan.dimensions.length + plan.lengths.length, kept: plan.skipped.filter(s => !s.follows).map(s => s.what) }
466
+ } catch {
467
+ return undefined
468
+ }
469
+ }
470
+
471
+ /** A value of the figure as it stands, by its graph reference. */
472
+ export function valueNow(asset: V3Asset, ref: string | undefined): number | undefined {
473
+ if (!ref) return undefined
474
+ try {
475
+ const values = compileV3Graph(asset.document.model as never).evaluate({ ...asset.designInputs, ...asset.stateDefaults }).values as Record<string, number>
476
+ const value = values[ref]
477
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
478
+ } catch {
479
+ return undefined
480
+ }
481
+ }
482
+
445
483
  export function rangeDiffers(a: V3SizeRange, b: V3SizeRange): boolean {
446
484
  return AXES.some(axis => Math.abs(a[axis].min - b[axis].min) > 1e-3 || Math.abs(a[axis].max - b[axis].max) > 1e-3)
447
485
  }
@@ -34,6 +34,6 @@ export declare const trackEdit: (clip: Clip, track: ClipTrack, patch?: Partial<P
34
34
  state: string;
35
35
  clip: string;
36
36
  keys: ClipKey[];
37
- loop: "wrap" | "hold";
37
+ loop: "hold" | "wrap";
38
38
  interpolation: "linear" | "step";
39
39
  };
@@ -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 { live } from 'lit/directives/live.js';
7
8
  import { PART_LIMIT, v3PartCountOf } from '@hatiolab/figure-model';
8
9
  import { captureV3Thumbnail } from './v3-thumbnail.js';
9
10
  import { drawableV3Parts } from './v3-renderer.js';
@@ -26,7 +27,7 @@ import { alignChanges, boxOf, canMirror, mirrorChanges } from './v3-align.js';
26
27
  import { paletteRecipe } from './v3-palette.js';
27
28
  import { nudgeOf } from './v3-stage-model.js';
28
29
  import { narrowingRetryOf, partRefusalOf, sizeRangeRefusalOf } from './v3-refusals.js';
29
- import { instanceRangeOf, instanceRangeToDeclare, rangeDiffers, turnsAboutAJoint } from './v3-links.js';
30
+ import { instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './v3-links.js';
30
31
  import { jointFanOf } from './v3-joint-fan.js';
31
32
  import { jointDragOf } from './v3-joint-drag.js';
32
33
  import { liveOverSession, NOTHING_LIVE, scrubbed, scrubStep } from './v3-live.js';
@@ -778,7 +779,21 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
778
779
  return html `<section>
779
780
  <h3>${i18next.t('figure.label.instance-range')}</h3>
780
781
  ${['x', 'y', 'z'].map(axis => html `<div inline-field>
781
- <label>${axis.toUpperCase()}</label><span>${mm(range.declared[axis].min)} – ${mm(range.declared[axis].max)} mm</span>
782
+ <label>${axis.toUpperCase()} <span unit>mm</span></label>
783
+ <div pair>
784
+ ${['min', 'max'].map(bound => html `<input
785
+ aria-label=${`size.${axis}.${bound}`}
786
+ type="number"
787
+ step="any"
788
+ .value=${live(String(Math.round(range.declared[axis][bound])))}
789
+ ?disabled=${unavailable}
790
+ @change=${(e) => {
791
+ const next = rangeWith(range.declared, axis, bound, e.target.valueAsNumber);
792
+ if (next)
793
+ this.linkCommand({ kind: 'declare-instance-size', range: next, replace: true });
794
+ }}
795
+ />`)}
796
+ </div>
782
797
  </div>`)}
783
798
  ${differs
784
799
  ? html `<p hint>
@@ -801,8 +816,18 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
801
816
  * it is offered it -- a mechanism does not shear -- and the author decides (chief architect's ruling 2026-09-24).
802
817
  */
803
818
  renderSizingRule(asset, unavailable) {
804
- if (asset.sizing === 'uniform')
805
- return html `<div inline-field><label>${i18next.t('figure.label.sizing-rule')}</label><span>${i18next.t('figure.label.sizing-uniform')}</span></div>`;
819
+ if (asset.sizing === 'uniform') {
820
+ /* One ratio reaches only what reads the size: every other length is offered to follow it, in one step. */
821
+ const follow = proportionToFollow(asset);
822
+ return html `<div inline-field><label>${i18next.t('figure.label.sizing-rule')}</label><span>${i18next.t('figure.label.sizing-uniform')}</span></div>
823
+ ${follow?.count
824
+ ? html `<p hint>${i18next.t('figure.text.proportion-to-follow', { count: follow.count })}</p>
825
+ <button act ?disabled=${unavailable} @click=${() => this.linkCommand({ kind: 'follow-size-in-proportion' })}>
826
+ <md-icon>open_in_full</md-icon>${i18next.t('figure.button.follow-size-in-proportion')}
827
+ </button>`
828
+ : nothing}
829
+ ${follow?.kept.length ? html `<p hint>${i18next.t('figure.text.proportion-kept', { what: follow.kept.join(', ') })}</p>` : nothing}`;
830
+ }
806
831
  return html `${turnsAboutAJoint(asset) ? html `<p hint>${i18next.t('figure.text.sizing-uniform-proposed')}</p>` : nothing}
807
832
  <button act ?disabled=${unavailable} title=${i18next.t('figure.text.sizing-uniform-explained')} @click=${() => this.linkCommand({ kind: 'scale-uniformly' })}>
808
833
  <md-icon>aspect_ratio</md-icon>${i18next.t('figure.button.scale-uniformly')}
@@ -855,7 +880,8 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
855
880
  const layout = model.nodes.find(n => n.id === `${this.selected}.layout`);
856
881
  const axis = model.nodes.find(n => n.id === `${this.selected}.repeat`)?.params?.axis ?? '?';
857
882
  const own = `${this.selected}.repeat.pitch`;
858
- const pitch = asset.designInputs[own] ?? model.constants.find(c => c.id === layout?.args[4])?.value;
883
+ /* The pitch as it stands: the part's own number, a constant, or what a pitch that follows a length comes to. */
884
+ const pitch = asset.designInputs[own] ?? model.constants.find(c => c.id === layout?.args[4])?.value ?? valueNow(asset, layout?.args[4]);
859
885
  const fits = layout?.op === 'fit-pitch@1';
860
886
  /* What the pitch gives, in numbers: this many copies at the length it is authored, and how long it may be placed. */
861
887
  const lengthRef = layout?.args[0] ?? '';