@things-factory/figure-ui 10.1.50 → 10.1.51

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.
@@ -13,6 +13,7 @@ import {
13
13
  alignOf,
14
14
  AXES,
15
15
  canFasten,
16
+ facesOf,
16
17
  canFollow,
17
18
  dimensionNow,
18
19
  followableAxes,
@@ -28,6 +29,7 @@ import {
28
29
  placeFacesOf,
29
30
  placeIdFor,
30
31
  placesOn,
32
+ rowAxesOf,
31
33
  positionsWorkedOut,
32
34
  type Alignment,
33
35
  type Axis,
@@ -39,6 +41,21 @@ import { HelpToggles } from './v3-help.js'
39
41
 
40
42
  const FACES = Object.keys(V3_FACES) as V3Face[]
41
43
 
44
+ const freshFasten = () => ({
45
+ face: 'bottom' as V3Face,
46
+ toPlane: false,
47
+ target: '',
48
+ targetFace: 'top' as V3Face,
49
+ facing: 'meet' as 'meet' | 'flush',
50
+ gap: 0,
51
+ twist: 0,
52
+ align: {} as Partial<Record<Axis, Alignment>>,
53
+ alignMm: {} as Partial<Record<Axis, number>>
54
+ })
55
+ const freshMotion = (): MotionForm => ({ kind: 'slide', axis: 'x', id: '', min: 0, max: 500, follow: '', multiplier: 1, offset: 0 })
56
+ const freshRow = () => ({ axis: '' as Axis | '', pitch: 200, fills: true, count: 5 })
57
+ const freshPlace = () => ({ role: 'grip' as 'seat' | 'grip', face: 'top' as V3Face, label: '', accepts: '', capacity: 1 })
58
+
42
59
  /**
43
60
  * What a part is tied to — its size response, what it is fastened to, how it moves (parity rows ④ ⑤ ⑥).
44
61
  *
@@ -84,9 +101,23 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
84
101
  /** Which ties this shows: the part's shape (size and placement, in 속성), its places (자리·능력) or its motion (움직임). */
85
102
  @property() section: 'shape' | 'places' | 'motion' = 'shape'
86
103
 
87
- @state() private fasten = { face: 'bottom' as V3Face, toPlane: false, target: '', targetFace: 'top' as V3Face, facing: 'meet' as 'meet' | 'flush', gap: 0, align: {} as Partial<Record<Axis, Alignment>>, alignMm: {} as Partial<Record<Axis, number>> }
88
- @state() private motion: MotionForm = { kind: 'slide', axis: 'x', id: '', min: 0, max: 500, follow: '', multiplier: 1, offset: 0 }
89
- @state() private place = { role: 'grip' as 'seat' | 'grip', face: 'top' as V3Face, label: '', accepts: '', capacity: 1 }
104
+ @state() private fasten = freshFasten()
105
+ @state() private motion: MotionForm = freshMotion()
106
+ @state() private place = freshPlace()
107
+ @state() private row = freshRow()
108
+
109
+ /*
110
+ The forms describe the next command for the selected part, so another part starts from the defaults: carrying the
111
+ last part's choices over fastened a shutter curtain level with a right edge nobody chose for it (2026-09-25).
112
+ */
113
+ willUpdate(changed: Map<string, unknown>) {
114
+ if (changed.has('partId') && changed.get('partId') !== undefined) {
115
+ this.fasten = freshFasten()
116
+ this.motion = freshMotion()
117
+ this.place = freshPlace()
118
+ this.row = freshRow()
119
+ }
120
+ }
90
121
 
91
122
  render() {
92
123
  const asset = this.asset
@@ -107,6 +138,7 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
107
138
  ${this.renderPositions(asset)}
108
139
  ${canFasten(asset, this.partId) ? this.renderFastening(asset) : html`<p hint>${i18next.t('figure.text.links-need-a-box')}</p>`}
109
140
  </section>
141
+ ${this.renderRow(asset)}
110
142
  `
111
143
  }
112
144
 
@@ -259,6 +291,13 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
259
291
  const to = fastenedTo(asset, this.partId)
260
292
  const f = this.fasten
261
293
  const targets = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFasten(asset, one))
294
+ /* 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
+ const mineFaces = facesOf(asset, this.partId).filter(face => FACES.includes(face as V3Face))
296
+ const myFace = mineFaces.includes(f.face) ? f.face : (mineFaces[0] as V3Face)
297
+ const theirFaces = f.target ? facesOf(asset, f.target) : FACES
298
+ const targetFace = theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0]!
299
+ const aim = f.toPlane ? ('plane' as const) : { part: f.target, face: targetFace }
300
+ const align = f.target || f.toPlane ? alignOf(asset, this.partId, aim, f.align, f.alignMm, !!f.twist) : undefined
262
301
  return html`
263
302
  ${to
264
303
  ? html`<div pair>
@@ -271,7 +310,7 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
271
310
  <div form>
272
311
  <span>${i18next.t('figure.label.my-face')}</span>
273
312
  <select @change=${(e: Event) => (this.fasten = { ...f, face: (e.target as HTMLSelectElement).value as V3Face })}>
274
- ${FACES.map(face => html`<option value=${face} ?selected=${face === f.face}>${i18next.t('figure.label.face-' + face)}</option>`)}
313
+ ${mineFaces.map(face => html`<option value=${face} ?selected=${face === myFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
275
314
  </select>
276
315
  </div>
277
316
  <label toggle>
@@ -288,7 +327,7 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
288
327
  </select>
289
328
  <span>${i18next.t('figure.label.target-face')}</span>
290
329
  <select @change=${(e: Event) => (this.fasten = { ...f, targetFace: (e.target as HTMLSelectElement).value as V3Face })}>
291
- ${FACES.map(face => html`<option value=${face} ?selected=${face === f.targetFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
330
+ ${theirFaces.map(face => html`<option value=${face} ?selected=${face === targetFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
292
331
  </select>
293
332
  <span>${i18next.t('figure.label.facing-meet')}</span>
294
333
  <select @change=${(e: Event) => (this.fasten = { ...f, facing: (e.target as HTMLSelectElement).value as 'meet' | 'flush' })}>
@@ -296,10 +335,12 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
296
335
  <option value="flush" ?selected=${f.facing === 'flush'}>${i18next.t('figure.label.facing-flush')}</option>
297
336
  </select>
298
337
  </div>`}
299
- ${this.renderAlignment()}
338
+ ${this.renderAlignment(f.toPlane || f.target ? alignAxesOf(asset, this.partId, aim, !!f.twist) : [])}
300
339
  <div form>
301
340
  <span>${i18next.t('figure.label.gap')}</span>
302
341
  <input type="number" step="any" .value=${String(f.gap)} @change=${(e: Event) => (this.fasten = { ...f, gap: Number((e.target as HTMLInputElement).value) || 0 })} />
342
+ <span>${i18next.t('figure.label.twist')}</span>
343
+ <input type="number" step="any" .value=${String(f.twist)} @change=${(e: Event) => (this.fasten = { ...f, twist: Number((e.target as HTMLInputElement).value) || 0 })} />
303
344
  </div>
304
345
  <button
305
346
  act
@@ -308,11 +349,12 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
308
349
  this.command({
309
350
  kind: 'attach',
310
351
  part: this.partId,
311
- face: f.face,
312
- to: f.toPlane ? { plane: 'mounting-plane' } : { part: f.target, face: f.targetFace },
352
+ face: myFace,
353
+ to: f.toPlane ? { plane: 'mounting-plane' } : { part: f.target, face: targetFace },
313
354
  ...(f.toPlane ? {} : { facing: f.facing }),
314
355
  ...(f.gap ? { gap: f.gap } : {}),
315
- ...(alignOf(f.toPlane ? 'plane' : f.targetFace, f.align, f.alignMm) ? { align: alignOf(f.toPlane ? 'plane' : f.targetFace, f.align, f.alignMm) } : {}),
356
+ twist: f.twist,
357
+ ...(align ? { align } : {}),
316
358
  replace: !!to
317
359
  } as V3AuthoringAction)}
318
360
  >
@@ -325,12 +367,12 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
325
367
  * Where on the target face the part sits, on the two axes across it: centred, level with the target's edge on
326
368
  * either side, or a distance off centre. A door hung level with the frame's front edge turns about that edge.
327
369
  */
328
- private renderAlignment() {
370
+ private renderAlignment(axes: { axis: Axis; edges: boolean }[]) {
329
371
  const f = this.fasten
330
372
  const set = (axis: Axis, how: Alignment) => (this.fasten = { ...f, align: { ...f.align, [axis]: how } })
331
373
  const setMm = (axis: Axis, mm: number) => (this.fasten = { ...f, alignMm: { ...f.alignMm, [axis]: mm } })
332
374
  return html`<div form>
333
- ${alignAxesOf(f.toPlane ? 'plane' : f.targetFace).map(({ axis, edges }) => {
375
+ ${axes.map(({ axis, edges }) => {
334
376
  const how = f.align[axis] === 'min' || f.align[axis] === 'max' ? (edges ? f.align[axis]! : 'centre') : (f.align[axis] ?? 'centre')
335
377
  return html`<span>${i18next.t('figure.label.align-' + axis)}</span>
336
378
  <select @change=${(e: Event) => set(axis, (e.target as HTMLSelectElement).value as Alignment)}>
@@ -348,6 +390,47 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
348
390
  </div>`
349
391
  }
350
392
 
393
+ /**
394
+ * Making the part a row across what it is fastened to (figure-model `make-repeat`): along one of the axes across its
395
+ * face, copies a pitch apart filling the parent's length, or a count spread over it. Once made, the row's own section
396
+ * (the editor's) shows it and takes it away.
397
+ */
398
+ private renderRow(asset: V3Asset) {
399
+ const axes = rowAxesOf(asset, this.partId)
400
+ if (!axes.length) return nothing
401
+ const r = this.row
402
+ const axis = axes.includes(r.axis as Axis) ? (r.axis as Axis) : axes[0]!
403
+ const ok = r.pitch > 0 && (r.fills || (Number.isSafeInteger(r.count) && r.count >= 1))
404
+ return html`<section>
405
+ <h3>${i18next.t('figure.label.repeat')}</h3>
406
+ <div form>
407
+ <span>${i18next.t('figure.label.repeat-axis')}</span>
408
+ <select @change=${(e: Event) => (this.row = { ...r, axis: (e.target as HTMLSelectElement).value as Axis })}>
409
+ ${axes.map(a => html`<option value=${a} ?selected=${a === axis}>${a.toUpperCase()}</option>`)}
410
+ </select>
411
+ <span>${i18next.t('figure.label.repeat-pitch')} (mm)</span>
412
+ <input type="number" step="any" min="0" .value=${String(r.pitch)} @change=${(e: Event) => (this.row = { ...r, pitch: Number((e.target as HTMLInputElement).value) })} />
413
+ </div>
414
+ <label toggle>
415
+ <input type="checkbox" .checked=${r.fills} @change=${(e: Event) => (this.row = { ...r, fills: (e.target as HTMLInputElement).checked })} />
416
+ ${i18next.t('figure.label.repeat-fills')}
417
+ </label>
418
+ ${r.fills
419
+ ? nothing
420
+ : html`<div form>
421
+ <span>${i18next.t('figure.label.repeat-count')}</span>
422
+ <input type="number" step="1" min="1" .value=${String(r.count)} @change=${(e: Event) => (this.row = { ...r, count: Number((e.target as HTMLInputElement).value) })} />
423
+ </div>`}
424
+ <button
425
+ act
426
+ ?disabled=${this.locked || !ok}
427
+ @click=${() => this.command({ kind: 'make-repeat', part: this.partId, axis, pitch: r.pitch, ...(r.fills ? {} : { count: r.count }) } as V3AuthoringAction)}
428
+ >
429
+ <md-icon>view_week</md-icon>${i18next.t('figure.button.make-repeat')}
430
+ </button>
431
+ </section>`
432
+ }
433
+
351
434
  private renderMotion(asset: V3Asset) {
352
435
  const now = motionOf(asset, this.partId)
353
436
  const joint = asset.joints?.find(j => j.body1.part === this.partId)
@@ -855,6 +855,11 @@ export class FigureV3Editor extends LitElement {
855
855
  })
856
856
  : i18next.t(fits ? 'figure.text.repeat-fits-length' : 'figure.text.repeat-fixed-count')}
857
857
  </p>
858
+ ${asset.joints?.some(j => j.body1.part === this.selected && j.repeat)
859
+ ? html`<button act ?disabled=${unavailable} @click=${() => this.run(() => this.session!.command({ kind: 'remove-repeat', part: this.selected }))}>
860
+ <md-icon>close</md-icon>${i18next.t('figure.button.remove-repeat')}
861
+ </button>`
862
+ : nothing}
858
863
  </section>`
859
864
  }
860
865
  /**
@@ -1,4 +1,4 @@
1
- import { V3_FACES, compileV3Graph, defaultInstanceSizeRange, foldInstanceSizeRange, v3AttachmentsOf, v3BindingsOf, v3InstanceSizeLimitsOf } from '@hatiolab/figure-model'
1
+ import { V3_FACES, v3ShapeFaces, compileV3Graph, defaultInstanceSizeRange, foldInstanceSizeRange, v3AttachmentsOf, v3BindingsOf, v3InstanceSizeLimitsOf } from '@hatiolab/figure-model'
2
2
  import type { V3SizeRange } from '@hatiolab/figure-model'
3
3
  import type { V3Asset, V3BindingPlace, V3Face, V3Joint } from '@hatiolab/figure-model'
4
4
 
@@ -115,29 +115,73 @@ export function dimensionNow(asset: V3Asset, part: string, axis: Axis): number |
115
115
  }
116
116
  }
117
117
 
118
- /** Whether `attach` can take this part: a box, or a cylinder (figure-model measures its faces since 0.1.47). */
119
- export function canFasten(asset: V3Asset, part: string): boolean {
118
+ /**
119
+ * The flat faces a part's shape declares, by name -- figure-model's shape definitions are the one place they are
120
+ * written (a portal's opening sides as well as its outer faces). A sphere has none.
121
+ */
122
+ export function facesOf(asset: V3Asset, part: string): string[] {
123
+ const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
124
+ return Object.keys((shape && v3ShapeFaces(shape.op)) ?? {})
125
+ }
126
+
127
+ /** The axis a face of a part faces along, from the same definitions. */
128
+ export function faceAxisOf(asset: V3Asset, part: string, face: string): Axis | undefined {
120
129
  const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
121
- return canFollow(asset, part) || shape?.op === 'cylinder-shape@1'
130
+ return (shape && v3ShapeFaces(shape.op)?.[face]?.axis) as Axis | undefined
122
131
  }
123
132
 
133
+ /** Whether `attach` can take this part: any part whose shape has a flat face. */
134
+ export function canFasten(asset: V3Asset, part: string): boolean {
135
+ return facesOf(asset, part).length > 0
136
+ }
137
+
138
+ const OUTER: Record<Axis, [string, string]> = { x: ['left', 'right'], y: ['bottom', 'top'], z: ['back', 'front'] }
139
+
140
+ /**
141
+ * The axes a part can be made a row along (r3 §8, figure-model `make-repeat`): across the face it is fastened by, when
142
+ * it is fastened fixed to another part and holds nothing. None for a part that is loose, moves, stands on the floor or
143
+ * already repeats -- the command would refuse those, so they are not offered.
144
+ */
145
+ export function rowAxesOf(asset: V3Asset, part: string): Axis[] {
146
+ const joints = asset.joints ?? []
147
+ const j = joints.find(o => o.body1.part === part)
148
+ if (!j || j.type !== 'fixed' || j.repeat || !('part' in j.body0)) return []
149
+ if (joints.some(o => 'part' in o.body0 && o.body0.part === part)) return []
150
+ return AXES.filter(axis => axis !== V3_FACES[j.body1.face])
151
+ }
152
+
153
+ /** The row a part is made into, as its fastening's record keeps it. */
154
+ export const rowOf = (asset: V3Asset, part: string) => asset.joints?.find(o => o.body1.part === part)?.repeat
155
+
124
156
  /** Where a part sits across the face it is fastened to, on one axis: centred, level with an edge, or a distance off centre. */
125
157
  export type Alignment = 'centre' | 'min' | 'max' | 'mm'
126
158
 
127
159
  /**
128
- * The axes across the face a part is fastened to — the two a fastening does not set by itself. On the plane the figure is
129
- * mounted on these are X and Z, and the plane has no edges, so only centred or a distance is offered there.
160
+ * The axes across the face a part is fastened to — the two a fastening does not set by itself. Edges to line up with
161
+ * are offered where both parts have their two outer faces on that axis; the plane the figure is mounted on has none,
162
+ * so on it (X and Z) only centred or a distance is offered.
130
163
  */
131
- export function alignAxesOf(target: V3Face | 'plane'): { axis: Axis; edges: boolean }[] {
164
+ export function alignAxesOf(asset: V3Asset, part: string, target: { part: string; face: string } | 'plane', twisted = false): { axis: Axis; edges: boolean }[] {
132
165
  if (target === 'plane') return (['x', 'z'] as Axis[]).map(axis => ({ axis, edges: false }))
133
- const along = V3_FACES[target] as Axis
134
- return AXES.filter(axis => axis !== along).map(axis => ({ axis, edges: true }))
166
+ const along = faceAxisOf(asset, target.part, target.face)
167
+ if (!along) return []
168
+ const theirs = new Set(facesOf(asset, target.part)),
169
+ mine = new Set(facesOf(asset, part))
170
+ /* A part twisted about the face's normal has no side level with the target's edges. */
171
+ return AXES.filter(axis => axis !== along).map(axis => ({ axis, edges: !twisted && OUTER[axis].every(f => theirs.has(f) && mine.has(f)) }))
135
172
  }
136
173
 
137
174
  /** The `align` of an attach command from the form's choices: only the axes across that face, and centred ones left out. */
138
- export function alignOf(target: V3Face | 'plane', how: Partial<Record<Axis, Alignment>>, mm: Partial<Record<Axis, number>>) {
175
+ export function alignOf(
176
+ asset: V3Asset,
177
+ part: string,
178
+ target: { part: string; face: string } | 'plane',
179
+ how: Partial<Record<Axis, Alignment>>,
180
+ mm: Partial<Record<Axis, number>>,
181
+ twisted = false
182
+ ) {
139
183
  const align: Partial<Record<Axis, 'min' | 'max' | { mm: number }>> = {}
140
- for (const { axis, edges } of alignAxesOf(target)) {
184
+ for (const { axis, edges } of alignAxesOf(asset, part, target, twisted)) {
141
185
  const one = how[axis] ?? 'centre'
142
186
  if (one === 'mm' && mm[axis]) align[axis] = { mm: mm[axis]! }
143
187
  else if ((one === 'min' || one === 'max') && edges) align[axis] = one
@@ -1,6 +1,7 @@
1
1
  import { html, css } from 'lit'
2
2
  import { customElement, state } from 'lit/decorators.js'
3
- import { PageView } from '@operato/shell'
3
+ import { navigate, PageView } from '@operato/shell'
4
+ import { modellerPathOf } from '../modeller/modeller-route.js'
4
5
  import { createV3ProductSession } from '../modeller/v3-product-session.js'
5
6
  import { v3ProductStore } from '../graphql/v3-store.js'
6
7
  import { i18next } from '@operato/i18n'
@@ -144,6 +145,15 @@ export class FigureV3ModellerPage extends PageView {
144
145
  this.updateContext()
145
146
  }
146
147
  if (!view.saving) this.announce()
148
+ /*
149
+ A new figure's first save gives it an id: the address becomes the figure's own, in place of the history entry.
150
+ Left at the bare modeller address, opening the modeller again from the menu was taken for the same page and kept
151
+ the saved figure -- its type showing where a new figure's was expected (2026-09-25).
152
+ */
153
+ if (view.id && !this.routeId && !view.saving) {
154
+ this.routeId = view.id
155
+ navigate(modellerPathOf({ id: view.id }), true)
156
+ }
147
157
  /* 저장이 끝난 순간 행을 다시 읽는다 — 발행된 도형을 고쳐 저장하면 서버가 초안으로 내린다. */
148
158
  if (this.wasDirty && !view.dirty && !view.saving && view.id) this.readRow(view.id)
149
159
  this.wasDirty = !!view.dirty
@@ -15,7 +15,7 @@ export declare class FigureV3Capability extends FigureV3Capability_base {
15
15
  /** The selected part, whose places are marked. */
16
16
  selected: string;
17
17
  locked: boolean;
18
- render(): import("lit-html").TemplateResult<1> | typeof nothing;
18
+ render(): typeof nothing | import("lit-html").TemplateResult<1>;
19
19
  private setRung;
20
20
  private pick;
21
21
  }
@@ -18,7 +18,7 @@ export declare class FigureV3Clips extends FigureV3Clips_base {
18
18
  /** Where each clip is scrubbed to, in seconds. */
19
19
  private at;
20
20
  private adding;
21
- render(): import("lit-html").TemplateResult<1> | typeof nothing;
21
+ render(): typeof nothing | import("lit-html").TemplateResult<1>;
22
22
  private renderClip;
23
23
  private renderTrack;
24
24
  private renderAdd;
@@ -22,7 +22,7 @@ export declare class FigureV3Controls extends FigureV3Controls_base {
22
22
  private open;
23
23
  /** Values in hand while a slider is dragged: the dial and the number follow them before the release commits. */
24
24
  private live;
25
- render(): import("lit-html").TemplateResult<1> | typeof nothing;
25
+ render(): typeof nothing | import("lit-html").TemplateResult<1>;
26
26
  private renderControl;
27
27
  private renderDial;
28
28
  private renderSettings;
@@ -30,7 +30,9 @@ export declare class FigureV3Links extends FigureV3Links_base {
30
30
  private fasten;
31
31
  private motion;
32
32
  private place;
33
- render(): import("lit-html").TemplateResult<1> | typeof nothing;
33
+ private row;
34
+ willUpdate(changed: Map<string, unknown>): void;
35
+ render(): typeof nothing | import("lit-html").TemplateResult<1>;
34
36
  /** Where things are held on this part: a seat things rest on, or a grip things are held in (parity row ⑤). */
35
37
  private renderPlaces;
36
38
  /** The figure holds things: `Capacity` and `Holdable`, the ladder's middle rung. One part edit, one undo step. */
@@ -48,6 +50,12 @@ export declare class FigureV3Links extends FigureV3Links_base {
48
50
  * either side, or a distance off centre. A door hung level with the frame's front edge turns about that edge.
49
51
  */
50
52
  private renderAlignment;
53
+ /**
54
+ * Making the part a row across what it is fastened to (figure-model `make-repeat`): along one of the axes across its
55
+ * face, copies a pitch apart filling the parent's length, or a count spread over it. Once made, the row's own section
56
+ * (the editor's) shows it and takes it away.
57
+ */
58
+ private renderRow;
51
59
  private renderMotion;
52
60
  private setKind;
53
61
  private command;
@@ -4,10 +4,24 @@ 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, alignOf, AXES, canFasten, canFollow, dimensionNow, followableAxes, positionIsShare, choiceOf, fastenedTo, followOf, hasInstanceSize, holdsThings, leadersFor, motionOf, motionJoint, placeFacesOf, placeIdFor, placesOn, positionsWorkedOut } from './v3-links.js';
7
+ import { acceptsOf, alignAxesOf, alignOf, AXES, canFasten, facesOf, canFollow, dimensionNow, followableAxes, positionIsShare, choiceOf, fastenedTo, followOf, hasInstanceSize, holdsThings, leadersFor, motionOf, 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);
11
+ const freshFasten = () => ({
12
+ face: 'bottom',
13
+ toPlane: false,
14
+ target: '',
15
+ targetFace: 'top',
16
+ facing: 'meet',
17
+ gap: 0,
18
+ twist: 0,
19
+ align: {},
20
+ alignMm: {}
21
+ });
22
+ const freshMotion = () => ({ kind: 'slide', axis: 'x', id: '', min: 0, max: 500, follow: '', multiplier: 1, offset: 0 });
23
+ const freshRow = () => ({ axis: '', pitch: 200, fills: true, count: 5 });
24
+ const freshPlace = () => ({ role: 'grip', face: 'top', label: '', accepts: '', capacity: 1 });
11
25
  /**
12
26
  * What a part is tied to — its size response, what it is fastened to, how it moves (parity rows ④ ⑤ ⑥).
13
27
  *
@@ -30,9 +44,10 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
30
44
  this.locked = false;
31
45
  /** Which ties this shows: the part's shape (size and placement, in 속성), its places (자리·능력) or its motion (움직임). */
32
46
  this.section = 'shape';
33
- this.fasten = { face: 'bottom', toPlane: false, target: '', targetFace: 'top', facing: 'meet', gap: 0, align: {}, alignMm: {} };
34
- this.motion = { kind: 'slide', axis: 'x', id: '', min: 0, max: 500, follow: '', multiplier: 1, offset: 0 };
35
- this.place = { role: 'grip', face: 'top', label: '', accepts: '', capacity: 1 };
47
+ this.fasten = freshFasten();
48
+ this.motion = freshMotion();
49
+ this.place = freshPlace();
50
+ this.row = freshRow();
36
51
  }
37
52
  static { this.styles = [SidePanelStyles, css `
38
53
  :host {
@@ -52,6 +67,18 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
52
67
  font-weight: 600;
53
68
  }
54
69
  `]; }
70
+ /*
71
+ The forms describe the next command for the selected part, so another part starts from the defaults: carrying the
72
+ last part's choices over fastened a shutter curtain level with a right edge nobody chose for it (2026-09-25).
73
+ */
74
+ willUpdate(changed) {
75
+ if (changed.has('partId') && changed.get('partId') !== undefined) {
76
+ this.fasten = freshFasten();
77
+ this.motion = freshMotion();
78
+ this.place = freshPlace();
79
+ this.row = freshRow();
80
+ }
81
+ }
55
82
  render() {
56
83
  const asset = this.asset;
57
84
  if (!asset || !this.partId)
@@ -72,6 +99,7 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
72
99
  ${this.renderPositions(asset)}
73
100
  ${canFasten(asset, this.partId) ? this.renderFastening(asset) : html `<p hint>${i18next.t('figure.text.links-need-a-box')}</p>`}
74
101
  </section>
102
+ ${this.renderRow(asset)}
75
103
  `;
76
104
  }
77
105
  /** Where things are held on this part: a seat things rest on, or a grip things are held in (parity row ⑤). */
@@ -209,6 +237,13 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
209
237
  const to = fastenedTo(asset, this.partId);
210
238
  const f = this.fasten;
211
239
  const targets = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFasten(asset, one));
240
+ /* The faces are the ones each shape declares: a part is fastened by one of its outer faces, to any face of the target. */
241
+ const mineFaces = facesOf(asset, this.partId).filter(face => FACES.includes(face));
242
+ const myFace = mineFaces.includes(f.face) ? f.face : mineFaces[0];
243
+ const theirFaces = f.target ? facesOf(asset, f.target) : FACES;
244
+ const targetFace = theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0];
245
+ const aim = f.toPlane ? 'plane' : { part: f.target, face: targetFace };
246
+ const align = f.target || f.toPlane ? alignOf(asset, this.partId, aim, f.align, f.alignMm, !!f.twist) : undefined;
212
247
  return html `
213
248
  ${to
214
249
  ? html `<div pair>
@@ -221,7 +256,7 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
221
256
  <div form>
222
257
  <span>${i18next.t('figure.label.my-face')}</span>
223
258
  <select @change=${(e) => (this.fasten = { ...f, face: e.target.value })}>
224
- ${FACES.map(face => html `<option value=${face} ?selected=${face === f.face}>${i18next.t('figure.label.face-' + face)}</option>`)}
259
+ ${mineFaces.map(face => html `<option value=${face} ?selected=${face === myFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
225
260
  </select>
226
261
  </div>
227
262
  <label toggle>
@@ -238,7 +273,7 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
238
273
  </select>
239
274
  <span>${i18next.t('figure.label.target-face')}</span>
240
275
  <select @change=${(e) => (this.fasten = { ...f, targetFace: e.target.value })}>
241
- ${FACES.map(face => html `<option value=${face} ?selected=${face === f.targetFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
276
+ ${theirFaces.map(face => html `<option value=${face} ?selected=${face === targetFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
242
277
  </select>
243
278
  <span>${i18next.t('figure.label.facing-meet')}</span>
244
279
  <select @change=${(e) => (this.fasten = { ...f, facing: e.target.value })}>
@@ -246,10 +281,12 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
246
281
  <option value="flush" ?selected=${f.facing === 'flush'}>${i18next.t('figure.label.facing-flush')}</option>
247
282
  </select>
248
283
  </div>`}
249
- ${this.renderAlignment()}
284
+ ${this.renderAlignment(f.toPlane || f.target ? alignAxesOf(asset, this.partId, aim, !!f.twist) : [])}
250
285
  <div form>
251
286
  <span>${i18next.t('figure.label.gap')}</span>
252
287
  <input type="number" step="any" .value=${String(f.gap)} @change=${(e) => (this.fasten = { ...f, gap: Number(e.target.value) || 0 })} />
288
+ <span>${i18next.t('figure.label.twist')}</span>
289
+ <input type="number" step="any" .value=${String(f.twist)} @change=${(e) => (this.fasten = { ...f, twist: Number(e.target.value) || 0 })} />
253
290
  </div>
254
291
  <button
255
292
  act
@@ -257,11 +294,12 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
257
294
  @click=${() => this.command({
258
295
  kind: 'attach',
259
296
  part: this.partId,
260
- face: f.face,
261
- to: f.toPlane ? { plane: 'mounting-plane' } : { part: f.target, face: f.targetFace },
297
+ face: myFace,
298
+ to: f.toPlane ? { plane: 'mounting-plane' } : { part: f.target, face: targetFace },
262
299
  ...(f.toPlane ? {} : { facing: f.facing }),
263
300
  ...(f.gap ? { gap: f.gap } : {}),
264
- ...(alignOf(f.toPlane ? 'plane' : f.targetFace, f.align, f.alignMm) ? { align: alignOf(f.toPlane ? 'plane' : f.targetFace, f.align, f.alignMm) } : {}),
301
+ twist: f.twist,
302
+ ...(align ? { align } : {}),
265
303
  replace: !!to
266
304
  })}
267
305
  >
@@ -273,12 +311,12 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
273
311
  * Where on the target face the part sits, on the two axes across it: centred, level with the target's edge on
274
312
  * either side, or a distance off centre. A door hung level with the frame's front edge turns about that edge.
275
313
  */
276
- renderAlignment() {
314
+ renderAlignment(axes) {
277
315
  const f = this.fasten;
278
316
  const set = (axis, how) => (this.fasten = { ...f, align: { ...f.align, [axis]: how } });
279
317
  const setMm = (axis, mm) => (this.fasten = { ...f, alignMm: { ...f.alignMm, [axis]: mm } });
280
318
  return html `<div form>
281
- ${alignAxesOf(f.toPlane ? 'plane' : f.targetFace).map(({ axis, edges }) => {
319
+ ${axes.map(({ axis, edges }) => {
282
320
  const how = f.align[axis] === 'min' || f.align[axis] === 'max' ? (edges ? f.align[axis] : 'centre') : (f.align[axis] ?? 'centre');
283
321
  return html `<span>${i18next.t('figure.label.align-' + axis)}</span>
284
322
  <select @change=${(e) => set(axis, e.target.value)}>
@@ -295,6 +333,47 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
295
333
  })}
296
334
  </div>`;
297
335
  }
336
+ /**
337
+ * Making the part a row across what it is fastened to (figure-model `make-repeat`): along one of the axes across its
338
+ * face, copies a pitch apart filling the parent's length, or a count spread over it. Once made, the row's own section
339
+ * (the editor's) shows it and takes it away.
340
+ */
341
+ renderRow(asset) {
342
+ const axes = rowAxesOf(asset, this.partId);
343
+ if (!axes.length)
344
+ return nothing;
345
+ const r = this.row;
346
+ const axis = axes.includes(r.axis) ? r.axis : axes[0];
347
+ const ok = r.pitch > 0 && (r.fills || (Number.isSafeInteger(r.count) && r.count >= 1));
348
+ return html `<section>
349
+ <h3>${i18next.t('figure.label.repeat')}</h3>
350
+ <div form>
351
+ <span>${i18next.t('figure.label.repeat-axis')}</span>
352
+ <select @change=${(e) => (this.row = { ...r, axis: e.target.value })}>
353
+ ${axes.map(a => html `<option value=${a} ?selected=${a === axis}>${a.toUpperCase()}</option>`)}
354
+ </select>
355
+ <span>${i18next.t('figure.label.repeat-pitch')} (mm)</span>
356
+ <input type="number" step="any" min="0" .value=${String(r.pitch)} @change=${(e) => (this.row = { ...r, pitch: Number(e.target.value) })} />
357
+ </div>
358
+ <label toggle>
359
+ <input type="checkbox" .checked=${r.fills} @change=${(e) => (this.row = { ...r, fills: e.target.checked })} />
360
+ ${i18next.t('figure.label.repeat-fills')}
361
+ </label>
362
+ ${r.fills
363
+ ? nothing
364
+ : html `<div form>
365
+ <span>${i18next.t('figure.label.repeat-count')}</span>
366
+ <input type="number" step="1" min="1" .value=${String(r.count)} @change=${(e) => (this.row = { ...r, count: Number(e.target.value) })} />
367
+ </div>`}
368
+ <button
369
+ act
370
+ ?disabled=${this.locked || !ok}
371
+ @click=${() => this.command({ kind: 'make-repeat', part: this.partId, axis, pitch: r.pitch, ...(r.fills ? {} : { count: r.count }) })}
372
+ >
373
+ <md-icon>view_week</md-icon>${i18next.t('figure.button.make-repeat')}
374
+ </button>
375
+ </section>`;
376
+ }
298
377
  renderMotion(asset) {
299
378
  const now = motionOf(asset, this.partId);
300
379
  const joint = asset.joints?.find(j => j.body1.part === this.partId);
@@ -412,6 +491,10 @@ __decorate([
412
491
  state(),
413
492
  __metadata("design:type", Object)
414
493
  ], FigureV3Links.prototype, "place", void 0);
494
+ __decorate([
495
+ state(),
496
+ __metadata("design:type", Object)
497
+ ], FigureV3Links.prototype, "row", void 0);
415
498
  FigureV3Links = __decorate([
416
499
  customElement('figure-v3-links')
417
500
  ], FigureV3Links);