chore: checkpoint web Blender migration source, tools and docs
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer,
web workflow engine and protocol, tooling, docs and task records.
Large generated evidence, user-testing archives, build snapshots and
imported test assets remain untracked.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
mes123456
2026-11-10 11:18:01 +08:00
parent 122142c26c
commit f3e21c6b98
6887 changed files with 257154 additions and 12294 deletions

View File

@@ -0,0 +1,32 @@
"""Read Action, keyframe and timeline-marker state from native saved Main files."""
import json
from pathlib import Path
import sys
import bpy
def curves_of(action):
curves = {}
try:
for curve in action.fcurves:
curves[(curve.data_path, curve.array_index)] = curve
except (AttributeError, RuntimeError):
pass
for layer in action.layers:
for strip in layer.strips:
for bag in getattr(strip, "channelbags", ()):
for curve in bag.fcurves:
curves[(curve.data_path, curve.array_index)] = curve
return [curves[key] for key in sorted(curves)]
directory = Path(sys.argv[sys.argv.index("--") + 1])
snapshots = {}
for file in sorted(directory.glob("*.blend")):
bpy.ops.wm.open_mainfile(filepath=str(file), load_ui=False)
actions = [{"name": action.name, "curves": [{"path": curve.data_path, "index": curve.array_index, "keys": [{"co": list(point.co), "left": list(point.handle_left), "right": list(point.handle_right), "leftType": point.handle_left_type, "rightType": point.handle_right_type, "interpolation": point.interpolation} for point in curve.keyframe_points]} for curve in curves_of(action)]} for action in sorted(bpy.data.actions, key=lambda item: item.name)]
objects = [{"name": obj.name, "action": obj.animation_data.action.name if obj.animation_data and obj.animation_data.action else None} for obj in sorted(bpy.data.objects, key=lambda item: item.name)]
scenes = [{"name": scene.name, "frame": scene.frame_current, "markers": [{"name": marker.name, "frame": marker.frame} for marker in sorted(scene.timeline_markers, key=lambda item: (item.frame, item.name))]} for scene in sorted(bpy.data.scenes, key=lambda item: item.name)]
snapshots[file.stem] = {"actions": actions, "objects": objects, "scenes": scenes}
(directory / "native-action-snapshots.json").write_text(json.dumps(snapshots, sort_keys=True, separators=(",", ":")), encoding="utf-8")

View File

@@ -0,0 +1,106 @@
import json
from pathlib import Path
import sys
import bpy
def curves_of(action):
curves = []
if hasattr(action, "fcurves"):
curves.extend(action.fcurves)
for layer in action.layers:
for strip in layer.strips:
for bag in getattr(strip, "channelbags", []):
curves.extend(bag.fcurves)
return curves
def curve_data(curve):
driver = curve.driver
return {
"path": curve.data_path,
"index": curve.array_index,
"group": curve.group.name if curve.group else None,
"driver": None if driver is None else {
"type": driver.type,
"expression": driver.expression,
"variables": [variable.name for variable in driver.variables],
},
"keys": [{
"co": list(key.co),
"left": list(key.handle_left),
"right": list(key.handle_right),
"leftType": key.handle_left_type,
"rightType": key.handle_right_type,
"interpolation": key.interpolation,
} for key in curve.keyframe_points],
}
def snapshot():
result = {
"actions": [{
"name": action.name,
"curves": sorted([curve_data(curve) for curve in curves_of(action)],
key=lambda curve: (curve["path"], curve["index"])),
"legacyGroups": [group.name for group in getattr(action, "groups", [])],
"slotGroups": [{
"slot": bag.slot_handle,
"groups": [group.name for group in bag.groups],
} for layer in action.layers for strip in layer.strips
for bag in getattr(strip, "channelbags", [])],
} for action in sorted(bpy.data.actions, key=lambda item: item.name)],
"objects": [{
"name": obj.name,
"action": obj.animation_data.action.name
if obj.animation_data and obj.animation_data.action else None,
"drivers": [curve_data(curve) for curve in obj.animation_data.drivers]
if obj.animation_data else [],
"location": list(obj.location),
} for obj in sorted(bpy.data.objects, key=lambda item: item.name)],
"scenes": [{
"name": scene.name,
"frame": scene.frame_current,
"activeKeyingSet": scene.keying_sets.active.bl_label
if scene.keying_sets.active else None,
"keyingSets": [{
"name": keying_set.bl_label,
"idname": keying_set.bl_idname,
"isPathAbsolute": keying_set.is_path_absolute,
"paths": [{
"path": item.data_path,
"arrayIndex": item.array_index,
"id": item.id.name if item.id else None,
"idType": item.id_type,
} for item in keying_set.paths],
} for keying_set in scene.keying_sets],
} for scene in sorted(bpy.data.scenes, key=lambda item: item.name)],
}
frames = []
scene = bpy.context.scene
original_frame = scene.frame_current
for frame in [1, 3, 5]:
scene.frame_set(frame)
evaluated = bpy.context.evaluated_depsgraph_get()
frames.append({
"frame": frame,
"objects": [{
"name": obj.name,
"location": list(obj.evaluated_get(evaluated).location),
"rotation": list(obj.evaluated_get(evaluated).rotation_euler),
"scale": list(obj.evaluated_get(evaluated).scale),
} for obj in sorted(bpy.data.objects, key=lambda item: item.name)],
})
scene.frame_set(original_frame)
result["evaluatedSamples"] = frames
return result
request_path = Path(sys.argv[sys.argv.index("--") + 1])
request = json.loads(request_path.read_text())
result = {}
for label, filename in request["files"].items():
bpy.ops.wm.open_mainfile(filepath=str(Path(filename).resolve()))
result[label] = snapshot()
Path(request["output"]).write_text(json.dumps(result, sort_keys=True, indent=2) + "\n")

View File

@@ -0,0 +1,37 @@
import bpy
import json
import pathlib
import sys
request = json.loads(pathlib.Path(sys.argv[sys.argv.index("--") + 1]).read_text())
def vector(values):
return [round(float(value), 6) for value in values]
states = {}
for label, file in request["files"].items():
bpy.ops.wm.open_mainfile(filepath=str(pathlib.Path(file).resolve()))
obj = bpy.data.objects["PoseConstraintArmatureObject"]
states[label] = {
"mirrorX": obj.data.use_mirror_x,
"bones": [{
"name": bone.name,
"parent": bone.parent.name if bone.parent else None,
"connected": bone.use_connect,
"head": vector(bone.head_local),
"tail": vector(bone.tail_local),
"matrix": [vector(row) for row in bone.matrix_local],
} for bone in sorted(obj.data.bones, key=lambda bone: bone.name)],
"constraints": [{
"bone": bone.name,
"items": [{"name": constraint.name, "type": constraint.type,
"target": constraint.target.name if getattr(constraint, "target", None) else None,
"subtarget": getattr(constraint, "subtarget", ""),
"influence": round(float(constraint.influence), 6),
"enabled": not constraint.mute}
for constraint in bone.constraints],
} for bone in sorted(obj.pose.bones, key=lambda bone: bone.name)],
}
pathlib.Path(request["output"]).write_text(json.dumps(states, indent=2) + "\n")

View File

@@ -0,0 +1,21 @@
import { expect, type APIRequestContext } from '@playwright/test';
import type { NativeMainReceipt } from '../../protocol/native-workflow';
export async function readBackendMain(api: APIRequestContext, origin: string, handle: { id: string; token: string }): Promise<NativeMainReceipt & { workspace: string; filepath: string; filepathExists: boolean; images: { name: string; filepath: string; absolute: string; exists: boolean; packed: boolean }[] }> {
const endpoint = `${origin}/api/blender-session`, headers = { 'X-Blender-Workflow': '1', 'X-Blender-Session-Token': handle.token, Origin: origin };
const read = () => api.get(`${endpoint}?id=${encodeURIComponent(handle.id)}`, { headers: { ...headers, 'X-Blender-Session-Action': 'read' } });
const post = (action: string, body: object) => api.post(endpoint, { headers: { ...headers, 'X-Blender-Session-Action': action }, data: { ...handle, ...body } });
const initial = await read(); expect(initial.ok()).toBe(true); const prior = await initial.json();
if (prior.command) { expect(prior.command.status).not.toBe('RUNNING'); expect((await post('ack', { sequence: prior.command.sequence })).ok()).toBe(true); }
const submitted = await post('command', { command: { kind: 'read-main' } });
expect(submitted.ok()).toBe(true); const issued = await submitted.json(); expect(Number.isSafeInteger(issued.sequence)).toBe(true); let command: any;
try {
await expect.poll(async () => { const response = await read(); expect(response.ok()).toBe(true); command = (await response.json()).command; return command?.sequence === issued.sequence ? command.status : null; }, { timeout: 120000 }).toBe('SUCCEEDED');
expect(command.artifacts ?? []).toEqual([]); expect(command.resources).toBeUndefined();
const receipt = command.mainReceipt; expect(receipt?.command).toBe('read-main'); expect(receipt.sequence).toBe(issued.sequence); expect(Array.isArray(receipt.objects)).toBe(true);
const observed = JSON.parse(command.output.trim().split('\n').at(-1)); expect(observed.version).toEqual([5, 2, 0]); expect(observed.objects).toBe(receipt.objects.length);
expect((await post('ack', { sequence: issued.sequence })).ok()).toBe(true);
return { ...receipt, workspace: observed.workspace, filepath: observed.filepath, filepathExists: observed.filepathExists, images: observed.images };
} finally {
if (command?.resources) { const released = await api.post(`${origin}/api/blender-resources`, { headers: { 'X-Blender-Workflow': '1', 'X-Blender-Resource-Action': 'release', Origin: origin }, data: command.resources }); expect(released.ok()).toBe(true); }
}
}

View File

@@ -0,0 +1,41 @@
import { expect, type Page } from '@playwright/test';
import type { NativeMainReceipt } from '../../protocol/native-workflow';
export function finiteLocation(value: unknown): asserts value is [number, number, number] {
expect(Array.isArray(value)).toBe(true); expect(value).toHaveLength(3);
for (const coordinate of value as unknown[]) { expect(typeof coordinate).toBe('number'); expect(Number.isFinite(coordinate)).toBe(true); }
}
export async function latestMainReceipt(page: Page): Promise<NativeMainReceipt | null> {
const output = await page.locator('.native-workflow-output').textContent(); expect(typeof output).toBe('string');
const line = output!.split('\n').filter(value => value.startsWith('[MainReceipt] ')).at(-1);
if (!line) return null;
const value = JSON.parse(line.slice('[MainReceipt] '.length)) as NativeMainReceipt;
expect(value !== null && typeof value === 'object' && !Array.isArray(value)).toBe(true);
expect(typeof value.command).toBe('string'); expect(Number.isSafeInteger(value.sequence)).toBe(true); expect(value.sequence).toBeGreaterThanOrEqual(0);
expect(value.activeObject === null || value.activeObject === undefined || typeof value.activeObject === 'string').toBe(true);
expect(Array.isArray(value.objects)).toBe(true);
for (const object of value.objects) { expect(object !== null && typeof object === 'object').toBe(true); expect(typeof object.name).toBe('string'); finiteLocation(object.location); }
return value;
}
export async function receiptAfter(page: Page, action: () => Promise<unknown>, status = 'SUCCEEDED'): Promise<NativeMainReceipt> {
const previous = (await latestMainReceipt(page))?.sequence ?? -1;
await action();
await expect.poll(async () => (await latestMainReceipt(page))?.sequence ?? -1, { timeout: 30000 }).toBeGreaterThan(previous);
await expect(page.locator('.native-workflow-controls output')).toHaveText('READY');
await expect(page.locator('.native-session-panel')).toHaveAttribute('data-command-status', status);
return (await latestMainReceipt(page))!;
}
type WebMain = { requestId: string; revision: number; nodes: { name: string; type: string; transform: { translation: [number, number, number] } }[] };
export async function webMain(page: Page): Promise<WebMain> {
const value = await page.evaluate(() => (window as any).__nativeWorkflowMain) as WebMain;
expect(value !== null && typeof value === 'object').toBe(true); expect(typeof value.requestId).toBe('string');
expect(Number.isSafeInteger(value.revision)).toBe(true); expect(Array.isArray(value.nodes)).toBe(true);
for (const node of value.nodes) { expect(typeof node.name).toBe('string'); expect(typeof node.type).toBe('string'); finiteLocation(node.transform?.translation); }
return value;
}
export async function expectWebLocation(page: Page, name: string, expected: [number, number, number]) {
finiteLocation(expected);
for (let axis = 0; axis < 3; axis++) await expect.poll(async () => {
const node = (await webMain(page)).nodes.find(item => item.name === name); expect(node).toBeDefined();
return node!.transform.translation[axis];
}, { timeout: 30000 }).toBeCloseTo(expected[axis], 5);
}

View File

@@ -0,0 +1,25 @@
import bpy
import pathlib
root = pathlib.Path(__file__).resolve().parents[3]
bpy.ops.wm.open_mainfile(filepath=str(root / "tests/files/web/basic_scene.blend"))
mesh = bpy.data.objects["BasicCube"].data
# Reverse one face of the closed cube so normal repair has observable work.
vertices = [tuple(vertex.co) for vertex in mesh.vertices]
faces = [tuple(polygon.vertices) for polygon in mesh.polygons]
faces[0] = tuple(reversed(faces[0]))
mesh.clear_geometry()
mesh.from_pydata(vertices, [], faces)
mesh.update()
uv = mesh.uv_layers.active or mesh.uv_layers.new(name="UVMap")
for corner in uv.data:
corner.uv = (0.125, 0.875)
bridge = bpy.data.meshes.new("WebBridgeMesh")
bridge.from_pydata([(-1, -1, 0), (1, -1, 0), (1, 1, 0), (-1, 1, 0),
(-1, -1, 2), (1, -1, 2), (1, 1, 2), (-1, 1, 2)], [],
[(3, 2, 1, 0), (4, 5, 6, 7)])
bridge.update()
obj = bpy.data.objects.new("WebBridgeObject", bridge)
bpy.context.scene.collection.objects.link(obj)
obj.location.x = 4
bpy.ops.wm.save_as_mainfile(filepath=str(root / "tests/files/web/mesh_editor_scene.blend"), check_existing=False)

View File

@@ -0,0 +1,17 @@
import bpy
import pathlib
root = pathlib.Path(__file__).resolve().parents[3]
bpy.ops.wm.open_mainfile(filepath=str(root / "tests/files/web/basic_scene.blend"))
obj = bpy.data.objects["BasicCube"]
array = obj.modifiers.new("Stack Array", "ARRAY")
array.count = 2
bevel = obj.modifiers.new("Stack Bevel", "BEVEL")
bevel.width = 0.1
bevel.segments = 2
other = obj.copy()
other.data = obj.data.copy()
other.name = "StackTarget"
bpy.context.scene.collection.objects.link(other)
other.location.x = 4
bpy.ops.wm.save_as_mainfile(filepath=str(root / "tests/files/web/modifier_stack_scene.blend"), check_existing=False)

View File

@@ -0,0 +1,36 @@
import pathlib
import sys
import bpy
root = pathlib.Path(__file__).resolve().parents[3]
hierarchy = "--hierarchy" in sys.argv
target = root / "tests/files/web" / (
"object_editor_visibility_scene.blend" if hierarchy else "object_editor_scene.blend")
if target.exists():
raise RuntimeError("Fixture already exists; retain the existing hash-bound input")
bpy.ops.wm.open_mainfile(filepath=str(root / "tests/files/web/basic_scene.blend"))
cube = bpy.data.objects["BasicCube"]
cube.location = (3.0, -2.0, 5.0)
cube.rotation_mode = "XYZ"
cube.rotation_euler = (0.2, 0.4, 0.6)
cube.scale = (1.5, 2.0, 0.5)
if hierarchy:
collection = bpy.data.collections["Collection"]
nested = bpy.data.collections.new("ObjectEditor Nested")
collection.children.link(nested)
camera = bpy.data.objects["BasicCamera"]
collection.objects.unlink(camera)
nested.objects.link(camera)
alternative = bpy.data.collections.new("ObjectEditor Alternative")
bpy.context.scene.collection.children.link(alternative)
alternative.objects.link(bpy.data.objects["BasicArea"])
hidden = bpy.data.objects.new("ObjectEditor Hidden", None)
hidden.hide_viewport = True
alternative.objects.link(hidden)
bpy.context.view_layer.update()
for obj in bpy.context.view_layer.objects:
obj.select_set(obj == cube)
bpy.context.view_layer.objects.active = cube
bpy.ops.wm.save_as_mainfile(filepath=str(target))

View File

@@ -0,0 +1,18 @@
import pathlib
import bpy
root = pathlib.Path(__file__).resolve().parents[3]
target = root / "tests/files/web/sequencer_scene_source.blend"
if target.exists():
raise RuntimeError("Retain the existing fixture; rebuild only when its recipe changes")
bpy.ops.wm.open_mainfile(filepath=str(root / "tests/files/web/sequencer_scene.blend"))
source = bpy.data.scenes.new("SourceScene")
source.frame_start = 1
source.frame_end = 48
camera = bpy.data.objects.new("SourceCamera", bpy.data.cameras.new("SourceCamera"))
source.collection.objects.link(camera)
source.camera = camera
source.world = bpy.data.worlds.new("SourceWorld")
source.world.color = (0.15, 0.35, 0.55)
bpy.context.window.scene = bpy.data.scenes["SequencerScene"]
bpy.ops.wm.save_as_mainfile(filepath=str(target))

View File

@@ -0,0 +1,27 @@
"""Read font layout and packed font links from saved native Main artifacts."""
import hashlib
import json
from pathlib import Path
import sys
import bpy
directory = Path(sys.argv[sys.argv.index("--") + 1])
result = {}
for blend in sorted(directory.glob("*.blend")):
bpy.ops.wm.open_mainfile(filepath=str(blend), load_ui=False)
texts = []
for obj in sorted(bpy.data.objects, key=lambda value: value.name):
if obj.type != "FONT":
continue
curve = obj.data
texts.append({
"object": obj.name, "data": curve.name, "body": curve.body,
"boxes": [{"x": box.x, "y": box.y, "width": box.width, "height": box.height} for box in curve.text_boxes],
"characters": [{"kern": char.kerning, "material": char.material_index, "bold": char.use_bold, "italic": char.use_italic, "underline": char.use_underline} for char in curve.body_format],
"links": {name: getattr(curve, name).name if getattr(curve, name) else None for name in ("font", "font_bold", "font_italic", "font_bold_italic")},
})
fonts = [{"name": font.name, "packedSha256": hashlib.sha256(bytes(font.packed_file.data)).hexdigest() if font.packed_file else None} for font in sorted(bpy.data.fonts, key=lambda value: value.name)]
result[blend.stem] = {"texts": texts, "fonts": fonts}
(directory / "native-font-snapshots.json").write_text(json.dumps(result, sort_keys=True, separators=(",", ":")), encoding="utf-8")

View File

@@ -0,0 +1,62 @@
import json
from pathlib import Path
import sys
import bpy
def attribute_data(attribute):
values = []
for item in attribute.data:
if hasattr(item, "vector"):
values.append(list(item.vector))
elif hasattr(item, "color"):
values.append(list(item.color))
elif hasattr(item, "value"):
values.append(item.value)
return {"name": attribute.name, "domain": attribute.domain,
"dataType": attribute.data_type, "values": values}
def drawing_data(drawing):
return {
"attributes": [attribute_data(attribute) for attribute in drawing.attributes],
"strokes": [{
"cyclic": stroke.cyclic,
"materialIndex": stroke.material_index,
"points": [{
"position": list(point.position),
"radius": point.radius,
"opacity": point.opacity,
"vertexColor": list(point.vertex_color),
"select": point.select,
} for point in stroke.points],
} for stroke in drawing.strokes],
}
def snapshot():
return {
"objects": [{"name": obj.name, "data": obj.data.name,
"materials": [material.name if material else None
for material in obj.data.materials]}
for obj in sorted(bpy.data.objects, key=lambda item: item.name)
if obj.type == "GREASEPENCIL"],
"greasePencils": [{
"name": grease_pencil.name,
"layers": [{
"name": layer.name,
"frames": [{"frame": frame.frame_number,
"drawing": drawing_data(frame.drawing)}
for frame in layer.frames],
} for layer in grease_pencil.layers],
} for grease_pencil in sorted(bpy.data.grease_pencils_v3, key=lambda item: item.name)],
}
request = json.loads(Path(sys.argv[sys.argv.index("--") + 1]).read_text())
result = {}
for label, filename in request["files"].items():
bpy.ops.wm.open_mainfile(filepath=str(Path(filename).resolve()))
result[label] = snapshot()
Path(request["output"]).write_text(json.dumps(result, sort_keys=True, indent=2) + "\n")

View File

@@ -0,0 +1,45 @@
"""Read browser-produced blend files and independently import OBJ using Blender."""
import json
import pathlib
import sys
import bpy
def vector(values):
return [round(float(value), 5) for value in values]
def summary():
bpy.context.view_layer.update()
return sorted([{
"name": obj.name, "type": obj.type,
"location": vector(obj.location), "rotation": vector(obj.rotation_euler),
"scale": vector(obj.scale),
"vertices": [vector(vertex.co) for vertex in obj.data.vertices] if obj.type == "MESH" else [],
"faces": [list(face.vertices) for face in obj.data.polygons] if obj.type == "MESH" else [],
} for obj in bpy.data.objects], key=lambda item: item["name"])
def geometry():
bpy.context.view_layer.update()
return sorted([{
"vertices": sorted(vector(obj.matrix_world @ vertex.co) for vertex in obj.data.vertices),
"faces": len(obj.data.polygons),
} for obj in bpy.data.objects if obj.type == "MESH"], key=lambda item: json.dumps(item, sort_keys=True))
directory, fixture, source = sys.argv[sys.argv.index("--") + 1:]
directory = pathlib.Path(directory)
states = {}
for blend in sorted(directory.glob("*.blend")):
bpy.ops.wm.open_mainfile(filepath=str(blend))
states[blend.stem] = summary()
if blend.stem == "after":
states["afterGeometry"] = geometry()
bpy.ops.wm.open_mainfile(filepath=fixture)
assert bpy.ops.wm.obj_import(filepath=source) == {"FINISHED"}
states["desktopExpected"] = summary()
bpy.ops.wm.read_factory_settings(use_empty=True)
assert bpy.ops.wm.obj_import(filepath=str(directory / "exported.obj")) == {"FINISHED"}
states["exportedGeometry"] = geometry()
(directory / "native.json").write_text(json.dumps(states, sort_keys=True, indent=2) + "\n")

View File

@@ -0,0 +1,23 @@
import bpy
import json
import pathlib
import sys
request = json.loads(pathlib.Path(sys.argv[sys.argv.index("--") + 1]).read_text())
def vector(values):
return [round(float(value), 6) for value in values]
states = {}
for label, file in request["files"].items():
bpy.ops.wm.open_mainfile(filepath=str(pathlib.Path(file).resolve()))
states[label] = {obj.name: {
"vertices": [vector(vertex.co) for vertex in obj.data.vertices],
"edges": [{"vertices": list(edge.vertices), "seam": edge.use_seam} for edge in obj.data.edges],
"faces": [{"vertices": list(face.vertices), "normal": vector(face.normal), "center": vector(face.center),
"loops": list(face.loop_indices)} for face in obj.data.polygons],
"uvLayers": [{"name": layer.name, "uv": [vector(item.uv) for item in layer.data]} for layer in obj.data.uv_layers],
} for obj in sorted(bpy.data.objects, key=lambda obj: obj.name) if obj.type == "MESH"}
pathlib.Path(request["output"]).write_text(json.dumps(states, indent=2) + "\n")

View File

@@ -0,0 +1,22 @@
import bpy
import json
import pathlib
import sys
request = json.loads(pathlib.Path(sys.argv[sys.argv.index("--") + 1]).read_text())
states = {}
for label, file in request["files"].items():
bpy.ops.wm.open_mainfile(filepath=str(pathlib.Path(file).resolve()))
states[label] = {obj.name: [{
"name": modifier.name, "type": modifier.type,
"viewport": modifier.show_viewport, "render": modifier.show_render,
"values": {key: round(float(getattr(modifier, key)), 6) if isinstance(getattr(modifier, key), float)
else list(getattr(modifier, key)) if not isinstance(getattr(modifier, key), (str, int, bool))
else getattr(modifier, key)
for key in ({"ARRAY": ["count", "use_relative_offset", "relative_offset_displace"],
"BEVEL": ["width", "segments", "profile", "limit_method", "affect"],
"SOLIDIFY": ["thickness", "offset"],
"MIRROR": ["use_axis", "use_clip"],
"SUBSURF": ["levels", "render_levels"]}.get(modifier.type, []))},
} for modifier in obj.modifiers] for obj in sorted(bpy.data.objects, key=lambda item: item.name)}
pathlib.Path(request["output"]).write_text(json.dumps(states, indent=2) + "\n")

View File

@@ -0,0 +1,52 @@
"""Source-derived WM recovery case; operate only on a separate working copy."""
import hashlib, json, pathlib, sys
import bpy
def run(input_path, work_dir):
input_path = str(pathlib.Path(input_path).resolve()); assert bpy.app.version == (5, 2, 0)
work = pathlib.Path(work_dir).resolve()
work.mkdir(parents=True, exist_ok=False)
source_sha256 = hashlib.sha256(pathlib.Path(input_path).read_bytes()).hexdigest()
assert bpy.ops.wm.open_mainfile(filepath=str(pathlib.Path(input_path).resolve()), load_ui=False, use_scripts=False) == {'FINISHED'}
marker = work / 'registered-script.json'
key = 'wbv2_native_recovery_script_executed'
assert key not in bpy.context.scene
text = bpy.data.texts.new('wbv2_native_recovery_trust.py')
text.write("import bpy,json\nfrom pathlib import Path\nbpy.context.scene[%r] = True\nPath(%r).write_text(json.dumps({'executed':True,'filepath':bpy.data.filepath}))\n" % (key, str(marker)))
text.use_module = True
original_images = {image.name: {'pixels': list(image.pixels[:4]), 'size': list(image.size)} for image in bpy.data.images if image.source == 'FILE'}
assert original_images and all(image['pixels'] and min(image['size']) > 0 for image in original_images.values())
autosave_dir = work / 'autosave'; autosave_dir.mkdir()
previous = (bpy.context.preferences.filepaths.temporary_directory, bpy.context.preferences.filepaths.auto_save_time)
try:
bpy.context.preferences.filepaths.temporary_directory = str(autosave_dir)
bpy.context.preferences.filepaths.auto_save_time = 60
assert bpy.ops.wm.save_as_mainfile(filepath=str(work / 'native-script-trust.blend'), relative_remap=True, check_existing=False) == {'FINISHED'}
assert bpy.ops.wm.save_auto_save() == {'FINISHED'}
assert not marker.exists() and key not in bpy.context.scene
autosaves = sorted(autosave_dir.glob('native-script-trust_*_autosave.blend'))
assert len(autosaves) == 1, [str(item) for item in autosaves]
autosave_sha256 = hashlib.sha256(autosaves[0].read_bytes()).hexdigest()
results = []
for allow in (False, True, False):
marker.unlink(missing_ok=True)
assert bpy.ops.wm.recover_auto_save(filepath=str(autosaves[0]), use_scripts=allow) == {'FINISHED'}
executed = marker.exists()
assert executed == allow and bool(bpy.context.scene.get(key, False)) == allow
assert pathlib.Path(bpy.data.filepath).resolve() == work / 'native-script-trust.blend'
images = {image.name: {'pixels': list(image.pixels[:4]), 'size': list(image.size)} for image in bpy.data.images if image.source == 'FILE'}
assert images == original_images
assert hashlib.sha256(autosaves[0].read_bytes()).hexdigest() == autosave_sha256
results.append({'useScripts': allow, 'executed': executed, 'sceneMarker': bool(bpy.context.scene.get(key, False)), 'scriptMarker': json.loads(marker.read_text()) if executed else None})
assert hashlib.sha256(pathlib.Path(input_path).read_bytes()).hexdigest() == source_sha256
return {'kind': 'source-derived-native-operator-case', 'platform': sys.platform, 'version': list(bpy.app.version), 'inputSha256': source_sha256, 'autosavePath': str(autosaves[0]), 'autosaveSha256': autosave_sha256, 'workingBlend': bpy.data.filepath, 'results': results, 'images': original_images}
finally:
bpy.context.preferences.filepaths.temporary_directory, bpy.context.preferences.filepaths.auto_save_time = previous
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('--input', required=True)
parser.add_argument('--work-dir', required=True)
args = parser.parse_args(sys.argv[sys.argv.index('--') + 1:])
print('WBV2_NATIVE_AUTOSAVE_SCRIPT_TRUST', json.dumps(run(args.input, args.work_dir), sort_keys=True))

View File

@@ -0,0 +1,12 @@
import type { Worker } from '@playwright/test';
export async function installNativeComputeCapture(worker: Worker) {
await worker.evaluate(() => {
const root = self as any, calls: any[] = []; root.__wbv2NativeComputeCapture = calls;
for(const transport of ['Worker','MessagePort']){const proto = (globalThis as any)[transport].prototype, original = proto.postMessage;
proto.postMessage = function(this: any, envelope: any, ...rest: any[]) { const message = envelope?.request ?? envelope; if(message?.job && message.control instanceof SharedArrayBuffer && message.data instanceof SharedArrayBuffer){calls.push({ transport, job: structuredClone(message.job), sizes: [...message.sizes], data: message.data.byteLength <= 2097152 ? Array.from(new Uint8Array(message.data)) : null, control: message.control, error: message.error });}else if(Array.isArray(message?.releaseOwners))calls.push({ transport, releaseOwners: [...message.releaseOwners] }); if(calls.length > 64){calls.shift(); root.__wbv2CaptureTruncated = true;} return Reflect.apply(original, this, [envelope, ...rest]); };
}
});
}
export async function readNativeComputeCapture(worker: Worker) {
return worker.evaluate(() => { if((self as any).__wbv2CaptureTruncated)throw new Error('Native compute capture budget exceeded'); return ((self as any).__wbv2NativeComputeCapture ?? []).map(({ control, error, ...snapshot }: any) => { if(!control)return snapshot; const flags = new Int32Array(control), bytes = new Uint8Array(error), length = Math.min(bytes.length, Math.max(0, Atomics.load(flags, 1))); return { ...snapshot, state: Atomics.load(flags, 0), diagnostic: new TextDecoder().decode(bytes.slice(0, length)) }; }); });
}

31
web/tests/fixtures/native-gl-trace.ts vendored Normal file
View File

@@ -0,0 +1,31 @@
import type { Worker } from '@playwright/test';
export async function installNativeGLTrace(worker: Worker, finishBeforeCompute = false) {
await worker.evaluate(finishBeforeCompute => {
const root = self as any, proto = WebGL2RenderingContext.prototype as any, events: any[] = [];
const ids = new WeakMap<object, number>(), current = new WeakMap<object, number>(), uniforms = new WeakMap<object, { name: string; program: number }>(); let next = 1, frozen = false;
const id = (value: object | null) => { if(!value)return 0; if(!ids.has(value))ids.set(value, next++); return ids.get(value)!; };
root.__nativeGLTrace = events;
if(finishBeforeCompute){const post = MessagePort.prototype.postMessage; let sequence = 0;
MessagePort.prototype.postMessage = function(this: MessagePort, envelope: any, ...rest: any[]) { if(envelope?.request?.job && root.__wbv2LastGL){const current = ++sequence; console.info(`WBV2_GL_FINISH_BEGIN ${current}`); root.__wbv2LastGL.finish(); console.info(`WBV2_GL_FINISH_DONE ${current}`);} return Reflect.apply(post, this, [envelope, ...rest]); };
}
const activeUniform = proto.getActiveUniform, uniformLocation = proto.getUniformLocation;
const drawState = (gl: any) => {
const program = gl.getParameter(gl.CURRENT_PROGRAM), result: { program: number; blocks: any[]; samplers: any[] } = { program: id(program), blocks: [], samplers: [] };
if(!program)return result;
for(let i = 0; i < Math.min(16, gl.getProgramParameter(program, gl.ACTIVE_UNIFORM_BLOCKS)); i++){const binding = gl.getActiveUniformBlockParameter(program, i, gl.UNIFORM_BLOCK_BINDING); result.blocks.push({ name: gl.getActiveUniformBlockName(program, i), binding, required: gl.getActiveUniformBlockParameter(program, i, gl.UNIFORM_BLOCK_DATA_SIZE), buffer: id(gl.getIndexedParameter(gl.UNIFORM_BUFFER_BINDING, binding)), size: gl.getIndexedParameter(gl.UNIFORM_BUFFER_SIZE, binding) });}
for(let i = 0; i < Math.min(64, gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS)); i++){const item = Reflect.apply(activeUniform, gl, [program, i]) as WebGLActiveInfo; if([gl.SAMPLER_2D,gl.SAMPLER_3D,gl.SAMPLER_CUBE,gl.SAMPLER_2D_ARRAY,gl.INT_SAMPLER_2D,gl.UNSIGNED_INT_SAMPLER_2D].includes(item.type)){const location = Reflect.apply(uniformLocation, gl, [program, item.name]); if(location){const value = gl.getUniform(program, location); result.samplers.push({ name: item.name, type: item.type, units: ArrayBuffer.isView(value) ? Array.from(value as any) : value });}}}
return result;
};
const methods = ['useProgram','getUniformLocation','getActiveUniform','uniform1i','uniform1iv','uniform2iv','uniform3iv','uniform4iv','uniform1ui','uniform1fv','uniform2fv','uniform3fv','uniform4fv','uniformMatrix3fv','uniformMatrix4fv','uniformBlockBinding','bindBufferBase','bindBufferRange','vertexAttribPointer','vertexAttribIPointer','drawArrays','drawElements','drawArraysInstanced','drawElementsInstanced','activeTexture','bindTexture','texStorage2D','texSubImage2D','readPixels','getError'];
for(const name of methods){const original = proto[name]; if(typeof original !== 'function')continue;
proto[name] = function(this: any, ...args: any[]) {
root.__wbv2LastGL = this;
if(!frozen && name.startsWith('draw'))root.__nativeGLDrawState = drawState(this);
const result = Reflect.apply(original, this, args);
if(name === 'getUniformLocation' && result)uniforms.set(result as object, { name: args[1], program: id(args[0]) }); if(!current.has(this) || name === 'useProgram')current.set(this, id(this.getParameter(this.CURRENT_PROGRAM)));
if(!frozen){const brief = args.slice(0, 5).map(value => value == null || ['number','string','boolean'].includes(typeof value) ? value : ArrayBuffer.isView(value) ? { bytes: value.byteLength } : uniforms.get(value) ?? { kind: value.constructor?.name, id: id(value) }); events.push({ name, args: brief, program: current.get(this), result: name === 'getError' ? result : undefined }); if(events.length > 32)events.shift(); if(name === 'getError' && result)frozen = true;}
return result;
};
}
}, finishBeforeCompute);
}

View File

@@ -0,0 +1,24 @@
"""Prepare a real quit.blend through the original GUI Blender exit lifecycle."""
import argparse, hashlib, json, pathlib, sys
import bpy
assert not bpy.app.background and bpy.app.version == (5, 2, 0)
parser = argparse.ArgumentParser()
parser.add_argument('--input', required=True); parser.add_argument('--work-dir', required=True)
args = parser.parse_args(sys.argv[sys.argv.index('--') + 1:])
work = pathlib.Path(args.work_dir).resolve(); work.mkdir(parents=True, exist_ok=False)
input_path = pathlib.Path(args.input).resolve()
input_sha256 = hashlib.sha256(input_path.read_bytes()).hexdigest()
assert bpy.ops.wm.open_mainfile(filepath=str(input_path), load_ui=False, use_scripts=False) == {'FINISHED'}
assert bpy.ops.file.pack_all() == {'FINISHED'}
temporary = work / 'native-temp'; temporary.mkdir()
bpy.context.preferences.filepaths.temporary_directory = str(temporary)
working_blend = work / 'source-session.blend'
assert bpy.ops.wm.save_as_mainfile(filepath=str(working_blend), relative_remap=True, check_existing=False) == {'FINISHED'}
assert 'wbv2_last_session_unsaved' not in bpy.context.scene
bpy.context.scene['wbv2_last_session_unsaved'] = input_sha256
snapshot = {'filepath': bpy.data.filepath, 'scene': bpy.context.scene.name, 'marker': bpy.context.scene['wbv2_last_session_unsaved'], 'objects': sorted(obj.name for obj in bpy.data.objects), 'images': {image.name: {'pixels': list(image.pixels[:4]), 'size': list(image.size), 'packed': bool(image.packed_file)} for image in bpy.data.images if image.source == 'FILE'}}
assert snapshot['images'] and all(image['packed'] and image['pixels'] and min(image['size']) > 0 for image in snapshot['images'].values())
assert hashlib.sha256(input_path.read_bytes()).hexdigest() == input_sha256
(work / 'native-last-session-expected.json').write_text(json.dumps({'schemaVersion': 1, 'kind': 'source-derived-original-native-exit', 'phase': 'EXPECTED_STATE_WRITTEN_QUIT_PENDING', 'inputSha256': input_sha256, 'producerSha256': hashlib.sha256(pathlib.Path(__file__).read_bytes()).hexdigest(), 'version': list(bpy.app.version), 'platform': sys.platform, 'quitFile': str(temporary / 'quit.blend'), 'expectedMain': snapshot}, indent=2))
print('WBV2_NATIVE_QUIT_EXPECTED', str(work / 'native-last-session-expected.json'), flush=True)
assert bpy.ops.wm.quit_blender('EXEC_DEFAULT') == {'FINISHED'}

View File

@@ -0,0 +1,24 @@
"""Read back the actual GUI-produced quit.blend using original native recovery."""
import argparse, hashlib, json, pathlib, sys
import bpy
assert bpy.app.version == (5, 2, 0) and sys.platform != "emscripten"
parser = argparse.ArgumentParser()
parser.add_argument("--work-dir", required=True)
args = parser.parse_args(sys.argv[sys.argv.index("--") + 1:])
work = pathlib.Path(args.work_dir).resolve(); expected_path = work / "native-last-session-expected.json"; expected = json.loads(expected_path.read_text())
assert bpy.app.background, "Readback must not generate another GUI-exit quit file"
assert expected["schemaVersion"] == 1 and expected["kind"] == "source-derived-original-native-exit" and expected["phase"] == "EXPECTED_STATE_WRITTEN_QUIT_PENDING"
producer = pathlib.Path(__file__).with_name("native-last-session-prepare.py")
assert expected["producerSha256"] == hashlib.sha256(producer.read_bytes()).hexdigest()
quit_file = pathlib.Path(expected["quitFile"]).resolve(); assert quit_file == work / "native-temp" / "quit.blend"
quit_bytes = quit_file.read_bytes(); assert quit_bytes; quit_sha256 = hashlib.sha256(quit_bytes).hexdigest()
assert expected["expectedMain"]["filepath"] == str(work / "source-session.blend")
bpy.context.preferences.filepaths.temporary_directory = str(quit_file.parent)
assert pathlib.Path(bpy.app.tempdir).parent == quit_file.parent
assert bpy.ops.wm.recover_last_session("EXEC_DEFAULT", use_scripts=False) == {"FINISHED"}
actual = {"filepath": bpy.data.filepath, "scene": bpy.context.scene.name, "marker": bpy.context.scene.get("wbv2_last_session_unsaved"), "objects": sorted(obj.name for obj in bpy.data.objects), "images": {image.name: {"pixels": list(image.pixels[:4]), "size": list(image.size), "packed": bool(image.packed_file)} for image in bpy.data.images if image.source == "FILE"}}
assert actual == expected["expectedMain"], "Actual native quit recovery differs from the producer Main"
assert hashlib.sha256(quit_file.read_bytes()).hexdigest() == quit_sha256, "Readback changed the original quit artifact"
receipt = {"schemaVersion": 1, "kind": "desktop-native-quit-readback", "phase": "NATIVE_QUIT_READBACK_CAPTURED", "acceptance": "NOT_CLAIMED", "inputSha256": expected["inputSha256"], "producerSha256": expected["producerSha256"], "readerSha256": hashlib.sha256(pathlib.Path(__file__).read_bytes()).hexdigest(), "expectedReceiptSha256": hashlib.sha256(expected_path.read_bytes()).hexdigest(), "quitFile": {"path": str(quit_file), "bytes": len(quit_bytes), "sha256": quit_sha256}, "actualMain": actual, "version": list(bpy.app.version), "platform": sys.platform, "nativeBinary": str(pathlib.Path(bpy.app.binary_path).resolve())}
with (work / "native-last-session-readback.json").open("x") as stream: json.dump(receipt, stream, indent=2)
print("WBV2_NATIVE_QUIT_READBACK", str(work / "native-last-session-readback.json"), flush=True)

View File

@@ -0,0 +1,116 @@
import { expect, type Page } from '@playwright/test';
import { Buffer } from 'node:buffer';
import { pythonReply } from './wasm-console-workflow';
import { finiteLocation } from './console-main-workflow';
import { performNativeAction } from './native-ui-workflow';
import type { WebEngineHistory, WebEngineRequest } from '../../protocol/web-engine';
export async function nativeRequest(page: Page, command: WebEngineRequest['command']) {
const response = await page.evaluate(command => new Promise<any>((resolve, reject) => {
const worker = (window as any).__nativeWorkflowEngine as Worker, requestId = `web-engine-oracle-${crypto.randomUUID()}`;
if (!worker) throw new Error('Native Worker is unavailable');
const listener = ({ data }: MessageEvent) => {
if (data?.requestId !== requestId || typeof data.ok !== 'boolean') return; clearTimeout(timer); worker.removeEventListener('message', listener); const blend = data.result?.blend;
if (blend !== undefined && (!(blend instanceof ArrayBuffer) || blend.byteLength > 64 * 1024 * 1024)) { reject(new Error('Native blend readback must be an ArrayBuffer within 64 MiB')); return; }
const file = data.result?.desktopFiles?.buffer;
if (file !== undefined && (!(file instanceof ArrayBuffer) || file.byteLength > 64 * 1024 * 1024)) { reject(new Error('Native file readback must be an ArrayBuffer within 64 MiB')); return; }
resolve({ ...data, blendBytes: blend === undefined ? undefined : Array.from(new Uint8Array(blend)), fileBytes: file === undefined ? undefined : Array.from(new Uint8Array(file)) });
};
const timer = setTimeout(() => { worker.removeEventListener('message', listener); reject(new Error('Native readback timed out')); }, 60000); worker.addEventListener('message', listener); worker.postMessage({ requestId, command });
}), command);
expect(response.ok, response.error?.message).toBe(true); return response;
}
export async function queryNativeJSON(page: Page, source: string) {
const response = await nativeRequest(page, { type: 'jsonCommand', cmd: 'pythonExec', params: { source: `exec(${JSON.stringify(source)}, {'__name__': '__wbv2_readback__'})`, filename: 'B2MainReadback.py', console: false } });
const reply = await pythonReply(page); expect(reply).not.toBeNull(); expect(reply!.requestId).toBe(response.requestId);
expect(reply!.state, reply!.output).toBe('DONE'); expect(reply!.changed).toBe(false); return { revision: reply!.revision, value: JSON.parse(reply!.output) };
}
export async function readNativeHistory(page: Page): Promise<WebEngineHistory> {
const response = await nativeRequest(page, { type: 'jsonCommand', cmd: 'history', params: {} }), value = response.result?.json;
expect(value !== null && typeof value === 'object' && !Array.isArray(value)).toBe(true); expect(value.schemaVersion).toBe(1);
for (const key of ['revision', 'cursor', 'limit']) { expect(Number.isSafeInteger(value[key])).toBe(true); expect(value[key]).toBeGreaterThanOrEqual(0); }
expect(Array.isArray(value.entries)).toBe(true); for (const entry of value.entries) { expect(Number.isSafeInteger(entry.index)).toBe(true); expect(typeof entry.label).toBe('string'); }
return value;
}
export async function readNativeBlend(page: Page): Promise<Buffer> {
const response = await nativeRequest(page, { type: 'saveBlend' }), bytes = response.blendBytes;
expect(Array.isArray(bytes)).toBe(true); expect(bytes.length).toBeGreaterThan(0); expect(bytes.every((value: unknown) => Number.isInteger(value) && (value as number) >= 0 && (value as number) <= 255)).toBe(true);
return Buffer.from(bytes);
}
export async function readModelingMain(page: Page, name: string) {
const source = [
`import bpy, bmesh, json\no = bpy.data.objects[${JSON.stringify(name)}]`,
"v = bmesh.from_edit_mesh(o.data).verts if o.mode == 'EDIT' else o.data.vertices",
"tool = bpy.context.workspace.tools.from_space_view3d_mode(bpy.context.mode, create=False)",
"print(json.dumps({'name': o.name, 'mode': o.mode, 'rotationMode': o.rotation_mode, 'toolId': tool.idname if tool else None, 'orientation': bpy.context.scene.transform_orientation_slots[0].type,",
"'location': list(o.location), 'rotation': list(o.rotation_euler), 'scale': list(o.scale), 'selected': sorted(x.name for x in bpy.context.scene.objects if x.select_get()), 'objects': sorted(x.name for x in bpy.context.scene.objects),",
"'vertices': [{'index': x.index, 'co': list(x.co), 'world': list(o.matrix_world @ x.co), 'selected': bool(x.select)} for x in v]}))",
].join('\n');
const result = await queryNativeJSON(page, source), value = result.value;
expect(value !== null && typeof value === 'object' && !Array.isArray(value)).toBe(true); expect(value.name).toBe(name); expect(typeof value.mode).toBe('string'); for (const key of ['location', 'rotation', 'scale']) finiteLocation(value[key]);
expect(typeof value.orientation).toBe('string'); expect(typeof value.rotationMode).toBe('string'); expect(value.toolId === null || typeof value.toolId === 'string').toBe(true);
for (const key of ['selected', 'objects']) { expect(Array.isArray(value[key])).toBe(true); expect(value[key].every((item: unknown) => typeof item === 'string')).toBe(true); }
expect(Array.isArray(value.vertices)).toBe(true); for (const vertex of value.vertices) { finiteLocation(vertex.co); finiteLocation(vertex.world); expect(typeof vertex.selected).toBe('boolean'); expect(Number.isSafeInteger(vertex.index)).toBe(true); expect(vertex.index).toBeGreaterThanOrEqual(0); }
expect(new Set(value.vertices.map((vertex: any) => vertex.index)).size).toBe(value.vertices.length); return result;
}
export function expectVector(actual: unknown, expected: unknown) {
finiteLocation(actual); finiteLocation(expected); for (let index = 0; index < 3; index++) expect(actual[index]).toBeCloseTo(expected[index], 5);
}
export function expectModelingData(actual: any, expected: any) {
for (const key of ['name', 'mode', 'rotationMode', 'toolId', 'orientation']) expect(actual[key]).toBe(expected[key]);
for (const key of ['selected', 'objects']) expect(actual[key]).toEqual(expected[key]);
for (const key of ['location', 'rotation', 'scale']) expectVector(actual[key], expected[key]);
expect(actual.vertices).toHaveLength(expected.vertices.length);
for (const vertex of expected.vertices) { const found = actual.vertices.find((item: any) => item.index === vertex.index); expect(found).toBeDefined(); expect(found.selected).toBe(vertex.selected); expectVector(found.co, vertex.co); expectVector(found.world, vertex.world); }
}
export async function transformNative(page: Page, key: 'g' | 'r' | 's', axis: string | null, input: string) {
await page.keyboard.press(key); const started = await waitNativeModal(page, true);
if (axis !== null) await page.keyboard.press(axis); await page.keyboard.type(input);
const confirmStartedAt = Date.now(), confirmed = await performNativeAction(page, { type: 'gpuUI', gpuOperation: 'frame', keyPressed: 13 }, () => page.keyboard.press('Enter'));
const acknowledgedAt = Date.now(), finished = await waitNativeModal(page, false, confirmed.requestId);
return { key, axis, input, started, confirmed, finished, receiptMs: acknowledgedAt - confirmStartedAt, confirmationMs: Date.now() - confirmStartedAt };
}
export async function nativeKeyFrame(page: Page, key: string, keyCode: number) {
expect(Number.isSafeInteger(keyCode)).toBe(true); const reply = await performNativeAction(page, { type: 'gpuUI', gpuOperation: 'frame', keyPressed: keyCode }, () => page.keyboard.press(key));
return waitNativeModal(page, false, reply.requestId);
}
export async function waitModelingMode(page: Page, name: string, mode: 'OBJECT' | 'EDIT') {
let state: Awaited<ReturnType<typeof readModelingMain>> | undefined;
await expect.poll(async () => { state = await readModelingMain(page, name); return { mode: state.value.mode, toolReady: typeof state.value.toolId === 'string' && state.value.toolId.length > 0 }; }, { timeout: 15000 }).toEqual({ mode, toolReady: true });
return state!;
}
export async function projectNearestVertex(page: Page, name: string) {
const source = [
`import bpy, bmesh, json\nfrom bpy_extras.view3d_utils import location_3d_to_region_2d\no = bpy.data.objects[${JSON.stringify(name)}]`,
"area = bpy.context.area\nassert area.type == 'VIEW_3D'\nregion = next(r for r in area.regions if r.type == 'WINDOW')\nview = area.spaces.active.region_3d\neye = view.view_matrix.inverted().translation\npoints = []",
"for vertex in bmesh.from_edit_mesh(o.data).verts:",
" world = o.matrix_world @ vertex.co\n point = location_3d_to_region_2d(region, view, world)",
" if point is not None and 0 < point.x < region.width and 0 < point.y < region.height:\n points.append({'index': vertex.index, 'x': region.x + point.x, 'y': region.y + point.y, 'distance': (world - eye).length_squared})",
"assert points, 'No visible projected vertex'\np = min(points, key=lambda p: p['distance'])\np['window'] = [bpy.context.window.width, bpy.context.window.height]\nprint(json.dumps(p))",
].join('\n');
const { value } = await queryNativeJSON(page, source); expect(value !== null && typeof value === 'object').toBe(true);
for (const field of ['x', 'y', 'distance']) { expect(typeof value[field]).toBe('number'); expect(Number.isFinite(value[field])).toBe(true); } expect(Number.isSafeInteger(value.index)).toBe(true); expect(value.index).toBeGreaterThanOrEqual(0);
expect(Array.isArray(value.window)).toBe(true); expect(value.window).toHaveLength(2); for (const size of value.window) { expect(Number.isSafeInteger(size)).toBe(true); expect(size).toBeGreaterThan(0); }
const bounds = await page.locator('canvas[aria-label="Blender desktop"]').boundingBox(); expect(bounds).not.toBeNull();
return { index: value.index, native: value, point: { x: bounds!.x + value.x * bounds!.width / value.window[0], y: bounds!.y + (1 - value.y / value.window[1]) * bounds!.height } };
}
export async function focusNativeViewport(page: Page) {
const canvas = page.locator('canvas[aria-label="Blender desktop"]');
if (!await canvas.isVisible()) await page.getByRole('button', { name: '桌面', exact: true }).click();
await expect.poll(async () => { const retry = page.getByRole('button', { name: '重试原生桌面', exact: true }); if (await retry.isVisible()) throw new Error(`Native canvas prerequisite failed: ${await retry.locator('..').textContent()}`); return canvas.getAttribute('data-native-display'); }, { timeout: 90000 }).toBe('acknowledged');
const area = await page.evaluate(() => (window as any).__nativeWorkflowAreas?.find((area: any) => area.editor === 'VIEW_3D' && area.regions.some((region: any) => region.kind === 'MAIN' && region.visible))); expect(area).toBeDefined();
const { x, y, width, height } = area.rect; for (const value of [x, y, width, height]) { expect(typeof value).toBe('number'); expect(Number.isFinite(value)).toBe(true); } expect(width).toBeGreaterThan(0); expect(height).toBeGreaterThan(0);
const bounds = await canvas.boundingBox(); expect(bounds).not.toBeNull(); const point = { x: bounds!.x + (x + width / 2) * bounds!.width, y: bounds!.y + (1 - y - height / 2) * bounds!.height };
await expect(canvas).toHaveAttribute('data-native-input-ready', 'true', { timeout: 15000 });
await page.mouse.move(point.x, point.y); await canvas.focus();
await waitNativeModal(page, false); await expect(canvas).toBeFocused(); return { canvas, point };
}
export async function waitNativeModal(page: Page, active: boolean, after?: string) {
let observed: any;
await expect.poll(async () => {
observed = await page.evaluate(() => (window as any).__nativeWorkflowGPU);
if (observed?.attached === false) { const failure = await page.evaluate(() => (window as any).__nativeWorkflowGPUFailure); if (typeof failure?.message === 'string') throw new Error(`Native canvas detached: ${failure.message}`); }
if (typeof observed?.requestId !== 'string' || observed.requestId === after || typeof observed.modalActive !== 'boolean') return null;
for (const key of ['inputDiscarded', 'contextLost']) if (observed[key] !== undefined) { expect(typeof observed[key]).toBe('boolean'); if (observed[key]) return null; }
return { active: observed.modalActive, ready: observed.displayAcknowledged === true, error: typeof observed.error?.message === 'string' ? observed.error.message : null };
}, { timeout: 15000 }).toEqual({ active, ready: true, error: null }); return observed;
}

139
web/tests/fixtures/native-ui-workflow.ts vendored Normal file
View File

@@ -0,0 +1,139 @@
import { expect, type Locator, type Page } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
const browserDiagnostics = new WeakMap<Page, { type: string; text: string }[]>();
export async function exposeWebTools(page: Page) {
const button = page.getByTestId('native-web-tools-toggle');
/* Browser mode has no native-canvas overlay, so the toggle is absent and the
* web tools are already visible. */
if (!(await button.count())) return;
const events = await button.locator('..').evaluate(element => getComputedStyle(element).pointerEvents);
expect(typeof events).toBe('string'); expect(['auto', 'none']).toContain(events);
if (events === 'auto') await button.click();
}
export async function resolveProjectReplacement(page: Page, choice: 'cancel' | 'discard' | 'save') {
const dialog = page.getByRole('dialog', { name: '保存当前工程的修改?', exact: true });
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: { cancel: '取消', discard: '不保存,继续', save: '保存后继续' }[choice], exact: true }).click();
await expect(dialog).toBeHidden({ timeout: 30000 });
}
export async function observeNative(page: Page) {
const diagnostics: { type: string; text: string }[] = []; browserDiagnostics.set(page, diagnostics);
const record = (type: string, text: string) => { const bounded = text.slice(0, 4000); if (!diagnostics.some(item => item.text === bounded)) { diagnostics.push({ type, text: bounded }); if (diagnostics.length > 16) diagnostics.shift(); } };
page.on('console', message => { const text = message.text(); if (message.type() === 'error' || /ERROR|Traceback|Error:/.test(text)) record(message.type(), text); }); page.on('pageerror', error => record('pageerror', error.message));
await page.addInitScript(() => {
const responses: { requestId: string; ok: boolean; errorCode?: string }[] = [];
const requests: { requestId: string; method?: string; command?: string; [key: string]: unknown }[] = [];
(window as any).__nativeWorkflowResponses = responses;
(window as any).__nativeWorkflowRequests = requests;
const uiKeys = ['data-user-action-open-status', 'data-user-action-save-status', 'data-save-transaction-status', 'data-project-action-conflict', 'data-project-action-conflict-owner-id', 'data-project-action-conflict-request-id', 'data-dirty'];
const uiTransitions: unknown[] = []; (window as any).__nativeWorkflowUITransitions = uiTransitions; let previousUI = '';
const captureUI = () => { const root = document.querySelector('main.blender-app'); if (!root) return; const values = Object.fromEntries(uiKeys.map(key => [key, root.getAttribute(key)])), signature = JSON.stringify(values); if (signature !== previousUI) { previousUI = signature; uiTransitions.push({ at: performance.now(), values }); if (uiTransitions.length > 16) uiTransitions.shift(); } };
new MutationObserver(captureUI).observe(document, { subtree: true, childList: true, attributes: true, attributeFilter: uiKeys });
window.Worker = new Proxy(window.Worker, {
construct(target, args) {
const worker = Reflect.construct(target, args) as Worker;
const post = worker.postMessage;
worker.postMessage = (...values: any[]) => {
const message = values[0];
if (typeof message?.requestId === "string" && message.requestId.startsWith("web-engine-")) {
(window as any).__nativeWorkflowEngine = worker;
const command = message.command ?? message.params?.command ?? message.args?.command ?? message.payload?.command;
const operation = command?.command ?? command?.editCommand ?? command?.payload ?? command;
requests.push({ requestId: message.requestId, method: message.method, command: operation?.type, commandId: operation?.id ?? operation?.commandId, markers: operation?.markers });
Object.assign(requests[requests.length - 1], { type: message.command?.type ?? message.type, jsonCommand: message.command?.cmd ?? message.cmd });
Object.assign(requests[requests.length - 1], { gpuOperation: message.command?.type === 'gpuUI' ? message.command.op : undefined, nativeOperator: message.command?.type === 'gpuUI' && message.command.op === 'invoke' ? message.command.operator : undefined });
const inputs = message.command?.type === 'gpuUI' && Array.isArray(message.command.events) ? message.command.events : undefined;
Object.assign(requests[requests.length - 1], { mouseReleased: inputs?.some((event: any) => event.type === 3), keyPressed: inputs?.findLast((event: any) => event.type === 5)?.key, inputEvents: inputs?.slice(-32).map((event: any) => ({ type: event.type, key: event.key, modifiers: event.modifiers, x: event.x, y: event.y, value: event.value })) });
Object.assign(requests[requests.length - 1], { editAction: operation?.edit?.action, editOperation: operation?.edit?.operation, context: operation?.edit?.context, workspaceId: operation?.edit?.workspaceId, areaId: operation?.edit?.areaId });
}
Reflect.apply(post, worker, values);
};
worker.addEventListener("message", ({ data }) => {
if (typeof data?.requestId === "string" && data.requestId.startsWith("web-engine-") && typeof data.ok === "boolean") {
const snapshot = data.result?.snapshot;
if (data.result?.gpuUI) (window as any).__nativeWorkflowGPU = structuredClone({ requestId: data.requestId, ...data.result.gpuUI });
const gpuError = data.result?.gpuUI?.error;
if (gpuError) (window as any).__nativeWorkflowGPUFailure = structuredClone({ requestId: data.requestId, ...gpuError, detail: typeof gpuError.detail === 'string' ? gpuError.detail.slice(0, 8192) : undefined });
const activeWorkspace = snapshot?.editorWorkflow?.workspaces?.find((item: any) => item.id === snapshot.editorWorkflow.context.workspaceId);
if (activeWorkspace) (window as any).__nativeWorkflowAreas = structuredClone(activeWorkspace.areas.map((area: any) => ({ id: area.id, editor: area.editor, rect: area.rect, regions: area.regions })));
if (snapshot?.editorWorkflow) (window as any).__nativeWorkflowEditor = structuredClone({ requestId: data.requestId, context: snapshot.editorWorkflow.context, workspaces: snapshot.editorWorkflow.workspaces?.map((item: any) => ({ id: item.id, name: item.name })) });
if (data.result?.json?.engine === 'CPYTHON_WEBENGINE') (window as any).__nativeWorkflowPython = structuredClone({ requestId: data.requestId, ...data.result.json });
if (snapshot && Array.isArray(snapshot.nodes)) (window as any).__nativeWorkflowMain = structuredClone({ requestId: data.requestId, revision: snapshot.revision, nodes: snapshot.nodes.map((node: any) => ({ id: node.id, name: node.name, type: node.type, transform: node.transform })) });
responses.push({ requestId: data.requestId, ok: data.ok, errorCode: data.error?.code, ...({ errorMessage: data.error?.message, errorDetail: typeof data.error?.detail === 'string' ? data.error.detail.slice(0, 8192) : undefined, status: structuredClone(data.result?.status), markers: structuredClone(snapshot?.scenes?.[0]?.timelineMarkers) } as object) });
Object.assign(responses[responses.length - 1], { nativeError: structuredClone(data.result?.gpuUI?.error), nativeModalActive: data.result?.gpuUI?.modalActive });
}
});
return worker;
},
});
});
}
export async function captureNativeWorkflowState(page: Page) {
const state = await page.evaluate(() => ({ engineStatus: document.querySelector('[data-testid="engine-status"]')?.textContent ?? [...document.querySelectorAll('[data-testid="status-bar"],output')].map((node) => node.textContent?.slice(0, 400)).filter(Boolean).slice(0, 8), main: (window as any).__nativeWorkflowMain, editor: (window as any).__nativeWorkflowEditor, python: (window as any).__nativeWorkflowPython, gpu: (window as any).__nativeWorkflowGPU, areas: (window as any).__nativeWorkflowAreas,
diagnosticDetail: document.querySelector('main.blender-app')?.getAttribute('data-last-diagnostic-detail'),
canvasTelemetry: (window as any).__wbv2Canvas,
requests: (window as any).__nativeWorkflowRequests?.slice(-16), responses: (window as any).__nativeWorkflowResponses?.slice(-8),
uiTransitions: (window as any).__nativeWorkflowUITransitions,
console: [...document.querySelectorAll('section[aria-label="Python Console"]')].map(region => ({ status: region.querySelector('[role="status"]')?.textContent, output: region.querySelector('pre')?.textContent?.slice(-4096), input: region.querySelector('textarea')?.value, disabled: region.querySelector('textarea')?.disabled })) }));
return { ...state, gpuFailure: await page.evaluate(() => (window as any).__nativeWorkflowGPUFailure), browserDiagnostics: browserDiagnostics.get(page) ?? [] };
}
export async function performNativeAction(page: Page, match: Record<string, string | number | boolean>, action: () => Promise<unknown>, ok = true, dispatchTimeout = 10000) {
const start = await page.evaluate(() => (window as any).__nativeWorkflowRequests?.length); expect(Number.isSafeInteger(start)).toBe(true);
await action();
const pending = await page.waitForFunction(({ start, match }) => (window as any).__nativeWorkflowRequests.slice(start).find((item: any) => Object.entries(match).every(([key, value]) => item[key] === value)) ?? false, { start, match }, { timeout: dispatchTimeout });
const request = await pending.jsonValue(); expect(typeof request.requestId).toBe('string');
const reply = await page.waitForFunction(id => (window as any).__nativeWorkflowResponses.find((item: any) => item.requestId === id) ?? false, request.requestId, { timeout: 60000 });
const response = await reply.jsonValue(); expect(typeof response.ok).toBe('boolean'); expect(response.ok, response.errorMessage).toBe(ok);
return response;
}
export class NativeUIWorkflow {
readonly timings: { stage: string; seconds: number }[] = [];
constructor(readonly page: Page, readonly directory: string) {
fs.mkdirSync(directory, { recursive: true });
}
async measured<T>(stage: string, action: () => Promise<T>): Promise<T> {
const start = performance.now();
try {
return await action();
} finally {
this.timings.push({ stage, seconds: (performance.now() - start) / 1000 });
fs.writeFileSync(path.join(this.directory, "timings.json"), JSON.stringify(this.timings, null, 2));
if (!this.page.isClosed()) fs.writeFileSync(path.join(this.directory, "ui-state.json"), JSON.stringify(await this.page.evaluate(() => ({ requests: (window as any).__nativeWorkflowRequests.slice(-12), responses: (window as any).__nativeWorkflowResponses.slice(-12), markers: [...document.querySelectorAll("input")].filter((element) => /^Marker \d/.test(element.getAttribute("aria-label") ?? "")).map((element) => ({ label: element.getAttribute("aria-label"), value: element.value })) })), null, 2));
}
}
async command(action: () => Promise<unknown>, ok = true) {
const count = await this.page.evaluate(() => (window as any).__nativeWorkflowRequests.length);
await action();
await expect.poll(() => this.page.evaluate(() => (window as any).__nativeWorkflowRequests.length), { timeout: 10000 }).toBeGreaterThan(count);
const request = await this.page.evaluate((index) => {
const added = (window as any).__nativeWorkflowRequests.slice(index);
return added.find((entry: any) => entry.command !== "setFrame" && entry.method !== "previewCommand") ?? added.at(-1);
}, count);
await expect.poll(() => this.page.evaluate((id) => (window as any).__nativeWorkflowResponses.some((entry: any) => entry.requestId === id), request.requestId), { timeout: 10000 }).toBe(true);
const result = await this.page.evaluate((id) => (window as any).__nativeWorkflowResponses.find((entry: any) => entry.requestId === id), request.requestId);
expect(result.ok).toBe(ok);
return result;
}
async load(file: string, object: string) {
await this.command(() => this.page.getByTestId("blend-file-input").setInputFiles(file));
await expect(this.page.getByTestId("open-progress")).not.toBeVisible();
const nativeToggle = this.page.getByTestId("native-web-tools-toggle");
if (await nativeToggle.count()) await nativeToggle.click({ force: true });
const item = this.page.getByRole("treeitem").filter({ hasText: object });
await item.click();
await expect(item).toHaveAttribute("aria-selected", "true");
}
async save(name: string) {
const promise = this.page.waitForEvent("download", { timeout: 10000 });
await this.page.getByRole("button", { name: "保存项目", exact: true }).click();
const download = await promise;
await download.saveAs(path.join(this.directory, `${name}.blend`));
}
async click(button: Locator, ok = true) {
return this.command(() => button.click(), ok);
}
}

View File

@@ -0,0 +1,41 @@
"""Read canonical native Main NLA state from saved UI test artifacts."""
import json
from pathlib import Path
import sys
import bpy
directory = Path(sys.argv[sys.argv.index("--") + 1])
snapshots = {}
for blend in sorted(directory.glob("*.blend")):
bpy.ops.wm.open_mainfile(filepath=str(blend), load_ui=False)
objects = []
for obj in sorted(bpy.data.objects, key=lambda item: item.name):
tracks = []
if obj.animation_data:
for track in obj.animation_data.nla_tracks:
tracks.append({
"name": track.name,
"mute": track.mute,
"solo": track.is_solo,
"strips": [{
"name": strip.name,
"type": strip.type,
"action": strip.action.name if strip.action else None,
"start": strip.frame_start,
"end": strip.frame_end,
"actionStart": strip.action_frame_start,
"actionEnd": strip.action_frame_end,
"scale": strip.scale,
"repeat": strip.repeat,
"influence": strip.influence,
"blend": strip.blend_type,
"extrapolation": strip.extrapolation,
"mute": strip.mute,
"reverse": strip.use_reverse,
} for strip in track.strips],
})
objects.append({"name": obj.name, "type": obj.type, "data": obj.data.name if obj.data else None, "tracks": tracks})
snapshots[blend.stem] = {"objects": objects, "scenes": [{"name": scene.name, "frame": scene.frame_current} for scene in sorted(bpy.data.scenes, key=lambda item: item.name)]}
(directory / "native-nla-snapshots.json").write_text(json.dumps(snapshots, sort_keys=True, separators=(",", ":")), encoding="utf-8")

View File

@@ -0,0 +1,19 @@
import bpy
import json
import pathlib
import sys
arguments = sys.argv[sys.argv.index("--") + 1:]
destination = pathlib.Path(arguments[0])
tree = bpy.data.node_groups[arguments[1] if len(arguments) > 1 else "Web Authored Geometry"]
transform = next((node for node in tree.nodes if node.bl_idname == "GeometryNodeTransform"), None)
destination.write_text(json.dumps({
"name": tree.name,
"treeCount": len(bpy.data.node_groups),
"nodes": sorted(node.bl_idname for node in tree.nodes),
"links": len(tree.links),
"interfaceSockets": len([item for item in tree.interface.items_tree if item.item_type == "SOCKET"]),
"translation": list(transform.inputs["Translation"].default_value) if transform else None,
"owners": sorted(obj.name for obj in bpy.data.objects if any(
modifier.type == "NODES" and modifier.node_group == tree for modifier in obj.modifiers)),
}, indent=2) + "\n")

View File

@@ -0,0 +1,36 @@
import bpy
import json
import pathlib
import sys
request = json.loads(pathlib.Path(sys.argv[sys.argv.index("--") + 1]).read_text())
def vector(values):
return [round(float(value), 6) for value in values]
states = {}
for label, file in request["files"].items():
bpy.ops.wm.open_mainfile(filepath=str(pathlib.Path(file).resolve()))
states[label] = {
"objects": [{"name": obj.name, "type": obj.type,
"data": obj.data.name if obj.data else None}
for obj in sorted(bpy.data.objects, key=lambda obj: obj.name)],
"curves": [{
"name": curve.name, "type": curve.bl_rna.identifier,
"splines": [{
"type": spline.type,
"cyclicU": spline.use_cyclic_u, "cyclicV": spline.use_cyclic_v,
"u": spline.point_count_u, "v": spline.point_count_v,
"orderU": spline.order_u if spline.type == "NURBS" else None,
"orderV": spline.order_v if spline.type == "NURBS" and spline.point_count_v > 1 else None,
"points": [vector(point.co) for point in spline.points],
"bezier": [{"co": vector(point.co),
"left": vector(point.handle_left), "right": vector(point.handle_right),
"leftType": point.handle_left_type, "rightType": point.handle_right_type}
for point in spline.bezier_points],
} for spline in curve.splines],
} for curve in sorted(bpy.data.curves, key=lambda curve: curve.name)],
}
pathlib.Path(request["output"]).write_text(json.dumps(states, indent=2) + "\n")

View File

@@ -0,0 +1,48 @@
import json
import pathlib
import sys
import bpy
def vector(value):
return [round(float(component), 6) for component in value]
directory = pathlib.Path(sys.argv[sys.argv.index("--") + 1])
states = {}
for blend in sorted(directory.glob("*.blend")):
bpy.ops.wm.open_mainfile(filepath=str(blend))
bpy.context.view_layer.update()
states[blend.stem] = {
"objects": sorted(({
"name": obj.name,
"type": obj.type,
"dataType": obj.data.bl_rna.identifier if obj.data else None,
"dataName": obj.data.name if obj.data else None,
"location": vector(obj.location),
"rotation": vector(obj.rotation_euler),
"scale": vector(obj.scale),
"selected": obj.select_get(),
"hideViewport": obj.hide_viewport,
"hideRender": obj.hide_render,
"visible": obj.visible_get(),
"collections": sorted(collection.name for collection in obj.users_collection),
} for obj in bpy.data.objects), key=lambda obj: obj["name"]),
"collections": sorted(({
"name": collection.name,
"hideViewport": collection.hide_viewport,
"hideRender": collection.hide_render,
"objects": sorted(obj.name for obj in collection.objects),
} for collection in bpy.data.collections), key=lambda collection: collection["name"]),
"active": bpy.context.view_layer.objects.active.name
if bpy.context.view_layer.objects.active else None,
"dataBlocks": {
"cameras": sorted(data.name for data in bpy.data.cameras),
"lights": sorted(data.name for data in bpy.data.lights),
"armatures": sorted(data.name for data in bpy.data.armatures),
"meshes": sorted(data.name for data in bpy.data.meshes),
},
}
(directory / "native-object-editor-snapshots.json").write_text(
json.dumps(states, indent=2, sort_keys=True) + "\n")

View File

@@ -0,0 +1,31 @@
"""Read browser Main and independently create the same cube in desktop Blender."""
import json
import pathlib
import sys
import bpy
def vector(values):
return [round(float(value), 6) + 0.0 for value in values]
def summary():
bpy.context.view_layer.update()
return [{"name": obj.name, "type": obj.type, "location": vector(obj.location),
"vertices": sorted(vector(v.co) for v in obj.data.vertices) if obj.type == "MESH" else [],
"faces": len(obj.data.polygons) if obj.type == "MESH" else 0}
for obj in sorted(bpy.data.objects, key=lambda obj: obj.name)]
directory = pathlib.Path(sys.argv[sys.argv.index("--") + 1])
states = {}
for blend in sorted(directory.glob("*.blend")):
bpy.ops.wm.open_mainfile(filepath=str(blend))
states[blend.stem] = summary()
bpy.ops.wm.open_mainfile(filepath=str(directory / "before.blend"))
primitive = json.loads((directory / "primitive.json").read_text())["primitive"]
operator = getattr(bpy.ops.mesh, "primitive_" + primitive + "_add")
assert operator(size=2, location=(0, 0, 0)) == {"FINISHED"}
bpy.context.active_object.name = "BridgePrimitive"
states["expected"] = summary()
(directory / "native.json").write_text(json.dumps(states, sort_keys=True, indent=2) + "\n")

View File

@@ -0,0 +1,46 @@
"""Read native imported data; fmtf matches upstream tests/python/modules/io_report.py."""
import bpy, hashlib, json
def fmtf(f):
return '0.000' if abs(f) < 0.0005 else f'{f:.3f}'
def value(item):
if isinstance(item, float): return fmtf(item)
if item is None or isinstance(item, (str, int, bool)): return item
return [value(component) for component in item]
objects = []
for obj in sorted(bpy.context.scene.objects, key=lambda item: item.name):
record = dict(name=obj.name, type=obj.type, matrix=value(obj.matrix_world), parent=obj.parent.name if obj.parent else None)
record['collections'] = sorted((c.name, c.hide_viewport, c.hide_render) for c in obj.users_collection)
if obj.type == 'MESH':
record['mesh'] = dict(vertices=[value(v.co) for v in obj.data.vertices], faces=[list(p.vertices) for p in obj.data.polygons], materials=[m.name if m else None for m in obj.data.materials])
record['mesh']['uv'] = [(layer.name, [value(loop.uv) for loop in layer.data]) for layer in obj.data.uv_layers]
record['mesh']['groups'] = [(group.name, group.index) for group in obj.vertex_groups]
record['mesh']['weights'] = [[(group.group, fmtf(group.weight)) for group in vertex.groups] for vertex in obj.data.vertices]
record['mesh']['modifiers'] = [(m.name, m.type, getattr(m, 'object', None).name if getattr(m, 'object', None) else None) for m in obj.modifiers]
if obj.type == 'ARMATURE':
record['bones'] = [dict(name=b.name, parent=b.parent.name if b.parent else None, matrix=value(b.matrix_local), head=value(b.head_local), tail=value(b.tail_local), deform=b.use_deform) for b in obj.data.bones]
animation = obj.animation_data
record['animation'] = None if not animation else dict(action=animation.action.name if animation.action else None, nla=[(track.name, track.mute, [(s.name, s.action.name if s.action else None, fmtf(s.frame_start), fmtf(s.frame_end)) for s in track.strips]) for track in animation.nla_tracks])
objects.append(record)
materials = []
for material in sorted(bpy.data.materials, key=lambda item: item.name):
tree = material.node_tree
nodes = [] if tree is None else [dict(name=n.name, type=n.bl_idname, image=n.image.name if getattr(n, 'image', None) else None, inputs=[(s.identifier, value(getattr(s, 'default_value', None))) for s in n.inputs]) for n in sorted(tree.nodes, key=lambda item: item.name)]
links = [] if tree is None else sorted((link.from_node.name, link.from_socket.identifier, link.to_node.name, link.to_socket.identifier) for link in tree.links)
materials.append(dict(name=material.name, nodes=nodes, links=links))
images = []
for image in sorted(bpy.data.images, key=lambda item: item.name):
if image.type != 'IMAGE': continue
pixels = hashlib.sha256(','.join(fmtf(p) for p in image.pixels[:]).encode()).hexdigest()
packed = hashlib.sha256(image.packed_file.data).hexdigest() if image.packed_file else None
images.append(dict(name=image.name, size=list(image.size), colorspace=image.colorspace_settings.name, packed=packed, pixels=pixels))
actions = []
for action in sorted(bpy.data.actions, key=lambda item: item.name):
channels = []
for layer in action.layers:
for strip in layer.strips:
if strip.type != 'KEYFRAME': continue
for bag in strip.channelbags:
for curve in sorted(bag.fcurves, key=lambda c: (c.data_path, c.array_index)):
channels.append(dict(layer=layer.name, slot=bag.slot.identifier if bag.slot else None, path=curve.data_path, index=curve.array_index, extrapolation=curve.extrapolation, keys=[(value(k.co), value(k.handle_left), value(k.handle_right), k.interpolation, k.handle_left_type, k.handle_right_type) for k in curve.keyframe_points]))
actions.append(dict(name=action.name, slots=[s.identifier for s in action.slots], channels=channels))
print(json.dumps(dict(objects=objects, materials=materials, images=images, actions=actions), separators=(',', ':')))

View File

@@ -0,0 +1,56 @@
"""Independently read and author the same rig state in desktop Blender."""
import json
import pathlib
import sys
import bpy
def vector(values):
return [round(float(value), 5) + 0.0 for value in values]
def summary():
bpy.context.view_layer.update()
owner = bpy.data.objects["PoseConstraintArmatureObject"]
return {
"bones": [{"name": b.name, "parent": b.parent.name if b.parent else None,
"head": vector(b.head_local), "tail": vector(b.tail_local),
"matrix": [vector(row) for row in b.matrix_local], "connected": b.use_connect}
for b in sorted(owner.data.bones, key=lambda b: b.name)],
"pose": [{"name": p.name, "location": vector(p.location), "rotation": vector(p.rotation_euler),
"scale": vector(p.scale), "matrix": [vector(row) for row in p.matrix_basis]}
for p in sorted(owner.pose.bones, key=lambda p: p.name)],
}
directory = pathlib.Path(sys.argv[sys.argv.index("--") + 1])
command = json.loads((directory / "command.json").read_text())
states = {}
for blend in sorted(directory.glob("*.blend")):
bpy.ops.wm.open_mainfile(filepath=str(blend))
states[blend.stem] = summary()
bpy.ops.wm.open_mainfile(filepath=str(directory / "before.blend"))
obj = bpy.data.objects["PoseConstraintArmatureObject"]
if command["type"] == "setArmatureBones":
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
bpy.ops.object.mode_set(mode="EDIT")
for bone in list(obj.data.edit_bones):
obj.data.edit_bones.remove(bone)
for item in command["bones"]:
bone = obj.data.edit_bones.new(item["name"])
bone.head, bone.tail, bone.roll = item["head"], item["tail"], item["roll"]
for item in command["bones"]:
bone = obj.data.edit_bones[item["name"]]
if item["parentName"]:
bone.parent = obj.data.edit_bones[item["parentName"]]
bone.use_connect = item["connected"]
bpy.ops.object.mode_set(mode="OBJECT")
else:
bone = obj.pose.bones[command["boneName"]]
bone.rotation_mode = "XYZ"
bone.location = command["translation"]
bone.rotation_euler = command["rotationEuler"]
bone.scale = command["scale"]
states["expected"] = summary()
(directory / "native.json").write_text(json.dumps(states, sort_keys=True, indent=2) + "\n")

View File

@@ -0,0 +1,25 @@
import json
import pathlib
import sys
import bpy
directory = pathlib.Path(sys.argv[sys.argv.index("--") + 1])
states = {}
for blend in sorted(directory.glob("*.blend")):
bpy.ops.wm.open_mainfile(filepath=str(blend))
states[blend.stem] = {
scene.name: {
"camera": scene.camera.name if scene.camera else None,
"objects": sorted(obj.name for obj in scene.objects),
"strips": sorted(({
"name": strip.name, "type": strip.type,
"start": strip.frame_final_start, "end": strip.frame_final_end,
"channel": strip.channel, "mute": strip.mute,
"sourceScene": strip.scene.name if strip.type == "SCENE" and strip.scene else None,
"sourceSound": strip.sound.filepath if strip.type == "SOUND" and strip.sound else None,
"images": [element.filename for element in strip.elements] if strip.type == "IMAGE" else [],
} for strip in scene.sequence_editor.strips), key=lambda strip: strip["name"])
if scene.sequence_editor else [],
} for scene in bpy.data.scenes
}
(directory / "native-scene-strip-snapshots.json").write_text(json.dumps(states, indent=2, sort_keys=True) + "\n")

View File

@@ -0,0 +1,30 @@
"""Compare saved browser Main geometry with the desktop remesh operator."""
import json
import pathlib
import sys
import bpy
def summary():
bpy.context.view_layer.update()
def vector(values):
return [round(float(value), 6) for value in values]
return {"objects": [{
"name": obj.name, "type": obj.type,
"matrix": [vector(row) for row in obj.matrix_world],
"vertices": [vector(v.co) for v in obj.data.vertices] if obj.type == "MESH" else [],
"faces": [list(p.vertices) for p in obj.data.polygons] if obj.type == "MESH" else [],
"voxelSize": round(obj.data.remesh_voxel_size, 6) if obj.type == "MESH" else None,
} for obj in sorted(bpy.data.objects, key=lambda obj: obj.name)]}
directory = pathlib.Path(sys.argv[sys.argv.index("--") + 1])
states = {}
for blend in sorted(directory.glob("*.blend")):
bpy.ops.wm.open_mainfile(filepath=str(blend), load_ui=False)
states[blend.stem] = summary()
bpy.ops.wm.open_mainfile(filepath=str(directory / "before.blend"), load_ui=False)
bpy.context.active_object.data.remesh_voxel_size = 0.3
assert bpy.ops.object.voxel_remesh() == {"FINISHED"}
states["expected"] = summary()
(directory / "native.json").write_text(json.dumps(states, sort_keys=True, indent=2) + "\n")

View File

@@ -0,0 +1,52 @@
import json
import hashlib
import pathlib
import sys
import bpy
def strip_state(strip):
return {
"name": strip.name,
"type": strip.type,
"channel": strip.channel,
"start": strip.frame_final_start,
"end": strip.frame_final_end,
"sourceStart": strip.frame_offset_start,
"sourceEnd": strip.frame_offset_end,
"muted": strip.mute,
"locked": strip.lock,
"inputs": [getattr(strip, key).name for key in ("input_1", "input_2")
if getattr(strip, key, None)],
"children": sorted((strip_state(child) for child in strip.strips),
key=lambda child: child["name"]) if strip.type == "META" else [],
"source": {
"sound": strip.sound.filepath if strip.type == "SOUND" and strip.sound else None,
"movie": strip.filepath if strip.type == "MOVIE" else None,
"scene": strip.scene.name if strip.type == "SCENE" and strip.scene else None,
"directory": strip.directory if strip.type == "IMAGE" else None,
"images": [element.filename for element in strip.elements] if strip.type == "IMAGE" else [],
},
}
directory = pathlib.Path(sys.argv[sys.argv.index("--") + 1])
snapshots = {}
for blend in sorted(directory.glob("*.blend")):
bpy.ops.wm.open_mainfile(filepath=str(blend))
snapshots[blend.stem] = {
scene.name: sorted((strip_state(strip) for strip in scene.sequence_editor.strips),
key=lambda strip: strip["name"])
if scene.sequence_editor else []
for scene in bpy.data.scenes
}
snapshots[blend.stem]["__soundData"] = sorted(({
"name": sound.name,
"path": sound.filepath,
"users": sound.users,
"packedSha256": hashlib.sha256(sound.packed_file.data).hexdigest()
if sound.packed_file else None,
} for sound in bpy.data.sounds), key=lambda sound: sound["name"])
(directory / "native-sequencer-snapshots.json").write_text(
json.dumps(snapshots, indent=2, sort_keys=True) + "\n")

View File

@@ -0,0 +1,37 @@
"""Read canonical object/scene Main state from Chromium-saved blend artifacts."""
import json
import pathlib
import sys
import bpy
directory = pathlib.Path(sys.argv[sys.argv.index("--") + 1])
snapshots = {}
for blend in sorted(directory.glob("*.blend")):
bpy.ops.wm.open_mainfile(filepath=str(blend), load_ui=False)
# Names bind ID references without serializing process-specific addresses.
snapshots[blend.stem] = {
"objects": [
{
"name": obj.name,
"type": obj.type,
"data": obj.data.name if obj.data else None,
"parent": obj.parent.name if obj.parent else None,
"matrix": [list(row) for row in obj.matrix_world],
"collections": sorted(collection.name for collection in obj.users_collection),
}
for obj in sorted(bpy.data.objects, key=lambda item: item.name)
],
"scenes": [
{
"name": scene.name,
"frame": scene.frame_current,
"objects": sorted(obj.name for obj in scene.objects),
}
for scene in sorted(bpy.data.scenes, key=lambda item: item.name)
],
}
(directory / "native-main-snapshots.json").write_text(
json.dumps(snapshots, sort_keys=True, separators=(",", ":")), encoding="utf-8"
)

35
web/tests/fixtures/server-operator-runner.py vendored Executable file
View File

@@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""Transport test double, not Blender behavioral evidence."""
import hashlib
import json
import os
from pathlib import Path
import signal
import sys
import time
envelope = json.load(sys.stdin)
request = envelope["request"]
root = Path(envelope["workspaceRoot"])
mode = request["properties"].get("mode", "success")
if mode == "signal":
os.kill(os.getpid(), signal.SIGTERM)
time.sleep(10 if mode == "slow" else 0.4)
if mode == "malformed":
(root / "result.json").write_text("{")
sys.exit(0)
result = {key: request[key] for key in (
"schemaVersion", "route", "jobId", "sourceBlendSha256", "sourceRevision",
"blenderBuild", "propertiesSha256", "requestSha256",
)}
data = b"transport-result"
result.update(status="SUCCEEDED", result=["FINISHED"],
resultSha256=hashlib.sha256(b'["FINISHED"]').hexdigest(),
resultBlendByteLength=len(data),
resultBlendSha256=hashlib.sha256(data).hexdigest())
if mode == "bad-hash":
result["resultBlendSha256"] = "0" * 64
if mode == "bad-binding":
result["sourceRevision"] += 1
(root / "result.blend").write_bytes(data)
(root / "result.json").write_text(json.dumps(result))

View File

@@ -0,0 +1,32 @@
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import type { Page, TestInfo } from '@playwright/test';
import { queryNativeJSON } from './native-modeling-workflow';
const root = path.resolve(import.meta.dirname, '../../..');
export async function stageOriginalAutosaveCase(page: Page, info: TestInfo) {
const catalog = JSON.parse(fs.readFileSync(path.join(root, 'tests/golden/WBV2-P1424-001/source-cases-20261028/file-workflow-cases.json'), 'utf8'));
const entry = catalog.cases.find((item: { sourceCaseId: string }) => item.sourceCaseId === 'upstream.blendfile_autosave'); if (!entry) throw new Error('Original autosave case binding missing');
const index = JSON.parse(fs.readFileSync(path.resolve(root, catalog.assetIdentityIndex), 'utf8'));
const caseDir = `/tmp/wbv2-source/${createHash('sha256').update(`${process.env.WBV2_CASE_ATTEMPT ?? ''}:${info.outputPath('autosave-case')}`).digest('hex').slice(0, 24)}`;
const sourceHashes: Record<string, string> = {};
const assetHashes: Record<string, string> = {};
const writeBytes = async (relative: string, bytes: Buffer) => {
const destination = path.posix.join(caseDir, relative);
for (let offset = 0; offset < bytes.length; offset += 65536) {
const chunk = bytes.subarray(offset, offset + 65536);
const receipt = await queryNativeJSON(page, `import base64,json,os\np=${JSON.stringify(destination)}\nos.makedirs(os.path.dirname(p),exist_ok=True)\nwith open(p,${JSON.stringify(offset === 0 ? 'wb' : 'ab')}) as output:\n count=output.write(base64.b64decode(${JSON.stringify(chunk.toString('base64'))}))\nprint(json.dumps({'bytes':count}))`);
if ((receipt.value as { bytes: number }).bytes !== chunk.length) throw new Error(`Incomplete original case write: ${relative}`);
}
};
for (const name of ['bl_blendfile_utils.py', 'bl_blendfile_autosave.py']) {
const bytes = fs.readFileSync(path.join(root, path.dirname(entry.script), name));
sourceHashes[name] = createHash('sha256').update(bytes).digest('hex'); if (sourceHashes[name] !== entry.sourceHashes?.[name]) throw new Error(`Original autosave source identity mismatch: ${name}`); await writeBytes(name, bytes);
}
for (const relative of entry.assets as string[]) {
const expected = index.files.find((item: { path: string }) => item.path === relative);
const bytes = fs.readFileSync(path.resolve(root, catalog.assetRoot, relative)), digest = createHash('sha256').update(bytes).digest('hex');
if (!expected || bytes.length !== expected.bytes || digest !== expected.sha256) throw new Error(`Original autosave asset identity mismatch: ${relative}`);
assetHashes[relative] = digest; await writeBytes(`assets/${relative}`, bytes);
}
return { sourceCaseId: entry.sourceCaseId, caseDir, sourceHashes, assetHashes, writeCaseBytes: writeBytes, script: path.basename(entry.script), argv: entry.arguments.map((arg: string) => arg.replaceAll('{assets}', `${caseDir}/assets`).replaceAll('{output}', `${caseDir}/output`)) }; }

View File

@@ -0,0 +1,57 @@
import { expect, type Page } from '@playwright/test';
import type { EmbeddedPythonResult } from '../../app/src/engine-client/WebEngineClient';
import { performNativeAction } from './native-ui-workflow';
export async function pythonReply(page: Page): Promise<(EmbeddedPythonResult & { requestId: string }) | null> {
const value = await page.evaluate(() => (window as any).__nativeWorkflowPython);
if (value === undefined) return null;
expect(value !== null && typeof value === 'object' && !Array.isArray(value)).toBe(true);
expect(typeof value.requestId).toBe('string'); expect(value.engine).toBe('CPYTHON_WEBENGINE'); expect(value.bpy).toBe(true);
expect(typeof value.state).toBe('string'); expect(['DONE', 'MORE', 'ERROR']).toContain(value.state);
expect(typeof value.output).toBe('string'); expect(typeof value.version).toBe('string'); expect(typeof value.changed).toBe('boolean'); expect(typeof value.truncated).toBe('boolean');
expect(Number.isSafeInteger(value.revision)).toBe(true); expect(value.revision).toBeGreaterThanOrEqual(0);
return value;
}
export async function runWasmPython(page: Page, source: string, state: 'DONE' | 'ERROR' = 'DONE') {
const previous = (await pythonReply(page))?.requestId ?? null;
await page.getByRole('button', { name: '清空 Python 控制台', exact: true }).click();
const input = page.getByLabel('Python 输入', { exact: true }); await input.fill(source); await input.press('Enter');
let dispatchedId: string | null = null;
await expect.poll(async () => {
const id = await page.evaluate(() => { const requests = (window as any).__nativeWorkflowRequests; if (!Array.isArray(requests)) throw new Error('Native request observation unavailable'); const request = requests.findLast((item: any) => item.type === 'jsonCommand' && item.jsonCommand === 'pythonExec'); return typeof request?.requestId === 'string' ? request.requestId : null; });
if (id !== null && id !== previous) { dispatchedId = id; return id; }
const region = page.getByRole('region', { name: 'Python Console', exact: true }), output = await region.locator('pre').textContent();
if (typeof output === 'string' && output.length > 0 && await region.getByRole('status').textContent() === 'error') throw new Error(`Console failed before native reply: ${output}`);
return previous;
}, { timeout: 10000, message: 'Console must dispatch a new native pythonExec request' }).not.toBe(previous);
await expect.poll(async () => (await pythonReply(page))?.requestId ?? null, { timeout: 60000, message: 'Native Python must reply to the dispatched request' }).toBe(dispatchedId);
const result = (await pythonReply(page))!; expect(result.state).toBe(state);
await expect(page.getByRole('region', { name: 'Python Console', exact: true }).getByRole('status')).toHaveText(state === 'ERROR' ? 'error' : 'idle');
return result;
}
export async function openFromUI(page: Page, action: () => Promise<unknown>) {
await performNativeAction(page, { type: 'openBlend' }, action, true, 60000);
await expect(page.getByTestId('open-progress')).toBeHidden();
}
export async function switchNativeWorkspace(page: Page, name: string) {
const flow = await page.evaluate(() => (window as any).__nativeWorkflowEditor);
expect(Array.isArray(flow?.workspaces)).toBe(true); expect(typeof flow?.context?.workspaceId).toBe('string');
const target = flow.workspaces.find((workspace: any) => typeof workspace.name === 'string' && workspace.name === name); expect(typeof target?.id).toBe('string');
if (flow.context.workspaceId === target.id) return;
await performNativeAction(page, { command: 'editEditorContext', editAction: 'SWITCH_WORKSPACE', workspaceId: target.id }, () => page.getByRole('button', { name, exact: true }).click());
await expect.poll(() => page.evaluate(() => (window as any).__nativeWorkflowEditor?.context?.workspaceId), { timeout: 10000 }).toBe(target.id);
}
export async function expectUILocation(page: Page, name: string, expected: [number, number, number]) {
expect(expected.every(Number.isFinite)).toBe(true);
const group = page.getByRole('group', { name: `${name}: Transform`, exact: true }), actual: number[] = [];
for (const [index, axis] of ['X', 'Y', 'Z'].entries()) {
let value = NaN;
await expect.poll(async () => { value = await group.getByRole('spinbutton', { name: `Location ${axis}`, exact: true }).evaluate(element => { if (!(element instanceof HTMLInputElement) || element.type !== 'number') throw new Error('Location must be a numeric input'); return element.valueAsNumber; }); expect(Number.isFinite(value)).toBe(true); return value; }).toBeCloseTo(expected[index], 5);
actual.push(value);
}
return actual;
}
export async function showNativeObjectProperties(page: Page) {
const context = page.getByLabel('Properties context', { exact: true });
if (await context.inputValue() !== 'OBJECT') await performNativeAction(page, { command: 'editEditorContext', editAction: 'PROPERTIES_EDIT', editOperation: 'SET_CONTEXT', context: 'OBJECT' }, () => context.selectOption('OBJECT'));
await expect(context).toHaveValue('OBJECT');
}

View File

@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import type { FullConfig } from '@playwright/test';
export default async function setup(config: FullConfig) {
const origin = config.projects[0].use.baseURL; assert.equal(typeof origin, 'string');
const candidate = JSON.parse(fs.readFileSync(path.join(process.env.WEB_DESKTOP_CANDIDATE_PUBLIC!, '../candidate.json'), 'utf8'));
const observed = [];
for (const name of ['web_engine.js', 'web_engine.wasm']) {
const response = await fetch(`${origin}/vendor/blender/${name}`, { signal: AbortSignal.timeout(60000) });
assert.equal(response.status, 200, `serve ${name}`);
assert.equal(typeof response.headers.get('cross-origin-opener-policy'), 'string');
assert.equal(response.headers.get('cross-origin-opener-policy'), 'same-origin');
assert.equal(typeof response.headers.get('cross-origin-embedder-policy'), 'string');
assert.equal(response.headers.get('cross-origin-embedder-policy'), 'require-corp');
const bytes = await response.arrayBuffer(); assert.ok(bytes instanceof ArrayBuffer);
const sha256 = crypto.createHash('sha256').update(new Uint8Array(bytes)).digest('hex');
assert.equal(typeof candidate.sha256[name], 'string'); assert.equal(sha256, candidate.sha256[name], `served ${name} drift`);
observed.push({ name, bytes: bytes.byteLength, sha256 });
}
fs.writeFileSync(path.join(process.env.WBV2_CASE_ATTEMPT!, 'http-prerequisites.json'), JSON.stringify({ origin, observed }, null, 2));
}