@things-factory/figure-ui 10.1.129 → 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
@@ -3,6 +3,7 @@ import type { FigureAsset, FigureFace } from '@hatiolab/figure-model'
3
3
 
4
4
  import { boxOf } from './align.js'
5
5
  import { alignAxesOf, facesOf, faceAxisOf, hasInstanceSize, mountingPlaneOf, type Alignment } from './links.js'
6
+ import type { FigureRevision } from './revision.js'
6
7
 
7
8
  /*
8
9
  * What the 붙이기 drawing reads (user 2026-10-01): the face a part is fastened to, seen from above, and where the part
@@ -19,11 +20,11 @@ export type FastenTarget = { part: string; face: string } | 'plane'
19
20
  const OPPOSITE: Record<FigureFace, FigureFace> = { left: 'right', right: 'left', bottom: 'top', top: 'bottom', back: 'front', front: 'back' }
20
21
  const NORMAL: Record<FigureFace, [Axis, 1 | -1]> = { left: ['x', -1], right: ['x', 1], bottom: ['y', -1], top: ['y', 1], back: ['z', -1], front: ['z', 1] }
21
22
 
22
- /** The world direction a part's face looks along now, turns and all; by its name when the face's outline is not read. */
23
- function directionOf(asset: FigureAsset, part: string, face: string): [Axis, 1 | -1] | undefined {
23
+ /** The world direction a part's face looks along now, as drawn, turns and all; by its name when the face's outline is not read. */
24
+ function directionOf(revision: FigureRevision, part: string, face: string): [Axis, 1 | -1] | undefined {
24
25
  const area = (() => {
25
26
  try {
26
- return figurePartFaceOf(asset, part, face)
27
+ return figurePartFaceOf(revision.asset, part, face, revision.inputs)
27
28
  } catch {
28
29
  return undefined
29
30
  }
@@ -40,19 +41,20 @@ function directionOf(asset: FigureAsset, part: string, face: string): [Axis, 1 |
40
41
  * centre. Read from where each face looks now, so a part standing turned offers the face that is really there.
41
42
  * Undefined when the part has no such face.
42
43
  */
43
- export function myFaceFor(asset: FigureAsset, part: string, target: FastenTarget, mode: FastenMode): FigureFace | 'centre' | undefined {
44
+ export function myFaceFor(revision: FigureRevision, part: string, target: FastenTarget, mode: FastenMode): FigureFace | 'centre' | undefined {
45
+ const asset = revision.asset
44
46
  if (mode === 'centre') return target === 'plane' ? undefined : 'centre'
45
47
  const want: [Axis, 1 | -1] | undefined =
46
48
  target === 'plane'
47
49
  ? /* The plane is met from above on a floor, from in front on a wall. */ [mountingPlaneOf(asset).normal, -1]
48
50
  : (() => {
49
- const d = directionOf(asset, target.part, target.face)
51
+ const d = directionOf(revision, target.part, target.face)
50
52
  return d && [d[0], (mode === 'meet' ? -d[1] : d[1]) as 1 | -1]
51
53
  })()
52
54
  if (!want) return undefined
53
55
  const mine = facesOf(asset, part).filter((f): f is FigureFace => f in NORMAL)
54
56
  const hit = mine.find(f => {
55
- const d = directionOf(asset, part, f)
57
+ const d = directionOf(revision, part, f)
56
58
  return d && d[0] === want[0] && d[1] === want[1]
57
59
  })
58
60
  return hit ?? (target !== 'plane' && mode === 'meet' && target.face in OPPOSITE ? OPPOSITE[target.face as FigureFace] : undefined)
@@ -72,17 +74,18 @@ export interface FaceView {
72
74
  edges: Record<Axis, boolean>
73
75
  }
74
76
 
75
- export function faceViewOf(asset: FigureAsset, part: string, target: FastenTarget): FaceView | undefined {
77
+ export function faceViewOf(revision: FigureRevision, part: string, target: FastenTarget): FaceView | undefined {
78
+ const asset = revision.asset
76
79
  const across = alignAxesOf(asset, part, target)
77
80
  if (across.length !== 2) return undefined
78
- const box = boxOf(asset, part)
81
+ const box = boxOf(revision, part)
79
82
  if (!box) return undefined
80
83
  const axes = [across[0]!.axis, across[1]!.axis] as [Axis, Axis]
81
84
  const face: Partial<Record<Axis, number>> = {}
82
85
  if (target === 'plane') {
83
86
  for (const a of axes) if (hasInstanceSize(asset, a)) face[a] = asset.designInputs[`size.${a}`]!
84
87
  } else {
85
- const theirs = boxOf(asset, target.part)
88
+ const theirs = boxOf(revision, target.part)
86
89
  const normal = faceAxisOf(asset, target.part, target.face)
87
90
  for (const a of axes) if (theirs && a !== normal) face[a] = theirs.extent[a]
88
91
  }
@@ -1,6 +1,8 @@
1
- import { compileFigureAsset, parseFigureAsset, figureOccupiedBoxesOf, figureWritersOf } from '@hatiolab/figure-model'
2
1
  import type { FigureAsset, FigureFace } from '@hatiolab/figure-model'
3
2
 
3
+ import { boxesOf } from './align.js'
4
+ import { derivedOf, type FigureRevision } from './revision.js'
5
+
4
6
  /*
5
7
  * What the properties tab reads, kept apart from the element so it can be tested without a browser.
6
8
  */
@@ -29,16 +31,11 @@ export interface FigureFigureFacts {
29
31
  * records the face only as that node, so this is where the screen learns which one is chosen. Parts are the placed
30
32
  * bodies (`place@1`), in the order the model lists them.
31
33
  */
32
- let lastFacts: { source: string; facts: FigureFigureFacts } | undefined
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
34
+ export function figureFigureFacts(revision: FigureRevision): FigureFigureFacts {
35
+ return derivedOf(revision, 'figure-facts', () => figureFactsOf(revision))
39
36
  }
40
- function figureFactsOf(source: string): FigureFigureFacts {
41
- const asset = parseFigureAsset(source) as FigureAsset & { document: { model: { nodes: Array<{ id: string; op: string }> } } }
37
+ function figureFactsOf(revision: FigureRevision): FigureFigureFacts {
38
+ const asset = revision.asset as FigureAsset & { document: { model: { nodes: Array<{ id: string; op: string }> } } }
42
39
  const parts = asset.document.model.nodes.filter(node => node.op === 'place@1').map(node => node.id)
43
40
  const feature = (asset.occupancy?.contact as { surface?: { feature?: string } } | undefined)?.surface?.feature
44
41
  const found = feature?.match(/^(.+)\.mount\.(left|right|bottom|top|back|front)\.value$/)
@@ -48,16 +45,17 @@ function figureFactsOf(source: string): FigureFigureFacts {
48
45
  declared: !!asset.occupancy,
49
46
  placement: asset.placement as Placement | undefined,
50
47
  mounting: found ? { part: found[1], face: found[2] as FigureFace } : undefined,
51
- suggested: suggestedMountOf(asset, parts)
48
+ suggested: suggestedMountOf(revision, parts)
52
49
  }
53
50
  }
54
51
 
55
- function suggestedMountOf(asset: FigureAsset, parts: string[]): { part: string; face: FigureFace } | undefined {
56
- const placement = (asset.placement ?? 'floor') as Placement
52
+ /* As the parts are drawn now. */
53
+ function suggestedMountOf(revision: FigureRevision, parts: string[]): { part: string; face: FigureFace } | undefined {
54
+ const placement = (revision.asset.placement ?? 'floor') as Placement
57
55
  if (placement === 'center' || !parts.length) return undefined
58
56
  let boxes
59
57
  try {
60
- boxes = figureOccupiedBoxesOf(compileFigureAsset(asset).evaluate().geometry, figureWritersOf(asset)).filter(b => parts.includes(b.part))
58
+ boxes = boxesOf(revision).filter(b => parts.includes(b.part))
61
59
  } catch {
62
60
  return undefined
63
61
  }
@@ -55,6 +55,8 @@ import { SidePanelStyles } from './side-panel-styles.js'
55
55
  import './figure-fasten-drawing.js'
56
56
  import type { FastenChange } from './figure-fasten-drawing.js'
57
57
  import { faceViewOf, modeOf, myFaceFor, turnsInPlace, type FastenMode } from './fasten-geometry.js'
58
+ import { boxOf } from './align.js'
59
+ import type { FigureRevision } from './revision.js'
58
60
  import { HelpToggles } from './help.js'
59
61
  import { jointAxisWord } from './joint-axis.js'
60
62
 
@@ -149,12 +151,13 @@ export class FigureLinks extends localize(i18next)(LitElement) {
149
151
  }
150
152
  `]
151
153
 
152
- /** The asset being edited. */
153
- @property({ attribute: false }) asset?: FigureAsset
154
+ /** The figure at the version on screen. */
155
+ @property({ attribute: false }) revision?: FigureRevision
156
+ private get asset(): FigureAsset | undefined {
157
+ return this.revision?.asset
158
+ }
154
159
  /** The selected part. */
155
160
  @property() partId = ''
156
- /** The part's own extent along its axes now, in mm — what a dimension is fixed at when it stops following. */
157
- @property({ attribute: false }) dims?: [number, number, number]
158
161
  @property({ type: Boolean }) locked = false
159
162
  /** Which ties this shows: the part's shape (size and placement, in 속성), its places (자리·능력) or its motion (움직임). */
160
163
  @property() section: 'shape' | 'places' | 'motion' = 'shape'
@@ -201,7 +204,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
201
204
  if (this.asset && this.partId && this.partId !== this.loadedFor) {
202
205
  this.loadedFor = this.partId
203
206
  /* A part's own fastening is not another part's choices: the form opens on it (walk 1, 2026-09-26). */
204
- const own = this.asset && this.partId ? fasteningOf(this.asset, this.partId) : null
207
+ const own = this.revision && this.partId ? fasteningOf(this.revision, this.partId) : null
205
208
  this.fasten = own ? ({ ...freshFasten(), ...own } as ReturnType<typeof freshFasten>) : freshFasten()
206
209
  this.motion = freshMotion()
207
210
  this.place = freshPlace()
@@ -339,7 +342,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
339
342
  const now = followOf(asset, this.partId, axis)
340
343
  const following = now.of === 'instance' && now.axis === axis
341
344
  const custom = now.of !== 'own' && !following
342
- const mm = measureNow(asset, this.partId, axis)
345
+ const mm = measureNow(this.revision!, this.partId, axis)
343
346
  return html`<div row>
344
347
  <b>${word(axis)}</b>
345
348
  <span measure>${mm === undefined ? '—' : `${Math.round(mm)} mm`}</span>
@@ -422,7 +425,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
422
425
  }
423
426
 
424
427
  private async openFastening(asset: FigureAsset) {
425
- const own = fasteningOf(asset, this.partId)
428
+ const own = fasteningOf(this.revision!, this.partId)
426
429
  this.fasten = own ? ({ ...freshFasten(), ...own } as ReturnType<typeof freshFasten>) : freshFasten()
427
430
  this.spotlit = true
428
431
  await this.updateComplete
@@ -477,7 +480,8 @@ export class FigureLinks extends localize(i18next)(LitElement) {
477
480
  private follow(asset: FigureAsset, axis: Axis, choice: string, now: Follow) {
478
481
  const following = now.of !== 'own'
479
482
  /* The measure as it stands: a dimension stops following at it, and a share is taken of it. */
480
- const current = dimensionNow(asset, this.partId, axis) ?? this.dims?.[AXES.indexOf(axis)] ?? 100
483
+ /* The part's own extent along the axis now, as drawn: what a dimension is fixed at when it stops following. */
484
+ const current = dimensionNow(this.revision!, this.partId, axis) ?? boxOf(this.revision!, this.partId)?.dims[AXES.indexOf(axis)] ?? 100
481
485
  if (choice === 'own') {
482
486
  if (!following) return
483
487
  this.command({ kind: 'unlink-dimension', part: this.partId, axis, mm: Math.max(1, Math.round(current)) })
@@ -524,13 +528,13 @@ export class FigureLinks extends localize(i18next)(LitElement) {
524
528
  const theirFaces = f.target ? facesOf(asset, f.target) : FACES
525
529
  const targetFace = theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0]!
526
530
  const aim = f.toPlane ? ('plane' as const) : f.target ? { part: f.target, face: targetFace } : undefined
527
- this.fasten = { ...f, ...(aim ? placeKeptOf(asset, this.partId, aim, !!f.twist) : { align: {}, alignMm: {}, alignInput: {} }) }
531
+ this.fasten = { ...f, ...(aim ? placeKeptOf(this.revision!, this.partId, aim, !!f.twist) : { align: {}, alignMm: {}, alignInput: {} }) }
528
532
  }
529
533
 
530
534
  private renderFastening(asset: FigureAsset) {
531
535
  const held = heldBy(asset, this.partId)
532
536
  /* Placed by an origin (ADR-0107 decision 4): attach writes faces, so the form would move it off its origin. Said, and taken off only. */
533
- if (fasteningOf(asset, this.partId)?.atOrigin) {
537
+ if (fasteningOf(this.revision!, this.partId)?.atOrigin) {
534
538
  this.fastenCandidate = undefined
535
539
  return html`<div fastened fasten-at-origin>
536
540
  <p>${this.heldWords(asset)}</p>
@@ -559,7 +563,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
559
563
  const aim = f.toPlane ? ('plane' as const) : { part: f.target, face: targetFace }
560
564
  /* The part's own face is not chosen: the target's face and how give it (user 2026-10-01; the kernel takes no other). */
561
565
  const mode: FastenMode = f.toPlane ? 'meet' : modeOf(f.face, f.facing)
562
- const derived = f.toPlane || f.target ? myFaceFor(asset, this.partId, aim, mode) : undefined
566
+ const derived = f.toPlane || f.target ? myFaceFor(this.revision!, this.partId, aim, mode) : undefined
563
567
  const myFace = derived ?? (mineFaces.includes(f.face) ? f.face : mineFaces[0]!)
564
568
  const align = f.target || f.toPlane ? alignOf(asset, this.partId, aim, f.align, f.alignMm, !!f.twist, f.alignInput) : undefined
565
569
  const action =
@@ -587,7 +591,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
587
591
  ...(f.twist ? [i18next.t('figure.text.fasten-summary-twist', { degrees: String(f.twist) })] : [])
588
592
  ]
589
593
  /* Across the face, a row only where the drawing cannot set it: no drawing of the face, or a share or a field to follow. */
590
- const view = f.toPlane || f.target ? faceViewOf(asset, this.partId, aim) : undefined
594
+ const view = f.toPlane || f.target ? faceViewOf(this.revision!, this.partId, aim) : undefined
591
595
  const alignRows = (f.toPlane || f.target ? alignAxesOf(asset, this.partId, aim, !!f.twist) : []).filter(
592
596
  ({ axis }) => !view || !view.axes.includes(axis) || !!f.alignInput?.[axis] || hasInstanceSize(asset, axis)
593
597
  )
@@ -8,6 +8,7 @@ import { create, registerFigure, unregisterFigure } from '@hatiolab/things-scene
8
8
 
9
9
  import { DEFAULT_VIEW, previewBoardDepth, sceneLook, WORKBENCH, type ViewSettings } from './figure-view.js'
10
10
  import { dialShape } from './angle-dial.js'
11
+ import { derivedOf, type FigureRevision } from './revision.js'
11
12
  import { authoredSizeOf, PREVIEW_FACTOR, PREVIEW_WAYS, previewClampsOf, previewInstances, setupPartOf } from './preview-model.js'
12
13
 
13
14
  /**
@@ -170,7 +171,15 @@ export class FigurePreview extends localize(i18next)(LitElement) {
170
171
  `
171
172
 
172
173
  /** The figure being authored, as the editor holds it now. */
173
- @property({ attribute: false }) asset?: FigureAsset
174
+ @property({ attribute: false }) revision?: FigureRevision
175
+ private get asset(): FigureAsset | undefined {
176
+ return this.revision?.asset
177
+ }
178
+ /** The size the figure was made at, once per version: without a declared size it is measured by evaluating the figure. */
179
+ private authoredSize(): { x: number; y: number; z: number } {
180
+ const shown = this.revision!
181
+ return derivedOf(shown, 'authored-size', () => authoredSizeOf(shown.asset))
182
+ }
174
183
  /** Which figure: the type it is registered under here is this one's own, never a real type a board uses. */
175
184
  @property() context = ''
176
185
  /**
@@ -209,7 +218,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
209
218
  const clips = asset ? [...new Set((asset.drivers ?? []).map(d => d.clip))] : []
210
219
  let clampsByWay: ReturnType<typeof previewClampsOf> = {}
211
220
  try {
212
- clampsByWay = asset ? previewClampsOf(asset, authoredSizeOf(asset)) : {}
221
+ clampsByWay = asset ? previewClampsOf(asset, this.authoredSize()) : {}
213
222
  } catch {
214
223
  clampsByWay = {}
215
224
  }
@@ -322,7 +331,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
322
331
  this.standUp()
323
332
  return
324
333
  }
325
- if (changed.has('asset') || changed.has('context')) {
334
+ if (changed.has('revision') || changed.has('context')) {
326
335
  this.standUp()
327
336
  return
328
337
  }
@@ -368,7 +377,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
368
377
  try {
369
378
  /* Registered as a board registers it, under a type of this preview's own: a real type's drawings stay as they are. */
370
379
  const type = `figure-preview-${this.context || 'new'}`
371
- const size = authoredSizeOf(asset)
380
+ const size = this.authoredSize()
372
381
  /*
373
382
  The figure changed under a scene that is standing: the same type is registered again and the content made again
374
383
  inside the same 3D scene. Making a scene anew costs about a third of a second and its first drawing compiles the
@@ -10,6 +10,7 @@ import type { FigureAuthoringAction, FigureFace } from '@hatiolab/figure-model'
10
10
 
11
11
  import { occupancyFromParts } from './occupancy.js'
12
12
  import { tagsOf, figureFigureFacts, type Placement, type FigureFigureFacts } from './figure-facts.js'
13
+ import type { FigureRevision } from './revision.js'
13
14
  import { SidePanelStyles } from './side-panel-styles.js'
14
15
  import { ScrollbarStyles } from '@operato/styles'
15
16
 
@@ -58,8 +59,8 @@ export class FigureProperties extends localize(i18next)(LitElement) {
58
59
  }
59
60
  `]
60
61
 
61
- /** The source being edited. */
62
- @property({ type: String }) source?: string
62
+ /** The figure at the version on screen. */
63
+ @property({ attribute: false }) revision?: FigureRevision
63
64
  /** The saved row, if any. Category and tags live on it. */
64
65
  @property({ type: String }) figureId?: string
65
66
  @property({ type: String }) figureCategory?: string
@@ -70,7 +71,7 @@ export class FigureProperties extends localize(i18next)(LitElement) {
70
71
 
71
72
  private facts(): FigureFigureFacts | undefined {
72
73
  try {
73
- return this.source ? figureFigureFacts(this.source) : undefined
74
+ return this.revision ? figureFigureFacts(this.revision) : undefined
74
75
  } catch {
75
76
  /* A source the format refuses has no facts to show; the verdict tab says why. */
76
77
  return undefined
@@ -252,8 +253,8 @@ export class FigureProperties extends localize(i18next)(LitElement) {
252
253
  }
253
254
 
254
255
  private declare(placement: Placement) {
255
- if (!this.source) return
256
- this.command(occupancyFromParts(this.source, placement))
256
+ if (!this.revision) return
257
+ this.command(occupancyFromParts(this.revision.asset, placement))
257
258
  }
258
259
 
259
260
  private command(action: FigureAuthoringAction) {
@@ -5,12 +5,13 @@ import { customElement, property, state } from 'lit/decorators.js'
5
5
 
6
6
  import { i18next, localize } from '@operato/i18n'
7
7
  import { ScrollbarStyles } from '@operato/styles'
8
- import { LIMITS, parseFigureAsset, FIGURE_PER_INSTANCE, figureCostOf } from '@hatiolab/figure-model'
8
+ import { LIMITS, FIGURE_PER_INSTANCE, figureCostOf } from '@hatiolab/figure-model'
9
9
 
10
10
  import { inspectFigureDraft } from '../graphql/index.js'
11
11
  import type { FigureInspection } from '../types.js'
12
12
  import { gateFindingStyles, renderGateFinding } from './gate-findings.js'
13
13
  import { MEASURED } from './scene-budget.js'
14
+ import type { FigureRevision } from './revision.js'
14
15
  import { SidePanelStyles } from './side-panel-styles.js'
15
16
  import { budgetOf, CURVE_SIZE, curveLayout, GRADE_SCALE, shortNumber, figureReportOf } from './maturity.js'
16
17
  import type { FigureBudgetUse, FigureMaturityAxis, FigureReport } from './maturity.js'
@@ -378,8 +379,10 @@ export class FigureVerdict extends localize(i18next)(LitElement) {
378
379
  `
379
380
  ]
380
381
 
381
- /** The source being edited, serialised. Every change of it is a new question. */
382
- @property({ type: String }) source?: string
382
+ /** The figure at the version on screen. A version whose text is another is a new question. */
383
+ @property({ attribute: false }) revision?: FigureRevision
384
+ /* The text last judged: a save hands out the same version again as a new object, which asks nothing new. */
385
+ private judged?: string
383
386
  /** The saved row, if any — the server reads its type and last release from it. */
384
387
  @property({ type: String }) figureId?: string
385
388
  /** Changing the type changes the verdict (a temporary type is refused), so it is a reason to ask again. */
@@ -408,19 +411,22 @@ export class FigureVerdict extends localize(i18next)(LitElement) {
408
411
  /** The report is measured once the figure has settled: it is read beside the work, and each measure compiles the figure several times. */
409
412
  private measureTimer?: ReturnType<typeof setTimeout>
410
413
  updated(changes: Map<string, unknown>) {
411
- if (changes.has('source')) {
414
+ const text = changes.has('revision') ? this.revision?.text : this.judged
415
+ const changed = text !== this.judged
416
+ if (changed) {
417
+ this.judged = text
412
418
  clearTimeout(this.measureTimer)
413
419
  if (this.report === undefined && !this.measureTimer) this.measure()
414
420
  else this.measureTimer = setTimeout(() => this.measure(), MEASURE_SETTLE_MS)
415
421
  }
416
- if (changes.has('source') || changes.has('figureId') || changes.has('figureType')) this.schedule()
422
+ if (changed || changes.has('figureId') || changes.has('figureType')) this.schedule()
417
423
  }
418
424
 
419
425
  private measure() {
420
426
  let report: FigureReport | undefined
421
427
  let bodies = 0
422
428
  try {
423
- const asset = this.source ? parseFigureAsset(this.source) : undefined
429
+ const asset = this.revision?.asset
424
430
  const cost = asset ? figureCostOf(asset) : undefined
425
431
  bodies = cost ? cost.groups + cost.hidden : 0
426
432
  report = asset ? figureReportOf(asset) : undefined
@@ -436,7 +442,7 @@ export class FigureVerdict extends localize(i18next)(LitElement) {
436
442
 
437
443
  private schedule() {
438
444
  clearTimeout(this.timer)
439
- if (!this.source) {
445
+ if (!this.revision) {
440
446
  this.inspection = undefined
441
447
  return
442
448
  }
@@ -445,7 +451,7 @@ export class FigureVerdict extends localize(i18next)(LitElement) {
445
451
 
446
452
  private async ask() {
447
453
  const question = ++this.asked
448
- const source = this.source!
454
+ const source = this.revision!.text
449
455
  this.asking = true
450
456
  try {
451
457
  const inspection = await inspectFigureDraft(source, this.figureId)
@@ -4,7 +4,7 @@ import type { FigureAsset } from '@hatiolab/figure-model'
4
4
  import type { FigureRenderPart } from './renderer.js'
5
5
  import { sectionShape } from './drawn-shapes.js'
6
6
  import { jointAxisUnit } from './joint-axis.js'
7
- import { evaluatedGeometryOf } from './evaluated.js'
7
+ import { derivedOf, graphValuesOf, type FigureRevision } from './revision.js'
8
8
 
9
9
  /*
10
10
  * Where the selected part turns, and how far -- a translucent fan on the editing board. An axis name says nothing about which way a part swings; on a six-axis arm the author went to the preview and
@@ -183,29 +183,32 @@ function turnOf(asset: FigureAsset, part: string, values: Record<string, unknown
183
183
  }
184
184
 
185
185
  /**
186
- * The fan for the part, as a body to stand on the board, or null when the part does not turn. `values` are the
187
- * inputs the figure is drawn with now (a pose in hand included), so the fan follows the arm as it is posed.
186
+ * The fan for the part, as a body to stand on the board, or null when the part does not turn. Drawn at the version as it
187
+ * is drawn; with a value in hand, `live` gives the values in hand and the bodies drawn with them, so the fan follows the
188
+ * arm as it is posed.
188
189
  */
189
- export function jointFanOf(asset: FigureAsset, part: string, values: Record<string, number> = {}): FigureRenderPart | null {
190
+ export function jointFanOf(revision: FigureRevision, part: string, live?: { values: Record<string, number>; geometry: readonly unknown[] }): FigureRenderPart | null {
190
191
  if (!part) return null
192
+ const asset = revision.asset
191
193
  let graph: ReturnType<typeof compileFigureGraph>
192
194
  try {
193
- graph = compileFigureGraph(asset.document.model as never)
195
+ graph = derivedOf(revision, 'graph', () => compileFigureGraph(asset.document.model as never))
194
196
  } catch {
195
197
  return null
196
198
  }
197
- const inputs = { ...asset.designInputs, ...asset.stateDefaults, ...values }
199
+ const inputs = { ...revision.inputs, ...live?.values }
198
200
  const at = (over: Record<string, number>) => graph.evaluate({ ...inputs, ...over }).values as Record<string, unknown>
201
+ const geometry = (live?.geometry ?? revision.geometry) as readonly { id: string }[]
199
202
  let now: Record<string, unknown>
200
203
  try {
201
- now = at({})
204
+ now = live ? at({}) : (graphValuesOf(revision) as Record<string, unknown>)
202
205
  } catch {
203
206
  return null
204
207
  }
205
208
  let box: WorldBox | undefined
206
209
  try {
207
210
  box = figureWorldBoxesOf(
208
- evaluatedGeometryOf(asset, values).filter(g => g.id === part),
211
+ geometry.filter(g => g.id === part) as never,
209
212
  figureWritersOf(asset)
210
213
  )[0]
211
214
  } catch {
@@ -225,7 +228,7 @@ export function jointFanOf(asset: FigureAsset, part: string, values: Record<stri
225
228
  Sized from what the joint turns, not from the selected part: selecting the elbow housing or the forearm
226
229
  shows the elbow's one fan, not a smaller one for the smaller part (user, 2026-09-24).
227
230
  */
228
- const radius = Math.max(60, Math.round(Math.max(largestMeasureOf(asset, turn.turnedPart, inputs), Math.hypot(...inPlane)) * 0.6))
231
+ const radius = Math.max(60, Math.round(Math.max(largestMeasureOf(geometry, turn.turnedPart), Math.hypot(...inPlane)) * 0.6))
229
232
  const edge = turn.pivot.map((v, i) => v + d[i]! * radius)
230
233
 
231
234
  const { min, max } = turn.control
@@ -265,15 +268,10 @@ export function jointFanOf(asset: FigureAsset, part: string, values: Record<stri
265
268
  } as unknown as FigureRenderPart
266
269
  }
267
270
 
268
- /** The values in hand that belong to a record's keys (design inputs, or state defaults). */
269
- function pick(values: Record<string, number>, of: Record<string, number>): Record<string, number> {
270
- return Object.fromEntries(Object.entries(values).filter(([id]) => id in of))
271
- }
272
-
273
- /** A part's largest own measure (a box side, a diameter, a length) at the inputs given; 0 when it cannot be read. */
274
- function largestMeasureOf(asset: FigureAsset, part: string, inputs: Record<string, number>): number {
271
+ /** A part's largest own measure (a box side, a diameter, a length) as drawn; 0 when it cannot be read. */
272
+ function largestMeasureOf(geometry: readonly { id: string }[], part: string): number {
275
273
  try {
276
- const body = evaluatedGeometryOf(asset, inputs).find(g => g.id === part) as { shape?: { dimensions?: Record<string, number> } } | undefined
274
+ const body = geometry.find(g => g.id === part) as { shape?: { dimensions?: Record<string, number> } } | undefined
277
275
  const dims = Object.entries(body?.shape?.dimensions ?? {}).map(([k, v]) => (k.startsWith('radius') ? v * 2 : v))
278
276
  return dims.length ? Math.max(...dims) : 0
279
277
  } catch {
@@ -1,8 +1,8 @@
1
- import { FIGURE_FACES, figureShapeFaces, figurePartsExtentOf, compileFigureAsset, compileFigureGraph, figureFollowOf, proposeFigureProportion, defaultInstanceSizeRange, foldInstanceSizeRange, figureAttachmentsOf, figureBindingsOf, figureInstanceSizeLimitsOf, figureFollowableAxesOf, figureShapeProviderOf, figureMimicTermsOf } from '@hatiolab/figure-model'
1
+ import { FIGURE_FACES, figureShapeFaces, figurePartsExtentOf, compileFigureAsset, figureFollowOf, proposeFigureProportion, defaultInstanceSizeRange, foldInstanceSizeRange, figureAttachmentsOf, figureBindingsOf, figureInstanceSizeLimitsOf, figureFollowableAxesOf, figureShapeProviderOf, figureMimicTermsOf } from '@hatiolab/figure-model'
2
2
  import type { FigureFollow, FigureSizeRange } from '@hatiolab/figure-model'
3
3
  import type { FigureAsset, FigureBindingPlace, FigureFace, FigureJoint } from '@hatiolab/figure-model'
4
4
  import { startingShapeKindOf } from './starting-shape.js'
5
- import { evaluatedGeometryOf } from './evaluated.js'
5
+ import { graphValuesOf, type FigureRevision } from './revision.js'
6
6
  import { jointAxisToken, jointAxisVectorOf, type JointAxisVector } from './joint-axis.js'
7
7
 
8
8
  /*
@@ -163,16 +163,15 @@ export function fieldWordsOf(id: string, say: (key: string) => string): string {
163
163
  }
164
164
 
165
165
  /** What a part's own measure on an axis is now (a box's side, a cylinder's radius or length), at the design as it stands. */
166
- export function dimensionNow(asset: FigureAsset, part: string, axis: Axis): number | undefined {
167
- const m = modelOf(asset)
166
+ export function dimensionNow(revision: FigureRevision, part: string, axis: Axis): number | undefined {
167
+ const m = modelOf(revision.asset)
168
168
  /* The argument the shape names for this axis (figure-model's provider), not its place among the arguments. */
169
169
  const shape = m.nodes.find(n => n.id === `${part}.shape`)
170
170
  const edit = shape && figureShapeProviderOf(shape as never)?.edit[axis]
171
171
  const ref = edit && !('refused' in edit) ? shape!.args[edit.arg] : undefined
172
172
  if (!ref) return undefined
173
173
  try {
174
- const values = compileFigureGraph(asset.document.model as never).evaluate({ ...asset.designInputs, ...asset.stateDefaults }).values as Record<string, number>
175
- const value = values[ref]
174
+ const value = graphValuesOf(revision)[ref]
176
175
  return typeof value === 'number' && Number.isFinite(value) ? value : undefined
177
176
  } catch {
178
177
  return undefined
@@ -183,13 +182,12 @@ export function dimensionNow(asset: FigureAsset, part: string, axis: Axis): numb
183
182
  * How long a part is along one axis now, as it is drawn: the shape's own measure through figure-model's provider (a
184
183
  * cylinder's width is twice its radius), read after any rule it follows -- so a linked measure still says its size.
185
184
  */
186
- export function measureNow(asset: FigureAsset, part: string, axis: Axis): number | undefined {
187
- const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
185
+ export function measureNow(revision: FigureRevision, part: string, axis: Axis): number | undefined {
186
+ const shape = modelOf(revision.asset).nodes.find(n => n.id === `${part}.shape`)
188
187
  const edit = shape && figureShapeProviderOf(shape as never)?.edit[axis]
189
188
  if (!shape || !edit || 'refused' in edit) return undefined
190
189
  try {
191
- const values = compileFigureGraph(asset.document.model as never).evaluate({ ...asset.designInputs, ...asset.stateDefaults }).values as Record<string, number>
192
- const value = values[shape.args[edit.arg]!]
190
+ const value = graphValuesOf(revision)[shape.args[edit.arg]!]
193
191
  return typeof value === 'number' && Number.isFinite(value) ? value * edit.k : undefined
194
192
  } catch {
195
193
  return undefined
@@ -329,7 +327,7 @@ export function hangsFrom(asset: FigureAsset, part: string, from: string): boole
329
327
  * from there broke its turning in place (walk 1, 2026-09-26). Null for a part fastened to nothing.
330
328
  */
331
329
  export function fasteningOf(
332
- asset: FigureAsset,
330
+ revision: FigureRevision,
333
331
  part: string
334
332
  ): {
335
333
  face: string
@@ -344,7 +342,7 @@ export function fasteningOf(
344
342
  /** Placed by a part's origin (ADR-0107 decision 4): no face to fasten by, and the form does not write it. */
345
343
  atOrigin?: true
346
344
  } | null {
347
- const record = asset.joints?.find(j => j.body1.part === part)
345
+ const record = revision.asset.joints?.find(j => j.body1.part === part)
348
346
  if (!record) return null
349
347
  const toPlane = 'plane' in record.body0
350
348
  const target = 'part' in record.body0 ? record.body0.part : ''
@@ -352,7 +350,7 @@ export function fasteningOf(
352
350
  const atOrigin = !('plane' in record.body0) && (!('face' in record.body0) || !('face' in record.body1))
353
351
  const twist = record.origin?.twist ?? 0
354
352
  /* Where across the face: as the record says it, and where it says nothing, where the part stands now. */
355
- const kept = placeKeptOf(asset, part, toPlane ? 'plane' : { part: target, face: targetFace }, !!twist)
353
+ const kept = placeKeptOf(revision, part, toPlane ? 'plane' : { part: target, face: targetFace }, !!twist)
356
354
  for (const [axis, how] of Object.entries(record.origin?.align ?? {}) as Array<[Axis, unknown]>) {
357
355
  if (how === 'min' || how === 'max' || how === 'centre') {
358
356
  kept.align[axis] = how
@@ -387,18 +385,15 @@ export function fasteningOf(
387
385
  * its frame slid into it (2026-09-26). Left centred where it already is, and where the target stands turned.
388
386
  */
389
387
  export function placeKeptOf(
390
- asset: FigureAsset,
388
+ revision: FigureRevision,
391
389
  part: string,
392
390
  target: { part: string; face: string } | 'plane',
393
391
  twisted = false
394
392
  ): { align: Partial<Record<Axis, Alignment>>; alignMm: Partial<Record<Axis, number>>; alignInput: Partial<Record<Axis, string>> } {
395
393
  const kept = { align: {} as Partial<Record<Axis, Alignment>>, alignMm: {} as Partial<Record<Axis, number>>, alignInput: {} as Partial<Record<Axis, string>> }
396
- let geometry
397
- try {
398
- geometry = evaluatedGeometryOf(asset) as unknown as Array<{ id: string; pose?: { r: number[][]; t: number[] } }>
399
- } catch {
400
- return kept
401
- }
394
+ const asset = revision.asset
395
+ /* Where it stands as drawn: the pose the command that fastens it sees. */
396
+ const geometry = revision.geometry as unknown as Array<{ id: string; pose?: { r: number[][]; t: number[] } }>
402
397
  const poseOf = (id: string) => geometry.find(g => g.id === id)?.pose
403
398
  const mine = poseOf(part)
404
399
  if (!mine) return kept
@@ -730,11 +725,10 @@ export function proportionToFollow(asset: FigureAsset): { count: number; kept: s
730
725
  }
731
726
 
732
727
  /** A value of the figure as it stands, by its graph reference. */
733
- export function valueNow(asset: FigureAsset, ref: string | undefined): number | undefined {
728
+ export function valueNow(revision: FigureRevision, ref: string | undefined): number | undefined {
734
729
  if (!ref) return undefined
735
730
  try {
736
- const values = compileFigureGraph(asset.document.model as never).evaluate({ ...asset.designInputs, ...asset.stateDefaults }).values as Record<string, number>
737
- const value = values[ref]
731
+ const value = graphValuesOf(revision)[ref]
738
732
  return typeof value === 'number' && Number.isFinite(value) ? value : undefined
739
733
  } catch {
740
734
  return undefined
@@ -2,6 +2,7 @@ import { applyFigureAuthoring, compileFigureAsset, editFigurePart } from '@hatio
2
2
  import type { FigureAsset, FigureAuthoringAction, FigurePartEdit } from '@hatiolab/figure-model'
3
3
 
4
4
  import { drawableFigureParts, type FigureRenderPart } from './renderer.js'
5
+ import type { FigureRevision } from './revision.js'
5
6
 
6
7
  /*
7
8
  * The figure while a value is in hand: evaluated with the values being typed or dragged, drawn at once and written
@@ -62,6 +63,8 @@ export function livePartsOf(asset: FigureAsset, live: LiveValues): FigureRenderP
62
63
  * point's coordinate before it was moved, a corner's radius before it was typed again) and would put it back.
63
64
  */
64
65
  for (const id of Object.keys(design)) if (next.designInputs[id] !== asset.designInputs[id]) delete design[id]
66
+ /* A value the figure already holds changes nothing: a pose in hand alone is drawn from the figure as it is, not a copy. */
67
+ for (const id of Object.keys(design)) if (design[id] === next.designInputs[id]) delete design[id]
65
68
  if (Object.keys(design).length) next = { ...next, designInputs: { ...next.designInputs, ...design } }
66
69
  return drawableFigureParts(compileFigureAsset(next).evaluate(state).geometry)
67
70
  } catch {
@@ -88,12 +91,12 @@ export function scrubbed(start: number, pixels: number, step: number): number {
88
91
  }
89
92
 
90
93
  /**
91
- * The bodies to draw while a value is in hand, over every input as the session holds it now (`current`): the pose the
94
+ * The bodies to draw while a value is in hand, over every input of the version as the session holds it now: the pose the
92
95
  * author has already set with the other controls, and the design values. Drawing over the asset's saved defaults put
93
96
  * every other joint back to its saved angle while one slider moved (user, 2026-09-24: the arm jumped on release).
94
97
  */
95
- export function liveOverSession(asset: FigureAsset, current: Record<string, number>, live: LiveValues): FigureRenderPart[] | null {
98
+ export function liveOverSession(revision: FigureRevision, live: LiveValues): FigureRenderPart[] | null {
96
99
  if (!isLive(live)) return null
97
- return livePartsOf(asset, { ...live, values: { ...current, ...live.values } })
100
+ return livePartsOf(revision.asset, { ...live, values: { ...revision.inputs, ...live.values } })
98
101
  }
99
102