@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.
- package/LICENSE +1409 -0
- package/README.md +17 -0
- package/contributions/blender-header-menus.tsx +483 -0
- package/contributions/blender-icon-trace.mjs +403 -0
- package/contributions/blender-icons.source.mjs +2925 -0
- package/contributions/blender-node-editor.document.tsx +1402 -0
- package/contributions/blender-node-geometry.ts +1138 -0
- package/contributions/blender-node-panels.source.mjs +485 -0
- package/contributions/blender-outliner-authoring.ts +1729 -0
- package/contributions/blender-outliner-model.ts +389 -0
- package/contributions/blender-palette.source.mjs +319 -0
- package/contributions/blender-properties-model.ts +351 -0
- package/contributions/blender-properties-tab.tsx +100 -0
- package/contributions/blender-properties-view.tsx +1191 -0
- package/contributions/blender-runtime-skin.ts +619 -0
- package/contributions/blender-runtime.document.tsx +232 -0
- package/contributions/blender-timeline-geometry.ts +323 -0
- package/contributions/blender-timeline.document.tsx +1056 -0
- package/contributions/blender-uv-editor.document.tsx +483 -0
- package/contributions/blender-uv-geometry.ts +305 -0
- package/contributions/blender-version.status.tsx +93 -0
- package/contributions/blender.command.ts +102 -0
- package/contributions/blender.icons.json +1247 -0
- package/contributions/blender.icons.traced.json +1561 -0
- package/contributions/blender.keymap.ts +39 -0
- package/contributions/blender.node-panels.json +2436 -0
- package/contributions/blender.palette.json +93 -0
- package/contributions/blender.status.tsx +263 -0
- package/contributions/blender.style.ts +271 -0
- package/contributions/model.layout.ts +53 -0
- package/contributions/models.finder.ts +59 -0
- package/contributions/properties-bone-constraints.inspector.tsx +50 -0
- package/contributions/properties-bone.inspector.tsx +184 -0
- package/contributions/properties-collection.inspector.tsx +96 -0
- package/contributions/properties-constraints.inspector.tsx +69 -0
- package/contributions/properties-data.inspector.tsx +229 -0
- package/contributions/properties-material.inspector.tsx +121 -0
- package/contributions/properties-modifiers.inspector.tsx +74 -0
- package/contributions/properties-object.inspector.tsx +215 -0
- package/contributions/properties-output.inspector.tsx +210 -0
- package/contributions/properties-particles.inspector.tsx +494 -0
- package/contributions/properties-physics.inspector.tsx +614 -0
- package/contributions/properties-render.inspector.tsx +446 -0
- package/contributions/properties-scene.inspector.tsx +174 -0
- package/contributions/properties-texture.inspector.tsx +300 -0
- package/contributions/properties-view-layer.inspector.tsx +145 -0
- package/contributions/properties-world.inspector.tsx +130 -0
- package/contributions/sculpt.layout.ts +25 -0
- package/contributions/shading.layout.ts +99 -0
- package/contributions/texture.layout.ts +16 -0
- package/contributions/uv-editing.layout.ts +93 -0
- package/host/blender-runtime-host.ts +1256 -0
- package/package.json +77 -0
- package/src/layouts.tsx +48 -0
- package/src/looks.ts +14 -0
- package/src/node-view-state.ts +125 -0
- package/src/timeline-view-state.ts +154 -0
- 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
|
+
}
|