@things-factory/figure-ui 10.1.245 → 10.1.247

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 (127) hide show
  1. package/client/modeller/align.ts +51 -13
  2. package/client/modeller/clips.ts +51 -1
  3. package/client/modeller/data-interface.ts +16 -0
  4. package/client/modeller/editor-styles.ts +6 -0
  5. package/client/modeller/editor.ts +112 -25
  6. package/client/modeller/fasten-form.ts +8 -1
  7. package/client/modeller/fasten-geometry.ts +24 -2
  8. package/client/modeller/figure-clips.ts +137 -4
  9. package/client/modeller/figure-fasten-dialog.ts +1 -0
  10. package/client/modeller/figure-fasten-drawing.ts +11 -5
  11. package/client/modeller/figure-links.ts +25 -0
  12. package/client/modeller/figure-material-panel.ts +54 -4
  13. package/client/modeller/figure-parts.ts +7 -2
  14. package/client/modeller/figure-pipe-panel.ts +215 -0
  15. package/client/modeller/figure-preview.ts +125 -4
  16. package/client/modeller/figure-section-editor.ts +36 -3
  17. package/client/modeller/links.ts +24 -2
  18. package/client/modeller/material-edit.ts +9 -0
  19. package/client/modeller/maturity.ts +15 -4
  20. package/client/modeller/palette.ts +9 -0
  21. package/client/modeller/pipe-path.ts +21 -0
  22. package/client/modeller/refusals.ts +66 -1
  23. package/client/modeller/section-controls.ts +2 -1
  24. package/client/modeller/section-drawing.ts +20 -1
  25. package/client/modeller/starting-shape.ts +8 -5
  26. package/dist-client/modeller/align.d.ts +28 -7
  27. package/dist-client/modeller/align.js +37 -12
  28. package/dist-client/modeller/align.js.map +1 -1
  29. package/dist-client/modeller/clips.d.ts +24 -0
  30. package/dist-client/modeller/clips.js +53 -1
  31. package/dist-client/modeller/clips.js.map +1 -1
  32. package/dist-client/modeller/data-interface.d.ts +6 -0
  33. package/dist-client/modeller/data-interface.js +17 -0
  34. package/dist-client/modeller/data-interface.js.map +1 -1
  35. package/dist-client/modeller/editor-styles.js +6 -0
  36. package/dist-client/modeller/editor-styles.js.map +1 -1
  37. package/dist-client/modeller/editor.d.ts +10 -1
  38. package/dist-client/modeller/editor.js +117 -26
  39. package/dist-client/modeller/editor.js.map +1 -1
  40. package/dist-client/modeller/fasten-form.d.ts +2 -0
  41. package/dist-client/modeller/fasten-form.js +5 -1
  42. package/dist-client/modeller/fasten-form.js.map +1 -1
  43. package/dist-client/modeller/fasten-geometry.d.ts +8 -1
  44. package/dist-client/modeller/fasten-geometry.js +22 -1
  45. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  46. package/dist-client/modeller/figure-clips.d.ts +9 -0
  47. package/dist-client/modeller/figure-clips.js +137 -4
  48. package/dist-client/modeller/figure-clips.js.map +1 -1
  49. package/dist-client/modeller/figure-fasten-dialog.js +1 -0
  50. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  51. package/dist-client/modeller/figure-fasten-drawing.d.ts +3 -1
  52. package/dist-client/modeller/figure-fasten-drawing.js +13 -5
  53. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  54. package/dist-client/modeller/figure-links.d.ts +5 -0
  55. package/dist-client/modeller/figure-links.js +22 -0
  56. package/dist-client/modeller/figure-links.js.map +1 -1
  57. package/dist-client/modeller/figure-material-panel.d.ts +2 -0
  58. package/dist-client/modeller/figure-material-panel.js +53 -4
  59. package/dist-client/modeller/figure-material-panel.js.map +1 -1
  60. package/dist-client/modeller/figure-parts.d.ts +1 -1
  61. package/dist-client/modeller/figure-parts.js +5 -1
  62. package/dist-client/modeller/figure-parts.js.map +1 -1
  63. package/dist-client/modeller/figure-pipe-panel.d.ts +29 -0
  64. package/dist-client/modeller/figure-pipe-panel.js +221 -0
  65. package/dist-client/modeller/figure-pipe-panel.js.map +1 -0
  66. package/dist-client/modeller/figure-preview.d.ts +8 -0
  67. package/dist-client/modeller/figure-preview.js +128 -4
  68. package/dist-client/modeller/figure-preview.js.map +1 -1
  69. package/dist-client/modeller/figure-section-editor.d.ts +5 -0
  70. package/dist-client/modeller/figure-section-editor.js +33 -3
  71. package/dist-client/modeller/figure-section-editor.js.map +1 -1
  72. package/dist-client/modeller/links.d.ts +6 -0
  73. package/dist-client/modeller/links.js +17 -2
  74. package/dist-client/modeller/links.js.map +1 -1
  75. package/dist-client/modeller/material-edit.d.ts +8 -0
  76. package/dist-client/modeller/material-edit.js +8 -0
  77. package/dist-client/modeller/material-edit.js.map +1 -1
  78. package/dist-client/modeller/maturity.js +16 -5
  79. package/dist-client/modeller/maturity.js.map +1 -1
  80. package/dist-client/modeller/palette.js +9 -0
  81. package/dist-client/modeller/palette.js.map +1 -1
  82. package/dist-client/modeller/pipe-path.d.ts +19 -0
  83. package/dist-client/modeller/pipe-path.js +14 -0
  84. package/dist-client/modeller/pipe-path.js.map +1 -0
  85. package/dist-client/modeller/refusals.d.ts +12 -1
  86. package/dist-client/modeller/refusals.js +67 -0
  87. package/dist-client/modeller/refusals.js.map +1 -1
  88. package/dist-client/modeller/section-controls.js +2 -1
  89. package/dist-client/modeller/section-controls.js.map +1 -1
  90. package/dist-client/modeller/section-drawing.d.ts +5 -0
  91. package/dist-client/modeller/section-drawing.js +19 -1
  92. package/dist-client/modeller/section-drawing.js.map +1 -1
  93. package/dist-client/modeller/starting-shape.d.ts +9 -2
  94. package/dist-client/modeller/starting-shape.js +8 -3
  95. package/dist-client/modeller/starting-shape.js.map +1 -1
  96. package/dist-client/tsconfig.tsbuildinfo +1 -1
  97. package/package.json +8 -8
  98. package/test/align.test.ts +28 -6
  99. package/test/browser/editing-page.js +21 -1
  100. package/test/browser/editing.check.mjs +124 -0
  101. package/test/browser/links.check.mjs +49 -8
  102. package/test/browser/modeller.check.mjs +27 -1
  103. package/test/browser/part-ids.check.mjs +60 -3
  104. package/test/browser/parts.check.mjs +2 -2
  105. package/test/browser/places-mesh.check.mjs +14 -8
  106. package/test/browser/screen.check.mjs +54 -1
  107. package/test/browser/section-page.js +7 -1
  108. package/test/browser/section.check.mjs +81 -0
  109. package/test/browser/side-panel-sweep.check.mjs +13 -0
  110. package/test/clips.test.ts +30 -1
  111. package/test/data-motion.test.ts +14 -1
  112. package/test/fasten-geometry.test.ts +13 -2
  113. package/test/joint-frames.test.ts +12 -2
  114. package/test/links.test.ts +24 -0
  115. package/test/material.test.ts +15 -1
  116. package/test/maturity.test.ts +19 -1
  117. package/test/palette-icons.test.ts +2 -1
  118. package/test/pipe-panel.test.ts +33 -0
  119. package/test/refusals.test.ts +29 -0
  120. package/test/revision.test.ts +1 -1
  121. package/test/section-editor.test.ts +17 -1
  122. package/translations/en.json +86 -3
  123. package/translations/es.json +86 -3
  124. package/translations/ja.json +86 -3
  125. package/translations/ko.json +86 -3
  126. package/translations/ms.json +86 -3
  127. package/translations/zh.json +86 -3
@@ -1,5 +1,5 @@
1
- import { figureOccupiedBoxesOf, figureWritersOf } from '@hatiolab/figure-model'
2
- import type { FigureInputChange, FigureWorldBox } from '@hatiolab/figure-model'
1
+ import { figureHangingChainOf, figureOccupiedBoxesOf, figureWritersOf } from '@hatiolab/figure-model'
2
+ import type { FigureAsset, FigureInputChange, FigurePartEdit, FigureWorldBox } from '@hatiolab/figure-model'
3
3
 
4
4
  import { derivedOf, type FigureRevision } from './revision.js'
5
5
 
@@ -7,7 +7,7 @@ import type { GizmoReach } from './gizmo.js'
7
7
 
8
8
  /**
9
9
  * 맞추기 — the align tools on a part: centre on X, centre on Z, stand on the floor,
10
- * mirror on X or Z, snap to the grid.
10
+ * snap to the grid; and whether a mirrored copy is offered.
11
11
  *
12
12
  * The asset's frame has the floor at y 0 and X and Z centred on 0, so centring puts the part's box centre on 0 and the floor puts its lowest point on
13
13
  * y 0. The part's box is its evaluated, rotated extent (`figureOccupiedBoxesOf`), so a turned part lands by what it
@@ -72,20 +72,58 @@ export function alignChanges(revision: FigureRevision, part: string, reach: Gizm
72
72
  }
73
73
 
74
74
  /**
75
- * Where a mirrored copy goes: the same distance on the other side of the centre plane. The copy is made by
76
- * `duplicate-part` first; this is the position the copy is then given.
75
+ * Mirroring a part that already sits on the centre plane would put the copy inside it, so it is not offered. The copy
76
+ * itself is figure-model's `mirror-part`, which places it -- fastened or not -- and refuses what it cannot mirror.
77
77
  */
78
- export function mirrorChanges(revision: FigureRevision, part: string, copy: string, axis: 'x' | 'z'): FigureInputChange[] {
78
+ export function canMirror(revision: FigureRevision, part: string, axis: 'x' | 'z'): boolean {
79
79
  const box = boxOf(revision, part)
80
- if (!box) return []
81
- const id = `${part}.position.${axis}`
82
- return [{ kind: 'set-design', input: `${copy}.position.${axis}`, unit: 'mm', value: Math.round((revision.asset.designInputs[id] ?? 0) - 2 * box.centre[axis]) }]
80
+ return !!box && Math.abs(box.centre[axis]) >= 0.5
83
81
  }
84
82
 
85
- /** Mirroring a part that already sits on the centre plane would put the copy inside it, so it is refused. */
86
- export function canMirror(revision: FigureRevision, part: string, axis: 'x' | 'z', reach: GizmoReach): boolean {
87
- const box = reach.translate ? boxOf(revision, part) : undefined
88
- return !!box && Math.abs(box.centre[axis]) >= 0.5
83
+ /** How a mirrored copy is made: with what hangs from the part, under which names, turning as a mirror or as the original. */
84
+ export interface MirrorForm {
85
+ axis: 'x' | 'z'
86
+ chain: boolean
87
+ /** The beginning swapped in the copies' names (\`fl\` → \`fr\`); empty, the copies take \`-copy\`. */
88
+ from: string
89
+ to: string
90
+ keepMotion: boolean
91
+ }
92
+
93
+ /* The beginning a mirror across each axis swaps, by the figure's faces: X is its left and right, Z its front and back. */
94
+ const SWAPS: Record<'x' | 'z', Record<string, string>> = { x: { l: 'r', r: 'l' }, z: { f: 'b', b: 'f' } }
95
+
96
+ /**
97
+ * What a mirror of this part asks before it is made: the parts hanging from it (a leg from its hip), whether any of them
98
+ * moves, and the form started -- the chain taken, the names' beginning guessed from the part's own (\`fl-hip\` across Z
99
+ * is \`bl-\`, across X \`fr-\`, by the figure's faces), the motion mirrored. A part with nothing hanging and nothing
100
+ * moving asks nothing.
101
+ */
102
+ export function mirrorPlanOf(asset: FigureAsset, part: string, axis: 'x' | 'z'): { hanging: string[]; moves: boolean; asks: boolean; form: MirrorForm } {
103
+ const chain = figureHangingChainOf(asset, part)
104
+ const moves = (asset.joints ?? []).some(j => chain.includes(j.body1.part) && !!j.motion?.length)
105
+ const head = /^([a-zA-Z]{1,3})-/.exec(part)?.[1] ?? ''
106
+ const swapped = [...head].map(c => SWAPS[axis][c.toLowerCase()] ?? c).join('')
107
+ const named = head && swapped !== head && chain.every(p => p.startsWith(`${head}-`))
108
+ const hanging = chain.slice(1)
109
+ return {
110
+ hanging,
111
+ moves,
112
+ asks: hanging.length > 0 || moves,
113
+ form: { axis, chain: true, from: named ? `${head}-` : '', to: named ? `${swapped}-` : '', keepMotion: false }
114
+ }
115
+ }
116
+
117
+ /** The one edit the form comes to. */
118
+ export function mirrorEditOf(part: string, form: MirrorForm): FigurePartEdit {
119
+ return {
120
+ kind: 'mirror-part',
121
+ target: part,
122
+ axis: form.axis,
123
+ ...(form.chain ? { chain: true } : {}),
124
+ ...(form.from && form.to && form.from !== form.to ? { prefix: { from: form.from, to: form.to } } : {}),
125
+ ...(form.keepMotion ? { keepMotion: true } : {})
126
+ }
89
127
  }
90
128
 
91
129
  /**
@@ -1,4 +1,4 @@
1
- import { driverValue } from '@hatiolab/figure-model'
1
+ import { driverValue, figureShiftedDriverKeys } from '@hatiolab/figure-model'
2
2
  import type { FigureAsset, FigureDriver, FigureInputChange } from '@hatiolab/figure-model'
3
3
 
4
4
  /*
@@ -111,12 +111,62 @@ export function withKey(keys: readonly ClipKey[], key: ClipKey): ClipKey[] {
111
111
  return [...keys.filter(k => k.seconds !== key.seconds), key].sort((a, b) => a.seconds - b.seconds)
112
112
  }
113
113
 
114
+ /**
115
+ * A key typed where it stands: its moment, its value, or both. The first key stays at 0 (a track starts there); a moment
116
+ * outside the clip, or on another key's, is refused (null) rather than taking that key's place unseen.
117
+ */
118
+ export function withKeyChanged(keys: readonly ClipKey[], index: number, patch: Partial<ClipKey>, duration: number): ClipKey[] | null {
119
+ const was = keys[index]
120
+ if (!was) return null
121
+ const next = { seconds: patch.seconds ?? was.seconds, value: patch.value ?? was.value }
122
+ if (!Number.isFinite(next.seconds) || !Number.isFinite(next.value)) return null
123
+ if (index === 0 && next.seconds !== 0) return null
124
+ if (next.seconds < 0 || next.seconds > duration) return null
125
+ if (keys.some((k, i) => i !== index && k.seconds === next.seconds)) return null
126
+ return keys.map((k, i) => (i === index ? next : k)).sort((a, b) => a.seconds - b.seconds)
127
+ }
128
+
114
129
  /** A track's keys without one; a track keeps at least its two ends, as the format asks. */
115
130
  export function withoutKey(keys: readonly ClipKey[], index: number): ClipKey[] | null {
116
131
  if (keys.length <= 2 || index === 0) return null
117
132
  return keys.filter((_, i) => i !== index)
118
133
  }
119
134
 
135
+ /** A name no clip has, for a copy: `walk-copy`, then `walk-copy-2`, … */
136
+ export function freeClipNameOf(asset: FigureAsset, clip: string): string {
137
+ const taken = new Set((asset.drivers ?? []).map(d => d.clip))
138
+ const base = `${clip}-copy`
139
+ if (!taken.has(base)) return base
140
+ let n = 2
141
+ while (taken.has(`${base}-${n}`)) n++
142
+ return `${base}-${n}`
143
+ }
144
+
145
+ /** The controls a track of `clip` can be copied onto: those it plays already, and those it may take. */
146
+ export const copyTargetsOf = (asset: FigureAsset, clip: string): string[] => [
147
+ ...new Set([...(asset.drivers ?? []).filter(d => d.clip === clip).map(d => d.state), ...undrivenOf(asset, clip)])
148
+ ]
149
+
150
+ /** Whether a track can be moved round its loop: one that wraps and does not add up (figure-model's rule). */
151
+ export const shiftable = (track: ClipTrack) => track.loop === 'wrap' && !track.accumulates
152
+
153
+ /**
154
+ * A track copied onto `target` -- itself, or another control -- played `seconds` later round its loop (robot dog
155
+ * 2026-11-03: a leg's swing half a period after another's). One edit; a track the clip plays on `target` is replaced.
156
+ */
157
+ export function copiedTrackEdit(asset: FigureAsset, clip: Clip, track: ClipTrack, target: string, seconds: number) {
158
+ const driver = (asset.drivers ?? []).find(d => d.clip === clip.name && d.state === track.state)!
159
+ return {
160
+ kind: 'set-driver' as const,
161
+ state: target,
162
+ clip: clip.name,
163
+ keys: seconds ? figureShiftedDriverKeys(driver, seconds) : track.keys,
164
+ loop: track.loop,
165
+ interpolation: track.interpolation,
166
+ accumulates: track.accumulates
167
+ }
168
+ }
169
+
120
170
  /** The edit that writes a track back. */
121
171
  export const trackEdit = (clip: Clip, track: ClipTrack, patch: Partial<Pick<ClipTrack, 'keys' | 'loop' | 'interpolation'>> = {}) => ({
122
172
  kind: 'set-driver' as const,
@@ -390,6 +390,22 @@ export function sampleOf(
390
390
  /** The rows a person gives a value by hand, as a board's saved values: one value, read by a binding, not inside a list. */
391
391
  export const valueRowsOf = (asset: FigureAsset): FigureDataRow[] => schemaRowsOf(asset).filter(r => isValue(r.node) && r.bindings.length > 0 && !r.path.includes('[]'))
392
392
 
393
+ /**
394
+ * What the data gives a row, read the way a placed figure reads its data: when it is there, it is what the figure shows,
395
+ * and a value saved by hand for the row is not read (things-scene's FigureDataReader: live data first). Undefined when
396
+ * the data leaves the row out, or gives it null.
397
+ */
398
+ export function dataValueAt(data: unknown, path: string): unknown {
399
+ const steps = parseFigureAccessor(path)
400
+ if (!steps) return undefined
401
+ let at: unknown = data
402
+ for (const step of steps) {
403
+ if (at === null || typeof at !== 'object') return undefined
404
+ at = 'field' in step ? (at as Record<string, unknown>)[step.field] : (at as unknown[])[step.index]
405
+ }
406
+ return at === null ? undefined : at
407
+ }
408
+
393
409
  /** A value typed or chosen for a row, as its kind reads it: a number (NaN when empty), true or false, or the text. */
394
410
  export function typedValueOf(node: FigureDataNode, typed: string): FigureDataValue {
395
411
  if (node.kind === 'number') return typed.trim() === '' ? Number.NaN : Number(typed)
@@ -447,4 +447,10 @@ export const EditorStyles = css`
447
447
  color: var(--md-sys-color-on-surface-variant);
448
448
  line-height: 1.5;
449
449
  }
450
+ /* A mirror asked about: under its row, the panel's own field rows (what comes with it, its names, how it turns). */
451
+ div[mirror-form] {
452
+ display: grid;
453
+ gap: 6px;
454
+ margin: 4px 0 8px;
455
+ }
450
456
  `
@@ -8,7 +8,7 @@ import { openPopup } from '@operato/layout'
8
8
  import { OxPrompt } from '@operato/popup'
9
9
  import { customElement, property, state } from 'lit/decorators.js'
10
10
  import { live } from 'lit/directives/live.js'
11
- import { parseFigureAsset, figureFastenAxesOf, figurePartFaceOf, PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figurePartIdFor, figureRecipeIdFor, figureSizeEditOf, figureSizeEditorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, type FigurePlaneContact, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang } from '@hatiolab/figure-model'
11
+ import { parseFigureAsset, figureFastenAxesOf, figurePartFaceOf, PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figurePartIdFor, figureRecipeIdFor, figureSizeEditOf, figureSizeEditorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, type FigurePlaneContact, figureLayDownOf, figureHangingChainOf, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang } from '@hatiolab/figure-model'
12
12
  import type { RecipeTemplate } from './recipes.js'
13
13
  import './figure-recipe-catalog.js'
14
14
  import type { FigureAsset, FigureAuthoringAction, FigureEvaluatedShape, FigureInputChange, FigurePartEdit, FigurePrimitiveCreation, FigureProposalStep, FigureRecipe, FigureRecipeValue, FigurePlacement, FigureFaceMappingNeed, FigureFaceRemap } from '@hatiolab/figure-model'
@@ -19,6 +19,7 @@ import './canvas.js'
19
19
  import './figure-parts.js'
20
20
  import './figure-material-panel.js'
21
21
  import './figure-section-panel.js'
22
+ import './figure-pipe-panel.js'
22
23
  import './figure-section-editor.js'
23
24
  import { faceHighlightOf, rowWordsOf } from './command-preview.js'
24
25
  import { CommandPreview, NO_WORDS } from './command-preview-controller.js'
@@ -47,7 +48,7 @@ import { groupFields, ownerOf, partLabelOf, repeatOf, type FieldLike, type Field
47
48
  import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
48
49
  import { EditorStyles } from './editor-styles.js'
49
50
  import { ScrollbarStyles } from '@operato/styles'
50
- import { alignChanges, canMirror, mirrorChanges, type AlignTool } from './align.js'
51
+ import { alignChanges, canMirror, mirrorEditOf, mirrorPlanOf, type AlignTool, type MirrorForm } from './align.js'
51
52
  import type { FigurePaletteKind, FigurePartRow } from './figure-parts.js'
52
53
  import { paletteRecipe } from './palette.js'
53
54
  import { meshChoicesOf, meshPartOf } from './mesh-import.js'
@@ -65,7 +66,7 @@ import { nudgeOf, placementOf } from './stage-model.js'
65
66
  type HeldValues = { position?: number[]; rotation?: number[]; size?: (number | undefined)[]; sizeRuled?: boolean }
66
67
  import { gateFindingStyles, renderGateFinding } from './gate-findings.js'
67
68
  import type { FigureGateFinding } from '../types.js'
68
- import { editRefusalOf, partNameRefusalOf, sizeRangeFitsOf, partRefusalOf, placedRefusalOf, recipeRefusalOf, removalNoticeOf } from './refusals.js'
69
+ import { defaultsMovedOf, editRefusalOf, partNameRefusalOf, sizeRangeFitsOf, partRefusalOf, placedRefusalOf, recipeRefusalOf, removalNoticeOf } from './refusals.js'
69
70
  import type { FigureRenderPart } from './renderer.js'
70
71
  import type { FigureBudgetLine } from './maturity.js'
71
72
  import { HelpToggles } from './help.js'
@@ -156,6 +157,8 @@ export class FigureEditor extends LitElement {
156
157
  * the tab or the middle changes under it.
157
158
  */
158
159
  @state() private fastening = false
160
+ /** A mirrored copy being asked about (with what hangs from it, under which names), for the part selected. */
161
+ @state() private mirroring?: { part: string; form: MirrorForm; hanging: string[]; moves: boolean }
159
162
  private fastenPopup?: { close: () => void }
160
163
  private fastenWindow?: HTMLElement & {
161
164
  revision?: FigureRevision
@@ -388,11 +391,12 @@ export class FigureEditor extends LitElement {
388
391
  return code ? editRefusalOf(error, (key, params) => i18next.t(key, params)) : error instanceof Error ? error.message : String(error)
389
392
  }
390
393
  private run(action: () => unknown) {
394
+ const before = this.shown()?.asset
391
395
  try {
392
396
  action()
393
397
  this.error = ''
394
- /* The next step stands on its own: what the last removal let go of is no longer the news. */
395
- this.notice = ''
398
+ /* The next step stands on its own: what the last removal let go of is no longer the news -- what this one moved is. */
399
+ this.notice = defaultsMovedOf(before, this.shown()?.asset, (key, params) => i18next.t(key, params))
396
400
  this.changed()
397
401
  } catch (error) {
398
402
  if (this.refusals.askClipLoss(error, () => this.run(action))) return
@@ -763,16 +767,54 @@ export class FigureEditor extends LitElement {
763
767
  if (changes.length) this.session!.apply(changes)
764
768
  })
765
769
  }
766
- /** A mirrored copy: duplicate, then place the copy on the other side — two steps. */
770
+ /**
771
+ * A mirrored copy, one step: figure-model's mirror-part, a fastened part's record reflected with it. A part with parts
772
+ * hanging from it, or moving, is asked about first -- what comes with it, its names, how it turns (robot dog 2026-11-03).
773
+ */
767
774
  private mirror(axis: 'x' | 'z') {
775
+ const plan = mirrorPlanOf(this.shown()!.asset, this.selected, axis)
776
+ if (plan.asks) this.mirroring = { part: this.selected, form: plan.form, hanging: plan.hanging, moves: plan.moves }
777
+ else this.mirrorWith(this.selected, plan.form)
778
+ }
779
+ private mirrorWith(part: string, form: MirrorForm) {
768
780
  this.run(() => {
769
- const id = this.selected
770
- const copy = this.session!.edit({ kind: 'duplicate-part', target: id }).made?.[0]
771
- if (!copy) return
772
- this.session!.apply(mirrorChanges(this.shown()!, id, copy, axis))
773
- this.selected = copy
781
+ /* The copy of the part itself, the first of what the edit made. */
782
+ const copy = this.session!.edit(mirrorEditOf(part, form)).made?.[0]
783
+ this.mirroring = undefined
784
+ if (copy) this.selected = copy
774
785
  })
775
786
  }
787
+ private renderMirrorForm(unavailable: boolean) {
788
+ const m = this.mirroring
789
+ if (!m || m.part !== this.selected) return nothing
790
+ const set = (patch: Partial<MirrorForm>) => (this.mirroring = { ...m, form: { ...m.form, ...patch } })
791
+ const text = (key: 'from' | 'to') => (e: Event) => set({ [key]: (e.target as HTMLInputElement).value.trim() })
792
+ return html`<div mirror-form>
793
+ ${m.hanging.length
794
+ ? html`<label toggle>
795
+ <input type="checkbox" .checked=${m.form.chain} @change=${(e: Event) => set({ chain: (e.target as HTMLInputElement).checked })} />
796
+ ${i18next.t('figure.label.mirror-with-hanging', { count: m.hanging.length })}
797
+ </label>`
798
+ : nothing}
799
+ <div form>
800
+ <span>${i18next.t('figure.label.mirror-names')}</span>
801
+ <div pair>
802
+ <input aria-label=${i18next.t('figure.label.mirror-names-from')} placeholder=${i18next.t('figure.label.mirror-names-from')} .value=${m.form.from} @change=${text('from')} />
803
+ <input aria-label=${i18next.t('figure.label.mirror-names-to')} placeholder=${i18next.t('figure.label.mirror-names-to')} .value=${m.form.to} @change=${text('to')} />
804
+ </div>
805
+ </div>
806
+ ${m.moves
807
+ ? html`<label toggle title=${i18next.t('figure.text.mirror-keep-motion-explained')}>
808
+ <input type="checkbox" .checked=${m.form.keepMotion} @change=${(e: Event) => set({ keepMotion: (e.target as HTMLInputElement).checked })} />
809
+ ${i18next.t('figure.label.mirror-keep-motion')}
810
+ </label>`
811
+ : nothing}
812
+ <div pair>
813
+ <button act ?disabled=${unavailable} @click=${() => this.mirrorWith(m.part, m.form)}>${i18next.t('figure.button.mirror-' + m.form.axis)}</button>
814
+ <button act @click=${() => (this.mirroring = undefined)}>${i18next.t('figure.button.cancel')}</button>
815
+ </div>
816
+ </div>`
817
+ }
776
818
  /**
777
819
  * The one-off "put it on the plane", by the figure's placement (designer 2026-09-27): figure-model's rule
778
820
  * (`figurePlaneAlignChanges`) -- a floor figure's part to the floor by its bottom, a ceiling one's to the ceiling by
@@ -790,16 +832,29 @@ export class FigureEditor extends LitElement {
790
832
  }
791
833
  })
792
834
  }
835
+ /*
836
+ A part with parts hanging from it, or one on the mounting plane, is put down as a whole: the lowest point of all of it,
837
+ as it stands, reaches the plane -- a body by its feet (robot dog 2026-11-03).
838
+ */
839
+ private laysDownWhole(asset: FigureAsset, part: string) {
840
+ return figureHangingChainOf(asset, part).length > 1 || !!asset.joints?.some(j => 'plane' in j.body0 && 'part' in j.body1 && j.body1.part === part)
841
+ }
793
842
  private renderPlaneAlign(shown: FigureRevision, unavailable: boolean) {
794
843
  const placement = shown.asset.placement ?? 'floor'
795
844
  /* A tilted part touches the plane at a point or an edge, a ball at its lowest; the tooltip says so rather than promise a face (designer 2026-09-27). */
796
- const contact = this.planeContactOf(shown, this.selected)
797
- const told = contact === 'tilted' || contact === 'extreme' ? `-${contact}` : ''
845
+ const contact = this.laysDownWhole(shown.asset, this.selected) ? 'hanging' : this.planeContactOf(shown, this.selected)
846
+ const told = contact === 'tilted' || contact === 'extreme' || contact === 'hanging' ? `-${contact}` : ''
798
847
  return html`<button plane-align=${placement} ?disabled=${unavailable} title=${i18next.t(`figure.text.align-to-${placement}${told}`)}
799
848
  @click=${() => this.alignToPlane()}>${i18next.t(`figure.button.align-to-${placement}`)}</button>`
800
849
  }
801
850
  private alignToPlane() {
802
851
  const asset = this.shown()!.asset
852
+ if (this.laysDownWhole(asset, this.selected)) {
853
+ const { command, refusal } = figureLayDownOf(asset, this.selected)
854
+ if (refusal) this.error = i18next.t(`figure.text.align-refused-${refusal.toLowerCase().replace(/_/g, '-')}`, { part: partLabelOf(this.selected) })
855
+ else if (command) this.run(() => this.session!.command(command))
856
+ return
857
+ }
803
858
  const { changes, contact, refusal } = figurePlaneAlignChanges(asset, this.selected)
804
859
  if (refusal) {
805
860
  this.error = i18next.t(`figure.text.align-refused-${refusal.toLowerCase().replace(/_/g, '-')}`, { part: partLabelOf(this.selected) })
@@ -811,23 +866,33 @@ export class FigureEditor extends LitElement {
811
866
  private renderTools(unavailable: boolean) {
812
867
  const reach = this.reachOf(this.selected)
813
868
  const shown = this.shown()
814
- if (!this.selected || !reach.translate || !shown) return null
869
+ if (!this.selected || !shown) return null
815
870
  /*
816
871
  * In words, two rows by what they do (user 2026-10-01: 「무엇을 하는 것인지 모르겠다」): moving the part to the figure's
817
872
  * centre lines, onto its plane or onto the grid; and making a mirrored copy, which makes a part and moves nothing.
818
873
  */
819
874
  const tool = (name: AlignTool, label: string, title: string) =>
820
875
  html`<button ?disabled=${unavailable} title=${i18next.t(title)} @click=${() => this.align(name)}>${i18next.t(label)}</button>`
821
- const mirrors = (['x', 'z'] as const).map(axis => ({ axis, able: canMirror(shown, this.selected, axis, reach) }))
822
- return html`<div vector-row>
823
- <label>${i18next.t('figure.label.tools')}</label>
824
- <div tools>
825
- ${tool('centre-x', 'figure.button.center-x', 'figure.text.center-on-x')}
826
- ${tool('centre-z', 'figure.button.center-z', 'figure.text.center-on-z')}
827
- ${this.renderPlaneAlign(shown, unavailable)}
828
- ${tool('snap', 'figure.button.snap', 'figure.text.snap-explained')}
829
- </div>
830
- </div>
876
+ /* A fastened part is not moved by these tools, but it is mirrored: the copy's fastening is reflected with it. */
877
+ const mirrors = (['x', 'z'] as const).map(axis => ({ axis, able: canMirror(shown, this.selected, axis) }))
878
+ /* On the mounting plane nothing else here moves it; putting it down writes its gap. */
879
+ const onPlane = !reach.translate && !!shown.asset.joints?.some(j => 'plane' in j.body0 && 'part' in j.body1 && j.body1.part === this.selected)
880
+ return html`${onPlane
881
+ ? html`<div vector-row>
882
+ <label>${i18next.t('figure.label.tools')}</label>
883
+ <div tools>${this.renderPlaneAlign(shown, unavailable)}</div>
884
+ </div>`
885
+ : nothing}${reach.translate
886
+ ? html`<div vector-row>
887
+ <label>${i18next.t('figure.label.tools')}</label>
888
+ <div tools>
889
+ ${tool('centre-x', 'figure.button.center-x', 'figure.text.center-on-x')}
890
+ ${tool('centre-z', 'figure.button.center-z', 'figure.text.center-on-z')}
891
+ ${this.renderPlaneAlign(shown, unavailable)}
892
+ ${tool('snap', 'figure.button.snap', 'figure.text.snap-explained')}
893
+ </div>
894
+ </div>`
895
+ : null}
831
896
  <div vector-row>
832
897
  <label>${i18next.t('figure.label.mirror-copy')}</label>
833
898
  <div tools>
@@ -838,6 +903,7 @@ export class FigureEditor extends LitElement {
838
903
  )}
839
904
  </div>
840
905
  </div>
906
+ ${this.renderMirrorForm(unavailable)}
841
907
  `
842
908
  }
843
909
  /**
@@ -912,6 +978,21 @@ export class FigureEditor extends LitElement {
912
978
  const place = groups.vectors.filter(g => g.kind === 'vector' && g.name !== 'size')
913
979
  const fastened = !!this.asset()?.joints?.some(j => j.body1.part === ownerOf(this.selected))
914
980
  const placeLocked = fastened && place.every(g => g.kind === 'vector' && (['x', 'y', 'z'] as const).every(axis => !g.cells[axis]))
981
+ /* A pipe's measures are its section, wall and path, in its own panel (ADR-0100 §16.2): not raw fields of the part. */
982
+ const body = geometry.find(g => g.id === this.selected) as { id: string; shape?: FigureEvaluatedShape } | undefined
983
+ if (body?.shape?.sweep)
984
+ return this.renderLinks(
985
+ unavailable,
986
+ 'shape',
987
+ html`<section slot="size" group-fields>
988
+ <h3>${i18next.t('figure.label.group-pipe')}</h3>
989
+ <!-- Its command reaches the links' own handler: it is slotted in them. -->
990
+ <figure-pipe-panel .body=${body} ?locked=${unavailable}></figure-pipe-panel>
991
+ </section>
992
+ ${place.length && !placeLocked
993
+ ? html`<div slot="place" group-fields>${place.map(g => this.renderVector(g, unavailable, held))}${this.renderHeldAxes(place)}</div>`
994
+ : nothing}`
995
+ )
915
996
  return this.renderLinks(
916
997
  unavailable,
917
998
  'shape',
@@ -1065,12 +1146,18 @@ export class FigureEditor extends LitElement {
1065
1146
  this.holdAction(null)
1066
1147
  this.notice = ''
1067
1148
  const hadSize = this.hasSize()
1149
+ const before = this.shown()?.asset
1068
1150
  try {
1069
1151
  this.session!.command(action)
1070
1152
  this.error = ''
1071
1153
  this.faceMapping = undefined
1072
- /* What the command did besides what was asked, in one line: the figure's size declared to follow (ADR ruling Q1). */
1154
+ /*
1155
+ What the command did besides what was asked, in one line: the figure's size declared to follow (ADR ruling Q1), a
1156
+ default pose its new range moved.
1157
+ */
1073
1158
  if (action.kind === 'follow-size' && !hadSize) this.notice = this.sizeDeclaredWords()
1159
+ const moved = defaultsMovedOf(before, this.shown()?.asset, (key, params) => i18next.t(key, params))
1160
+ if (moved) this.notice = [this.notice, moved].filter(Boolean).join(' ')
1074
1161
  this.changed()
1075
1162
  return true
1076
1163
  } catch (error) {
@@ -1,7 +1,7 @@
1
1
  import { FIGURE_FACES, figureFastenKeptGapOf, figureFastenTargetAt, figurePlaneFacingOf, figureSurfaceKeptPlaceOf } from '@hatiolab/figure-model'
2
2
  import type { FigureAuthoringAction, FigureFace, FigureSurfacePlace } from '@hatiolab/figure-model'
3
3
 
4
- import { faceViewOf, type FaceView, type FastenMode, type FastenTarget } from './fasten-geometry.js'
4
+ import { faceViewOf, facesAsDrawnOf, type FaceView, type FastenMode, type FastenTarget } from './fasten-geometry.js'
5
5
  import type { FastenChange } from './figure-fasten-drawing.js'
6
6
  import {
7
7
  alignOf,
@@ -192,9 +192,16 @@ export interface FastenPlan {
192
192
  face?: { part: string; face: string }
193
193
  /** On a point of the target's curved surface: which surface, about which axis, and where (ADR-0140 3.9). */
194
194
  surface?: { form: 'around' | 'toward'; axis: Axis; place: FigureSurfacePlace }
195
+ /** The target's own face at each of the drawing's places, as the target is drawn (\`facesAsDrawnOf\`). */
196
+ drawnFaces?: Record<FigureFace, FigureFace>
195
197
  }
196
198
 
197
199
  export function fastenPlanOf(revision: FigureRevision, part: string, f: FastenForm): FastenPlan {
200
+ const plan = fastenPlanWithout(revision, part, f)
201
+ return f.target && !f.toPlane ? { ...plan, drawnFaces: facesAsDrawnOf(revision, f.target) } : plan
202
+ }
203
+
204
+ function fastenPlanWithout(revision: FigureRevision, part: string, f: FastenForm): FastenPlan {
198
205
  const asset = revision.asset
199
206
  const theirFaces = f.target ? facesOf(asset, f.target) : FACES
200
207
  const targetFace = (theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0]) as FigureFace
@@ -1,5 +1,5 @@
1
- import { figureFaceInsideAlignOf, figureFaceMeasureOf, figureFaceOverhangAt, figureFastenPlaceOf } from '@hatiolab/figure-model'
2
- import type { FigureAsset, FigureFaceMeasure, FigureFacing, FigureFastenAlignment } from '@hatiolab/figure-model'
1
+ import { FIGURE_FACES, figureFaceInsideAlignOf, figureFaceMeasureOf, figureFaceOverhangAt, figureFastenPlaceOf } from '@hatiolab/figure-model'
2
+ import type { FigureAsset, FigureFace, FigureFaceMeasure, FigureFacing, FigureFastenAlignment } from '@hatiolab/figure-model'
3
3
 
4
4
  import { alignAxesOf, type Alignment } from './links.js'
5
5
  import type { FigureRevision } from './revision.js'
@@ -16,6 +16,28 @@ type Axis = 'x' | 'y' | 'z'
16
16
  export type FastenMode = FigureFacing
17
17
  export type FastenTarget = { part: string; face: string } | 'plane'
18
18
 
19
+ /* Which way each of the six faces looks, along its axis, in a part's own frame. */
20
+ const OUTWARD: Record<FigureFace, number> = { left: -1, right: 1, bottom: -1, top: 1, back: -1, front: 1 }
21
+ const SIX = Object.keys(OUTWARD) as FigureFace[]
22
+
23
+ /**
24
+ * The target's own face that stands at each of the drawing's six places as the part is drawn (robot dog, 2026-11-03):
25
+ * a face is named in its part's own frame, so a cylinder laid on its side has its 「아래」 on the side, and the box drawn
26
+ * upright showed the name where the face is not. Each place -- up, down, right, left, toward the eye, away -- shows the
27
+ * own face whose outward normal, turned by the part's pose, points most that way. Unturned, each is itself.
28
+ */
29
+ export function facesAsDrawnOf(revision: FigureRevision, target: string): Record<FigureFace, FigureFace> {
30
+ const drawn = (revision.geometry as Array<{ id: string; pose?: { r: number[][] } }>).find(g => g.id === target)?.pose?.r
31
+ const same = Object.fromEntries(SIX.map(f => [f, f])) as Record<FigureFace, FigureFace>
32
+ if (!drawn) return same
33
+ const index = { x: 0, y: 1, z: 2 } as const
34
+ /* World direction of an own face: its column of the pose, outward. */
35
+ const world = (f: FigureFace) => [0, 1, 2].map(i => OUTWARD[f] * drawn[i]![index[FIGURE_FACES[f] as Axis]]!)
36
+ const along = (place: FigureFace) => [0, 1, 2].map(i => (i === index[FIGURE_FACES[place] as Axis] ? OUTWARD[place] : 0))
37
+ const dot = (a: number[], b: number[]) => a.reduce((sum, v, i) => sum + v * b[i]!, 0)
38
+ return Object.fromEntries(SIX.map(place => [place, SIX.reduce((best, f) => (dot(world(f), along(place)) > dot(world(best), along(place)) ? f : best))])) as Record<FigureFace, FigureFace>
39
+ }
40
+
19
41
  /**
20
42
  * The face seen from above: its two axes across (as the form's alignment names them), the face's size on each as a
21
43
  * fastening lines up to it -- none for a plane with no declared size -- the part's real extent on each, whether its