@things-factory/figure-ui 10.1.48 → 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 (34) hide show
  1. package/client/modeller/figure-v3-parts.ts +85 -10
  2. package/client/modeller/side-panel-styles.ts +8 -0
  3. package/client/modeller/v3-canvas.ts +25 -5
  4. package/client/modeller/v3-editor.ts +159 -22
  5. package/client/modeller/v3-fields.ts +15 -1
  6. package/client/modeller/v3-stage-model.ts +24 -0
  7. package/dist-client/modeller/figure-v3-parts.d.ts +13 -0
  8. package/dist-client/modeller/figure-v3-parts.js +80 -10
  9. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  10. package/dist-client/modeller/side-panel-styles.js +8 -0
  11. package/dist-client/modeller/side-panel-styles.js.map +1 -1
  12. package/dist-client/modeller/v3-canvas.d.ts +2 -0
  13. package/dist-client/modeller/v3-canvas.js +27 -5
  14. package/dist-client/modeller/v3-canvas.js.map +1 -1
  15. package/dist-client/modeller/v3-clips.d.ts +1 -1
  16. package/dist-client/modeller/v3-editor.d.ts +19 -0
  17. package/dist-client/modeller/v3-editor.js +160 -20
  18. package/dist-client/modeller/v3-editor.js.map +1 -1
  19. package/dist-client/modeller/v3-fields.d.ts +6 -0
  20. package/dist-client/modeller/v3-fields.js +14 -2
  21. package/dist-client/modeller/v3-fields.js.map +1 -1
  22. package/dist-client/modeller/v3-stage-model.d.ts +8 -0
  23. package/dist-client/modeller/v3-stage-model.js +26 -0
  24. package/dist-client/modeller/v3-stage-model.js.map +1 -1
  25. package/dist-client/tsconfig.tsbuildinfo +1 -1
  26. package/package.json +5 -5
  27. package/test/v3-fields.test.ts +37 -1
  28. package/test/v3-gizmo.test.ts +3 -1
  29. package/test/v3-maturity.test.ts +1 -1
  30. package/translations/en.json +11 -1
  31. package/translations/ja.json +11 -1
  32. package/translations/ko.json +11 -1
  33. package/translations/ms.json +11 -1
  34. package/translations/zh.json +11 -1
@@ -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,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] {
@@ -8,6 +8,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
10
  import { JOINT_FAN } from './v3-joint-fan.js'
11
+ import { ownerOf } from './v3-fields.js'
11
12
 
12
13
  /*
13
14
  * The V3 modeller's canvas -- a things-scene board in editing mode, as the V2 modeller's was.
@@ -160,7 +161,12 @@ export class FigureV3Canvas extends LitElement {
160
161
  if (!this.stage) return
161
162
  const needed = boardFor(this.parts, this.board)
162
163
  const outgrown = !this.board || needed.width > this.board.width || needed.height > this.board.height || needed.depth > this.board.depth
163
- 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)
164
170
  this.sync()
165
171
  return
166
172
  }
@@ -214,6 +220,19 @@ export class FigureV3Canvas extends LitElement {
214
220
  return found
215
221
  }
216
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
+
217
236
  /** Bring the components to the evaluation: remove the gone, write only what differs, add the new. */
218
237
  private sync() {
219
238
  const layer = this.scene?.root
@@ -251,7 +270,7 @@ export class FigureV3Canvas extends LitElement {
251
270
  root.on('selected', (after: unknown) => {
252
271
  const name = (after as SceneComponent[] | undefined)?.[0]?.state?.name
253
272
  const id = typeof name === 'string' ? name : ''
254
- 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 }))
255
274
  })
256
275
  /*
257
276
  A change comes up to the board with the component in the last argument's `origin` (the scene appends
@@ -275,10 +294,11 @@ export class FigureV3Canvas extends LitElement {
275
294
  const layer = this.scene?.root
276
295
  const root = layer?.root
277
296
  if (!layer || !root) return
278
- 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) : []
279
299
  const now = root.selected ?? []
280
- if ((now[0] ?? undefined) === component && now.length === (component ? 1 : 0)) return
281
- root.selected = component ? [component] : []
300
+ if (now.length === wanted.length && wanted.every((one, i) => now[i] === one)) return
301
+ root.selected = wanted
282
302
  layer.invalidate?.()
283
303
  }
284
304
 
@@ -3,6 +3,7 @@ import { i18next } from '@operato/i18n'
3
3
  import type { OxThreePane } from '@operato/layout'
4
4
  import '@operato/layout'
5
5
  import { customElement, property, state } from 'lit/decorators.js'
6
+ import { PART_LIMIT, v3PartCountOf } from '@hatiolab/figure-model'
6
7
  import type { V3Asset, V3AuthoringAction, V3InputChange, V3PartEdit, V3PrimitiveCreation } from '@hatiolab/figure-model'
7
8
  import type { V3ProductSession } from './v3-product-session.js'
8
9
  import { captureV3Thumbnail } from './v3-thumbnail.js'
@@ -19,12 +20,14 @@ import { DEFAULT_VIEW, type ViewSettings } from './figure-view.js'
19
20
  import './figure-v3-capability.js'
20
21
  import { gizmoChanges, gizmoReachOf, type GizmoReach, type GizmoResult } from './v3-gizmo.js'
21
22
  import { keyActionOf } from './v3-keys.js'
22
- import { groupFields, partLabelOf, repeatOf, type FieldLike, type FieldGroup } from './v3-fields.js'
23
+ import { groupFields, ownerOf, partLabelOf, repeatOf, type FieldLike, type FieldGroup } from './v3-fields.js'
23
24
  import { SidePanelStyles } from './side-panel-styles.js'
24
25
  import { alignChanges, boxOf, canMirror, mirrorChanges, type AlignTool } from './v3-align.js'
25
26
  import type { V3PaletteKind, V3PartRow } from './figure-v3-parts.js'
26
27
  import { paletteRecipe } from './v3-palette.js'
27
28
  import type { FigureV3Canvas } from './v3-canvas.js'
29
+ import { nudgeOf } from './v3-stage-model.js'
30
+ import type { V3RenderPart } from './v3-renderer.js'
28
31
  import type { V3BudgetLine } from './v3-maturity.js'
29
32
  import { instanceRangeOf, rangeDiffers, turnsAboutAJoint } from './v3-links.js'
30
33
  import { jointFanOf } from './v3-joint-fan.js'
@@ -288,7 +291,7 @@ export class FigureV3Editor extends LitElement {
288
291
  this.error = ''
289
292
  this.hiddenParts = new Set()
290
293
  }
291
- if (view && !view.geometry.some(g => g.id === this.selected)) this.selected = ''
294
+ if (view && !view.geometry.some(g => ownerOf(g.id) === this.selected)) this.selected = ''
292
295
  }
293
296
  private run(action: () => unknown) {
294
297
  try {
@@ -305,7 +308,7 @@ export class FigureV3Editor extends LitElement {
305
308
  /** What the canvas draws: the figure with the value in hand when there is one, and without the parts hidden on the workbench. */
306
309
  private drawnParts(geometry: Parameters<typeof drawableV3Parts>[0] | undefined, asset: unknown) {
307
310
  const parts = (isLive(this.live) && asset ? livePartsOf(asset as V3Asset, this.live) : null) ?? drawableV3Parts(geometry ?? [])
308
- return parts.filter(body => !this.hiddenParts.has((body as { id: string }).id))
311
+ return parts.filter(body => !this.hiddenParts.has(ownerOf((body as { id: string }).id)))
309
312
  }
310
313
  /**
311
314
  * Where the selected part turns and how far, with the pose as it is drawn now -- a value in hand included, so the
@@ -397,7 +400,8 @@ export class FigureV3Editor extends LitElement {
397
400
  /** What the gizmo may write for the selected part — read off the asset being edited. */
398
401
  private reachOf(id: string): GizmoReach {
399
402
  const none = { translate: false, rotate: false, scale: false }
400
- if (!id || !this.session) return none
403
+ /* A repeat is moved and sized through its row, not by a gizmo on one copy. */
404
+ if (!id || !this.session || !this.session.read().geometry.some(g => g.id === id)) return none
401
405
  try {
402
406
  return gizmoReachOf(JSON.parse(this.session.source()), id)
403
407
  } catch {
@@ -409,8 +413,36 @@ export class FigureV3Editor extends LitElement {
409
413
  const view = this.session!.read()
410
414
  const sizes = Object.fromEntries(view.fields.map(field => [field.id, field.value as number]))
411
415
  const changes = gizmoChanges(id, this.reachOf(id), result, sizes)
412
- if (changes.length) this.run(() => this.session!.apply(changes))
413
- else this.requestUpdate()
416
+ if (changes.length) {
417
+ this.run(() => this.session!.apply(changes))
418
+ return
419
+ }
420
+ /*
421
+ A part whose place is worked out (a converted one) has no numbers for the gizmo to write: the drag is added beside
422
+ its rule (`nudge-part`). A part a joint or a fastening places, or a size that is worked out, is refused -- said in
423
+ words, not by the part silently springing back (user, 2026-09-24: 「기즈모 컨트롤도 전혀 작동이 안되는군」).
424
+ */
425
+ const body = view.geometry.find(g => g.id === id) as unknown as V3RenderPart | undefined
426
+ if (!body) return this.requestUpdate()
427
+ if (result.scale && !result.position && !result.rotation) {
428
+ this.error = i18next.t('figure.text.gizmo-size-worked-out', { part: partLabelOf(id) })
429
+ return this.requestUpdate()
430
+ }
431
+ const { by, turn } = nudgeOf(body, result)
432
+ if (!Object.keys(by).length && !Object.keys(turn).length) return this.requestUpdate()
433
+ try {
434
+ this.session!.command({ kind: 'nudge-part', part: id, ...(Object.keys(by).length ? { by } : {}), ...(Object.keys(turn).length ? { turn } : {}) })
435
+ this.error = ''
436
+ this.changed()
437
+ } catch (error) {
438
+ const message = error instanceof Error ? error.message : String(error)
439
+ this.error = /carried by a joint/.test(message)
440
+ ? i18next.t('figure.text.gizmo-joint-places', { part: partLabelOf(id) })
441
+ : /is fastened to/.test(message)
442
+ ? i18next.t('figure.text.gizmo-fastening-places', { part: partLabelOf(id) })
443
+ : message
444
+ this.requestUpdate()
445
+ }
414
446
  }
415
447
  /** 맞추기 — one tool, one step of the history, through the same inputs as the gizmo. */
416
448
  private align(tool: AlignTool) {
@@ -473,7 +505,7 @@ export class FigureV3Editor extends LitElement {
473
505
  */
474
506
  private renderRepeatNote(geometry: readonly unknown[] | undefined) {
475
507
  const repeat = repeatOf(this.selected, geometry)
476
- return repeat ? html`<p hint>${i18next.t('figure.text.repeat-member', { name: repeat.name, count: repeat.count, index: repeat.index })}</p>` : nothing
508
+ return repeat?.index ? html`<p hint>${i18next.t('figure.text.repeat-member', { name: repeat.name, count: repeat.count, index: repeat.index })}</p>` : nothing
477
509
  }
478
510
  private renamePart(input: HTMLInputElement) {
479
511
  const name = input.value.trim()
@@ -672,9 +704,117 @@ export class FigureV3Editor extends LitElement {
672
704
  <md-icon>aspect_ratio</md-icon>${i18next.t('figure.button.scale-uniformly')}
673
705
  </button>`
674
706
  }
707
+ /**
708
+ * The parts list, one row per part the author made: a repeat is one row with how many copies it makes now (V2 listed
709
+ * its parts the same way; user, 2026-09-24: the conveyor's sixteen rollers were sixteen rows).
710
+ */
711
+ private partRows(geometry: readonly { id: string }[]): V3PartRow[] {
712
+ const rows = new Map<string, V3PartRow>()
713
+ for (const g of geometry) {
714
+ const id = ownerOf(g.id)
715
+ const row = rows.get(id)
716
+ if (row) {
717
+ row.copies = (row.copies ?? 1) + 1
718
+ continue
719
+ }
720
+ rows.set(id, {
721
+ id,
722
+ provider: (g as { shape?: { provider?: string } }).shape?.provider ?? '',
723
+ hidden: this.hiddenParts.has(id),
724
+ invisible: (g as { visible?: boolean }).visible === false,
725
+ ...(id !== g.id ? { copies: 1 } : {})
726
+ })
727
+ }
728
+ return [...rows.values()]
729
+ }
730
+ /** The detail level's part budget for the list's head, from figure-model's count (a repeat once). */
731
+ private partBudgetOf(asset: V3Asset | undefined) {
732
+ if (!asset) return undefined
733
+ try {
734
+ return { level: asset.detailLevel ?? null, used: v3PartCountOf(asset), limit: asset.detailLevel ? PART_LIMIT[asset.detailLevel] : null }
735
+ } catch {
736
+ return undefined
737
+ }
738
+ }
739
+ /**
740
+ * A repeat: which way its copies run, how many there are now, and how far apart -- V2's `repeat: { axis, pitch }`.
741
+ * The pitch of a row that fills its length is edited as a number (`set-repeat` makes it the part's own the first
742
+ * time); a row of a fixed count says its pitch cannot be set yet (figure-model refuses it the same way).
743
+ */
744
+ private renderRepeat(asset: V3Asset | undefined, geometry: readonly { id: string }[], unavailable: boolean) {
745
+ const repeat = repeatOf(this.selected, geometry)
746
+ if (!asset || !repeat || repeat.index) return nothing
747
+ const model = asset.document.model as unknown as {
748
+ nodes: { id: string; op: string; args: string[]; params?: Record<string, string> }[]
749
+ constants: { id: string; value: number }[]
750
+ inputs: { id: string; max?: number }[]
751
+ }
752
+ const layout = model.nodes.find(n => n.id === `${this.selected}.layout`)
753
+ const axis = model.nodes.find(n => n.id === `${this.selected}.repeat`)?.params?.axis ?? '?'
754
+ const own = `${this.selected}.repeat.pitch`
755
+ const pitch = asset.designInputs[own] ?? model.constants.find(c => c.id === layout?.args[4])?.value
756
+ const fits = layout?.op === 'fit-pitch@1'
757
+ /* What the pitch gives, in numbers: this many copies at the length it is authored, and how long it may be placed. */
758
+ const lengthRef = layout?.args[0] ?? ''
759
+ const length = asset.designInputs[lengthRef]
760
+ const longest = model.inputs.find(i => i.id === lengthRef)?.max
761
+ const limit = model.constants.find(c => c.id === layout?.args[6])?.value
762
+ const field = { id: own, unit: 'mm' as const, role: 'design', value: pitch ?? 0 }
763
+ return html`<section>
764
+ <h3>${i18next.t('figure.label.repeat')}</h3>
765
+ <div inline-field><label>${i18next.t('figure.label.repeat-axis')}</label><span value>${axis.toUpperCase()}</span></div>
766
+ <div inline-field><label>${i18next.t('figure.label.repeat-count')}</label><span value>${repeat.count}</span></div>
767
+ <div inline-field>
768
+ <label>${i18next.t('figure.label.repeat-gap')} <span unit>mm</span></label>
769
+ <input
770
+ type="number"
771
+ step="any"
772
+ aria-label=${own}
773
+ .value=${String(pitch ?? '')}
774
+ ?disabled=${unavailable || !fits}
775
+ @change=${(e: Event) => this.setPitch(field, e)}
776
+ />
777
+ </div>
778
+ <p hint>
779
+ ${fits && length !== undefined && longest !== undefined && limit !== undefined
780
+ ? i18next.t('figure.text.repeat-what-pitch-gives', {
781
+ length: length.toLocaleString(),
782
+ count: repeat.count,
783
+ longest: longest.toLocaleString(),
784
+ limit
785
+ })
786
+ : i18next.t(fits ? 'figure.text.repeat-fits-length' : 'figure.text.repeat-fixed-count')}
787
+ </p>
788
+ </section>`
789
+ }
790
+ /**
791
+ * A pitch always goes through `set-repeat`: the command moves the longest length with it (at most `limit` copies),
792
+ * which a plain input edit would not. Refused only when the authored length would already hold more copies than
793
+ * that -- said with the numbers.
794
+ */
795
+ private setPitch(field: { id: string; value: number }, event: Event) {
796
+ const input = event.target as HTMLInputElement
797
+ const pitch = input.valueAsNumber
798
+ if (!Number.isFinite(pitch) || pitch === field.value) return
799
+ try {
800
+ this.session!.command({ kind: 'set-repeat', part: this.selected, pitch })
801
+ this.error = ''
802
+ this.changed()
803
+ } catch (error) {
804
+ input.value = String(field.value)
805
+ const code = (error as { code?: string })?.code
806
+ const message = error instanceof Error ? error.message : String(error)
807
+ const found = /\((\d[\d.]*) mm\) a pitch under ([\d.]+) mm makes more than (\d+)/.exec(message)
808
+ this.error =
809
+ code === 'INPUT_RANGE' && found
810
+ ? i18next.t('figure.text.repeat-pitch-too-small', { length: Number(found[1]).toLocaleString(), min: found[2], limit: found[3] })
811
+ : message
812
+ }
813
+ }
675
814
  /** Choose a part, from the list, the canvas or a place row. A figure-level tab gives way to the part's properties. */
676
815
  private choose(id: string) {
677
- this.selected = id
816
+ /* A copy on the canvas selects its repeat: the part the author made. */
817
+ this.selected = id ? ownerOf(id) : id
678
818
  if (id && (this.tab === 'verdict' || this.tab === 'view' || this.tab === 'history')) this.tab = 'properties'
679
819
  }
680
820
  private asset(): unknown {
@@ -707,11 +847,12 @@ export class FigureV3Editor extends LitElement {
707
847
  const view = this.session?.read()
708
848
  if (!view) return html`<p>${i18next.t('figure.text.opening-model')}</p>`
709
849
  const unavailable = view.loading || !!view.error || view.saving || !view.context
710
- const part = view.geometry.find(g => g.id === this.selected)
850
+ const part = view.geometry.find(g => ownerOf(g.id) === this.selected)
711
851
  /* The controls (state inputs) have their own place in the motion tab; these are the design values. */
712
852
  const design = view.fields.filter(i => i.role !== 'state')
713
- const own = design.filter(i => this.selected && i.id.startsWith(this.selected + '.'))
714
- const common = design.filter(i => !view.geometry.some(g => i.id.startsWith(g.id + '.')))
853
+ /* A repeat's own row values (its pitch) have their own section, not the shape's. */
854
+ const own = design.filter(i => this.selected && i.id.startsWith(this.selected + '.') && !i.id.startsWith(this.selected + '.repeat.'))
855
+ const common = design.filter(i => !view.geometry.some(g => i.id.startsWith(ownerOf(g.id) + '.')))
715
856
  const status = view.loading
716
857
  ? 'figure.text.loading-figure'
717
858
  : view.saving
@@ -747,21 +888,16 @@ export class FigureV3Editor extends LitElement {
747
888
  slot="start"
748
889
  .budget=${this.budget}
749
890
  @show-maturity=${() => (this.tab = 'verdict')}
750
- .parts=${view.geometry.map(
751
- (g): V3PartRow => ({
752
- id: g.id,
753
- provider: (g as { shape?: { provider?: string } }).shape?.provider ?? '',
754
- hidden: this.hiddenParts.has(g.id),
755
- invisible: (g as { visible?: boolean }).visible === false
756
- })
757
- )}
891
+ .parts=${this.partRows(view.geometry)}
892
+ .partBudget=${this.partBudgetOf(asset as V3Asset | undefined)}
893
+ @set-detail-level=${(e: CustomEvent) => this.linkCommand({ kind: 'set-detail-level', level: e.detail.level })}
758
894
  .selected=${this.selected}
759
895
  ?locked=${unavailable}
760
896
  @add-part=${(e: CustomEvent) => this.add(e.detail.kind)}
761
897
  @select-part=${(e: CustomEvent) => this.choose(e.detail.id)}
762
898
  @toggle-part-hidden=${(e: CustomEvent) => this.toggleHiddenPart(e.detail.id)}
763
899
  @set-all-parts-hidden=${(e: CustomEvent) =>
764
- (this.hiddenParts = e.detail.hidden ? new Set(view.geometry.map(g => g.id)) : new Set())}
900
+ (this.hiddenParts = e.detail.hidden ? new Set(view.geometry.map(g => ownerOf(g.id))) : new Set())}
765
901
  @move-part-up=${(e: CustomEvent) => this.movePart(e.detail.id, e.detail.to)}
766
902
  @move-part-down=${(e: CustomEvent) => this.movePart(e.detail.id, e.detail.to)}
767
903
  @duplicate-part=${(e: CustomEvent) => this.duplicatePart(e.detail.id)}
@@ -815,10 +951,11 @@ export class FigureV3Editor extends LitElement {
815
951
  </div>
816
952
  ${this.renderRepeatNote(view.geometry)}
817
953
  </section>
818
- ${this.renderGroups(groupFields(design, this.selected), 'figure.label.group-placement-size', 'figure.label.group-shape', unavailable, this.renderTools(unavailable))}
954
+ ${this.renderRepeat(asset as V3Asset | undefined, view.geometry, unavailable)}
955
+ ${this.renderGroups(groupFields(own, this.selected), 'figure.label.group-placement-size', 'figure.label.group-shape', unavailable, this.renderTools(unavailable))}
819
956
  ${this.renderLinks(unavailable, 'shape')}
820
957
  <figure-v3-material
821
- .body=${part}
958
+ .body=${{ ...part, id: this.selected }}
822
959
  .palette=${view.palette ?? {}}
823
960
  ?locked=${unavailable}
824
961
  @v3-part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
@@ -66,7 +66,11 @@ export function partLabelOf(id: string): string {
66
66
  */
67
67
  export function repeatOf(id: string, geometry: readonly unknown[] | undefined): { name: string; count: number; index: number } | null {
68
68
  const path = pathOf(id)
69
- if (!path) return null
69
+ if (!path) {
70
+ /* The repeat itself, by its name: how many copies it makes now. */
71
+ const copies = (geometry ?? []).filter(g => pathOf((g as { id?: string }).id ?? '')?.[0] === `${id}.repeat`).length
72
+ return copies ? { name: id, count: copies, index: 0 } : null
73
+ }
70
74
  const count = (geometry ?? []).filter(g => pathOf((g as { id?: string }).id ?? '')?.[0] === path[0]).length
71
75
  const index = path.find((step): step is number => typeof step === 'number')
72
76
  return { name: String(path[0]).replace(/\.repeat$/, ''), count, index: (index ?? 0) + 1 }
@@ -82,3 +86,13 @@ function pathOf(id: string): unknown[] | null {
82
86
  }
83
87
  }
84
88
 
89
+ /**
90
+ * The authored part a body belongs to: a repeat's copy belongs to its repeat (`roller`), any other body is its own. The
91
+ * editor selects, lists, hides and names parts by this -- what the author made, not every body the kernel draws
92
+ * (user, 2026-09-24: the conveyor's sixteen rollers were sixteen rows).
93
+ */
94
+ export function ownerOf(id: string): string {
95
+ const path = pathOf(id)
96
+ return path ? String(path[0]).replace(/\.repeat$/, '') : id
97
+ }
98
+
@@ -126,6 +126,30 @@ export function gizmoResultOf(part: V3RenderPart, after: Record<string, unknown>
126
126
  return result
127
127
  }
128
128
 
129
+ /**
130
+ * A drag as amounts, for a part whose place is worked out (`nudge-part`): how far its origin moved and how far each of
131
+ * its angles turned (rigid@1's order, degrees). Nothing on an axis that did not change.
132
+ */
133
+ export function nudgeOf(part: V3RenderPart, result: GizmoResult): { by: Partial<Record<'x' | 'y' | 'z', number>>; turn: Partial<Record<'x' | 'y' | 'z', number>> } {
134
+ const axes = ['x', 'y', 'z'] as const
135
+ const by: Partial<Record<'x' | 'y' | 'z', number>> = {}
136
+ const turn: Partial<Record<'x' | 'y' | 'z', number>> = {}
137
+ if (result.position) axes.forEach((axis, i) => {
138
+ const d = round(result.position![i]! - (part.pose.t[i] as number))
139
+ if (Math.abs(d) > 1e-6) by[axis] = d
140
+ })
141
+ if (result.rotation) {
142
+ const r = part.pose.r
143
+ const now = new THREE.Euler().setFromRotationMatrix(new THREE.Matrix4().set(r[0]![0]!, r[0]![1]!, r[0]![2]!, 0, r[1]![0]!, r[1]![1]!, r[1]![2]!, 0, r[2]![0]!, r[2]![1]!, r[2]![2]!, 0, 0, 0, 0, 1), 'XYZ')
144
+ const was: number[] = [now.x, now.y, now.z].map(radians => (radians * 180) / Math.PI)
145
+ axes.forEach((axis, i) => {
146
+ const d = round(result.rotation![i]! - was[i]!)
147
+ if (Math.abs(d) > 1e-6) turn[axis] = d
148
+ })
149
+ }
150
+ return { by, turn }
151
+ }
152
+
129
153
  /** The keys of `next` whose values differ from `current` -- only those are set, so the scene raises no echo. */
130
154
  export function changedKeys(current: Record<string, unknown>, next: Record<string, unknown>): Record<string, unknown> | null {
131
155
  const diff: Record<string, unknown> = {}
@@ -22,6 +22,8 @@ export interface V3PartRow {
22
22
  hidden?: boolean;
23
23
  /** 자산이 지금 상태에서 보이지 않는다고 한 것. 저장되는 것이고, 여기서는 표시만 한다. */
24
24
  invisible?: boolean;
25
+ /** 되풀이한 부품이면 지금 몇 개로 그려지나. 되풀이는 한 부품이다 — 한 줄로 보인다. */
26
+ copies?: number;
25
27
  }
26
28
  /** 팔레트 — 저작 API 가 만들 수 있는 것만 보인다. */
27
29
  export type V3PaletteKind = V3PrimitiveCreation['kind'] | 'plate' | 'wall';
@@ -38,7 +40,18 @@ export declare class FigureV3Parts extends FigureV3Parts_base {
38
40
  * 기준이 저작을 이끈다(V2 모델러의 정체성). 등급이 D 면 무엇이 넘쳤는지를 적는다. 누르면 성숙도 탭이 열린다.
39
41
  */
40
42
  budget?: V3BudgetLine;
43
+ /**
44
+ * 디테일 등급의 부품 한도 — V2 의 S/M/L(사용자 2026-09-24). 부품을 넣기 **전에** 몇 개 남았는지 보이고, 다 차면 팔레트가
45
+ * 잠긴다. 되풀이는 한 부품으로 센다. 등급을 안 정했으면 한도는 없다.
46
+ */
47
+ partBudget?: {
48
+ level: 'S' | 'M' | 'L' | null;
49
+ used: number;
50
+ limit: number | null;
51
+ };
41
52
  render(): import("lit-html").TemplateResult<1>;
53
+ /** 디테일 등급 — 고르면 한도가 바뀐다. V2 의 자리 그대로, 목록 머리 아래. */
54
+ private renderLevel;
42
55
  private renderBudget;
43
56
  /** 한꺼번에 감추고 한꺼번에 되돌린다 — 하나라도 감춰져 있으면 다 되돌리고, 다 보이면 다 감춘다. */
44
57
  private renderShowAll;