@things-factory/figure-ui 10.1.125 → 10.1.126

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 (38) hide show
  1. package/client/modeller/align.ts +6 -4
  2. package/client/modeller/editor.ts +142 -32
  3. package/client/modeller/evaluated.ts +47 -0
  4. package/client/modeller/figure-facts.ts +8 -0
  5. package/client/modeller/figure-properties.ts +32 -0
  6. package/client/modeller/figure-verdict.ts +10 -1
  7. package/client/modeller/joint-fan.ts +4 -7
  8. package/client/modeller/links.ts +2 -1
  9. package/dist-client/modeller/align.d.ts +2 -1
  10. package/dist-client/modeller/align.js +6 -4
  11. package/dist-client/modeller/align.js.map +1 -1
  12. package/dist-client/modeller/clips.d.ts +1 -1
  13. package/dist-client/modeller/editor.d.ts +35 -1
  14. package/dist-client/modeller/editor.js +129 -33
  15. package/dist-client/modeller/editor.js.map +1 -1
  16. package/dist-client/modeller/evaluated.d.ts +12 -0
  17. package/dist-client/modeller/evaluated.js +42 -0
  18. package/dist-client/modeller/evaluated.js.map +1 -0
  19. package/dist-client/modeller/figure-facts.d.ts +0 -7
  20. package/dist-client/modeller/figure-facts.js +9 -0
  21. package/dist-client/modeller/figure-facts.js.map +1 -1
  22. package/dist-client/modeller/figure-properties.d.ts +8 -2
  23. package/dist-client/modeller/figure-properties.js +26 -0
  24. package/dist-client/modeller/figure-properties.js.map +1 -1
  25. package/dist-client/modeller/figure-verdict.d.ts +2 -0
  26. package/dist-client/modeller/figure-verdict.js +10 -2
  27. package/dist-client/modeller/figure-verdict.js.map +1 -1
  28. package/dist-client/modeller/joint-fan.js +4 -7
  29. package/dist-client/modeller/joint-fan.js.map +1 -1
  30. package/dist-client/modeller/links.js +3 -2
  31. package/dist-client/modeller/links.js.map +1 -1
  32. package/dist-client/modeller/product-session.d.ts +68 -112
  33. package/dist-client/route.d.ts +1 -1
  34. package/dist-client/tsconfig.scratch.tsbuildinfo +1 -0
  35. package/dist-client/tsconfig.tsbuildinfo +1 -1
  36. package/package.json +3 -3
  37. package/test/evaluated.test.ts +40 -0
  38. package/test/gizmo.test.ts +1 -1
@@ -1,4 +1,5 @@
1
- import { compileFigureAsset, figureOccupiedBoxesOf, figureWritersOf } from '@hatiolab/figure-model'
1
+ import { figureOccupiedBoxesOf, figureWritersOf } from '@hatiolab/figure-model'
2
+ import { evaluatedGeometryOf } from './evaluated.js'
2
3
  import type { FigureAsset, FigureInputChange, FigureWorldBox } from '@hatiolab/figure-model'
3
4
 
4
5
  import type { GizmoReach } from './gizmo.js'
@@ -19,14 +20,15 @@ export type AlignTool = 'centre-x' | 'centre-z' | 'snap'
19
20
 
20
21
  const AXES = ['x', 'y', 'z'] as const
21
22
 
22
- export function boxOf(asset: FigureAsset, part: string): FigureWorldBox | undefined {
23
- const geometry = compileFigureAsset(asset).evaluate().geometry
23
+ /** `values`: the inputs the figure is drawn with, when the box is read as drawn; left out, the asset as it is saved. */
24
+ export function boxOf(asset: FigureAsset, part: string, values?: Record<string, number>): FigureWorldBox | undefined {
25
+ const geometry = evaluatedGeometryOf(asset, values)
24
26
  return figureOccupiedBoxesOf(geometry, figureWritersOf(asset)).find(box => box.part === part)
25
27
  }
26
28
 
27
29
  /** The snapping grid: about five steps across the figure, on 1 · 2 · 5 multiples. */
28
30
  export function gridStepOf(asset: FigureAsset): number {
29
- const geometry = compileFigureAsset(asset).evaluate().geometry
31
+ const geometry = evaluatedGeometryOf(asset)
30
32
  const boxes = figureOccupiedBoxesOf(geometry, figureWritersOf(asset))
31
33
  if (!boxes.length) return 1
32
34
  const span = Math.max(
@@ -6,7 +6,7 @@ import { notify } 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, parseFigureAsset, 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, figureRecipeIdFor, parseFigureAsset, figureSizeEditChanges, figureScaleAnchorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, type FigurePlaneContact, type FigureClipLoss, figurePartFaceOf, type FigureFaceArea, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang, figureRoomOnlyInputsOf, figureRoomRefitOf, seedFigureEvaluation, upgradeFigurePlacement } 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'
@@ -48,6 +48,7 @@ import type { FigureRenderPart } from './renderer.js'
48
48
  import type { FigureBudgetLine } from './maturity.js'
49
49
  import { endPlaceKeyOf, fieldWordsOf, followOf, measureNow, partSummaryOf, instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './links.js'
50
50
  import { jointFanOf } from './joint-fan.js'
51
+ import { seedEvaluatedGeometry } from './evaluated.js'
51
52
  import { jointDragOf } from './joint-drag.js'
52
53
  import type { FirstSave } from './first-save.js'
53
54
  import { isLive, livePartsOf, liveOverSession, NOTHING_LIVE, scrubbed, scrubStep, type LiveValues } from './live.js'
@@ -72,6 +73,20 @@ function foldSaid(): boolean {
72
73
  }
73
74
 
74
75
  /** Product editor surface. The injected session is shared by page persistence and AI commands. */
76
+ /** The words a panel is given when there are none: one array, so it does not read as a change. */
77
+ const NO_WORDS: string[] = []
78
+
79
+ /** How long a command's candidate rests before its preview is worked out. */
80
+ const PREVIEW_SETTLE_MS = 150
81
+
82
+ function deepFreeze<T>(value: T): T {
83
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
84
+ Object.freeze(value)
85
+ for (const inner of Object.values(value)) deepFreeze(inner)
86
+ }
87
+ return value
88
+ }
89
+
75
90
  @customElement('figure-editor')
76
91
  export class FigureEditor extends LitElement {
77
92
  /* The operato scrollbar, as every scrolling panel of the app has it -- the side panel scrolled with the browser's own. */
@@ -517,6 +532,8 @@ export class FigureEditor extends LitElement {
517
532
  disconnectedCallback() {
518
533
  window.removeEventListener('keydown', this.onKey, true)
519
534
  this.widthWatch.disconnect()
535
+ clearTimeout(this.previewTimer)
536
+ clearTimeout(this.detachTimer)
520
537
  super.disconnectedCallback()
521
538
  }
522
539
  /*
@@ -837,7 +854,7 @@ export class FigureEditor extends LitElement {
837
854
  */
838
855
  private insertRecipe(recipe: FigureRecipe, values: Record<string, FigureRecipeValue>) {
839
856
  try {
840
- const asset = JSON.parse(this.session!.source()) as FigureAsset
857
+ const asset = this.plainAsset() as FigureAsset
841
858
  const checked = parseFigureRecipe(recipe)
842
859
  const id = figureRecipeIdFor(asset, checked)
843
860
  const steps = resolveFigureRecipe(checked, { id, ...(checked.target === 'part' ? { target: this.selected } : {}), values })
@@ -853,7 +870,7 @@ export class FigureEditor extends LitElement {
853
870
  /** The design values of the asset being edited. */
854
871
  private designValues(): Record<string, number> {
855
872
  try {
856
- return this.session ? (JSON.parse(this.session.source()).designInputs ?? {}) : {}
873
+ return this.session ? (this.plainAsset().designInputs ?? {}) : {}
857
874
  } catch {
858
875
  return {}
859
876
  }
@@ -864,7 +881,7 @@ export class FigureEditor extends LitElement {
864
881
  /* A repeat is moved and sized through its row, not by a gizmo on one copy. */
865
882
  if (!id || !this.session || !this.session.read().geometry.some(g => g.id === id)) return none
866
883
  try {
867
- const asset = JSON.parse(this.session.source())
884
+ const asset = this.plainAsset()
868
885
  const reach = gizmoReachOf(asset, id)
869
886
  /*
870
887
  A size drag shows the face it keeps and the commit keeps the same (figureScaleAnchorOf). Where figure-model cannot
@@ -889,7 +906,7 @@ export class FigureEditor extends LitElement {
889
906
  private transformed(id: string, result: GizmoResult) {
890
907
  const view = this.session!.read()
891
908
  /* A part hanging from a joint: the drag edits the joint -- its value, or where it sits (ADR-0093). */
892
- const asset = JSON.parse(this.session!.source()) as FigureAsset
909
+ const asset = this.plainAsset() as FigureAsset
893
910
  const drawn = view.geometry.find(g => g.id === id) as unknown as FigureRenderPart | undefined
894
911
  const drag = drawn ? jointDragOf(asset, ownerOf(id), drawn, result, this.currentInputs()) : null
895
912
  if (drag) {
@@ -966,7 +983,7 @@ export class FigureEditor extends LitElement {
966
983
  /** 맞추기 — one tool, one step of the history, through the same inputs as the gizmo. */
967
984
  private align(tool: AlignTool) {
968
985
  this.run(() => {
969
- const asset = JSON.parse(this.session!.source())
986
+ const asset = this.plainAsset()
970
987
  const changes = alignChanges(asset, this.selected, this.reachOf(this.selected), tool)
971
988
  if (changes.length) this.session!.apply(changes)
972
989
  })
@@ -979,7 +996,7 @@ export class FigureEditor extends LitElement {
979
996
  this.session!.edit({ kind: 'duplicate-part', target: id })
980
997
  const copy = this.session!.read().geometry.find(g => !before.has(g.id))?.id
981
998
  if (!copy) return
982
- this.session!.apply(mirrorChanges(JSON.parse(this.session!.source()), id, copy, axis))
999
+ this.session!.apply(mirrorChanges(this.plainAsset(), id, copy, axis))
983
1000
  this.selected = copy
984
1001
  })
985
1002
  }
@@ -1017,7 +1034,7 @@ export class FigureEditor extends LitElement {
1017
1034
  @click=${() => this.alignToPlane()}>${i18next.t(`figure.button.align-to-${placement}`)}</button>`
1018
1035
  }
1019
1036
  private alignToPlane() {
1020
- const asset = parseFigureAsset(this.session!.source())
1037
+ const asset = this.parsedAsset()
1021
1038
  const { changes, contact, refusal } = figurePlaneAlignChanges(asset, this.selected)
1022
1039
  if (refusal) {
1023
1040
  this.error = i18next.t(`figure.text.align-refused-${refusal.toLowerCase().replace(/_/g, '-')}`, { part: partLabelOf(this.selected) })
@@ -1031,7 +1048,7 @@ export class FigureEditor extends LitElement {
1031
1048
  if (!this.selected || !reach.translate) return null
1032
1049
  let asset: unknown
1033
1050
  try {
1034
- asset = JSON.parse(this.session!.source())
1051
+ asset = this.plainAsset()
1035
1052
  } catch {
1036
1053
  return null
1037
1054
  }
@@ -1099,19 +1116,41 @@ export class FigureEditor extends LitElement {
1099
1116
  }
1100
1117
  }
1101
1118
  /** Size response, fastening and motion for the selected part, as authoring commands. */
1102
- private renderLinks(unavailable: boolean, section: 'shape' | 'places' | 'motion') {
1103
- let asset
1119
+ /*
1120
+ What a panel is handed is the same object while it says the same thing: a new array each render reads as a change to
1121
+ the panel, which draws itself again for nothing (sixteen times for one edit).
1122
+ */
1123
+ private dimsSaid?: { key: string; dims: [number, number, number] | undefined }
1124
+ private dimsOf(asset: FigureAsset): [number, number, number] | undefined {
1125
+ const view = this.session?.read()
1126
+ const key = `${view?.context}|${view?.revision}|${this.selected}`
1127
+ if (this.dimsSaid?.key === key) return this.dimsSaid.dims
1128
+ let dims: [number, number, number] | undefined
1104
1129
  try {
1105
- asset = JSON.parse(this.session!.source())
1130
+ dims = boxOf(asset, this.selected, this.currentInputs())?.dims
1106
1131
  } catch {
1107
- return null
1132
+ dims = undefined
1108
1133
  }
1109
- let dims: [number, number, number] | undefined
1134
+ this.dimsSaid = { key, dims }
1135
+ return dims
1136
+ }
1137
+ private previewWordsSaid?: { preview: unknown; part: string; language: string; words: string[] }
1138
+ private previewWordsNow(): string[] {
1139
+ const preview = this.currentPreview()
1140
+ const language = i18next.language
1141
+ if (this.previewWordsSaid && this.previewWordsSaid.preview === preview && this.previewWordsSaid.part === this.selected && this.previewWordsSaid.language === language) return this.previewWordsSaid.words
1142
+ const words = previewWordsOf(preview, this.selected, (key, params) => i18next.t(key, params))
1143
+ this.previewWordsSaid = { preview, part: this.selected, language, words }
1144
+ return words
1145
+ }
1146
+ private renderLinks(unavailable: boolean, section: 'shape' | 'places' | 'motion') {
1147
+ let asset
1110
1148
  try {
1111
- dims = boxOf(asset, this.selected)?.dims
1149
+ asset = this.plainAsset()
1112
1150
  } catch {
1113
- dims = undefined
1151
+ return null
1114
1152
  }
1153
+ const dims = this.dimsOf(asset)
1115
1154
  return html`<figure-links
1116
1155
  .asset=${asset}
1117
1156
  .section=${section}
@@ -1119,10 +1158,10 @@ export class FigureEditor extends LitElement {
1119
1158
  .dims=${dims}
1120
1159
  ?locked=${unavailable}
1121
1160
  @command=${(e: CustomEvent) => this.linkCommand(e.detail.action)}
1122
- .previewWords=${previewWordsOf(this.currentPreview(), this.selected, (key, params) => i18next.t(key, params))}
1161
+ .previewWords=${this.previewWordsNow()}
1123
1162
  .detachWords=${this.detachWords(asset as FigureAsset | undefined)}
1124
- .overhangWords=${section === 'shape' ? this.overhangWords(asset as FigureAsset | undefined) : []}
1125
- .overhangNote=${section === 'shape' ? this.overhangNote(asset as FigureAsset | undefined) : []}
1163
+ .overhangWords=${section === 'shape' ? this.overhangWords(asset as FigureAsset | undefined) : NO_WORDS}
1164
+ .overhangNote=${section === 'shape' ? this.overhangNote(asset as FigureAsset | undefined) : NO_WORDS}
1126
1165
  .motionEnds=${section === 'motion' ? this.motionEnds() : null}
1127
1166
  @go-fasten=${() => this.goFasten()}
1128
1167
  @preview-command=${(e: CustomEvent) => this.previewCommand(e.detail)}
@@ -1151,7 +1190,7 @@ export class FigureEditor extends LitElement {
1151
1190
  private renderControls(view: { fields: Array<{ id: string; role?: string; value: number }> }, unavailable: boolean) {
1152
1191
  let asset
1153
1192
  try {
1154
- asset = JSON.parse(this.session!.source())
1193
+ asset = this.plainAsset()
1155
1194
  } catch {
1156
1195
  return null
1157
1196
  }
@@ -1236,7 +1275,7 @@ export class FigureEditor extends LitElement {
1236
1275
  /* A size typed in keeps the face the figure's placement keeps -- figure-model's one rule, as the gizmo and "follow the size" do. */
1237
1276
  const part = this.selected
1238
1277
  const change: FigureInputChange = { kind: field.role === 'state' ? 'set-state' : 'set-design', input: field.id, unit: field.unit, value }
1239
- this.run(() => this.session!.apply(field.role !== 'state' && part ? figureSizeEditChanges(parseFigureAsset(this.session!.source()), part, [change]) : [change]))
1278
+ this.run(() => this.session!.apply(field.role !== 'state' && part ? figureSizeEditChanges(this.parsedAsset(), part, [change]) : [change]))
1240
1279
  // Rejected edits must also restore the displayed numeric value.
1241
1280
  input.value = String(this.session!.read().fields.find(i => i.id === field.id)!.value)
1242
1281
  }
@@ -1570,7 +1609,7 @@ export class FigureEditor extends LitElement {
1570
1609
  const pitch = input.valueAsNumber
1571
1610
  if (!Number.isFinite(pitch) || pitch === field.value) return
1572
1611
  /* Read from a design input the board may set: the input's value changes, the row keeps reading it (ADR-0096). */
1573
- const from = this.pitchInputOf(JSON.parse(this.session!.source()) as FigureAsset, this.selected)
1612
+ const from = this.pitchInputOf(this.plainAsset() as FigureAsset, this.selected)
1574
1613
  if (from) {
1575
1614
  this.run(() => this.session!.apply([{ kind: 'set-design', input: from, unit: 'mm', value: pitch }]))
1576
1615
  input.value = String(this.session!.read().fields.find(i => i.id === from)?.value ?? field.value)
@@ -1610,19 +1649,33 @@ export class FigureEditor extends LitElement {
1610
1649
  await this.querySelector<HTMLElement & { showRelease?: () => Promise<void> }>('figure-history-panel')?.showRelease?.()
1611
1650
  }
1612
1651
  /** The fastening form's command, seen before it is done; null clears what was shown. */
1652
+ /**
1653
+ * What a command would do is worked out on a copy of the figure -- several compiles of it -- so it waits for the field to
1654
+ * rest: a number typed digit by digit asks once, for the number it ends on. Clearing is at once, and the same question on
1655
+ * the same version is not asked again.
1656
+ */
1657
+ private previewTimer?: ReturnType<typeof setTimeout>
1613
1658
  private previewCommand(candidate: { action: FigureAuthoringAction; face?: { part: string; face: string } } | null) {
1659
+ clearTimeout(this.previewTimer)
1614
1660
  if (!candidate || !this.session || !this.selected) {
1615
1661
  this.preview = undefined
1616
1662
  return
1617
1663
  }
1618
1664
  const view = this.session.read()
1665
+ 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
1666
+ if (same) return
1667
+ this.previewTimer = setTimeout(() => this.computePreview(candidate), PREVIEW_SETTLE_MS)
1668
+ }
1669
+ private computePreview(candidate: { action: FigureAuthoringAction; face?: { part: string; face: string } }) {
1670
+ if (!this.session || !this.selected) return
1671
+ const view = this.session.read()
1619
1672
  const held = { context: view.context!, revision: view.revision!, key: JSON.stringify(candidate), part: this.selected }
1620
1673
  try {
1621
1674
  const seen = this.session.previewCommand(candidate.action)
1622
1675
  let face: FigureFaceArea | undefined
1623
1676
  if (candidate.face) {
1624
1677
  try {
1625
- face = figurePartFaceOf(parseFigureAsset(this.session.source()), candidate.face.part, candidate.face.face, this.currentInputs())
1678
+ face = figurePartFaceOf(this.parsedAsset(), candidate.face.part, candidate.face.face, this.currentInputs())
1626
1679
  } catch {
1627
1680
  face = undefined
1628
1681
  }
@@ -1660,16 +1713,35 @@ export class FigureEditor extends LitElement {
1660
1713
  when the copy shows it does.
1661
1714
  */
1662
1715
  private detachSaid?: { key: string; words: string[] }
1716
+ private detachTimer?: ReturnType<typeof setTimeout>
1717
+ private detachAsked?: string
1718
+ /**
1719
+ * Said once the figure has rested: working it out is a preview of the command on a copy (tens of milliseconds), and the
1720
+ * edit that made this version should be on screen before it. Until then nothing is said, as for a part with no joint.
1721
+ */
1663
1722
  private detachWords(asset: FigureAsset | undefined): string[] {
1664
1723
  const view = this.session?.read()
1665
1724
  const joint = asset?.joints?.find(j => j.body1.part === this.selected)
1666
- if (!asset || !view || !joint || !this.session) return []
1725
+ if (!asset || !view || !joint || !this.session) return NO_WORDS
1667
1726
  const key = `${view.context}|${view.revision}|${this.selected}`
1668
1727
  if (this.detachSaid?.key === key) return this.detachSaid.words
1728
+ if (this.detachAsked !== key) {
1729
+ this.detachAsked = key
1730
+ clearTimeout(this.detachTimer)
1731
+ this.detachTimer = setTimeout(() => {
1732
+ const now = this.session?.read()
1733
+ if (!now || `${now.context}|${now.revision}|${this.selected}` !== key) return
1734
+ this.computeDetachWords(asset, key)
1735
+ this.requestUpdate()
1736
+ }, PREVIEW_SETTLE_MS)
1737
+ }
1738
+ return NO_WORDS
1739
+ }
1740
+ private computeDetachWords(asset: FigureAsset, key: string): string[] {
1669
1741
  const say = (k: string, params?: Record<string, string>) => i18next.t(k, params)
1670
1742
  let words: string[]
1671
1743
  try {
1672
- const seen = this.session.previewCommand({ kind: 'detach', part: this.selected })
1744
+ const seen = this.session!.previewCommand({ kind: 'detach', part: this.selected })
1673
1745
  const own = seen.moves.some(m => m.body === this.selected)
1674
1746
  words = own
1675
1747
  ? [say('figure.text.detach-moves'), ...previewWordsOf({ context: seen.context, revision: seen.revision, key, moves: seen.moves }, this.selected, say)]
@@ -1695,7 +1767,7 @@ export class FigureEditor extends LitElement {
1695
1767
  }
1696
1768
  private overhangWords(asset: FigureAsset | undefined): string[] {
1697
1769
  const view = this.session?.read()
1698
- if (!asset || !view || !this.selected) return []
1770
+ if (!asset || !view || !this.selected) return NO_WORDS
1699
1771
  const key = `${view.context}|${view.revision}|${this.selected}`
1700
1772
  if (this.overhangSaid?.key === key) return this.overhangSaid.words
1701
1773
  const say = (k: string, params?: Record<string, string>) => i18next.t(k, params)
@@ -1731,7 +1803,7 @@ export class FigureEditor extends LitElement {
1731
1803
  if (this.motionEndsSaid?.key !== key) {
1732
1804
  let ends: MotionEnds | null = null
1733
1805
  try {
1734
- ends = motionEndsOf(parseFigureAsset(this.session!.source()), this.selected, this.currentInputs())
1806
+ ends = motionEndsOf(this.parsedAsset(), this.selected, this.currentInputs())
1735
1807
  } catch {
1736
1808
  ends = null
1737
1809
  }
@@ -1770,9 +1842,47 @@ export class FigureEditor extends LitElement {
1770
1842
  this.faceMapping = undefined
1771
1843
  if (id && (this.tab === 'verdict' || this.tab === 'view' || this.tab === 'history')) this.tab = 'properties'
1772
1844
  }
1845
+ /**
1846
+ * The figure as the session holds it now, parsed once per text: a render asks for it a dozen times, and parsing the whole
1847
+ * figure is most of what a selection costs. Read, never changed -- what changes a figure goes through the session.
1848
+ */
1849
+ /**
1850
+ * The session has evaluated this figure at this version, with these inputs: the screen's questions of the text it just
1851
+ * parsed are answered from that, not by evaluating the figure again (each is tens of milliseconds on a real figure).
1852
+ */
1853
+ private seeded(asset: FigureAsset): FigureAsset {
1854
+ const view = this.session?.read()
1855
+ if (view?.geometry.length) {
1856
+ const values = this.currentInputs()
1857
+ seedEvaluatedGeometry(asset, values, view.geometry as never)
1858
+ seedFigureEvaluation(asset, values, view.geometry)
1859
+ }
1860
+ return asset
1861
+ }
1862
+ private parsed?: { text: string; asset: FigureAsset }
1863
+ private parsedAsset(): FigureAsset {
1864
+ const text = this.session!.source()
1865
+ if (this.parsed?.text !== text) {
1866
+ /* The session's text is what its workspace exported, compiled then: it is read here, not compiled again. */
1867
+ const plain = this.plainAsset()
1868
+ const upgraded = upgradeFigurePlacement(plain)
1869
+ this.parsed = { text, asset: upgraded === plain ? plain : this.seeded(deepFreeze(upgraded)) }
1870
+ }
1871
+ return this.parsed.asset
1872
+ }
1873
+ private plain?: { text: string; asset: FigureAsset }
1874
+ /**
1875
+ * The figure as plain data, parsed once per text and frozen: the render, the gizmo's reach and every tool read it, and
1876
+ * a copy for each of them cost more than the selection they serve. A change goes through the session, never through this.
1877
+ */
1878
+ private plainAsset(): FigureAsset {
1879
+ const text = this.session!.source()
1880
+ if (this.plain?.text !== text) this.plain = { text, asset: this.seeded(deepFreeze(JSON.parse(text))) }
1881
+ return this.plain.asset
1882
+ }
1773
1883
  private asset(): unknown {
1774
1884
  try {
1775
- return JSON.parse(this.session!.source())
1885
+ return this.plainAsset()
1776
1886
  } catch {
1777
1887
  return undefined
1778
1888
  }
@@ -1907,7 +2017,7 @@ export class FigureEditor extends LitElement {
1907
2017
  let summary: { kind: string; size: string } | undefined
1908
2018
  try {
1909
2019
  summary = this.selected
1910
- ? partSummaryOf(parseFigureAsset(this.session!.source()), ownerOf(this.selected), (key, params) => i18next.t(key, params), i18next.language || 'ko')
2020
+ ? partSummaryOf(this.parsedAsset(), ownerOf(this.selected), (key, params) => i18next.t(key, params), i18next.language || 'ko')
1911
2021
  : undefined
1912
2022
  } catch {
1913
2023
  summary = undefined
@@ -1944,7 +2054,7 @@ export class FigureEditor extends LitElement {
1944
2054
  */
1945
2055
  let roomOnly: string[] = []
1946
2056
  try {
1947
- roomOnly = this.selected ? figureRoomOnlyInputsOf(parseFigureAsset(this.session!.source()), this.selected) : []
2057
+ roomOnly = this.selected ? figureRoomOnlyInputsOf(this.parsedAsset(), this.selected) : []
1948
2058
  } catch {
1949
2059
  roomOnly = []
1950
2060
  }
@@ -1954,7 +2064,7 @@ export class FigureEditor extends LitElement {
1954
2064
  try {
1955
2065
  const body = view.geometry.find(g => g.id === this.selected) as unknown as FigureRenderPart | undefined
1956
2066
  const placed = body ? placementOf(body) : undefined
1957
- const parsed = this.selected ? parseFigureAsset(this.session!.source()) : undefined
2067
+ const parsed = this.selected ? this.parsedAsset() : undefined
1958
2068
  if (placed && parsed)
1959
2069
  held = {
1960
2070
  position: placed.position,
@@ -2257,7 +2367,7 @@ export class FigureEditor extends LitElement {
2257
2367
  private async askRoomRefit() {
2258
2368
  let refit
2259
2369
  try {
2260
- refit = figureRoomRefitOf(parseFigureAsset(this.session!.source()))
2370
+ refit = figureRoomRefitOf(this.parsedAsset())
2261
2371
  } catch (error) {
2262
2372
  this.error = this.said(error)
2263
2373
  return
@@ -0,0 +1,47 @@
1
+ import { compileFigureAsset } from '@hatiolab/figure-model'
2
+ import type { FigureAsset } from '@hatiolab/figure-model'
3
+
4
+ type Geometry = ReturnType<ReturnType<typeof compileFigureAsset>['evaluate']>['geometry']
5
+
6
+ /**
7
+ * A figure evaluated, held by the asset object it was asked of and the values it was asked under. One selection asks
8
+ * several questions of the same figure as it stands (a part's box, the grid, where a fastening keeps it, the fan of a
9
+ * joint), and each of them evaluated the whole figure again. Another asset object, or other values, is asked afresh.
10
+ * The geometry is read, never changed.
11
+ */
12
+ const held = new WeakMap<object, Map<string, Geometry>>()
13
+
14
+ /** The values in hand that belong to a record's keys (design inputs, or state defaults). */
15
+ const pick = (values: Record<string, number>, of: Record<string, number>) => Object.fromEntries(Object.entries(values).filter(([id]) => id in of))
16
+
17
+ /** The values the asset reads, in one order: the same inputs are one question however they were listed. */
18
+ const keyOf = (own: Record<string, number>, state: Record<string, number>) =>
19
+ JSON.stringify([Object.entries(own).sort(([a], [b]) => (a < b ? -1 : 1)), Object.entries(state).sort(([a], [b]) => (a < b ? -1 : 1))])
20
+
21
+ /**
22
+ * Tells the questions asked of this asset the answer already in hand -- the geometry the session evaluated for this
23
+ * figure under these values -- so the screen does not evaluate what the workspace has just evaluated. The caller says it
24
+ * is the same figure: the asset is the session's text at that version, the values its inputs. Nothing is checked here.
25
+ */
26
+ export function seedEvaluatedGeometry(asset: FigureAsset, values: Record<string, number>, geometry: Geometry): void {
27
+ let mine = held.get(asset)
28
+ if (!mine) held.set(asset, (mine = new Map()))
29
+ mine.set(keyOf(pick(values, asset.designInputs), pick(values, asset.stateDefaults)), geometry)
30
+ }
31
+
32
+ /** `values` left out evaluates the asset as it is; given, they replace the design inputs and state defaults they name. */
33
+ export function evaluatedGeometryOf(asset: FigureAsset, values?: Record<string, number>): Geometry {
34
+ const own = values ? pick(values, asset.designInputs) : undefined
35
+ const state = values ? pick(values, asset.stateDefaults) : undefined
36
+ const key = values ? keyOf(own!, state!) : ''
37
+ let mine = held.get(asset)
38
+ if (!mine) held.set(asset, (mine = new Map()))
39
+ let geometry = mine.get(key)
40
+ if (!geometry) {
41
+ if (mine.size >= 8) mine.clear()
42
+ const source = values ? { ...asset, designInputs: { ...asset.designInputs, ...own }, stateDefaults: { ...asset.stateDefaults, ...state } } : asset
43
+ geometry = compileFigureAsset(source).evaluate().geometry
44
+ mine.set(key, geometry)
45
+ }
46
+ return geometry
47
+ }
@@ -29,7 +29,15 @@ export interface FigureFigureFacts {
29
29
  * records the face only as that node, so this is where the screen learns which one is chosen. Parts are the placed
30
30
  * bodies (`place@1`), in the order the model lists them.
31
31
  */
32
+ let lastFacts: { source: string; facts: FigureFigureFacts } | undefined
32
33
  export function figureFigureFacts(source: string): FigureFigureFacts {
34
+ /* The panel renders more often than the figure changes; the same text says the same facts. */
35
+ if (lastFacts?.source === source) return lastFacts.facts
36
+ const facts = figureFactsOf(source)
37
+ lastFacts = { source, facts }
38
+ return facts
39
+ }
40
+ function figureFactsOf(source: string): FigureFigureFacts {
33
41
  const asset = parseFigureAsset(source) as FigureAsset & { document: { model: { nodes: Array<{ id: string; op: string }> } } }
34
42
  const parts = asset.document.model.nodes.filter(node => node.op === 'place@1').map(node => node.id)
35
43
  const feature = (asset.occupancy?.contact as { surface?: { feature?: string } } | undefined)?.surface?.feature
@@ -77,7 +77,39 @@ export class FigureProperties extends localize(i18next)(LitElement) {
77
77
  }
78
78
  }
79
79
 
80
+ /*
81
+ The figure's own settings are shown while no part is picked; with one picked this is out of sight, and working out its
82
+ facts (the figure parsed and compiled) on every edit is for nobody. Out of sight it keeps what it last drew, and it
83
+ draws again when it comes back into view.
84
+ */
85
+ private lastDrawn?: unknown
86
+ private outOfSight = false
87
+ private watch?: ResizeObserver
88
+ connectedCallback() {
89
+ super.connectedCallback()
90
+ this.watch = new ResizeObserver(() => {
91
+ if (this.outOfSight && this.checkVisibility?.() !== false) {
92
+ this.outOfSight = false
93
+ this.requestUpdate()
94
+ }
95
+ })
96
+ this.watch.observe(this)
97
+ }
98
+ disconnectedCallback() {
99
+ this.watch?.disconnect()
100
+ super.disconnectedCallback()
101
+ }
102
+
80
103
  render() {
104
+ if (this.hasUpdated && this.checkVisibility?.() === false) {
105
+ this.outOfSight = true
106
+ return this.lastDrawn ?? nothing
107
+ }
108
+ this.lastDrawn = this.draw()
109
+ return this.lastDrawn
110
+ }
111
+
112
+ private draw() {
81
113
  const facts = this.facts()
82
114
  if (!facts) return nothing
83
115
  /* The figure's type is the page's (it lives on the row); it is slotted into the first section. */
@@ -17,6 +17,8 @@ import type { FigureBudgetUse, FigureMaturityAxis, FigureReport } from './maturi
17
17
 
18
18
  /** How long an edit settles before the server is asked again. One question per pause, not one per keystroke. */
19
19
  export const VERDICT_SETTLE_MS = 400
20
+ /** How long the figure rests before its report is measured again. */
21
+ const MEASURE_SETTLE_MS = 200
20
22
 
21
23
  /**
22
24
  * 성숙도 — the modeller's verdict while editing.
@@ -399,11 +401,18 @@ export class FigureVerdict extends localize(i18next)(LitElement) {
399
401
 
400
402
  disconnectedCallback() {
401
403
  clearTimeout(this.timer)
404
+ clearTimeout(this.measureTimer)
402
405
  super.disconnectedCallback()
403
406
  }
404
407
 
408
+ /** The report is measured once the figure has settled: it is read beside the work, and each measure compiles the figure several times. */
409
+ private measureTimer?: ReturnType<typeof setTimeout>
405
410
  updated(changes: Map<string, unknown>) {
406
- if (changes.has('source')) this.measure()
411
+ if (changes.has('source')) {
412
+ clearTimeout(this.measureTimer)
413
+ if (this.report === undefined && !this.measureTimer) this.measure()
414
+ else this.measureTimer = setTimeout(() => this.measure(), MEASURE_SETTLE_MS)
415
+ }
407
416
  if (changes.has('source') || changes.has('figureId') || changes.has('figureType')) this.schedule()
408
417
  }
409
418
 
@@ -1,9 +1,10 @@
1
- import { compileFigureAsset, compileFigureGraph, figureWorldBoxesOf, figureWritersOf } from '@hatiolab/figure-model'
1
+ import { compileFigureGraph, figureWorldBoxesOf, figureWritersOf } from '@hatiolab/figure-model'
2
2
  import type { FigureAsset } from '@hatiolab/figure-model'
3
3
 
4
4
  import type { FigureRenderPart } from './renderer.js'
5
5
  import { sectionShape } from './drawn-shapes.js'
6
6
  import { jointAxisUnit } from './joint-axis.js'
7
+ import { evaluatedGeometryOf } from './evaluated.js'
7
8
 
8
9
  /*
9
10
  * Where the selected part turns, and how far -- a translucent fan on the editing board. An axis name says nothing about which way a part swings; on a six-axis arm the author went to the preview and
@@ -204,9 +205,7 @@ export function jointFanOf(asset: FigureAsset, part: string, values: Record<stri
204
205
  let box: WorldBox | undefined
205
206
  try {
206
207
  box = figureWorldBoxesOf(
207
- compileFigureAsset({ ...asset, designInputs: { ...asset.designInputs, ...pick(values, asset.designInputs) }, stateDefaults: { ...asset.stateDefaults, ...pick(values, asset.stateDefaults) } })
208
- .evaluate()
209
- .geometry.filter(g => g.id === part),
208
+ evaluatedGeometryOf(asset, values).filter(g => g.id === part),
210
209
  figureWritersOf(asset)
211
210
  )[0]
212
211
  } catch {
@@ -274,9 +273,7 @@ function pick(values: Record<string, number>, of: Record<string, number>): Recor
274
273
  /** A part's largest own measure (a box side, a diameter, a length) at the inputs given; 0 when it cannot be read. */
275
274
  function largestMeasureOf(asset: FigureAsset, part: string, inputs: Record<string, number>): number {
276
275
  try {
277
- const body = compileFigureAsset({ ...asset, designInputs: pick(inputs, asset.designInputs), stateDefaults: pick(inputs, asset.stateDefaults) })
278
- .evaluate()
279
- .geometry.find(g => g.id === part) as { shape?: { dimensions?: Record<string, number> } } | undefined
276
+ const body = evaluatedGeometryOf(asset, inputs).find(g => g.id === part) as { shape?: { dimensions?: Record<string, number> } } | undefined
280
277
  const dims = Object.entries(body?.shape?.dimensions ?? {}).map(([k, v]) => (k.startsWith('radius') ? v * 2 : v))
281
278
  return dims.length ? Math.max(...dims) : 0
282
279
  } catch {
@@ -2,6 +2,7 @@ import { FIGURE_FACES, figureShapeFaces, figurePartsExtentOf, compileFigureAsset
2
2
  import type { FigureFollow, FigureSizeRange } from '@hatiolab/figure-model'
3
3
  import type { FigureAsset, FigureBindingPlace, FigureFace, FigureJoint } from '@hatiolab/figure-model'
4
4
  import { startingShapeKindOf } from './starting-shape.js'
5
+ import { evaluatedGeometryOf } from './evaluated.js'
5
6
  import { jointAxisToken, jointAxisVectorOf, type JointAxisVector } from './joint-axis.js'
6
7
 
7
8
  /*
@@ -394,7 +395,7 @@ export function placeKeptOf(
394
395
  const kept = { align: {} as Partial<Record<Axis, Alignment>>, alignMm: {} as Partial<Record<Axis, number>>, alignInput: {} as Partial<Record<Axis, string>> }
395
396
  let geometry
396
397
  try {
397
- geometry = compileFigureAsset(asset).evaluate().geometry as unknown as Array<{ id: string; pose?: { r: number[][]; t: number[] } }>
398
+ geometry = evaluatedGeometryOf(asset) as unknown as Array<{ id: string; pose?: { r: number[][]; t: number[] } }>
398
399
  } catch {
399
400
  return kept
400
401
  }
@@ -12,7 +12,8 @@ import type { GizmoReach } from './gizmo.js';
12
12
  * where the gizmo could (`GizmoReach`).
13
13
  */
14
14
  export type AlignTool = 'centre-x' | 'centre-z' | 'snap';
15
- export declare function boxOf(asset: FigureAsset, part: string): FigureWorldBox | undefined;
15
+ /** `values`: the inputs the figure is drawn with, when the box is read as drawn; left out, the asset as it is saved. */
16
+ export declare function boxOf(asset: FigureAsset, part: string, values?: Record<string, number>): FigureWorldBox | undefined;
16
17
  /** The snapping grid: about five steps across the figure, on 1 · 2 · 5 multiples. */
17
18
  export declare function gridStepOf(asset: FigureAsset): number;
18
19
  export declare function alignChanges(asset: FigureAsset, part: string, reach: GizmoReach, tool: AlignTool): FigureInputChange[];
@@ -1,12 +1,14 @@
1
- import { compileFigureAsset, figureOccupiedBoxesOf, figureWritersOf } from '@hatiolab/figure-model';
1
+ import { figureOccupiedBoxesOf, figureWritersOf } from '@hatiolab/figure-model';
2
+ import { evaluatedGeometryOf } from './evaluated.js';
2
3
  const AXES = ['x', 'y', 'z'];
3
- export function boxOf(asset, part) {
4
- const geometry = compileFigureAsset(asset).evaluate().geometry;
4
+ /** `values`: the inputs the figure is drawn with, when the box is read as drawn; left out, the asset as it is saved. */
5
+ export function boxOf(asset, part, values) {
6
+ const geometry = evaluatedGeometryOf(asset, values);
5
7
  return figureOccupiedBoxesOf(geometry, figureWritersOf(asset)).find(box => box.part === part);
6
8
  }
7
9
  /** The snapping grid: about five steps across the figure, on 1 · 2 · 5 multiples. */
8
10
  export function gridStepOf(asset) {
9
- const geometry = compileFigureAsset(asset).evaluate().geometry;
11
+ const geometry = evaluatedGeometryOf(asset);
10
12
  const boxes = figureOccupiedBoxesOf(geometry, figureWritersOf(asset));
11
13
  if (!boxes.length)
12
14
  return 1;