@things-factory/figure-ui 10.1.48 → 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 (104) 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 +86 -10
  6. package/client/modeller/figure-v3-properties.ts +2 -1
  7. package/client/modeller/side-panel-styles.ts +8 -0
  8. package/client/modeller/v3-canvas.ts +86 -12
  9. package/client/modeller/v3-editor.ts +244 -37
  10. package/client/modeller/v3-fields.ts +15 -1
  11. package/client/modeller/v3-first-save.ts +34 -0
  12. package/client/modeller/v3-gizmo.ts +52 -3
  13. package/client/modeller/v3-joint-drag.ts +116 -0
  14. package/client/modeller/v3-joint-fan.ts +24 -11
  15. package/client/modeller/v3-keys.ts +12 -1
  16. package/client/modeller/v3-links.ts +72 -5
  17. package/client/modeller/v3-live.ts +11 -0
  18. package/client/modeller/v3-product-session.ts +4 -2
  19. package/client/modeller/v3-refusals.ts +21 -0
  20. package/client/modeller/v3-stage-model.ts +24 -0
  21. package/client/pages/figure-v3-modeller-page.ts +46 -7
  22. package/dist-client/graphql/v3-store.js +2 -1
  23. package/dist-client/graphql/v3-store.js.map +1 -1
  24. package/dist-client/modeller/figure-history-panel.js +9 -1
  25. package/dist-client/modeller/figure-history-panel.js.map +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 +5 -0
  30. package/dist-client/modeller/figure-v3-links.js +81 -23
  31. package/dist-client/modeller/figure-v3-links.js.map +1 -1
  32. package/dist-client/modeller/figure-v3-parts.d.ts +13 -0
  33. package/dist-client/modeller/figure-v3-parts.js +81 -10
  34. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  35. package/dist-client/modeller/figure-v3-properties.js +2 -1
  36. package/dist-client/modeller/figure-v3-properties.js.map +1 -1
  37. package/dist-client/modeller/side-panel-styles.js +8 -0
  38. package/dist-client/modeller/side-panel-styles.js.map +1 -1
  39. package/dist-client/modeller/v3-canvas.d.ts +13 -0
  40. package/dist-client/modeller/v3-canvas.js +88 -12
  41. package/dist-client/modeller/v3-canvas.js.map +1 -1
  42. package/dist-client/modeller/v3-clips.d.ts +1 -1
  43. package/dist-client/modeller/v3-editor.d.ts +32 -1
  44. package/dist-client/modeller/v3-editor.js +245 -35
  45. package/dist-client/modeller/v3-editor.js.map +1 -1
  46. package/dist-client/modeller/v3-fields.d.ts +6 -0
  47. package/dist-client/modeller/v3-fields.js +14 -2
  48. package/dist-client/modeller/v3-fields.js.map +1 -1
  49. package/dist-client/modeller/v3-first-save.d.ts +17 -0
  50. package/dist-client/modeller/v3-first-save.js +27 -0
  51. package/dist-client/modeller/v3-first-save.js.map +1 -0
  52. package/dist-client/modeller/v3-gizmo.d.ts +18 -0
  53. package/dist-client/modeller/v3-gizmo.js +45 -2
  54. package/dist-client/modeller/v3-gizmo.js.map +1 -1
  55. package/dist-client/modeller/v3-joint-drag.d.ts +19 -0
  56. package/dist-client/modeller/v3-joint-drag.js +97 -0
  57. package/dist-client/modeller/v3-joint-drag.js.map +1 -0
  58. package/dist-client/modeller/v3-joint-fan.js +24 -11
  59. package/dist-client/modeller/v3-joint-fan.js.map +1 -1
  60. package/dist-client/modeller/v3-keys.d.ts +9 -1
  61. package/dist-client/modeller/v3-keys.js +3 -0
  62. package/dist-client/modeller/v3-keys.js.map +1 -1
  63. package/dist-client/modeller/v3-links.d.ts +45 -2
  64. package/dist-client/modeller/v3-links.js +52 -3
  65. package/dist-client/modeller/v3-links.js.map +1 -1
  66. package/dist-client/modeller/v3-live.d.ts +6 -0
  67. package/dist-client/modeller/v3-live.js +10 -0
  68. package/dist-client/modeller/v3-live.js.map +1 -1
  69. package/dist-client/modeller/v3-product-session.d.ts +4 -1
  70. package/dist-client/modeller/v3-product-session.js +2 -2
  71. package/dist-client/modeller/v3-product-session.js.map +1 -1
  72. package/dist-client/modeller/v3-refusals.d.ts +4 -0
  73. package/dist-client/modeller/v3-refusals.js +21 -0
  74. package/dist-client/modeller/v3-refusals.js.map +1 -0
  75. package/dist-client/modeller/v3-stage-model.d.ts +8 -0
  76. package/dist-client/modeller/v3-stage-model.js +26 -0
  77. package/dist-client/modeller/v3-stage-model.js.map +1 -1
  78. package/dist-client/pages/figure-v3-modeller-page.d.ts +4 -0
  79. package/dist-client/pages/figure-v3-modeller-page.js +45 -7
  80. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  81. package/dist-client/tsconfig.tsbuildinfo +1 -1
  82. package/package.json +7 -5
  83. package/test/browser/build.mjs +62 -0
  84. package/test/browser/modeller-page.js +198 -0
  85. package/test/browser/modeller.check.mjs +334 -0
  86. package/test/browser/serve.mjs +43 -0
  87. package/test/joints.ts +33 -0
  88. package/test/v3-clips.test.ts +3 -2
  89. package/test/v3-controls.test.ts +3 -2
  90. package/test/v3-fields.test.ts +38 -1
  91. package/test/v3-first-save.test.ts +75 -0
  92. package/test/v3-gizmo.test.ts +40 -5
  93. package/test/v3-joint-drag.test.ts +93 -0
  94. package/test/v3-joint-fan.test.ts +16 -6
  95. package/test/v3-keys.test.ts +15 -2
  96. package/test/v3-links.test.ts +62 -14
  97. package/test/v3-live.test.ts +23 -4
  98. package/test/v3-maturity.test.ts +3 -7
  99. package/test/v3-refusals.test.ts +28 -0
  100. package/translations/en.json +43 -5
  101. package/translations/ja.json +42 -4
  102. package/translations/ko.json +42 -4
  103. package/translations/ms.json +42 -4
  104. package/translations/zh.json +42 -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) {
@@ -3,6 +3,7 @@ import { css, html, LitElement, nothing } from 'lit'
3
3
  import { customElement, property } from 'lit/decorators.js'
4
4
  import { i18next, localize } from '@operato/i18n'
5
5
  import { ScrollbarStyles } from '@operato/styles'
6
+ import { PART_LIMIT } from '@hatiolab/figure-model'
6
7
  import type { V3PrimitiveCreation } from '@hatiolab/figure-model'
7
8
  import type { V3BudgetLine } from './v3-maturity.js'
8
9
  import { partLabelOf } from './v3-fields.js'
@@ -28,6 +29,8 @@ export interface V3PartRow {
28
29
  hidden?: boolean
29
30
  /** 자산이 지금 상태에서 보이지 않는다고 한 것. 저장되는 것이고, 여기서는 표시만 한다. */
30
31
  invisible?: boolean
32
+ /** 되풀이한 부품이면 지금 몇 개로 그려지나. 되풀이는 한 부품이다 — 한 줄로 보인다. */
33
+ copies?: number
31
34
  }
32
35
 
33
36
  /** 제공자마다의 아이콘. Material Symbols 이름이다. */
@@ -147,25 +150,61 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
147
150
  font-style: normal;
148
151
  color: var(--md-sys-color-error);
149
152
  }
153
+ span[room][full] {
154
+ color: var(--md-sys-color-error);
155
+ font-weight: 600;
156
+ }
157
+ p[full] {
158
+ margin: 4px 0 0;
159
+ font-size: 0.68rem;
160
+ color: var(--md-sys-color-error);
161
+ }
162
+ div[level] {
163
+ display: grid;
164
+ grid-template-columns: auto minmax(0, 1fr);
165
+ align-items: center;
166
+ gap: 6px;
167
+ margin: 0 0 6px;
168
+ font-size: 0.68rem;
169
+ color: var(--md-sys-color-on-surface-variant);
170
+ }
171
+ div[level] select {
172
+ min-width: 0;
173
+ padding: 2px 4px;
174
+ font: inherit;
175
+ font-size: 0.7rem;
176
+ color: var(--md-sys-color-on-surface);
177
+ background: var(--md-sys-color-surface-container-lowest);
178
+ border: 1px solid var(--md-sys-color-outline-variant);
179
+ border-radius: 6px;
180
+ }
181
+ small[copies] {
182
+ color: var(--md-sys-color-on-surface-variant);
183
+ }
150
184
  span[room] {
151
185
  font-size: 0.7rem;
152
186
  font-weight: 400;
153
187
  letter-spacing: 0;
154
188
  text-transform: none;
155
189
  }
190
+ /* Three to a row, small (user, 2026-09-24): the palette is reached for, not read -- the list below is what grows. */
156
191
  div[palette] {
157
192
  display: grid;
158
- grid-template-columns: repeat(2, 1fr);
159
- gap: 4px;
193
+ grid-template-columns: repeat(3, minmax(0, 1fr));
194
+ gap: 3px;
160
195
  }
161
196
  button[shape] {
162
197
  display: flex;
163
198
  flex-direction: column;
164
199
  align-items: center;
165
- gap: 2px;
166
- padding: 7px 2px 5px 2px;
200
+ gap: 1px;
201
+ min-width: 0;
202
+ padding: 5px 1px 4px;
167
203
  font: var(--label-font, inherit);
168
- font-size: 0.66rem;
204
+ font-size: 0.6rem;
205
+ line-height: 1.2;
206
+ text-align: center;
207
+ overflow-wrap: anywhere;
169
208
  color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
170
209
  background: none;
171
210
  border: 1px solid var(--md-sys-color-outline-variant);
@@ -182,7 +221,7 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
182
221
  cursor: default;
183
222
  }
184
223
  button[shape] md-icon {
185
- --md-icon-size: 20px;
224
+ --md-icon-size: 18px;
186
225
  }
187
226
  ol {
188
227
  margin: 0;
@@ -323,8 +362,15 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
323
362
  * 기준이 저작을 이끈다(V2 모델러의 정체성). 등급이 D 면 무엇이 넘쳤는지를 적는다. 누르면 성숙도 탭이 열린다.
324
363
  */
325
364
  @property({ attribute: false }) budget?: V3BudgetLine
365
+ /**
366
+ * 디테일 등급의 부품 한도 — V2 의 S/M/L(사용자 2026-09-24). 부품을 넣기 **전에** 몇 개 남았는지 보이고, 다 차면 팔레트가
367
+ * 잠긴다. 되풀이는 한 부품으로 센다. 등급을 안 정했으면 한도는 없다.
368
+ */
369
+ @property({ attribute: false }) partBudget?: { level: 'S' | 'M' | 'L' | null; used: number; limit: number | null }
326
370
 
327
371
  render() {
372
+ const b = this.partBudget
373
+ const full = !!b && b.limit !== null && b.used >= b.limit
328
374
  return html`
329
375
  <section>
330
376
  <h3>${i18next.t('figure.label.shapes')}</h3>
@@ -333,8 +379,8 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
333
379
  recipe => html`
334
380
  <button
335
381
  shape
336
- ?disabled=${this.locked}
337
- title=${i18next.t(recipe.label)}
382
+ ?disabled=${this.locked || full}
383
+ title=${full ? i18next.t('figure.text.part-limit-reached') : i18next.t(recipe.label)}
338
384
  @click=${() => this.ask('add-part', { kind: recipe.kind })}
339
385
  >
340
386
  <md-icon>${recipe.icon}</md-icon>
@@ -343,14 +389,18 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
343
389
  `
344
390
  )}
345
391
  </div>
392
+ ${full ? html`<p full>${i18next.t('figure.text.part-limit-reached-short', { level: b!.level, limit: b!.limit })}</p>` : nothing}
346
393
  </section>
347
394
 
348
395
  <section list>
349
396
  <h3>
350
397
  <span>${i18next.t('figure.label.parts')}</span>
351
- <span room>${this.parts.length}</span>
398
+ <span room ?full=${full} title=${i18next.t('figure.text.limit-comes-from-level')}
399
+ >${b && b.limit !== null ? i18next.t('figure.text.n-of-limit', { used: b.used, limit: b.limit }) : (b?.used ?? this.parts.length)}</span
400
+ >
352
401
  ${this.renderShowAll()}
353
402
  </h3>
403
+ ${this.renderLevel()}
354
404
  ${this.renderBudget()}
355
405
  ${this.parts.length === 0
356
406
  ? html`<p quiet>${i18next.t('figure.text.no-parts-yet')}</p>`
@@ -361,6 +411,32 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
361
411
  `
362
412
  }
363
413
 
414
+ /** 디테일 등급 — 고르면 한도가 바뀐다. V2 의 자리 그대로, 목록 머리 아래. */
415
+ private renderLevel() {
416
+ const b = this.partBudget
417
+ if (!b) return nothing
418
+ return html`<div level>
419
+ <label for="detail-level">${i18next.t('figure.label.detail-level')}</label>
420
+ <select
421
+ id="detail-level"
422
+ title=${i18next.t('figure.text.detail-level-explained')}
423
+ .value=${b.level ?? ''}
424
+ ?disabled=${this.locked}
425
+ @change=${(e: Event) => {
426
+ const value = (e.target as HTMLSelectElement).value
427
+ this.dispatchEvent(new CustomEvent('set-detail-level', { detail: { level: value || null }, bubbles: true, composed: true }))
428
+ }}
429
+ >
430
+ <option value="" ?selected=${!b.level}>${i18next.t('figure.label.detail-none')}</option>
431
+ ${(['S', 'M', 'L'] as const).map(
432
+ one => html`<option value=${one} ?selected=${one === b.level}>
433
+ ${one} ${i18next.t('figure.label.detail-' + one)} · ${i18next.t('figure.text.n-parts-at-most', { limit: PART_LIMIT[one] })}
434
+ </option>`
435
+ )}
436
+ </select>
437
+ </div>`
438
+ }
439
+
364
440
  private renderBudget() {
365
441
  const b = this.budget
366
442
  if (!b || !this.parts.length) return nothing
@@ -434,7 +510,7 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
434
510
  </button>
435
511
  <md-icon>${PROVIDER_ICON[part.provider] ?? 'deployed_code'}</md-icon>
436
512
  <span label title=${part.id}
437
- >${partLabelOf(part.id)}${part.invisible ? html`<small>${i18next.t('figure.label.state-hidden')}</small>` : nothing}</span
513
+ >${partLabelOf(part.id)}${part.copies ? html` <small copies>× ${part.copies}</small>` : nothing}${part.invisible ? html`<small>${i18next.t('figure.label.state-hidden')}</small>` : nothing}</span
438
514
  >
439
515
  ${selected ? this.renderOps(part.id, index) : nothing}
440
516
  </li>
@@ -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. */
@@ -88,6 +88,14 @@ export const SidePanelStyles = css`
88
88
  overflow-wrap: anywhere;
89
89
  white-space: normal;
90
90
  }
91
+ /* A value read, not edited, beside its label: the field's size and colour, not the page's. */
92
+ div[inline-field] > span[value] {
93
+ font-size: 0.76rem;
94
+ color: var(--md-sys-color-on-surface);
95
+ }
96
+ label span[unit] {
97
+ color: var(--md-sys-color-outline);
98
+ }
91
99
  div[inline-field] + div[inline-field],
92
100
  div[inline-field] + div[vector-row],
93
101
  div[vector-row] + div[inline-field] {