@things-factory/figure-ui 10.1.200 → 10.1.202

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 (73) hide show
  1. package/client/modeller/editor.ts +7 -3
  2. package/client/modeller/fasten-form.ts +18 -35
  3. package/client/modeller/fasten-geometry.ts +67 -115
  4. package/client/modeller/figure-fasten-dialog.ts +4 -12
  5. package/client/modeller/figure-links.ts +6 -7
  6. package/client/modeller/joint-drag.ts +5 -5
  7. package/client/modeller/links.ts +35 -99
  8. package/client/modeller/refusals.ts +2 -6
  9. package/dist-client/modeller/editor.js +7 -2
  10. package/dist-client/modeller/editor.js.map +1 -1
  11. package/dist-client/modeller/fasten-form.d.ts +5 -8
  12. package/dist-client/modeller/fasten-form.js +16 -28
  13. package/dist-client/modeller/fasten-form.js.map +1 -1
  14. package/dist-client/modeller/fasten-geometry.d.ts +20 -19
  15. package/dist-client/modeller/fasten-geometry.js +54 -113
  16. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  17. package/dist-client/modeller/figure-fasten-dialog.js +4 -12
  18. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  19. package/dist-client/modeller/figure-links.d.ts +1 -1
  20. package/dist-client/modeller/figure-links.js +6 -7
  21. package/dist-client/modeller/figure-links.js.map +1 -1
  22. package/dist-client/modeller/joint-drag.js +5 -5
  23. package/dist-client/modeller/joint-drag.js.map +1 -1
  24. package/dist-client/modeller/links.d.ts +19 -26
  25. package/dist-client/modeller/links.js +36 -94
  26. package/dist-client/modeller/links.js.map +1 -1
  27. package/dist-client/modeller/refusals.d.ts +1 -1
  28. package/dist-client/modeller/refusals.js +2 -6
  29. package/dist-client/modeller/refusals.js.map +1 -1
  30. package/dist-client/tsconfig.tsbuildinfo +1 -1
  31. package/package.json +8 -8
  32. package/test/ai-proposal.test.ts +1 -1
  33. package/test/browser/acceptance-page.js +11 -11
  34. package/test/browser/detail-page.js +1 -1
  35. package/test/browser/detail-tabs-page.js +1 -1
  36. package/test/browser/editing-page.js +1 -1
  37. package/test/browser/fix-alignment.check.mjs +3 -3
  38. package/test/browser/links.check.mjs +20 -8
  39. package/test/browser/modeller-page.js +3 -3
  40. package/test/browser/part-ids.check.mjs +2 -2
  41. package/test/browser/parts-page.js +2 -2
  42. package/test/browser/product-page.check.mjs +3 -3
  43. package/test/browser/product-page.js +1 -1
  44. package/test/browser/row-preview.check.mjs +2 -2
  45. package/test/browser/screen-page.js +3 -3
  46. package/test/browser/section-page.js +3 -3
  47. package/test/browser/section.check.mjs +4 -4
  48. package/test/clip-ai-proposal.test.ts +1 -1
  49. package/test/clip-consent.test.ts +1 -1
  50. package/test/command-preview.test.ts +8 -8
  51. package/test/face-mapping.test.ts +2 -2
  52. package/test/fasten-drawing.test.ts +14 -2
  53. package/test/fasten-form.test.ts +11 -3
  54. package/test/fasten-geometry.test.ts +22 -31
  55. package/test/fix-alignment.test.ts +16 -4
  56. package/test/gizmo.test.ts +2 -2
  57. package/test/joint-drag.test.ts +13 -2
  58. package/test/joint-fan.test.ts +1 -1
  59. package/test/joint-frames.test.ts +45 -23
  60. package/test/joints.ts +1 -1
  61. package/test/links.test.ts +68 -68
  62. package/test/live.test.ts +2 -2
  63. package/test/preview-size.test.ts +1 -1
  64. package/test/refusals.test.ts +18 -20
  65. package/test/resize-with-placement.test.ts +6 -6
  66. package/test/revision.test.ts +4 -5
  67. package/test/section-panel.test.ts +1 -1
  68. package/translations/en.json +9 -11
  69. package/translations/es.json +9 -11
  70. package/translations/ja.json +9 -11
  71. package/translations/ko.json +9 -11
  72. package/translations/ms.json +9 -11
  73. package/translations/zh.json +9 -11
@@ -1,6 +1,6 @@
1
- import { FIGURE_FACES, figureShapeFaces, figurePartsExtentOf, compileFigureAsset, 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, figureFastenKeptPlaceOf, figureFastenAxesOf, figureJointAsFixed, figureJointWithMotion } from '@hatiolab/figure-model'
2
2
  import type { FigureFollow, FigureSizeRange } from '@hatiolab/figure-model'
3
- import type { FigureAsset, FigureAuthoringAction, FigureBindingPlace, FigureDimensionSource, FigureFace, FigureJoint } from '@hatiolab/figure-model'
3
+ import type { FigureAsset, FigureAuthoringAction, FigureBindingPlace, FigureDimensionSource, FigureFace, FigureFacing, FigureJoint } 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'
@@ -55,7 +55,7 @@ export function heldBy(asset: FigureAsset, part: string): { part: string } | { p
55
55
  return to ? { part: to } : null
56
56
  }
57
57
 
58
- /** The word for where a joint end sits: its face's, or the part's origin for an end placed by it (ADR-0107 decision 4). */
58
+ /** The word for where a joint's body0 end sits: its face's, or the part's origin for an end placed by it (ADR-0107 decision 4). */
59
59
  export const endPlaceKeyOf = (end: object) => ('face' in end ? `figure.label.face-${(end as { face: string }).face}` : 'figure.label.joint-origin')
60
60
 
61
61
  /** Whether a dimension of this part can follow something — box-shaped, whose three sizes are X, Y and Z. */
@@ -259,9 +259,8 @@ export function rowAxesOf(asset: FigureAsset, part: string): Axis[] {
259
259
  if (!j || j.repeat || !('part' in j.body0)) return []
260
260
  if (joints.some(o => 'part' in o.body0 && o.body0.part === part)) return []
261
261
  /* Placed by an origin: no face to lay the row across, and the kernel refuses it. */
262
- const by = j.body1
263
- if (!('face' in j.body0) || !('face' in by)) return []
264
- return AXES.filter(axis => axis !== FIGURE_FACES[by.face])
262
+ if (!('face' in j.body0) || 'at' in j.body1) return []
263
+ return figureFastenAxesOf(asset, { part: j.body0.part, face: j.body0.face })
265
264
  }
266
265
 
267
266
  /**
@@ -275,7 +274,7 @@ export function rowBlockOf(asset: FigureAsset, part: string): 'unfastened' | 'on
275
274
  if (j.repeat) return undefined
276
275
  if (!('part' in j.body0)) return 'on-plane'
277
276
  if (joints.some(o => 'part' in o.body0 && o.body0.part === part)) return 'holds-parts'
278
- if (!('face' in j.body0) || !('face' in j.body1)) return 'at-origin'
277
+ if (!('face' in j.body0) || 'at' in j.body1) return 'at-origin'
279
278
  return undefined
280
279
  }
281
280
 
@@ -294,37 +293,18 @@ export type Alignment = 'centre' | 'min' | 'max' | 'mm' | 'share' | 'input'
294
293
  */
295
294
  /**
296
295
  * The mounting plane of this figure, by its placement (ADR-0093): the floor or the ceiling is Y = 0 and a part stands
297
- * on it by its bottom or hangs from it by its top; a wall is Z = 0 and a part is fastened to it by its back.
296
+ * on it or hangs below it; a wall is Z = 0 and a part stands out of it (ADR-0140 3.11).
298
297
  */
299
- export function mountingPlaneOf(asset: FigureAsset): { normal: Axis; faces: FigureFace[] } {
300
- return asset.placement === 'wall' ? { normal: 'z', faces: ['back'] } : { normal: 'y', faces: ['bottom', 'top'] }
298
+ export function mountingPlaneOf(asset: FigureAsset): { normal: Axis; facings: FigureFacing[] } {
299
+ return asset.placement === 'wall' ? { normal: 'z', facings: ['meet'] } : { normal: 'y', facings: ['meet', 'flush'] }
301
300
  }
302
301
 
303
- export function alignAxesOf(asset: FigureAsset, part: string, target: { part: string; face: string } | 'plane', twisted = false): { axis: Axis; edges: boolean }[] {
302
+ export function alignAxesOf(asset: FigureAsset, target: { part: string; face: string } | 'plane'): { axis: Axis; edges: boolean }[] {
304
303
  /* The plane has no edges, but a figure that declares its size has sides on it (figure-model 0.1.64). */
305
- if (target === 'plane') {
306
- const { normal } = mountingPlaneOf(asset)
307
- return AXES.filter(axis => axis !== normal).map(axis => ({ axis, edges: !twisted && hasInstanceSize(asset, axis) }))
308
- }
309
- const along = faceAxisOf(asset, target.part, target.face)
310
- if (!along) return []
311
- const theirs = new Set(facesOf(asset, target.part)),
312
- mine = new Set(facesOf(asset, part))
313
- /* A part twisted about the face's normal has no side level with the target's edges. */
314
- return AXES.filter(axis => axis !== along).map(axis => ({ axis, edges: !twisted && OUTER[axis].every(f => theirs.has(f) && mine.has(f)) }))
315
- }
316
-
317
- /**
318
- * The faces of its own a part may be fastened by: its outer faces -- on the mounting plane only the ones the plane takes
319
- * (a bottom or a top on the floor, a back on a wall) -- and, to another part, its centre, which a roller spins about.
320
- */
321
- export function ownFacesOf(asset: FigureAsset, part: string, toPlane: boolean): Array<FigureFace | 'centre'> {
322
- const outer = Object.keys(FIGURE_FACES) as FigureFace[]
323
- const plane = mountingPlaneOf(asset)
324
- return [
325
- ...(facesOf(asset, part).filter(face => outer.includes(face as FigureFace) && (!toPlane || plane.faces.includes(face as FigureFace))) as FigureFace[]),
326
- ...(toPlane ? [] : (['centre'] as const))
327
- ]
304
+ if (target === 'plane') return figureFastenAxesOf(asset, 'plane').map(axis => ({ axis, edges: hasInstanceSize(asset, axis) }))
305
+ /* The part is lined up by its real extent in the pose it has (ADR-0140 3.4): the target's two sides are what it needs. */
306
+ const theirs = new Set(facesOf(asset, target.part))
307
+ return figureFastenAxesOf(asset, target).map(axis => ({ axis, edges: OUTER[axis].every(f => theirs.has(f)) }))
328
308
  }
329
309
 
330
310
  /**
@@ -334,13 +314,11 @@ export function ownFacesOf(asset: FigureAsset, part: string, toPlane: boolean):
334
314
  */
335
315
  export function alignRowsOf(
336
316
  asset: FigureAsset,
337
- part: string,
338
317
  aim: { part: string; face: string } | 'plane',
339
318
  view: { axes: readonly Axis[] } | undefined,
340
- twisted: boolean,
341
319
  alignInput: Partial<Record<Axis, string>> = {}
342
320
  ): { axis: Axis; edges: boolean }[] {
343
- return alignAxesOf(asset, part, aim, twisted).filter(({ axis }) => !view || !view.axes.includes(axis) || !!alignInput[axis] || hasInstanceSize(asset, axis))
321
+ return alignAxesOf(asset, aim).filter(({ axis }) => !view || !view.axes.includes(axis) || !!alignInput[axis] || hasInstanceSize(asset, axis))
344
322
  }
345
323
 
346
324
  /** The controls a part's showing can follow: the figure's state inputs, with their range and label. */
@@ -374,8 +352,8 @@ export function hangsFrom(asset: FigureAsset, part: string, from: string): boole
374
352
  }
375
353
 
376
354
  /**
377
- * The fastening a part has, as the fastening form holds it -- so the form opens on what is there (the face, including
378
- * \`centre\`, the target and its face, the gap, the twist and where across the face) and a change is a change to that.
355
+ * The fastening a part has, as the fastening form holds it -- so the form opens on what is there (the target and its
356
+ * face, how it meets it, the gap and where across the face) and a change is a change to that.
379
357
  * Opening it on the defaults showed a roller fastened by its centre as fastened by its bottom, and fastening again
380
358
  * from there broke its turning in place (walk 1, 2026-09-26). Null for a part fastened to nothing.
381
359
  */
@@ -383,15 +361,13 @@ export function fasteningOf(
383
361
  revision: FigureRevision,
384
362
  part: string
385
363
  ): {
386
- face: string
387
364
  toPlane: boolean
388
365
  target: string
389
366
  targetFace: string
390
- facing: 'meet' | 'flush'
367
+ facing: FigureFacing
391
368
  gap: number
392
369
  /** The record's gap when it is worked out (a length that follows something): kept as it is until a number is typed. */
393
370
  gapKept?: unknown
394
- twist: number
395
371
  align: Partial<Record<Axis, Alignment>>
396
372
  alignMm: Partial<Record<Axis, number>>
397
373
  /** Placed by a part's origin (ADR-0107 decision 4): no face to fasten by, and the form does not write it. */
@@ -402,10 +378,9 @@ export function fasteningOf(
402
378
  const toPlane = 'plane' in record.body0
403
379
  const target = 'part' in record.body0 ? record.body0.part : ''
404
380
  const targetFace = 'face' in record.body0 ? record.body0.face : 'top'
405
- const atOrigin = !('plane' in record.body0) && (!('face' in record.body0) || !('face' in record.body1))
406
- const twist = record.origin?.twist ?? 0
381
+ const atOrigin = !('plane' in record.body0) && (!('face' in record.body0) || 'at' in record.body1)
407
382
  /* Where across the face: as the record says it, and where it says nothing, where the part stands now. */
408
- const kept = placeKeptOf(revision, part, toPlane ? 'plane' : { part: target, face: targetFace }, !!twist)
383
+ const kept = placeKeptOf(revision, part, toPlane ? 'plane' : { part: target, face: targetFace })
409
384
  for (const [axis, how] of Object.entries(record.origin?.align ?? {}) as Array<[Axis, unknown]>) {
410
385
  if (how === 'min' || how === 'max' || how === 'centre') {
411
386
  kept.align[axis] = how
@@ -422,14 +397,12 @@ export function fasteningOf(
422
397
  }
423
398
  const gap = record.origin?.gap
424
399
  return {
425
- face: 'face' in record.body1 ? record.body1.face : 'centre',
426
400
  toPlane,
427
401
  target,
428
402
  targetFace,
429
403
  facing: record.origin?.facing ?? 'meet',
430
404
  gap: typeof gap === 'number' ? gap : 0,
431
405
  ...(gap !== undefined && typeof gap !== 'number' ? { gapKept: gap } : {}),
432
- twist,
433
406
  ...kept,
434
407
  ...(atOrigin ? { atOrigin: true as const } : {})
435
408
  }
@@ -446,52 +419,28 @@ export function fastenGapOf(form: { gap: number; gapKept?: unknown }): { gap?: u
446
419
  }
447
420
 
448
421
  /**
449
- * The alignment that keeps the part where it stands when it is fastened: on each axis across the face, its distance
450
- * from the target's centre (the plane's is the origin). Fastening used to centre it -- a door set 100 mm in front of
451
- * its frame slid into it (2026-09-26). Left centred where it already is, and where the target stands turned.
422
+ * The alignment that keeps the part where it stands when it is fastened, as the form holds it: the kernel's place
423
+ * (`figureFastenKeptPlaceOf`, ADR-0140 3.7), a distance on each axis it is off the target's centre.
452
424
  */
453
425
  export function placeKeptOf(
454
426
  revision: FigureRevision,
455
427
  part: string,
456
- target: { part: string; face: string } | 'plane',
457
- twisted = false
428
+ target: { part: string; face: string } | 'plane'
458
429
  ): { align: Partial<Record<Axis, Alignment>>; alignMm: Partial<Record<Axis, number>>; alignInput: Partial<Record<Axis, string>> } {
459
- const kept = { align: {} as Partial<Record<Axis, Alignment>>, alignMm: {} as Partial<Record<Axis, number>>, alignInput: {} as Partial<Record<Axis, string>> }
460
- const asset = revision.asset
461
- /* Where it stands as drawn: the pose the command that fastens it sees. */
462
- const geometry = revision.geometry as unknown as Array<{ id: string; pose?: { r: number[][]; t: number[] } }>
463
- const poseOf = (id: string) => geometry.find(g => g.id === id)?.pose
464
- const mine = poseOf(part)
465
- if (!mine) return kept
466
- let centre = [0, 0, 0]
467
- if (target !== 'plane') {
468
- const theirs = poseOf(target.part)
469
- const upright = theirs?.r.every((row, i) => row.every((v, j) => Math.abs(v - (i === j ? 1 : 0)) < 1e-9))
470
- if (!theirs || !upright) return kept
471
- centre = theirs.t
472
- }
473
- for (const { axis } of alignAxesOf(asset, part, target, twisted)) {
474
- const k = AXES.indexOf(axis)
475
- const off = Math.round((mine.t[k]! - centre[k]!) * 10) / 10
476
- if (Math.abs(off) < 0.5) continue
477
- kept.align[axis] = 'mm'
478
- kept.alignMm[axis] = off
479
- }
480
- return kept
430
+ const alignMm = figureFastenKeptPlaceOf(revision.asset, part, target, { ...revision.inputs })
431
+ return { align: Object.fromEntries(Object.keys(alignMm).map(axis => [axis, 'mm'])) as Partial<Record<Axis, Alignment>>, alignMm, alignInput: {} }
481
432
  }
482
433
 
483
434
  /** The `align` of an attach command from the form's choices: only the axes across that face, and centred ones left out. */
484
435
  export function alignOf(
485
436
  asset: FigureAsset,
486
- part: string,
487
437
  target: { part: string; face: string } | 'plane',
488
438
  how: Partial<Record<Axis, Alignment>>,
489
439
  mm: Partial<Record<Axis, number>>,
490
- twisted = false,
491
440
  inputs: Partial<Record<Axis, string>> = {}
492
441
  ) {
493
442
  const align: Partial<Record<Axis, 'min' | 'max' | { mm: number | { from: { of: 'instance'; axis: Axis }; times: number } | { from: { of: 'input'; input: string } } }>> = {}
494
- for (const { axis, edges } of alignAxesOf(asset, part, target, twisted)) {
443
+ for (const { axis, edges } of alignAxesOf(asset, target)) {
495
444
  const one = how[axis] ?? 'centre'
496
445
  const size = asset.designInputs[`size.${axis}`]
497
446
  if (one === 'share' && mm[axis] && hasInstanceSize(asset, axis) && size)
@@ -644,24 +593,20 @@ export function leadersFor(asset: FigureAsset, part: string, kind: MotionForm['k
644
593
  * control or limits of its own — its range is the leader's times the multiplier.
645
594
  */
646
595
  export function motionJoint(joint: FigureJoint, m: MotionForm, part: string): FigureJoint {
596
+ /* Where it is fastened -- its row and its frame's own words too -- is the kernel's to keep (`figureJointWithMotion`, ADR-0140 D6). */
647
597
  const moving = {
648
- id: joint.id,
649
- body0: joint.body0,
650
- body1: joint.body1,
651
- ...(joint.origin ? { origin: joint.origin } : {}),
652
- /* A row keeps being a row when it is given a motion: dropped here, the row went with it (designer 2026-09-27). */
653
- ...(joint.repeat ? { repeat: joint.repeat } : {}),
654
598
  type: m.kind === 'slide' ? ('prismatic' as const) : ('revolute' as const),
655
599
  axis: (m.axisVector ?? m.axis.toUpperCase()) as FigureJoint['axis']
656
600
  }
657
- if (m.follow) return { ...moving, mimic: { joint: m.follow, multiplier: m.multiplier, ...(m.offset ? { offset: m.offset } : {}) } }
658
- if (m.followSum) return { ...moving, mimic: { sum: m.followSum, ...(m.offset ? { offset: m.offset } : {}) } }
659
- if (m.kind === 'slide' && m.travelSum && !m.travel) return { ...moving, travel: m.travelSum, state: { id: m.id || `${part}-${m.kind}` } }
601
+ const given = (motion: Omit<Parameters<typeof figureJointWithMotion>[1], 'type' | 'axis'>) => figureJointWithMotion(joint, { ...moving, ...motion })
602
+ if (m.follow) return given({ mimic: { joint: m.follow, multiplier: m.multiplier, ...(m.offset ? { offset: m.offset } : {}) } })
603
+ if (m.followSum) return given({ mimic: { sum: m.followSum, ...(m.offset ? { offset: m.offset } : {}) } })
604
+ if (m.kind === 'slide' && m.travelSum && !m.travel) return given({ travel: m.travelSum, state: { id: m.id || `${part}-${m.kind}` } })
660
605
  if (m.kind === 'slide' && m.travel) {
661
606
  const [of, axis] = m.travel.split(':')
662
- return { ...moving, travel: { source: { of: 'part', part: of!, axis: axis as Axis }, ...(m.travelPlus ? { plus: m.travelPlus } : {}) }, state: { id: m.id || `${part}-${m.kind}` } }
607
+ return given({ travel: { source: { of: 'part', part: of!, axis: axis as Axis }, ...(m.travelPlus ? { plus: m.travelPlus } : {}) }, state: { id: m.id || `${part}-${m.kind}` } })
663
608
  }
664
- return { ...moving, ...(m.kind === 'continuous' ? {} : { lowerLimit: m.min, upperLimit: m.max }), state: { id: m.id || `${part}-${m.kind}` } }
609
+ return given({ ...(m.kind === 'continuous' ? {} : { lowerLimit: m.min, upperLimit: m.max }), state: { id: m.id || `${part}-${m.kind}` } })
665
610
  }
666
611
 
667
612
  /**
@@ -832,14 +777,5 @@ export function turnsAboutAJoint(asset: FigureAsset): boolean {
832
777
  return modelOf(asset).nodes.some(n => n.op === 'axis-turn@1')
833
778
  }
834
779
 
835
- /** The joint made still: its fastening, its place and its row kept, its motion gone. */
836
- export function fixedJointOf(joint: FigureJoint): FigureJoint {
837
- return {
838
- id: joint.id,
839
- type: 'fixed',
840
- body0: joint.body0,
841
- body1: joint.body1,
842
- ...(joint.origin ? { origin: joint.origin } : {}),
843
- ...(joint.repeat ? { repeat: joint.repeat } : {})
844
- } as FigureJoint
845
- }
780
+ /** The joint made still: its fastening, its place and its row kept, its motion gone (the kernel's `figureJointAsFixed`, ADR-0140 D6). */
781
+ export const fixedJointOf = figureJointAsFixed
@@ -56,18 +56,14 @@ export function narrowingRetryOf<A extends { kind: string }>(action: A, error: u
56
56
  }
57
57
 
58
58
  /**
59
- * A placement refused because a part is fastened to the mounting plane by a face the new plane does not take (a bottom
59
+ * A placement refused because a part is fastened to the mounting plane in a way the new plane does not take (standing
60
60
  * on the floor, when the figure is to hang on a wall), said in the author's words with the parts it names.
61
61
  */
62
62
  export function placementRefusalOf(error: unknown, say: Say): string | null {
63
63
  if (codeOf(error) !== 'PLACEMENT_PLANE') return null
64
64
  const misfit = detailsOf(error, 'placement-face')
65
65
  if (!misfit) return null
66
- return say('figure.text.placement-plane-refused', {
67
- parts: misfit.parts.map(p => `${p.part} (${p.face})`).join(', '),
68
- placement: say('figure.label.placement-' + misfit.placement, {}),
69
- faces: misfit.takes.join(', ')
70
- })
66
+ return say('figure.text.placement-plane-refused', { parts: misfit.parts.map(p => p.part).join(', ') })
71
67
  }
72
68
 
73
69
  /** A refusal carried on in a record (an AI proposal's outcome) in figure-model's own shape: its code and details. */
@@ -1450,11 +1450,14 @@ let FigureEditor = class FigureEditor extends LitElement {
1450
1450
  const asset = shown.asset;
1451
1451
  const say = (k, params) => i18next.t(k, params);
1452
1452
  const where = (o) => ({ on: partLabelOf(o.on.part), face: say(`figure.label.face-${o.on.face}`) });
1453
+ /* On another part's face: the plane's edges are the figure's room, said where the room is. */
1454
+ const onPart = (o) => !!o && o.on !== 'plane';
1453
1455
  const sides = (o) => o.sides.map(s => say(`figure.text.overhang-side-${s.side}`, { axis: s.axis.toUpperCase(), mm: String(s.mm) })).join(', ');
1454
1456
  const words = [];
1455
1457
  const note = [];
1456
1458
  try {
1457
- const own = figureFaceOverhangOf(asset, part, shown.inputs);
1459
+ const found = figureFaceOverhangOf(asset, part, shown.inputs);
1460
+ const own = onPart(found) ? found : undefined;
1458
1461
  if (own?.state === 'larger') {
1459
1462
  note.push(say('figure.text.overhang-larger', { ...where(own), sides: sides(own) }));
1460
1463
  if (own.lopsided.length)
@@ -1466,8 +1469,10 @@ let FigureEditor = class FigureEditor extends LitElement {
1466
1469
  words.push(say('figure.text.overhang-possible', where(own)));
1467
1470
  else if (own?.state === 'unknown')
1468
1471
  words.push(say('figure.text.overhang-unknown', where(own)));
1472
+ if (own?.contact === 'off')
1473
+ words.push(say('figure.text.overhang-off', where(own)));
1469
1474
  const held = (asset.joints ?? []).filter(j => 'part' in j.body0 && j.body0.part === part).map(j => figureFaceOverhangOf(asset, j.body1.part, shown.inputs));
1470
- const past = held.filter((o) => o?.state === 'out' || o?.state === 'possible');
1475
+ const past = held.filter((o) => o?.state === 'out' || o?.state === 'possible' || o?.contact === 'off');
1471
1476
  if (past.length)
1472
1477
  words.push(say('figure.text.overhang-held', { parts: past.map(o => partLabelOf(o.part)).join(', ') }));
1473
1478
  }