@things-factory/figure-ui 10.1.47 → 10.1.49

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 (55) hide show
  1. package/client/modeller/figure-v3-parts.ts +86 -11
  2. package/client/modeller/side-panel-styles.ts +8 -0
  3. package/client/modeller/v3-canvas.ts +62 -8
  4. package/client/modeller/v3-editor.ts +196 -24
  5. package/client/modeller/v3-fields.ts +36 -0
  6. package/client/modeller/v3-joint-fan.ts +292 -0
  7. package/client/modeller/v3-links.ts +8 -0
  8. package/client/modeller/v3-stage-model.ts +24 -0
  9. package/client/modeller/v3-thumbnail.ts +8 -4
  10. package/dist-client/modeller/figure-v3-capability.d.ts +1 -1
  11. package/dist-client/modeller/figure-v3-clips.d.ts +1 -1
  12. package/dist-client/modeller/figure-v3-controls.d.ts +1 -1
  13. package/dist-client/modeller/figure-v3-links.d.ts +1 -1
  14. package/dist-client/modeller/figure-v3-material.d.ts +1 -1
  15. package/dist-client/modeller/figure-v3-parts.d.ts +13 -0
  16. package/dist-client/modeller/figure-v3-parts.js +81 -11
  17. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  18. package/dist-client/modeller/figure-v3-properties.d.ts +1 -1
  19. package/dist-client/modeller/side-panel-styles.js +8 -0
  20. package/dist-client/modeller/side-panel-styles.js.map +1 -1
  21. package/dist-client/modeller/v3-canvas.d.ts +17 -0
  22. package/dist-client/modeller/v3-canvas.js +72 -8
  23. package/dist-client/modeller/v3-canvas.js.map +1 -1
  24. package/dist-client/modeller/v3-clips.d.ts +1 -1
  25. package/dist-client/modeller/v3-editor.d.ts +34 -0
  26. package/dist-client/modeller/v3-editor.js +200 -22
  27. package/dist-client/modeller/v3-editor.js.map +1 -1
  28. package/dist-client/modeller/v3-fields.d.ts +15 -0
  29. package/dist-client/modeller/v3-fields.js +35 -0
  30. package/dist-client/modeller/v3-fields.js.map +1 -1
  31. package/dist-client/modeller/v3-joint-fan.d.ts +9 -0
  32. package/dist-client/modeller/v3-joint-fan.js +264 -0
  33. package/dist-client/modeller/v3-joint-fan.js.map +1 -0
  34. package/dist-client/modeller/v3-links.d.ts +5 -0
  35. package/dist-client/modeller/v3-links.js +7 -0
  36. package/dist-client/modeller/v3-links.js.map +1 -1
  37. package/dist-client/modeller/v3-stage-model.d.ts +8 -0
  38. package/dist-client/modeller/v3-stage-model.js +26 -0
  39. package/dist-client/modeller/v3-stage-model.js.map +1 -1
  40. package/dist-client/modeller/v3-thumbnail.js +6 -3
  41. package/dist-client/modeller/v3-thumbnail.js.map +1 -1
  42. package/dist-client/tsconfig.tsbuildinfo +1 -1
  43. package/package.json +5 -5
  44. package/test/v3-fields.test.ts +46 -1
  45. package/test/v3-gizmo.test.ts +3 -1
  46. package/test/v3-joint-fan.test.ts +210 -0
  47. package/test/v3-links.test.ts +13 -1
  48. package/test/v3-maturity.test.ts +1 -1
  49. package/test/v3-palette-icons.test.ts +22 -0
  50. package/test/v3-thumbnail.test.ts +29 -0
  51. package/translations/en.json +17 -2
  52. package/translations/ja.json +17 -2
  53. package/translations/ko.json +17 -2
  54. package/translations/ms.json +17 -2
  55. package/translations/zh.json +17 -2
@@ -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 이름이다. */
@@ -59,7 +62,7 @@ const RECIPES: Array<{ kind: V3PaletteKind; icon: string; label: string }> = [
59
62
  { kind: 'frustum', icon: 'change_history', label: 'figure.label.primitive-frustum' },
60
63
  { kind: 'polygon', icon: 'hexagon', label: 'figure.label.primitive-polygon' },
61
64
  { kind: 'plate', icon: 'check_box_outline_blank', label: 'figure.label.primitive-plate' },
62
- { kind: 'wall', icon: 'wall', label: 'figure.label.primitive-wall' },
65
+ { kind: 'wall', icon: 'crop_portrait', label: 'figure.label.primitive-wall' },
63
66
  { kind: 'portal', icon: 'door_front', label: 'figure.label.primitive-portal' }
64
67
  ]
65
68
 
@@ -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,31 @@ 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
+ ?disabled=${this.locked}
424
+ @change=${(e: Event) => {
425
+ const value = (e.target as HTMLSelectElement).value
426
+ this.dispatchEvent(new CustomEvent('set-detail-level', { detail: { level: value || null }, bubbles: true, composed: true }))
427
+ }}
428
+ >
429
+ <option value="" ?selected=${!b.level}>${i18next.t('figure.label.detail-none')}</option>
430
+ ${(['S', 'M', 'L'] as const).map(
431
+ one => html`<option value=${one} ?selected=${one === b.level}>
432
+ ${one} ${i18next.t('figure.label.detail-' + one)} · ${i18next.t('figure.text.n-parts-at-most', { limit: PART_LIMIT[one] })}
433
+ </option>`
434
+ )}
435
+ </select>
436
+ </div>`
437
+ }
438
+
364
439
  private renderBudget() {
365
440
  const b = this.budget
366
441
  if (!b || !this.parts.length) return nothing
@@ -434,7 +509,7 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
434
509
  </button>
435
510
  <md-icon>${PROVIDER_ICON[part.provider] ?? 'deployed_code'}</md-icon>
436
511
  <span label title=${part.id}
437
- >${partLabelOf(part.id)}${part.invisible ? html`<small>${i18next.t('figure.label.state-hidden')}</small>` : nothing}</span
512
+ >${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
513
  >
439
514
  ${selected ? this.renderOps(part.id, index) : nothing}
440
515
  </li>
@@ -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] {
@@ -7,6 +7,8 @@ import type { GizmoReach } from './v3-gizmo.js'
7
7
  import type { V3RenderPart } from './v3-renderer.js'
8
8
  import { DEFAULT_VIEW, sceneLook, WORKBENCH, type ViewSettings } from './figure-view.js'
9
9
  import { boardFor, changedKeys, componentOf, gizmoResultOf, type Board } from './v3-stage-model.js'
10
+ import { JOINT_FAN } from './v3-joint-fan.js'
11
+ import { ownerOf } from './v3-fields.js'
10
12
 
11
13
  /*
12
14
  * The V3 modeller's canvas -- a things-scene board in editing mode, as the V2 modeller's was.
@@ -44,6 +46,9 @@ interface SceneHandle {
44
46
  root?: SceneLayer
45
47
  }
46
48
 
49
+ /* The fan's colour when the figure's palette names no accent. */
50
+ const FAN_COLOUR = '#ff8a00'
51
+
47
52
  /* The 2D side of the board, which the author never sees on this page but the scene paints. */
48
53
  const PAPER = '#ffffff'
49
54
 
@@ -81,6 +86,14 @@ export class FigureV3Canvas extends LitElement {
81
86
  /** What the gizmo may write for the selected part; a change beyond it is not applied and the part springs back. */
82
87
  @property({ attribute: false }) reach: GizmoReach = { translate: false, rotate: false, scale: false }
83
88
  @property({ attribute: false }) view: ViewSettings = DEFAULT_VIEW
89
+ /**
90
+ * View only: nothing that happens on the board is told out. The card's picture is taken from a second canvas stood up
91
+ * off screen (`v3-thumbnail.ts`); the scene settles its components as it builds them, and a change told from there
92
+ * would reach the editor as an edit (V2's `viewOnly`, for the same reason).
93
+ */
94
+ @property({ type: Boolean }) viewOnly = false
95
+ /** Where the selected part turns and how far (`v3-joint-fan`): shown, never picked, never saved. */
96
+ @property({ attribute: false }) fan: V3RenderPart | null = null
84
97
 
85
98
  @query('div[stage]') private stage!: HTMLDivElement
86
99
 
@@ -103,7 +116,7 @@ export class FigureV3Canvas extends LitElement {
103
116
 
104
117
  protected updated(changed: PropertyValues) {
105
118
  if (!this.hasUpdated) return
106
- if (changed.has('parts') || changed.has('palette') || changed.has('context')) this.standUp()
119
+ if (changed.has('parts') || changed.has('palette') || changed.has('context') || changed.has('fan')) this.standUp()
107
120
  if (changed.has('view')) this.applyView()
108
121
  if (changed.has('selected')) this.showSelected()
109
122
  }
@@ -115,6 +128,25 @@ export class FigureV3Canvas extends LitElement {
115
128
  this.teardown()
116
129
  }
117
130
 
131
+ /**
132
+ * The board as a picture, for the card: the 3D view at this size, or nothing when it cannot be taken. The scene's
133
+ * `toDataURL` renders the 2D board (a white sheet, V2 found), and the 3D renderer keeps no drawing buffer, so the
134
+ * scene's own `snapshot3d` draws and reads in one task. A picture that cannot be taken is not an error and is not
135
+ * faked (V2's `snapshot`, lost when this canvas moved onto the scene -- no card had a picture since, user 2026-09-24).
136
+ */
137
+ async snapshot(width = 480, height = 360): Promise<string | undefined> {
138
+ const layer = this.scene?.root as (SceneLayer & { snapshot3d?: (w?: number, h?: number, type?: string, quality?: number) => Promise<string | undefined> }) | undefined
139
+ if (!layer?.snapshot3d) return undefined
140
+ try {
141
+ /* webp: a card needs no alpha, and png is three or four times heavier in every list response. */
142
+ const url = await layer.snapshot3d(width, height, 'image/webp', 0.85)
143
+ return typeof url === 'string' && url.startsWith('data:') ? url : undefined
144
+ } catch (e) {
145
+ console.warn(`[thumbnail] could not take the picture -- ${(e as Error).message}`)
146
+ return undefined
147
+ }
148
+ }
149
+
118
150
  /** The whole figure in view: the board's fit, then the 3D camera's own, as V2 did. */
119
151
  fit() {
120
152
  this.scene?.fit?.('ratio')
@@ -129,7 +161,12 @@ export class FigureV3Canvas extends LitElement {
129
161
  if (!this.stage) return
130
162
  const needed = boardFor(this.parts, this.board)
131
163
  const outgrown = !this.board || needed.width > this.board.width || needed.height > this.board.height || needed.depth > this.board.depth
132
- if (this.scene && this.standingFor === this.context && !outgrown) {
164
+ if (this.scene && this.standingFor === this.context) {
165
+ /*
166
+ A part dragged past the board's edge grows the board where it stands. Standing it up again lost the selection,
167
+ the gizmo and the camera after every such drag -- the gizmo seemed not to work at all (user, 2026-09-24).
168
+ */
169
+ if (outgrown) this.grow(needed)
133
170
  this.sync()
134
171
  return
135
172
  }
@@ -167,7 +204,10 @@ export class FigureV3Canvas extends LitElement {
167
204
  }
168
205
 
169
206
  private components(): Record<string, unknown>[] {
170
- return this.parts.map(part => componentOf(part, this.palette, this.board!)).filter((c): c is Record<string, unknown> => !!c)
207
+ const parts = this.parts.map(part => componentOf(part, this.palette, this.board!)).filter((c): c is Record<string, unknown> => !!c)
208
+ /* The fan in the accent colour, translucent; `capturable: false` keeps clicks, the marquee and the gizmo off it. */
209
+ const fan = this.fan ? componentOf(this.fan, { ...this.palette, [JOINT_FAN]: this.palette['palette.accent'] ?? FAN_COLOUR }, this.board!) : null
210
+ return fan ? [...parts, { ...fan, capturable: false, locked: true }] : parts
171
211
  }
172
212
 
173
213
  /** The components standing now, by name. Not held between calls: the scene stands its board up again at times. */
@@ -180,6 +220,19 @@ export class FigureV3Canvas extends LitElement {
180
220
  return found
181
221
  }
182
222
 
223
+ /** A larger board under the same scene: its size, and the grid that suits it. Components are re-placed by `sync`. */
224
+ private grow(needed: Board & { depth: number }) {
225
+ const layer = this.scene?.root
226
+ this.board = needed
227
+ if (!layer) return
228
+ this.writing = true
229
+ try {
230
+ layer.set({ width: needed.width, height: needed.height, depth: needed.depth, gridStep: niceGridStep(Math.max(needed.width, needed.height)) })
231
+ } finally {
232
+ this.writing = false
233
+ }
234
+ }
235
+
183
236
  /** Bring the components to the evaluation: remove the gone, write only what differs, add the new. */
184
237
  private sync() {
185
238
  const layer = this.scene?.root
@@ -213,11 +266,11 @@ export class FigureV3Canvas extends LitElement {
213
266
  private watchScene() {
214
267
  const layer = this.scene?.root
215
268
  const root = layer?.root
216
- if (!layer || !root?.on) return
269
+ if (!layer || !root?.on || this.viewOnly) return
217
270
  root.on('selected', (after: unknown) => {
218
271
  const name = (after as SceneComponent[] | undefined)?.[0]?.state?.name
219
272
  const id = typeof name === 'string' ? name : ''
220
- if (id !== this.selected) this.dispatchEvent(new CustomEvent('part-selected', { detail: id, bubbles: true, composed: true }))
273
+ if (ownerOf(id) !== this.selected) this.dispatchEvent(new CustomEvent('part-selected', { detail: id, bubbles: true, composed: true }))
221
274
  })
222
275
  /*
223
276
  A change comes up to the board with the component in the last argument's `origin` (the scene appends
@@ -241,10 +294,11 @@ export class FigureV3Canvas extends LitElement {
241
294
  const layer = this.scene?.root
242
295
  const root = layer?.root
243
296
  if (!layer || !root) return
244
- const component = this.selected ? this.standing().get(this.selected) : undefined
297
+ /* A repeat is selected whole: every copy of it (the editor selects the part the author made). */
298
+ const wanted = this.selected ? [...this.standing()].filter(([name]) => ownerOf(name) === this.selected).map(([, component]) => component) : []
245
299
  const now = root.selected ?? []
246
- if ((now[0] ?? undefined) === component && now.length === (component ? 1 : 0)) return
247
- root.selected = component ? [component] : []
300
+ if (now.length === wanted.length && wanted.every((one, i) => now[i] === one)) return
301
+ root.selected = wanted
248
302
  layer.invalidate?.()
249
303
  }
250
304