@things-factory/figure-ui 10.1.46 → 10.1.47

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 (138) hide show
  1. package/client/modeller/angle-dial.ts +70 -0
  2. package/client/modeller/figure-settings.ts +328 -0
  3. package/client/modeller/figure-v3-capability.ts +137 -0
  4. package/client/modeller/figure-v3-clips.ts +253 -0
  5. package/client/modeller/figure-v3-controls.ts +212 -0
  6. package/client/modeller/figure-v3-links.ts +61 -81
  7. package/client/modeller/figure-v3-material.ts +108 -35
  8. package/client/modeller/figure-v3-parts.ts +96 -3
  9. package/client/modeller/figure-v3-preview.ts +326 -0
  10. package/client/modeller/figure-v3-properties.ts +29 -57
  11. package/client/modeller/figure-v3-verdict.ts +598 -60
  12. package/client/modeller/figure-view.ts +152 -0
  13. package/client/modeller/side-panel-styles.ts +326 -0
  14. package/client/modeller/v3-canvas.ts +221 -263
  15. package/client/modeller/v3-clips.ts +89 -0
  16. package/client/modeller/v3-controls.ts +54 -0
  17. package/client/modeller/v3-editor.ts +531 -223
  18. package/client/modeller/v3-fields.ts +62 -0
  19. package/client/modeller/v3-help.ts +41 -0
  20. package/client/modeller/v3-keys.ts +34 -0
  21. package/client/modeller/v3-links.ts +93 -9
  22. package/client/modeller/v3-live.ts +79 -0
  23. package/client/modeller/v3-maturity.ts +415 -0
  24. package/client/modeller/v3-palette.ts +45 -0
  25. package/client/modeller/v3-preview-model.ts +54 -0
  26. package/client/modeller/v3-stage-model.ts +159 -0
  27. package/client/pages/figure-list-page.ts +4 -4
  28. package/client/pages/figure-v3-modeller-page.ts +66 -134
  29. package/dist-client/modeller/angle-dial.d.ts +32 -0
  30. package/dist-client/modeller/angle-dial.js +41 -0
  31. package/dist-client/modeller/angle-dial.js.map +1 -0
  32. package/dist-client/modeller/figure-settings.d.ts +39 -0
  33. package/dist-client/modeller/figure-settings.js +317 -0
  34. package/dist-client/modeller/figure-settings.js.map +1 -0
  35. package/dist-client/modeller/figure-v3-capability.d.ts +22 -0
  36. package/dist-client/modeller/figure-v3-capability.js +141 -0
  37. package/dist-client/modeller/figure-v3-capability.js.map +1 -0
  38. package/dist-client/modeller/figure-v3-clips.d.ts +31 -0
  39. package/dist-client/modeller/figure-v3-clips.js +268 -0
  40. package/dist-client/modeller/figure-v3-clips.js.map +1 -0
  41. package/dist-client/modeller/figure-v3-controls.d.ts +36 -0
  42. package/dist-client/modeller/figure-v3-controls.js +226 -0
  43. package/dist-client/modeller/figure-v3-controls.js.map +1 -0
  44. package/dist-client/modeller/figure-v3-links.d.ts +5 -2
  45. package/dist-client/modeller/figure-v3-links.js +64 -82
  46. package/dist-client/modeller/figure-v3-links.js.map +1 -1
  47. package/dist-client/modeller/figure-v3-material.d.ts +5 -2
  48. package/dist-client/modeller/figure-v3-material.js +109 -35
  49. package/dist-client/modeller/figure-v3-material.js.map +1 -1
  50. package/dist-client/modeller/figure-v3-parts.d.ts +10 -0
  51. package/dist-client/modeller/figure-v3-parts.js +88 -3
  52. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  53. package/dist-client/modeller/figure-v3-preview.d.ts +34 -0
  54. package/dist-client/modeller/figure-v3-preview.js +322 -0
  55. package/dist-client/modeller/figure-v3-preview.js.map +1 -0
  56. package/dist-client/modeller/figure-v3-properties.d.ts +2 -2
  57. package/dist-client/modeller/figure-v3-properties.js +29 -57
  58. package/dist-client/modeller/figure-v3-properties.js.map +1 -1
  59. package/dist-client/modeller/figure-v3-verdict.d.ts +31 -17
  60. package/dist-client/modeller/figure-v3-verdict.js +579 -59
  61. package/dist-client/modeller/figure-v3-verdict.js.map +1 -1
  62. package/dist-client/modeller/figure-view.d.ts +118 -0
  63. package/dist-client/modeller/figure-view.js +78 -0
  64. package/dist-client/modeller/figure-view.js.map +1 -0
  65. package/dist-client/modeller/side-panel-styles.d.ts +23 -0
  66. package/dist-client/modeller/side-panel-styles.js +326 -0
  67. package/dist-client/modeller/side-panel-styles.js.map +1 -0
  68. package/dist-client/modeller/v3-canvas.d.ts +29 -37
  69. package/dist-client/modeller/v3-canvas.js +188 -260
  70. package/dist-client/modeller/v3-canvas.js.map +1 -1
  71. package/dist-client/modeller/v3-clips.d.ts +39 -0
  72. package/dist-client/modeller/v3-clips.js +58 -0
  73. package/dist-client/modeller/v3-clips.js.map +1 -0
  74. package/dist-client/modeller/v3-controls.d.ts +28 -0
  75. package/dist-client/modeller/v3-controls.js +28 -0
  76. package/dist-client/modeller/v3-controls.js.map +1 -0
  77. package/dist-client/modeller/v3-editor.d.ts +58 -1
  78. package/dist-client/modeller/v3-editor.js +555 -218
  79. package/dist-client/modeller/v3-editor.js.map +1 -1
  80. package/dist-client/modeller/v3-fields.d.ts +30 -0
  81. package/dist-client/modeller/v3-fields.js +46 -0
  82. package/dist-client/modeller/v3-fields.js.map +1 -0
  83. package/dist-client/modeller/v3-help.d.ts +18 -0
  84. package/dist-client/modeller/v3-help.js +37 -0
  85. package/dist-client/modeller/v3-help.js.map +1 -0
  86. package/dist-client/modeller/v3-keys.d.ts +14 -0
  87. package/dist-client/modeller/v3-keys.js +23 -0
  88. package/dist-client/modeller/v3-keys.js.map +1 -0
  89. package/dist-client/modeller/v3-links.d.ts +30 -1
  90. package/dist-client/modeller/v3-links.js +94 -8
  91. package/dist-client/modeller/v3-links.js.map +1 -1
  92. package/dist-client/modeller/v3-live.d.ts +31 -0
  93. package/dist-client/modeller/v3-live.js +58 -0
  94. package/dist-client/modeller/v3-live.js.map +1 -0
  95. package/dist-client/modeller/v3-maturity.d.ts +120 -0
  96. package/dist-client/modeller/v3-maturity.js +317 -0
  97. package/dist-client/modeller/v3-maturity.js.map +1 -0
  98. package/dist-client/modeller/v3-palette.d.ts +7 -0
  99. package/dist-client/modeller/v3-palette.js +42 -0
  100. package/dist-client/modeller/v3-palette.js.map +1 -0
  101. package/dist-client/modeller/v3-preview-model.d.ts +32 -0
  102. package/dist-client/modeller/v3-preview-model.js +48 -0
  103. package/dist-client/modeller/v3-preview-model.js.map +1 -0
  104. package/dist-client/modeller/v3-stage-model.d.ts +31 -0
  105. package/dist-client/modeller/v3-stage-model.js +121 -0
  106. package/dist-client/modeller/v3-stage-model.js.map +1 -0
  107. package/dist-client/pages/figure-list-page.js +4 -4
  108. package/dist-client/pages/figure-list-page.js.map +1 -1
  109. package/dist-client/pages/figure-v3-modeller-page.d.ts +2 -6
  110. package/dist-client/pages/figure-v3-modeller-page.js +64 -136
  111. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  112. package/dist-client/tsconfig.chk.tsbuildinfo +1 -0
  113. package/dist-client/tsconfig.scratch-fm.tsbuildinfo +1 -1
  114. package/dist-client/tsconfig.tsbuildinfo +1 -1
  115. package/package.json +6 -6
  116. package/test/v3-clips.test.ts +70 -0
  117. package/test/v3-controls.test.ts +68 -0
  118. package/test/v3-fields.test.ts +62 -0
  119. package/test/v3-gizmo.test.ts +3 -5
  120. package/test/v3-keys.test.ts +37 -0
  121. package/test/v3-layout.test.ts +78 -0
  122. package/test/v3-links.test.ts +77 -6
  123. package/test/v3-live.test.ts +99 -0
  124. package/test/v3-material.test.ts +6 -0
  125. package/test/v3-maturity.test.ts +268 -0
  126. package/test/v3-parts-wiring.test.ts +3 -1
  127. package/test/v3-preview.test.ts +42 -0
  128. package/test/v3-properties.test.ts +4 -2
  129. package/test/v3-scene-canvas.test.ts +85 -0
  130. package/test/v3-stage-model.test.ts +25 -0
  131. package/test/v3-theme.test.ts +16 -25
  132. package/test/v3-verdict.test.ts +12 -6
  133. package/test/v3-view.test.ts +20 -0
  134. package/translations/en.json +84 -5
  135. package/translations/ja.json +86 -7
  136. package/translations/ko.json +84 -5
  137. package/translations/ms.json +86 -7
  138. package/translations/zh.json +86 -7
@@ -12,6 +12,9 @@ import {
12
12
  AXES,
13
13
  canFasten,
14
14
  canFollow,
15
+ dimensionNow,
16
+ followableAxes,
17
+ positionIsShare,
15
18
  choiceOf,
16
19
  fastenedTo,
17
20
  followOf,
@@ -25,6 +28,8 @@ import {
25
28
  type Axis,
26
29
  type Follow
27
30
  } from './v3-links.js'
31
+ import { SidePanelStyles } from './side-panel-styles.js'
32
+ import { HelpToggles } from './v3-help.js'
28
33
 
29
34
  const FACES = Object.keys(V3_FACES) as V3Face[]
30
35
 
@@ -43,76 +48,25 @@ const FACES = Object.keys(V3_FACES) as V3Face[]
43
48
  */
44
49
  @customElement('figure-v3-links')
45
50
  export class FigureV3Links extends localize(i18next)(LitElement) {
46
- static styles = css`
51
+ private readonly help = new HelpToggles(this)
52
+ static styles = [SidePanelStyles, css`
47
53
  :host {
48
- display: grid;
49
- gap: 10px;
54
+ display: block;
50
55
  }
51
- h3 {
52
- font-size: var(--ui-t-body, 13px);
53
- margin: 12px 0 0;
56
+ /* The measure's name, then its rule: X, or a cylinder's 반지름 · 길이, on one line. */
57
+ div[row] > b {
58
+ white-space: nowrap;
54
59
  }
55
60
  div[row] {
56
61
  display: grid;
57
- grid-template-columns: 24px minmax(0, 1fr);
58
- gap: 6px;
59
- align-items: center;
60
- }
61
- div[form] {
62
- display: grid;
63
- grid-template-columns: 96px minmax(0, 1fr);
62
+ grid-template-columns: minmax(24px, max-content) minmax(0, 1fr);
64
63
  gap: 6px;
65
64
  align-items: center;
66
65
  }
67
- div[pair] {
68
- display: flex;
69
- gap: 6px;
70
- }
71
- div[pair] > * {
72
- flex: 1;
73
- min-width: 0;
74
- }
75
- select,
76
- input,
77
- button {
78
- font: inherit;
79
- min-width: 0;
80
- }
81
- button[act] {
82
- justify-self: start;
83
- display: inline-flex;
84
- align-items: center;
85
- gap: 4px;
86
- padding: 4px 10px;
87
- border: none;
88
- border-radius: 999em;
89
- cursor: pointer;
90
- color: var(--md-sys-color-on-secondary-container);
91
- background: var(--md-sys-color-secondary-container);
92
- }
93
- button[act]:disabled {
94
- opacity: 0.5;
95
- cursor: default;
96
- }
97
- button[act] md-icon {
98
- font-size: 16px;
99
- width: 16px;
100
- height: 16px;
101
- }
102
- small,
103
- p[hint] {
104
- margin: 0;
105
- color: var(--md-sys-color-on-surface-variant);
106
- }
107
66
  b[bound] {
108
67
  font-weight: 600;
109
68
  }
110
- label[toggle] {
111
- display: flex;
112
- align-items: center;
113
- gap: 6px;
114
- }
115
- `
69
+ `]
116
70
 
117
71
  /** The asset being edited. */
118
72
  @property({ attribute: false }) asset?: V3Asset
@@ -121,6 +75,8 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
121
75
  /** The part's own extent along its axes now, in mm — what a dimension is fixed at when it stops following. */
122
76
  @property({ attribute: false }) dims?: [number, number, number]
123
77
  @property({ type: Boolean }) locked = false
78
+ /** Which ties this shows: the part's shape (size and placement, in 속성), its places (자리·능력) or its motion (움직임). */
79
+ @property() section: 'shape' | 'places' | 'motion' = 'shape'
124
80
 
125
81
  @state() private fasten = { face: 'bottom' as V3Face, toPlane: false, target: '', targetFace: 'top' as V3Face, facing: 'meet' as 'meet' | 'flush', gap: 0 }
126
82
  @state() private motion = { kind: 'slide' as 'slide' | 'turn', axis: 'x' as Axis, id: '', min: 0, max: 500 }
@@ -129,16 +85,22 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
129
85
  render() {
130
86
  const asset = this.asset
131
87
  if (!asset || !this.partId) return nothing
88
+ if (this.section === 'motion')
89
+ return html`<section><h3>${i18next.t('figure.label.motion')}${this.help.button('figure.text.motion-explained')}</h3>${this.help.hint('figure.text.motion-explained')}
90
+ ${this.renderMotion(asset)}</section>`
91
+ if (this.section === 'places')
92
+ return html`<section><h3>${i18next.t('figure.label.places-on-part', { part: this.partId })}${this.help.button('figure.text.place-explained')}</h3>${this.help.hint('figure.text.place-explained')}
93
+ ${placeFacesOf(asset, this.partId).length ? this.renderPlaces(asset) : html`<p hint>${i18next.t('figure.text.places-need-a-box')}</p>`}</section>`
132
94
  return html`
133
- <h3>${i18next.t('figure.label.dimensions')}</h3>
134
- ${canFollow(asset, this.partId) ? this.renderDimensions(asset) : html`<p hint>${i18next.t('figure.text.links-need-a-box')}</p>`}
135
- <h3>${i18next.t('figure.label.placement')}</h3>
136
- ${this.renderPositions(asset)}
137
- ${canFasten(asset, this.partId) ? this.renderFastening(asset) : html`<p hint>${i18next.t('figure.text.links-need-a-box')}</p>`}
138
- <h3>${i18next.t('figure.label.motion')}</h3>
139
- ${this.renderMotion(asset)}
140
- <h3>${i18next.t('figure.label.places')}</h3>
141
- ${placeFacesOf(asset, this.partId).length ? this.renderPlaces(asset) : html`<p hint>${i18next.t('figure.text.places-need-a-box')}</p>`}
95
+ <section>
96
+ <h3>${i18next.t('figure.label.dimensions')}</h3>
97
+ ${canFollow(asset, this.partId) ? this.renderDimensions(asset) : html`<p hint>${i18next.t('figure.text.links-need-a-box')}</p>`}
98
+ </section>
99
+ <section>
100
+ <h3>${i18next.t('figure.label.placement')}${this.help.button('figure.text.fastening-explained')}</h3>${this.help.hint('figure.text.fastening-explained')}
101
+ ${this.renderPositions(asset)}
102
+ ${canFasten(asset, this.partId) ? this.renderFastening(asset) : html`<p hint>${i18next.t('figure.text.links-need-a-box')}</p>`}
103
+ </section>
142
104
  `
143
105
  }
144
106
 
@@ -162,7 +124,6 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
162
124
  </button>
163
125
  </div>`
164
126
  )}
165
- <p hint>${i18next.t('figure.text.place-explained')}</p>
166
127
  ${holdsThings(asset)
167
128
  ? nothing
168
129
  : html`<p hint>${i18next.t('figure.text.place-needs-holding')}</p>
@@ -215,17 +176,23 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
215
176
 
216
177
  private renderDimensions(asset: V3Asset) {
217
178
  const others = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFollow(asset, one))
218
- return AXES.map(axis => {
179
+ const round = followableAxes(asset, this.partId).length === 2
180
+ return followableAxes(asset, this.partId).map(axis => {
219
181
  const now = followOf(asset, this.partId, axis)
220
182
  const value = choiceOf(now)
183
+ /* A cylinder's two measures are its radius and its length; its X and Y name them. */
184
+ const name = round ? i18next.t(axis === 'x' ? 'figure.label.field-radius' : 'figure.label.field-length') : axis.toUpperCase()
221
185
  return html`<div row>
222
- <b>${axis.toUpperCase()}</b>
223
- <select ?disabled=${this.locked || now.of === 'expression'} @change=${(e: Event) => this.follow(axis, (e.target as HTMLSelectElement).value, now)}>
186
+ <b>${name}</b>
187
+ <select ?disabled=${this.locked} @change=${(e: Event) => this.follow(asset, axis, (e.target as HTMLSelectElement).value, now)}>
224
188
  <option value="own" ?selected=${value === 'own'}>${i18next.t('figure.label.follows-nothing')}</option>
225
189
  ${AXES.filter(one => hasInstanceSize(asset, one)).map(
226
- one => html`<option value=${`instance:${one}`} ?selected=${value === `instance:${one}`}>
227
- ${i18next.t('figure.label.follows-instance')} · ${one.toUpperCase()}
228
- </option>`
190
+ one => html`<option value=${`share:${one}`} ?selected=${value === `share:${one}`}>
191
+ ${i18next.t('figure.label.follows-instance-share')} · ${one.toUpperCase()}
192
+ </option>
193
+ <option value=${`instance:${one}`} ?selected=${value === `instance:${one}`}>
194
+ ${i18next.t('figure.label.follows-instance')} · ${one.toUpperCase()}
195
+ </option>`
229
196
  )}
230
197
  ${others.flatMap(other =>
231
198
  AXES.map(
@@ -240,15 +207,24 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
240
207
  })
241
208
  }
242
209
 
243
- private follow(axis: Axis, choice: string, now: Follow) {
210
+ private follow(asset: V3Asset, axis: Axis, choice: string, now: Follow) {
244
211
  const following = now.of !== 'own'
212
+ /* The measure as it stands: a dimension stops following at it, and a share is taken of it. */
213
+ const current = dimensionNow(asset, this.partId, axis) ?? this.dims?.[AXES.indexOf(axis)] ?? 100
245
214
  if (choice === 'own') {
246
215
  if (!following) return
247
- const mm = Math.max(1, Math.round(this.dims?.[AXES.indexOf(axis)] ?? 100))
248
- this.command({ kind: 'unlink-dimension', part: this.partId, axis, mm })
216
+ this.command({ kind: 'unlink-dimension', part: this.partId, axis, mm: Math.max(1, Math.round(current)) })
249
217
  return
250
218
  }
251
219
  const [of, a, b] = choice.split(':')
220
+ if (of === 'share') {
221
+ /* V2's proportional rule: this measure is the share of the instance size it is now, so it grows with the figure. */
222
+ const size = asset.designInputs[`size.${a}`]
223
+ if (!size) return
224
+ const times = Math.round((current / size) * 1e6) / 1e6
225
+ this.command({ kind: 'link-dimension', part: this.partId, axis, source: { of: 'instance', axis: a as Axis }, times, replace: following })
226
+ return
227
+ }
252
228
  const source: V3DimensionSource = of === 'instance' ? { of: 'instance', axis: a as Axis } : { of: 'part', part: a!, axis: b as Axis }
253
229
  this.command({ kind: 'link-dimension', part: this.partId, axis, source, replace: following })
254
230
  }
@@ -260,10 +236,15 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
260
236
  private renderPositions(asset: V3Asset) {
261
237
  return positionsWorkedOut(asset, this.partId).map(
262
238
  axis => html`<div pair>
263
- <span>${axis.toUpperCase()} · ${i18next.t('figure.label.position-worked-out')}</span>
239
+ <span>${axis.toUpperCase()} · ${i18next.t(positionIsShare(asset, this.partId, axis) ? 'figure.label.position-in-proportion' : 'figure.label.position-worked-out')}</span>
264
240
  <button act ?disabled=${this.locked} @click=${() => this.command({ kind: 'fix-position', part: this.partId, axis })}>
265
241
  <md-icon>push_pin</md-icon>${i18next.t('figure.button.fix-position')}
266
242
  </button>
243
+ ${hasInstanceSize(asset, axis) && !positionIsShare(asset, this.partId, axis)
244
+ ? html`<button act ?disabled=${this.locked} title=${i18next.t('figure.text.scale-position-explained')} @click=${() => this.command({ kind: 'scale-position', part: this.partId, axis })}>
245
+ <md-icon>open_in_full</md-icon>${i18next.t('figure.button.scale-position')}
246
+ </button>`
247
+ : nothing}
267
248
  </div>`
268
249
  )
269
250
  }
@@ -280,7 +261,7 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
280
261
  <md-icon>link_off</md-icon>${i18next.t('figure.button.detach')}
281
262
  </button>
282
263
  </div>`
283
- : html`<p hint>${i18next.t('figure.text.fastening-explained')}</p>`}
264
+ : nothing}
284
265
  <div form>
285
266
  <span>${i18next.t('figure.label.my-face')}</span>
286
267
  <select @change=${(e: Event) => (this.fasten = { ...f, face: (e.target as HTMLSelectElement).value as V3Face })}>
@@ -348,7 +329,6 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
348
329
  const m = this.motion
349
330
  const unit = m.kind === 'turn' ? 'deg' : 'mm'
350
331
  return html`
351
- <p hint>${i18next.t('figure.text.motion-explained')}</p>
352
332
  <div pair>
353
333
  <select @change=${(e: Event) => this.setKind((e.target as HTMLSelectElement).value as 'slide' | 'turn')}>
354
334
  <option value="slide" ?selected=${m.kind === 'slide'}>${i18next.t('figure.label.motion-slide')}</option>
@@ -1,10 +1,11 @@
1
- import { css, html, LitElement, nothing } from 'lit'
2
- import { customElement, property } from 'lit/decorators.js'
1
+ import { css, html, LitElement, nothing, svg } from 'lit'
2
+ import { customElement, property, state } from 'lit/decorators.js'
3
3
 
4
4
  import { i18next, localize } from '@operato/i18n'
5
5
  import { MATERIAL_PRESETS, SEGMENT_PRESETS, v3TrianglesOf } from '@hatiolab/figure-model'
6
6
  import { activePalette } from '@hatiolab/things-scene'
7
7
  import type { V3AssetGeometry, V3Material, V3PartEdit } from '@hatiolab/figure-model'
8
+ import { SidePanelStyles } from './side-panel-styles.js'
8
9
 
9
10
  /** The purpose each palette role is for, by its token — the V2 inspector's words for the same roles. */
10
11
  const PURPOSE: Record<string, string> = {
@@ -40,14 +41,9 @@ const DEFAULT_SEGMENTS = 12
40
41
  */
41
42
  @customElement('figure-v3-material')
42
43
  export class FigureV3Material extends localize(i18next)(LitElement) {
43
- static styles = css`
44
+ static styles = [SidePanelStyles, css`
44
45
  :host {
45
- display: grid;
46
- gap: 10px;
47
- }
48
- h3 {
49
- font-size: var(--ui-t-body, 13px);
50
- margin: 12px 0 0;
46
+ display: block;
51
47
  }
52
48
  div[field] {
53
49
  display: grid;
@@ -74,46 +70,96 @@ export class FigureV3Material extends localize(i18next)(LitElement) {
74
70
  outline: 2px solid var(--md-sys-color-primary);
75
71
  outline-offset: 1px;
76
72
  }
77
- small {
78
- color: var(--md-sys-color-on-surface-variant);
79
- }
80
73
  select,
81
74
  input[type='range'] {
82
75
  font: inherit;
83
76
  min-width: 0;
84
77
  }
85
- label[toggle] {
86
- display: flex;
87
- align-items: center;
88
- gap: 6px;
89
- }
78
+ /* V2's segment picker: the four common counts as their silhouettes with their triangle cost, the rest folded. */
90
79
  div[segments] {
91
80
  display: grid;
92
- grid-template-columns: repeat(3, 1fr);
81
+ grid-template-columns: repeat(4, 1fr);
93
82
  gap: 4px;
94
83
  }
95
- div[segments] button {
96
- display: grid;
97
- padding: 4px;
84
+ button[seg] {
85
+ display: flex;
86
+ flex-direction: column;
87
+ align-items: center;
88
+ gap: 1px;
89
+ padding: 6px 2px 4px;
98
90
  font: inherit;
91
+ color: var(--md-sys-color-on-surface);
92
+ background: none;
99
93
  border: 1px solid var(--md-sys-color-outline-variant);
100
94
  border-radius: 6px;
101
- background: var(--md-sys-color-surface);
95
+ cursor: pointer;
96
+ }
97
+ button[seg]:hover:not([on]) {
98
+ background-color: var(--md-sys-color-surface-container);
99
+ }
100
+ button[seg][on] {
101
+ color: var(--md-sys-color-on-primary-container);
102
+ background-color: var(--md-sys-color-primary-container);
103
+ border-color: transparent;
104
+ }
105
+ /* Small, and 12 and 16 both read as circles: large enough for the corners to show (V2). */
106
+ button[seg] svg {
107
+ display: block;
108
+ width: 32px;
109
+ height: 32px;
110
+ }
111
+ button[seg] svg polygon {
112
+ fill: var(--md-sys-color-surface-container-highest);
113
+ stroke: currentColor;
114
+ stroke-width: 1.1;
115
+ stroke-linejoin: miter;
116
+ }
117
+ button[seg][on] svg polygon {
118
+ fill: var(--md-sys-color-surface-container-lowest);
119
+ }
120
+ button[seg] b {
121
+ font-size: 0.8rem;
122
+ }
123
+ button[seg] small {
124
+ margin: 0;
125
+ font-size: 0.6rem;
126
+ }
127
+ details[free] summary {
128
+ margin-top: 4px;
129
+ cursor: pointer;
130
+ font-size: 0.68rem;
131
+ color: var(--md-sys-color-on-surface-variant);
132
+ }
133
+ div[presets] {
134
+ display: flex;
135
+ gap: 4px;
136
+ margin-top: 4px;
137
+ }
138
+ div[presets] button {
139
+ flex: 1;
140
+ padding: 3px 0;
141
+ font: inherit;
142
+ font-size: 0.72rem;
102
143
  color: var(--md-sys-color-on-surface);
144
+ background: none;
145
+ border: 1px solid var(--md-sys-color-outline-variant);
146
+ border-radius: 6px;
103
147
  cursor: pointer;
104
148
  }
105
- div[segments] button[on] {
106
- background: var(--md-sys-color-secondary-container);
107
- color: var(--md-sys-color-on-secondary-container);
108
- border-color: var(--md-sys-color-primary);
149
+ div[presets] button[on] {
150
+ color: var(--md-sys-color-on-primary-container);
151
+ background-color: var(--md-sys-color-primary-container);
152
+ border-color: transparent;
109
153
  }
110
- `
154
+ `]
111
155
 
112
156
  /** The selected body as evaluated — its material, its segments and its shape. */
113
157
  @property({ attribute: false }) body?: V3AssetGeometry & { id: string }
114
158
  /** The asset's palette, token → colour. */
115
159
  @property({ attribute: false }) palette: Record<string, string> = {}
116
160
  @property({ type: Boolean }) locked = false
161
+ /** The glow while its slider is dragged: shown beside it and drawn on the canvas, recorded on release. */
162
+ @state() private glowing?: number
117
163
 
118
164
  render() {
119
165
  const part = this.body
@@ -127,7 +173,7 @@ export class FigureV3Material extends localize(i18next)(LitElement) {
127
173
  const painted = entries.filter(([token]) => !token.startsWith('palette.status-'))
128
174
  const lit = entries.filter(([token]) => token.startsWith('palette.status-'))
129
175
  const glow = material.emissive
130
- return html`
176
+ return html`<section>
131
177
  <h3>${i18next.t('figure.label.part-material')}</h3>
132
178
  <div field>
133
179
  <label>${i18next.t('figure.label.palette-role')}</label>
@@ -168,9 +214,10 @@ export class FigureV3Material extends localize(i18next)(LitElement) {
168
214
  step="0.05"
169
215
  ?disabled=${this.locked}
170
216
  .value=${String(glow?.intensity ?? 0)}
217
+ @input=${(e: Event) => this.glow(Number((e.target as HTMLInputElement).value), true)}
171
218
  @change=${(e: Event) => this.glow(Number((e.target as HTMLInputElement).value))}
172
219
  />
173
- <small>${(glow?.intensity ?? 0).toFixed(2)}</small>
220
+ <small>${(this.glowing ?? glow?.intensity ?? 0).toFixed(2)}</small>
174
221
  </div>
175
222
  ${glow
176
223
  ? html`<div field>
@@ -179,7 +226,7 @@ export class FigureV3Material extends localize(i18next)(LitElement) {
179
226
  </div>`
180
227
  : nothing}
181
228
  ${this.renderSegments(part)}
182
- `
229
+ </section>`
183
230
  }
184
231
 
185
232
  private renderSegments(part: V3AssetGeometry & { id: string }) {
@@ -190,17 +237,29 @@ export class FigureV3Material extends localize(i18next)(LitElement) {
190
237
  <div field>
191
238
  <label>${i18next.t('figure.label.segments')}</label>
192
239
  <div segments>
193
- ${SEGMENT_PRESETS.map(
240
+ ${SEGMENT_PRESETS.slice(0, 4).map(
194
241
  preset => html`<button
242
+ seg
195
243
  ?on=${preset === current}
196
244
  ?disabled=${this.locked}
197
245
  @click=${() => this.edit({ kind: 'set-segments', target: part.id, segments: preset })}
198
246
  >
247
+ ${silhouette(preset)}
199
248
  <b>${preset}</b>
200
249
  <small>${i18next.t('figure.text.n-triangles', { count: v3TrianglesOf({ ...part, segments: preset }) })}</small>
201
250
  </button>`
202
251
  )}
203
252
  </div>
253
+ <details free ?open=${SEGMENT_PRESETS.slice(4).includes(current as never)}>
254
+ <summary>${i18next.t('figure.text.more-segment-options')}</summary>
255
+ <div presets>
256
+ ${SEGMENT_PRESETS.slice(4).map(
257
+ preset => html`<button ?on=${preset === current} ?disabled=${this.locked} @click=${() => this.edit({ kind: 'set-segments', target: part.id, segments: preset })}>
258
+ ${preset}
259
+ </button>`
260
+ )}
261
+ </div>
262
+ </details>
204
263
  </div>
205
264
  `
206
265
  }
@@ -224,12 +283,14 @@ export class FigureV3Material extends localize(i18next)(LitElement) {
224
283
  }
225
284
 
226
285
  /** Intensity 0 is no glow at all, as in V2; a new glow takes the part's own colour until another is picked. */
227
- private glow(intensity: number) {
286
+ private glow(intensity: number, live = false) {
228
287
  const material = this.body!.material
229
- this.put({ emissive: intensity > 0 ? { token: material.emissive?.token ?? material.token, intensity, on: true } : undefined })
288
+ this.glowing = live ? intensity : undefined
289
+ this.put({ emissive: intensity > 0 ? { token: material.emissive?.token ?? material.token, intensity, on: true } : undefined }, live)
230
290
  }
231
291
 
232
- private put(patch: Partial<V3Material>) {
292
+ /** A material change: recorded, or -- while a slider is dragged -- only drawn (`v3-part-live`). */
293
+ private put(patch: Partial<V3Material>, live = false) {
233
294
  const material: V3Material = { ...this.body!.material, ...patch }
234
295
  for (const key of Object.keys(material) as (keyof V3Material)[]) if (material[key] === undefined) delete material[key]
235
296
  /* The colours this material names that the asset does not carry yet. */
@@ -239,10 +300,22 @@ export class FigureV3Material extends localize(i18next)(LitElement) {
239
300
  .filter((token): token is string => !!token && this.palette[token] === undefined && offered[token] !== undefined)
240
301
  .map(token => [token, offered[token]!])
241
302
  )
242
- this.edit({ kind: 'set-material', target: this.body!.id, material, ...(Object.keys(palette).length ? { palette } : {}) })
303
+ const edit: V3PartEdit = { kind: 'set-material', target: this.body!.id, material, ...(Object.keys(palette).length ? { palette } : {}) }
304
+ if (live) this.dispatchEvent(new CustomEvent('v3-part-live', { detail: { edit }, bubbles: true, composed: true }))
305
+ else this.edit(edit)
243
306
  }
244
307
 
245
308
  private edit(edit: V3PartEdit) {
246
309
  this.dispatchEvent(new CustomEvent('v3-part-edit', { detail: { edit }, bubbles: true, composed: true }))
247
310
  }
248
311
  }
312
+
313
+ /** A regular polygon of this many sides -- how round a body with this rim count looks from its end (V2). */
314
+ function silhouette(segments: number) {
315
+ const points: string[] = []
316
+ for (let i = 0; i < segments; i++) {
317
+ const angle = -Math.PI / 2 + (i * 2 * Math.PI) / segments
318
+ points.push(`${(16 + 13 * Math.cos(angle)).toFixed(2)},${(16 + 13 * Math.sin(angle)).toFixed(2)}`)
319
+ }
320
+ return svg`<svg viewBox="0 0 32 32" aria-hidden="true"><polygon points=${points.join(' ')}></polygon></svg>`
321
+ }
@@ -4,6 +4,8 @@ import { customElement, property } from 'lit/decorators.js'
4
4
  import { i18next, localize } from '@operato/i18n'
5
5
  import { ScrollbarStyles } from '@operato/styles'
6
6
  import type { V3PrimitiveCreation } from '@hatiolab/figure-model'
7
+ import type { V3BudgetLine } from './v3-maturity.js'
8
+ import { partLabelOf } from './v3-fields.js'
7
9
 
8
10
  /**
9
11
  * 도형 팔레트와 부품 목록 — V3 자산의 것.
@@ -44,10 +46,20 @@ const PROVIDER_ICON: Record<string, string> = {
44
46
  }
45
47
 
46
48
  /** 팔레트 — 저작 API 가 만들 수 있는 것만 보인다. */
47
- const RECIPES: Array<{ kind: V3PrimitiveCreation['kind']; icon: string; label: string }> = [
49
+ /*
50
+ The palette: V2's shapes. A plate and a wall are boxes of a shape a person reaches for (V2 drew both as boxes), so
51
+ they are starting sizes, not kinds of their own.
52
+ */
53
+ export type V3PaletteKind = V3PrimitiveCreation['kind'] | 'plate' | 'wall'
54
+ const RECIPES: Array<{ kind: V3PaletteKind; icon: string; label: string }> = [
48
55
  { kind: 'box', icon: 'deployed_code', label: 'figure.label.primitive-cube' },
49
56
  { kind: 'cylinder', icon: 'database', label: 'figure.label.primitive-cylinder' },
50
57
  { kind: 'rounded-box', icon: 'crop_square', label: 'figure.label.primitive-rounded-box' },
58
+ { kind: 'sphere', icon: 'circle', label: 'figure.label.primitive-sphere' },
59
+ { kind: 'frustum', icon: 'change_history', label: 'figure.label.primitive-frustum' },
60
+ { kind: 'polygon', icon: 'hexagon', label: 'figure.label.primitive-polygon' },
61
+ { kind: 'plate', icon: 'check_box_outline_blank', label: 'figure.label.primitive-plate' },
62
+ { kind: 'wall', icon: 'wall', label: 'figure.label.primitive-wall' },
51
63
  { kind: 'portal', icon: 'door_front', label: 'figure.label.primitive-portal' }
52
64
  ]
53
65
 
@@ -62,7 +74,7 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
62
74
  min-height: 0;
63
75
  overflow: hidden;
64
76
  background-color: var(--md-sys-color-surface-container-lowest);
65
- border-right: 1px solid var(--md-sys-color-outline-variant);
77
+ /* The divider beside it is the editor's frame's (ox-three-pane). */
66
78
  color: var(--md-sys-color-on-surface);
67
79
  font: var(--label-font, inherit);
68
80
  font-size: 0.76rem;
@@ -90,6 +102,51 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
90
102
  text-transform: uppercase;
91
103
  color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
92
104
  }
105
+ /* The cost line: the grade's letter in its colour, then what the figure costs. */
106
+ button[budget] {
107
+ display: flex;
108
+ align-items: flex-start;
109
+ gap: 6px;
110
+ width: 100%;
111
+ margin: 0 0 6px;
112
+ padding: 4px 6px;
113
+ font: inherit;
114
+ font-size: 0.68rem;
115
+ line-height: 1.5;
116
+ text-align: left;
117
+ color: var(--md-sys-color-on-surface-variant);
118
+ background: none;
119
+ border: 1px solid var(--md-sys-color-outline-variant);
120
+ border-radius: 6px;
121
+ cursor: pointer;
122
+ }
123
+ button[budget]:hover {
124
+ background: var(--md-sys-color-surface-container);
125
+ }
126
+ button[budget] b {
127
+ flex: none;
128
+ min-width: 16px;
129
+ text-align: center;
130
+ border-radius: 4px;
131
+ color: var(--md-sys-color-on-primary-container);
132
+ background: var(--md-sys-color-primary-container);
133
+ }
134
+ button[budget][grade='C'] b {
135
+ color: var(--md-sys-color-on-tertiary-container);
136
+ background: var(--md-sys-color-tertiary-container);
137
+ }
138
+ button[budget][grade='D'] {
139
+ border-color: var(--md-sys-color-error);
140
+ }
141
+ button[budget][grade='D'] b {
142
+ color: var(--md-sys-color-on-error);
143
+ background: var(--md-sys-color-error);
144
+ }
145
+ button[budget] em {
146
+ display: block;
147
+ font-style: normal;
148
+ color: var(--md-sys-color-error);
149
+ }
93
150
  span[room] {
94
151
  font-size: 0.7rem;
95
152
  font-weight: 400;
@@ -261,6 +318,11 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
261
318
  @property({ type: String }) selected = ''
262
319
  /** 편집할 수 없을 때(불러오는 중 · 저장 중) 팔레트와 조작을 잠근다. */
263
320
  @property({ type: Boolean }) locked = false
321
+ /**
322
+ * 이 도형이 치르는 값 — 삼각형 · 묶음 · 한 화면에 몇 대 · 등급. 부품을 더하는 바로 그 자리에서 보여야 로우폴리의
323
+ * 기준이 저작을 이끈다(V2 모델러의 정체성). 등급이 D 면 무엇이 넘쳤는지를 적는다. 누르면 성숙도 탭이 열린다.
324
+ */
325
+ @property({ attribute: false }) budget?: V3BudgetLine
264
326
 
265
327
  render() {
266
328
  return html`
@@ -289,6 +351,7 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
289
351
  <span room>${this.parts.length}</span>
290
352
  ${this.renderShowAll()}
291
353
  </h3>
354
+ ${this.renderBudget()}
292
355
  ${this.parts.length === 0
293
356
  ? html`<p quiet>${i18next.t('figure.text.no-parts-yet')}</p>`
294
357
  : html`<ol>
@@ -298,6 +361,36 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
298
361
  `
299
362
  }
300
363
 
364
+ private renderBudget() {
365
+ const b = this.budget
366
+ if (!b || !this.parts.length) return nothing
367
+ const over = b.grade === 'D'
368
+ return html`<button
369
+ budget
370
+ grade=${b.grade}
371
+ title=${i18next.t('figure.text.parts-budget-open')}
372
+ @click=${() => this.dispatchEvent(new CustomEvent('show-maturity', { bubbles: true, composed: true }))}
373
+ >
374
+ <b>${b.grade}</b>
375
+ <span>
376
+ ${i18next.t('figure.text.parts-budget', {
377
+ triangles: b.triangles.toLocaleString(),
378
+ groups: b.groups.toLocaleString(),
379
+ instances: b.instances.toLocaleString()
380
+ })}
381
+ ${over
382
+ ? html`<em
383
+ >${i18next.t('figure.text.parts-budget-over', {
384
+ budget: i18next.t('figure.label.budget-' + b.tightest.key),
385
+ used: b.tightest.used.toLocaleString(),
386
+ limit: b.tightest.limit.toLocaleString()
387
+ })}</em
388
+ >`
389
+ : nothing}
390
+ </span>
391
+ </button>`
392
+ }
393
+
301
394
  /** 한꺼번에 감추고 한꺼번에 되돌린다 — 하나라도 감춰져 있으면 다 되돌리고, 다 보이면 다 감춘다. */
302
395
  private renderShowAll() {
303
396
  if (this.parts.length === 0) return nothing
@@ -341,7 +434,7 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
341
434
  </button>
342
435
  <md-icon>${PROVIDER_ICON[part.provider] ?? 'deployed_code'}</md-icon>
343
436
  <span label title=${part.id}
344
- >${part.id}${part.invisible ? html`<small>${i18next.t('figure.label.state-hidden')}</small>` : nothing}</span
437
+ >${partLabelOf(part.id)}${part.invisible ? html`<small>${i18next.t('figure.label.state-hidden')}</small>` : nothing}</span
345
438
  >
346
439
  ${selected ? this.renderOps(part.id, index) : nothing}
347
440
  </li>