@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
@@ -5,6 +5,10 @@ import { i18next } from '@operato/i18n';
5
5
  import { create, Model, niceGridStep } from '@hatiolab/things-scene';
6
6
  import { DEFAULT_VIEW, sceneLook, WORKBENCH } from './figure-view.js';
7
7
  import { boardFor, changedKeys, componentOf, gizmoResultOf } from './v3-stage-model.js';
8
+ import { JOINT_FAN } from './v3-joint-fan.js';
9
+ import { ownerOf } from './v3-fields.js';
10
+ /* The fan's colour when the figure's palette names no accent. */
11
+ const FAN_COLOUR = '#ff8a00';
8
12
  /* The 2D side of the board, which the author never sees on this page but the scene paints. */
9
13
  const PAPER = '#ffffff';
10
14
  let FigureV3Canvas = class FigureV3Canvas extends LitElement {
@@ -18,6 +22,14 @@ let FigureV3Canvas = class FigureV3Canvas extends LitElement {
18
22
  /** What the gizmo may write for the selected part; a change beyond it is not applied and the part springs back. */
19
23
  this.reach = { translate: false, rotate: false, scale: false };
20
24
  this.view = DEFAULT_VIEW;
25
+ /**
26
+ * View only: nothing that happens on the board is told out. The card's picture is taken from a second canvas stood up
27
+ * off screen (`v3-thumbnail.ts`); the scene settles its components as it builds them, and a change told from there
28
+ * would reach the editor as an edit (V2's `viewOnly`, for the same reason).
29
+ */
30
+ this.viewOnly = false;
31
+ /** Where the selected part turns and how far (`v3-joint-fan`): shown, never picked, never saved. */
32
+ this.fan = null;
21
33
  /** Set while this canvas writes to components, so the scene's echo of it is not taken for a drag. */
22
34
  this.writing = false;
23
35
  }
@@ -56,7 +68,7 @@ let FigureV3Canvas = class FigureV3Canvas extends LitElement {
56
68
  updated(changed) {
57
69
  if (!this.hasUpdated)
58
70
  return;
59
- if (changed.has('parts') || changed.has('palette') || changed.has('context'))
71
+ if (changed.has('parts') || changed.has('palette') || changed.has('context') || changed.has('fan'))
60
72
  this.standUp();
61
73
  if (changed.has('view'))
62
74
  this.applyView();
@@ -69,6 +81,26 @@ let FigureV3Canvas = class FigureV3Canvas extends LitElement {
69
81
  this.watcher = undefined;
70
82
  this.teardown();
71
83
  }
84
+ /**
85
+ * The board as a picture, for the card: the 3D view at this size, or nothing when it cannot be taken. The scene's
86
+ * `toDataURL` renders the 2D board (a white sheet, V2 found), and the 3D renderer keeps no drawing buffer, so the
87
+ * scene's own `snapshot3d` draws and reads in one task. A picture that cannot be taken is not an error and is not
88
+ * faked (V2's `snapshot`, lost when this canvas moved onto the scene -- no card had a picture since, user 2026-09-24).
89
+ */
90
+ async snapshot(width = 480, height = 360) {
91
+ const layer = this.scene?.root;
92
+ if (!layer?.snapshot3d)
93
+ return undefined;
94
+ try {
95
+ /* webp: a card needs no alpha, and png is three or four times heavier in every list response. */
96
+ const url = await layer.snapshot3d(width, height, 'image/webp', 0.85);
97
+ return typeof url === 'string' && url.startsWith('data:') ? url : undefined;
98
+ }
99
+ catch (e) {
100
+ console.warn(`[thumbnail] could not take the picture -- ${e.message}`);
101
+ return undefined;
102
+ }
103
+ }
72
104
  /** The whole figure in view: the board's fit, then the 3D camera's own, as V2 did. */
73
105
  fit() {
74
106
  this.scene?.fit?.('ratio');
@@ -83,7 +115,13 @@ let FigureV3Canvas = class FigureV3Canvas extends LitElement {
83
115
  return;
84
116
  const needed = boardFor(this.parts, this.board);
85
117
  const outgrown = !this.board || needed.width > this.board.width || needed.height > this.board.height || needed.depth > this.board.depth;
86
- if (this.scene && this.standingFor === this.context && !outgrown) {
118
+ if (this.scene && this.standingFor === this.context) {
119
+ /*
120
+ A part dragged past the board's edge grows the board where it stands. Standing it up again lost the selection,
121
+ the gizmo and the camera after every such drag -- the gizmo seemed not to work at all (user, 2026-09-24).
122
+ */
123
+ if (outgrown)
124
+ this.grow(needed);
87
125
  this.sync();
88
126
  return;
89
127
  }
@@ -122,7 +160,10 @@ let FigureV3Canvas = class FigureV3Canvas extends LitElement {
122
160
  }
123
161
  }
124
162
  components() {
125
- return this.parts.map(part => componentOf(part, this.palette, this.board)).filter((c) => !!c);
163
+ const parts = this.parts.map(part => componentOf(part, this.palette, this.board)).filter((c) => !!c);
164
+ /* The fan in the accent colour, translucent; `capturable: false` keeps clicks, the marquee and the gizmo off it. */
165
+ const fan = this.fan ? componentOf(this.fan, { ...this.palette, [JOINT_FAN]: this.palette['palette.accent'] ?? FAN_COLOUR }, this.board) : null;
166
+ return fan ? [...parts, { ...fan, capturable: false, locked: true }] : parts;
126
167
  }
127
168
  /** The components standing now, by name. Not held between calls: the scene stands its board up again at times. */
128
169
  standing() {
@@ -134,6 +175,20 @@ let FigureV3Canvas = class FigureV3Canvas extends LitElement {
134
175
  }
135
176
  return found;
136
177
  }
178
+ /** A larger board under the same scene: its size, and the grid that suits it. Components are re-placed by `sync`. */
179
+ grow(needed) {
180
+ const layer = this.scene?.root;
181
+ this.board = needed;
182
+ if (!layer)
183
+ return;
184
+ this.writing = true;
185
+ try {
186
+ layer.set({ width: needed.width, height: needed.height, depth: needed.depth, gridStep: niceGridStep(Math.max(needed.width, needed.height)) });
187
+ }
188
+ finally {
189
+ this.writing = false;
190
+ }
191
+ }
137
192
  /** Bring the components to the evaluation: remove the gone, write only what differs, add the new. */
138
193
  sync() {
139
194
  const layer = this.scene?.root;
@@ -171,12 +226,12 @@ let FigureV3Canvas = class FigureV3Canvas extends LitElement {
171
226
  watchScene() {
172
227
  const layer = this.scene?.root;
173
228
  const root = layer?.root;
174
- if (!layer || !root?.on)
229
+ if (!layer || !root?.on || this.viewOnly)
175
230
  return;
176
231
  root.on('selected', (after) => {
177
232
  const name = after?.[0]?.state?.name;
178
233
  const id = typeof name === 'string' ? name : '';
179
- if (id !== this.selected)
234
+ if (ownerOf(id) !== this.selected)
180
235
  this.dispatchEvent(new CustomEvent('part-selected', { detail: id, bubbles: true, composed: true }));
181
236
  });
182
237
  /*
@@ -204,11 +259,12 @@ let FigureV3Canvas = class FigureV3Canvas extends LitElement {
204
259
  const root = layer?.root;
205
260
  if (!layer || !root)
206
261
  return;
207
- const component = this.selected ? this.standing().get(this.selected) : undefined;
262
+ /* A repeat is selected whole: every copy of it (the editor selects the part the author made). */
263
+ const wanted = this.selected ? [...this.standing()].filter(([name]) => ownerOf(name) === this.selected).map(([, component]) => component) : [];
208
264
  const now = root.selected ?? [];
209
- if ((now[0] ?? undefined) === component && now.length === (component ? 1 : 0))
265
+ if (now.length === wanted.length && wanted.every((one, i) => now[i] === one))
210
266
  return;
211
- root.selected = component ? [component] : [];
267
+ root.selected = wanted;
212
268
  layer.invalidate?.();
213
269
  }
214
270
  /** View settings go into the scene as they are: they are the board's own state names. Nothing is stood up again. */
@@ -250,6 +306,14 @@ __decorate([
250
306
  property({ attribute: false }),
251
307
  __metadata("design:type", Object)
252
308
  ], FigureV3Canvas.prototype, "view", void 0);
309
+ __decorate([
310
+ property({ type: Boolean }),
311
+ __metadata("design:type", Object)
312
+ ], FigureV3Canvas.prototype, "viewOnly", void 0);
313
+ __decorate([
314
+ property({ attribute: false }),
315
+ __metadata("design:type", Object)
316
+ ], FigureV3Canvas.prototype, "fan", void 0);
253
317
  __decorate([
254
318
  query('div[stage]'),
255
319
  __metadata("design:type", HTMLDivElement)
@@ -1 +1 @@
1
- {"version":3,"file":"v3-canvas.js","sourceRoot":"","sources":["../../client/modeller/v3-canvas.ts"],"names":[],"mappings":"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","sourcesContent":["import { LitElement, html, css, nothing, unsafeCSS, type PropertyValues } from 'lit'\nimport { customElement, property, query } from 'lit/decorators.js'\nimport { i18next } from '@operato/i18n'\nimport { create, Model, niceGridStep } from '@hatiolab/things-scene'\n\nimport type { GizmoReach } from './v3-gizmo.js'\nimport type { V3RenderPart } from './v3-renderer.js'\nimport { DEFAULT_VIEW, sceneLook, WORKBENCH, type ViewSettings } from './figure-view.js'\nimport { boardFor, changedKeys, componentOf, gizmoResultOf, type Board } from './v3-stage-model.js'\n\n/*\n * The V3 modeller's canvas -- a things-scene board in editing mode, as the V2 modeller's was.\n *\n * Each body figure-model evaluates stands on the board as a `figure-v3-part` component, so everything the V2 canvas\n * had comes from the scene, not from code of our own: picking and the orange outline, the gizmo with its snapping and\n * live readout, box and shift selection, the camera (damping, zoom toward the cursor, double-click to fit, the six\n * view presets and their keys), the studio environment, the coordinate ground, lights and shadows, supersampling.\n * An earlier canvas drew the bodies with three.js directly and lost all of that (user, 2026-09-24: \"why nerf it?\").\n *\n * What the gizmo writes back to a component comes out as `part-transformed` in the inputs' terms (`v3-stage-model`),\n * and the editor turns it into one step of the history; the next evaluation puts the component where the inputs say,\n * so a change a part cannot take (a fastened part moved) springs back.\n */\n\ninterface SceneComponent {\n state: Record<string, unknown>\n set: (props: Record<string, unknown>) => void\n dispose?: () => void\n}\ninterface SceneLayer extends SceneComponent {\n components?: SceneComponent[]\n root?: { on?: (event: string, handler: (...args: unknown[]) => void) => void; selected?: SceneComponent[] }\n on?: (event: string, handler: (...args: unknown[]) => void) => void\n addComponent?: (component: SceneComponent) => void\n removeComponent?: (component: SceneComponent) => void\n invalidate?: () => void\n fitCameraToModel?: () => void\n app?: unknown\n}\ninterface SceneHandle {\n dispose?: () => void\n resize?: () => void\n fit?: (type?: string) => void\n root?: SceneLayer\n}\n\n/* The 2D side of the board, which the author never sees on this page but the scene paints. */\nconst PAPER = '#ffffff'\n\n@customElement('figure-v3-canvas')\nexport class FigureV3Canvas extends LitElement {\n static styles = css`\n :host {\n position: relative;\n display: flex;\n min-height: 0;\n overflow: hidden;\n background-color: ${unsafeCSS(WORKBENCH)};\n }\n /* The scene sets position: relative inline on its target; the size is given so it still fills the host. */\n div[stage] {\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n }\n p {\n position: absolute;\n inset: 45% 1rem auto;\n margin: 0;\n text-align: center;\n pointer-events: none;\n color: #f4f4f4;\n }\n `\n @property({ attribute: false }) parts: V3RenderPart[] = []\n @property({ attribute: false }) palette: Record<string, string> = {}\n /** Which figure is open. Another one stands the board up again. */\n @property() context = ''\n @property() selected = ''\n /** What the gizmo may write for the selected part; a change beyond it is not applied and the part springs back. */\n @property({ attribute: false }) reach: GizmoReach = { translate: false, rotate: false, scale: false }\n @property({ attribute: false }) view: ViewSettings = DEFAULT_VIEW\n\n @query('div[stage]') private stage!: HTMLDivElement\n\n private scene?: SceneHandle\n private board?: Board & { depth: number }\n private standingFor?: string\n private watcher?: ResizeObserver\n /** Set while this canvas writes to components, so the scene's echo of it is not taken for a drag. */\n private writing = false\n\n render() {\n return html`<div stage></div>${this.parts.length ? nothing : html`<p>${i18next.t('figure.text.add-a-shape-to-start')}</p>`}`\n }\n\n firstUpdated() {\n this.standUp()\n this.watcher = new ResizeObserver(() => this.scene?.resize?.())\n this.watcher.observe(this)\n }\n\n protected updated(changed: PropertyValues) {\n if (!this.hasUpdated) return\n if (changed.has('parts') || changed.has('palette') || changed.has('context')) this.standUp()\n if (changed.has('view')) this.applyView()\n if (changed.has('selected')) this.showSelected()\n }\n\n disconnectedCallback() {\n super.disconnectedCallback()\n this.watcher?.disconnect()\n this.watcher = undefined\n this.teardown()\n }\n\n /** The whole figure in view: the board's fit, then the 3D camera's own, as V2 did. */\n fit() {\n this.scene?.fit?.('ratio')\n this.scene?.root?.fitCameraToModel?.()\n }\n\n /**\n * Stand the board up for this figure, or bring the one standing up to date. A new board only for another figure or\n * one that has outgrown it -- standing up again loses the selection and the camera.\n */\n private standUp() {\n if (!this.stage) return\n const needed = boardFor(this.parts, this.board)\n const outgrown = !this.board || needed.width > this.board.width || needed.height > this.board.height || needed.depth > this.board.depth\n if (this.scene && this.standingFor === this.context && !outgrown) {\n this.sync()\n return\n }\n this.teardown()\n if (!this.parts.length) return\n this.board = needed\n try {\n this.scene = create({\n target: this.stage,\n model: {\n type: 'model-layer',\n width: needed.width,\n height: needed.height,\n depth: needed.depth,\n threed: true,\n /* Two samples a pixel: MSAA alone leaves steps on the silhouettes of one asset shown large (V2). */\n pixelRatio: Math.min(3, (window.devicePixelRatio || 1) * 2),\n ...sceneLook(this.view ?? DEFAULT_VIEW),\n gridStep: niceGridStep(Math.max(needed.width, needed.height)),\n sky: 'color',\n fillStyle: PAPER,\n components: this.components()\n },\n /* Editing mode: the camera view buttons, gizmo modes, help and keys V2's canvas had. */\n mode: 1\n }) as SceneHandle\n this.standingFor = this.context\n this.watchScene()\n this.scene.resize?.()\n this.fit()\n this.showSelected()\n } catch (error) {\n this.dispatchEvent(new CustomEvent('render-error', { detail: error, bubbles: true, composed: true }))\n }\n }\n\n private components(): Record<string, unknown>[] {\n return this.parts.map(part => componentOf(part, this.palette, this.board!)).filter((c): c is Record<string, unknown> => !!c)\n }\n\n /** The components standing now, by name. Not held between calls: the scene stands its board up again at times. */\n private standing(): Map<string, SceneComponent> {\n const found = new Map<string, SceneComponent>()\n for (const component of this.scene?.root?.components ?? []) {\n const name = component.state?.name\n if (typeof name === 'string') found.set(name, component)\n }\n return found\n }\n\n /** Bring the components to the evaluation: remove the gone, write only what differs, add the new. */\n private sync() {\n const layer = this.scene?.root\n if (!layer || !this.board) return\n const wanted = new Map(this.components().map(c => [c.name as string, c]))\n const standing = this.standing()\n this.writing = true\n try {\n for (const [name, component] of standing) {\n if (wanted.has(name)) continue\n layer.removeComponent?.(component)\n component.dispose?.()\n }\n for (const [name, state] of wanted) {\n const found = standing.get(name)\n if (found) {\n const diff = changedKeys(found.state, state)\n if (diff) found.set(diff)\n continue\n }\n const made = Model.compile({ ...state }, layer.app as never) as SceneComponent | null\n if (made) layer.addComponent?.(made)\n }\n } finally {\n this.writing = false\n }\n layer.invalidate?.()\n }\n\n /** Selection and drags, told out by the part's name -- never by its place in the list, which may change meanwhile. */\n private watchScene() {\n const layer = this.scene?.root\n const root = layer?.root\n if (!layer || !root?.on) return\n root.on('selected', (after: unknown) => {\n const name = (after as SceneComponent[] | undefined)?.[0]?.state?.name\n const id = typeof name === 'string' ? name : ''\n if (id !== this.selected) this.dispatchEvent(new CustomEvent('part-selected', { detail: id, bubbles: true, composed: true }))\n })\n /*\n A change comes up to the board with the component in the last argument's `origin` (the scene appends\n { origin, name, deliverer } as it bubbles). Reading the first argument as the component does nothing (V2).\n */\n layer.on?.('change', (...args: unknown[]) => {\n if (this.writing) return\n const origin = (args[args.length - 1] as { origin?: SceneComponent } | undefined)?.origin\n const name = origin?.state?.name\n const part = typeof name === 'string' ? this.parts.find(p => p.id === name) : undefined\n if (!part || !this.board) return\n const result = gizmoResultOf(part, origin!.state, this.board)\n if (!result.position && !result.rotation && !result.scale) return\n this.dispatchEvent(new CustomEvent('part-transformed', { detail: { id: part.id, result }, bubbles: true, composed: true }))\n /* After the editor has applied (or refused) it: a refused change springs back to where the inputs put it. */\n setTimeout(() => this.sync())\n })\n }\n\n private showSelected() {\n const layer = this.scene?.root\n const root = layer?.root\n if (!layer || !root) return\n const component = this.selected ? this.standing().get(this.selected) : undefined\n const now = root.selected ?? []\n if ((now[0] ?? undefined) === component && now.length === (component ? 1 : 0)) return\n root.selected = component ? [component] : []\n layer.invalidate?.()\n }\n\n /** View settings go into the scene as they are: they are the board's own state names. Nothing is stood up again. */\n private applyView() {\n const layer = this.scene?.root\n if (!layer) return\n layer.set(sceneLook(this.view ?? DEFAULT_VIEW))\n layer.invalidate?.()\n }\n\n private teardown() {\n this.scene?.dispose?.()\n this.scene = undefined\n this.standingFor = undefined\n this.board = undefined\n }\n}\n"]}
1
+ {"version":3,"file":"v3-canvas.js","sourceRoot":"","sources":["../../client/modeller/v3-canvas.ts"],"names":[],"mappings":"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","sourcesContent":["import { LitElement, html, css, nothing, unsafeCSS, type PropertyValues } from 'lit'\nimport { customElement, property, query } from 'lit/decorators.js'\nimport { i18next } from '@operato/i18n'\nimport { create, Model, niceGridStep } from '@hatiolab/things-scene'\n\nimport type { GizmoReach } from './v3-gizmo.js'\nimport type { V3RenderPart } from './v3-renderer.js'\nimport { DEFAULT_VIEW, sceneLook, WORKBENCH, type ViewSettings } from './figure-view.js'\nimport { boardFor, changedKeys, componentOf, gizmoResultOf, type Board } from './v3-stage-model.js'\nimport { JOINT_FAN } from './v3-joint-fan.js'\nimport { ownerOf } from './v3-fields.js'\n\n/*\n * The V3 modeller's canvas -- a things-scene board in editing mode, as the V2 modeller's was.\n *\n * Each body figure-model evaluates stands on the board as a `figure-v3-part` component, so everything the V2 canvas\n * had comes from the scene, not from code of our own: picking and the orange outline, the gizmo with its snapping and\n * live readout, box and shift selection, the camera (damping, zoom toward the cursor, double-click to fit, the six\n * view presets and their keys), the studio environment, the coordinate ground, lights and shadows, supersampling.\n * An earlier canvas drew the bodies with three.js directly and lost all of that (user, 2026-09-24: \"why nerf it?\").\n *\n * What the gizmo writes back to a component comes out as `part-transformed` in the inputs' terms (`v3-stage-model`),\n * and the editor turns it into one step of the history; the next evaluation puts the component where the inputs say,\n * so a change a part cannot take (a fastened part moved) springs back.\n */\n\ninterface SceneComponent {\n state: Record<string, unknown>\n set: (props: Record<string, unknown>) => void\n dispose?: () => void\n}\ninterface SceneLayer extends SceneComponent {\n components?: SceneComponent[]\n root?: { on?: (event: string, handler: (...args: unknown[]) => void) => void; selected?: SceneComponent[] }\n on?: (event: string, handler: (...args: unknown[]) => void) => void\n addComponent?: (component: SceneComponent) => void\n removeComponent?: (component: SceneComponent) => void\n invalidate?: () => void\n fitCameraToModel?: () => void\n app?: unknown\n}\ninterface SceneHandle {\n dispose?: () => void\n resize?: () => void\n fit?: (type?: string) => void\n root?: SceneLayer\n}\n\n/* The fan's colour when the figure's palette names no accent. */\nconst FAN_COLOUR = '#ff8a00'\n\n/* The 2D side of the board, which the author never sees on this page but the scene paints. */\nconst PAPER = '#ffffff'\n\n@customElement('figure-v3-canvas')\nexport class FigureV3Canvas extends LitElement {\n static styles = css`\n :host {\n position: relative;\n display: flex;\n min-height: 0;\n overflow: hidden;\n background-color: ${unsafeCSS(WORKBENCH)};\n }\n /* The scene sets position: relative inline on its target; the size is given so it still fills the host. */\n div[stage] {\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n }\n p {\n position: absolute;\n inset: 45% 1rem auto;\n margin: 0;\n text-align: center;\n pointer-events: none;\n color: #f4f4f4;\n }\n `\n @property({ attribute: false }) parts: V3RenderPart[] = []\n @property({ attribute: false }) palette: Record<string, string> = {}\n /** Which figure is open. Another one stands the board up again. */\n @property() context = ''\n @property() selected = ''\n /** What the gizmo may write for the selected part; a change beyond it is not applied and the part springs back. */\n @property({ attribute: false }) reach: GizmoReach = { translate: false, rotate: false, scale: false }\n @property({ attribute: false }) view: ViewSettings = DEFAULT_VIEW\n /**\n * View only: nothing that happens on the board is told out. The card's picture is taken from a second canvas stood up\n * off screen (`v3-thumbnail.ts`); the scene settles its components as it builds them, and a change told from there\n * would reach the editor as an edit (V2's `viewOnly`, for the same reason).\n */\n @property({ type: Boolean }) viewOnly = false\n /** Where the selected part turns and how far (`v3-joint-fan`): shown, never picked, never saved. */\n @property({ attribute: false }) fan: V3RenderPart | null = null\n\n @query('div[stage]') private stage!: HTMLDivElement\n\n private scene?: SceneHandle\n private board?: Board & { depth: number }\n private standingFor?: string\n private watcher?: ResizeObserver\n /** Set while this canvas writes to components, so the scene's echo of it is not taken for a drag. */\n private writing = false\n\n render() {\n return html`<div stage></div>${this.parts.length ? nothing : html`<p>${i18next.t('figure.text.add-a-shape-to-start')}</p>`}`\n }\n\n firstUpdated() {\n this.standUp()\n this.watcher = new ResizeObserver(() => this.scene?.resize?.())\n this.watcher.observe(this)\n }\n\n protected updated(changed: PropertyValues) {\n if (!this.hasUpdated) return\n if (changed.has('parts') || changed.has('palette') || changed.has('context') || changed.has('fan')) this.standUp()\n if (changed.has('view')) this.applyView()\n if (changed.has('selected')) this.showSelected()\n }\n\n disconnectedCallback() {\n super.disconnectedCallback()\n this.watcher?.disconnect()\n this.watcher = undefined\n this.teardown()\n }\n\n /**\n * The board as a picture, for the card: the 3D view at this size, or nothing when it cannot be taken. The scene's\n * `toDataURL` renders the 2D board (a white sheet, V2 found), and the 3D renderer keeps no drawing buffer, so the\n * scene's own `snapshot3d` draws and reads in one task. A picture that cannot be taken is not an error and is not\n * faked (V2's `snapshot`, lost when this canvas moved onto the scene -- no card had a picture since, user 2026-09-24).\n */\n async snapshot(width = 480, height = 360): Promise<string | undefined> {\n const layer = this.scene?.root as (SceneLayer & { snapshot3d?: (w?: number, h?: number, type?: string, quality?: number) => Promise<string | undefined> }) | undefined\n if (!layer?.snapshot3d) return undefined\n try {\n /* webp: a card needs no alpha, and png is three or four times heavier in every list response. */\n const url = await layer.snapshot3d(width, height, 'image/webp', 0.85)\n return typeof url === 'string' && url.startsWith('data:') ? url : undefined\n } catch (e) {\n console.warn(`[thumbnail] could not take the picture -- ${(e as Error).message}`)\n return undefined\n }\n }\n\n /** The whole figure in view: the board's fit, then the 3D camera's own, as V2 did. */\n fit() {\n this.scene?.fit?.('ratio')\n this.scene?.root?.fitCameraToModel?.()\n }\n\n /**\n * Stand the board up for this figure, or bring the one standing up to date. A new board only for another figure or\n * one that has outgrown it -- standing up again loses the selection and the camera.\n */\n private standUp() {\n if (!this.stage) return\n const needed = boardFor(this.parts, this.board)\n const outgrown = !this.board || needed.width > this.board.width || needed.height > this.board.height || needed.depth > this.board.depth\n if (this.scene && this.standingFor === this.context) {\n /*\n A part dragged past the board's edge grows the board where it stands. Standing it up again lost the selection,\n the gizmo and the camera after every such drag -- the gizmo seemed not to work at all (user, 2026-09-24).\n */\n if (outgrown) this.grow(needed)\n this.sync()\n return\n }\n this.teardown()\n if (!this.parts.length) return\n this.board = needed\n try {\n this.scene = create({\n target: this.stage,\n model: {\n type: 'model-layer',\n width: needed.width,\n height: needed.height,\n depth: needed.depth,\n threed: true,\n /* Two samples a pixel: MSAA alone leaves steps on the silhouettes of one asset shown large (V2). */\n pixelRatio: Math.min(3, (window.devicePixelRatio || 1) * 2),\n ...sceneLook(this.view ?? DEFAULT_VIEW),\n gridStep: niceGridStep(Math.max(needed.width, needed.height)),\n sky: 'color',\n fillStyle: PAPER,\n components: this.components()\n },\n /* Editing mode: the camera view buttons, gizmo modes, help and keys V2's canvas had. */\n mode: 1\n }) as SceneHandle\n this.standingFor = this.context\n this.watchScene()\n this.scene.resize?.()\n this.fit()\n this.showSelected()\n } catch (error) {\n this.dispatchEvent(new CustomEvent('render-error', { detail: error, bubbles: true, composed: true }))\n }\n }\n\n private components(): Record<string, unknown>[] {\n const parts = this.parts.map(part => componentOf(part, this.palette, this.board!)).filter((c): c is Record<string, unknown> => !!c)\n /* The fan in the accent colour, translucent; `capturable: false` keeps clicks, the marquee and the gizmo off it. */\n const fan = this.fan ? componentOf(this.fan, { ...this.palette, [JOINT_FAN]: this.palette['palette.accent'] ?? FAN_COLOUR }, this.board!) : null\n return fan ? [...parts, { ...fan, capturable: false, locked: true }] : parts\n }\n\n /** The components standing now, by name. Not held between calls: the scene stands its board up again at times. */\n private standing(): Map<string, SceneComponent> {\n const found = new Map<string, SceneComponent>()\n for (const component of this.scene?.root?.components ?? []) {\n const name = component.state?.name\n if (typeof name === 'string') found.set(name, component)\n }\n return found\n }\n\n /** A larger board under the same scene: its size, and the grid that suits it. Components are re-placed by `sync`. */\n private grow(needed: Board & { depth: number }) {\n const layer = this.scene?.root\n this.board = needed\n if (!layer) return\n this.writing = true\n try {\n layer.set({ width: needed.width, height: needed.height, depth: needed.depth, gridStep: niceGridStep(Math.max(needed.width, needed.height)) })\n } finally {\n this.writing = false\n }\n }\n\n /** Bring the components to the evaluation: remove the gone, write only what differs, add the new. */\n private sync() {\n const layer = this.scene?.root\n if (!layer || !this.board) return\n const wanted = new Map(this.components().map(c => [c.name as string, c]))\n const standing = this.standing()\n this.writing = true\n try {\n for (const [name, component] of standing) {\n if (wanted.has(name)) continue\n layer.removeComponent?.(component)\n component.dispose?.()\n }\n for (const [name, state] of wanted) {\n const found = standing.get(name)\n if (found) {\n const diff = changedKeys(found.state, state)\n if (diff) found.set(diff)\n continue\n }\n const made = Model.compile({ ...state }, layer.app as never) as SceneComponent | null\n if (made) layer.addComponent?.(made)\n }\n } finally {\n this.writing = false\n }\n layer.invalidate?.()\n }\n\n /** Selection and drags, told out by the part's name -- never by its place in the list, which may change meanwhile. */\n private watchScene() {\n const layer = this.scene?.root\n const root = layer?.root\n if (!layer || !root?.on || this.viewOnly) return\n root.on('selected', (after: unknown) => {\n const name = (after as SceneComponent[] | undefined)?.[0]?.state?.name\n const id = typeof name === 'string' ? name : ''\n if (ownerOf(id) !== this.selected) this.dispatchEvent(new CustomEvent('part-selected', { detail: id, bubbles: true, composed: true }))\n })\n /*\n A change comes up to the board with the component in the last argument's `origin` (the scene appends\n { origin, name, deliverer } as it bubbles). Reading the first argument as the component does nothing (V2).\n */\n layer.on?.('change', (...args: unknown[]) => {\n if (this.writing) return\n const origin = (args[args.length - 1] as { origin?: SceneComponent } | undefined)?.origin\n const name = origin?.state?.name\n const part = typeof name === 'string' ? this.parts.find(p => p.id === name) : undefined\n if (!part || !this.board) return\n const result = gizmoResultOf(part, origin!.state, this.board)\n if (!result.position && !result.rotation && !result.scale) return\n this.dispatchEvent(new CustomEvent('part-transformed', { detail: { id: part.id, result }, bubbles: true, composed: true }))\n /* After the editor has applied (or refused) it: a refused change springs back to where the inputs put it. */\n setTimeout(() => this.sync())\n })\n }\n\n private showSelected() {\n const layer = this.scene?.root\n const root = layer?.root\n if (!layer || !root) return\n /* A repeat is selected whole: every copy of it (the editor selects the part the author made). */\n const wanted = this.selected ? [...this.standing()].filter(([name]) => ownerOf(name) === this.selected).map(([, component]) => component) : []\n const now = root.selected ?? []\n if (now.length === wanted.length && wanted.every((one, i) => now[i] === one)) return\n root.selected = wanted\n layer.invalidate?.()\n }\n\n /** View settings go into the scene as they are: they are the board's own state names. Nothing is stood up again. */\n private applyView() {\n const layer = this.scene?.root\n if (!layer) return\n layer.set(sceneLook(this.view ?? DEFAULT_VIEW))\n layer.invalidate?.()\n }\n\n private teardown() {\n this.scene?.dispose?.()\n this.scene = undefined\n this.standingFor = undefined\n this.board = undefined\n }\n}\n"]}
@@ -34,6 +34,6 @@ export declare const trackEdit: (clip: Clip, track: ClipTrack, patch?: Partial<P
34
34
  state: string;
35
35
  clip: string;
36
36
  keys: ClipKey[];
37
- loop: "wrap" | "hold";
37
+ loop: "hold" | "wrap";
38
38
  interpolation: "linear" | "step";
39
39
  };
@@ -50,6 +50,11 @@ export declare class FigureV3Editor extends LitElement {
50
50
  private run;
51
51
  /** What the canvas draws: the figure with the value in hand when there is one, and without the parts hidden on the workbench. */
52
52
  private drawnParts;
53
+ /**
54
+ * Where the selected part turns and how far, with the pose as it is drawn now -- a value in hand included, so the
55
+ * fan follows the arm while a control is dragged. Its size comes from what the joint turns (`v3-joint-fan`).
56
+ */
57
+ private fanOf;
53
58
  /** A value in hand: an input's value (design or pose) or a part edit. Nothing is recorded. */
54
59
  private hold;
55
60
  private changed;
@@ -76,6 +81,11 @@ export declare class FigureV3Editor extends LitElement {
76
81
  * A part's name — the V2 part name field. It renames every value the part owns, and what refers to them follows
77
82
  * (figure-model's rename-part), as one step of the history.
78
83
  */
84
+ /**
85
+ * A repeated body is one of its repeat's copies and is named after it -- the list and this field read `roller 15`, not
86
+ * its path through the repeat (user, 2026-09-24: 「부품 이름 ???」). A copy has no name of its own to change.
87
+ */
88
+ private renderRepeatNote;
79
89
  private renamePart;
80
90
  /** Size response, fastening and motion for the selected part (parity ④ ⑤ ⑥), as authoring commands. */
81
91
  private renderLinks;
@@ -95,6 +105,30 @@ export declare class FigureV3Editor extends LitElement {
95
105
  private range?;
96
106
  /** The sizes a board may give the figure, and the way to measure them again after its parts changed. */
97
107
  private renderInstanceRange;
108
+ /**
109
+ * How the figure grows. One ratio (`sizing: 'uniform'`) is said as it is; a figure with joints that has not declared
110
+ * it is offered it -- a mechanism does not shear -- and the author decides (chief architect's ruling 2026-09-24).
111
+ */
112
+ private renderSizingRule;
113
+ /**
114
+ * The parts list, one row per part the author made: a repeat is one row with how many copies it makes now (V2 listed
115
+ * its parts the same way; user, 2026-09-24: the conveyor's sixteen rollers were sixteen rows).
116
+ */
117
+ private partRows;
118
+ /** The detail level's part budget for the list's head, from figure-model's count (a repeat once). */
119
+ private partBudgetOf;
120
+ /**
121
+ * A repeat: which way its copies run, how many there are now, and how far apart -- V2's `repeat: { axis, pitch }`.
122
+ * The pitch of a row that fills its length is edited as a number (`set-repeat` makes it the part's own the first
123
+ * time); a row of a fixed count says its pitch cannot be set yet (figure-model refuses it the same way).
124
+ */
125
+ private renderRepeat;
126
+ /**
127
+ * A pitch always goes through `set-repeat`: the command moves the longest length with it (at most `limit` copies),
128
+ * which a plain input edit would not. Refused only when the authored length would already hold more copies than
129
+ * that -- said with the numbers.
130
+ */
131
+ private setPitch;
98
132
  /** Choose a part, from the list, the canvas or a place row. A figure-level tab gives way to the part's properties. */
99
133
  private choose;
100
134
  private asset;
@@ -4,6 +4,7 @@ import { LitElement, html, css, nothing } from 'lit';
4
4
  import { i18next } from '@operato/i18n';
5
5
  import '@operato/layout';
6
6
  import { customElement, property, state } from 'lit/decorators.js';
7
+ import { PART_LIMIT, v3PartCountOf } from '@hatiolab/figure-model';
7
8
  import { captureV3Thumbnail } from './v3-thumbnail.js';
8
9
  import { drawableV3Parts } from './v3-renderer.js';
9
10
  import './v3-canvas.js';
@@ -18,11 +19,13 @@ import { DEFAULT_VIEW } from './figure-view.js';
18
19
  import './figure-v3-capability.js';
19
20
  import { gizmoChanges, gizmoReachOf } from './v3-gizmo.js';
20
21
  import { keyActionOf } from './v3-keys.js';
21
- import { groupFields } from './v3-fields.js';
22
+ import { groupFields, ownerOf, partLabelOf, repeatOf } from './v3-fields.js';
22
23
  import { SidePanelStyles } from './side-panel-styles.js';
23
24
  import { alignChanges, boxOf, canMirror, mirrorChanges } from './v3-align.js';
24
25
  import { paletteRecipe } from './v3-palette.js';
25
- import { instanceRangeOf, rangeDiffers } from './v3-links.js';
26
+ import { nudgeOf } from './v3-stage-model.js';
27
+ import { instanceRangeOf, rangeDiffers, turnsAboutAJoint } from './v3-links.js';
28
+ import { jointFanOf } from './v3-joint-fan.js';
26
29
  import { isLive, livePartsOf, NOTHING_LIVE, scrubbed, scrubStep } from './v3-live.js';
27
30
  /** Product editor surface. The injected session is shared by page persistence and AI commands. */
28
31
  let FigureV3Editor = class FigureV3Editor extends LitElement {
@@ -279,7 +282,7 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
279
282
  this.error = '';
280
283
  this.hiddenParts = new Set();
281
284
  }
282
- if (view && !view.geometry.some(g => g.id === this.selected))
285
+ if (view && !view.geometry.some(g => ownerOf(g.id) === this.selected))
283
286
  this.selected = '';
284
287
  }
285
288
  run(action) {
@@ -299,7 +302,21 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
299
302
  /** What the canvas draws: the figure with the value in hand when there is one, and without the parts hidden on the workbench. */
300
303
  drawnParts(geometry, asset) {
301
304
  const parts = (isLive(this.live) && asset ? livePartsOf(asset, this.live) : null) ?? drawableV3Parts(geometry ?? []);
302
- return parts.filter(body => !this.hiddenParts.has(body.id));
305
+ return parts.filter(body => !this.hiddenParts.has(ownerOf(body.id)));
306
+ }
307
+ /**
308
+ * Where the selected part turns and how far, with the pose as it is drawn now -- a value in hand included, so the
309
+ * fan follows the arm while a control is dragged. Its size comes from what the joint turns (`v3-joint-fan`).
310
+ */
311
+ fanOf(asset) {
312
+ if (!asset || !this.selected || this.hiddenParts.has(this.selected))
313
+ return null;
314
+ try {
315
+ return jointFanOf(asset, this.selected, this.live.values);
316
+ }
317
+ catch {
318
+ return null;
319
+ }
303
320
  }
304
321
  /** A value in hand: an input's value (design or pose) or a part edit. Nothing is recorded. */
305
322
  hold(values, edit) {
@@ -388,7 +405,8 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
388
405
  /** What the gizmo may write for the selected part — read off the asset being edited. */
389
406
  reachOf(id) {
390
407
  const none = { translate: false, rotate: false, scale: false };
391
- if (!id || !this.session)
408
+ /* A repeat is moved and sized through its row, not by a gizmo on one copy. */
409
+ if (!id || !this.session || !this.session.read().geometry.some(g => g.id === id))
392
410
  return none;
393
411
  try {
394
412
  return gizmoReachOf(JSON.parse(this.session.source()), id);
@@ -402,10 +420,39 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
402
420
  const view = this.session.read();
403
421
  const sizes = Object.fromEntries(view.fields.map(field => [field.id, field.value]));
404
422
  const changes = gizmoChanges(id, this.reachOf(id), result, sizes);
405
- if (changes.length)
423
+ if (changes.length) {
406
424
  this.run(() => this.session.apply(changes));
407
- else
425
+ return;
426
+ }
427
+ /*
428
+ A part whose place is worked out (a converted one) has no numbers for the gizmo to write: the drag is added beside
429
+ its rule (`nudge-part`). A part a joint or a fastening places, or a size that is worked out, is refused -- said in
430
+ words, not by the part silently springing back (user, 2026-09-24: 「기즈모 컨트롤도 전혀 작동이 안되는군」).
431
+ */
432
+ const body = view.geometry.find(g => g.id === id);
433
+ if (!body)
434
+ return this.requestUpdate();
435
+ if (result.scale && !result.position && !result.rotation) {
436
+ this.error = i18next.t('figure.text.gizmo-size-worked-out', { part: partLabelOf(id) });
437
+ return this.requestUpdate();
438
+ }
439
+ const { by, turn } = nudgeOf(body, result);
440
+ if (!Object.keys(by).length && !Object.keys(turn).length)
441
+ return this.requestUpdate();
442
+ try {
443
+ this.session.command({ kind: 'nudge-part', part: id, ...(Object.keys(by).length ? { by } : {}), ...(Object.keys(turn).length ? { turn } : {}) });
444
+ this.error = '';
445
+ this.changed();
446
+ }
447
+ catch (error) {
448
+ const message = error instanceof Error ? error.message : String(error);
449
+ this.error = /carried by a joint/.test(message)
450
+ ? i18next.t('figure.text.gizmo-joint-places', { part: partLabelOf(id) })
451
+ : /is fastened to/.test(message)
452
+ ? i18next.t('figure.text.gizmo-fastening-places', { part: partLabelOf(id) })
453
+ : message;
408
454
  this.requestUpdate();
455
+ }
409
456
  }
410
457
  /** 맞추기 — one tool, one step of the history, through the same inputs as the gizmo. */
411
458
  align(tool) {
@@ -465,6 +512,14 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
465
512
  * A part's name — the V2 part name field. It renames every value the part owns, and what refers to them follows
466
513
  * (figure-model's rename-part), as one step of the history.
467
514
  */
515
+ /**
516
+ * A repeated body is one of its repeat's copies and is named after it -- the list and this field read `roller 15`, not
517
+ * its path through the repeat (user, 2026-09-24: 「부품 이름 ???」). A copy has no name of its own to change.
518
+ */
519
+ renderRepeatNote(geometry) {
520
+ const repeat = repeatOf(this.selected, geometry);
521
+ return repeat?.index ? html `<p hint>${i18next.t('figure.text.repeat-member', { name: repeat.name, count: repeat.count, index: repeat.index })}</p>` : nothing;
522
+ }
468
523
  renamePart(input) {
469
524
  const name = input.value.trim();
470
525
  const from = this.selected;
@@ -653,11 +708,133 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
653
708
  <md-icon>straighten</md-icon>${i18next.t('figure.button.remeasure-range')}
654
709
  </button>`
655
710
  : nothing}
711
+ ${this.renderSizingRule(asset, unavailable)}
712
+ </section>`;
713
+ }
714
+ /**
715
+ * How the figure grows. One ratio (`sizing: 'uniform'`) is said as it is; a figure with joints that has not declared
716
+ * it is offered it -- a mechanism does not shear -- and the author decides (chief architect's ruling 2026-09-24).
717
+ */
718
+ renderSizingRule(asset, unavailable) {
719
+ if (asset.sizing === 'uniform')
720
+ return html `<div inline-field><label>${i18next.t('figure.label.sizing-rule')}</label><span>${i18next.t('figure.label.sizing-uniform')}</span></div>`;
721
+ return html `${turnsAboutAJoint(asset) ? html `<p hint>${i18next.t('figure.text.sizing-uniform-proposed')}</p>` : nothing}
722
+ <button act ?disabled=${unavailable} title=${i18next.t('figure.text.sizing-uniform-explained')} @click=${() => this.linkCommand({ kind: 'scale-uniformly' })}>
723
+ <md-icon>aspect_ratio</md-icon>${i18next.t('figure.button.scale-uniformly')}
724
+ </button>`;
725
+ }
726
+ /**
727
+ * The parts list, one row per part the author made: a repeat is one row with how many copies it makes now (V2 listed
728
+ * its parts the same way; user, 2026-09-24: the conveyor's sixteen rollers were sixteen rows).
729
+ */
730
+ partRows(geometry) {
731
+ const rows = new Map();
732
+ for (const g of geometry) {
733
+ const id = ownerOf(g.id);
734
+ const row = rows.get(id);
735
+ if (row) {
736
+ row.copies = (row.copies ?? 1) + 1;
737
+ continue;
738
+ }
739
+ rows.set(id, {
740
+ id,
741
+ provider: g.shape?.provider ?? '',
742
+ hidden: this.hiddenParts.has(id),
743
+ invisible: g.visible === false,
744
+ ...(id !== g.id ? { copies: 1 } : {})
745
+ });
746
+ }
747
+ return [...rows.values()];
748
+ }
749
+ /** The detail level's part budget for the list's head, from figure-model's count (a repeat once). */
750
+ partBudgetOf(asset) {
751
+ if (!asset)
752
+ return undefined;
753
+ try {
754
+ return { level: asset.detailLevel ?? null, used: v3PartCountOf(asset), limit: asset.detailLevel ? PART_LIMIT[asset.detailLevel] : null };
755
+ }
756
+ catch {
757
+ return undefined;
758
+ }
759
+ }
760
+ /**
761
+ * A repeat: which way its copies run, how many there are now, and how far apart -- V2's `repeat: { axis, pitch }`.
762
+ * The pitch of a row that fills its length is edited as a number (`set-repeat` makes it the part's own the first
763
+ * time); a row of a fixed count says its pitch cannot be set yet (figure-model refuses it the same way).
764
+ */
765
+ renderRepeat(asset, geometry, unavailable) {
766
+ const repeat = repeatOf(this.selected, geometry);
767
+ if (!asset || !repeat || repeat.index)
768
+ return nothing;
769
+ const model = asset.document.model;
770
+ const layout = model.nodes.find(n => n.id === `${this.selected}.layout`);
771
+ const axis = model.nodes.find(n => n.id === `${this.selected}.repeat`)?.params?.axis ?? '?';
772
+ const own = `${this.selected}.repeat.pitch`;
773
+ const pitch = asset.designInputs[own] ?? model.constants.find(c => c.id === layout?.args[4])?.value;
774
+ const fits = layout?.op === 'fit-pitch@1';
775
+ /* What the pitch gives, in numbers: this many copies at the length it is authored, and how long it may be placed. */
776
+ const lengthRef = layout?.args[0] ?? '';
777
+ const length = asset.designInputs[lengthRef];
778
+ const longest = model.inputs.find(i => i.id === lengthRef)?.max;
779
+ const limit = model.constants.find(c => c.id === layout?.args[6])?.value;
780
+ const field = { id: own, unit: 'mm', role: 'design', value: pitch ?? 0 };
781
+ return html `<section>
782
+ <h3>${i18next.t('figure.label.repeat')}</h3>
783
+ <div inline-field><label>${i18next.t('figure.label.repeat-axis')}</label><span value>${axis.toUpperCase()}</span></div>
784
+ <div inline-field><label>${i18next.t('figure.label.repeat-count')}</label><span value>${repeat.count}</span></div>
785
+ <div inline-field>
786
+ <label>${i18next.t('figure.label.repeat-gap')} <span unit>mm</span></label>
787
+ <input
788
+ type="number"
789
+ step="any"
790
+ aria-label=${own}
791
+ .value=${String(pitch ?? '')}
792
+ ?disabled=${unavailable || !fits}
793
+ @change=${(e) => this.setPitch(field, e)}
794
+ />
795
+ </div>
796
+ <p hint>
797
+ ${fits && length !== undefined && longest !== undefined && limit !== undefined
798
+ ? i18next.t('figure.text.repeat-what-pitch-gives', {
799
+ length: length.toLocaleString(),
800
+ count: repeat.count,
801
+ longest: longest.toLocaleString(),
802
+ limit
803
+ })
804
+ : i18next.t(fits ? 'figure.text.repeat-fits-length' : 'figure.text.repeat-fixed-count')}
805
+ </p>
656
806
  </section>`;
657
807
  }
808
+ /**
809
+ * A pitch always goes through `set-repeat`: the command moves the longest length with it (at most `limit` copies),
810
+ * which a plain input edit would not. Refused only when the authored length would already hold more copies than
811
+ * that -- said with the numbers.
812
+ */
813
+ setPitch(field, event) {
814
+ const input = event.target;
815
+ const pitch = input.valueAsNumber;
816
+ if (!Number.isFinite(pitch) || pitch === field.value)
817
+ return;
818
+ try {
819
+ this.session.command({ kind: 'set-repeat', part: this.selected, pitch });
820
+ this.error = '';
821
+ this.changed();
822
+ }
823
+ catch (error) {
824
+ input.value = String(field.value);
825
+ const code = error?.code;
826
+ const message = error instanceof Error ? error.message : String(error);
827
+ const found = /\((\d[\d.]*) mm\) a pitch under ([\d.]+) mm makes more than (\d+)/.exec(message);
828
+ this.error =
829
+ code === 'INPUT_RANGE' && found
830
+ ? i18next.t('figure.text.repeat-pitch-too-small', { length: Number(found[1]).toLocaleString(), min: found[2], limit: found[3] })
831
+ : message;
832
+ }
833
+ }
658
834
  /** Choose a part, from the list, the canvas or a place row. A figure-level tab gives way to the part's properties. */
659
835
  choose(id) {
660
- this.selected = id;
836
+ /* A copy on the canvas selects its repeat: the part the author made. */
837
+ this.selected = id ? ownerOf(id) : id;
661
838
  if (id && (this.tab === 'verdict' || this.tab === 'view' || this.tab === 'history'))
662
839
  this.tab = 'properties';
663
840
  }
@@ -695,11 +872,12 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
695
872
  if (!view)
696
873
  return html `<p>${i18next.t('figure.text.opening-model')}</p>`;
697
874
  const unavailable = view.loading || !!view.error || view.saving || !view.context;
698
- const part = view.geometry.find(g => g.id === this.selected);
875
+ const part = view.geometry.find(g => ownerOf(g.id) === this.selected);
699
876
  /* The controls (state inputs) have their own place in the motion tab; these are the design values. */
700
877
  const design = view.fields.filter(i => i.role !== 'state');
701
- const own = design.filter(i => this.selected && i.id.startsWith(this.selected + '.'));
702
- const common = design.filter(i => !view.geometry.some(g => i.id.startsWith(g.id + '.')));
878
+ /* A repeat's own row values (its pitch) have their own section, not the shape's. */
879
+ const own = design.filter(i => this.selected && i.id.startsWith(this.selected + '.') && !i.id.startsWith(this.selected + '.repeat.'));
880
+ const common = design.filter(i => !view.geometry.some(g => i.id.startsWith(ownerOf(g.id) + '.')));
703
881
  const status = view.loading
704
882
  ? 'figure.text.loading-figure'
705
883
  : view.saving
@@ -733,18 +911,15 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
733
911
  slot="start"
734
912
  .budget=${this.budget}
735
913
  @show-maturity=${() => (this.tab = 'verdict')}
736
- .parts=${view.geometry.map((g) => ({
737
- id: g.id,
738
- provider: g.shape?.provider ?? '',
739
- hidden: this.hiddenParts.has(g.id),
740
- invisible: g.visible === false
741
- }))}
914
+ .parts=${this.partRows(view.geometry)}
915
+ .partBudget=${this.partBudgetOf(asset)}
916
+ @set-detail-level=${(e) => this.linkCommand({ kind: 'set-detail-level', level: e.detail.level })}
742
917
  .selected=${this.selected}
743
918
  ?locked=${unavailable}
744
919
  @add-part=${(e) => this.add(e.detail.kind)}
745
920
  @select-part=${(e) => this.choose(e.detail.id)}
746
921
  @toggle-part-hidden=${(e) => this.toggleHiddenPart(e.detail.id)}
747
- @set-all-parts-hidden=${(e) => (this.hiddenParts = e.detail.hidden ? new Set(view.geometry.map(g => g.id)) : new Set())}
922
+ @set-all-parts-hidden=${(e) => (this.hiddenParts = e.detail.hidden ? new Set(view.geometry.map(g => ownerOf(g.id))) : new Set())}
748
923
  @move-part-up=${(e) => this.movePart(e.detail.id, e.detail.to)}
749
924
  @move-part-down=${(e) => this.movePart(e.detail.id, e.detail.to)}
750
925
  @duplicate-part=${(e) => this.duplicatePart(e.detail.id)}
@@ -760,6 +935,7 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
760
935
  : html `<figure-v3-canvas
761
936
  .view=${this.view}
762
937
  .parts=${this.drawnParts(view.geometry, asset)}
938
+ .fan=${this.fanOf(asset)}
763
939
  .palette=${view.palette ?? {}}
764
940
  .context=${view.context ?? ''}
765
941
  .selected=${this.selected}
@@ -790,16 +966,18 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
790
966
  <input
791
967
  name-field
792
968
  aria-label=${i18next.t('figure.label.name')}
793
- .value=${this.selected}
794
- ?disabled=${unavailable}
969
+ .value=${partLabelOf(this.selected)}
970
+ ?disabled=${unavailable || !!repeatOf(this.selected, view.geometry)}
795
971
  @change=${(e) => this.renamePart(e.target)}
796
972
  />
797
973
  </div>
974
+ ${this.renderRepeatNote(view.geometry)}
798
975
  </section>
799
- ${this.renderGroups(groupFields(design, this.selected), 'figure.label.group-placement-size', 'figure.label.group-shape', unavailable, this.renderTools(unavailable))}
976
+ ${this.renderRepeat(asset, view.geometry, unavailable)}
977
+ ${this.renderGroups(groupFields(own, this.selected), 'figure.label.group-placement-size', 'figure.label.group-shape', unavailable, this.renderTools(unavailable))}
800
978
  ${this.renderLinks(unavailable, 'shape')}
801
979
  <figure-v3-material
802
- .body=${part}
980
+ .body=${{ ...part, id: this.selected }}
803
981
  .palette=${view.palette ?? {}}
804
982
  ?locked=${unavailable}
805
983
  @v3-part-edit=${(e) => this.run(() => this.session.edit(e.detail.edit))}