@things-factory/figure-ui 10.1.204 → 10.1.205

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 (86) hide show
  1. package/client/modeller/canvas.ts +91 -9
  2. package/client/modeller/editor-styles.ts +7 -22
  3. package/client/modeller/editor.ts +176 -145
  4. package/client/modeller/fasten-form.ts +85 -6
  5. package/client/modeller/figure-fasten-dialog.ts +42 -9
  6. package/client/modeller/figure-fasten-drawing.ts +233 -12
  7. package/client/modeller/figure-section-editor.ts +6 -6
  8. package/client/modeller/figure-slot-layout-panel.ts +1 -1
  9. package/client/modeller/gizmo.ts +12 -1
  10. package/client/modeller/links.ts +48 -6
  11. package/client/modeller/section-drawing.ts +7 -7
  12. package/client/modeller/size-drag.ts +112 -0
  13. package/client/modeller/slot-layout-host.ts +3 -3
  14. package/client/modeller/stage-model.ts +6 -9
  15. package/client/pages/figure-modeller-page.ts +1 -4
  16. package/dist-client/modeller/canvas.d.ts +24 -3
  17. package/dist-client/modeller/canvas.js +96 -11
  18. package/dist-client/modeller/canvas.js.map +1 -1
  19. package/dist-client/modeller/editor-styles.js +7 -22
  20. package/dist-client/modeller/editor-styles.js.map +1 -1
  21. package/dist-client/modeller/editor.d.ts +28 -32
  22. package/dist-client/modeller/editor.js +176 -153
  23. package/dist-client/modeller/editor.js.map +1 -1
  24. package/dist-client/modeller/fasten-form.d.ts +36 -3
  25. package/dist-client/modeller/fasten-form.js +80 -5
  26. package/dist-client/modeller/fasten-form.js.map +1 -1
  27. package/dist-client/modeller/figure-fasten-dialog.d.ts +17 -3
  28. package/dist-client/modeller/figure-fasten-dialog.js +47 -10
  29. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  30. package/dist-client/modeller/figure-fasten-drawing.d.ts +33 -1
  31. package/dist-client/modeller/figure-fasten-drawing.js +235 -11
  32. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  33. package/dist-client/modeller/figure-section-editor.d.ts +3 -3
  34. package/dist-client/modeller/figure-section-editor.js +4 -4
  35. package/dist-client/modeller/figure-section-editor.js.map +1 -1
  36. package/dist-client/modeller/figure-slot-layout-panel.js +1 -1
  37. package/dist-client/modeller/figure-slot-layout-panel.js.map +1 -1
  38. package/dist-client/modeller/gizmo.d.ts +12 -1
  39. package/dist-client/modeller/gizmo.js +8 -0
  40. package/dist-client/modeller/gizmo.js.map +1 -1
  41. package/dist-client/modeller/links.d.ts +14 -2
  42. package/dist-client/modeller/links.js +42 -4
  43. package/dist-client/modeller/links.js.map +1 -1
  44. package/dist-client/modeller/section-drawing.d.ts +3 -4
  45. package/dist-client/modeller/section-drawing.js +10 -9
  46. package/dist-client/modeller/section-drawing.js.map +1 -1
  47. package/dist-client/modeller/size-drag.d.ts +38 -0
  48. package/dist-client/modeller/size-drag.js +85 -0
  49. package/dist-client/modeller/size-drag.js.map +1 -0
  50. package/dist-client/modeller/slot-layout-host.d.ts +1 -3
  51. package/dist-client/modeller/slot-layout-host.js +3 -3
  52. package/dist-client/modeller/slot-layout-host.js.map +1 -1
  53. package/dist-client/modeller/stage-model.d.ts +5 -7
  54. package/dist-client/modeller/stage-model.js +6 -6
  55. package/dist-client/modeller/stage-model.js.map +1 -1
  56. package/dist-client/pages/figure-modeller-page.d.ts +0 -2
  57. package/dist-client/pages/figure-modeller-page.js +1 -6
  58. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  59. package/dist-client/tsconfig.tsbuildinfo +1 -1
  60. package/dist-server/tsconfig.tsbuildinfo +1 -1
  61. package/package.json +7 -7
  62. package/test/align.test.ts +7 -11
  63. package/test/browser/acceptance-page.js +10 -0
  64. package/test/browser/editing.check.mjs +17 -13
  65. package/test/browser/links-page.js +2 -2
  66. package/test/browser/links.check.mjs +94 -56
  67. package/test/browser/modeller-page.js +30 -18
  68. package/test/browser/modeller-revision.check.mjs +39 -0
  69. package/test/browser/modeller.check.mjs +32 -10
  70. package/test/browser/screen-page.js +0 -2
  71. package/test/browser/screen.check.mjs +11 -13
  72. package/test/browser/section.check.mjs +2 -2
  73. package/test/fasten-form.test.ts +62 -1
  74. package/test/gizmo.test.ts +13 -15
  75. package/test/links.test.ts +8 -3
  76. package/test/resize-with-placement.test.ts +14 -13
  77. package/test/revision.test.ts +3 -2
  78. package/test/section-editor.test.ts +9 -10
  79. package/test/size-drag.test.ts +114 -0
  80. package/test/slot-layout-host.test.ts +13 -8
  81. package/translations/en.json +25 -22
  82. package/translations/es.json +25 -22
  83. package/translations/ja.json +25 -22
  84. package/translations/ko.json +25 -22
  85. package/translations/ms.json +25 -22
  86. package/translations/zh.json +25 -22
@@ -267,8 +267,8 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
267
267
  @property({ attribute: false }) body?: { id: string; shape: FigureEvaluatedShape }
268
268
  /** Which of its measures can be set (figure-model's `figureSectionMeasuresOf`). */
269
269
  @property({ attribute: false }) measures?: { measures: Record<Axis, FigureSectionMeasure>; round: boolean }
270
- /** Along the push, the face a length change keeps: 1 its start, -1 its end, 0 its centre; undefined when it cannot be said. */
271
- @property({ attribute: false }) kept?: -1 | 0 | 1
270
+ /** What a length typed keeps: the part's middle, or its fastening (ADR-0140 4.1); undefined when it keeps no point. */
271
+ @property({ attribute: false }) kept?: 'centre' | 'relation' | 'none'
272
272
  /** Why a push's end cannot be drawn to a point now, in the kernel's words (what is fastened to it); empty when it can. */
273
273
  @property({ attribute: false }) pointBlocked = ''
274
274
  /** The figure's revision now; the editor remembers the one it opened at. */
@@ -490,10 +490,10 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
490
490
  return { h: Math.round(p.x), v: Math.round(p.y) }
491
491
  }
492
492
 
493
- /** What a measure's field says under it: following another value, or for the length the face it keeps. */
494
- private noteWords(axis: Axis, kept?: -1 | 0 | 1): string {
495
- const note = measureNoteOf(this.measures?.measures[axis], kept, axis)
496
- return note ? i18next.t(note.key, note.face ? { face: i18next.t('figure.label.face-' + note.face) } : {}) : ''
493
+ /** What a measure's field says under it: following another value, or for the length what it keeps. */
494
+ private noteWords(axis: Axis, kept?: 'centre' | 'relation' | 'none'): string {
495
+ const note = measureNoteOf(this.measures?.measures[axis], kept)
496
+ return note ? i18next.t(note.key) : ''
497
497
  }
498
498
 
499
499
  render() {
@@ -169,7 +169,7 @@ export class FigureSlotLayoutPanel extends localize(i18next)(LitElement) {
169
169
  const anchorFaces = anchorFacesOf(form.anchors, geometry)
170
170
  const value = {
171
171
  canUseDefault: false,
172
- presets: slotPresetsOf(form.anchors, key => i18next.t(key)),
172
+ presets: slotPresetsOf(form.anchors),
173
173
  /* The first anchor's face is the size a preset is laid out over; the slot height is the author's. */
174
174
  presetSize: anchorFaces[form.anchors[0]?.id ?? ''] ?? {},
175
175
  anchors: form.anchors.map(a => ({ id: a.id, label: `${a.id} · ${a.part} ${i18next.t('figure.label.face-' + a.face)}` })),
@@ -1,5 +1,5 @@
1
1
  import { figureAttachmentsOf, figureShapeProviderOf } from '@hatiolab/figure-model'
2
- import type { FigureAsset, FigureInputChange } from '@hatiolab/figure-model'
2
+ import type { FigureAsset, FigureInputChange, FigureSizeKeep } from '@hatiolab/figure-model'
3
3
 
4
4
  /**
5
5
  * What the gizmo may write for a part: move · turn · size, on a asset.
@@ -115,6 +115,17 @@ export interface GizmoResult {
115
115
  position?: [number, number, number]
116
116
  rotation?: [number, number, number]
117
117
  scale?: [number, number, number]
118
+ /** The handle a size drag held, per own axis -1 · 0 · 1 (the scene's `scaleGrab`): what figure-model keeps from. */
119
+ grab?: { x: number; y: number; z: number }
120
+ }
121
+
122
+ /**
123
+ * What a size drag keeps (ADR-0140 4.1): the side opposite a handle on one axis; the middle for the uniform handle, or
124
+ * when no handle is known -- figure-model's own words for it, so the drag and its commit ask the same.
125
+ */
126
+ export function sizeKeepOf(grab: GizmoResult['grab']): FigureSizeKeep {
127
+ const held = (['x', 'y', 'z'] as const).filter(axis => grab?.[axis])
128
+ return held.length === 1 ? { keep: 'opposite', axis: held[0]!, side: grab![held[0]!]! > 0 ? 1 : -1 } : { keep: 'centre' }
118
129
  }
119
130
 
120
131
  /**
@@ -1,6 +1,6 @@
1
- import { FIGURE_FACES, figureShapeFaces, figurePartsExtentOf, compileFigureAsset, figureFollowOf, proposeFigureProportion, defaultInstanceSizeRange, foldInstanceSizeRange, figureAttachmentsOf, figureBindingsOf, figureInstanceSizeLimitsOf, figureFollowableAxesOf, figureShapeProviderOf, figureMimicTermsOf, figureFastenKeptPlaceOf, figureFastenAxesOf, figureJointAsFixed, figureJointWithMotion } from '@hatiolab/figure-model'
1
+ import { FIGURE_FACES, figureShapeFaces, figurePartsExtentOf, compileFigureAsset, figureFollowOf, proposeFigureProportion, defaultInstanceSizeRange, foldInstanceSizeRange, figureAttachmentsOf, figureBindingsOf, figureInstanceSizeLimitsOf, figureFollowableAxesOf, figureShapeProviderOf, figureMimicTermsOf, figureFastenKeptPlaceOf, figureFastenAxesOf, figureJointAsFixed, figureJointWithMotion, figureSurfaceOf } from '@hatiolab/figure-model'
2
2
  import type { FigureFollow, FigureSizeRange } from '@hatiolab/figure-model'
3
- import type { FigureAsset, FigureAuthoringAction, FigureBindingPlace, FigureDimensionSource, FigureFace, FigureFacing, FigureJoint } from '@hatiolab/figure-model'
3
+ import type { FigureAsset, FigureAuthoringAction, FigureBindingPlace, FigureDimensionSource, FigureFace, FigureFacing, FigureJoint, FigureSurfacePlace } from '@hatiolab/figure-model'
4
4
  import { startingShapeKindOf } from './starting-shape.js'
5
5
  import { boxOf } from './align.js'
6
6
  import { graphValuesOf, type FigureRevision } from './revision.js'
@@ -35,6 +35,23 @@ export function followOf(asset: FigureAsset, part: string, axis: Axis): Follow {
35
35
  return figureFollowOf(asset, part, axis)
36
36
  }
37
37
 
38
+ /**
39
+ * The parts whose size follows the figure's size on some axis, in the model's order: the ones that change when the
40
+ * figure is resized on a drawing. Read by figure-model's own follow (`figureFollowOf`), not worked out again here.
41
+ */
42
+ export function sizeFollowersOf(asset: FigureAsset): string[] {
43
+ const parts = (modelOf(asset).nodes as Array<{ id: string; op: string }>).filter(n => n.op === 'place@1').map(n => n.id)
44
+ return parts.filter(part =>
45
+ AXES.some(axis => {
46
+ try {
47
+ return figureFollowOf(asset, part, axis).of === 'instance'
48
+ } catch {
49
+ return false
50
+ }
51
+ })
52
+ )
53
+ }
54
+
38
55
  /** Whether the asset declares an instance size on an axis — only then can a dimension follow it. */
39
56
  export const hasInstanceSize = (asset: FigureAsset, axis: Axis) => modelOf(asset).inputs.some(i => i.id === `size.${axis}` && i.role === 'design')
40
57
 
@@ -224,7 +241,14 @@ export function measureNow(revision: FigureRevision, part: string, axis: Axis):
224
241
  */
225
242
  export function facesOf(asset: FigureAsset, part: string): string[] {
226
243
  const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
227
- return inScreenOrder(Object.keys((shape && figureShapeFaces(shape as never)) ?? {}))
244
+ const faces = (shape && figureShapeFaces(shape as never)) ?? {}
245
+ /* A cylinder's curved side is no face: a part is fastened to a point on it (ADR-0140 3.9, `surfaceOf`). */
246
+ return inScreenOrder(Object.keys(faces).filter(f => faces[f]!.contact !== 'line'))
247
+ }
248
+
249
+ /** The curved surface a part offers to be fastened to at a point: a cylinder's side or a sphere's round (ADR-0140 3.9). */
250
+ export function surfaceOf(asset: FigureAsset, part: string): { form: 'around' | 'toward'; axis: Axis } | undefined {
251
+ return figureSurfaceOf(asset, part)
228
252
  }
229
253
 
230
254
  /** Faces in the order the screen lists them: the six outer ones left to front, then the rest as the shape gives them. */
@@ -242,7 +266,7 @@ export function faceAxisOf(asset: FigureAsset, part: string, face: string): Axis
242
266
 
243
267
  /** Whether `attach` can take this part: any part whose shape has a flat face. */
244
268
  export function canFasten(asset: FigureAsset, part: string): boolean {
245
- return facesOf(asset, part).length > 0
269
+ return facesOf(asset, part).length > 0 || !!surfaceOf(asset, part)
246
270
  }
247
271
 
248
272
  const OUTER: Record<Axis, [string, string]> = { x: ['left', 'right'], y: ['bottom', 'top'], z: ['back', 'front'] }
@@ -267,13 +291,15 @@ export function rowAxesOf(asset: FigureAsset, part: string): Axis[] {
267
291
  * Why a part cannot be made into a row here, in the terms `rowAxesOf` refuses it: said, not hidden (ADR-0097 §5.5).
268
292
  * Nothing when it can, or when it already is a row (that row is shown where it stands).
269
293
  */
270
- export function rowBlockOf(asset: FigureAsset, part: string): 'unfastened' | 'on-plane' | 'holds-parts' | 'at-origin' | undefined {
294
+ export function rowBlockOf(asset: FigureAsset, part: string): 'unfastened' | 'on-plane' | 'holds-parts' | 'at-origin' | 'on-surface' | undefined {
271
295
  const joints = asset.joints ?? []
272
296
  const j = joints.find(o => o.body1.part === part)
273
297
  if (!j) return 'unfastened'
274
298
  if (j.repeat) return undefined
275
299
  if (!('part' in j.body0)) return 'on-plane'
276
300
  if (joints.some(o => 'part' in o.body0 && o.body0.part === part)) return 'holds-parts'
301
+ /* On a point of a curved surface a row has no face to run across (ADR-0140 3.9): the kernel refuses it. */
302
+ if ('surface' in j.body0) return 'on-surface'
277
303
  if (!('face' in j.body0) || 'at' in j.body1) return 'at-origin'
278
304
  return undefined
279
305
  }
@@ -372,13 +398,29 @@ export function fasteningOf(
372
398
  alignMm: Partial<Record<Axis, number>>
373
399
  /** Placed by a part's origin (ADR-0107 decision 4): no face to fasten by, and the form does not write it. */
374
400
  atOrigin?: true
401
+ /** On a point of the target's curved surface (ADR-0140 3.9), as the record says it. */
402
+ surface?: FigureSurfacePlace
375
403
  } | null {
376
404
  const record = revision.asset.joints?.find(j => j.body1.part === part)
377
405
  if (!record) return null
378
406
  const toPlane = 'plane' in record.body0
379
407
  const target = 'part' in record.body0 ? record.body0.part : ''
408
+ const surface = 'surface' in record.body0 ? record.body0.surface : undefined
380
409
  const targetFace = 'face' in record.body0 ? record.body0.face : 'top'
381
- const atOrigin = !('plane' in record.body0) && (!('face' in record.body0) || 'at' in record.body1)
410
+ const atOrigin = !('plane' in record.body0) && ((!('face' in record.body0) && !surface) || 'at' in record.body1)
411
+ if (surface)
412
+ return {
413
+ toPlane,
414
+ target,
415
+ targetFace,
416
+ facing: record.origin?.facing ?? 'meet',
417
+ gap: typeof record.origin?.gap === 'number' ? record.origin.gap : 0,
418
+ ...(record.origin?.gap !== undefined && typeof record.origin.gap !== 'number' ? { gapKept: record.origin.gap } : {}),
419
+ align: {},
420
+ alignMm: {},
421
+ surface,
422
+ ...(atOrigin ? { atOrigin: true as const } : {})
423
+ }
382
424
  /* Where across the face: as the record says it, and where it says nothing, where the part stands now. */
383
425
  const kept = placeKeptOf(revision, part, toPlane ? 'plane' : { part: target, face: targetFace })
384
426
  for (const [axis, how] of Object.entries(record.origin?.align ?? {}) as Array<[Axis, unknown]>) {
@@ -1,6 +1,5 @@
1
1
  import { FIGURE_SECTION_PLANE, FIGURE_SECTION_POINT_LIMIT, checkFigureSection, figureExtrusionFeaturesOf, figureSectionSidesOf } from '@hatiolab/figure-model'
2
2
  import type { FigureAuthoringAction, FigureCavityRequest, FigureEvaluatedShape, FigureSection, FigureSectionIssue, FigureSectionMeasure, FigureSectionPoint } from '@hatiolab/figure-model'
3
- import { END_FACE } from './section-controls.js'
4
3
 
5
4
  /*
6
5
  * What the section editor does to a drawn section's points, and how it says what is wrong with them (ADR-0100 §3.2).
@@ -218,14 +217,15 @@ export function sectionActionOf(part: string, kind: FigureSection['kind'] | unde
218
217
  }
219
218
 
220
219
  /**
221
- * What a measure's field says under it: that it follows another value, or -- given where along the push a length
222
- * change keeps the part (1 its start, -1 its end, 0 its centre) -- the face it keeps; null when there is nothing to say.
220
+ * What a measure's field says under it: that it follows another value, or what a measure typed keeps -- the part's
221
+ * middle, or its fastening (ADR-0140 4.1, figure-model's `figureSizeEditOf`); null when there is nothing to say.
223
222
  */
224
- export function measureNoteOf(measure: FigureSectionMeasure | undefined, kept: -1 | 0 | 1 | undefined, axis: Axis): { key: string; face?: string } | null {
223
+ export function measureNoteOf(measure: FigureSectionMeasure | undefined, kept: 'centre' | 'relation' | 'none' | undefined): { key: string } | null {
225
224
  if (measure === 'linked') return { key: 'figure.text.section-editor-linked' }
226
- if (kept === undefined) return null
227
- if (kept === 0) return { key: 'figure.text.section-editor-keeps-centre' }
228
- return { key: 'figure.text.section-editor-keeps', face: END_FACE(axis, kept === 1 ? 'start' : 'end') }
225
+ if (kept === 'centre') return { key: 'figure.text.size-keeps-centre' }
226
+ if (kept === 'relation') return { key: 'figure.text.size-keeps-fastening' }
227
+ if (kept === 'none') return { key: 'figure.text.size-keeps-none' }
228
+ return null
229
229
  }
230
230
 
231
231
  /** The words beside a drawn section's edit button: how many points it has, and the apex when its end is one point. */
@@ -0,0 +1,112 @@
1
+ import { compileFigureDocument, figurePlacementOf, figureShapeReachOf, figureSizeEditOf, figureSizeEditorOf } from '@hatiolab/figure-model'
2
+ import type { FigureAsset, FigureInputChange, FigureSizeKeep } from '@hatiolab/figure-model'
3
+
4
+ import { ownerOf } from './fields.js'
5
+ import type { FigureRenderPart } from './renderer.js'
6
+
7
+ /*
8
+ * A size drag drawn from figure-model, frame by frame (ADR-0140 4.4). The gizmo says only which handle and how far; the
9
+ * one size function (`figureSizeEditorOf`) turns the sizes into the changes a commit writes, and the instance evaluator
10
+ * the figure is compiled into once, when the drag begins, steps to them (`advance`). What is drawn while dragging is
11
+ * what the release writes: the same function, the same sizes, the same keep. Nothing drawn goes back into what the
12
+ * gizmo computes (2026-09-24: the gizmo fought the modeller when evaluations were written back onto it).
13
+ */
14
+
15
+ export interface SizeDragFrame {
16
+ /** What a release now would write: the sizes and, for a free part, its own position. */
17
+ changes: FigureInputChange[]
18
+ /**
19
+ * Every body moved or reshaped since the drag began, by id: its pose and shape at this frame as figure-model evaluates
20
+ * them. The evaluator tells only what changed since the frame before; a body that moved and then stood still is kept.
21
+ */
22
+ moved: Map<string, Pick<FigureRenderPart, 'pose' | 'shape'>>
23
+ }
24
+
25
+ export interface SizeDrag {
26
+ part: string
27
+ keep: FigureSizeKeep
28
+ frame(sizes: readonly FigureInputChange[]): SizeDragFrame
29
+ }
30
+
31
+ type Pose = FigureRenderPart['pose']
32
+
33
+ /** The drag of `part`'s size from `inputs` (the figure's inputs as it is drawn), keeping what `keep` says. */
34
+ export function startSizeDrag(asset: FigureAsset, part: string, inputs: Record<string, number>, keep: FigureSizeKeep): SizeDrag {
35
+ const edit = figureSizeEditorOf(asset, part)
36
+ const evaluator = compileFigureDocument(asset.document).evaluator({ changes: true })
37
+ const base = { ...asset.designInputs, ...asset.stateDefaults, ...inputs }
38
+ /* The bodies as the evaluator numbers its slots: the order of this first evaluation. */
39
+ let ids = evaluator.evaluate(base).geometry.map(g => (g as { id: string }).id)
40
+ const moved = new Map<string, Pick<FigureRenderPart, 'pose' | 'shape'>>()
41
+ return {
42
+ part,
43
+ keep,
44
+ frame(sizes) {
45
+ const { changes } = edit(sizes, keep)
46
+ const next = { ...base }
47
+ for (const c of changes) next[c.input] = c.value as number
48
+ const step = evaluator.advance(next)
49
+ if (step.kind === 'changes' && step.structure) ids = step.structure.ids
50
+ for (let k = 0; k < step.bodies.length; k++) {
51
+ const id = ids[step.bodies[k]!]
52
+ const p = step.poses.subarray(k * 12, k * 12 + 12)
53
+ if (id === undefined || Number.isNaN(p[0]!)) continue
54
+ const pose: Pose = { r: [[p[0]!, p[1]!, p[2]!], [p[3]!, p[4]!, p[5]!], [p[6]!, p[7]!, p[8]!]], t: [p[9]!, p[10]!, p[11]!] }
55
+ /* The evaluator's shapes are frozen; the scene's builder writes into what it is given, so the canvas gets its own copy. */
56
+ const frozen = step.kind === 'changes' ? step.shapes[k] : undefined
57
+ const shape = frozen ? structuredClone(frozen) : undefined
58
+ const had = moved.get(id)
59
+ moved.set(id, { pose, ...(shape ? { shape: shape as unknown as FigureRenderPart['shape'] } : had?.shape ? { shape: had.shape } : {}) } as Pick<FigureRenderPart, 'pose' | 'shape'>)
60
+ }
61
+ return { changes, moved: new Map(moved) }
62
+ }
63
+ }
64
+ }
65
+
66
+ /**
67
+ * What a size typed for this part keeps, in figure-model's words: its middle (`centre`), its fastening (`relation`), or
68
+ * no point -- its position is worked out, and only the size changes (`none`). The words beside the size fields (ADR-0140
69
+ * 4.1, chief architect 10-20); nothing when the part cannot be asked.
70
+ */
71
+ export function keptBySizeOf(asset: FigureAsset, part: string): 'centre' | 'relation' | 'none' | undefined {
72
+ try {
73
+ const kept = figureSizeEditOf(asset, part, [], { keep: 'centre' }).kept
74
+ return kept === 'opposite' ? undefined : kept
75
+ } catch {
76
+ return undefined
77
+ }
78
+ }
79
+
80
+ /** The words for what a size keeps, by `keptBySizeOf`'s answer. */
81
+ export const SIZE_KEEPS_WORDS = { centre: 'figure.text.size-keeps-centre', relation: 'figure.text.size-keeps-fastening', none: 'figure.text.size-keeps-none' } as const
82
+
83
+ /** The parts drawn this frame: each one the frame moved, with its new pose and shape; every other one as it was. */
84
+ export function partsAtFrame(parts: readonly FigureRenderPart[], moved: SizeDragFrame['moved']): FigureRenderPart[] {
85
+ if (!moved.size) return parts as FigureRenderPart[]
86
+ return parts.map(part => {
87
+ const now = moved.get(part.id)
88
+ return now ? { ...part, pose: now.pose, ...(now.shape ? { shape: now.shape } : {}) } : part
89
+ })
90
+ }
91
+
92
+ /** The side of its mounting plane a figure uses (ADR-0097 4.2): up from a floor, down from a ceiling, out of a wall. */
93
+ const USED: Record<string, [number, number, number] | undefined> = { floor: [0, 1, 0], ceiling: [0, -1, 0], wall: [0, 0, 1], center: undefined }
94
+
95
+ /**
96
+ * How far a free part goes past its figure's mounting plane, in millimetres, as drawn: its real shape's extreme against
97
+ * the side the placement uses (figure-model's reach, the rule a fastening stands a part by). 0 when it does not, for a
98
+ * fastened part -- its fastening places it -- and for a figure placed by its centre (ADR-0140 5).
99
+ */
100
+ export function throughPlaneOf(asset: FigureAsset, parts: readonly FigureRenderPart[], part: string): number {
101
+ const n = USED[figurePlacementOf(asset)]
102
+ if (!n || (asset.joints ?? []).some(j => j.body1.part === part)) return 0
103
+ let lowest = Infinity
104
+ for (const body of parts) {
105
+ if (ownerOf(body.id) !== part) continue
106
+ const { r, t } = body.pose
107
+ /* The side's direction in the body's own frame: rᵀ n. */
108
+ const d = [0, 1, 2].map(j => r[0]![j]! * n[0] + r[1]![j]! * n[1] + r[2]![j]! * n[2]) as [number, number, number]
109
+ lowest = Math.min(lowest, t[0]! * n[0] + t[1]! * n[1] + t[2]! * n[2] + figureShapeReachOf(body.shape as never, d).min)
110
+ }
111
+ return Number.isFinite(lowest) && lowest < 0 ? Math.round(-lowest * 10) / 10 : 0
112
+ }
@@ -66,14 +66,14 @@ export function anchorFacesOf(anchors: readonly SlotAnchor[], geometry: readonly
66
66
  * a one-anchor preset on the first, a twin fork on the first two. A single slot stands on a top and hangs from a bottom.
67
67
  * The editor's own controls then make it what the equipment is.
68
68
  */
69
- export function slotPresetsOf(anchors: readonly SlotAnchor[], say: (key: string) => string) {
69
+ export function slotPresetsOf(anchors: readonly SlotAnchor[]) {
70
70
  const first = anchors[0]
71
71
  const on = { ...(first ? { anchor: first.id } : {}), ...(anchors.length >= 2 ? { forks: [anchors[0]!.id, anchors[1]!.id] as [string, string] } : {}) }
72
72
  const mount = first?.face === 'bottom' ? ('HANG' as const) : ('ON' as const)
73
73
  return SLOT_LAYOUT_PRESET_KEYS.map(key => ({
74
74
  key,
75
- label: say(`figure.label.slot-preset-${key}`),
76
- params: SLOT_LAYOUT_PRESET_PARAMS[key].map(p => ({ key: p.key, label: say(`figure.label.slot-preset-param-${p.key}`), integer: p.integer })),
75
+ /* The editor names a preset and its values from their keys (@operato/slot-layout-editor's own words). */
76
+ params: SLOT_LAYOUT_PRESET_PARAMS[key].map(p => ({ key: p.key, integer: p.integer })),
77
77
  build: (size: SlotPresetSize, params: Record<string, number>) => slotLayoutPreset(key, size, params, { ...on, mount })
78
78
  }))
79
79
  }
@@ -64,16 +64,13 @@ function colours(part: FigureRenderPart, palette: Record<string, string>) {
64
64
  return { color: palette[m.token] ?? '#9a9a9a', preset: m.preset, transparent: !!m.transparent, ...(glow ? { glow } : {}) }
65
65
  }
66
66
 
67
- /** Per axis of the body's own frame, the face a size drag keeps: 1 the low face, -1 the high face, 0 the centre. */
68
- export type ScaleAnchor = Record<'x' | 'y' | 'z', -1 | 0 | 1>
69
-
70
67
  /**
71
- * The component state that draws a body where figure-model put it. With `anchor` -- figure-model's
72
- * `figureScaleAnchorOf` for its part -- the scene's scale gizmo keeps that face while it is dragged, the face the model
73
- * keeps when the drag is written; without it the gizmo keeps the face across from the handle held, and the part jumped
74
- * on release (designer 2026-09-27). Body x · y · z are the component's width · depth · height, as the scene's axes are.
68
+ * The component state that draws a body where figure-model put it. A part the author edits is `owned`: while its size
69
+ * is dragged the scene does not move it and tells the handle held (`scaleGrab`), and the modeller draws where
70
+ * figure-model's size function puts it, every frame (ADR-0140 4.4). Body x · y · z are the component's width · depth ·
71
+ * height, as the scene's axes are.
75
72
  */
76
- export function componentOf(part: FigureRenderPart, palette: Record<string, string>, board: Board, anchor?: ScaleAnchor): Record<string, unknown> | null {
73
+ export function componentOf(part: FigureRenderPart, palette: Record<string, string>, board: Board, owned = false): Record<string, unknown> | null {
77
74
  const frame = frameOf(part)
78
75
  if (!frame) return null
79
76
  const { centre, extent, rotation, origin } = frame
@@ -97,7 +94,7 @@ export function componentOf(part: FigureRenderPart, palette: Record<string, stri
97
94
  rotationX: round(euler.x),
98
95
  rotation: round(-euler.y),
99
96
  rotationY: round(euler.z),
100
- ...(anchor ? { scaleAnchor: { ...anchor } } : {})
97
+ ...(owned ? { scalePlacement: 'owner' } : {})
101
98
  }
102
99
  }
103
100
 
@@ -147,8 +147,6 @@ export class FigureModellerPage extends PageView {
147
147
  @state() private commandError = ''
148
148
  /** What the verdict says blocks release now, handed to the editor's bar. */
149
149
  @state() private releaseBlockers: FigureGateFinding[] = []
150
- /** The verdict's count, handed to the editor's verdict tab. */
151
- @state() private instances?: number
152
150
  @state() private budget?: FigureBudgetLine
153
151
  private wasDirty = false
154
152
  private titled?: string | null
@@ -532,7 +530,7 @@ export class FigureModellerPage extends PageView {
532
530
  if (this.error) return html`<p role="alert" class="error">${this.error}</p>`
533
531
  const view = this.session.read()
534
532
  /* A new figure's third way to start: the app's assistant dock, with a request put in its input for the author to finish and send (B, U04). */
535
- return html`<figure-editor .session=${this.session} .releaseState=${this.row} .releaseBlockers=${this.releaseBlockers} .templates=${this.templates} .guideOf=${this.guideOf} .thumbnailOf=${this.thumbnailOf} .firstSave=${this.askFirstSave} .revision=${this.revision} .instances=${this.instances} .budget=${this.budget} aiDraft @ask-ai-draft=${() => askAssistant({ draft: i18next.t('figure.text.ai-draft-request') })} @changed=${this.changed}>
533
+ return html`<figure-editor .session=${this.session} .releaseState=${this.row} .releaseBlockers=${this.releaseBlockers} .templates=${this.templates} .guideOf=${this.guideOf} .thumbnailOf=${this.thumbnailOf} .firstSave=${this.askFirstSave} .revision=${this.revision} .budget=${this.budget} aiDraft @ask-ai-draft=${() => askAssistant({ draft: i18next.t('figure.text.ai-draft-request') })} @changed=${this.changed}>
536
534
  ${this.commandError ? html`<p slot="notice" fit-error role="alert">${this.commandError}</p>` : ''}
537
535
  <figure-properties
538
536
  slot="figure"
@@ -562,7 +560,6 @@ export class FigureModellerPage extends PageView {
562
560
  .figureId=${view.id}
563
561
  .figureType=${this.row.type}
564
562
  @capacity-changed=${(event: CustomEvent) => {
565
- this.instances = event.detail.instances
566
563
  this.budget = event.detail.budget
567
564
  }}
568
565
  @fit-base-to-parts=${this.fitToParts}
@@ -2,16 +2,19 @@ import { LitElement, type PropertyValues } from 'lit';
2
2
  import type { GizmoReach } from './gizmo.js';
3
3
  import type { FigureRenderPart } from './renderer.js';
4
4
  import { type ViewSettings } from '@things-factory/figure-viewer/dist-client/figure-view.js';
5
- import { type ScaleAnchor } from './stage-model.js';
6
5
  export declare class FigureCanvas extends LitElement {
7
6
  static styles: import("lit").CSSResult;
8
7
  parts: FigureRenderPart[];
9
8
  palette: Record<string, string>;
10
- /** The face a size drag keeps, per part (figure-model's `figureScaleAnchorOf`), so the drag shows what is written. */
11
- anchors: Record<string, ScaleAnchor>;
12
9
  /** Which figure is open. Another one stands the board up again. */
13
10
  context: string;
14
11
  selected: string;
12
+ /**
13
+ * The fastening window is open (ADR-0140 stage 4): a part pressed is aimed at, not chosen -- told out as `part-aimed`
14
+ * with where the press landed (the scene's `pickedAt`: the point and the drawn triangle's outward normal, in the
15
+ * figure's frame), and the part being fastened stays chosen.
16
+ */
17
+ aiming: boolean;
15
18
  /** What the gizmo may write for the selected part; a change beyond it is not applied and the part springs back. */
16
19
  reach: GizmoReach;
17
20
  view: ViewSettings;
@@ -61,6 +64,13 @@ export declare class FigureCanvas extends LitElement {
61
64
  * one that has outgrown it -- standing up again loses the selection and the camera.
62
65
  */
63
66
  private standUp;
67
+ /**
68
+ * A part's component state, kept for the part object it was made from while the palette and the board are the same: a
69
+ * size drag redraws the board every frame, and only the parts the frame moved are new objects (ADR-0140 4.4).
70
+ */
71
+ private made;
72
+ private madeFor?;
73
+ private componentFor;
64
74
  private components;
65
75
  /** The components standing now, by name. Not held between calls: the scene stands its board up again at times. */
66
76
  private standing;
@@ -72,9 +82,20 @@ export declare class FigureCanvas extends LitElement {
72
82
  private watchScene;
73
83
  /** Components the gizmo changed since the last commit, by part name. */
74
84
  private pending;
85
+ /** The handle each size drag held, by part name, from the scene's `scaleGrab` -- told with the release. */
86
+ private grabs;
87
+ private sizingFrame?;
88
+ /** Once an animation frame: where a size drag stands, told so the editor draws figure-model's result for it. */
89
+ private sizingSoon;
75
90
  private pointerDown;
76
91
  private commitTimer?;
92
+ /**
93
+ * The parts as they stood when the pointer went down. A drag is measured against them: while a size drag is drawn, the
94
+ * parts given are its frames (ADR-0140 4.4), and measured against those it grew on itself and its release wrote nothing.
95
+ */
96
+ private atPress?;
77
97
  private onPointerDown;
98
+ private pressedPart;
78
99
  private onPointerUp;
79
100
  private commitSoon;
80
101
  /** Tell each changed part once, in the inputs' terms; then put the board where the inputs say (a refusal springs back). */