@volter/editor-blender 0.1.0

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 (58) hide show
  1. package/LICENSE +1409 -0
  2. package/README.md +17 -0
  3. package/contributions/blender-header-menus.tsx +483 -0
  4. package/contributions/blender-icon-trace.mjs +403 -0
  5. package/contributions/blender-icons.source.mjs +2925 -0
  6. package/contributions/blender-node-editor.document.tsx +1402 -0
  7. package/contributions/blender-node-geometry.ts +1138 -0
  8. package/contributions/blender-node-panels.source.mjs +485 -0
  9. package/contributions/blender-outliner-authoring.ts +1729 -0
  10. package/contributions/blender-outliner-model.ts +389 -0
  11. package/contributions/blender-palette.source.mjs +319 -0
  12. package/contributions/blender-properties-model.ts +351 -0
  13. package/contributions/blender-properties-tab.tsx +100 -0
  14. package/contributions/blender-properties-view.tsx +1191 -0
  15. package/contributions/blender-runtime-skin.ts +619 -0
  16. package/contributions/blender-runtime.document.tsx +232 -0
  17. package/contributions/blender-timeline-geometry.ts +323 -0
  18. package/contributions/blender-timeline.document.tsx +1056 -0
  19. package/contributions/blender-uv-editor.document.tsx +483 -0
  20. package/contributions/blender-uv-geometry.ts +305 -0
  21. package/contributions/blender-version.status.tsx +93 -0
  22. package/contributions/blender.command.ts +102 -0
  23. package/contributions/blender.icons.json +1247 -0
  24. package/contributions/blender.icons.traced.json +1561 -0
  25. package/contributions/blender.keymap.ts +39 -0
  26. package/contributions/blender.node-panels.json +2436 -0
  27. package/contributions/blender.palette.json +93 -0
  28. package/contributions/blender.status.tsx +263 -0
  29. package/contributions/blender.style.ts +271 -0
  30. package/contributions/model.layout.ts +53 -0
  31. package/contributions/models.finder.ts +59 -0
  32. package/contributions/properties-bone-constraints.inspector.tsx +50 -0
  33. package/contributions/properties-bone.inspector.tsx +184 -0
  34. package/contributions/properties-collection.inspector.tsx +96 -0
  35. package/contributions/properties-constraints.inspector.tsx +69 -0
  36. package/contributions/properties-data.inspector.tsx +229 -0
  37. package/contributions/properties-material.inspector.tsx +121 -0
  38. package/contributions/properties-modifiers.inspector.tsx +74 -0
  39. package/contributions/properties-object.inspector.tsx +215 -0
  40. package/contributions/properties-output.inspector.tsx +210 -0
  41. package/contributions/properties-particles.inspector.tsx +494 -0
  42. package/contributions/properties-physics.inspector.tsx +614 -0
  43. package/contributions/properties-render.inspector.tsx +446 -0
  44. package/contributions/properties-scene.inspector.tsx +174 -0
  45. package/contributions/properties-texture.inspector.tsx +300 -0
  46. package/contributions/properties-view-layer.inspector.tsx +145 -0
  47. package/contributions/properties-world.inspector.tsx +130 -0
  48. package/contributions/sculpt.layout.ts +25 -0
  49. package/contributions/shading.layout.ts +99 -0
  50. package/contributions/texture.layout.ts +16 -0
  51. package/contributions/uv-editing.layout.ts +93 -0
  52. package/host/blender-runtime-host.ts +1256 -0
  53. package/package.json +77 -0
  54. package/src/layouts.tsx +48 -0
  55. package/src/looks.ts +14 -0
  56. package/src/node-view-state.ts +125 -0
  57. package/src/timeline-view-state.ts +154 -0
  58. package/src/uv-view-state.ts +125 -0
@@ -0,0 +1,483 @@
1
+ /**
2
+ * THE UV EDITOR — Blender's UV editor over the engine's own mesh data, in a UV
3
+ * Editing workspace (WORK.md §Blender in the tab is Blender, "Inspection
4
+ * parity", I5; ARCHITECTURE-CORE §Blender north star).
5
+ *
6
+ * Every constant and colour it draws with is `./blender-uv-geometry.ts`, read
7
+ * from Blender's source at the engine's pin and cited there; every number it
8
+ * draws comes from `session.py`'s `rna_uv_layout` door. Blender's UI layer is
9
+ * never run, ported or recorded: this is OUR panel over RNA.
10
+ *
11
+ * ## SVG, not canvas 2D
12
+ *
13
+ * The same reason the node view gives, measured for this data: a UV layout is
14
+ * one `<path>` per island wash, one per island outline and one `<circle>` per
15
+ * drawn corner, and the browser's own renderer does the transform, the
16
+ * antialiasing and the hit testing. MEASURED on
17
+ * `examples/first-person/src/models/arena-weapons.blend`'s largest UV mesh
18
+ * (`LauncherMuzzleCollar`, 1,120 corners / 280 faces): 280 face paths, 280
19
+ * outline paths and 1,120 dots. The DOT CAP below is what keeps that honest on
20
+ * a mesh an order of magnitude larger, and the geometry lives in its own
21
+ * module precisely so a canvas can take over behind it when a measurement says
22
+ * so.
23
+ *
24
+ * ## It never edits, and every gesture that would is refused BY NAME
25
+ *
26
+ * Pinning, unwrapping, selecting and moving a UV all write the mesh, and
27
+ * editing parity is not the program. The refusals are the view's own, reached
28
+ * from the verb table and from the pointer handlers through one function
29
+ * (`refuseUvViewGesture`), so the two cannot drift into two vocabularies.
30
+ */
31
+
32
+ import type { BlenderUvLayout } from '@volter/blender-engine/browser/rna';
33
+ import { registerViewVerbs } from '@volter/editor-sdk/views';
34
+ import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
35
+ import {
36
+ blenderRnaVersion,
37
+ blenderUvLayout,
38
+ subscribeBlenderRna,
39
+ } from '../host/blender-runtime-host';
40
+ import {
41
+ refuseUvViewGesture,
42
+ requestUvViewAll,
43
+ setUvViewState,
44
+ subscribeUvView,
45
+ uvViewAllRequest,
46
+ uvViewState,
47
+ uvViewVersion,
48
+ } from '../src/uv-view-state';
49
+ import {
50
+ UV_CHROME,
51
+ UV_EDGE_DRAWN_WIDTH,
52
+ UV_FACEDOT_SIZE,
53
+ UV_GRID_OPEN,
54
+ UV_GRID_SUBDIV,
55
+ UV_LINE_STYLES,
56
+ UV_OPACITY,
57
+ UV_PIN_COLOR,
58
+ UV_THEME,
59
+ UV_TILE_GRID,
60
+ UV_VERT_DOT_SIZE,
61
+ UV_VERT_OUTLINE_WIDTH,
62
+ uvBytes,
63
+ uvFloat32,
64
+ uvRgba,
65
+ uvUint32,
66
+ } from './blender-uv-geometry';
67
+
68
+ export const point = 'workspace.document';
69
+ /** Blender's own name for this editor — `rna_space.cc`'s `SPACE_IMAGE` item
70
+ * reads "UV Editor" when `SpaceImage.mode` is `UV`, which is the mode
71
+ * Blender's UV Editing workspace opens it in (measured on the engine: that
72
+ * workspace's `IMAGE_EDITOR` area answers `mode: 'UV'`). */
73
+ export const title = 'UV Editor';
74
+
75
+ /** How many corner dots the view will draw before it stands them down and
76
+ * says so. A dot is an element; 20,000 of them is a DOM the browser spends
77
+ * more time laying out than the read took, and a silent thinning is exactly
78
+ * the "named grade of acceptable partiality" the anti-shim rule forbids — so
79
+ * the cap is a WARNING with a number in it, never a quiet decimation. */
80
+ const DOT_CAP = 8000;
81
+
82
+ const ZOOM_MIN = 16;
83
+ const ZOOM_MAX = 8192;
84
+
85
+ const REFUSALS = {
86
+ move: (name: string) =>
87
+ `Dragging a UV in "${name}" writes the mesh's UV attribute, which the document would save. This is Blender's UV editor as an INSPECTION surface: editing parity is not the program.`,
88
+ select:
89
+ "Selecting a UV writes the mesh's selection state. This view draws what the DATA carries — and at this pin a mesh outside edit mode carries no UV selection at all (MeshUVLoopLayer declares uv, pin, name, active, active_render, active_clone and nothing else), so everything draws unselected. Editing parity is not the program.",
90
+ pin: "Pinning a UV writes `MeshUVLoopLayer.pin`, which the document would save. Existing pins ARE drawn, in Blender's own hard red. Editing parity is not the program.",
91
+ unwrap:
92
+ 'Unwrapping runs `bpy.ops.uv.unwrap`, which rewrites every UV on the mesh. Editing parity is not the program.',
93
+ } as const;
94
+
95
+ /** THE VIEW'S VERBS, published once, here — U8's ruling 1 (2026-09-19). Under
96
+ * the Code-OSS frame each becomes a `vgai.blender-uv-view.<verb>` command the
97
+ * bridge dispatches into the view, and standalone `vgai edit` reaches the
98
+ * SAME table through the session. **This view adds no `blender-*` session
99
+ * verb at all**, which is exactly what the ruling asked the remaining I5
100
+ * views to stop paying for. */
101
+ registerViewVerbs({
102
+ view: 'blender-uv-view',
103
+ title,
104
+ verbs: [
105
+ { id: 'state', run: () => uvViewState() },
106
+ {
107
+ id: 'view-all',
108
+ title: 'UV Editor: View All',
109
+ run: () => {
110
+ requestUvViewAll();
111
+ return uvViewState();
112
+ },
113
+ },
114
+ {
115
+ id: 'zoom',
116
+ title: 'UV Editor: Zoom',
117
+ run: (args) => {
118
+ const to = Number(args?.['to'] ?? args?.['zoom']);
119
+ if (!Number.isFinite(to))
120
+ throw new Error('zoom needs a numeric `to` — CSS pixels per UV unit, 16 … 8192.');
121
+ const { transform } = uvViewState();
122
+ setUvViewState({
123
+ transform: { ...transform, zoom: Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, to)) },
124
+ });
125
+ return uvViewState();
126
+ },
127
+ },
128
+ {
129
+ id: 'pan',
130
+ title: 'UV Editor: Pan',
131
+ run: (args) => {
132
+ const du = Number(args?.['u'] ?? 0);
133
+ const dv = Number(args?.['v'] ?? 0);
134
+ if (!Number.isFinite(du) || !Number.isFinite(dv))
135
+ throw new Error('pan needs numeric `u` and `v`, in UV units.');
136
+ const { transform } = uvViewState();
137
+ setUvViewState({
138
+ transform: { ...transform, cx: transform.cx + du, cy: transform.cy + dv },
139
+ });
140
+ return uvViewState();
141
+ },
142
+ },
143
+ { id: 'move-uv', run: () => refuse(REFUSALS.move('the active UV map')) },
144
+ { id: 'select', run: () => refuse(REFUSALS.select) },
145
+ { id: 'pin', run: () => refuse(REFUSALS.pin) },
146
+ { id: 'unwrap', run: () => refuse(REFUSALS.unwrap) },
147
+ ],
148
+ });
149
+
150
+ function refuse(text: string): unknown {
151
+ refuseUvViewGesture(text);
152
+ return uvViewState();
153
+ }
154
+
155
+ interface Prepared {
156
+ readonly uv: Float32Array;
157
+ readonly tris: Uint32Array;
158
+ readonly starts: Uint32Array;
159
+ readonly totals: Uint32Array;
160
+ readonly pins: Uint8Array | null;
161
+ }
162
+
163
+ function prepare(layout: BlenderUvLayout | null): Prepared | null {
164
+ if (!layout?.uvBase64 || !layout.triangleBase64) return null;
165
+ return {
166
+ uv: uvFloat32(layout.uvBase64),
167
+ tris: uvUint32(layout.triangleBase64),
168
+ starts: uvUint32(layout.loopStartBase64 ?? ''),
169
+ totals: uvUint32(layout.loopTotalBase64 ?? ''),
170
+ // A PIN IS ONE BYTE, not a float: the door ships `array("b")` bytes
171
+ // straight off `MeshUVLoopLayer.pin` and answers null when nothing is
172
+ // pinned, so an absent payload is "no pins" rather than "unknown".
173
+ pins: layout.pinBase64 ? uvBytes(layout.pinBase64) : null,
174
+ };
175
+ }
176
+
177
+ export default function BlenderUvEditor() {
178
+ // THE TREE'S FRESHNESS IS THE TREE'S (ruling 3, 2026-09-19): the RNA door's
179
+ // own version, bumped by every write and by a presented frame, rather than
180
+ // the properties model's — which moves only when a frame ships.
181
+ const version = useSyncExternalStore(subscribeBlenderRna, blenderRnaVersion, blenderRnaVersion);
182
+ useSyncExternalStore(subscribeUvView, uvViewVersion, uvViewVersion);
183
+ const framed = useRef(0);
184
+ const canvas = useRef<HTMLDivElement | null>(null);
185
+ const layoutRef = useRef<BlenderUvLayout | null>(null);
186
+ const errorRef = useRef<string | null>(null);
187
+ const { transform, refusal, size } = uvViewState();
188
+ const [, force] = [0, useCallback(() => setUvViewState({}), [])];
189
+
190
+ useEffect(() => {
191
+ let live = true;
192
+ void (async () => {
193
+ try {
194
+ const answer = await blenderUvLayout();
195
+ if (!live) return;
196
+ layoutRef.current = answer;
197
+ errorRef.current = null;
198
+ } catch (thrown) {
199
+ if (!live) return;
200
+ errorRef.current = thrown instanceof Error ? thrown.message : String(thrown);
201
+ }
202
+ force();
203
+ })();
204
+ return () => {
205
+ live = false;
206
+ };
207
+ }, [version, force]);
208
+
209
+ useEffect(() => {
210
+ const element = canvas.current;
211
+ if (!element) return;
212
+ const observer = new ResizeObserver(() => {
213
+ const next = { w: element.clientWidth, h: element.clientHeight };
214
+ const current = uvViewState().size;
215
+ if (current.w !== next.w || current.h !== next.h) setUvViewState({ size: next });
216
+ });
217
+ observer.observe(element);
218
+ setUvViewState({ size: { w: element.clientWidth, h: element.clientHeight } });
219
+ return () => observer.disconnect();
220
+ }, []);
221
+
222
+ const layout = layoutRef.current;
223
+ const prepared = useMemo(() => prepare(layout), [layout]);
224
+
225
+ // VIEW ALL — Blender's `image_view_all`: fit the drawn bounds with a margin,
226
+ // never a fixed zoom. The ASK is a counter in the store because framing
227
+ // needs the read layout, which the verb does not have.
228
+ const request = uvViewAllRequest();
229
+ useEffect(() => {
230
+ if (request === framed.current) return;
231
+ framed.current = request;
232
+ const bounds = layout?.bounds ?? [0, 0, 1, 1];
233
+ const { w, h } = uvViewState().size;
234
+ if (w === 0 || h === 0) return;
235
+ const [u0, v0, u1, v1] = bounds;
236
+ const spanU = Math.max(u1 - u0, 1e-3);
237
+ const spanV = Math.max(v1 - v0, 1e-3);
238
+ const zoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.min(w / spanU, h / spanV) * 0.9));
239
+ setUvViewState({
240
+ transform: { cx: (u0 + u1) / 2, cy: (v0 + v1) / 2, zoom },
241
+ framedAt: Date.now(),
242
+ });
243
+ }, [request, layout]);
244
+
245
+ // UV SPACE IS Y-UP and the screen is Y-DOWN, so the flip happens ONCE, here,
246
+ // in the group transform — nothing downstream carries a sign.
247
+ const toX = (u: number) => (u - transform.cx) * transform.zoom + size.w / 2;
248
+ const toY = (v: number) => size.h / 2 - (v - transform.cy) * transform.zoom;
249
+
250
+ const drawing = useMemo(() => {
251
+ if (!layout || !prepared) return null;
252
+ const { uv, tris, starts, totals, pins } = prepared;
253
+ const faces: string[] = [];
254
+ const outlines: string[] = [];
255
+ const dots: { x: number; y: number; pinned: boolean }[] = [];
256
+ const faceDots: { x: number; y: number }[] = [];
257
+ const polygons = starts.length;
258
+ for (let poly = 0; poly < polygons; poly++) {
259
+ const start = starts[poly]!;
260
+ const total = totals[poly]!;
261
+ if (total < 3) continue;
262
+ let d = '';
263
+ let cu = 0;
264
+ let cv = 0;
265
+ for (let i = 0; i < total; i++) {
266
+ const corner = start + i;
267
+ const u = uv[corner * 2]!;
268
+ const v = uv[corner * 2 + 1]!;
269
+ cu += u;
270
+ cv += v;
271
+ d += `${i === 0 ? 'M' : 'L'}${toX(u).toFixed(2)} ${toY(v).toFixed(2)}`;
272
+ }
273
+ d += 'Z';
274
+ faces.push(d);
275
+ outlines.push(d);
276
+ faceDots.push({ x: toX(cu / total), y: toY(cv / total) });
277
+ }
278
+ const corners = uv.length >> 1;
279
+ for (let corner = 0; corner < corners && dots.length < DOT_CAP; corner++) {
280
+ dots.push({
281
+ x: toX(uv[corner * 2]!),
282
+ y: toY(uv[corner * 2 + 1]!),
283
+ pinned: pins ? pins[corner] !== 0 : false,
284
+ });
285
+ }
286
+ return { faces, outlines, dots, faceDots, corners, triangles: tris.length / 3 };
287
+ }, [layout, prepared, transform, size]);
288
+
289
+ // WHAT THE VIEW DREW, published so the parity reading is a measurement of the
290
+ // shipped drawing rather than a second run of the same arithmetic.
291
+ useEffect(() => {
292
+ if (!layout) return;
293
+ const warnings: string[] = [];
294
+ if (layout.layers.length === 0 && layout.object)
295
+ warnings.push(`"${layout.object}" has no UV map — Blender's UV editor draws an empty tile.`);
296
+ if (drawing && drawing.corners > DOT_CAP)
297
+ warnings.push(
298
+ `${drawing.corners} UV corners exceed this view's ${DOT_CAP}-dot budget; ${drawing.corners - DOT_CAP} corner marks are not drawn. The islands, their edges and the face dots are all drawn.`,
299
+ );
300
+ if (!layout.image)
301
+ warnings.push(
302
+ "No image behind the tile: the active material's node tree has no image texture with an image, so there is nothing for Blender's backdrop to show.",
303
+ );
304
+ warnings.push(UV_GRID_OPEN);
305
+ warnings.push(
306
+ "The line style is SHADOW (#707070), not Blender's stock OUTLINE: `edit_uv_line_style_from_space_image` returns OUTLINE only when `SpaceImage.mode == SI_MODE_UV`, and there is no SpaceImage here.",
307
+ );
308
+ setUvViewState({
309
+ drawn: {
310
+ object: layout.object,
311
+ mesh: layout.mesh,
312
+ layer: layout.active,
313
+ layers: layout.layers,
314
+ mode: layout.mode,
315
+ loops: layout.loops,
316
+ polygons: layout.polygons,
317
+ triangles: drawing?.triangles ?? 0,
318
+ faces: drawing?.faces.length ?? 0,
319
+ edges: drawing?.outlines.length ?? 0,
320
+ verts: drawing?.dots.length ?? 0,
321
+ faceDots: drawing?.faceDots.length ?? 0,
322
+ pinned: drawing?.dots.filter((dot) => dot.pinned).length ?? 0,
323
+ bounds: layout.bounds ?? null,
324
+ tiles: UV_TILE_GRID,
325
+ subdiv: UV_GRID_SUBDIV,
326
+ image: layout.image?.name ?? null,
327
+ warnings,
328
+ },
329
+ });
330
+ }, [layout, drawing]);
331
+
332
+ const gridLines: {
333
+ key: string;
334
+ x1: number;
335
+ y1: number;
336
+ x2: number;
337
+ y2: number;
338
+ major: boolean;
339
+ }[] = useMemo(() => {
340
+ const out: typeof gridLines = [];
341
+ const [tilesU, tilesV] = UV_TILE_GRID;
342
+ const [subU, subV] = UV_GRID_SUBDIV;
343
+ for (let i = 0; i <= tilesU * subU; i++) {
344
+ const u = i / subU;
345
+ out.push({
346
+ key: `u${i}`,
347
+ x1: toX(u),
348
+ y1: toY(0),
349
+ x2: toX(u),
350
+ y2: toY(tilesV),
351
+ major: i % subU === 0,
352
+ });
353
+ }
354
+ for (let i = 0; i <= tilesV * subV; i++) {
355
+ const v = i / subV;
356
+ out.push({
357
+ key: `v${i}`,
358
+ x1: toX(0),
359
+ y1: toY(v),
360
+ x2: toX(tilesU),
361
+ y2: toY(v),
362
+ major: i % subV === 0,
363
+ });
364
+ }
365
+ return out;
366
+ }, [transform, size]);
367
+
368
+ const status = layout
369
+ ? layout.active
370
+ ? `UV Editor · ${layout.object} · ${layout.active} · ${layout.polygons} faces, ${layout.loops} corners · ${layout.mode}`
371
+ : `UV Editor · ${layout.object ?? 'no mesh selected'} · no UV map · ${layout.mode}`
372
+ : (errorRef.current ?? 'UV Editor · reading…');
373
+
374
+ return (
375
+ <div
376
+ data-testid="blender-uv-editor"
377
+ style={{
378
+ display: 'flex',
379
+ flexDirection: 'column',
380
+ // FILLED FROM THE HOST BOX, NOT `height: 100%`, and that is the
381
+ // MEASURED difference between a drawer panel and an EDITOR GROUP. In
382
+ // the drawer this view's `height: '100%'` worked, because the drawer
383
+ // group imposes a height on its panel. As a `workspace.document` in
384
+ // `vgai:area:uv` it resolved to ZERO against an auto-height content
385
+ // box — measured live 2026-09-19, `vgai.blender-uv-view.state`
386
+ // answered `size: {w: 782, h: 0}` with a fully READ subject beneath
387
+ // it (24 corners, 6 faces, the ProbeChecker backdrop found), which is
388
+ // a view that has everything to draw and nowhere to draw it. The node
389
+ // view already had `position: absolute; inset: 0` for its own reason
390
+ // and was `{w: 1294, h: 467}` in the same layout, which is what named
391
+ // the difference.
392
+ position: 'absolute',
393
+ inset: 0,
394
+ overflow: 'hidden',
395
+ background: UV_THEME.back,
396
+ color: UV_CHROME.text,
397
+ font: '11px Inter, system-ui, sans-serif',
398
+ }}
399
+ >
400
+ {/* A HEIGHT IMPOSED FROM ABOVE. The node view's walk measured what the
401
+ other way costs: an SVG at `height: 100%` inside an auto-height parent
402
+ settles at ~30 px, because each side's height is a function of the
403
+ other's. */}
404
+ <div ref={canvas} style={{ flex: 1, minHeight: 0, position: 'relative' }}>
405
+ <svg
406
+ width={size.w}
407
+ height={size.h}
408
+ style={{ display: 'block' }}
409
+ onPointerDown={() => refuseUvViewGesture(REFUSALS.select)}
410
+ aria-label="UV layout"
411
+ >
412
+ <title>UV layout</title>
413
+ {gridLines.map((line) => (
414
+ <line
415
+ key={line.key}
416
+ x1={line.x1}
417
+ y1={line.y1}
418
+ x2={line.x2}
419
+ y2={line.y2}
420
+ stroke={UV_THEME.grid}
421
+ strokeWidth={line.major ? 2 : 1}
422
+ />
423
+ ))}
424
+ {drawing?.faces.map((d, index) => (
425
+ // ONE WASH PER FACE, at `.face` #ffffff alpha 0x0a — Blender's own
426
+ // unselected island colour, and an island reads as a wash because
427
+ // overlapping faces each add their 4 %.
428
+ // biome-ignore lint/suspicious/noArrayIndexKey: a face's index IS its identity here — the door answers polygons in order and nothing reorders them.
429
+ <path
430
+ key={`f${index}`}
431
+ d={d}
432
+ fill={uvRgba(UV_THEME.face, UV_THEME.faceAlpha * UV_OPACITY)}
433
+ />
434
+ ))}
435
+ {drawing?.outlines.map((d, index) => (
436
+ // biome-ignore lint/suspicious/noArrayIndexKey: as above.
437
+ <path
438
+ key={`e${index}`}
439
+ d={d}
440
+ fill="none"
441
+ stroke={UV_LINE_STYLES.shadow.inner}
442
+ strokeWidth={UV_EDGE_DRAWN_WIDTH}
443
+ />
444
+ ))}
445
+ {drawing?.faceDots.map((dot, index) => (
446
+ // biome-ignore lint/suspicious/noArrayIndexKey: as above.
447
+ <circle
448
+ key={`fd${index}`}
449
+ cx={dot.x}
450
+ cy={dot.y}
451
+ r={UV_FACEDOT_SIZE / 2}
452
+ fill={UV_THEME.faceDot}
453
+ />
454
+ ))}
455
+ {drawing?.dots.map((dot, index) => (
456
+ // biome-ignore lint/suspicious/noArrayIndexKey: as above.
457
+ <circle
458
+ key={`v${index}`}
459
+ cx={dot.x}
460
+ cy={dot.y}
461
+ r={UV_VERT_DOT_SIZE / 2}
462
+ fill={dot.pinned ? UV_PIN_COLOR : UV_THEME.vertex}
463
+ stroke={dot.pinned ? UV_PIN_COLOR : 'none'}
464
+ strokeWidth={dot.pinned ? UV_VERT_OUTLINE_WIDTH : 0}
465
+ />
466
+ ))}
467
+ </svg>
468
+ </div>
469
+ <div
470
+ style={{
471
+ borderTop: `1px solid ${UV_CHROME.rule}`,
472
+ padding: UV_CHROME.statusPadding,
473
+ display: 'flex',
474
+ gap: UV_CHROME.statusGap,
475
+ alignItems: 'center',
476
+ }}
477
+ >
478
+ <span>{status}</span>
479
+ {refusal ? <span style={{ color: UV_CHROME.refusal }}>{refusal}</span> : null}
480
+ </div>
481
+ </div>
482
+ );
483
+ }