Advance Blender WebEngine N-015 through N-022 parity

This commit is contained in:
mes123456
2026-08-12 13:26:34 -04:00
parent 9fd26010f6
commit b3cefaeec5
51 changed files with 1809 additions and 171 deletions

View File

@@ -1,15 +1,65 @@
import { gateSelectionInteraction, parseRaycastSelectionHit, recordSelection, SELECTION_HISTORY_BUDGET, stepSelectionHistory } from "../../../protocol/selection-history";
import {
gateSelectionInteraction,
parseRaycastSelectionHit,
parseSelectionHistory,
patchSelectionRanges,
recordSelection,
SELECTION_HISTORY_BUDGET,
stepSelectionHistory,
} from "../../../protocol/selection-history";
const empty = { activeObjectId: null, objectIds: [], targets: [] };
const selected = {
activeObjectId: "object:1",
objectIds: ["object:1", "object:2"],
targets: [
{ objectId: "object:1", dataId: "curve:1", mode: "VERT", nonMeshKind: "HANDLE_LEFT", indices: [3, 1] },
{ objectId: "object:2", dataId: "curve:2", mode: "VERT", nonMeshKind: "HANDLE_RIGHT", indices: [2] },
],
};
const base = { schemaVersion: 2, revision: 0, cursor: 0, entries: [empty] };
const empty = { activeObjectId: null, objectIds: [], meshId: null, elementMode: "FACE", elementIndices: [] };
const selected = { activeObjectId: "object:1", objectIds: ["object:1"], meshId: "mesh:1", elementMode: "VERT", elementIndices: [3, 1] };
const base = { schemaVersion: 1, revision: 0, cursor: 0, entries: [empty] };
self.onmessage = () => {
const result: Record<string, unknown> = {};
try { const next = recordSelection(base, 0, { ...selected, nonMeshKind: "HANDLE_LEFT" }); const undone = stepSelectionHistory(next, 1, "UNDO"); const redone = stepSelectionHistory(undone, 2, "REDO"); result.history = [redone.revision, redone.cursor, redone.entries[redone.cursor].elementIndices, redone.entries[redone.cursor].nonMeshKind]; } catch (error) { result.history = error instanceof Error ? error.message : String(error); }
try { recordSelection(base, 4, selected); } catch (error) { result.revision = error instanceof Error ? error.message : String(error); }
try { recordSelection(base, 0, { ...selected, elementIndices: new Array(SELECTION_HISTORY_BUDGET.maxElements + 1).fill(1) }); } catch (error) { result.budget = error instanceof Error ? error.message : String(error); }
try { parseRaycastSelectionHit({ sourceRevision: 2, dataId: "mesh:1", mode: "VERT", index: 0, distance: 1, point: [0, 0, 0] }, 3); } catch (error) { result.raycast = error instanceof Error ? error.message : String(error); }
try { result.handleHit = parseRaycastSelectionHit({ sourceRevision: 3, dataId: "curve:1", mode: "VERT", index: 2, distance: 1, point: [0, 0, 0], nonMeshKind: "HANDLE_RIGHT" }, 3).nonMeshKind; } catch (error) { result.handleHit = error instanceof Error ? error.message : String(error); }
try {
const next = recordSelection(base, 0, selected);
const undone = stepSelectionHistory(next, 1, "UNDO");
const redone = stepSelectionHistory(undone, 2, "REDO");
result.history = [redone.revision, redone.cursor, redone.entries[redone.cursor].targets.map((target) => [target.dataId, target.indices, target.nonMeshKind])];
}
catch (error) { result.history = error instanceof Error ? error.message : String(error); }
try { recordSelection(base, 4, selected); }
catch (error) { result.revision = error instanceof Error ? error.message : String(error); }
try {
recordSelection(base, 0, { ...selected, targets: [{ ...selected.targets[0], indices: new Array(SELECTION_HISTORY_BUDGET.maxElements + 1).fill(1) }] });
}
catch (error) { result.budget = error instanceof Error ? error.message : String(error); }
try { parseRaycastSelectionHit({ sourceRevision: 2, dataId: "mesh:1", mode: "VERT", index: 0, distance: 1, point: [0, 0, 0] }, 3); }
catch (error) { result.raycast = error instanceof Error ? error.message : String(error); }
try {
const hit = parseRaycastSelectionHit({ sourceRevision: 3, objectId: "object:1", dataId: "curve:1", mode: "VERT", index: 2, distance: 1, point: [0, 0, 0], nonMeshKind: "HANDLE_RIGHT" }, 3);
result.handleHit = [hit.objectId, hit.nonMeshKind];
}
catch (error) { result.handleHit = error instanceof Error ? error.message : String(error); }
try {
const patched = patchSelectionRanges(selected, [
{ objectId: "object:1", dataId: "curve:1", mode: "VERT", nonMeshKind: "HANDLE_LEFT", start: 2, end: 4, selected: true },
{ objectId: "object:1", dataId: "curve:1", mode: "VERT", nonMeshKind: "HANDLE_LEFT", start: 3, end: 3, selected: false },
{ objectId: "object:2", dataId: "curve:2", mode: "VERT", nonMeshKind: "HANDLE_RIGHT", start: 2, end: 2, selected: false },
]);
result.rangePatch = patched.targets.map((target) => [target.dataId, target.indices]);
}
catch (error) { result.rangePatch = error instanceof Error ? error.message : String(error); }
try {
const migrated = parseSelectionHistory({
schemaVersion: 1,
revision: 0,
cursor: 0,
entries: [{ activeObjectId: "object:1", objectIds: ["object:1"], meshId: "mesh:1", elementMode: "FACE", elementIndices: [4] }],
});
result.migrated = [migrated.schemaVersion, migrated.entries[0].targets[0].dataId];
}
catch (error) { result.migrated = error instanceof Error ? error.message : String(error); }
result.gates = [gateSelectionInteraction("RAYCAST").status, gateSelectionInteraction("HISTORY").status, gateSelectionInteraction("GIZMO").status];
self.postMessage(result);
};

View File

@@ -1,9 +1,9 @@
import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageAssetRecord, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationRecord, StorageOperationResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheListResult, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage";
import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageAssetRecord, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationRecord, StorageOperationResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheFrameReadResult, StorageSimulationCacheListResult, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage";
import { normalizeProjectAssetPath } from "../../../protocol/asset-path";
import { parseLODCacheRecord, type LODCacheRecord } from "../../../protocol/lod";
import { parseSimulationCacheManifest, simulationCacheKey, SimulationCacheValidationError, verifySimulationCache, type SimulationCacheManifestIR } from "../../../protocol/simulation-cache";
import { parseSimulationCacheManifest, selectSimulationCacheFrame, simulationCacheKey, SimulationCacheValidationError, verifySimulationCache, verifySimulationCacheFrame, type SimulationCacheManifestIR } from "../../../protocol/simulation-cache";
import { STORAGE_DATABASE_NAME, STORAGE_SCHEMA_VERSION, STORAGE_STORES, upgradeStorageSchema } from "../storage/migrations";
import { deleteLodCache, ensureProjectLayout, projectLayout, readContentAsset, readLodCache, readProjectBlend, recoverProjectBlend, validateSha256, writeContentAsset, writeLodCache, writeProjectBlend, type ProjectSaveFault } from "../storage/opfs-files";
import { deleteLodCache, ensureProjectLayout, projectLayout, readContentAsset, readContentAssetRange, readLodCache, readProjectBlend, recoverProjectBlend, validateSha256, writeContentAsset, writeLodCache, writeProjectBlend, type ProjectSaveFault } from "../storage/opfs-files";
const scope = self as unknown as {
onmessage: ((event: MessageEvent<StorageRequest>) => void) | null;
@@ -724,6 +724,36 @@ async function readSimulationCache(projectId: string, cacheKey: string): Promise
return { projectId, cacheKey, persisted: true, manifest, path: row.path, data: asset.data };
}
async function readSimulationCacheFrame(projectId: string, cacheKey: string, frame: number): Promise<StorageSimulationCacheFrameReadResult> {
projectLayout(projectId);
const row = await readSimulationRow(projectId, cacheKey);
if (!row) throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache manifest is missing");
const manifest = parseSimulationCacheManifest(row.manifest);
if (simulationCacheKey(manifest) !== cacheKey) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache manifest key is inconsistent");
}
const selected = selectSimulationCacheFrame(manifest, frame);
const asset = await readAssetRow(projectId, manifest.cacheSha256);
if (!asset || asset.bytes !== manifest.byteLength) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache payload is missing or truncated");
}
const data = asset.buffer ?
asset.buffer.slice(selected.byteOffset, selected.byteOffset + selected.byteLength) :
await readContentAssetRange(projectId, manifest.cacheSha256, selected.byteOffset, selected.byteLength, manifest.byteLength);
await verifySimulationCacheFrame(manifest, frame, data);
return {
projectId,
cacheKey,
persisted: true,
manifest,
path: row.path,
frame,
byteOffset: selected.byteOffset,
byteLength: selected.byteLength,
data,
};
}
async function listSimulationCaches(projectId: string): Promise<StorageSimulationCacheListResult> {
projectLayout(projectId);
const db = await openDatabase();
@@ -773,6 +803,7 @@ scope.onmessage = async (event) => {
else if (command.type === "pruneLOD") result = await pruneLOD(command.projectId, command.maxBytes);
else if (command.type === "putSimulationCache") result = await withProjectTransaction(command.projectId, () => putSimulationCache(command.projectId, command.manifest, command.data));
else if (command.type === "readSimulationCache") result = await readSimulationCache(command.projectId, command.cacheKey);
else if (command.type === "readSimulationCacheFrame") result = await readSimulationCacheFrame(command.projectId, command.cacheKey, command.frame);
else if (command.type === "listSimulationCaches") result = await listSimulationCaches(command.projectId);
else throw new Error("Unknown storage command");
if (result && "data" in result && result.data instanceof ArrayBuffer) scope.postMessage({ requestId: event.data.requestId, ok: true, result }, [result.data]);

View File

@@ -35,7 +35,7 @@ import {
setPBRMaterialSelected,
} from "../three-adapter/pbr";
import { GPUTextureStore } from "../three-adapter/texture-assets";
import { applyNonMeshTransform, createNonMeshObject } from "../three-adapter/nonmesh";
import { applyNonMeshElementSelection, applyNonMeshTransform, createNonMeshObject } from "../three-adapter/nonmesh";
import { applyGreasePencilTransform, createGreasePencilObject } from "../three-adapter/grease-pencil";
const workerScope = self as unknown as {
@@ -276,6 +276,7 @@ function setSnapshot(snapshot: SceneSnapshotIR, buffers: MeshGeometryBuffer[], n
const greasePencilsById = new Map((snapshot.greasePencils ?? []).map((data) => [data.id, data]));
let greasePencilCount = 0;
let greasePencilBlockedCount = 0;
let greasePencilOnionStrokeCount = 0;
for (const node of snapshot.nodes) {
if (node.type !== "GREASE_PENCIL" || !node.visible || !node.dataId) continue;
const data = greasePencilsById.get(node.dataId);
@@ -288,9 +289,10 @@ function setSnapshot(snapshot: SceneSnapshotIR, buffers: MeshGeometryBuffer[], n
applyGreasePencilTransform(object, node);
root.add(object);
objectById.set(node.id, object);
greasePencilOnionStrokeCount += Number(object.userData.greasePencilOnionStrokeCount ?? 0);
greasePencilCount++;
}
post({ type: "snapshotStatus", nonMeshCount, nonMeshBlockedCount, greasePencilCount, greasePencilBlockedCount });
post({ type: "snapshotStatus", nonMeshCount, nonMeshBlockedCount, greasePencilCount, greasePencilBlockedCount, greasePencilOnionStrokeCount });
const world = snapshot.worlds.find((candidate) => candidate.id === snapshot.scenes[0]?.worldId) ?? snapshot.worlds[0];
const sceneDefinition = snapshot.scenes.find((candidate) => candidate.id === snapshot.sceneId) ?? snapshot.scenes[0];
scene.background = world ? new Color().setRGB(...world.color) : new Color(0x25272b);
@@ -311,7 +313,7 @@ function setSnapshot(snapshot: SceneSnapshotIR, buffers: MeshGeometryBuffer[], n
render();
}
function setSelection(ids: string[]): void {
function setSelection(ids: string[], elements: Array<{ dataId: string; kind: NonMeshElementKind; index: number }>): void {
const selected = new Set(ids);
const visited = new Set<Object3D>();
for (const [id, object] of objectById) {
@@ -329,6 +331,15 @@ function setSelection(ids: string[]): void {
}
}
}
const selection = new Map<string, Map<NonMeshElementKind, Set<number>>>();
for (const element of elements) {
const kinds = selection.get(element.dataId) ?? new Map<NonMeshElementKind, Set<number>>();
const indices = kinds.get(element.kind) ?? new Set<number>();
indices.add(element.index);
kinds.set(element.kind, indices);
selection.set(element.dataId, kinds);
}
if (root) applyNonMeshElementSelection(root, selection);
render();
}
@@ -411,7 +422,7 @@ workerScope.onmessage = (event): void => {
else if (message.type === "snapshot") setSnapshot(message.snapshot, message.geometryBuffers, message.nonMeshGeometryBuffers);
else if (message.type === "textureAssets") applyTextureAssets(message.assets);
else if (message.type === "resize") resize(message.width, message.height, message.pixelRatio);
else if (message.type === "selection") setSelection(message.objectIds);
else if (message.type === "selection") setSelection(message.objectIds, message.elements);
else if (message.type === "interaction") {
editMode = message.editMode;
selectionMode = message.selectionMode;

View File

@@ -366,6 +366,30 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
for (const field of ["useTemperature", "castsShadow"] as const) if (properties[field] !== undefined && typeof properties[field] !== "boolean") throw report("RENDER_PROPERTY_INVALID", `Light ${field} must be boolean`);
return;
}
case "setCameraProperties": {
const camera = currentSnapshot?.cameras.find((candidate) => candidate.id === payload.dataId);
const properties = payload.properties;
if (!camera || !validPropertyKeys(properties, ["projection", "lensMm", "sensorWidthMm", "sensorHeightMm", "sensorFit", "shift", "near", "far", "orthoScale", "depthOfField"])) throw report("RENDER_PROPERTY_INVALID", "Camera target or properties are invalid");
if (properties.projection !== undefined && !["PERSPECTIVE", "ORTHOGRAPHIC"].includes(properties.projection)) throw report("RENDER_PROPERTY_INVALID", "Camera projection is invalid");
for (const [field, minimum, maximum] of [["lensMm", 0.1, 10_000], ["sensorWidthMm", 0.1, 10_000], ["sensorHeightMm", 0.1, 10_000], ["near", 0.0001, 1e9], ["far", 0.0002, 1e12], ["orthoScale", 0.0001, 1e9]] as const) {
const value = properties[field];
if (value !== undefined && !boundedNumber(value, minimum, maximum)) throw report("RENDER_PROPERTY_INVALID", `Camera ${field} is outside the bounded range`);
}
if (properties.near !== undefined && properties.far !== undefined && properties.far <= properties.near) throw report("RENDER_PROPERTY_INVALID", "Camera far clip must exceed near clip");
if (properties.sensorFit !== undefined && ![0, 1, 2].includes(properties.sensorFit)) throw report("RENDER_PROPERTY_INVALID", "Camera sensorFit is invalid");
if (properties.shift !== undefined && (!Array.isArray(properties.shift) || properties.shift.length !== 2 || properties.shift.some((value) => !boundedNumber(value, -1000, 1000)))) throw report("RENDER_PROPERTY_INVALID", "Camera shift is invalid");
if (properties.depthOfField !== undefined) {
const dof = properties.depthOfField;
if (!validPropertyKeys(dof, ["enabled", "focusDistance", "apertureFStop", "apertureBlades", "apertureRotation", "apertureRatio"])) throw report("RENDER_PROPERTY_INVALID", "Camera depthOfField is invalid");
if (dof.enabled !== undefined && typeof dof.enabled !== "boolean") throw report("RENDER_PROPERTY_INVALID", "Camera depthOfField.enabled must be boolean");
for (const [field, minimum, maximum] of [["focusDistance", 0, 1e9], ["apertureFStop", 0.01, 1000], ["apertureRotation", -Math.PI * 2, Math.PI * 2], ["apertureRatio", 0.01, 100]] as const) {
const value = dof[field];
if (value !== undefined && !boundedNumber(value, minimum, maximum)) throw report("RENDER_PROPERTY_INVALID", `Camera depthOfField.${field} is outside the bounded range`);
}
if (dof.apertureBlades !== undefined && (!Number.isSafeInteger(dof.apertureBlades) || dof.apertureBlades < 0 || dof.apertureBlades > 64)) throw report("RENDER_PROPERTY_INVALID", "Camera depthOfField.apertureBlades is outside the bounded range");
}
return;
}
case "setWorldProperties": {
const world = currentSnapshot?.worlds.find((candidate) => candidate.id === payload.dataId);
const properties = payload.properties;