@things-factory/figure-ui 10.1.128 → 10.1.141

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 (95) hide show
  1. package/client/modeller/align.ts +24 -18
  2. package/client/modeller/editor.ts +165 -253
  3. package/client/modeller/fasten-geometry.ts +12 -9
  4. package/client/modeller/figure-facts.ts +12 -14
  5. package/client/modeller/figure-links.ts +16 -12
  6. package/client/modeller/figure-preview.ts +13 -4
  7. package/client/modeller/figure-properties.ts +6 -5
  8. package/client/modeller/figure-verdict.ts +14 -8
  9. package/client/modeller/joint-fan.ts +15 -17
  10. package/client/modeller/links.ts +17 -23
  11. package/client/modeller/live.ts +6 -3
  12. package/client/modeller/motion-ends.ts +6 -7
  13. package/client/modeller/occupancy.ts +2 -3
  14. package/client/modeller/product-session.ts +15 -0
  15. package/client/modeller/revision.ts +66 -0
  16. package/client/pages/figure-modeller-page.ts +5 -18
  17. package/dist-client/modeller/align.d.ts +12 -7
  18. package/dist-client/modeller/align.js +21 -17
  19. package/dist-client/modeller/align.js.map +1 -1
  20. package/dist-client/modeller/editor.d.ts +8 -34
  21. package/dist-client/modeller/editor.js +175 -250
  22. package/dist-client/modeller/editor.js.map +1 -1
  23. package/dist-client/modeller/fasten-geometry.d.ts +3 -2
  24. package/dist-client/modeller/fasten-geometry.js +11 -9
  25. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  26. package/dist-client/modeller/figure-facts.d.ts +9 -1
  27. package/dist-client/modeller/figure-facts.js +11 -15
  28. package/dist-client/modeller/figure-facts.js.map +1 -1
  29. package/dist-client/modeller/figure-links.d.ts +4 -5
  30. package/dist-client/modeller/figure-links.js +14 -13
  31. package/dist-client/modeller/figure-links.js.map +1 -1
  32. package/dist-client/modeller/figure-preview.d.ts +5 -2
  33. package/dist-client/modeller/figure-preview.js +13 -4
  34. package/dist-client/modeller/figure-preview.js.map +1 -1
  35. package/dist-client/modeller/figure-properties.d.ts +3 -2
  36. package/dist-client/modeller/figure-properties.js +6 -6
  37. package/dist-client/modeller/figure-properties.js.map +1 -1
  38. package/dist-client/modeller/figure-verdict.d.ts +4 -2
  39. package/dist-client/modeller/figure-verdict.js +12 -9
  40. package/dist-client/modeller/figure-verdict.js.map +1 -1
  41. package/dist-client/modeller/joint-fan.d.ts +8 -4
  42. package/dist-client/modeller/joint-fan.js +15 -16
  43. package/dist-client/modeller/joint-fan.js.map +1 -1
  44. package/dist-client/modeller/links.d.ts +6 -5
  45. package/dist-client/modeller/links.js +17 -24
  46. package/dist-client/modeller/links.js.map +1 -1
  47. package/dist-client/modeller/live.d.ts +3 -2
  48. package/dist-client/modeller/live.js +7 -3
  49. package/dist-client/modeller/live.js.map +1 -1
  50. package/dist-client/modeller/motion-ends.d.ts +2 -2
  51. package/dist-client/modeller/motion-ends.js +5 -6
  52. package/dist-client/modeller/motion-ends.js.map +1 -1
  53. package/dist-client/modeller/occupancy.d.ts +2 -2
  54. package/dist-client/modeller/occupancy.js +2 -3
  55. package/dist-client/modeller/occupancy.js.map +1 -1
  56. package/dist-client/modeller/product-session.d.ts +44 -39
  57. package/dist-client/modeller/product-session.js +17 -0
  58. package/dist-client/modeller/product-session.js.map +1 -1
  59. package/dist-client/modeller/revision.d.ts +37 -0
  60. package/dist-client/modeller/revision.js +39 -0
  61. package/dist-client/modeller/revision.js.map +1 -0
  62. package/dist-client/pages/figure-modeller-page.d.ts +0 -6
  63. package/dist-client/pages/figure-modeller-page.js +6 -14
  64. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  65. package/dist-client/tsconfig.tsbuildinfo +1 -1
  66. package/package.json +7 -7
  67. package/test/align.test.ts +14 -12
  68. package/test/browser/modeller-page.js +52 -4
  69. package/test/browser/modeller-revision.check.mjs +144 -0
  70. package/test/command-preview.test.ts +9 -8
  71. package/test/fasten-drawing.test.ts +1 -1
  72. package/test/fasten-geometry.test.ts +10 -9
  73. package/test/fields.test.ts +1 -1
  74. package/test/first-minute.test.ts +1 -1
  75. package/test/gizmo.test.ts +1 -1
  76. package/test/joint-fan.test.ts +30 -14
  77. package/test/joint-frames.test.ts +9 -8
  78. package/test/links.test.ts +13 -12
  79. package/test/live.test.ts +3 -3
  80. package/test/parts-wiring.test.ts +1 -1
  81. package/test/properties.test.ts +6 -5
  82. package/test/release-panel.test.ts +3 -3
  83. package/test/resize-with-placement.test.ts +8 -6
  84. package/test/revision-of.ts +22 -0
  85. package/test/revision.test.ts +172 -0
  86. package/test/section-editor.test.ts +4 -3
  87. package/test/section-panel.test.ts +5 -4
  88. package/test/verdict.test.ts +1 -1
  89. package/client/modeller/evaluated.ts +0 -47
  90. package/dist-client/modeller/evaluated.d.ts +0 -12
  91. package/dist-client/modeller/evaluated.js +0 -42
  92. package/dist-client/modeller/evaluated.js.map +0 -1
  93. package/dist-client/tsconfig.scratch.tsbuildinfo +0 -1
  94. package/test/evaluated.test.ts +0 -40
  95. package/test/mode-switch.test.ts +0 -43
@@ -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, seedFigureEvaluation, upgradeFigurePlacement } from '@hatiolab/figure-model'
9
+ import { PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figureRecipeIdFor, figureSizeEditChanges, figureScaleAnchorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, type FigurePlaneContact, type FigureClipLoss, figurePartFaceOf, type FigureFaceArea, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang, figureRoomOnlyInputsOf, figureRoomRefitOf } 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'
@@ -32,7 +32,7 @@ import { keyActionOf } from './keys.js'
32
32
  import { groupFields, ownerOf, partLabelOf, repeatOf, type FieldLike, type FieldGroup } from './fields.js'
33
33
  import { SidePanelStyles } from './side-panel-styles.js'
34
34
  import { ScrollbarStyles } from '@operato/styles'
35
- import { alignChanges, boxOf, canMirror, mirrorChanges, type AlignTool } from './align.js'
35
+ import { alignChanges, canMirror, mirrorChanges, type AlignTool } from './align.js'
36
36
  import type { FigurePaletteKind, FigurePartRow } from './figure-parts.js'
37
37
  import { paletteRecipe } from './palette.js'
38
38
  import { apexOver, drawnOf } from './section-drawing.js'
@@ -48,7 +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
+ import { derivedHeld, derivedOf, type FigureRevision } from './revision.js'
52
52
  import { jointDragOf } from './joint-drag.js'
53
53
  import type { FirstSave } from './first-save.js'
54
54
  import { isLive, livePartsOf, liveOverSession, NOTHING_LIVE, scrubbed, scrubStep, type LiveValues } from './live.js'
@@ -75,18 +75,11 @@ function foldSaid(): boolean {
75
75
  /** Product editor surface. The injected session is shared by page persistence and AI commands. */
76
76
  /** The words a panel is given when there are none: one array, so it does not read as a change. */
77
77
  const NO_WORDS: string[] = []
78
+ const NO_OVERHANG = { words: NO_WORDS, note: NO_WORDS }
78
79
 
79
80
  /** How long a command's candidate rests before its preview is worked out. */
80
81
  const PREVIEW_SETTLE_MS = 150
81
82
 
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
-
90
83
  @customElement('figure-editor')
91
84
  export class FigureEditor extends LitElement {
92
85
  /* The operato scrollbar, as every scrolling panel of the app has it -- the side panel scrolled with the browser's own. */
@@ -494,7 +487,7 @@ export class FigureEditor extends LitElement {
494
487
  aria-pressed=${String(this.gizmoMode === tool.mode)}
495
488
  ?disabled=${!tool.can}
496
489
  aria-label=${i18next.t('figure.button.gizmo-' + tool.mode)}
497
- title=${`${i18next.t('figure.button.gizmo-' + tool.mode)} (${tool.key})${tool.can || !this.selected ? '' : ' · ' + i18next.t(tool.mode === 'scale' && this.sizeDragUnsure(this.asset() as FigureAsset, this.selected) ? 'figure.text.gizmo-size-turned-fastening' : 'figure.text.gizmo-not-own', { part: partLabelOf(this.selected), extreme: this.keptExtremeWord() })}`}
490
+ title=${`${i18next.t('figure.button.gizmo-' + tool.mode)} (${tool.key})${tool.can || !this.selected ? '' : ' · ' + i18next.t(tool.mode === 'scale' && this.sizeDragUnsure(this.asset()!, this.selected) ? 'figure.text.gizmo-size-turned-fastening' : 'figure.text.gizmo-not-own', { part: partLabelOf(this.selected), extreme: this.keptExtremeWord() })}`}
498
491
  @click=${() => this.useGizmo(tool.mode)}
499
492
  >
500
493
  <md-icon>${tool.icon}</md-icon>
@@ -691,7 +684,7 @@ export class FigureEditor extends LitElement {
691
684
  /** The scene toggles: the mode already shown takes the gizmo away. */
692
685
  private useGizmo(mode: 'translate' | 'rotate' | 'scale') {
693
686
  if (mode === 'scale' && this.selected && this.gizmoMode !== 'scale') {
694
- const asset = this.asset() as FigureAsset | undefined
687
+ const asset = this.asset()
695
688
  if (asset && this.sizeDragUnsure(asset, this.selected)) {
696
689
  this.error = i18next.t('figure.text.gizmo-size-turned-fastening', { part: partLabelOf(this.selected), extreme: this.keptExtremeWord() })
697
690
  return
@@ -723,31 +716,34 @@ export class FigureEditor extends LitElement {
723
716
  this.live = NOTHING_LIVE
724
717
  }
725
718
  }
719
+ /** The bodies drawn with the value in hand, or null when nothing is in hand (or the figure cannot take it). */
720
+ private liveParts(shown: FigureRevision | undefined): FigureRenderPart[] | null {
721
+ return shown ? liveOverSession(shown, this.live) : null
722
+ }
726
723
  /** What the canvas draws: the figure with the value in hand when there is one, and without the parts hidden on the workbench. */
727
- private drawnParts(geometry: Parameters<typeof drawableFigureParts>[0] | undefined, asset: unknown) {
728
- const parts = (asset ? liveOverSession(asset as FigureAsset, this.currentInputs(), this.live) : null) ?? drawableFigureParts(geometry ?? [])
724
+ private drawnParts(geometry: Parameters<typeof drawableFigureParts>[0] | undefined, live: FigureRenderPart[] | null) {
725
+ const parts = live ?? drawableFigureParts(geometry ?? [])
729
726
  return parts.filter(body => !this.hiddenParts.has(ownerOf((body as { id: string }).id)))
730
727
  }
731
728
  /**
732
729
  * Where the selected part turns and how far, with the pose as it is drawn now -- a value in hand included, so the
733
730
  * fan follows the arm while a control is dragged. Its size comes from what the joint turns (`joint-fan`).
734
731
  */
735
- private fanOf(asset: FigureAsset | undefined) {
736
- if (!asset || !this.selected || this.hiddenParts.has(this.selected)) return null
732
+ private fanOf(shown: FigureRevision | undefined, live: FigureRenderPart[] | null) {
733
+ if (!shown || !this.selected || this.hiddenParts.has(this.selected)) return null
737
734
  try {
738
- return jointFanOf(asset, this.selected, { ...this.currentInputs(), ...this.live.values })
735
+ return jointFanOf(shown, this.selected, live ? { values: this.live.values, geometry: live } : undefined)
739
736
  } catch {
740
737
  return null
741
738
  }
742
739
  }
743
740
  /**
744
- * Every input as the session holds it now -- the pose the author has set with the controls, and design values. The
745
- * asset's source carries only the saved defaults, so drawing a value in hand over it put every other joint back to its
746
- * saved angle while one slider moved, and the arm jumped when released (user, 2026-09-24).
741
+ * The figure on screen at its version: what every panel and helper reads (`revision.ts`). Every input as the session
742
+ * holds it now is its `inputs` -- the pose set with the controls included: drawing a value in hand over the saved
743
+ * defaults put every other joint back to its saved angle while one slider moved (user, 2026-09-24).
747
744
  */
748
- private currentInputs(): Record<string, number> {
749
- const view = this.session?.read()
750
- return view ? Object.fromEntries(view.fields.map(field => [field.id, field.value as number])) : {}
745
+ private shown(): FigureRevision | undefined {
746
+ return this.session?.revisionNow()
751
747
  }
752
748
  /** A value in hand: an input's value (design or pose) or a part edit. Nothing is recorded. */
753
749
  private hold(values: Record<string, number>, edit?: FigurePartEdit) {
@@ -865,7 +861,7 @@ export class FigureEditor extends LitElement {
865
861
  */
866
862
  private insertRecipe(recipe: FigureRecipe, values: Record<string, FigureRecipeValue>) {
867
863
  try {
868
- const asset = this.plainAsset() as FigureAsset
864
+ const asset = this.shown()!.asset
869
865
  const checked = parseFigureRecipe(recipe)
870
866
  const id = figureRecipeIdFor(asset, checked)
871
867
  const steps = resolveFigureRecipe(checked, { id, ...(checked.target === 'part' ? { target: this.selected } : {}), values })
@@ -881,7 +877,7 @@ export class FigureEditor extends LitElement {
881
877
  /** The design values of the asset being edited. */
882
878
  private designValues(): Record<string, number> {
883
879
  try {
884
- return this.session ? (this.plainAsset().designInputs ?? {}) : {}
880
+ return this.shown()?.asset.designInputs ?? {}
885
881
  } catch {
886
882
  return {}
887
883
  }
@@ -892,7 +888,7 @@ export class FigureEditor extends LitElement {
892
888
  /* A repeat is moved and sized through its row, not by a gizmo on one copy. */
893
889
  if (!id || !this.session || !this.session.read().geometry.some(g => g.id === id)) return none
894
890
  try {
895
- const asset = this.plainAsset()
891
+ const asset = this.shown()!.asset
896
892
  const reach = gizmoReachOf(asset, id)
897
893
  /*
898
894
  A size drag shows the face it keeps and the commit keeps the same (figureScaleAnchorOf). Where figure-model cannot
@@ -917,9 +913,10 @@ export class FigureEditor extends LitElement {
917
913
  private transformed(id: string, result: GizmoResult) {
918
914
  const view = this.session!.read()
919
915
  /* A part hanging from a joint: the drag edits the joint -- its value, or where it sits (ADR-0093). */
920
- const asset = this.plainAsset() as FigureAsset
916
+ const shown = this.shown()!
917
+ const asset = shown.asset
921
918
  const drawn = view.geometry.find(g => g.id === id) as unknown as FigureRenderPart | undefined
922
- const drag = drawn ? jointDragOf(asset, ownerOf(id), drawn, result, this.currentInputs()) : null
919
+ const drag = drawn ? jointDragOf(asset, ownerOf(id), drawn, result, shown.inputs) : null
923
920
  if (drag) {
924
921
  if (drag.kind === 'value') this.run(() => this.session!.apply([{ kind: 'set-state', input: drag.input, unit: drag.unit, value: drag.value }]))
925
922
  else if (drag.kind === 'joint') this.run(() => this.session!.command({ kind: 'set-joint', joint: drag.joint }))
@@ -994,8 +991,7 @@ export class FigureEditor extends LitElement {
994
991
  /** 맞추기 — one tool, one step of the history, through the same inputs as the gizmo. */
995
992
  private align(tool: AlignTool) {
996
993
  this.run(() => {
997
- const asset = this.plainAsset()
998
- const changes = alignChanges(asset, this.selected, this.reachOf(this.selected), tool)
994
+ const changes = alignChanges(this.shown()!, this.selected, this.reachOf(this.selected), tool)
999
995
  if (changes.length) this.session!.apply(changes)
1000
996
  })
1001
997
  }
@@ -1007,7 +1003,7 @@ export class FigureEditor extends LitElement {
1007
1003
  this.session!.edit({ kind: 'duplicate-part', target: id })
1008
1004
  const copy = this.session!.read().geometry.find(g => !before.has(g.id))?.id
1009
1005
  if (!copy) return
1010
- this.session!.apply(mirrorChanges(this.plainAsset(), id, copy, axis))
1006
+ this.session!.apply(mirrorChanges(this.shown()!, id, copy, axis))
1011
1007
  this.selected = copy
1012
1008
  })
1013
1009
  }
@@ -1019,33 +1015,29 @@ export class FigureEditor extends LitElement {
1019
1015
  */
1020
1016
  /* What a tilted part's typed size keeps, by the figure's placement: not the floor's lowest point everywhere (designer 2026-09-27). */
1021
1017
  private keptExtremeWord(): string {
1022
- const placement = (this.asset() as FigureAsset | undefined)?.placement ?? 'floor'
1018
+ const placement = (this.asset())?.placement ?? 'floor'
1023
1019
  return i18next.t(`figure.label.kept-extreme-${placement}`)
1024
1020
  }
1025
- /* How the selected part would meet the plane, kept per source and part: working it out compiles the figure. */
1026
- private planeContact?: { source: string; part: string; contact?: FigurePlaneContact }
1027
- private planeContactOf(source: string, part: string): FigurePlaneContact | undefined {
1028
- if (this.planeContact?.source !== source || this.planeContact.part !== part) {
1029
- let contact: FigurePlaneContact | undefined
1021
+ /* How the selected part would meet the plane, worked out once per version and part. */
1022
+ private planeContactOf(shown: FigureRevision, part: string): FigurePlaneContact | undefined {
1023
+ return derivedOf(shown, `plane-contact|${part}`, () => {
1030
1024
  try {
1031
- contact = figurePlaneAlignChanges(parseFigureAsset(source), part).contact
1025
+ return figurePlaneAlignChanges(shown.asset, part).contact
1032
1026
  } catch {
1033
- contact = undefined
1027
+ return undefined
1034
1028
  }
1035
- this.planeContact = { source, part, contact }
1036
- }
1037
- return this.planeContact.contact
1029
+ })
1038
1030
  }
1039
- private renderPlaneAlign(asset: FigureAsset, unavailable: boolean) {
1040
- const placement = asset.placement ?? 'floor'
1031
+ private renderPlaneAlign(shown: FigureRevision, unavailable: boolean) {
1032
+ const placement = shown.asset.placement ?? 'floor'
1041
1033
  /* A tilted part touches the plane at a point or an edge, a ball at its lowest; the tooltip says so rather than promise a face (designer 2026-09-27). */
1042
- const contact = this.planeContactOf(this.session!.source(), this.selected)
1034
+ const contact = this.planeContactOf(shown, this.selected)
1043
1035
  const told = contact === 'tilted' || contact === 'extreme' ? `-${contact}` : ''
1044
1036
  return html`<button plane-align=${placement} ?disabled=${unavailable} title=${i18next.t(`figure.text.align-to-${placement}${told}`)}
1045
1037
  @click=${() => this.alignToPlane()}>${i18next.t(`figure.button.align-to-${placement}`)}</button>`
1046
1038
  }
1047
1039
  private alignToPlane() {
1048
- const asset = this.parsedAsset()
1040
+ const asset = this.shown()!.asset
1049
1041
  const { changes, contact, refusal } = figurePlaneAlignChanges(asset, this.selected)
1050
1042
  if (refusal) {
1051
1043
  this.error = i18next.t(`figure.text.align-refused-${refusal.toLowerCase().replace(/_/g, '-')}`, { part: partLabelOf(this.selected) })
@@ -1056,26 +1048,21 @@ export class FigureEditor extends LitElement {
1056
1048
  }
1057
1049
  private renderTools(unavailable: boolean) {
1058
1050
  const reach = this.reachOf(this.selected)
1059
- if (!this.selected || !reach.translate) return null
1060
- let asset: unknown
1061
- try {
1062
- asset = this.plainAsset()
1063
- } catch {
1064
- return null
1065
- }
1051
+ const shown = this.shown()
1052
+ if (!this.selected || !reach.translate || !shown) return null
1066
1053
  /*
1067
1054
  * In words, two rows by what they do (user 2026-10-01: 「무엇을 하는 것인지 모르겠다」): moving the part to the figure's
1068
1055
  * centre lines, onto its plane or onto the grid; and making a mirrored copy, which makes a part and moves nothing.
1069
1056
  */
1070
1057
  const tool = (name: AlignTool, label: string, title: string) =>
1071
1058
  html`<button ?disabled=${unavailable} title=${i18next.t(title)} @click=${() => this.align(name)}>${i18next.t(label)}</button>`
1072
- const mirrors = (['x', 'z'] as const).map(axis => ({ axis, able: canMirror(asset as never, this.selected, axis, reach) }))
1059
+ const mirrors = (['x', 'z'] as const).map(axis => ({ axis, able: canMirror(shown, this.selected, axis, reach) }))
1073
1060
  return html`<div vector-row>
1074
1061
  <label>${i18next.t('figure.label.tools')}</label>
1075
1062
  <div tools>
1076
1063
  ${tool('centre-x', 'figure.button.center-x', 'figure.text.center-on-x')}
1077
1064
  ${tool('centre-z', 'figure.button.center-z', 'figure.text.center-on-z')}
1078
- ${this.renderPlaneAlign(asset as FigureAsset, unavailable)}
1065
+ ${this.renderPlaneAlign(shown, unavailable)}
1079
1066
  ${tool('snap', 'figure.button.snap', 'figure.text.snap-explained')}
1080
1067
  </div>
1081
1068
  </div>
@@ -1131,20 +1118,6 @@ export class FigureEditor extends LitElement {
1131
1118
  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
1132
1119
  the panel, which draws itself again for nothing (sixteen times for one edit).
1133
1120
  */
1134
- private dimsSaid?: { key: string; dims: [number, number, number] | undefined }
1135
- private dimsOf(asset: FigureAsset): [number, number, number] | undefined {
1136
- const view = this.session?.read()
1137
- const key = `${view?.context}|${view?.revision}|${this.selected}`
1138
- if (this.dimsSaid?.key === key) return this.dimsSaid.dims
1139
- let dims: [number, number, number] | undefined
1140
- try {
1141
- dims = boxOf(asset, this.selected, this.currentInputs())?.dims
1142
- } catch {
1143
- dims = undefined
1144
- }
1145
- this.dimsSaid = { key, dims }
1146
- return dims
1147
- }
1148
1121
  private previewWordsSaid?: { preview: unknown; part: string; language: string; words: string[] }
1149
1122
  private previewWordsNow(): string[] {
1150
1123
  const preview = this.currentPreview()
@@ -1155,24 +1128,18 @@ export class FigureEditor extends LitElement {
1155
1128
  return words
1156
1129
  }
1157
1130
  private renderLinks(unavailable: boolean, section: 'shape' | 'places' | 'motion') {
1158
- let asset
1159
- try {
1160
- asset = this.plainAsset()
1161
- } catch {
1162
- return null
1163
- }
1164
- const dims = this.dimsOf(asset)
1131
+ const shown = this.shown()
1132
+ if (!shown) return null
1165
1133
  return html`<figure-links
1166
- .asset=${asset}
1134
+ .revision=${shown}
1167
1135
  .section=${section}
1168
1136
  .partId=${this.selected}
1169
- .dims=${dims}
1170
1137
  ?locked=${unavailable}
1171
1138
  @command=${(e: CustomEvent) => this.linkCommand(e.detail.action)}
1172
1139
  .previewWords=${this.previewWordsNow()}
1173
- .detachWords=${this.detachWords(asset as FigureAsset | undefined)}
1174
- .overhangWords=${section === 'shape' ? this.overhangWords(asset as FigureAsset | undefined) : NO_WORDS}
1175
- .overhangNote=${section === 'shape' ? this.overhangNote(asset as FigureAsset | undefined) : NO_WORDS}
1140
+ .detachWords=${this.detachWords(shown)}
1141
+ .overhangWords=${section === 'shape' ? this.overhangOf(shown).words : NO_WORDS}
1142
+ .overhangNote=${section === 'shape' ? this.overhangOf(shown).note : NO_WORDS}
1176
1143
  .motionEnds=${section === 'motion' ? this.motionEnds() : null}
1177
1144
  @go-fasten=${() => this.goFasten()}
1178
1145
  @preview-command=${(e: CustomEvent) => this.previewCommand(e.detail)}
@@ -1199,12 +1166,8 @@ export class FigureEditor extends LitElement {
1199
1166
  private lastAnchors: Record<string, ScaleAnchor> = {}
1200
1167
  /** The figure's controls: try a value, name it, set its range and travel time, keep the pose. */
1201
1168
  private renderControls(view: { fields: Array<{ id: string; role?: string; value: number }> }, unavailable: boolean) {
1202
- let asset
1203
- try {
1204
- asset = this.plainAsset()
1205
- } catch {
1206
- return null
1207
- }
1169
+ const asset = this.shown()?.asset
1170
+ if (!asset) return null
1208
1171
  const values = Object.fromEntries(view.fields.filter(f => f.role === 'state').map(f => [f.id, f.value]))
1209
1172
  return html`<figure-controls
1210
1173
  .asset=${asset}
@@ -1286,7 +1249,7 @@ export class FigureEditor extends LitElement {
1286
1249
  /* 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. */
1287
1250
  const part = this.selected
1288
1251
  const change: FigureInputChange = { kind: field.role === 'state' ? 'set-state' : 'set-design', input: field.id, unit: field.unit, value }
1289
- this.run(() => this.session!.apply(field.role !== 'state' && part ? figureSizeEditChanges(this.parsedAsset(), part, [change]) : [change]))
1252
+ this.run(() => this.session!.apply(field.role !== 'state' && part ? figureSizeEditChanges(this.shown()!.asset, part, [change]) : [change]))
1290
1253
  // Rejected edits must also restore the displayed numeric value.
1291
1254
  input.value = String(this.session!.read().fields.find(i => i.id === field.id)!.value)
1292
1255
  }
@@ -1331,14 +1294,12 @@ export class FigureEditor extends LitElement {
1331
1294
  label.addEventListener('pointerup', up)
1332
1295
  label.addEventListener('pointercancel', up)
1333
1296
  }
1334
- /** The measured range, kept for the revision it was measured at: measuring evaluates the figure at its edges. */
1335
- private range?: { at: string; of: ReturnType<typeof instanceRangeOf>; toDeclare?: ReturnType<typeof instanceRangeToDeclare> }
1336
1297
  /** The sizes a board may give the figure, and the way to measure them again after its parts changed. */
1337
- private renderInstanceRange(asset: FigureAsset | undefined, unavailable: boolean) {
1338
- if (!asset) return nothing
1339
- /* Another figure, or another step of this one, is measured again. */
1340
- const at = `${this.session?.read().context ?? ''}:${this.revision}`
1341
- if (this.range?.at !== at) {
1298
+ private renderInstanceRange(shown: FigureRevision | undefined, unavailable: boolean) {
1299
+ if (!shown) return nothing
1300
+ const asset = shown.asset
1301
+ /* Measured once per version: measuring evaluates the figure at its edges. */
1302
+ const measured = derivedOf(shown, 'instance-range', () => {
1342
1303
  let of: ReturnType<typeof instanceRangeOf>
1343
1304
  let toDeclare: ReturnType<typeof instanceRangeToDeclare>
1344
1305
  try {
@@ -1351,11 +1312,11 @@ export class FigureEditor extends LitElement {
1351
1312
  } catch {
1352
1313
  toDeclare = undefined
1353
1314
  }
1354
- this.range = { at, of, toDeclare }
1355
- }
1356
- const range = this.range.of
1315
+ return { of, toDeclare }
1316
+ })
1317
+ const range = measured.of
1357
1318
  /* No size yet: the figure is declared its size from its parts, so a board can resize it and parts can follow it. */
1358
- const toDeclare = this.range.toDeclare
1319
+ const toDeclare = measured.toDeclare
1359
1320
  if (!range && toDeclare)
1360
1321
  return html`<section>
1361
1322
  <h3>${i18next.t('figure.label.instance-range')}</h3>
@@ -1472,9 +1433,10 @@ export class FigureEditor extends LitElement {
1472
1433
  * The pitch of a row that fills its length is edited as a number (`set-repeat` makes it the part's own the first
1473
1434
  * time); a row of a fixed count says its pitch cannot be set yet (figure-model refuses it the same way).
1474
1435
  */
1475
- private renderRepeat(asset: FigureAsset | undefined, geometry: readonly { id: string }[], unavailable: boolean) {
1436
+ private renderRepeat(shown: FigureRevision | undefined, geometry: readonly { id: string }[], unavailable: boolean) {
1476
1437
  const repeat = repeatOf(this.selected, geometry)
1477
- if (!asset || !repeat || repeat.index) return nothing
1438
+ const asset = shown?.asset
1439
+ if (!shown || !asset || !repeat || repeat.index) return nothing
1478
1440
  /* The row is set on its part; what hangs from that part is copied with it and has no row of its own. */
1479
1441
  if (!asset.document.model.nodes.some(n => n.id === `${this.selected}.repeat`)) return nothing
1480
1442
  const model = asset.document.model as unknown as {
@@ -1486,7 +1448,7 @@ export class FigureEditor extends LitElement {
1486
1448
  const axis = model.nodes.find(n => n.id === `${this.selected}.repeat`)?.params?.axis ?? '?'
1487
1449
  const own = `${this.selected}.repeat.pitch`
1488
1450
  /* The pitch as it stands: the part's own number, a constant, or what a pitch that follows a length comes to. */
1489
- const pitch = asset.designInputs[own] ?? model.constants.find(c => c.id === layout?.args[4])?.value ?? valueNow(asset, layout?.args[4])
1451
+ const pitch = asset.designInputs[own] ?? model.constants.find(c => c.id === layout?.args[4])?.value ?? valueNow(shown, layout?.args[4])
1490
1452
  const fits = layout?.op === 'fit-pitch@1'
1491
1453
  /* What the pitch gives, in numbers: this many copies at the length it is authored, and how long it may be placed. */
1492
1454
  const lengthRef = layout?.args[0] ?? ''
@@ -1509,7 +1471,7 @@ export class FigureEditor extends LitElement {
1509
1471
  @change=${(e: Event) => this.setPitch(field, e)}
1510
1472
  />
1511
1473
  </div>
1512
- <ul row-reading>${rowWordsOf(this.rowNow(asset), (key, params) => i18next.t(key, params)).map(line => html`<li>${line}</li>`)}</ul>
1474
+ <ul row-reading>${rowWordsOf(this.rowNow(shown), (key, params) => i18next.t(key, params)).map(line => html`<li>${line}</li>`)}</ul>
1513
1475
  <p hint>
1514
1476
  ${fits && length !== undefined && longest !== undefined && limit !== undefined
1515
1477
  ? i18next.t('figure.text.repeat-what-pitch-gives', {
@@ -1529,12 +1491,14 @@ export class FigureEditor extends LitElement {
1529
1491
  </section>`
1530
1492
  }
1531
1493
  /** The selected row as it is drawn now, read by figure-model: nothing when it cannot be read. */
1532
- private rowNow(asset: FigureAsset): FigureRowReading | undefined {
1533
- try {
1534
- return figureRowOf(asset, this.selected, this.currentInputs())
1535
- } catch {
1536
- return undefined
1537
- }
1494
+ private rowNow(shown: FigureRevision): FigureRowReading | undefined {
1495
+ return derivedOf(shown, `row|${this.selected}`, () => {
1496
+ try {
1497
+ return figureRowOf(shown.asset, this.selected, shown.inputs)
1498
+ } catch {
1499
+ return undefined
1500
+ }
1501
+ })
1538
1502
  }
1539
1503
  /** The design input a row's pitch is read from, when it is read from one (ADR-0096). */
1540
1504
  private pitchInputOf(asset: FigureAsset, part: string): string | undefined {
@@ -1620,7 +1584,7 @@ export class FigureEditor extends LitElement {
1620
1584
  const pitch = input.valueAsNumber
1621
1585
  if (!Number.isFinite(pitch) || pitch === field.value) return
1622
1586
  /* Read from a design input the board may set: the input's value changes, the row keeps reading it (ADR-0096). */
1623
- const from = this.pitchInputOf(this.plainAsset() as FigureAsset, this.selected)
1587
+ const from = this.pitchInputOf(this.shown()!.asset, this.selected)
1624
1588
  if (from) {
1625
1589
  this.run(() => this.session!.apply([{ kind: 'set-design', input: from, unit: 'mm', value: pitch }]))
1626
1590
  input.value = String(this.session!.read().fields.find(i => i.id === from)?.value ?? field.value)
@@ -1686,13 +1650,14 @@ export class FigureEditor extends LitElement {
1686
1650
  let face: FigureFaceArea | undefined
1687
1651
  if (candidate.face) {
1688
1652
  try {
1689
- face = figurePartFaceOf(this.parsedAsset(), candidate.face.part, candidate.face.face, this.currentInputs())
1653
+ const shown = this.shown()!
1654
+ face = figurePartFaceOf(shown.asset, candidate.face.part, candidate.face.face, shown.inputs)
1690
1655
  } catch {
1691
1656
  face = undefined
1692
1657
  }
1693
1658
  }
1694
1659
  /* A row is read on the figure the command would make, by figure-model, as drawn. */
1695
- const row = candidate.action.kind === 'make-repeat' ? figureRowOf(seen.asset, this.selected, this.currentInputs()) : undefined
1660
+ const row = candidate.action.kind === 'make-repeat' ? figureRowOf(seen.asset, this.selected, this.shown()!.inputs) : undefined
1696
1661
  this.preview = {
1697
1662
  ...held,
1698
1663
  context: seen.context,
@@ -1723,104 +1688,91 @@ export class FigureEditor extends LitElement {
1723
1688
  it stays where it stands now, what stays fastened to it, which clips would go. "It stays where it is" is said only
1724
1689
  when the copy shows it does.
1725
1690
  */
1726
- private detachSaid?: { key: string; words: string[] }
1727
1691
  private detachTimer?: ReturnType<typeof setTimeout>
1728
- private detachAsked?: string
1692
+ private detachAsked?: { shown: FigureRevision; part: string }
1729
1693
  /**
1730
1694
  * Said once the figure has rested: working it out is a preview of the command on a copy (tens of milliseconds), and the
1731
1695
  * edit that made this version should be on screen before it. Until then nothing is said, as for a part with no joint.
1732
1696
  */
1733
- private detachWords(asset: FigureAsset | undefined): string[] {
1734
- const view = this.session?.read()
1735
- const joint = asset?.joints?.find(j => j.body1.part === this.selected)
1736
- if (!asset || !view || !joint || !this.session) return NO_WORDS
1737
- const key = `${view.context}|${view.revision}|${this.selected}`
1738
- if (this.detachSaid?.key === key) return this.detachSaid.words
1739
- if (this.detachAsked !== key) {
1740
- this.detachAsked = key
1697
+ private detachWords(shown: FigureRevision): string[] {
1698
+ const part = this.selected
1699
+ if (!this.session || !shown.asset.joints?.some(j => j.body1.part === part)) return NO_WORDS
1700
+ const key = `detach|${part}`
1701
+ const said = derivedHeld<string[]>(shown, key)
1702
+ if (said) return said
1703
+ if (this.detachAsked?.shown !== shown || this.detachAsked.part !== part) {
1704
+ this.detachAsked = { shown, part }
1741
1705
  clearTimeout(this.detachTimer)
1742
1706
  this.detachTimer = setTimeout(() => {
1743
- const now = this.session?.read()
1744
- if (!now || `${now.context}|${now.revision}|${this.selected}` !== key) return
1745
- this.computeDetachWords(asset, key)
1707
+ if (this.shown() !== shown || this.selected !== part) return
1708
+ derivedOf(shown, key, () => this.computeDetachWords(shown, part))
1746
1709
  this.requestUpdate()
1747
1710
  }, PREVIEW_SETTLE_MS)
1748
1711
  }
1749
1712
  return NO_WORDS
1750
1713
  }
1751
- private computeDetachWords(asset: FigureAsset, key: string): string[] {
1714
+ private computeDetachWords(shown: FigureRevision, part: string): string[] {
1752
1715
  const say = (k: string, params?: Record<string, string>) => i18next.t(k, params)
1753
1716
  let words: string[]
1754
1717
  try {
1755
- const seen = this.session!.previewCommand({ kind: 'detach', part: this.selected })
1756
- const own = seen.moves.some(m => m.body === this.selected)
1718
+ const seen = this.session!.previewCommand({ kind: 'detach', part })
1719
+ const own = seen.moves.some(m => m.body === part)
1757
1720
  words = own
1758
- ? [say('figure.text.detach-moves'), ...previewWordsOf({ context: seen.context, revision: seen.revision, key, moves: seen.moves }, this.selected, say)]
1721
+ ? [say('figure.text.detach-moves'), ...previewWordsOf({ context: seen.context, revision: seen.revision, key: `detach|${part}`, moves: seen.moves }, part, say)]
1759
1722
  : /* It stays where it is: as expected, so nothing is said (user 2026-10-01). */ []
1760
- const children = (asset.joints ?? []).filter(j => 'part' in j.body0 && j.body0.part === this.selected).map(j => partLabelOf(j.body1.part))
1723
+ const children = (shown.asset.joints ?? []).filter(j => 'part' in j.body0 && j.body0.part === part).map(j => partLabelOf(j.body1.part))
1761
1724
  if (children.length) words.push(say('figure.text.detach-keeps-children', { parts: children.join(', ') }))
1762
1725
  if (seen.clipsWouldGo.length) words.push(say('figure.text.detach-clips', { clips: seen.clipsWouldGo.map(c => c.clip).join(', ') }))
1763
1726
  } catch (error) {
1764
1727
  words = [say('figure.text.detach-refused', { reason: this.said(error) })]
1765
1728
  }
1766
- this.detachSaid = { key, words }
1767
1729
  return words
1768
1730
  }
1769
1731
  /*
1770
1732
  Past the edge of the face a part is fastened to (ADR-0097 §5.5, Q3): the selected part's own, and the parts fastened
1771
- to it -- said apart from the room, nothing moved. Read once per figure, version and part.
1733
+ to it -- said apart from the room, nothing moved. Read once per version, part and language. The note says it is wider
1734
+ than its face: a fact of the relation, said in its details, not as a warning (designer 2026-09-27).
1772
1735
  */
1773
- private overhangSaid?: { key: string; words: string[]; note: string[] }
1774
- /** Wider than its face: a fact of the relation, said in its details, not as a warning (designer 2026-09-27). */
1775
- private overhangNote(asset: FigureAsset | undefined): string[] {
1776
- this.overhangWords(asset)
1777
- return this.overhangSaid?.note ?? []
1778
- }
1779
- private overhangWords(asset: FigureAsset | undefined): string[] {
1780
- const view = this.session?.read()
1781
- if (!asset || !view || !this.selected) return NO_WORDS
1782
- const key = `${view.context}|${view.revision}|${this.selected}`
1783
- if (this.overhangSaid?.key === key) return this.overhangSaid.words
1784
- const say = (k: string, params?: Record<string, string>) => i18next.t(k, params)
1785
- const where = (o: FigureFaceOverhang) => ({ on: partLabelOf(o.on.part), face: say(`figure.label.face-${o.on.face}`) })
1786
- const sides = (o: FigureFaceOverhang) => o.sides.map(s => say(`figure.text.overhang-side-${s.side}`, { axis: s.axis.toUpperCase(), mm: String(s.mm) })).join(', ')
1787
- const words: string[] = []
1788
- const note: string[] = []
1789
- try {
1790
- const inputs = this.currentInputs()
1791
- const own = figureFaceOverhangOf(asset, this.selected, inputs)
1792
- if (own?.state === 'larger') {
1793
- note.push(say('figure.text.overhang-larger', { ...where(own), sides: sides(own) }))
1794
- if (own.lopsided.length) note.push(say('figure.text.overhang-lopsided', { axes: own.lopsided.map(a => a.toUpperCase()).join(', ') }))
1736
+ private overhangOf(shown: FigureRevision): { words: string[]; note: string[] } {
1737
+ const part = this.selected
1738
+ if (!part) return NO_OVERHANG
1739
+ return derivedOf(shown, `overhang|${part}|${i18next.language}`, () => {
1740
+ const asset = shown.asset
1741
+ const say = (k: string, params?: Record<string, string>) => i18next.t(k, params)
1742
+ const where = (o: FigureFaceOverhang) => ({ on: partLabelOf(o.on.part), face: say(`figure.label.face-${o.on.face}`) })
1743
+ const sides = (o: FigureFaceOverhang) => o.sides.map(s => say(`figure.text.overhang-side-${s.side}`, { axis: s.axis.toUpperCase(), mm: String(s.mm) })).join(', ')
1744
+ const words: string[] = []
1745
+ const note: string[] = []
1746
+ try {
1747
+ const own = figureFaceOverhangOf(asset, part, shown.inputs)
1748
+ if (own?.state === 'larger') {
1749
+ note.push(say('figure.text.overhang-larger', { ...where(own), sides: sides(own) }))
1750
+ if (own.lopsided.length) note.push(say('figure.text.overhang-lopsided', { axes: own.lopsided.map(a => a.toUpperCase()).join(', ') }))
1751
+ }
1752
+ if (own?.state === 'out') words.push(say('figure.text.overhang-out', { ...where(own), sides: sides(own) }))
1753
+ else if (own?.state === 'possible') words.push(say('figure.text.overhang-possible', where(own)))
1754
+ else if (own?.state === 'unknown') words.push(say('figure.text.overhang-unknown', where(own)))
1755
+ const held = (asset.joints ?? []).filter(j => 'part' in j.body0 && j.body0.part === part).map(j => figureFaceOverhangOf(asset, j.body1.part, shown.inputs))
1756
+ const past = held.filter((o): o is FigureFaceOverhang => o?.state === 'out' || o?.state === 'possible')
1757
+ if (past.length) words.push(say('figure.text.overhang-held', { parts: past.map(o => partLabelOf(o.part)).join(', ') }))
1758
+ } catch {
1759
+ /* Not read: nothing said rather than a guess. */
1795
1760
  }
1796
- if (own?.state === 'out') words.push(say('figure.text.overhang-out', { ...where(own), sides: sides(own) }))
1797
- else if (own?.state === 'possible') words.push(say('figure.text.overhang-possible', where(own)))
1798
- else if (own?.state === 'unknown') words.push(say('figure.text.overhang-unknown', where(own)))
1799
- const held = (asset.joints ?? []).filter(j => 'part' in j.body0 && j.body0.part === this.selected).map(j => figureFaceOverhangOf(asset, j.body1.part, inputs))
1800
- const past = held.filter((o): o is FigureFaceOverhang => o?.state === 'out' || o?.state === 'possible')
1801
- if (past.length) words.push(say('figure.text.overhang-held', { parts: past.map(o => partLabelOf(o.part)).join(', ') }))
1802
- } catch {
1803
- /* Not read: nothing said rather than a guess. */
1804
- }
1805
- this.overhangSaid = { key, words, note }
1806
- return words
1761
+ return { words, note }
1762
+ })
1807
1763
  }
1808
- /** The selected part's motion at both ends of its range, made once per figure, version and part. */
1809
- private motionEndsSaid?: { key: string; ends: MotionEnds | null }
1764
+ /** The selected part's motion at both ends of its range, made once per version and part. */
1810
1765
  private motionEnds(): MotionEnds | null {
1811
- const view = this.session?.read()
1812
- if (!view || !this.selected) return null
1813
- const key = `${view.context}|${view.revision}|${this.selected}`
1814
- if (this.motionEndsSaid?.key !== key) {
1815
- let ends: MotionEnds | null = null
1766
+ const shown = this.shown()
1767
+ const part = this.selected
1768
+ if (!shown || !part) return null
1769
+ return derivedOf(shown, `motion-ends|${part}`, () => {
1816
1770
  try {
1817
- ends = motionEndsOf(this.parsedAsset(), this.selected, this.currentInputs())
1771
+ return motionEndsOf(shown, part)
1818
1772
  } catch {
1819
- ends = null
1773
+ return null
1820
1774
  }
1821
- this.motionEndsSaid = { key, ends }
1822
- }
1823
- return this.motionEndsSaid.ends
1775
+ })
1824
1776
  }
1825
1777
  /** "Fasten it first", from the motion tab: to the properties tab, the fastening form open on this part. */
1826
1778
  private async goFasten() {
@@ -1853,50 +1805,8 @@ export class FigureEditor extends LitElement {
1853
1805
  this.faceMapping = undefined
1854
1806
  if (id && (this.tab === 'verdict' || this.tab === 'view' || this.tab === 'history')) this.tab = 'properties'
1855
1807
  }
1856
- /**
1857
- * The figure as the session holds it now, parsed once per text: a render asks for it a dozen times, and parsing the whole
1858
- * figure is most of what a selection costs. Read, never changed -- what changes a figure goes through the session.
1859
- */
1860
- /**
1861
- * The session has evaluated this figure at this version, with these inputs: the screen's questions of the text it just
1862
- * parsed are answered from that, not by evaluating the figure again (each is tens of milliseconds on a real figure).
1863
- */
1864
- private seeded(asset: FigureAsset): FigureAsset {
1865
- const view = this.session?.read()
1866
- if (view?.geometry.length) {
1867
- const values = this.currentInputs()
1868
- seedEvaluatedGeometry(asset, values, view.geometry as never)
1869
- seedFigureEvaluation(asset, values, view.geometry)
1870
- }
1871
- return asset
1872
- }
1873
- private parsed?: { text: string; asset: FigureAsset }
1874
- private parsedAsset(): FigureAsset {
1875
- const text = this.session!.source()
1876
- if (this.parsed?.text !== text) {
1877
- /* The session's text is what its workspace exported, compiled then: it is read here, not compiled again. */
1878
- const plain = this.plainAsset()
1879
- const upgraded = upgradeFigurePlacement(plain)
1880
- this.parsed = { text, asset: upgraded === plain ? plain : this.seeded(deepFreeze(upgraded)) }
1881
- }
1882
- return this.parsed.asset
1883
- }
1884
- private plain?: { text: string; asset: FigureAsset }
1885
- /**
1886
- * The figure as plain data, parsed once per text and frozen: the render, the gizmo's reach and every tool read it, and
1887
- * a copy for each of them cost more than the selection they serve. A change goes through the session, never through this.
1888
- */
1889
- private plainAsset(): FigureAsset {
1890
- const text = this.session!.source()
1891
- if (this.plain?.text !== text) this.plain = { text, asset: this.seeded(deepFreeze(JSON.parse(text))) }
1892
- return this.plain.asset
1893
- }
1894
- private asset(): unknown {
1895
- try {
1896
- return this.plainAsset()
1897
- } catch {
1898
- return undefined
1899
- }
1808
+ private asset(): FigureAsset | undefined {
1809
+ return this.shown()?.asset
1900
1810
  }
1901
1811
  /** A side tab says what it is in words under its icon: the icons alone had to be learnt (designer 2026-09-27, U05). */
1902
1812
  private renderTab(tab: SideTab, icon: string, label: string, badge?: number) {
@@ -1963,25 +1873,26 @@ export class FigureEditor extends LitElement {
1963
1873
  * Why a drawn push's end cannot be drawn to one point now: the kernel's own refusal of that command on a copy (what is
1964
1874
  * fastened to the end face), said as the face. Asked once per figure revision and part.
1965
1875
  */
1966
- private pointBlocked?: { key: string; words: string }
1967
1876
  private pointBlockedOf(drawn: NonNullable<ReturnType<typeof drawnOf>>): string {
1968
- if (drawn.apex || drawn.section.kind !== 'polygon' || !this.session) return ''
1969
- const key = `${this.session.read().revision}|${this.selected}`
1970
- if (this.pointBlocked?.key === key) return this.pointBlocked.words
1971
- let words = ''
1972
- try {
1973
- this.session.previewCommand({ kind: 'set-section', part: this.selected!, apex: apexOver(drawn.section.points, drawn.axis) })
1974
- } catch (error) {
1975
- if ((error as { code?: string }).code === 'FEATURE_MAPPING_REQUIRED')
1976
- words = i18next.t('figure.text.section-editor-apex-blocked', { face: i18next.t('figure.label.face-' + END_FACE(drawn.axis, 'end')) })
1977
- }
1978
- this.pointBlocked = { key, words }
1979
- return words
1877
+ const shown = this.shown()
1878
+ const session = this.session
1879
+ if (drawn.apex || drawn.section.kind !== 'polygon' || !session || !shown) return ''
1880
+ const part = this.selected
1881
+ const apex = apexOver(drawn.section.points, drawn.axis)
1882
+ return derivedOf(shown, `point-blocked|${part}|${i18next.language}`, () => {
1883
+ try {
1884
+ session.previewCommand({ kind: 'set-section', part, apex })
1885
+ } catch (error) {
1886
+ if ((error as { code?: string }).code === 'FEATURE_MAPPING_REQUIRED')
1887
+ return i18next.t('figure.text.section-editor-apex-blocked', { face: i18next.t('figure.label.face-' + END_FACE(drawn.axis, 'end')) })
1888
+ }
1889
+ return ''
1890
+ })
1980
1891
  }
1981
1892
  private renderStart() {
1982
1893
  const shapes: FigurePaletteKind[] = ['box', 'cylinder', 'plate']
1983
1894
  /* Where it is authored from, chosen before any part (ADR-0097): the figure's own setting, no room made up for it. */
1984
- const placement = ((this.asset() as FigureAsset | undefined)?.placement ?? 'floor') as FigurePlacement
1895
+ const placement = ((this.asset())?.placement ?? 'floor') as FigurePlacement
1985
1896
  return html`<div start role="region" aria-label=${i18next.t('figure.text.start-question')}>
1986
1897
  <h2>${i18next.t('figure.text.start-question')}</h2>
1987
1898
  <div start-paths>
@@ -2028,7 +1939,7 @@ export class FigureEditor extends LitElement {
2028
1939
  let summary: { kind: string; size: string } | undefined
2029
1940
  try {
2030
1941
  summary = this.selected
2031
- ? partSummaryOf(this.parsedAsset(), ownerOf(this.selected), (key, params) => i18next.t(key, params), i18next.language || 'ko')
1942
+ ? partSummaryOf(this.shown()!.asset, ownerOf(this.selected), (key, params) => i18next.t(key, params), i18next.language || 'ko')
2032
1943
  : undefined
2033
1944
  } catch {
2034
1945
  summary = undefined
@@ -2063,9 +1974,11 @@ export class FigureEditor extends LitElement {
2063
1974
  A part's field only the declared room still reads -- its old height position once the floor holds it -- moves
2064
1975
  nothing; it is shown apart as what it is, not as a position (ADR-0097, designer 2026-09-27).
2065
1976
  */
1977
+ const shown = this.shown()
1978
+ const asset = shown?.asset
2066
1979
  let roomOnly: string[] = []
2067
1980
  try {
2068
- roomOnly = this.selected ? figureRoomOnlyInputsOf(this.parsedAsset(), this.selected) : []
1981
+ roomOnly = this.selected && asset ? figureRoomOnlyInputsOf(asset, this.selected) : []
2069
1982
  } catch {
2070
1983
  roomOnly = []
2071
1984
  }
@@ -2075,14 +1988,13 @@ export class FigureEditor extends LitElement {
2075
1988
  try {
2076
1989
  const body = view.geometry.find(g => g.id === this.selected) as unknown as FigureRenderPart | undefined
2077
1990
  const placed = body ? placementOf(body) : undefined
2078
- const parsed = this.selected ? this.parsedAsset() : undefined
2079
- if (placed && parsed)
1991
+ if (placed && shown && this.selected)
2080
1992
  held = {
2081
1993
  position: placed.position,
2082
1994
  rotation: placed.rotation,
2083
- size: (['x', 'y', 'z'] as const).map(axis => measureNow(parsed, ownerOf(this.selected), axis)),
1995
+ size: (['x', 'y', 'z'] as const).map(axis => measureNow(shown, ownerOf(this.selected), axis)),
2084
1996
  /* Only a size a rule sets: a palette part's own numbers are its shape's fields, not a rule's (found in the browser). */
2085
- sizeRuled: (['x', 'y', 'z'] as const).every(axis => followOf(parsed, ownerOf(this.selected), axis).of !== 'own')
1997
+ sizeRuled: (['x', 'y', 'z'] as const).every(axis => followOf(shown.asset, ownerOf(this.selected), axis).of !== 'own')
2086
1998
  }
2087
1999
  } catch {
2088
2000
  held = undefined
@@ -2095,8 +2007,8 @@ export class FigureEditor extends LitElement {
2095
2007
  : view.dirty || !view.id
2096
2008
  ? 'figure.text.unsaved-changes'
2097
2009
  : 'figure.text.saved'
2098
- const asset = this.asset()
2099
2010
  const tab = this.tab
2011
+ const live = this.liveParts(shown)
2100
2012
  return html`<ox-canvas-bar
2101
2013
  history
2102
2014
  fit
@@ -2137,8 +2049,8 @@ export class FigureEditor extends LitElement {
2137
2049
  slot="start"
2138
2050
  .budget=${this.budget}
2139
2051
  @show-maturity=${() => (this.tab = 'verdict')}
2140
- .parts=${this.partRows(view.geometry, asset as FigureAsset | undefined)}
2141
- .partBudget=${this.partBudgetOf(asset as FigureAsset | undefined)}
2052
+ .parts=${this.partRows(view.geometry, asset)}
2053
+ .partBudget=${this.partBudgetOf(asset)}
2142
2054
  @set-detail-level=${(e: CustomEvent) => this.linkCommand({ kind: 'set-detail-level', level: e.detail.level })}
2143
2055
  .selected=${this.selected}
2144
2056
  .modelName=${view.name ?? ''}
@@ -2160,7 +2072,7 @@ export class FigureEditor extends LitElement {
2160
2072
  ? html`<figure-preview
2161
2073
  ?hidden=${this.middle !== 'preview'}
2162
2074
  .active=${this.middle === 'preview'}
2163
- .asset=${asset}
2075
+ .revision=${shown}
2164
2076
  .context=${view.context ?? ''}
2165
2077
  .view=${this.view}
2166
2078
  .selected=${this.selected}
@@ -2179,9 +2091,9 @@ export class FigureEditor extends LitElement {
2179
2091
  ${this.sectionEditing && this.sectionEditing === this.selected && !unavailable ? this.renderSectionEditor(view.geometry) : nothing}
2180
2092
  <figure-canvas
2181
2093
  .view=${this.view}
2182
- .parts=${this.drawnParts(view.geometry, asset)}
2183
- .anchors=${this.anchorsOf(asset as FigureAsset | undefined)}
2184
- .fan=${this.fanOf(asset as FigureAsset | undefined)}
2094
+ .parts=${this.drawnParts(view.geometry, live)}
2095
+ .anchors=${this.anchorsOf(asset)}
2096
+ .fan=${this.fanOf(shown, live)}
2185
2097
  .ghost=${this.previewShown().ghost}
2186
2098
  .highlight=${this.previewShown().highlight}
2187
2099
  .palette=${view.palette ?? {}}
@@ -2221,7 +2133,7 @@ export class FigureEditor extends LitElement {
2221
2133
  </div>
2222
2134
  ${this.renderRepeatNote(view.geometry)}
2223
2135
  </section>
2224
- ${this.renderRepeat(asset as FigureAsset | undefined, view.geometry, unavailable)}
2136
+ ${this.renderRepeat(shown, view.geometry, unavailable)}
2225
2137
  ${this.renderGroups(this.withSizeRow(groupFields(partFields, this.selected), held), 'figure.label.group-placement-size', 'figure.label.group-shape', unavailable, this.renderTools(unavailable), held)}
2226
2138
  ${roomFields.length ? this.renderRoomOnly(roomFields, unavailable) : nothing}
2227
2139
  ${this.renderLinks(unavailable, 'shape')}
@@ -2252,7 +2164,7 @@ export class FigureEditor extends LitElement {
2252
2164
  </section>`
2253
2165
  : html`${this.renderSubject()}<slot name="figure"></slot>
2254
2166
  ${this.renderGroups(groupFields(common), 'figure.label.group-figure-size', 'figure.label.design-inputs', unavailable)}
2255
- ${this.renderInstanceRange(asset as FigureAsset | undefined, unavailable)}`}
2167
+ ${this.renderInstanceRange(shown, unavailable)}`}
2256
2168
  </div>
2257
2169
  <div panel ?off=${tab !== 'capability'}>
2258
2170
  ${this.renderSubject()}
@@ -2354,7 +2266,7 @@ export class FigureEditor extends LitElement {
2354
2266
  * something else. A size that follows something is said at the panel's head, so not again here.
2355
2267
  */
2356
2268
  private renderHeldAxes(vectors: FieldGroup[]) {
2357
- const fastened = !!(this.asset() as FigureAsset | undefined)?.joints?.some(j => j.body1.part === ownerOf(this.selected))
2269
+ const fastened = !!(this.asset())?.joints?.some(j => j.body1.part === ownerOf(this.selected))
2358
2270
  const held = vectors.filter(g => g.kind === 'vector' && g.name !== 'size' && (['x', 'y', 'z'] as const).some(axis => !g.cells[axis]))
2359
2271
  if (!held.length) return nothing
2360
2272
  const placed = held.every(g => g.name === 'position' || g.name === 'rotation')
@@ -2381,7 +2293,7 @@ export class FigureEditor extends LitElement {
2381
2293
  private async askRoomRefit() {
2382
2294
  let refit
2383
2295
  try {
2384
- refit = figureRoomRefitOf(this.parsedAsset())
2296
+ refit = figureRoomRefitOf(this.shown()!.asset)
2385
2297
  } catch (error) {
2386
2298
  this.error = this.said(error)
2387
2299
  return