@things-factory/figure-ui 10.1.49 → 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.
Files changed (97) hide show
  1. package/client/graphql/v3-store.ts +2 -1
  2. package/client/modeller/figure-history-panel.ts +9 -1
  3. package/client/modeller/figure-v3-first-save.ts +98 -0
  4. package/client/modeller/figure-v3-links.ts +177 -31
  5. package/client/modeller/figure-v3-parts.ts +1 -0
  6. package/client/modeller/figure-v3-properties.ts +2 -1
  7. package/client/modeller/v3-canvas.ts +61 -7
  8. package/client/modeller/v3-editor.ts +96 -21
  9. package/client/modeller/v3-first-save.ts +34 -0
  10. package/client/modeller/v3-gizmo.ts +52 -3
  11. package/client/modeller/v3-joint-drag.ts +116 -0
  12. package/client/modeller/v3-joint-fan.ts +24 -11
  13. package/client/modeller/v3-keys.ts +12 -1
  14. package/client/modeller/v3-links.ts +119 -8
  15. package/client/modeller/v3-live.ts +11 -0
  16. package/client/modeller/v3-product-session.ts +4 -2
  17. package/client/modeller/v3-refusals.ts +21 -0
  18. package/client/pages/figure-v3-modeller-page.ts +57 -8
  19. package/dist-client/graphql/v3-store.js +2 -1
  20. package/dist-client/graphql/v3-store.js.map +1 -1
  21. package/dist-client/modeller/figure-history-panel.js +9 -1
  22. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  23. package/dist-client/modeller/figure-v3-capability.d.ts +1 -1
  24. package/dist-client/modeller/figure-v3-clips.d.ts +1 -1
  25. package/dist-client/modeller/figure-v3-controls.d.ts +1 -1
  26. package/dist-client/modeller/figure-v3-first-save.d.ts +20 -0
  27. package/dist-client/modeller/figure-v3-first-save.js +118 -0
  28. package/dist-client/modeller/figure-v3-first-save.js.map +1 -0
  29. package/dist-client/modeller/figure-v3-links.d.ts +14 -1
  30. package/dist-client/modeller/figure-v3-links.js +169 -28
  31. package/dist-client/modeller/figure-v3-links.js.map +1 -1
  32. package/dist-client/modeller/figure-v3-material.d.ts +1 -1
  33. package/dist-client/modeller/figure-v3-parts.js +1 -0
  34. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  35. package/dist-client/modeller/figure-v3-properties.d.ts +1 -1
  36. package/dist-client/modeller/figure-v3-properties.js +2 -1
  37. package/dist-client/modeller/figure-v3-properties.js.map +1 -1
  38. package/dist-client/modeller/v3-canvas.d.ts +11 -0
  39. package/dist-client/modeller/v3-canvas.js +61 -7
  40. package/dist-client/modeller/v3-canvas.js.map +1 -1
  41. package/dist-client/modeller/v3-clips.d.ts +1 -1
  42. package/dist-client/modeller/v3-editor.d.ts +13 -1
  43. package/dist-client/modeller/v3-editor.js +96 -21
  44. package/dist-client/modeller/v3-editor.js.map +1 -1
  45. package/dist-client/modeller/v3-first-save.d.ts +17 -0
  46. package/dist-client/modeller/v3-first-save.js +27 -0
  47. package/dist-client/modeller/v3-first-save.js.map +1 -0
  48. package/dist-client/modeller/v3-gizmo.d.ts +18 -0
  49. package/dist-client/modeller/v3-gizmo.js +45 -2
  50. package/dist-client/modeller/v3-gizmo.js.map +1 -1
  51. package/dist-client/modeller/v3-joint-drag.d.ts +19 -0
  52. package/dist-client/modeller/v3-joint-drag.js +97 -0
  53. package/dist-client/modeller/v3-joint-drag.js.map +1 -0
  54. package/dist-client/modeller/v3-joint-fan.js +24 -11
  55. package/dist-client/modeller/v3-joint-fan.js.map +1 -1
  56. package/dist-client/modeller/v3-keys.d.ts +9 -1
  57. package/dist-client/modeller/v3-keys.js +3 -0
  58. package/dist-client/modeller/v3-keys.js.map +1 -1
  59. package/dist-client/modeller/v3-links.d.ts +72 -3
  60. package/dist-client/modeller/v3-links.js +89 -6
  61. package/dist-client/modeller/v3-links.js.map +1 -1
  62. package/dist-client/modeller/v3-live.d.ts +6 -0
  63. package/dist-client/modeller/v3-live.js +10 -0
  64. package/dist-client/modeller/v3-live.js.map +1 -1
  65. package/dist-client/modeller/v3-product-session.d.ts +4 -1
  66. package/dist-client/modeller/v3-product-session.js +2 -2
  67. package/dist-client/modeller/v3-product-session.js.map +1 -1
  68. package/dist-client/modeller/v3-refusals.d.ts +4 -0
  69. package/dist-client/modeller/v3-refusals.js +21 -0
  70. package/dist-client/modeller/v3-refusals.js.map +1 -0
  71. package/dist-client/pages/figure-v3-modeller-page.d.ts +4 -0
  72. package/dist-client/pages/figure-v3-modeller-page.js +56 -8
  73. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  74. package/dist-client/tsconfig.tsbuildinfo +1 -1
  75. package/package.json +7 -5
  76. package/test/browser/build.mjs +62 -0
  77. package/test/browser/modeller-page.js +198 -0
  78. package/test/browser/modeller.check.mjs +358 -0
  79. package/test/browser/serve.mjs +43 -0
  80. package/test/joints.ts +33 -0
  81. package/test/v3-clips.test.ts +3 -2
  82. package/test/v3-controls.test.ts +3 -2
  83. package/test/v3-fields.test.ts +3 -2
  84. package/test/v3-first-save.test.ts +84 -0
  85. package/test/v3-gizmo.test.ts +38 -5
  86. package/test/v3-joint-drag.test.ts +93 -0
  87. package/test/v3-joint-fan.test.ts +16 -6
  88. package/test/v3-keys.test.ts +15 -2
  89. package/test/v3-links.test.ts +114 -14
  90. package/test/v3-live.test.ts +23 -4
  91. package/test/v3-maturity.test.ts +2 -6
  92. package/test/v3-refusals.test.ts +28 -0
  93. package/translations/en.json +40 -5
  94. package/translations/ja.json +39 -4
  95. package/translations/ko.json +39 -4
  96. package/translations/ms.json +39 -4
  97. package/translations/zh.json +39 -4
@@ -13,13 +13,14 @@ export const v3ProductStore: V3ProductStore = {
13
13
  async save(request) {
14
14
  const response = await client.mutate({
15
15
  mutation: gql`
16
- mutation SaveFigureV3($source: String!, $id: String, $expectedSource: String, $creationId: String, $thumbnail: String) {
16
+ mutation SaveFigureV3($source: String!, $id: String, $expectedSource: String, $creationId: String, $thumbnail: String, $type: String) {
17
17
  saveFigureV3(
18
18
  source: $source
19
19
  id: $id
20
20
  expectedSource: $expectedSource
21
21
  creationId: $creationId
22
22
  thumbnail: $thumbnail
23
+ type: $type
23
24
  ) {
24
25
  id
25
26
  source
@@ -9,6 +9,7 @@ import { ScrollbarStyles } from '@operato/styles'
9
9
 
10
10
  import { fetchFigureVersions, inspectFigure, releaseFigure, revertFigureVersion } from '../graphql/index.js'
11
11
  import type { FigureGateFinding, FigureGateFix, FigureGateFixPlan, FigureInspection, FigureVersion } from '../types.js'
12
+ import { OxPrompt } from '@operato/popup'
12
13
 
13
14
  /**
14
15
  * 이력 — **발행하는 자리이자, 발행된 판들을 보는 자리.**
@@ -558,7 +559,14 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
558
559
 
559
560
  private async revert(version: number) {
560
561
  /* 되돌아갈 수 없는 조작은 아니지만(판본은 남는다), 지금 화면의 것이 덮인다 — 이름으로 확인한다. */
561
- if (!confirm(i18next.t('figure.text.revert-to-version-confirm', { version }))) {
562
+ const agreed = await OxPrompt.open({
563
+ type: 'warning',
564
+ title: i18next.t('figure.button.revert'),
565
+ text: i18next.t('figure.text.revert-to-version-confirm', { version }),
566
+ confirmButton: { text: i18next.t('figure.button.revert') },
567
+ cancelButton: { text: i18next.t('figure.button.keep-editing') }
568
+ })
569
+ if (!agreed) {
562
570
  return
563
571
  }
564
572
 
@@ -0,0 +1,98 @@
1
+ import { LitElement, html, css, nothing } from 'lit'
2
+ import { customElement, property, state } from 'lit/decorators.js'
3
+ import { i18next } from '@operato/i18n'
4
+ import { closePopup } from '@operato/popup'
5
+ import '@operato/popup/ox-popup-actions.js'
6
+ import { CommonPopupStyles, FormFieldStyles } from '@operato/styles'
7
+ import '@material/web/button/filled-button.js'
8
+ import '@material/web/button/text-button.js'
9
+
10
+ import { firstSaveRefusalOf, releasableType, typeOf, type FirstSave } from './v3-first-save.js'
11
+
12
+ /**
13
+ * The first save of a new figure: its type and its name, asked once (V2's save dialog, in the suite's popup look).
14
+ * Closes with the answer, or with nothing when cancelled -- then nothing is saved.
15
+ */
16
+ @customElement('figure-v3-first-save')
17
+ export class FigureV3FirstSave extends LitElement {
18
+ static styles = [
19
+ FormFieldStyles,
20
+ CommonPopupStyles,
21
+ css`
22
+ /* The popup's height is a share of the window (small is 40%): on a low window the buttons must still be reached. */
23
+ :host {
24
+ overflow-y: auto;
25
+ }
26
+ div[field] {
27
+ display: grid;
28
+ gap: 4px;
29
+ }
30
+ div[field] label {
31
+ font-size: var(--popup-font-size-small);
32
+ color: var(--md-sys-color-on-surface-variant);
33
+ }
34
+ div[field] input {
35
+ box-sizing: border-box;
36
+ width: 100%;
37
+ }
38
+ input[type-field] {
39
+ font-family: var(--mono-font, monospace);
40
+ }
41
+ `
42
+ ]
43
+
44
+ /** The type the figure has so far -- empty for a temporary one, which is not offered. */
45
+ @property() type = ''
46
+ @property() name = ''
47
+ /** Type names already used in this domain. */
48
+ @property({ attribute: false }) taken: string[] = []
49
+ @state() private refusal: { key: string; type?: string } | null = null
50
+
51
+ render() {
52
+ return html`
53
+ <div class="what">${i18next.t('figure.text.save-new-figure-desc')}</div>
54
+ <div field>
55
+ <label for="type">${i18next.t('figure.label.figure-type')}</label>
56
+ <input
57
+ id="type"
58
+ type-field
59
+ autofocus
60
+ .value=${this.type}
61
+ placeholder=${i18next.t('figure.text.figure-type-example')}
62
+ @input=${(e: Event) => {
63
+ this.type = typeOf((e.target as HTMLInputElement).value)
64
+ ;(e.target as HTMLInputElement).value = this.type
65
+ this.refusal = null
66
+ }}
67
+ @keydown=${this.onKey}
68
+ />
69
+ ${this.type && !releasableType(this.type) ? html`<div class="note">${i18next.t('figure.text.type-name-will-not-release')}</div>` : nothing}
70
+ </div>
71
+ <div field>
72
+ <label for="name">${i18next.t('figure.label.name')}</label>
73
+ <input
74
+ id="name"
75
+ .value=${this.name}
76
+ placeholder=${i18next.t('figure.text.figure-name-example')}
77
+ @input=${(e: Event) => (this.name = (e.target as HTMLInputElement).value)}
78
+ @keydown=${this.onKey}
79
+ />
80
+ </div>
81
+ ${this.refusal ? html`<div class="failure" role="alert">${i18next.t(this.refusal.key, { type: this.refusal.type })}</div>` : nothing}
82
+ <ox-popup-actions>
83
+ <md-text-button slot="cancel" @click=${() => closePopup(this)}>${i18next.t('figure.button.cancel')}</md-text-button>
84
+ <md-filled-button slot="confirm" ?disabled=${!this.type} @click=${this.confirm}>${i18next.t('figure.button.save')}</md-filled-button>
85
+ </ox-popup-actions>
86
+ `
87
+ }
88
+
89
+ private onKey = (e: KeyboardEvent) => {
90
+ if (e.key === 'Enter' && !e.isComposing) this.confirm()
91
+ }
92
+
93
+ private confirm = () => {
94
+ this.refusal = firstSaveRefusalOf(this.type, this.taken)
95
+ if (this.refusal) return
96
+ closePopup(this, { type: this.type, name: this.name.trim() || this.type } satisfies FirstSave)
97
+ }
98
+ }
@@ -9,8 +9,11 @@ import type { V3Asset, V3AuthoringAction, V3DimensionSource, V3Face } from '@hat
9
9
 
10
10
  import {
11
11
  acceptsOf,
12
+ alignAxesOf,
13
+ alignOf,
12
14
  AXES,
13
15
  canFasten,
16
+ facesOf,
14
17
  canFollow,
15
18
  dimensionNow,
16
19
  followableAxes,
@@ -20,19 +23,39 @@ import {
20
23
  followOf,
21
24
  hasInstanceSize,
22
25
  holdsThings,
26
+ leadersFor,
23
27
  motionOf,
28
+ motionJoint,
24
29
  placeFacesOf,
25
30
  placeIdFor,
26
31
  placesOn,
32
+ rowAxesOf,
27
33
  positionsWorkedOut,
34
+ type Alignment,
28
35
  type Axis,
29
- type Follow
36
+ type Follow,
37
+ type MotionForm
30
38
  } from './v3-links.js'
31
39
  import { SidePanelStyles } from './side-panel-styles.js'
32
40
  import { HelpToggles } from './v3-help.js'
33
41
 
34
42
  const FACES = Object.keys(V3_FACES) as V3Face[]
35
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
+
36
59
  /**
37
60
  * What a part is tied to — its size response, what it is fastened to, how it moves (parity rows ④ ⑤ ⑥).
38
61
  *
@@ -78,9 +101,23 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
78
101
  /** Which ties this shows: the part's shape (size and placement, in 속성), its places (자리·능력) or its motion (움직임). */
79
102
  @property() section: 'shape' | 'places' | 'motion' = 'shape'
80
103
 
81
- @state() private fasten = { face: 'bottom' as V3Face, toPlane: false, target: '', targetFace: 'top' as V3Face, facing: 'meet' as 'meet' | 'flush', gap: 0 }
82
- @state() private motion = { kind: 'slide' as 'slide' | 'turn', axis: 'x' as Axis, id: '', min: 0, max: 500 }
83
- @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
+ }
84
121
 
85
122
  render() {
86
123
  const asset = this.asset
@@ -101,6 +138,7 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
101
138
  ${this.renderPositions(asset)}
102
139
  ${canFasten(asset, this.partId) ? this.renderFastening(asset) : html`<p hint>${i18next.t('figure.text.links-need-a-box')}</p>`}
103
140
  </section>
141
+ ${this.renderRow(asset)}
104
142
  `
105
143
  }
106
144
 
@@ -253,6 +291,13 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
253
291
  const to = fastenedTo(asset, this.partId)
254
292
  const f = this.fasten
255
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
256
301
  return html`
257
302
  ${to
258
303
  ? html`<div pair>
@@ -265,7 +310,7 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
265
310
  <div form>
266
311
  <span>${i18next.t('figure.label.my-face')}</span>
267
312
  <select @change=${(e: Event) => (this.fasten = { ...f, face: (e.target as HTMLSelectElement).value as V3Face })}>
268
- ${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>`)}
269
314
  </select>
270
315
  </div>
271
316
  <label toggle>
@@ -282,7 +327,7 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
282
327
  </select>
283
328
  <span>${i18next.t('figure.label.target-face')}</span>
284
329
  <select @change=${(e: Event) => (this.fasten = { ...f, targetFace: (e.target as HTMLSelectElement).value as V3Face })}>
285
- ${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>`)}
286
331
  </select>
287
332
  <span>${i18next.t('figure.label.facing-meet')}</span>
288
333
  <select @change=${(e: Event) => (this.fasten = { ...f, facing: (e.target as HTMLSelectElement).value as 'meet' | 'flush' })}>
@@ -290,9 +335,12 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
290
335
  <option value="flush" ?selected=${f.facing === 'flush'}>${i18next.t('figure.label.facing-flush')}</option>
291
336
  </select>
292
337
  </div>`}
338
+ ${this.renderAlignment(f.toPlane || f.target ? alignAxesOf(asset, this.partId, aim, !!f.twist) : [])}
293
339
  <div form>
294
340
  <span>${i18next.t('figure.label.gap')}</span>
295
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 })} />
296
344
  </div>
297
345
  <button
298
346
  act
@@ -301,10 +349,12 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
301
349
  this.command({
302
350
  kind: 'attach',
303
351
  part: this.partId,
304
- face: f.face,
305
- 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 },
306
354
  ...(f.toPlane ? {} : { facing: f.facing }),
307
355
  ...(f.gap ? { gap: f.gap } : {}),
356
+ twist: f.twist,
357
+ ...(align ? { align } : {}),
308
358
  replace: !!to
309
359
  } as V3AuthoringAction)}
310
360
  >
@@ -313,58 +363,154 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
313
363
  `
314
364
  }
315
365
 
366
+ /**
367
+ * Where on the target face the part sits, on the two axes across it: centred, level with the target's edge on
368
+ * either side, or a distance off centre. A door hung level with the frame's front edge turns about that edge.
369
+ */
370
+ private renderAlignment(axes: { axis: Axis; edges: boolean }[]) {
371
+ const f = this.fasten
372
+ const set = (axis: Axis, how: Alignment) => (this.fasten = { ...f, align: { ...f.align, [axis]: how } })
373
+ const setMm = (axis: Axis, mm: number) => (this.fasten = { ...f, alignMm: { ...f.alignMm, [axis]: mm } })
374
+ return html`<div form>
375
+ ${axes.map(({ axis, edges }) => {
376
+ const how = f.align[axis] === 'min' || f.align[axis] === 'max' ? (edges ? f.align[axis]! : 'centre') : (f.align[axis] ?? 'centre')
377
+ return html`<span>${i18next.t('figure.label.align-' + axis)}</span>
378
+ <select @change=${(e: Event) => set(axis, (e.target as HTMLSelectElement).value as Alignment)}>
379
+ <option value="centre" ?selected=${how === 'centre'}>${i18next.t('figure.label.align-centre')}</option>
380
+ ${edges
381
+ ? html`<option value="min" ?selected=${how === 'min'}>${i18next.t(`figure.label.align-${axis}-min`)}</option>
382
+ <option value="max" ?selected=${how === 'max'}>${i18next.t(`figure.label.align-${axis}-max`)}</option>`
383
+ : nothing}
384
+ <option value="mm" ?selected=${how === 'mm'}>${i18next.t('figure.label.align-mm')}</option>
385
+ </select>
386
+ ${how === 'mm'
387
+ ? html`<span></span><input type="number" step="any" aria-label=${i18next.t('figure.label.align-mm')} .value=${String(f.alignMm[axis] ?? 0)} @change=${(e: Event) => setMm(axis, Number((e.target as HTMLInputElement).value) || 0)} />`
388
+ : nothing}`
389
+ })}
390
+ </div>`
391
+ }
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
+
316
434
  private renderMotion(asset: V3Asset) {
317
435
  const now = motionOf(asset, this.partId)
436
+ const joint = asset.joints?.find(j => j.body1.part === this.partId)
318
437
  if (now) {
319
438
  return html`<div pair>
320
439
  <span>
321
440
  ${i18next.t('figure.label.motion-' + now.kind)} · ${String(now.axis).toUpperCase()}
322
- ${now.control ? html`<small>${now.control.id} (${now.control.unit}) ${now.control.min} … ${now.control.max}</small>` : nothing}
441
+ ${now.follows
442
+ ? html`<small>${i18next.t('figure.label.motion-follows', { joint: now.follows.joint, multiplier: now.follows.multiplier, offset: now.follows.offset ?? 0 })}</small>`
443
+ : now.control
444
+ ? html`<small>${now.control.id} (${now.control.unit}) ${now.control.min} … ${now.control.max}</small>`
445
+ : nothing}
323
446
  </span>
324
- <button act ?disabled=${this.locked} @click=${() => this.command({ kind: 'remove-motion', part: this.partId })}>
447
+ <button
448
+ act
449
+ ?disabled=${this.locked || !joint}
450
+ @click=${() => joint && this.command({ kind: 'set-joint', joint: { id: joint.id, type: 'fixed', body0: joint.body0, body1: joint.body1, ...(joint.origin ? { origin: joint.origin } : {}) } })}
451
+ >
325
452
  <md-icon>close</md-icon>${i18next.t('figure.button.remove-motion')}
326
453
  </button>
327
454
  </div>`
328
455
  }
456
+ /* A joint needs a parent (URDF): a part fastened to nothing is fastened first. */
457
+ if (!joint) return html`<p hint>${i18next.t('figure.text.fasten-before-moving')}</p>`
329
458
  const m = this.motion
330
- const unit = m.kind === 'turn' ? 'deg' : 'mm'
459
+ const unit = m.kind === 'slide' ? 'mm' : 'deg'
460
+ /* A follower follows a joint that moves the same way; a choice the kind no longer allows is dropped. */
461
+ const leaders = leadersFor(asset, this.partId, m.kind)
462
+ const follow = leaders.some(j => j.id === m.follow) ? m.follow : ''
331
463
  return html`
332
464
  <div pair>
333
- <select @change=${(e: Event) => this.setKind((e.target as HTMLSelectElement).value as 'slide' | 'turn')}>
465
+ <select @change=${(e: Event) => this.setKind((e.target as HTMLSelectElement).value as 'slide' | 'turn' | 'continuous')}>
334
466
  <option value="slide" ?selected=${m.kind === 'slide'}>${i18next.t('figure.label.motion-slide')}</option>
335
467
  <option value="turn" ?selected=${m.kind === 'turn'}>${i18next.t('figure.label.motion-turn')}</option>
468
+ <option value="continuous" ?selected=${m.kind === 'continuous'}>${i18next.t('figure.label.motion-continuous')}</option>
336
469
  </select>
337
470
  <select @change=${(e: Event) => (this.motion = { ...m, axis: (e.target as HTMLSelectElement).value as Axis })}>
338
471
  ${AXES.map(a => html`<option value=${a} ?selected=${a === m.axis}>${i18next.t('figure.label.motion-axis')} ${a.toUpperCase()}</option>`)}
339
472
  </select>
340
473
  </div>
341
- <div form>
342
- <span>${i18next.t('figure.label.control-id')}</span>
343
- <input .value=${m.id} placeholder=${`${this.partId}-${m.kind}`} @change=${(e: Event) => (this.motion = { ...m, id: (e.target as HTMLInputElement).value.trim() })} />
344
- <span>${i18next.t('figure.label.control-range')} (${unit})</span>
345
- <div pair>
346
- <input type="number" step="any" .value=${String(m.min)} @change=${(e: Event) => (this.motion = { ...m, min: Number((e.target as HTMLInputElement).value) })} />
347
- <input type="number" step="any" .value=${String(m.max)} @change=${(e: Event) => (this.motion = { ...m, max: Number((e.target as HTMLInputElement).value) })} />
348
- </div>
349
- </div>
474
+ ${leaders.length
475
+ ? html`<div form>
476
+ <span>${i18next.t('figure.label.motion-follow')}</span>
477
+ <select @change=${(e: Event) => (this.motion = { ...m, follow: (e.target as HTMLSelectElement).value })}>
478
+ <option value="" ?selected=${!follow}>${i18next.t('figure.label.motion-own-control')}</option>
479
+ ${leaders.map(j => html`<option value=${j.id} ?selected=${j.id === follow}>${j.body1.part}${j.state ? ` · ${j.state.id}` : ''}</option>`)}
480
+ </select>
481
+ </div>`
482
+ : nothing}
483
+ ${follow
484
+ ? html`<div form>
485
+ <span>${i18next.t('figure.label.motion-multiplier')}</span>
486
+ <input type="number" step="any" .value=${String(m.multiplier)} @change=${(e: Event) => (this.motion = { ...m, multiplier: Number((e.target as HTMLInputElement).value) })} />
487
+ <span>${i18next.t('figure.label.motion-offset')} (${unit})</span>
488
+ <input type="number" step="any" .value=${String(m.offset)} @change=${(e: Event) => (this.motion = { ...m, offset: Number((e.target as HTMLInputElement).value) || 0 })} />
489
+ </div>`
490
+ : html`<div form>
491
+ <span>${i18next.t('figure.label.control-id')}</span>
492
+ <input .value=${m.id} placeholder=${`${this.partId}-${m.kind}`} @change=${(e: Event) => (this.motion = { ...m, id: (e.target as HTMLInputElement).value.trim() })} />
493
+ ${m.kind === 'continuous'
494
+ ? nothing
495
+ : html`<span>${i18next.t('figure.label.control-range')} (${unit})</span>
496
+ <div pair>
497
+ <input type="number" step="any" .value=${String(m.min)} @change=${(e: Event) => (this.motion = { ...m, min: Number((e.target as HTMLInputElement).value) })} />
498
+ <input type="number" step="any" .value=${String(m.max)} @change=${(e: Event) => (this.motion = { ...m, max: Number((e.target as HTMLInputElement).value) })} />
499
+ </div>`}
500
+ </div>`}
350
501
  <button
351
502
  act
352
- ?disabled=${this.locked || !(m.max > m.min)}
353
- @click=${() =>
354
- this.command({
355
- kind: 'add-motion',
356
- part: this.partId,
357
- motion: { kind: m.kind, axis: m.axis },
358
- state: { id: m.id || `${this.partId}-${m.kind}`, unit, min: m.min, max: m.max, start: m.min }
359
- })}
503
+ ?disabled=${this.locked || (follow ? !(Number.isFinite(m.multiplier) && m.multiplier !== 0) : m.kind !== 'continuous' && !(m.max > m.min && m.min <= 0 && 0 <= m.max))}
504
+ title=${!follow && m.kind !== 'continuous' && !(m.min <= 0 && 0 <= m.max) ? i18next.t('figure.text.joint-range-holds-zero') : ''}
505
+ @click=${() => this.command({ kind: 'set-joint', joint: motionJoint(joint, { ...m, follow }, this.partId) })}
360
506
  >
361
507
  <md-icon>animation</md-icon>${i18next.t('figure.button.add-motion')}
362
508
  </button>
363
509
  `
364
510
  }
365
511
 
366
- private setKind(kind: 'slide' | 'turn') {
367
- this.motion = kind === 'turn' ? { ...this.motion, kind, min: -90, max: 90 } : { ...this.motion, kind, min: 0, max: 500 }
512
+ private setKind(kind: 'slide' | 'turn' | 'continuous') {
513
+ this.motion = kind === 'slide' ? { ...this.motion, kind, min: 0, max: 500 } : { ...this.motion, kind, min: -90, max: 90 }
368
514
  }
369
515
 
370
516
  private command(action: V3AuthoringAction) {
@@ -420,6 +420,7 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
420
420
  <select
421
421
  id="detail-level"
422
422
  title=${i18next.t('figure.text.detail-level-explained')}
423
+ .value=${b.level ?? ''}
423
424
  ?disabled=${this.locked}
424
425
  @change=${(e: Event) => {
425
426
  const value = (e.target as HTMLSelectElement).value
@@ -10,6 +10,7 @@ import type { V3AuthoringAction, V3Face } from '@hatiolab/figure-model'
10
10
  import { occupancyFromParts } from './v3-occupancy.js'
11
11
  import { tagsOf, v3FigureFacts, type Placement, type V3FigureFacts } from './v3-figure-facts.js'
12
12
  import { SidePanelStyles } from './side-panel-styles.js'
13
+ import { ScrollbarStyles } from '@operato/styles'
13
14
 
14
15
  const PLACEMENTS: Placement[] = ['floor', 'ceiling', 'center']
15
16
  const FACES = Object.keys(V3_FACES) as V3Face[]
@@ -32,7 +33,7 @@ const FACES = Object.keys(V3_FACES) as V3Face[]
32
33
  */
33
34
  @customElement('figure-v3-properties')
34
35
  export class FigureV3Properties extends localize(i18next)(LitElement) {
35
- static styles = [SidePanelStyles, css`
36
+ static styles = [ScrollbarStyles, SidePanelStyles, css`
36
37
  :host {
37
38
  display: block;
38
39
  /* It stands inside the properties tab's panel, which gives the margin; a second one indented it past the type. */
@@ -37,6 +37,7 @@ interface SceneLayer extends SceneComponent {
37
37
  removeComponent?: (component: SceneComponent) => void
38
38
  invalidate?: () => void
39
39
  fitCameraToModel?: () => void
40
+ setGizmoMode?: (mode: 'translate' | 'rotate' | 'scale') => void
40
41
  app?: unknown
41
42
  }
42
43
  interface SceneHandle {
@@ -153,6 +154,11 @@ export class FigureV3Canvas extends LitElement {
153
154
  this.scene?.root?.fitCameraToModel?.()
154
155
  }
155
156
 
157
+ /** The gizmo in a mode on the selected part, as W, E and R do with the canvas focused -- the scene's toggle. */
158
+ gizmo(mode: 'translate' | 'rotate' | 'scale') {
159
+ this.scene?.root?.setGizmoMode?.(mode)
160
+ }
161
+
156
162
  /**
157
163
  * Stand the board up for this figure, or bring the one standing up to date. A new board only for another figure or
158
164
  * one that has outgrown it -- standing up again loses the selection and the camera.
@@ -276,18 +282,61 @@ export class FigureV3Canvas extends LitElement {
276
282
  A change comes up to the board with the component in the last argument's `origin` (the scene appends
277
283
  { origin, name, deliverer } as it bubbles). Reading the first argument as the component does nothing (V2).
278
284
  */
285
+ /*
286
+ A drag is written once, when it ends. The scene's gizmo writes the component on every move -- a size anchored on
287
+ the opposite face, from the scale it captured when the drag began -- and writing each move back through the
288
+ editor (and the evaluation back onto the component) fought it: the scale gizmo did not work (user, 2026-09-24).
289
+ While a pointer is down the changes are only kept; on release the last one is told, and the board is brought to
290
+ the evaluation. A change with no pointer down (the arrow keys) is told after a short pause.
291
+ */
279
292
  layer.on?.('change', (...args: unknown[]) => {
280
293
  if (this.writing) return
281
294
  const origin = (args[args.length - 1] as { origin?: SceneComponent } | undefined)?.origin
282
295
  const name = origin?.state?.name
283
- const part = typeof name === 'string' ? this.parts.find(p => p.id === name) : undefined
284
- if (!part || !this.board) return
285
- const result = gizmoResultOf(part, origin!.state, this.board)
286
- if (!result.position && !result.rotation && !result.scale) return
287
- this.dispatchEvent(new CustomEvent('part-transformed', { detail: { id: part.id, result }, bubbles: true, composed: true }))
288
- /* After the editor has applied (or refused) it: a refused change springs back to where the inputs put it. */
289
- setTimeout(() => this.sync())
296
+ if (typeof name !== 'string' || !this.parts.some(p => p.id === name)) return
297
+ this.pending.set(name, origin!)
298
+ if (!this.pointerDown) this.commitSoon(80)
290
299
  })
300
+ this.stage.addEventListener('pointerdown', this.onPointerDown, true)
301
+ window.addEventListener('pointerup', this.onPointerUp, true)
302
+ window.addEventListener('pointercancel', this.onPointerUp, true)
303
+ }
304
+
305
+ /** Components the gizmo changed since the last commit, by part name. */
306
+ private pending = new Map<string, SceneComponent>()
307
+ private pointerDown = false
308
+ private commitTimer?: ReturnType<typeof setTimeout>
309
+ private onPointerDown = () => {
310
+ this.pointerDown = true
311
+ }
312
+ private onPointerUp = () => {
313
+ if (!this.pointerDown) return
314
+ this.pointerDown = false
315
+ /* After the scene's own drag end (it settles the depth and rebuilds the geometry on release). */
316
+ this.commitSoon(0)
317
+ }
318
+ private commitSoon(delay: number) {
319
+ clearTimeout(this.commitTimer)
320
+ this.commitTimer = setTimeout(() => this.commit(), delay)
321
+ }
322
+ /** Tell each changed part once, in the inputs' terms; then put the board where the inputs say (a refusal springs back). */
323
+ private commit() {
324
+ /*
325
+ Not while a pointer is down: a pause set by a change just before the press (the arrow keys, the scene settling
326
+ the pick) fired in the middle of the drag and wrote where the part was then -- a drag on a converted part became
327
+ two steps, the first one a few millimetres in (browser check, 2026-09-25). What is pending waits for the release.
328
+ */
329
+ if (this.pointerDown || !this.board || !this.pending.size) return
330
+ const changed = [...this.pending]
331
+ this.pending.clear()
332
+ for (const [name, component] of changed) {
333
+ const part = this.parts.find(p => p.id === name)
334
+ if (!part) continue
335
+ const result = gizmoResultOf(part, component.state, this.board)
336
+ if (!result.position && !result.rotation && !result.scale) continue
337
+ this.dispatchEvent(new CustomEvent('part-transformed', { detail: { id: part.id, result }, bubbles: true, composed: true }))
338
+ }
339
+ setTimeout(() => this.sync())
291
340
  }
292
341
 
293
342
  private showSelected() {
@@ -311,6 +360,11 @@ export class FigureV3Canvas extends LitElement {
311
360
  }
312
361
 
313
362
  private teardown() {
363
+ clearTimeout(this.commitTimer)
364
+ this.pending.clear()
365
+ this.stage?.removeEventListener('pointerdown', this.onPointerDown, true)
366
+ window.removeEventListener('pointerup', this.onPointerUp, true)
367
+ window.removeEventListener('pointercancel', this.onPointerUp, true)
314
368
  this.scene?.dispose?.()
315
369
  this.scene = undefined
316
370
  this.standingFor = undefined