@things-factory/figure-ui 10.1.72 → 10.1.74

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 (39) hide show
  1. package/client/modeller/figure-v3-links.ts +12 -5
  2. package/client/modeller/figure-v3-parts.ts +1 -0
  3. package/client/modeller/figure-v3-properties.ts +3 -2
  4. package/client/modeller/v3-editor.ts +86 -2
  5. package/client/modeller/v3-figure-facts.ts +26 -2
  6. package/client/modeller/v3-links.ts +50 -0
  7. package/client/modeller/v3-recipes.ts +16 -1
  8. package/dist-client/modeller/figure-v3-links.js +9 -3
  9. package/dist-client/modeller/figure-v3-links.js.map +1 -1
  10. package/dist-client/modeller/figure-v3-parts.js +1 -0
  11. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  12. package/dist-client/modeller/figure-v3-properties.js +3 -2
  13. package/dist-client/modeller/figure-v3-properties.js.map +1 -1
  14. package/dist-client/modeller/v3-clips.d.ts +1 -1
  15. package/dist-client/modeller/v3-editor.d.ts +9 -0
  16. package/dist-client/modeller/v3-editor.js +88 -1
  17. package/dist-client/modeller/v3-editor.js.map +1 -1
  18. package/dist-client/modeller/v3-figure-facts.d.ts +9 -0
  19. package/dist-client/modeller/v3-figure-facts.js +23 -2
  20. package/dist-client/modeller/v3-figure-facts.js.map +1 -1
  21. package/dist-client/modeller/v3-links.d.ts +17 -0
  22. package/dist-client/modeller/v3-links.js +38 -0
  23. package/dist-client/modeller/v3-links.js.map +1 -1
  24. package/dist-client/modeller/v3-recipes.js +21 -1
  25. package/dist-client/modeller/v3-recipes.js.map +1 -1
  26. package/dist-client/tsconfig.fui.tsbuildinfo +1 -1
  27. package/dist-client/tsconfig.tsbuildinfo +1 -1
  28. package/package.json +5 -5
  29. package/test/browser/modeller-page.js +40 -1
  30. package/test/browser/modeller.check.mjs +65 -3
  31. package/test/v3-fields.test.ts +2 -1
  32. package/test/v3-links.test.ts +25 -0
  33. package/test/v3-properties.test.ts +13 -0
  34. package/test/v3-recipes.test.ts +7 -0
  35. package/translations/en.json +11 -2
  36. package/translations/ja.json +10 -1
  37. package/translations/ko.json +11 -2
  38. package/translations/ms.json +10 -1
  39. package/translations/zh.json +10 -1
@@ -27,6 +27,7 @@ import {
27
27
  shownWhenOf,
28
28
  visibilityControlsOf,
29
29
  placeKeptOf,
30
+ fasteningOf,
30
31
  hasInstanceSize,
31
32
  holdsThings,
32
33
  leadersFor,
@@ -49,7 +50,7 @@ import { HelpToggles } from './v3-help.js'
49
50
  const FACES = Object.keys(V3_FACES) as V3Face[]
50
51
 
51
52
  const freshFasten = () => ({
52
- face: 'bottom' as V3Face,
53
+ face: 'bottom' as V3Face | 'centre',
53
54
  toPlane: false,
54
55
  target: '',
55
56
  targetFace: 'top' as V3Face,
@@ -131,7 +132,9 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
131
132
  */
132
133
  willUpdate(changed: Map<string, unknown>) {
133
134
  if (changed.has('partId') && changed.get('partId') !== undefined) {
134
- this.fasten = freshFasten()
135
+ /* A part's own fastening is not another part's choices: the form opens on it (walk 1, 2026-09-26). */
136
+ const own = this.asset && this.partId ? fasteningOf(this.asset, this.partId) : null
137
+ this.fasten = own ? ({ ...freshFasten(), ...own } as ReturnType<typeof freshFasten>) : freshFasten()
135
138
  this.motion = freshMotion()
136
139
  this.place = freshPlace()
137
140
  this.row = freshRow()
@@ -355,8 +358,12 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
355
358
  /* The faces are the ones each shape declares: a part is fastened by one of its outer faces, to any face of the target. */
356
359
  /* On the mounting plane only the faces it takes: a bottom or a top on the floor, a back on a wall. */
357
360
  const plane = mountingPlaneOf(asset)
358
- const mineFaces = facesOf(asset, this.partId).filter(face => FACES.includes(face as V3Face) && (!f.toPlane || plane.faces.includes(face as V3Face)))
359
- const myFace = mineFaces.includes(f.face) ? f.face : (mineFaces[0] as V3Face)
361
+ /* Its outer faces, and its centre -- a roller spins about its middle (ADR-0093); the mounting plane takes a face. */
362
+ const mineFaces: Array<V3Face | 'centre'> = [
363
+ ...(facesOf(asset, this.partId).filter(face => FACES.includes(face as V3Face) && (!f.toPlane || plane.faces.includes(face as V3Face))) as V3Face[]),
364
+ ...(f.toPlane ? [] : (['centre'] as const))
365
+ ]
366
+ const myFace = mineFaces.includes(f.face) ? f.face : mineFaces[0]!
360
367
  const theirFaces = f.target ? facesOf(asset, f.target) : FACES
361
368
  const targetFace = theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0]!
362
369
  const aim = f.toPlane ? ('plane' as const) : { part: f.target, face: targetFace }
@@ -372,7 +379,7 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
372
379
  : nothing}
373
380
  <div form>
374
381
  <span>${i18next.t('figure.label.my-face')}</span>
375
- <select @change=${(e: Event) => (this.fasten = { ...f, face: (e.target as HTMLSelectElement).value as V3Face })}>
382
+ <select @change=${(e: Event) => (this.fasten = { ...f, face: (e.target as HTMLSelectElement).value as V3Face | 'centre' })}>
376
383
  ${mineFaces.map(face => html`<option value=${face} ?selected=${face === myFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
377
384
  </select>
378
385
  </div>
@@ -463,6 +463,7 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
463
463
  >
464
464
  <b>${b.grade}</b>
465
465
  <span>
466
+ <strong performance-cost>${i18next.t('figure.label.performance-cost')}</strong>
466
467
  ${i18next.t('figure.text.parts-budget', {
467
468
  triangles: b.triangles.toLocaleString(),
468
469
  groups: b.groups.toLocaleString(),
@@ -161,8 +161,9 @@ export class FigureV3Properties extends localize(i18next)(LitElement) {
161
161
 
162
162
  private renderMounting(facts: V3FigureFacts) {
163
163
  const mounting = facts.mounting
164
- const part = [this.pickedPart, mounting?.part, facts.parts[0]].find(one => one && facts.parts.includes(one))
165
- const face: V3Face = this.pickedFace ?? mounting?.face ?? 'bottom'
164
+ /* Chosen, or what is set, or where the figure meets its plane -- not the first part listed (walk 1). */
165
+ const part = [this.pickedPart, mounting?.part, facts.suggested?.part, facts.parts[0]].find(one => one && facts.parts.includes(one))
166
+ const face: V3Face = this.pickedFace ?? mounting?.face ?? facts.suggested?.face ?? 'bottom'
166
167
  return html`
167
168
  <div field>
168
169
  <label>${i18next.t('figure.label.mounting-face')}</label>
@@ -7,7 +7,7 @@ import { live } from 'lit/directives/live.js'
7
7
  import { PART_LIMIT, parseV3Recipe, resolveV3Recipe, v3PartCountOf, v3RecipeIdFor } from '@hatiolab/figure-model'
8
8
  import type { RecipeTemplate } from './v3-recipes.js'
9
9
  import './figure-v3-recipe-catalog.js'
10
- import type { V3Asset, V3AuthoringAction, V3InputChange, V3PartEdit, V3PrimitiveCreation, V3Recipe, V3RecipeValue } from '@hatiolab/figure-model'
10
+ import type { V3Asset, V3AuthoringAction, V3InputChange, V3PartEdit, V3PrimitiveCreation, V3ProposalStep, V3Recipe, V3RecipeValue } from '@hatiolab/figure-model'
11
11
  import type { V3ProductSession } from './v3-product-session.js'
12
12
  import { captureV3Thumbnail } from './v3-thumbnail.js'
13
13
  import { drawableV3Parts } from './v3-renderer.js'
@@ -269,6 +269,8 @@ export class FigureV3Editor extends LitElement {
269
269
  /** Reads a template's guide; given by the page. */
270
270
  @property({ attribute: false }) guideOf?: (name: string) => Promise<string>
271
271
  @property({ attribute: false }) thumbnailOf?: (name: string) => string
272
+ /** The 「보드에서 바꿀 수 있음」 form being filled for a row, before it is published (ADR-0096). */
273
+ @state() private publishing?: { part: string; label: string; min: number; max: number }
272
274
  @state() private catalogOpen = false
273
275
  /** Why the last recipe was refused, in the kernel's words. */
274
276
  @state() private recipeError = ''
@@ -1040,6 +1042,7 @@ export class FigureV3Editor extends LitElement {
1040
1042
  })
1041
1043
  : i18next.t(fits ? 'figure.text.repeat-fits-length' : 'figure.text.repeat-fixed-count')}
1042
1044
  </p>
1045
+ ${this.renderPublicPitch(asset, pitch ?? 0, unavailable)}
1043
1046
  ${asset.joints?.some(j => j.body1.part === this.selected && j.repeat)
1044
1047
  ? html`<button act ?disabled=${unavailable} @click=${() => this.run(() => this.session!.command({ kind: 'remove-repeat', part: this.selected }))}>
1045
1048
  <md-icon>close</md-icon>${i18next.t('figure.button.remove-repeat')}
@@ -1047,6 +1050,80 @@ export class FigureV3Editor extends LitElement {
1047
1050
  : nothing}
1048
1051
  </section>`
1049
1052
  }
1053
+ /** The design input a row's pitch is read from, when it is read from one (ADR-0096). */
1054
+ private pitchInputOf(asset: V3Asset, part: string): string | undefined {
1055
+ const pitch = asset.joints?.find(j => j.body1.part === part && j.repeat)?.repeat?.pitch as { from?: { of?: string; input?: string } } | number | undefined
1056
+ return typeof pitch === 'object' && pitch?.from?.of === 'input' ? pitch.from.input : undefined
1057
+ }
1058
+ /**
1059
+ * 「보드에서 바꿀 수 있음」 (ADR-0096): the row's pitch made a design input the figure publishes -- a name on the board and
1060
+ * a range the board's value is held to -- in one step; changed or withdrawn the same way. Only a row with a record.
1061
+ */
1062
+ private renderPublicPitch(asset: V3Asset, pitch: number, unavailable: boolean) {
1063
+ const part = this.selected
1064
+ if (!asset.joints?.some(j => j.body1.part === part && j.repeat)) return nothing
1065
+ const from = this.pitchInputOf(asset, part)
1066
+ const listed = from ? asset.publicInputs?.find(p => p.input === from) : undefined
1067
+ const input = from ? asset.document.model.inputs.find(i => i.id === from) : undefined
1068
+ const form =
1069
+ this.publishing?.part === part
1070
+ ? this.publishing
1071
+ : listed && input
1072
+ ? { part, label: listed.label, min: input.min, max: input.max }
1073
+ : undefined
1074
+ const open = !!form
1075
+ const edit = (key: 'label' | 'min' | 'max') => (e: Event) => {
1076
+ const target = e.target as HTMLInputElement
1077
+ const base = form ?? { part, label: '', min: 0, max: 0 }
1078
+ this.publishing = { ...base, [key]: key === 'label' ? target.value : target.valueAsNumber }
1079
+ }
1080
+ const steps = (): V3ProposalStep[] => {
1081
+ const f = form!
1082
+ const id = from ?? `${part}.pitch`
1083
+ if (listed) return [{ kind: 'set-input-range', input: id, min: f.min, max: f.max }, { kind: 'publish-input', input: id, label: f.label }] as V3ProposalStep[]
1084
+ const exists = asset.document.model.inputs.some(i => i.id === id)
1085
+ return [
1086
+ ...(exists ? [{ kind: 'set-input-range', input: id, min: f.min, max: f.max }] : [{ kind: 'add-design-input', id, unit: 'mm', min: f.min, max: f.max, value: pitch }]),
1087
+ ...(from ? [] : [{ kind: 'set-repeat', part, pitch: { from: { of: 'input', input: id } } }]),
1088
+ { kind: 'publish-input', input: id, label: f.label }
1089
+ ] as V3ProposalStep[]
1090
+ }
1091
+ const run = (list: V3ProposalStep[]) => {
1092
+ try {
1093
+ this.session!.applyRecipe(list)
1094
+ this.publishing = undefined
1095
+ this.error = ''
1096
+ this.changed()
1097
+ } catch (error) {
1098
+ this.error = error instanceof Error ? error.message : String(error)
1099
+ }
1100
+ }
1101
+ return html`<div public-pitch>
1102
+ <label toggle>
1103
+ <input
1104
+ type="checkbox"
1105
+ aria-label="figure.label.board-sets"
1106
+ .checked=${open}
1107
+ ?disabled=${unavailable}
1108
+ @change=${(e: Event) =>
1109
+ (e.target as HTMLInputElement).checked
1110
+ ? (this.publishing = { part, label: i18next.t('figure.label.repeat-gap'), min: Math.max(1, Math.round(pitch / 2)), max: Math.round(pitch * 2) })
1111
+ : listed
1112
+ ? run([{ kind: 'unpublish-input', input: from }, { kind: 'set-repeat', part, pitch }] as V3ProposalStep[])
1113
+ : (this.publishing = undefined)}
1114
+ />
1115
+ ${i18next.t('figure.label.board-sets')}
1116
+ </label>
1117
+ ${open
1118
+ ? html`<div inline-field><label>${i18next.t('figure.label.board-name')}</label><input aria-label="figure.label.board-name" .value=${form!.label} @change=${edit('label')} /></div>
1119
+ <div inline-field><label>${i18next.t('figure.label.least')} <span unit>mm</span></label><input type="number" step="any" aria-label="figure.label.least" .value=${String(form!.min)} @change=${edit('min')} /></div>
1120
+ <div inline-field><label>${i18next.t('figure.label.most')} <span unit>mm</span></label><input type="number" step="any" aria-label="figure.label.most" .value=${String(form!.max)} @change=${edit('max')} /></div>
1121
+ <button act publish-pitch ?disabled=${unavailable || !form!.label.trim()} @click=${() => run(steps())}>
1122
+ <md-icon>${listed ? 'check' : 'ios_share'}</md-icon>${i18next.t(listed ? 'figure.button.apply' : 'figure.button.publish-input')}
1123
+ </button>`
1124
+ : nothing}
1125
+ </div>`
1126
+ }
1050
1127
  /**
1051
1128
  * A pitch always goes through `set-repeat`: the command moves the longest length with it (at most `limit` copies),
1052
1129
  * which a plain input edit would not. Refused only when the authored length would already hold more copies than
@@ -1056,6 +1133,13 @@ export class FigureV3Editor extends LitElement {
1056
1133
  const input = event.target as HTMLInputElement
1057
1134
  const pitch = input.valueAsNumber
1058
1135
  if (!Number.isFinite(pitch) || pitch === field.value) return
1136
+ /* Read from a design input the board may set: the input's value changes, the row keeps reading it (ADR-0096). */
1137
+ const from = this.pitchInputOf(JSON.parse(this.session!.source()) as V3Asset, this.selected)
1138
+ if (from) {
1139
+ this.run(() => this.session!.apply([{ kind: 'set-design', input: from, unit: 'mm', value: pitch }]))
1140
+ input.value = String(this.session!.read().fields.find(i => i.id === from)?.value ?? field.value)
1141
+ return
1142
+ }
1059
1143
  try {
1060
1144
  this.session!.command({ kind: 'set-repeat', part: this.selected, pitch })
1061
1145
  this.error = ''
@@ -1228,7 +1312,7 @@ export class FigureV3Editor extends LitElement {
1228
1312
  name-field
1229
1313
  aria-label=${i18next.t('figure.label.name')}
1230
1314
  .value=${partLabelOf(this.selected)}
1231
- ?disabled=${unavailable || !!repeatOf(this.selected, view.geometry)}
1315
+ ?disabled=${unavailable || !!repeatOf(this.selected, view.geometry)?.index}
1232
1316
  @change=${(e: Event) => this.renamePart(e.target as HTMLInputElement)}
1233
1317
  />
1234
1318
  </div>
@@ -1,4 +1,4 @@
1
- import { parseV3Asset } from '@hatiolab/figure-model'
1
+ import { compileV3Asset, parseV3Asset, v3OccupiedBoxesOf, v3WritersOf } from '@hatiolab/figure-model'
2
2
  import type { V3Asset, V3Face } from '@hatiolab/figure-model'
3
3
 
4
4
  /*
@@ -14,6 +14,12 @@ export interface V3FigureFacts {
14
14
  declared: boolean
15
15
  placement?: Placement
16
16
  mounting?: { part: string; face: V3Face }
17
+ /**
18
+ * Where the figure meets its plane, as its parts stand: the part whose bottom is lowest for a floor, whose top is
19
+ * highest for a ceiling, whose back is furthest back for a wall. What the mounting face is offered as before one is
20
+ * chosen -- the first part in the list offered a conveyor's bed, 700 mm up on its legs (walk 1, 2026-09-26).
21
+ */
22
+ suggested?: { part: string; face: V3Face }
17
23
  }
18
24
 
19
25
  /**
@@ -33,10 +39,28 @@ export function v3FigureFacts(source: string): V3FigureFacts {
33
39
  parts,
34
40
  declared: !!asset.occupancy,
35
41
  placement: asset.occupancy?.placement as Placement | undefined,
36
- mounting: found ? { part: found[1], face: found[2] as V3Face } : undefined
42
+ mounting: found ? { part: found[1], face: found[2] as V3Face } : undefined,
43
+ suggested: suggestedMountOf(asset, parts)
37
44
  }
38
45
  }
39
46
 
47
+ function suggestedMountOf(asset: V3Asset, parts: string[]): { part: string; face: V3Face } | undefined {
48
+ const placement = (asset.occupancy?.placement ?? 'floor') as Placement
49
+ if (placement === 'center' || !parts.length) return undefined
50
+ let boxes
51
+ try {
52
+ boxes = v3OccupiedBoxesOf(compileV3Asset(asset).evaluate().geometry, v3WritersOf(asset)).filter(b => parts.includes(b.part))
53
+ } catch {
54
+ return undefined
55
+ }
56
+ const [axis, face, sign] = placement === 'wall' ? (['z', 'back', -1] as const) : placement === 'ceiling' ? (['y', 'top', 1] as const) : (['y', 'bottom', -1] as const)
57
+ /* The side of each box that faces the plane; the one furthest toward it, the first in the list among equals. */
58
+ const reach = (b: (typeof boxes)[number]) => sign * (b.centre[axis] + (sign * b.extent[axis]) / 2)
59
+ let best: (typeof boxes)[number] | undefined
60
+ for (const b of boxes) if (!best || reach(b) > reach(best) + 1e-6) best = b
61
+ return best ? { part: best.part, face } : undefined
62
+ }
63
+
40
64
  /**
41
65
  * A tag line as a list: split on commas, drop a leading `#`, keep each once. An empty
42
66
  * line is an empty list, so an author can clear every tag.
@@ -182,6 +182,56 @@ export function hangsFrom(asset: V3Asset, part: string, from: string): boolean {
182
182
  return false
183
183
  }
184
184
 
185
+ /**
186
+ * The fastening a part has, as the fastening form holds it -- so the form opens on what is there (the face, including
187
+ * \`centre\`, the target and its face, the gap, the twist and where across the face) and a change is a change to that.
188
+ * Opening it on the defaults showed a roller fastened by its centre as fastened by its bottom, and fastening again
189
+ * from there broke its turning in place (walk 1, 2026-09-26). Null for a part fastened to nothing.
190
+ */
191
+ export function fasteningOf(
192
+ asset: V3Asset,
193
+ part: string
194
+ ): {
195
+ face: string
196
+ toPlane: boolean
197
+ target: string
198
+ targetFace: string
199
+ facing: 'meet' | 'flush'
200
+ gap: number
201
+ twist: number
202
+ align: Partial<Record<Axis, Alignment>>
203
+ alignMm: Partial<Record<Axis, number>>
204
+ } | null {
205
+ const record = asset.joints?.find(j => j.body1.part === part)
206
+ if (!record) return null
207
+ const toPlane = 'plane' in record.body0
208
+ const target = 'part' in record.body0 ? record.body0.part : ''
209
+ const targetFace = 'part' in record.body0 ? record.body0.face : 'top'
210
+ const twist = record.origin?.twist ?? 0
211
+ /* Where across the face: as the record says it, and where it says nothing, where the part stands now. */
212
+ const kept = placeKeptOf(asset, part, toPlane ? 'plane' : { part: target, face: targetFace }, !!twist)
213
+ for (const [axis, how] of Object.entries(record.origin?.align ?? {}) as Array<[Axis, unknown]>) {
214
+ if (how === 'min' || how === 'max' || how === 'centre') {
215
+ kept.align[axis] = how
216
+ delete kept.alignMm[axis]
217
+ } else if (how && typeof how === 'object' && typeof (how as { mm?: unknown }).mm === 'number') {
218
+ kept.align[axis] = 'mm'
219
+ kept.alignMm[axis] = (how as { mm: number }).mm
220
+ }
221
+ }
222
+ const gap = record.origin?.gap
223
+ return {
224
+ face: record.body1.face,
225
+ toPlane,
226
+ target,
227
+ targetFace,
228
+ facing: record.origin?.facing ?? 'meet',
229
+ gap: typeof gap === 'number' ? gap : 0,
230
+ twist,
231
+ ...kept
232
+ }
233
+ }
234
+
185
235
  /**
186
236
  * The alignment that keeps the part where it stands when it is fastened: on each axis across the face, its distance
187
237
  * from the target's centre (the plane's is the origin). Fastening used to centre it -- a door set 100 mm in front of
@@ -84,9 +84,24 @@ export function recipeFieldOf(param: V3RecipeParam, value: V3RecipeValue): strin
84
84
  /** The templates a category and words find: a word matches the title, the description, the name or a tag. */
85
85
  export function recipesMatching<T extends { category: string; title: string; description: string; name: string; tags: string[] }>(templates: readonly T[], category: string, query: string): T[] {
86
86
  const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
87
- return templates.filter(t => {
87
+ const found = templates.filter(t => {
88
88
  if (category && t.category !== category) return false
89
89
  const text = [t.title, t.description, t.name, ...t.tags].join(' ').toLowerCase()
90
90
  return words.every(w => text.includes(w))
91
91
  })
92
+ if (!words.length) return found
93
+ /*
94
+ What the search names comes first: a title that is the words, then one that starts with them, then a title word that
95
+ does, then the rest in their order. 「다리」 listed 사다리 before 다리 넷 (walk 1, 2026-09-26).
96
+ */
97
+ const rank = (t: T) => {
98
+ const title = t.title.toLowerCase()
99
+ const query = words.join(' ')
100
+ if (title === query) return 0
101
+ if (title.startsWith(query)) return 1
102
+ if (words.every(w => title.split(/\s+/).some(part => part.startsWith(w)))) return 2
103
+ if (words.every(w => title.includes(w))) return 3
104
+ return 4
105
+ }
106
+ return found.map((t, i) => ({ t, i, r: rank(t) })).sort((a, b) => a.r - b.r || a.i - b.i).map(x => x.t)
92
107
  }
@@ -4,7 +4,7 @@ import { css, html, LitElement, nothing } from 'lit';
4
4
  import { customElement, property, state } from 'lit/decorators.js';
5
5
  import { i18next, localize } from '@operato/i18n';
6
6
  import { V3_FACES } from '@hatiolab/figure-model';
7
- import { acceptsOf, alignAxesOf, mountingPlaneOf, alignOf, AXES, canFasten, facesOf, canFollow, dimensionNow, followableAxes, positionIsShare, choiceOf, heldBy, followOf, hangsFrom, shownWhenOf, visibilityControlsOf, placeKeptOf, hasInstanceSize, holdsThings, leadersFor, motionOf, motionFormOf, motionJoint, placeFacesOf, placeIdFor, placesOn, rowAxesOf, positionsWorkedOut } from './v3-links.js';
7
+ import { acceptsOf, alignAxesOf, mountingPlaneOf, alignOf, AXES, canFasten, facesOf, canFollow, dimensionNow, followableAxes, positionIsShare, choiceOf, heldBy, followOf, hangsFrom, shownWhenOf, visibilityControlsOf, placeKeptOf, fasteningOf, hasInstanceSize, holdsThings, leadersFor, motionOf, motionFormOf, motionJoint, placeFacesOf, placeIdFor, placesOn, rowAxesOf, positionsWorkedOut } from './v3-links.js';
8
8
  import { SidePanelStyles } from './side-panel-styles.js';
9
9
  import { HelpToggles } from './v3-help.js';
10
10
  const FACES = Object.keys(V3_FACES);
@@ -85,7 +85,9 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
85
85
  */
86
86
  willUpdate(changed) {
87
87
  if (changed.has('partId') && changed.get('partId') !== undefined) {
88
- this.fasten = freshFasten();
88
+ /* A part's own fastening is not another part's choices: the form opens on it (walk 1, 2026-09-26). */
89
+ const own = this.asset && this.partId ? fasteningOf(this.asset, this.partId) : null;
90
+ this.fasten = own ? { ...freshFasten(), ...own } : freshFasten();
89
91
  this.motion = freshMotion();
90
92
  this.place = freshPlace();
91
93
  this.row = freshRow();
@@ -294,7 +296,11 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
294
296
  /* The faces are the ones each shape declares: a part is fastened by one of its outer faces, to any face of the target. */
295
297
  /* On the mounting plane only the faces it takes: a bottom or a top on the floor, a back on a wall. */
296
298
  const plane = mountingPlaneOf(asset);
297
- const mineFaces = facesOf(asset, this.partId).filter(face => FACES.includes(face) && (!f.toPlane || plane.faces.includes(face)));
299
+ /* Its outer faces, and its centre -- a roller spins about its middle (ADR-0093); the mounting plane takes a face. */
300
+ const mineFaces = [
301
+ ...facesOf(asset, this.partId).filter(face => FACES.includes(face) && (!f.toPlane || plane.faces.includes(face))),
302
+ ...(f.toPlane ? [] : ['centre'])
303
+ ];
298
304
  const myFace = mineFaces.includes(f.face) ? f.face : mineFaces[0];
299
305
  const theirFaces = f.target ? facesOf(asset, f.target) : FACES;
300
306
  const targetFace = theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0];