@things-factory/figure-ui 10.1.49 → 10.1.50

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 (91) 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 +89 -26
  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 +91 -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 +72 -5
  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 +46 -7
  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-first-save.d.ts +20 -0
  24. package/dist-client/modeller/figure-v3-first-save.js +118 -0
  25. package/dist-client/modeller/figure-v3-first-save.js.map +1 -0
  26. package/dist-client/modeller/figure-v3-links.d.ts +5 -0
  27. package/dist-client/modeller/figure-v3-links.js +81 -23
  28. package/dist-client/modeller/figure-v3-links.js.map +1 -1
  29. package/dist-client/modeller/figure-v3-parts.js +1 -0
  30. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  31. package/dist-client/modeller/figure-v3-properties.js +2 -1
  32. package/dist-client/modeller/figure-v3-properties.js.map +1 -1
  33. package/dist-client/modeller/v3-canvas.d.ts +11 -0
  34. package/dist-client/modeller/v3-canvas.js +61 -7
  35. package/dist-client/modeller/v3-canvas.js.map +1 -1
  36. package/dist-client/modeller/v3-editor.d.ts +13 -1
  37. package/dist-client/modeller/v3-editor.js +91 -21
  38. package/dist-client/modeller/v3-editor.js.map +1 -1
  39. package/dist-client/modeller/v3-first-save.d.ts +17 -0
  40. package/dist-client/modeller/v3-first-save.js +27 -0
  41. package/dist-client/modeller/v3-first-save.js.map +1 -0
  42. package/dist-client/modeller/v3-gizmo.d.ts +18 -0
  43. package/dist-client/modeller/v3-gizmo.js +45 -2
  44. package/dist-client/modeller/v3-gizmo.js.map +1 -1
  45. package/dist-client/modeller/v3-joint-drag.d.ts +19 -0
  46. package/dist-client/modeller/v3-joint-drag.js +97 -0
  47. package/dist-client/modeller/v3-joint-drag.js.map +1 -0
  48. package/dist-client/modeller/v3-joint-fan.js +24 -11
  49. package/dist-client/modeller/v3-joint-fan.js.map +1 -1
  50. package/dist-client/modeller/v3-keys.d.ts +9 -1
  51. package/dist-client/modeller/v3-keys.js +3 -0
  52. package/dist-client/modeller/v3-keys.js.map +1 -1
  53. package/dist-client/modeller/v3-links.d.ts +45 -2
  54. package/dist-client/modeller/v3-links.js +52 -3
  55. package/dist-client/modeller/v3-links.js.map +1 -1
  56. package/dist-client/modeller/v3-live.d.ts +6 -0
  57. package/dist-client/modeller/v3-live.js +10 -0
  58. package/dist-client/modeller/v3-live.js.map +1 -1
  59. package/dist-client/modeller/v3-product-session.d.ts +4 -1
  60. package/dist-client/modeller/v3-product-session.js +2 -2
  61. package/dist-client/modeller/v3-product-session.js.map +1 -1
  62. package/dist-client/modeller/v3-refusals.d.ts +4 -0
  63. package/dist-client/modeller/v3-refusals.js +21 -0
  64. package/dist-client/modeller/v3-refusals.js.map +1 -0
  65. package/dist-client/pages/figure-v3-modeller-page.d.ts +4 -0
  66. package/dist-client/pages/figure-v3-modeller-page.js +45 -7
  67. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  68. package/dist-client/tsconfig.tsbuildinfo +1 -1
  69. package/package.json +7 -5
  70. package/test/browser/build.mjs +62 -0
  71. package/test/browser/modeller-page.js +198 -0
  72. package/test/browser/modeller.check.mjs +334 -0
  73. package/test/browser/serve.mjs +43 -0
  74. package/test/joints.ts +33 -0
  75. package/test/v3-clips.test.ts +3 -2
  76. package/test/v3-controls.test.ts +3 -2
  77. package/test/v3-fields.test.ts +3 -2
  78. package/test/v3-first-save.test.ts +75 -0
  79. package/test/v3-gizmo.test.ts +38 -5
  80. package/test/v3-joint-drag.test.ts +93 -0
  81. package/test/v3-joint-fan.test.ts +16 -6
  82. package/test/v3-keys.test.ts +15 -2
  83. package/test/v3-links.test.ts +62 -14
  84. package/test/v3-live.test.ts +23 -4
  85. package/test/v3-maturity.test.ts +2 -6
  86. package/test/v3-refusals.test.ts +28 -0
  87. package/translations/en.json +33 -5
  88. package/translations/ja.json +32 -4
  89. package/translations/ko.json +32 -4
  90. package/translations/ms.json +32 -4
  91. package/translations/zh.json +32 -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,6 +9,8 @@ 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,
14
16
  canFollow,
@@ -20,13 +22,17 @@ import {
20
22
  followOf,
21
23
  hasInstanceSize,
22
24
  holdsThings,
25
+ leadersFor,
23
26
  motionOf,
27
+ motionJoint,
24
28
  placeFacesOf,
25
29
  placeIdFor,
26
30
  placesOn,
27
31
  positionsWorkedOut,
32
+ type Alignment,
28
33
  type Axis,
29
- type Follow
34
+ type Follow,
35
+ type MotionForm
30
36
  } from './v3-links.js'
31
37
  import { SidePanelStyles } from './side-panel-styles.js'
32
38
  import { HelpToggles } from './v3-help.js'
@@ -78,8 +84,8 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
78
84
  /** Which ties this shows: the part's shape (size and placement, in 속성), its places (자리·능력) or its motion (움직임). */
79
85
  @property() section: 'shape' | 'places' | 'motion' = 'shape'
80
86
 
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 }
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 }
83
89
  @state() private place = { role: 'grip' as 'seat' | 'grip', face: 'top' as V3Face, label: '', accepts: '', capacity: 1 }
84
90
 
85
91
  render() {
@@ -290,6 +296,7 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
290
296
  <option value="flush" ?selected=${f.facing === 'flush'}>${i18next.t('figure.label.facing-flush')}</option>
291
297
  </select>
292
298
  </div>`}
299
+ ${this.renderAlignment()}
293
300
  <div form>
294
301
  <span>${i18next.t('figure.label.gap')}</span>
295
302
  <input type="number" step="any" .value=${String(f.gap)} @change=${(e: Event) => (this.fasten = { ...f, gap: Number((e.target as HTMLInputElement).value) || 0 })} />
@@ -305,6 +312,7 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
305
312
  to: f.toPlane ? { plane: 'mounting-plane' } : { part: f.target, face: f.targetFace },
306
313
  ...(f.toPlane ? {} : { facing: f.facing }),
307
314
  ...(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) } : {}),
308
316
  replace: !!to
309
317
  } as V3AuthoringAction)}
310
318
  >
@@ -313,58 +321,113 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
313
321
  `
314
322
  }
315
323
 
324
+ /**
325
+ * Where on the target face the part sits, on the two axes across it: centred, level with the target's edge on
326
+ * either side, or a distance off centre. A door hung level with the frame's front edge turns about that edge.
327
+ */
328
+ private renderAlignment() {
329
+ const f = this.fasten
330
+ const set = (axis: Axis, how: Alignment) => (this.fasten = { ...f, align: { ...f.align, [axis]: how } })
331
+ const setMm = (axis: Axis, mm: number) => (this.fasten = { ...f, alignMm: { ...f.alignMm, [axis]: mm } })
332
+ return html`<div form>
333
+ ${alignAxesOf(f.toPlane ? 'plane' : f.targetFace).map(({ axis, edges }) => {
334
+ const how = f.align[axis] === 'min' || f.align[axis] === 'max' ? (edges ? f.align[axis]! : 'centre') : (f.align[axis] ?? 'centre')
335
+ return html`<span>${i18next.t('figure.label.align-' + axis)}</span>
336
+ <select @change=${(e: Event) => set(axis, (e.target as HTMLSelectElement).value as Alignment)}>
337
+ <option value="centre" ?selected=${how === 'centre'}>${i18next.t('figure.label.align-centre')}</option>
338
+ ${edges
339
+ ? html`<option value="min" ?selected=${how === 'min'}>${i18next.t(`figure.label.align-${axis}-min`)}</option>
340
+ <option value="max" ?selected=${how === 'max'}>${i18next.t(`figure.label.align-${axis}-max`)}</option>`
341
+ : nothing}
342
+ <option value="mm" ?selected=${how === 'mm'}>${i18next.t('figure.label.align-mm')}</option>
343
+ </select>
344
+ ${how === 'mm'
345
+ ? 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)} />`
346
+ : nothing}`
347
+ })}
348
+ </div>`
349
+ }
350
+
316
351
  private renderMotion(asset: V3Asset) {
317
352
  const now = motionOf(asset, this.partId)
353
+ const joint = asset.joints?.find(j => j.body1.part === this.partId)
318
354
  if (now) {
319
355
  return html`<div pair>
320
356
  <span>
321
357
  ${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}
358
+ ${now.follows
359
+ ? html`<small>${i18next.t('figure.label.motion-follows', { joint: now.follows.joint, multiplier: now.follows.multiplier, offset: now.follows.offset ?? 0 })}</small>`
360
+ : now.control
361
+ ? html`<small>${now.control.id} (${now.control.unit}) ${now.control.min} … ${now.control.max}</small>`
362
+ : nothing}
323
363
  </span>
324
- <button act ?disabled=${this.locked} @click=${() => this.command({ kind: 'remove-motion', part: this.partId })}>
364
+ <button
365
+ act
366
+ ?disabled=${this.locked || !joint}
367
+ @click=${() => joint && this.command({ kind: 'set-joint', joint: { id: joint.id, type: 'fixed', body0: joint.body0, body1: joint.body1, ...(joint.origin ? { origin: joint.origin } : {}) } })}
368
+ >
325
369
  <md-icon>close</md-icon>${i18next.t('figure.button.remove-motion')}
326
370
  </button>
327
371
  </div>`
328
372
  }
373
+ /* A joint needs a parent (URDF): a part fastened to nothing is fastened first. */
374
+ if (!joint) return html`<p hint>${i18next.t('figure.text.fasten-before-moving')}</p>`
329
375
  const m = this.motion
330
- const unit = m.kind === 'turn' ? 'deg' : 'mm'
376
+ const unit = m.kind === 'slide' ? 'mm' : 'deg'
377
+ /* A follower follows a joint that moves the same way; a choice the kind no longer allows is dropped. */
378
+ const leaders = leadersFor(asset, this.partId, m.kind)
379
+ const follow = leaders.some(j => j.id === m.follow) ? m.follow : ''
331
380
  return html`
332
381
  <div pair>
333
- <select @change=${(e: Event) => this.setKind((e.target as HTMLSelectElement).value as 'slide' | 'turn')}>
382
+ <select @change=${(e: Event) => this.setKind((e.target as HTMLSelectElement).value as 'slide' | 'turn' | 'continuous')}>
334
383
  <option value="slide" ?selected=${m.kind === 'slide'}>${i18next.t('figure.label.motion-slide')}</option>
335
384
  <option value="turn" ?selected=${m.kind === 'turn'}>${i18next.t('figure.label.motion-turn')}</option>
385
+ <option value="continuous" ?selected=${m.kind === 'continuous'}>${i18next.t('figure.label.motion-continuous')}</option>
336
386
  </select>
337
387
  <select @change=${(e: Event) => (this.motion = { ...m, axis: (e.target as HTMLSelectElement).value as Axis })}>
338
388
  ${AXES.map(a => html`<option value=${a} ?selected=${a === m.axis}>${i18next.t('figure.label.motion-axis')} ${a.toUpperCase()}</option>`)}
339
389
  </select>
340
390
  </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>
391
+ ${leaders.length
392
+ ? html`<div form>
393
+ <span>${i18next.t('figure.label.motion-follow')}</span>
394
+ <select @change=${(e: Event) => (this.motion = { ...m, follow: (e.target as HTMLSelectElement).value })}>
395
+ <option value="" ?selected=${!follow}>${i18next.t('figure.label.motion-own-control')}</option>
396
+ ${leaders.map(j => html`<option value=${j.id} ?selected=${j.id === follow}>${j.body1.part}${j.state ? ` · ${j.state.id}` : ''}</option>`)}
397
+ </select>
398
+ </div>`
399
+ : nothing}
400
+ ${follow
401
+ ? html`<div form>
402
+ <span>${i18next.t('figure.label.motion-multiplier')}</span>
403
+ <input type="number" step="any" .value=${String(m.multiplier)} @change=${(e: Event) => (this.motion = { ...m, multiplier: Number((e.target as HTMLInputElement).value) })} />
404
+ <span>${i18next.t('figure.label.motion-offset')} (${unit})</span>
405
+ <input type="number" step="any" .value=${String(m.offset)} @change=${(e: Event) => (this.motion = { ...m, offset: Number((e.target as HTMLInputElement).value) || 0 })} />
406
+ </div>`
407
+ : html`<div form>
408
+ <span>${i18next.t('figure.label.control-id')}</span>
409
+ <input .value=${m.id} placeholder=${`${this.partId}-${m.kind}`} @change=${(e: Event) => (this.motion = { ...m, id: (e.target as HTMLInputElement).value.trim() })} />
410
+ ${m.kind === 'continuous'
411
+ ? nothing
412
+ : html`<span>${i18next.t('figure.label.control-range')} (${unit})</span>
413
+ <div pair>
414
+ <input type="number" step="any" .value=${String(m.min)} @change=${(e: Event) => (this.motion = { ...m, min: Number((e.target as HTMLInputElement).value) })} />
415
+ <input type="number" step="any" .value=${String(m.max)} @change=${(e: Event) => (this.motion = { ...m, max: Number((e.target as HTMLInputElement).value) })} />
416
+ </div>`}
417
+ </div>`}
350
418
  <button
351
419
  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
- })}
420
+ ?disabled=${this.locked || (follow ? !(Number.isFinite(m.multiplier) && m.multiplier !== 0) : m.kind !== 'continuous' && !(m.max > m.min && m.min <= 0 && 0 <= m.max))}
421
+ title=${!follow && m.kind !== 'continuous' && !(m.min <= 0 && 0 <= m.max) ? i18next.t('figure.text.joint-range-holds-zero') : ''}
422
+ @click=${() => this.command({ kind: 'set-joint', joint: motionJoint(joint, { ...m, follow }, this.partId) })}
360
423
  >
361
424
  <md-icon>animation</md-icon>${i18next.t('figure.button.add-motion')}
362
425
  </button>
363
426
  `
364
427
  }
365
428
 
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 }
429
+ private setKind(kind: 'slide' | 'turn' | 'continuous') {
430
+ this.motion = kind === 'slide' ? { ...this.motion, kind, min: 0, max: 500 } : { ...this.motion, kind, min: -90, max: 90 }
368
431
  }
369
432
 
370
433
  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