@things-factory/figure-ui 10.1.125 → 10.1.127

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 (42) hide show
  1. package/client/modeller/align.ts +6 -4
  2. package/client/modeller/canvas.ts +6 -1
  3. package/client/modeller/editor.ts +149 -34
  4. package/client/modeller/evaluated.ts +47 -0
  5. package/client/modeller/figure-facts.ts +8 -0
  6. package/client/modeller/figure-preview.ts +38 -16
  7. package/client/modeller/figure-properties.ts +32 -0
  8. package/client/modeller/figure-verdict.ts +10 -1
  9. package/client/modeller/joint-fan.ts +4 -7
  10. package/client/modeller/links.ts +2 -1
  11. package/dist-client/modeller/align.d.ts +2 -1
  12. package/dist-client/modeller/align.js +6 -4
  13. package/dist-client/modeller/align.js.map +1 -1
  14. package/dist-client/modeller/canvas.js +8 -1
  15. package/dist-client/modeller/canvas.js.map +1 -1
  16. package/dist-client/modeller/editor.d.ts +35 -1
  17. package/dist-client/modeller/editor.js +138 -37
  18. package/dist-client/modeller/editor.js.map +1 -1
  19. package/dist-client/modeller/evaluated.d.ts +12 -0
  20. package/dist-client/modeller/evaluated.js +42 -0
  21. package/dist-client/modeller/evaluated.js.map +1 -0
  22. package/dist-client/modeller/figure-facts.d.ts +0 -7
  23. package/dist-client/modeller/figure-facts.js +9 -0
  24. package/dist-client/modeller/figure-facts.js.map +1 -1
  25. package/dist-client/modeller/figure-preview.js +38 -16
  26. package/dist-client/modeller/figure-preview.js.map +1 -1
  27. package/dist-client/modeller/figure-properties.d.ts +8 -2
  28. package/dist-client/modeller/figure-properties.js +26 -0
  29. package/dist-client/modeller/figure-properties.js.map +1 -1
  30. package/dist-client/modeller/figure-verdict.d.ts +2 -0
  31. package/dist-client/modeller/figure-verdict.js +10 -2
  32. package/dist-client/modeller/figure-verdict.js.map +1 -1
  33. package/dist-client/modeller/joint-fan.js +4 -7
  34. package/dist-client/modeller/joint-fan.js.map +1 -1
  35. package/dist-client/modeller/links.js +3 -2
  36. package/dist-client/modeller/links.js.map +1 -1
  37. package/dist-client/modeller/product-session.d.ts +68 -112
  38. package/dist-client/tsconfig.tsbuildinfo +1 -1
  39. package/package.json +4 -4
  40. package/test/evaluated.test.ts +40 -0
  41. package/test/gizmo.test.ts +1 -1
  42. package/test/mode-switch.test.ts +41 -0
@@ -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(
@@ -115,7 +115,10 @@ export class FigureCanvas extends LitElement {
115
115
 
116
116
  firstUpdated() {
117
117
  this.standUp()
118
- this.watcher = new ResizeObserver(() => this.scene?.resize?.())
118
+ /* Out of sight it has no size to draw at: it resizes when it is shown again, not to nothing. */
119
+ this.watcher = new ResizeObserver(() => {
120
+ if (this.checkVisibility?.() !== false) this.scene?.resize?.()
121
+ })
119
122
  this.watcher.observe(this)
120
123
  }
121
124
 
@@ -140,6 +143,8 @@ export class FigureCanvas extends LitElement {
140
143
  * faked (user, 2026-09-24: no card had a picture after this canvas moved onto the scene).
141
144
  */
142
145
  async snapshot(width = 480, height = 360): Promise<string | undefined> {
146
+ /* Kept standing while the preview is in front, it is not drawn: no picture, as when there was no canvas. */
147
+ if (this.checkVisibility?.() === false) return undefined
143
148
  const layer = this.scene?.root as (SceneLayer & { snapshot3d?: (w?: number, h?: number, type?: string, quality?: number) => Promise<string | undefined> }) | undefined
144
149
  if (!layer?.snapshot3d) return undefined
145
150
  try {
@@ -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. */
@@ -144,6 +159,10 @@ export class FigureEditor extends LitElement {
144
159
  min-width: 0;
145
160
  min-height: 0;
146
161
  }
162
+ /* The edit canvas stays standing while the preview is in front (a 3D scene costs a third of a second to make), out of sight. */
163
+ div[middle][hidden] {
164
+ display: none;
165
+ }
147
166
  ox-canvas-bar .status {
148
167
  padding: 0 8px;
149
168
  color: var(--md-sys-color-on-surface-variant);
@@ -517,6 +536,8 @@ export class FigureEditor extends LitElement {
517
536
  disconnectedCallback() {
518
537
  window.removeEventListener('keydown', this.onKey, true)
519
538
  this.widthWatch.disconnect()
539
+ clearTimeout(this.previewTimer)
540
+ clearTimeout(this.detachTimer)
520
541
  super.disconnectedCallback()
521
542
  }
522
543
  /*
@@ -837,7 +858,7 @@ export class FigureEditor extends LitElement {
837
858
  */
838
859
  private insertRecipe(recipe: FigureRecipe, values: Record<string, FigureRecipeValue>) {
839
860
  try {
840
- const asset = JSON.parse(this.session!.source()) as FigureAsset
861
+ const asset = this.plainAsset() as FigureAsset
841
862
  const checked = parseFigureRecipe(recipe)
842
863
  const id = figureRecipeIdFor(asset, checked)
843
864
  const steps = resolveFigureRecipe(checked, { id, ...(checked.target === 'part' ? { target: this.selected } : {}), values })
@@ -853,7 +874,7 @@ export class FigureEditor extends LitElement {
853
874
  /** The design values of the asset being edited. */
854
875
  private designValues(): Record<string, number> {
855
876
  try {
856
- return this.session ? (JSON.parse(this.session.source()).designInputs ?? {}) : {}
877
+ return this.session ? (this.plainAsset().designInputs ?? {}) : {}
857
878
  } catch {
858
879
  return {}
859
880
  }
@@ -864,7 +885,7 @@ export class FigureEditor extends LitElement {
864
885
  /* A repeat is moved and sized through its row, not by a gizmo on one copy. */
865
886
  if (!id || !this.session || !this.session.read().geometry.some(g => g.id === id)) return none
866
887
  try {
867
- const asset = JSON.parse(this.session.source())
888
+ const asset = this.plainAsset()
868
889
  const reach = gizmoReachOf(asset, id)
869
890
  /*
870
891
  A size drag shows the face it keeps and the commit keeps the same (figureScaleAnchorOf). Where figure-model cannot
@@ -889,7 +910,7 @@ export class FigureEditor extends LitElement {
889
910
  private transformed(id: string, result: GizmoResult) {
890
911
  const view = this.session!.read()
891
912
  /* 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
913
+ const asset = this.plainAsset() as FigureAsset
893
914
  const drawn = view.geometry.find(g => g.id === id) as unknown as FigureRenderPart | undefined
894
915
  const drag = drawn ? jointDragOf(asset, ownerOf(id), drawn, result, this.currentInputs()) : null
895
916
  if (drag) {
@@ -966,7 +987,7 @@ export class FigureEditor extends LitElement {
966
987
  /** 맞추기 — one tool, one step of the history, through the same inputs as the gizmo. */
967
988
  private align(tool: AlignTool) {
968
989
  this.run(() => {
969
- const asset = JSON.parse(this.session!.source())
990
+ const asset = this.plainAsset()
970
991
  const changes = alignChanges(asset, this.selected, this.reachOf(this.selected), tool)
971
992
  if (changes.length) this.session!.apply(changes)
972
993
  })
@@ -979,7 +1000,7 @@ export class FigureEditor extends LitElement {
979
1000
  this.session!.edit({ kind: 'duplicate-part', target: id })
980
1001
  const copy = this.session!.read().geometry.find(g => !before.has(g.id))?.id
981
1002
  if (!copy) return
982
- this.session!.apply(mirrorChanges(JSON.parse(this.session!.source()), id, copy, axis))
1003
+ this.session!.apply(mirrorChanges(this.plainAsset(), id, copy, axis))
983
1004
  this.selected = copy
984
1005
  })
985
1006
  }
@@ -1017,7 +1038,7 @@ export class FigureEditor extends LitElement {
1017
1038
  @click=${() => this.alignToPlane()}>${i18next.t(`figure.button.align-to-${placement}`)}</button>`
1018
1039
  }
1019
1040
  private alignToPlane() {
1020
- const asset = parseFigureAsset(this.session!.source())
1041
+ const asset = this.parsedAsset()
1021
1042
  const { changes, contact, refusal } = figurePlaneAlignChanges(asset, this.selected)
1022
1043
  if (refusal) {
1023
1044
  this.error = i18next.t(`figure.text.align-refused-${refusal.toLowerCase().replace(/_/g, '-')}`, { part: partLabelOf(this.selected) })
@@ -1031,7 +1052,7 @@ export class FigureEditor extends LitElement {
1031
1052
  if (!this.selected || !reach.translate) return null
1032
1053
  let asset: unknown
1033
1054
  try {
1034
- asset = JSON.parse(this.session!.source())
1055
+ asset = this.plainAsset()
1035
1056
  } catch {
1036
1057
  return null
1037
1058
  }
@@ -1099,19 +1120,41 @@ export class FigureEditor extends LitElement {
1099
1120
  }
1100
1121
  }
1101
1122
  /** Size response, fastening and motion for the selected part, as authoring commands. */
1102
- private renderLinks(unavailable: boolean, section: 'shape' | 'places' | 'motion') {
1103
- let asset
1123
+ /*
1124
+ 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
1125
+ the panel, which draws itself again for nothing (sixteen times for one edit).
1126
+ */
1127
+ private dimsSaid?: { key: string; dims: [number, number, number] | undefined }
1128
+ private dimsOf(asset: FigureAsset): [number, number, number] | undefined {
1129
+ const view = this.session?.read()
1130
+ const key = `${view?.context}|${view?.revision}|${this.selected}`
1131
+ if (this.dimsSaid?.key === key) return this.dimsSaid.dims
1132
+ let dims: [number, number, number] | undefined
1104
1133
  try {
1105
- asset = JSON.parse(this.session!.source())
1134
+ dims = boxOf(asset, this.selected, this.currentInputs())?.dims
1106
1135
  } catch {
1107
- return null
1136
+ dims = undefined
1108
1137
  }
1109
- let dims: [number, number, number] | undefined
1138
+ this.dimsSaid = { key, dims }
1139
+ return dims
1140
+ }
1141
+ private previewWordsSaid?: { preview: unknown; part: string; language: string; words: string[] }
1142
+ private previewWordsNow(): string[] {
1143
+ const preview = this.currentPreview()
1144
+ const language = i18next.language
1145
+ if (this.previewWordsSaid && this.previewWordsSaid.preview === preview && this.previewWordsSaid.part === this.selected && this.previewWordsSaid.language === language) return this.previewWordsSaid.words
1146
+ const words = previewWordsOf(preview, this.selected, (key, params) => i18next.t(key, params))
1147
+ this.previewWordsSaid = { preview, part: this.selected, language, words }
1148
+ return words
1149
+ }
1150
+ private renderLinks(unavailable: boolean, section: 'shape' | 'places' | 'motion') {
1151
+ let asset
1110
1152
  try {
1111
- dims = boxOf(asset, this.selected)?.dims
1153
+ asset = this.plainAsset()
1112
1154
  } catch {
1113
- dims = undefined
1155
+ return null
1114
1156
  }
1157
+ const dims = this.dimsOf(asset)
1115
1158
  return html`<figure-links
1116
1159
  .asset=${asset}
1117
1160
  .section=${section}
@@ -1119,10 +1162,10 @@ export class FigureEditor extends LitElement {
1119
1162
  .dims=${dims}
1120
1163
  ?locked=${unavailable}
1121
1164
  @command=${(e: CustomEvent) => this.linkCommand(e.detail.action)}
1122
- .previewWords=${previewWordsOf(this.currentPreview(), this.selected, (key, params) => i18next.t(key, params))}
1165
+ .previewWords=${this.previewWordsNow()}
1123
1166
  .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) : []}
1167
+ .overhangWords=${section === 'shape' ? this.overhangWords(asset as FigureAsset | undefined) : NO_WORDS}
1168
+ .overhangNote=${section === 'shape' ? this.overhangNote(asset as FigureAsset | undefined) : NO_WORDS}
1126
1169
  .motionEnds=${section === 'motion' ? this.motionEnds() : null}
1127
1170
  @go-fasten=${() => this.goFasten()}
1128
1171
  @preview-command=${(e: CustomEvent) => this.previewCommand(e.detail)}
@@ -1151,7 +1194,7 @@ export class FigureEditor extends LitElement {
1151
1194
  private renderControls(view: { fields: Array<{ id: string; role?: string; value: number }> }, unavailable: boolean) {
1152
1195
  let asset
1153
1196
  try {
1154
- asset = JSON.parse(this.session!.source())
1197
+ asset = this.plainAsset()
1155
1198
  } catch {
1156
1199
  return null
1157
1200
  }
@@ -1236,7 +1279,7 @@ export class FigureEditor extends LitElement {
1236
1279
  /* 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
1280
  const part = this.selected
1238
1281
  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]))
1282
+ this.run(() => this.session!.apply(field.role !== 'state' && part ? figureSizeEditChanges(this.parsedAsset(), part, [change]) : [change]))
1240
1283
  // Rejected edits must also restore the displayed numeric value.
1241
1284
  input.value = String(this.session!.read().fields.find(i => i.id === field.id)!.value)
1242
1285
  }
@@ -1570,7 +1613,7 @@ export class FigureEditor extends LitElement {
1570
1613
  const pitch = input.valueAsNumber
1571
1614
  if (!Number.isFinite(pitch) || pitch === field.value) return
1572
1615
  /* 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)
1616
+ const from = this.pitchInputOf(this.plainAsset() as FigureAsset, this.selected)
1574
1617
  if (from) {
1575
1618
  this.run(() => this.session!.apply([{ kind: 'set-design', input: from, unit: 'mm', value: pitch }]))
1576
1619
  input.value = String(this.session!.read().fields.find(i => i.id === from)?.value ?? field.value)
@@ -1610,19 +1653,33 @@ export class FigureEditor extends LitElement {
1610
1653
  await this.querySelector<HTMLElement & { showRelease?: () => Promise<void> }>('figure-history-panel')?.showRelease?.()
1611
1654
  }
1612
1655
  /** The fastening form's command, seen before it is done; null clears what was shown. */
1656
+ /**
1657
+ * 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
1658
+ * rest: a number typed digit by digit asks once, for the number it ends on. Clearing is at once, and the same question on
1659
+ * the same version is not asked again.
1660
+ */
1661
+ private previewTimer?: ReturnType<typeof setTimeout>
1613
1662
  private previewCommand(candidate: { action: FigureAuthoringAction; face?: { part: string; face: string } } | null) {
1663
+ clearTimeout(this.previewTimer)
1614
1664
  if (!candidate || !this.session || !this.selected) {
1615
1665
  this.preview = undefined
1616
1666
  return
1617
1667
  }
1618
1668
  const view = this.session.read()
1669
+ 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
1670
+ if (same) return
1671
+ this.previewTimer = setTimeout(() => this.computePreview(candidate), PREVIEW_SETTLE_MS)
1672
+ }
1673
+ private computePreview(candidate: { action: FigureAuthoringAction; face?: { part: string; face: string } }) {
1674
+ if (!this.session || !this.selected) return
1675
+ const view = this.session.read()
1619
1676
  const held = { context: view.context!, revision: view.revision!, key: JSON.stringify(candidate), part: this.selected }
1620
1677
  try {
1621
1678
  const seen = this.session.previewCommand(candidate.action)
1622
1679
  let face: FigureFaceArea | undefined
1623
1680
  if (candidate.face) {
1624
1681
  try {
1625
- face = figurePartFaceOf(parseFigureAsset(this.session.source()), candidate.face.part, candidate.face.face, this.currentInputs())
1682
+ face = figurePartFaceOf(this.parsedAsset(), candidate.face.part, candidate.face.face, this.currentInputs())
1626
1683
  } catch {
1627
1684
  face = undefined
1628
1685
  }
@@ -1660,16 +1717,35 @@ export class FigureEditor extends LitElement {
1660
1717
  when the copy shows it does.
1661
1718
  */
1662
1719
  private detachSaid?: { key: string; words: string[] }
1720
+ private detachTimer?: ReturnType<typeof setTimeout>
1721
+ private detachAsked?: string
1722
+ /**
1723
+ * Said once the figure has rested: working it out is a preview of the command on a copy (tens of milliseconds), and the
1724
+ * edit that made this version should be on screen before it. Until then nothing is said, as for a part with no joint.
1725
+ */
1663
1726
  private detachWords(asset: FigureAsset | undefined): string[] {
1664
1727
  const view = this.session?.read()
1665
1728
  const joint = asset?.joints?.find(j => j.body1.part === this.selected)
1666
- if (!asset || !view || !joint || !this.session) return []
1729
+ if (!asset || !view || !joint || !this.session) return NO_WORDS
1667
1730
  const key = `${view.context}|${view.revision}|${this.selected}`
1668
1731
  if (this.detachSaid?.key === key) return this.detachSaid.words
1732
+ if (this.detachAsked !== key) {
1733
+ this.detachAsked = key
1734
+ clearTimeout(this.detachTimer)
1735
+ this.detachTimer = setTimeout(() => {
1736
+ const now = this.session?.read()
1737
+ if (!now || `${now.context}|${now.revision}|${this.selected}` !== key) return
1738
+ this.computeDetachWords(asset, key)
1739
+ this.requestUpdate()
1740
+ }, PREVIEW_SETTLE_MS)
1741
+ }
1742
+ return NO_WORDS
1743
+ }
1744
+ private computeDetachWords(asset: FigureAsset, key: string): string[] {
1669
1745
  const say = (k: string, params?: Record<string, string>) => i18next.t(k, params)
1670
1746
  let words: string[]
1671
1747
  try {
1672
- const seen = this.session.previewCommand({ kind: 'detach', part: this.selected })
1748
+ const seen = this.session!.previewCommand({ kind: 'detach', part: this.selected })
1673
1749
  const own = seen.moves.some(m => m.body === this.selected)
1674
1750
  words = own
1675
1751
  ? [say('figure.text.detach-moves'), ...previewWordsOf({ context: seen.context, revision: seen.revision, key, moves: seen.moves }, this.selected, say)]
@@ -1695,7 +1771,7 @@ export class FigureEditor extends LitElement {
1695
1771
  }
1696
1772
  private overhangWords(asset: FigureAsset | undefined): string[] {
1697
1773
  const view = this.session?.read()
1698
- if (!asset || !view || !this.selected) return []
1774
+ if (!asset || !view || !this.selected) return NO_WORDS
1699
1775
  const key = `${view.context}|${view.revision}|${this.selected}`
1700
1776
  if (this.overhangSaid?.key === key) return this.overhangSaid.words
1701
1777
  const say = (k: string, params?: Record<string, string>) => i18next.t(k, params)
@@ -1731,7 +1807,7 @@ export class FigureEditor extends LitElement {
1731
1807
  if (this.motionEndsSaid?.key !== key) {
1732
1808
  let ends: MotionEnds | null = null
1733
1809
  try {
1734
- ends = motionEndsOf(parseFigureAsset(this.session!.source()), this.selected, this.currentInputs())
1810
+ ends = motionEndsOf(this.parsedAsset(), this.selected, this.currentInputs())
1735
1811
  } catch {
1736
1812
  ends = null
1737
1813
  }
@@ -1770,9 +1846,47 @@ export class FigureEditor extends LitElement {
1770
1846
  this.faceMapping = undefined
1771
1847
  if (id && (this.tab === 'verdict' || this.tab === 'view' || this.tab === 'history')) this.tab = 'properties'
1772
1848
  }
1849
+ /**
1850
+ * The figure as the session holds it now, parsed once per text: a render asks for it a dozen times, and parsing the whole
1851
+ * figure is most of what a selection costs. Read, never changed -- what changes a figure goes through the session.
1852
+ */
1853
+ /**
1854
+ * The session has evaluated this figure at this version, with these inputs: the screen's questions of the text it just
1855
+ * parsed are answered from that, not by evaluating the figure again (each is tens of milliseconds on a real figure).
1856
+ */
1857
+ private seeded(asset: FigureAsset): FigureAsset {
1858
+ const view = this.session?.read()
1859
+ if (view?.geometry.length) {
1860
+ const values = this.currentInputs()
1861
+ seedEvaluatedGeometry(asset, values, view.geometry as never)
1862
+ seedFigureEvaluation(asset, values, view.geometry)
1863
+ }
1864
+ return asset
1865
+ }
1866
+ private parsed?: { text: string; asset: FigureAsset }
1867
+ private parsedAsset(): FigureAsset {
1868
+ const text = this.session!.source()
1869
+ if (this.parsed?.text !== text) {
1870
+ /* The session's text is what its workspace exported, compiled then: it is read here, not compiled again. */
1871
+ const plain = this.plainAsset()
1872
+ const upgraded = upgradeFigurePlacement(plain)
1873
+ this.parsed = { text, asset: upgraded === plain ? plain : this.seeded(deepFreeze(upgraded)) }
1874
+ }
1875
+ return this.parsed.asset
1876
+ }
1877
+ private plain?: { text: string; asset: FigureAsset }
1878
+ /**
1879
+ * The figure as plain data, parsed once per text and frozen: the render, the gizmo's reach and every tool read it, and
1880
+ * a copy for each of them cost more than the selection they serve. A change goes through the session, never through this.
1881
+ */
1882
+ private plainAsset(): FigureAsset {
1883
+ const text = this.session!.source()
1884
+ if (this.plain?.text !== text) this.plain = { text, asset: this.seeded(deepFreeze(JSON.parse(text))) }
1885
+ return this.plain.asset
1886
+ }
1773
1887
  private asset(): unknown {
1774
1888
  try {
1775
- return JSON.parse(this.session!.source())
1889
+ return this.plainAsset()
1776
1890
  } catch {
1777
1891
  return undefined
1778
1892
  }
@@ -1907,7 +2021,7 @@ export class FigureEditor extends LitElement {
1907
2021
  let summary: { kind: string; size: string } | undefined
1908
2022
  try {
1909
2023
  summary = this.selected
1910
- ? partSummaryOf(parseFigureAsset(this.session!.source()), ownerOf(this.selected), (key, params) => i18next.t(key, params), i18next.language || 'ko')
2024
+ ? partSummaryOf(this.parsedAsset(), ownerOf(this.selected), (key, params) => i18next.t(key, params), i18next.language || 'ko')
1911
2025
  : undefined
1912
2026
  } catch {
1913
2027
  summary = undefined
@@ -1944,7 +2058,7 @@ export class FigureEditor extends LitElement {
1944
2058
  */
1945
2059
  let roomOnly: string[] = []
1946
2060
  try {
1947
- roomOnly = this.selected ? figureRoomOnlyInputsOf(parseFigureAsset(this.session!.source()), this.selected) : []
2061
+ roomOnly = this.selected ? figureRoomOnlyInputsOf(this.parsedAsset(), this.selected) : []
1948
2062
  } catch {
1949
2063
  roomOnly = []
1950
2064
  }
@@ -1954,7 +2068,7 @@ export class FigureEditor extends LitElement {
1954
2068
  try {
1955
2069
  const body = view.geometry.find(g => g.id === this.selected) as unknown as FigureRenderPart | undefined
1956
2070
  const placed = body ? placementOf(body) : undefined
1957
- const parsed = this.selected ? parseFigureAsset(this.session!.source()) : undefined
2071
+ const parsed = this.selected ? this.parsedAsset() : undefined
1958
2072
  if (placed && parsed)
1959
2073
  held = {
1960
2074
  position: placed.position,
@@ -2050,7 +2164,8 @@ export class FigureEditor extends LitElement {
2050
2164
  }}
2051
2165
  @part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
2052
2166
  ></figure-preview>`
2053
- : html`<div middle>
2167
+ : nothing}
2168
+ <div middle ?hidden=${this.middle === 'preview'}>
2054
2169
  ${view.geometry.length === 0 && !unavailable ? this.renderStart() : nothing}
2055
2170
  ${this.sectionEditing && this.sectionEditing === this.selected && !unavailable ? this.renderSectionEditor(view.geometry) : nothing}
2056
2171
  <figure-canvas
@@ -2069,7 +2184,7 @@ export class FigureEditor extends LitElement {
2069
2184
  @render-error=${(e: CustomEvent<Error>) => {
2070
2185
  this.error = e.detail.message
2071
2186
  }}
2072
- ></figure-canvas></div>`}
2187
+ ></figure-canvas></div>
2073
2188
  <aside slot="end">
2074
2189
  <div tabs>
2075
2190
  ${this.renderTab('properties', 'tune', i18next.t('figure.label.properties'))}
@@ -2257,7 +2372,7 @@ export class FigureEditor extends LitElement {
2257
2372
  private async askRoomRefit() {
2258
2373
  let refit
2259
2374
  try {
2260
- refit = figureRoomRefitOf(parseFigureAsset(this.session!.source()))
2375
+ refit = figureRoomRefitOf(this.parsedAsset())
2261
2376
  } catch (error) {
2262
2377
  this.error = this.said(error)
2263
2378
  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
@@ -27,6 +27,8 @@ interface SceneHandle {
27
27
  dispose?: () => void
28
28
  resize?: () => void
29
29
  fit?: (type?: string) => void
30
+ /** The scene's container: it can make the content again while keeping the 3D renderer and the camera (undo and redo use it). */
31
+ rootContainer?: { rebuildContentModel?: (model: unknown, options?: { preserveThreeD?: boolean }) => void }
30
32
  root?: {
31
33
  set?: (patch: Record<string, unknown>) => void
32
34
  fitCameraToModel?: () => void
@@ -345,30 +347,50 @@ export class FigurePreview extends localize(i18next)(LitElement) {
345
347
  this.teardown()
346
348
  return
347
349
  }
348
- this.teardown()
349
- this.driving = {}
350
350
  try {
351
351
  /* Registered as a board registers it, under a type of this preview's own: a real type's drawings stay as they are. */
352
352
  const type = `figure-preview-${this.context || 'new'}`
353
+ const size = authoredSizeOf(asset)
354
+ /*
355
+ The figure changed under a scene that is standing: the same type is registered again and the content made again
356
+ inside the same 3D scene. Making a scene anew costs about a third of a second and its first drawing compiles the
357
+ shaders again -- a value typed beside the preview held the screen for seconds. The scene is made anew only when
358
+ there is none, for another figure, or when the content cannot be made again in place.
359
+ */
360
+ const again = this.scene?.rootContainer?.rebuildContentModel && this.registered === type
361
+ if (!again) this.teardown()
362
+ this.driving = {}
353
363
  registerFigure(type, asset)
354
364
  this.registered = type
355
- const size = authoredSizeOf(asset)
356
365
  const { components, board } = previewInstances(type, size)
366
+ const model = {
367
+ type: 'model-layer',
368
+ width: board.width,
369
+ height: board.height,
370
+ depth: previewBoardDepth({ placement: asset.placement, base: { y: size.y } }, view.ceilingHeight, PREVIEW_FACTOR),
371
+ threed: true,
372
+ sky: 'color',
373
+ ...sceneLook(view),
374
+ placement: view.placement,
375
+ heights: { floor: 0, ceiling: view.ceilingHeight },
376
+ pixelRatio: Math.min(3, (window.devicePixelRatio || 1) * 2),
377
+ components
378
+ }
379
+ if (again) {
380
+ try {
381
+ this.scene!.rootContainer!.rebuildContentModel!(model, { preserveThreeD: true })
382
+ this.standing = signature
383
+ this.cannot = ''
384
+ return
385
+ } catch {
386
+ this.teardown()
387
+ registerFigure(type, asset)
388
+ this.registered = type
389
+ }
390
+ }
357
391
  this.scene = create({
358
392
  target: this.stage,
359
- model: {
360
- type: 'model-layer',
361
- width: board.width,
362
- height: board.height,
363
- depth: previewBoardDepth({ placement: asset.placement, base: { y: size.y } }, view.ceilingHeight, PREVIEW_FACTOR),
364
- threed: true,
365
- sky: 'color',
366
- ...sceneLook(view),
367
- placement: view.placement,
368
- heights: { floor: 0, ceiling: view.ceilingHeight },
369
- pixelRatio: Math.min(3, (window.devicePixelRatio || 1) * 2),
370
- components
371
- },
393
+ model,
372
394
  mode: 0
373
395
  }) as SceneHandle
374
396
  this.standing = signature
@@ -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. */