feat(web): complete product workflow implementation
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

This commit is contained in:
mes123456
2026-09-06 08:33:27 +08:00
parent 7e3f6da78b
commit 1184a51ddf
39 changed files with 3736 additions and 160 deletions

View File

@@ -4,9 +4,11 @@ import { WebEngineClient } from "../engine-client/WebEngineClient";
import { loadWebEngineManifest, verifyWasmResource } from "../../../protocol/manifest";
import type { ProgressEvent } from "../../../protocol/progress";
import type { SceneSnapshotIR } from "../../../protocol/scene-ir";
import type { SequencerStripIR, SequencerStripType, SequencerTimelineIR } from "../../../protocol/sequencer";
import type { GPUTextureAsset } from "../../../protocol/render-assets";
import { collectGPUTextureAssetRequests, createGPUTextureAsset, RenderAssetValidationError } from "../../../protocol/render-assets";
import type { MeshEditOperation, MeshElementMode, MeshGeometryBuffer, WebEngineEditCommand, WebEngineLODLevelResult } from "../../../protocol/web-engine";
import type { MeshEditOperation, MeshElementMode, MeshGeometryBuffer, MeshPrimitive, WebEngineEditCommand, WebEngineLODLevelResult } from "../../../protocol/web-engine";
import type { BlenderOperatorDescriptor, BlenderOperatorPropertySpec, BlenderOperatorPropertyValue } from "../../../protocol/blender-operator";
import { coreSceneCommands } from "../../../protocol/core-scene-commands";
import { meshAuthoringCommands } from "../../../protocol/mesh-authoring-commands";
import { nonMeshGeometryCommands } from "../../../protocol/nonmesh-geometry-commands";
@@ -19,6 +21,8 @@ import { parseLibraryMainAppendRequest, type LibraryMainAppendRequestIR } from "
import type { RenderCapabilityRequest } from "../../../protocol/render-capabilities";
import { compositorCommands } from "../../../protocol/compositor-commands";
import { geometryNodeCommands } from "../../../protocol/geometry-node-commands";
import { GEOMETRY_NODE_ALLOWLIST, type GeometryNodeGraphIR } from "../../../protocol/geometry-nodes";
import type { ShaderGraphIR, ShaderSocketIR } from "../../../protocol/shader-graph";
import { physicsCommands } from "../../../protocol/physics-commands";
import { WEB_ENGINE_COMMAND_CAPABILITIES } from "../../../protocol/web-engine-capabilities";
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
@@ -34,7 +38,7 @@ import type { GreasePencilPointPreview, GreasePencilPointRef } from "../three-ad
import { buildLODCacheKey } from "../three-adapter/lod";
import { decodeLODGeometry, encodeLODGeometry } from "../../../protocol/mesh-cache";
import type { LODCacheRecord } from "../../../protocol/lod";
import { modifierStackHash } from "../../../protocol/modifier";
import { modifierParameterMetadata, modifierStackHash } from "../../../protocol/modifier";
import { exportGLB } from "../../../protocol/glb-export";
import { mapEvaluatedNonMeshForExport } from "../../../protocol/nonmesh-export";
import { normalizeProjectAssetPath } from "../../../protocol/asset-path";
@@ -63,7 +67,7 @@ import {
type NanoVDBViewportProjectContextIR,
} from "../volume/nanovdb-viewport";
import { composePaintColorPatch, composePaintWeightPatch, createSculptMeshAttributes, createSculptStroke } from "../../../protocol/paint";
import type { SculptBrush } from "../../../protocol/sculpt";
import type { SculptBrush, SculptStrokeSampleIR } from "../../../protocol/sculpt";
import { createDefaultWebWorkspaceState, reduceUICommand, type EditorType, type UICommand, type WorkspaceId } from "../../../protocol/ui-schema";
import { parseEditorWorkflow, type EditorWorkflowIR } from "../../../protocol/editor-workflow";
import { importExternalVFontIntoMain } from "../fonts/external-vfont-import";
@@ -104,6 +108,112 @@ function StorageBudgetPanel({ budget, onCleanup }: { budget: StorageBudgetResult
return <section className="storage-budget-panel" data-testid="storage-budget-panel" data-project-id={budget?.projectId ?? ""} data-total-bytes={budget?.totalBytes ?? 0} aria-label="存储预算"><strong>存储预算</strong>{values.map(([label, bytes]) => <span key={label} data-category={label} data-bytes={bytes}>{label} {formatStorageBytes(bytes)}</span>)}<output>合计 {formatStorageBytes(budget?.totalBytes ?? 0)}</output><button type="button" data-testid="cleanup-project-assets" aria-label="清理项目孤儿资源" disabled={!budget?.projectId || budget.projectId === "untitled"} onClick={onCleanup}>清理孤儿资源</button></section>;
}
interface OperatorPaletteCommand {
id: string;
label: string;
keywords?: string;
execute?: () => unknown;
}
function BlenderOperatorPalette({ client, commands, onClose, onApplied }: {
client: WebEngineClient | null;
commands: ReadonlyArray<OperatorPaletteCommand>;
onClose: () => void;
onApplied: (result: Awaited<ReturnType<WebEngineClient["invokeBlenderOperator"]>>, descriptor: BlenderOperatorDescriptor) => void;
}) {
const [query, setQuery] = useState("");
const [catalog, setCatalog] = useState<BlenderOperatorDescriptor[]>([]);
const [selected, setSelected] = useState<BlenderOperatorDescriptor | null>(null);
const [values, setValues] = useState<Record<string, BlenderOperatorPropertyValue>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [executing, setExecuting] = useState(false);
useEffect(() => {
let active = true;
if (!client) {
setLoading(false);
return () => { active = false; };
}
setLoading(true);
void client.listBlenderOperators().then(({ operators }) => {
if (active) setCatalog(operators);
}).catch((reason: unknown) => {
if (active) setError(reason instanceof Error ? reason.message : String(reason));
}).finally(() => {
if (active) setLoading(false);
});
return () => { active = false; };
}, [client]);
const normalizedQuery = query.trim().toLowerCase();
const staticMatches = commands.filter((command) => `${command.label} ${command.keywords ?? ""}`.toLowerCase().includes(normalizedQuery));
const nativeMatches = catalog.filter((operator) => `${operator.id} ${operator.label} ${operator.family} ${operator.description ?? ""}`.toLowerCase().includes(normalizedQuery));
const chooseOperator = (operator: BlenderOperatorDescriptor): void => {
const next: Record<string, BlenderOperatorPropertyValue> = {};
for (const property of operator.properties) {
if (property.defaultValue !== undefined) next[property.name] = property.defaultValue;
else if (property.type === "enum" && property.enumItems?.[0]) next[property.name] = property.enumItems[0].value;
else if (property.type === "boolean") next[property.name] = false;
else next[property.name] = "";
}
setValues(next);
setSelected(operator);
setError(null);
};
const renderProperty = (property: BlenderOperatorPropertySpec): React.ReactNode => {
const value = values[property.name];
const label = property.label ?? property.name;
if (property.type === "boolean") {
return <label key={property.name} className="operator-property"><input type="checkbox" checked={Boolean(value)} onChange={(event) => setValues((current) => ({ ...current, [property.name]: event.target.checked }))} />{label}</label>;
}
if (property.type === "enum") {
return <label key={property.name} className="operator-property">{label}<select value={String(value ?? "")} onChange={(event) => setValues((current) => ({ ...current, [property.name]: event.target.value }))}>{(property.enumItems ?? []).map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label>;
}
return <label key={property.name} className="operator-property">{label}<input type={property.type === "number" ? "number" : "text"} value={String(value ?? "")} min={property.min} max={property.max} step={property.type === "number" ? "any" : undefined} required={property.required} onChange={(event) => setValues((current) => ({ ...current, [property.name]: property.type === "number" ? event.target.value : event.target.value }))} /></label>;
};
const executeOperator = async (event: React.FormEvent): Promise<void> => {
event.preventDefault();
if (!selected || !client) return;
const properties: Record<string, BlenderOperatorPropertyValue> = {};
for (const property of selected.properties) {
const value = values[property.name];
if (property.required && (value === undefined || value === "")) {
setError(`${property.label ?? property.name} 为必填项`);
return;
}
if (property.type === "number") {
if (value === "" || value === undefined) continue;
const numberValue = Number(value);
if (!Number.isFinite(numberValue)) {
setError(`${property.label ?? property.name} 必须是数字`);
return;
}
properties[property.name] = numberValue;
}
else if (value !== undefined && value !== "") properties[property.name] = value;
}
setExecuting(true);
setError(null);
try {
const result = await client.invokeBlenderOperator({ operator: selected.id, properties });
onApplied(result, selected);
onClose();
}
catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason));
}
finally {
setExecuting(false);
}
};
return <div className="operator-search-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}><section className="operator-search-dialog" role="dialog" aria-modal="true" aria-label="操作搜索"><header><strong>操作搜索</strong><button type="button" aria-label="关闭操作搜索" onClick={onClose}>×</button></header><input autoFocus value={query} placeholder="搜索 Blender 操作..." aria-label="搜索 Blender 操作" onChange={(event) => setQuery(event.target.value)} />{loading ? <p>正在加载原生操作目录…</p> : null}{error ? <p role="alert">{error}</p> : null}<div className="operator-search-results" role="listbox">{staticMatches.map((command) => <button key={`command:${command.id}`} type="button" onClick={() => { onClose(); void command.execute?.(); }}>{command.label}</button>)}{nativeMatches.map((operator) => <button key={`native:${operator.id}`} type="button" onClick={() => chooseOperator(operator)}>{operator.label}<small>{operator.id}</small></button>)}{!loading && staticMatches.length === 0 && nativeMatches.length === 0 ? <p>没有匹配的操作</p> : null}</div>{selected ? <form className="operator-property-form" onSubmit={(event) => void executeOperator(event)}><h3>{selected.label}</h3>{selected.properties.map(renderProperty)}<button type="submit" disabled={executing || !client}>{executing ? "执行中…" : "执行"}</button></form> : null}</section></div>;
}
async function sha256Hex(data: ArrayBuffer): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("");
@@ -685,6 +795,16 @@ function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVer
const [materialCoat, setMaterialCoat] = useState(0);
const [materialCoatRoughness, setMaterialCoatRoughness] = useState(0.03);
const [materialEmissionStrength, setMaterialEmissionStrength] = useState(1);
const [geometryGraphDraft, setGeometryGraphDraft] = useState<GeometryNodeGraphIR | null>(null);
const [geometryNodeType, setGeometryNodeType] = useState<string>(GEOMETRY_NODE_ALLOWLIST[2]);
const [geometryNodeName, setGeometryNodeName] = useState("Web Node");
const [geometryLinkFrom, setGeometryLinkFrom] = useState("");
const [geometryLinkTo, setGeometryLinkTo] = useState("");
const [shaderGraphDraft, setShaderGraphDraft] = useState<ShaderGraphIR | null>(null);
const [shaderNodeType, setShaderNodeType] = useState<"RGB" | "VALUE" | "MATH" | "IMAGE_TEXTURE" | "PRINCIPLED" | "MATERIAL_OUTPUT">("VALUE");
const [shaderNodeName, setShaderNodeName] = useState("Web Node");
const [shaderLinkFrom, setShaderLinkFrom] = useState("");
const [shaderLinkTo, setShaderLinkTo] = useState("");
const [renameValue, setRenameValue] = useState("");
const [paintColor, setPaintColor] = useState("#cc6633");
const [paintWeight, setPaintWeight] = useState(1);
@@ -702,6 +822,8 @@ function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVer
const [sculptMirrorObjectSpace, setSculptMirrorObjectSpace] = useState(false);
const [sculptMaskValue, setSculptMaskValue] = useState(0);
const [sculptActiveFaceSet, setSculptActiveFaceSet] = useState(1);
const [sculptRemeshVoxelSize, setSculptRemeshVoxelSize] = useState(0.05);
const [sculptCursorSamples, setSculptCursorSamples] = useState<SculptStrokeSampleIR[]>([]);
const [greasePencilLayerId, setGreasePencilLayerId] = useState("");
const [greasePencilLayerName, setGreasePencilLayerName] = useState("Web Layer");
const [greasePencilStrokeIndex, setGreasePencilStrokeIndex] = useState(0);
@@ -751,6 +873,56 @@ function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVer
const activeWorld = snapshot?.worlds.find((world) => world.id === activeNode?.dataId);
const activeScene = snapshot?.scenes.find((scene) => scene.id === snapshot.sceneId) ?? snapshot?.scenes[0];
const packedImages = snapshot?.images.filter((image) => image.packed && image.assetId) ?? [];
useEffect(() => {
let drawing = false;
const viewportPoint = (event: PointerEvent): SculptStrokeSampleIR | null => {
const viewport = document.querySelector<HTMLElement>("[data-editor='3D Viewport']");
const target = event.target;
if (!viewport || !(target instanceof Node) || !viewport.contains(target)) return null;
const rect = viewport.getBoundingClientRect();
const width = Math.max(1, rect.width);
const height = Math.max(1, rect.height);
const x = Math.max(0, Math.min(1, (event.clientX - rect.left) / width));
const y = Math.max(0, Math.min(1, (event.clientY - rect.top) / height));
return {
position: [x * 2 - 1, (1 - y) * 2 - 1, 0],
normal: [0, 0, 1],
radius: sculptRadius,
strength: sculptStrength,
pressure: Math.max(0, Math.min(1, event.pressure || sculptPressure)),
time: event.timeStamp / 1000,
};
};
const beginStroke = (event: PointerEvent): void => {
if (event.button !== 0) return;
const point = viewportPoint(event);
if (!point) return;
drawing = true;
setSculptCursorSamples([point]);
};
const extendStroke = (event: PointerEvent): void => {
if (!drawing && event.buttons === 0) return;
const point = viewportPoint(event);
if (!point) return;
drawing = true;
setSculptCursorSamples((current) => {
const previous = current[current.length - 1];
if (previous && Math.hypot(point.position[0] - previous.position[0], point.position[1] - previous.position[1]) < 0.002) return current;
return [...current, point].slice(-2048);
});
};
const endStroke = (): void => { drawing = false; };
window.addEventListener("pointerdown", beginStroke);
window.addEventListener("pointermove", extendStroke);
window.addEventListener("pointerup", endStroke);
window.addEventListener("pointercancel", endStroke);
return () => {
window.removeEventListener("pointerdown", beginStroke);
window.removeEventListener("pointermove", extendStroke);
window.removeEventListener("pointerup", endStroke);
window.removeEventListener("pointercancel", endStroke);
};
}, [sculptPressure, sculptRadius, sculptStrength]);
useEffect(() => {
if (!activeMaterial) return;
setMaterialColor(`#${activeMaterial.baseColor.slice(0, 3).map((component) => Math.round(Math.max(0, Math.min(1, component)) * 255).toString(16).padStart(2, "0")).join("")}`);
@@ -763,6 +935,18 @@ function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVer
setMaterialCoatRoughness(activeMaterial.coatRoughness ?? 0.03);
setMaterialEmissionStrength(activeMaterial.emissionStrength ?? 1);
}, [activeMaterial]);
useEffect(() => {
if (activeGeometryGraph) setGeometryGraphDraft(JSON.parse(JSON.stringify(activeGeometryGraph)) as GeometryNodeGraphIR);
else if (activeMesh) setGeometryGraphDraft(geometryNodeCommands.createGraph(activeMesh.id, `${activeMesh.name} Geometry`));
else setGeometryGraphDraft(null);
}, [activeGeometryGraph?.id, activeGeometryGraph?.graphHash, activeMesh?.id]);
useEffect(() => {
if (!activeMaterial) {
setShaderGraphDraft(null);
return;
}
setShaderGraphDraft(shaderGraphCommands.setPrincipledGraph(activeMaterial.id, activeMaterial.baseColor, activeMaterial.roughness, activeMaterial.metallic));
}, [activeMaterial?.id]);
useEffect(() => setRenameValue(activeNode?.name ?? ""), [activeNode?.id]);
useEffect(() => setFontBody(activeFont?.text ?? ""), [activeFont?.id, activeFont?.text]);
useEffect(() => setFontLinks(activeFont?.fontLinks ?? { regular: "", bold: "", italic: "", boldItalic: "" }), [activeFont?.id, activeFont?.fontLinks]);
@@ -836,13 +1020,31 @@ function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVer
};
const applySculptStroke = (): void => {
if (!activeMesh) return;
onCommand({ type: "sculptStroke", stroke: createSculptStroke(activeMesh.id, sculptBrush, sculptRadius, sculptStrength, sculptPressure, sculptSymmetry, sculptMirrorObjectSpace) });
onCommand({ type: "sculptStroke", stroke: createSculptStroke(activeMesh.id, sculptBrush, sculptRadius, sculptStrength, sculptPressure, sculptSymmetry, sculptMirrorObjectSpace, sculptCursorSamples) });
setSculptCursorSamples([]);
};
const applySculptRemesh = (): void => {
if (!client || !activeMesh) return;
void client.invokeBlenderOperator({
operator: "object.voxel_remesh",
properties: { objectId: activeMesh.id, voxel_size: sculptRemeshVoxelSize },
}).catch(() => undefined);
};
const applySculptMeshAttributes = (): void => {
if (!activeMesh || !snapshot) return;
const faceCount = (activeMesh as typeof activeMesh & { faceCount?: number }).faceCount ?? 0;
onCommand({ type: "setSculptMeshAttributes", attributes: createSculptMeshAttributes(activeMesh.id, activeMesh.vertexCount, faceCount, sculptMaskValue, sculptActiveFaceSet, snapshot.revision) });
};
const commitGeometryGraph = (): void => {
if (activeMesh && geometryGraphDraft) onCommand(geometryNodeCommands.setGraph(activeMesh.id, geometryGraphDraft));
};
const commitShaderGraph = (): void => {
if (activeMaterial && shaderGraphDraft) onCommand({ type: "setShaderGraph", materialId: activeMaterial.id, graph: shaderGraphDraft });
};
const parseGraphSocket = (value: string): { nodeId: string; socketId: string } | null => {
const separator = value.lastIndexOf(":");
return separator > 0 ? { nodeId: value.slice(0, separator), socketId: value.slice(separator + 1) } : null;
};
const translateGreasePencilPoint = (): void => {
if (!activeGreasePencil || !activeGreasePencilFrame || !snapshot) return;
const selectedPoints = greasePencilPointSelection
@@ -1096,6 +1298,87 @@ function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVer
<div className="property-section" data-testid="font-links-workbench" data-command="setFontLinks"><h3>Font Style Links</h3>{(["regular", "bold", "italic", "boldItalic"] as const).map((slot) => { const selected = fontLinks[slot]; const resources = snapshot?.vfonts ?? []; return <label key={slot}>{slot} <select aria-label={`Font ${slot} link`} value={selected} disabled={resources.length === 0} onChange={(event) => setFontLink(slot, event.target.value)}><option value="">Default</option>{selected && !resources.some((font) => font.id === selected) ? <option value={selected}>{selected}</option> : null}{resources.map((font) => <option key={font.id} value={font.id}>{font.name}{font.packed ? " (packed)" : ""}</option>)}</select></label>; })}<div className="property-actions"><button type="button" disabled={!snapshot?.vfonts?.length} onClick={applyFontLinks}>Apply Links</button></div><output>{Object.entries(fontLinks).map(([slot, fontId]) => `${slot}: ${fontId || "default"}`).join(" · ") || "No explicit style links"}</output></div>
</> : null}
{activeVolume ? <div className="property-section" data-testid="volume-workbench"><h3>Volume</h3><output>{activeVolume.sourcePath ?? "No source path"}</output><output>{activeVolume.volumeGrids?.length ?? 0} grids</output>{activeVolume.volumeProperties ? <><output>Density {activeVolume.volumeProperties.displayDensity.toFixed(3)} · Step {activeVolume.volumeProperties.stepSize.toFixed(4)}</output><div className="property-actions"><button type="button" onClick={() => onCommand(nonMeshGeometryCommands.setVolumeProperties(activeVolume.id, activeVolume.sourcePath ?? "", Math.min(1e6, activeVolume.volumeProperties!.displayDensity + 0.1), activeVolume.volumeProperties!.interpolation, activeVolume.volumeProperties!.stepSize))}>Increase Density</button><button type="button" onClick={() => onCommand(nonMeshGeometryCommands.setVolumeProperties(activeVolume.id, activeVolume.sourcePath ?? "", activeVolume.volumeProperties!.displayDensity, activeVolume.volumeProperties!.interpolation === "LINEAR" ? "NEAREST" : "LINEAR", activeVolume.volumeProperties!.stepSize))}>Toggle Interpolation</button></div></> : <output>Volume properties unavailable</output>}</div> : null}
{activeNode?.type === "ARMATURE" ? (() => {
const armature = snapshot?.armatures?.find((item) => item.objectId === activeNode.id || item.id === activeNode.dataId);
if (!armature) return null;
const commandBones = armature.bones.map((bone) => ({
name: bone.name,
parentName: armature.bones.find((candidate) => candidate.id === bone.parentId)?.name ?? null,
head: bone.head,
tail: bone.tail,
roll: bone.roll ?? 0,
connected: bone.connected ?? false,
}));
return <div className="property-section" data-testid="armature-authoring-workbench">
<h3>Armature</h3>
<output>{armature.bones.length} bones</output>
<form className="property-grid" onSubmit={(event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const name = String(data.get("newBoneName") ?? "").trim();
if (!name) return;
const number = (field: string) => Number(data.get(field));
onCommand({ type: "setArmatureBones", objectId: activeNode.id, bones: [...commandBones, {
name,
parentName: String(data.get("newBoneParent") ?? "") || null,
head: [number("newHeadX"), number("newHeadY"), number("newHeadZ")],
tail: [number("newTailX"), number("newTailY"), number("newTailZ")],
roll: number("newRoll"),
connected: data.get("newConnected") === "on",
}] });
}}>
<label>Name <input name="newBoneName" defaultValue={`Bone.${String(armature.bones.length + 1).padStart(3, "0")}`} /></label>
<label>Parent <select name="newBoneParent" defaultValue=""><option value="">None</option>{armature.bones.map((bone) => <option key={bone.id} value={bone.name}>{bone.name}</option>)}</select></label>
{(["X", "Y", "Z"] as const).map((axis, index) => <label key={`new-head-${axis}`}>Head {axis} <input name={`newHead${axis}`} type="number" step="0.01" defaultValue={index === 2 ? 0 : 0} /></label>)}
{(["X", "Y", "Z"] as const).map((axis, index) => <label key={`new-tail-${axis}`}>Tail {axis} <input name={`newTail${axis}`} type="number" step="0.01" defaultValue={index === 2 ? 1 : 0} /></label>)}
<label>Roll <input name="newRoll" type="number" step="0.01" defaultValue={0} /></label>
<label>Connected <input name="newConnected" type="checkbox" /></label>
<div className="property-actions"><button type="submit">Add Bone</button></div>
</form>
{armature.bones.map((bone) => {
const currentParent = armature.bones.find((candidate) => candidate.id === bone.parentId)?.name ?? "";
return <div className="property-subsection" key={bone.id} data-resource-id={bone.id}>
<h4>{bone.name}</h4>
<form className="property-grid" onSubmit={(event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const number = (field: string) => Number(data.get(field));
const nextName = String(data.get("boneName") ?? "").trim();
onCommand({ type: "setArmatureBones", objectId: activeNode.id, bones: commandBones.map((item) => item.name === bone.name ? {
name: nextName,
parentName: String(data.get("parentName") ?? "") || null,
head: [number("headX"), number("headY"), number("headZ")],
tail: [number("tailX"), number("tailY"), number("tailZ")],
roll: number("roll"),
connected: data.get("connected") === "on",
} : { ...item, parentName: item.parentName === bone.name ? nextName : item.parentName }) });
}}>
<label>Name <input name="boneName" defaultValue={bone.name} /></label>
<label>Parent <select name="parentName" defaultValue={currentParent}><option value="">None</option>{armature.bones.filter((candidate) => candidate.id !== bone.id).map((candidate) => <option key={candidate.id} value={candidate.name}>{candidate.name}</option>)}</select></label>
{(["X", "Y", "Z"] as const).map((axis, index) => <label key={`head-${axis}`}>Head {axis} <input name={`head${axis}`} type="number" step="0.01" defaultValue={bone.head[index]} /></label>)}
{(["X", "Y", "Z"] as const).map((axis, index) => <label key={`tail-${axis}`}>Tail {axis} <input name={`tail${axis}`} type="number" step="0.01" defaultValue={bone.tail[index]} /></label>)}
<label>Roll <input name="roll" type="number" step="0.01" defaultValue={bone.roll ?? 0} /></label>
<label>Connected <input name="connected" type="checkbox" defaultChecked={bone.connected ?? false} /></label>
<div className="property-actions"><button type="submit">Apply Rest Pose</button><button type="button" onClick={() => onCommand({ type: "setArmatureBones", objectId: activeNode.id, bones: commandBones.filter((item) => item.name !== bone.name).map((item) => item.parentName === bone.name ? { ...item, parentName: null, connected: false } : item) })}>Remove Bone</button></div>
</form>
<form className="property-grid" onSubmit={(event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const number = (field: string) => Number(data.get(field));
onCommand({ type: "setPoseBoneTransform", objectId: activeNode.id, boneName: bone.name,
translation: [number("locationX"), number("locationY"), number("locationZ")],
rotationEuler: [number("rotationX"), number("rotationY"), number("rotationZ")],
scale: [number("scaleX"), number("scaleY"), number("scaleZ")] });
}}>
{(["X", "Y", "Z"] as const).map((axis) => <label key={`location-${axis}`}>Location {axis} <input name={`location${axis}`} type="number" step="0.01" defaultValue={0} /></label>)}
{(["X", "Y", "Z"] as const).map((axis) => <label key={`rotation-${axis}`}>Rotation {axis} <input name={`rotation${axis}`} type="number" step="0.01" defaultValue={0} /></label>)}
{(["X", "Y", "Z"] as const).map((axis) => <label key={`scale-${axis}`}>Scale {axis} <input name={`scale${axis}`} type="number" min="0.0001" step="0.01" defaultValue={1} /></label>)}
<div className="property-actions"><button type="submit">Apply Pose</button></div>
</form>
</div>;
})}
</div>;
})() : null}
{activeMetaball ? <div className="property-section" data-testid="metaball-workbench"><h3>Metaball</h3><output>{activeMetaball.elements?.length ?? 0} elements</output>{activeMetaball.elements?.length ? <div className="property-actions"><button type="button" onClick={() => onCommand(nonMeshGeometryCommands.setMetaballElements(activeMetaball.id, activeMetaball.elements!.map((element, index) => index === 0 ? { ...element, radius: Math.min(100000, element.radius + 0.05) } : element)))}>Increase Active Radius</button></div> : <output>No editable elements</output>}</div> : null}
{activeNonMeshData ? <div className="property-section" data-testid="nonmesh-advanced-workbench" data-command="deleteNonMeshData"><h3>Non-Mesh Data</h3><output>{activeNonMeshData.name} · {activeNonMeshData.type} · {activeNonMeshData.pointCount} points</output><button type="button" onClick={() => onCommand(nonMeshGeometryCommands.deleteNonMeshData(activeNonMeshData.id))}>Delete Active Data</button></div> : null}
<div className="property-section" data-testid="render-capability-workbench"><h3>Render Capabilities</h3><button type="button" onClick={() => onQueryRenderCapability(renderCompositorCommands.boundedShaderGraph())}>检查 Shader</button><button type="button" onClick={() => onQueryRenderCapability(renderCompositorCommands.webGPUBackend())}>检查 WebGPU</button><button type="button" onClick={() => onQueryRenderCapability(renderCompositorCommands.postProcess(["FXAA", "TAA"]))}>检查 Post Process</button>{snapshot?.scenes[0]?.compositorGraph ? <button type="button" onClick={() => { compositorCommands.evaluate(snapshot.scenes[0].compositorGraph!); }}>准备 Compositor 图</button> : null}</div>
@@ -1141,7 +1424,7 @@ function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVer
<output>{activeGreasePencil.layerCount} layers / {activeGreasePencil.frameCount} frames / {activeGreasePencil.strokeCount} strokes</output>
</div> : null}
{activeMesh && activeNode ? <div className="property-section" data-testid="paint-editor"><h3>Paint</h3><label>Vertex color <input aria-label="Paint vertex color" type="color" value={paintColor} onChange={(event) => setPaintColor(event.target.value)} /></label><label>Vertex group <input aria-label="Paint vertex group" value={paintGroup} onChange={(event) => setPaintGroup(event.target.value)} /></label><label>Weight <input aria-label="Paint vertex weight" type="range" min="0" max="1" step="0.01" value={paintWeight} onChange={(event) => setPaintWeight(Number(event.target.value))} /><output>{paintWeight.toFixed(2)}</output></label><label>Selection mask <input aria-label="Paint selection mask" type="range" min="0" max="1" step="0.01" value={paintSelectionMask} onChange={(event) => setPaintSelectionMask(Number(event.target.value))} /><output>{paintSelectionMask.toFixed(2)}</output></label><label>Normalize weights <input aria-label="Paint normalize weights" type="checkbox" checked={paintNormalize} onChange={(event) => setPaintNormalize(event.target.checked)} /></label><label>Limit influences <select aria-label="Paint influence limit" value={paintLimit} onChange={(event) => setPaintLimit(Number(event.target.value))}>{[1, 2, 3, 4, 8].map((limit) => <option key={limit} value={limit}>{limit}</option>)}</select></label><label>Mirror X/Y/Z <input aria-label="Paint mirror weights" type="checkbox" checked={paintMirror} onChange={(event) => setPaintMirror(event.target.checked)} /></label>{paintMirror ? <label>Mirror axis <select aria-label="Paint mirror axis" value={paintMirrorAxis} onChange={(event) => setPaintMirrorAxis(Number(event.target.value) as 0 | 1 | 2)}><option value={0}>X</option><option value={1}>Y</option><option value={2}>Z</option></select></label> : null}<div className="property-actions"><button type="button" disabled={selectedVertexIndices.length === 0} onClick={() => { const rgb = [1, 3, 5].map((offset) => Number.parseInt(paintColor.slice(offset, offset + 2), 16) / 255) as [number, number, number]; onCommand({ type: "setVertexColors", meshId: activeMesh.id, attributeName: "WebPaintColor", domain: "POINT", indices: selectedVertexIndices, colors: selectedVertexIndices.flatMap(() => [...rgb, 1]) }); }}>Apply Color</button><button type="button" disabled={selectedVertexIndices.length === 0 || !paintGroup} onClick={() => onCommand({ type: "setVertexWeights", objectId: activeNode.id, vertexGroup: paintGroup, indices: selectedVertexIndices, values: selectedVertexIndices.map(() => paintWeight), normalize: paintNormalize, limit: paintLimit, mirror: paintMirror, ...(paintMirror ? { mirrorAxis: paintMirrorAxis, mirrorTolerance: 1e-4 } : {}) })}>Apply Weight</button><button type="button" disabled={selectedVertexIndices.length === 0 || paintSelectionMask <= 0} onClick={blendPaintColor}>Blend Color</button><button type="button" disabled={selectedVertexIndices.length === 0 || !paintGroup || paintSelectionMask <= 0} onClick={blendPaintWeight}>Blend Weight</button></div><output>{selectedVertexIndices.length} selected vertices</output><output data-testid="paint-color-attribute">{activeMesh.attributes?.some((attribute) => attribute.name === "WebPaintColor" && attribute.domain === "POINT") ? "WebPaintColor POINT" : "No WebPaintColor"}</output><output data-testid="paint-vertex-group">{activeMesh.vertexGroups?.some((group) => group.name === paintGroup) ? paintGroup : "No paint group"}</output></div> : null}
{activeMesh ? <div className="property-section" data-testid="sculpt-editor" data-command="sculptStroke,setSculptMeshAttributes"><h3>Sculpt</h3><label>Brush <select aria-label="Sculpt brush" value={sculptBrush} onChange={(event) => setSculptBrush(event.target.value as SculptBrush)}><option value="DRAW">Draw</option><option value="INFLATE">Inflate</option><option value="GRAB">Grab</option><option value="SMOOTH">Smooth</option></select></label><label>Radius <input aria-label="Sculpt radius" type="number" min="0.001" step="0.01" value={sculptRadius} onChange={(event) => setSculptRadius(Math.max(0.001, Number(event.target.value) || 0.001))} /></label><label>Strength <input aria-label="Sculpt strength" type="range" min="-1" max="1" step="0.01" value={sculptStrength} onChange={(event) => setSculptStrength(Number(event.target.value))} /><output>{sculptStrength.toFixed(2)}</output></label><label>Pressure <input aria-label="Sculpt pressure" type="range" min="0" max="1" step="0.01" value={sculptPressure} onChange={(event) => setSculptPressure(Number(event.target.value))} /><output>{sculptPressure.toFixed(2)}</output></label><label>Symmetry X/Y/Z <span className="property-actions">{([0, 1, 2] as const).map((axis) => <label key={axis}><input aria-label={`Sculpt symmetry ${axis === 0 ? "X" : axis === 1 ? "Y" : "Z"}`} type="checkbox" checked={sculptSymmetry[axis]} onChange={(event) => setSculptSymmetry((current) => current.map((value, index) => index === axis ? event.target.checked : value) as [boolean, boolean, boolean])} />{axis === 0 ? "X" : axis === 1 ? "Y" : "Z"}</label>)}</span></label><label>Mirror object space <input aria-label="Sculpt mirror object space" type="checkbox" checked={sculptMirrorObjectSpace} onChange={(event) => setSculptMirrorObjectSpace(event.target.checked)} /></label><div className="property-actions"><button type="button" onClick={applySculptStroke}>Apply Sculpt Stroke</button></div><label>Mask <input aria-label="Sculpt mask" type="range" min="0" max="1" step="0.01" value={sculptMaskValue} onChange={(event) => setSculptMaskValue(Number(event.target.value))} /><output>{sculptMaskValue.toFixed(2)}</output></label><label>Active face set <input aria-label="Sculpt active face set" type="number" min="-1" step="1" value={sculptActiveFaceSet} onChange={(event) => setSculptActiveFaceSet(Math.max(-1, Math.floor(Number(event.target.value) || 0)))} /></label><div className="property-actions"><button type="button" disabled={activeMesh.vertexCount <= 0} onClick={applySculptMeshAttributes}>Apply Sculpt Attributes</button></div><output>{activeMesh.vertexCount} vertices · {(activeMesh as typeof activeMesh & { faceCount?: number }).faceCount ?? 0} faces</output></div> : null}
{activeMesh ? <div className="property-section" data-testid="sculpt-editor" data-command="sculptStroke,setSculptMeshAttributes,invokeBlenderOperator"><h3>Sculpt</h3><label>Brush <select aria-label="Sculpt brush" value={sculptBrush} onChange={(event) => setSculptBrush(event.target.value as SculptBrush)}><option value="DRAW">Draw</option><option value="INFLATE">Inflate</option><option value="GRAB">Grab</option><option value="SMOOTH">Smooth</option></select></label><label>Radius <input aria-label="Sculpt radius" type="number" min="0.001" step="0.01" value={sculptRadius} onChange={(event) => setSculptRadius(Math.max(0.001, Number(event.target.value) || 0.001))} /></label><label>Strength <input aria-label="Sculpt strength" type="range" min="-1" max="1" step="0.01" value={sculptStrength} onChange={(event) => setSculptStrength(Number(event.target.value))} /><output>{sculptStrength.toFixed(2)}</output></label><label>Pressure <input aria-label="Sculpt pressure" type="range" min="0" max="1" step="0.01" value={sculptPressure} onChange={(event) => setSculptPressure(Number(event.target.value))} /><output>{sculptPressure.toFixed(2)}</output></label><label>Symmetry X/Y/Z <span className="property-actions">{([0, 1, 2] as const).map((axis) => <label key={axis}><input aria-label={`Sculpt symmetry ${axis === 0 ? "X" : axis === 1 ? "Y" : "Z"}`} type="checkbox" checked={sculptSymmetry[axis]} onChange={(event) => setSculptSymmetry((current) => current.map((value, index) => index === axis ? event.target.checked : value) as [boolean, boolean, boolean])} />{axis === 0 ? "X" : axis === 1 ? "Y" : "Z"}</label>)}</span></label><label>Mirror object space <input aria-label="Sculpt mirror object space" type="checkbox" checked={sculptMirrorObjectSpace} onChange={(event) => setSculptMirrorObjectSpace(event.target.checked)} /></label><div className="property-actions"><button type="button" onClick={applySculptStroke}>Apply Sculpt Stroke ({sculptCursorSamples.length || 3} samples)</button></div><label>Mask <input aria-label="Sculpt mask" type="range" min="0" max="1" step="0.01" value={sculptMaskValue} onChange={(event) => setSculptMaskValue(Number(event.target.value))} /><output>{sculptMaskValue.toFixed(2)}</output></label><label>Active face set <input aria-label="Sculpt active face set" type="number" min="0" step="1" value={sculptActiveFaceSet} onChange={(event) => setSculptActiveFaceSet(Math.max(0, Math.floor(Number(event.target.value) || 0)))} /></label><div className="property-actions"><button type="button" disabled={activeMesh.vertexCount <= 0} onClick={applySculptMeshAttributes}>Apply Sculpt Attributes</button></div><label>Remesh voxel size <input aria-label="Sculpt remesh voxel size" type="number" min="0.001" step="0.001" value={sculptRemeshVoxelSize} onChange={(event) => setSculptRemeshVoxelSize(Math.max(0.001, Number(event.target.value) || 0.001))} /></label><div className="property-actions"><button type="button" disabled={!client} onClick={applySculptRemesh}>Voxel Remesh</button></div><output>{activeMesh.vertexCount} vertices · {(activeMesh as typeof activeMesh & { faceCount?: number }).faceCount ?? 0} faces</output></div> : null}
{activeMesh ? <div className="property-section">
<h3>Material Slots</h3>
<div className="material-slots">{activeMesh.materialSlotIds?.map((id, index) => <span key={`${id}:${index}`}>{index + 1}. {snapshot?.materials.find((material) => material.id === id)?.name ?? "Empty"}</span>)}</div>
@@ -1164,8 +1447,41 @@ function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVer
<label>法线 <select aria-label="材质法线图片" value="" onChange={(event) => event.target.value && onCommand({ type: "setMaterialImageNode", materialId: activeMaterial.id, imageId: event.target.value, usage: "NORMAL", uvMap: activeMesh.activeUVMap ?? undefined })}><option value="">选择 Normal...</option>{snapshot?.images.map((image) => <option key={image.id} value={image.id}>{image.name}</option>)}</select></label>
</> : null}
</div> : null}
{activeMesh && geometryGraphDraft ? (() => {
const outputs = geometryGraphDraft.nodes.flatMap((node) => node.sockets.filter((socket) => socket.direction === "OUTPUT").map((socket) => ({ key: `${node.id}:${socket.id}`, label: `${node.name} · ${socket.name}`, nodeId: node.id, socketId: socket.id })));
const inputs = geometryGraphDraft.nodes.flatMap((node) => node.sockets.filter((socket) => socket.direction === "INPUT").map((socket) => ({ key: `${node.id}:${socket.id}`, label: `${node.name} · ${socket.name}`, nodeId: node.id, socketId: socket.id })));
return <div className="property-section node-graph-authoring" data-testid="geometry-node-authoring">
<h3>Geometry Node Graph</h3>
<div className="node-graph-toolbar"><label>节点类型 <select value={geometryNodeType} onChange={(event) => setGeometryNodeType(event.target.value)}>{GEOMETRY_NODE_ALLOWLIST.map((type) => <option key={type} value={type}>{type}</option>)}</select></label><label>名称 <input value={geometryNodeName} onChange={(event) => setGeometryNodeName(event.target.value)} /></label><button type="button" onClick={() => setGeometryGraphDraft(geometryNodeCommands.addNode(geometryGraphDraft, geometryNodeType, geometryNodeName))}>添加节点</button></div>
<div className="node-graph-link-toolbar"><label>输出 <select value={geometryLinkFrom} onChange={(event) => setGeometryLinkFrom(event.target.value)}><option value="">选择输出…</option>{outputs.map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}</select></label><label>输入 <select value={geometryLinkTo} onChange={(event) => setGeometryLinkTo(event.target.value)}><option value="">选择输入…</option>{inputs.map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}</select></label><button type="button" disabled={!parseGraphSocket(geometryLinkFrom) || !parseGraphSocket(geometryLinkTo)} onClick={() => { const from = parseGraphSocket(geometryLinkFrom); const to = parseGraphSocket(geometryLinkTo); if (from && to) setGeometryGraphDraft(geometryNodeCommands.connect(geometryGraphDraft, { fromNodeId: from.nodeId, fromSocketId: from.socketId, toNodeId: to.nodeId, toSocketId: to.socketId })); }}>连接</button></div>
<div className="node-graph-list">{geometryGraphDraft.nodes.map((node) => <article className="node-graph-node" key={node.id}><header><strong>{node.name}</strong><small>{node.type}</small><button type="button" disabled={node.type === "NodeGroupInput" || node.type === "NodeGroupOutput"} onClick={() => setGeometryGraphDraft(geometryNodeCommands.removeNode(geometryGraphDraft, node.id))}>移除</button></header>{node.sockets.filter((socket) => socket.direction === "INPUT").map((socket) => <label key={socket.id}>{socket.name}{socket.defaultValue !== undefined ? <input type={typeof socket.defaultValue === "boolean" ? "checkbox" : "text"} checked={typeof socket.defaultValue === "boolean" ? socket.defaultValue : undefined} value={typeof socket.defaultValue === "boolean" ? undefined : Array.isArray(socket.defaultValue) ? socket.defaultValue.join(",") : String(socket.defaultValue)} onChange={(event) => { const raw = typeof socket.defaultValue === "boolean" ? event.target.checked : event.target.value; const value = Array.isArray(socket.defaultValue) ? raw.toString().split(",").map((item) => Number(item.trim())).filter((item) => Number.isFinite(item)) : typeof socket.defaultValue === "number" ? Number(raw) : raw; setGeometryGraphDraft(geometryNodeCommands.setSocketValue(geometryGraphDraft, node.id, socket.id, value)); }} /> : <span className="node-graph-socket-empty">linked</span>}</label>)}</article>)}</div>
<div className="node-graph-links">{geometryGraphDraft.links.map((link, index) => <div key={`${link.fromNodeId}:${link.fromSocketId}:${link.toNodeId}:${link.toSocketId}:${index}`}><span>{link.fromNodeId}:{link.fromSocketId} → {link.toNodeId}:{link.toSocketId}</span><button type="button" onClick={() => setGeometryGraphDraft(geometryNodeCommands.disconnect(geometryGraphDraft, link))}>断开</button></div>)}</div>
<button type="button" onClick={commitGeometryGraph}>提交 Geometry 图</button>
</div>;
})() : null}
{activeMaterial && shaderGraphDraft ? (() => {
const outputs = shaderGraphDraft.nodes.flatMap((node) => node.sockets.filter((socket) => socket.direction === "OUTPUT").map((socket) => ({ key: `${node.id}:${socket.id}`, label: `${node.name} · ${socket.name}` })));
const inputs = shaderGraphDraft.nodes.flatMap((node) => node.sockets.filter((socket) => socket.direction === "INPUT").map((socket) => ({ key: `${node.id}:${socket.id}`, label: `${node.name} · ${socket.name}` })));
const renderValue = (nodeId: string, socket: ShaderSocketIR): React.ReactNode => {
if (socket.defaultValue === undefined) return <span className="node-graph-socket-empty">linked</span>;
return <input type={typeof socket.defaultValue === "boolean" ? "checkbox" : "text"} checked={typeof socket.defaultValue === "boolean" ? socket.defaultValue : undefined} value={typeof socket.defaultValue === "boolean" ? undefined : Array.isArray(socket.defaultValue) ? socket.defaultValue.join(",") : String(socket.defaultValue)} onChange={(event) => { const raw = typeof socket.defaultValue === "boolean" ? event.target.checked : event.target.value; const value = Array.isArray(socket.defaultValue) ? raw.toString().split(",").map((item) => Number(item.trim())).filter((item) => Number.isFinite(item)) : typeof socket.defaultValue === "number" ? Number(raw) : raw; setShaderGraphDraft(shaderGraphCommands.setSocketValue(shaderGraphDraft, nodeId, socket.id, value)); }} />;
};
return <div className="property-section node-graph-authoring" data-testid="shader-graph-authoring">
<h3>Shader Graph</h3>
<div className="node-graph-toolbar"><label>节点类型 <select value={shaderNodeType} onChange={(event) => setShaderNodeType(event.target.value as typeof shaderNodeType)}>{(["RGB", "VALUE", "MATH", "IMAGE_TEXTURE", "PRINCIPLED", "MATERIAL_OUTPUT"] as const).map((type) => <option key={type} value={type}>{type}</option>)}</select></label><label>名称 <input value={shaderNodeName} onChange={(event) => setShaderNodeName(event.target.value)} /></label><button type="button" disabled={shaderNodeType === "MATERIAL_OUTPUT" && shaderGraphDraft.nodes.some((node) => node.type === "MATERIAL_OUTPUT")} onClick={() => setShaderGraphDraft(shaderGraphCommands.addNode(shaderGraphDraft, shaderNodeType, shaderNodeName))}>添加节点</button></div>
<div className="node-graph-link-toolbar"><label>输出 <select value={shaderLinkFrom} onChange={(event) => setShaderLinkFrom(event.target.value)}><option value="">选择输出…</option>{outputs.map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}</select></label><label>输入 <select value={shaderLinkTo} onChange={(event) => setShaderLinkTo(event.target.value)}><option value="">选择输入…</option>{inputs.map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}</select></label><button type="button" disabled={!parseGraphSocket(shaderLinkFrom) || !parseGraphSocket(shaderLinkTo)} onClick={() => { const from = parseGraphSocket(shaderLinkFrom); const to = parseGraphSocket(shaderLinkTo); if (from && to) setShaderGraphDraft(shaderGraphCommands.connect(shaderGraphDraft, { fromNodeId: from.nodeId, fromSocketId: from.socketId, toNodeId: to.nodeId, toSocketId: to.socketId })); }}>连接</button></div>
<div className="node-graph-list">{shaderGraphDraft.nodes.map((node) => <article className="node-graph-node" key={node.id}><header><strong>{node.name}</strong><small>{node.type}</small><button type="button" disabled={node.type === "MATERIAL_OUTPUT"} onClick={() => setShaderGraphDraft(shaderGraphCommands.removeNode(shaderGraphDraft, node.id))}>移除</button></header>{node.sockets.filter((socket) => socket.direction === "INPUT").map((socket) => <label key={socket.id}>{socket.name}{renderValue(node.id, socket)}</label>)}</article>)}</div>
<div className="node-graph-links">{shaderGraphDraft.links.map((link, index) => <div key={`${link.fromNodeId}:${link.fromSocketId}:${link.toNodeId}:${link.toSocketId}:${index}`}><span>{link.fromNodeId}:{link.fromSocketId} → {link.toNodeId}:{link.toSocketId}</span><button type="button" onClick={() => setShaderGraphDraft(shaderGraphCommands.disconnect(shaderGraphDraft, link))}>断开</button></div>)}</div>
<button type="button" onClick={commitShaderGraph}>提交 Shader 图</button>
</div>;
})() : null}
{activeNode?.constraints?.length ? <div className="property-section"><h3>Constraints</h3>{activeNode.constraints.map((constraint) => <label key={constraint.name}>{constraint.name}<input type="range" min="0" max="1" step="0.05" value={constraint.influence} onChange={(event) => onCommand({ type: "setConstraint", objectId: activeNode.id, constraintName: constraint.name, enabled: constraint.enabled, influence: Number(event.target.value) })} /><input aria-label={`启用约束 ${constraint.name}`} type="checkbox" checked={constraint.enabled} onChange={(event) => onCommand({ type: "setConstraint", objectId: activeNode.id, constraintName: constraint.name, enabled: event.target.checked, influence: constraint.influence })} /></label>)}</div> : null}
{activeMesh?.modifierStack?.length ? <div className="property-section"><h3>Modifier Stack</h3>{activeMesh.modifierStack.map((modifier) => <div className="modifier-row" key={modifier.uuid}><span>{modifier.name}</span>{(["showViewport", "showRender", "showEditMode", "showOnCage"] as const).map((field, index) => <label key={field}>{["V", "R", "E", "C"][index]}<input type="checkbox" checked={Boolean(modifier[field])} onChange={(event) => { if (field === "showViewport") onSetModifierEnabled(activeMesh.id, modifier.uuid, event.target.checked); else onCommand({ type: "setModifierVisibility", meshId: activeMesh.id, modifierUuid: modifier.uuid, showViewport: modifier.showViewport, showRender: modifier.showRender, showEditMode: Boolean(modifier.showEditMode), showOnCage: Boolean(modifier.showOnCage), [field]: event.target.checked }); }} aria-label={`${["Viewport", "Render", "Edit", "Cage"][index]} ${modifier.name}`} /></label>)}</div>)}</div> : null}
{activeMesh ? (() => {
const meshMetadata = activeMesh as typeof activeMesh & { modifierMetadata?: { types?: string[] } };
const availableTypes = meshMetadata.modifierMetadata?.types?.filter(Boolean) ?? Array.from(new Set((activeMesh.modifierStack ?? []).map((modifier) => modifier.type).filter(Boolean)));
const firstType = availableTypes[0] ?? "";
return <div className="property-section modifier-stack-authoring"><div className="modifier-stack-header"><h3>Modifier Stack</h3><div className="modifier-authoring-controls"><input aria-label="Modifier type" defaultValue={firstType} list="modifier-type-metadata" data-modifier-type-input placeholder="Modifier type" /><datalist id="modifier-type-metadata">{availableTypes.map((type) => <option key={type} value={type} />)}</datalist><button type="button" onClick={(event) => { const input = event.currentTarget.parentElement?.querySelector<HTMLInputElement>("[data-modifier-type-input]"); const modifierType = input?.value.trim() || firstType; if (modifierType) onCommand({ type: "addModifier", meshId: activeMesh.id, modifierType }); }}>Add</button></div></div>{activeMesh.modifierStack?.map((modifier, index) => <div className="modifier-row" key={modifier.uuid}><div className="modifier-row-heading"><span>{modifier.name}</span><span className="modifier-type">{modifier.type}</span><div className="modifier-order-controls"><button type="button" aria-label={`Move ${modifier.name} up`} disabled={index === 0} onClick={() => onCommand({ type: "moveModifier", meshId: activeMesh.id, modifierUuid: modifier.uuid, direction: "UP" })}>↑</button><button type="button" aria-label={`Move ${modifier.name} down`} disabled={index === (activeMesh.modifierStack?.length ?? 1) - 1} onClick={() => onCommand({ type: "moveModifier", meshId: activeMesh.id, modifierUuid: modifier.uuid, direction: "DOWN" })}>↓</button><button type="button" aria-label={`Remove ${modifier.name}`} onClick={() => onCommand({ type: "removeModifier", meshId: activeMesh.id, modifierUuid: modifier.uuid })}>Remove</button></div></div><div className="modifier-visibility-controls">{(["showViewport", "showRender", "showEditMode", "showOnCage"] as const).map((field, fieldIndex) => <label key={field}>{["V", "R", "E", "C"][fieldIndex]}<input type="checkbox" checked={Boolean(modifier[field])} onChange={(event) => { if (field === "showViewport") onSetModifierEnabled(activeMesh.id, modifier.uuid, event.target.checked); else onCommand({ type: "setModifierVisibility", meshId: activeMesh.id, modifierUuid: modifier.uuid, showViewport: modifier.showViewport, showRender: modifier.showRender, showEditMode: Boolean(modifier.showEditMode), showOnCage: Boolean(modifier.showOnCage), [field]: event.target.checked }); }} aria-label={`${["Viewport", "Render", "Edit", "Cage"][fieldIndex]} ${modifier.name}`} /></label>)}</div><div className="modifier-parameter-controls">{modifierParameterMetadata(modifier).map((field) => <label key={field.key}>{field.key}{field.kind === "boolean" ? <input type="checkbox" checked={Boolean(field.value)} onChange={(event) => onCommand({ type: "setModifierParameters", meshId: activeMesh.id, modifierUuid: modifier.uuid, parameters: { ...(modifier.parameters ?? {}), [field.key]: event.target.checked } })} /> : field.kind === "number" ? <input type="number" value={Number(field.value)} onChange={(event) => onCommand({ type: "setModifierParameters", meshId: activeMesh.id, modifierUuid: modifier.uuid, parameters: { ...(modifier.parameters ?? {}), [field.key]: Number(event.target.value) } })} /> : <input type="text" value={String(field.value ?? "")} onChange={(event) => onCommand({ type: "setModifierParameters", meshId: activeMesh.id, modifierUuid: modifier.uuid, parameters: { ...(modifier.parameters ?? {}), [field.key]: event.target.value } })} />}</label>)}</div></div>)}</div>;
})() : null}
<div className="property-section modifier-panel">
<h3>Decimate</h3>
<label>模式 <select aria-label="Decimate 模式" value={mode} onChange={(event) => setMode(event.target.value as SimplifyMode)}><option value="COLLAPSE">Collapse</option><option value="UNSUBDIV">Un-Subdivide</option><option value="DISSOLVE_PLANAR">Dissolve Planar</option></select></label>
@@ -1245,11 +1561,90 @@ function OperatorSearch({ commands, onClose }: { commands: readonly OperatorComm
);
}
function SequencerAuthoringPanel({ scene, revision, frame, onCommand }: { scene: SceneSnapshotIR["scenes"][number]; revision: number; frame: number; onCommand: (command: WebEngineEditCommand) => void }) {
const timeline = scene.sequencerTimeline ?? {
schemaVersion: 1,
id: `sequencer:${scene.id}`,
revision,
frameStart: 1,
frameEnd: 250,
fpsNumerator: 24000,
fpsDenominator: 1000,
strips: [],
} satisfies SequencerTimelineIR;
const [selectedId, setSelectedId] = useState<string | null>(null);
const [newType, setNewType] = useState<Exclude<SequencerStripType, "EFFECT">>("MOVIE");
const strips = timeline.strips;
const selected = strips.find((strip) => strip.id === selectedId) ?? strips[0] ?? null;
useEffect(() => {
if (selected?.id !== selectedId) setSelectedId(selected?.id ?? null);
}, [selected?.id, selectedId]);
const commit = (nextStrips: SequencerStripIR[]): void => {
onCommand({ type: "setSequencerStrips", sceneId: scene.id, timeline: { ...timeline, revision, strips: nextStrips } });
};
const addStrip = (): void => {
const id = `strip:web-${Date.now().toString(36)}`;
const duration = 24;
const strip: SequencerStripIR = {
id,
name: `Web ${newType}`,
type: newType,
channel: 1,
frameStart: frame,
frameEnd: frame + duration,
sourceStart: 0,
sourceEnd: duration,
speed: 1,
muted: false,
locked: false,
...(newType === "SCENE" ? { sourceId: scene.id } : {}),
...(newType === "META" ? { childStripIds: [] } : {}),
...(newType === "MOVIE" ? { sourcePath: `//web-proxy/${id.slice(6)}.mp4`, mimeType: "video/mp4" } : {}),
...(newType === "SOUND" ? { sourcePath: `//web-proxy/${id.slice(6)}.wav`, mimeType: "audio/wav" } : {}),
...(newType === "IMAGE" ? { sourcePath: `//web-proxy/${id.slice(6)}.png`, mimeType: "image/png" } : {}),
};
commit([...strips, strip]);
setSelectedId(id);
};
const removeSelected = (): void => {
if (!selected) return;
const next = strips.filter((strip) => strip.id !== selected.id).map((strip) => ({
...strip,
inputStripIds: strip.inputStripIds?.filter((id) => id !== selected.id),
childStripIds: strip.childStripIds?.filter((id) => id !== selected.id),
}));
commit(next);
setSelectedId(null);
};
const updateSelected = (patch: Partial<SequencerStripIR>): void => {
if (!selected) return;
commit(strips.map((strip) => strip.id === selected.id ? { ...strip, ...patch } : strip));
};
const moveSelected = (frameDelta: number, channelDelta = 0): void => {
if (!selected) return;
updateSelected({ frameStart: selected.frameStart + frameDelta, frameEnd: selected.frameEnd + frameDelta, channel: Math.max(1, selected.channel + channelDelta) });
};
const splitSelected = (): void => {
if (!selected || selected.frameEnd - selected.frameStart < 2) return;
const splitFrame = selected.frameStart + Math.floor((selected.frameEnd - selected.frameStart) / 2);
const sourceSplit = selected.sourceStart + Math.round((splitFrame - selected.frameStart) * selected.speed);
const rightId = `${selected.id}:right`;
if (strips.some((strip) => strip.id === rightId)) return;
const left = { ...selected, frameEnd: splitFrame, sourceEnd: sourceSplit };
const right = { ...selected, id: rightId, name: `${selected.name} Right`, frameStart: splitFrame, sourceStart: sourceSplit };
commit(strips.flatMap((strip) => strip.id === selected.id ? [left, right] : [strip]));
setSelectedId(rightId);
};
return <section className="sequencer-authoring" aria-label="Sequencer Strip Editing"><div className="sequencer-toolbar"><select aria-label="New sequencer strip type" value={newType} onChange={(event) => setNewType(event.target.value as Exclude<SequencerStripType, "EFFECT">)}><option value="SCENE">Scene</option><option value="MOVIE">Movie</option><option value="SOUND">Sound</option><option value="IMAGE">Image</option><option value="META">Meta</option></select><button type="button" onClick={addStrip}>Add Strip</button><button type="button" disabled={!selected} onClick={removeSelected}>Remove</button></div><label>Strip<select aria-label="Sequencer strip" value={selected?.id ?? ""} onChange={(event) => setSelectedId(event.target.value || null)}><option value="">Select Strip…</option>{strips.map((strip) => <option key={strip.id} value={strip.id}>{strip.name} · {strip.type}</option>)}</select></label>{selected ? <div className="sequencer-strip-editor"><output>{selected.id} · {selected.type}</output><label>Start<input type="number" value={selected.frameStart} onChange={(event) => updateSelected({ frameStart: Number(event.target.value) })} /></label><label>End<input type="number" value={selected.frameEnd} onChange={(event) => updateSelected({ frameEnd: Number(event.target.value) })} /></label><label>Channel<input type="number" min={1} max={128} value={selected.channel} onChange={(event) => updateSelected({ channel: Math.max(1, Math.min(128, Number(event.target.value))) })} /></label><label>Muted<input type="checkbox" checked={selected.muted} onChange={(event) => updateSelected({ muted: event.target.checked })} /></label><div className="property-actions"><button type="button" onClick={() => moveSelected(-1)}>Move −1</button><button type="button" onClick={() => moveSelected(1)}>Move +1</button><button type="button" onClick={() => moveSelected(0, -1)}>Channel −1</button><button type="button" onClick={() => moveSelected(0, 1)}>Channel +1</button><button type="button" onClick={() => updateSelected({ frameStart: selected.frameStart + 1, sourceStart: selected.sourceStart + Math.max(1, Math.round(selected.speed)) })} disabled={selected.frameStart + 1 >= selected.frameEnd}>Trim start</button><button type="button" onClick={() => updateSelected({ frameEnd: selected.frameEnd - 1, sourceEnd: Math.max(selected.sourceStart + 1, selected.sourceEnd - Math.max(1, Math.round(selected.speed))) })} disabled={selected.frameEnd - 1 <= selected.frameStart}>Trim end</button><button type="button" onClick={splitSelected}>Split</button></div></div> : <output>{scene.sequencerStatus === "AVAILABLE" ? "No strips" : "Sequencer timeline will be created in Main"}</output>}</section>;
}
function Timeline({ snapshot, frame, start, end, onFrameChange, onCommand }: { snapshot: SceneSnapshotIR | null; frame: number; start: number; end: number; onFrameChange: (value: number) => void; onCommand: (command: WebEngineEditCommand) => void }) {
const [playing, setPlaying] = useState(false);
const [selectedNlaTrackId, setSelectedNlaTrackId] = useState<string | null>(null);
const [selectedNlaStripId, setSelectedNlaStripId] = useState<string | null>(null);
const [nlaFrameStartDraft, setNlaFrameStartDraft] = useState("");
const [actionNameDraft, setActionNameDraft] = useState("");
const [markerNameDraft, setMarkerNameDraft] = useState("");
useEffect(() => {
if (!playing) return undefined;
const timer = window.setInterval(() => {
@@ -1267,6 +1662,8 @@ function Timeline({ snapshot, frame, start, end, onFrameChange, onCommand }: { s
const fourth = Math.round(start + (end - start) * 0.8);
const animation = snapshot?.animations.find((candidate) => candidate.targetId === snapshot.activeObjectId);
const actions = snapshot?.animations.filter((candidate) => candidate.targetId === snapshot.activeObjectId) ?? [];
const activeScene = snapshot?.scenes?.[0];
const markers = activeScene?.timelineMarkers ?? [];
const nlaTracks = snapshot?.nlaTracks?.filter((track) => track.ownerId === snapshot.activeObjectId) ?? [];
const selectedNlaTrack = nlaTracks.find((track) => track.id === selectedNlaTrackId) ?? null;
const selectedNlaStrip = selectedNlaTrack?.strips.find((strip) => strip.id === selectedNlaStripId) ?? null;
@@ -1299,12 +1696,16 @@ function Timeline({ snapshot, frame, start, end, onFrameChange, onCommand }: { s
: animation?.channels.flatMap((channel) => channel.keyframes.map((keyframe) => keyframe.frame)) ?? [])]
.filter((keyframe) => keyframe >= start && keyframe <= end)
.sort((left, right) => left - right);
const activeKeyframe = animation?.channels[0]?.keyframes.find((keyframe) => keyframe.frame === frame);
return (
<div className="timeline-content">
<div className="timeline-controls"><button type="button" aria-label="跳到第一帧" onClick={() => { setPlaying(false); onFrameChange(start); }}>|◀</button><button type="button" aria-label="上一帧" onClick={() => onFrameChange(Math.max(start, frame - 1))}>◀</button><button type="button" aria-label="播放" onClick={() => setPlaying((current) => !current)}>{playing ? "Ⅱ" : "▶"}</button><button type="button" aria-label="下一帧" onClick={() => onFrameChange(Math.min(end, frame + 1))}>▶|</button><button type="button" aria-label="跳到最后一帧" onClick={() => { setPlaying(false); onFrameChange(end); }}>▶|</button><output className="frame-number">{frame}</output>{snapshot?.activeObjectId ? <><button type="button" aria-label="插入位置关键帧" onClick={() => onCommand(animationRenderCommands.insertKeyframe(snapshot.activeObjectId!, frame, "LOCATION"))}>Loc</button><button type="button" aria-label="插入旋转关键帧" onClick={() => onCommand(animationRenderCommands.insertKeyframe(snapshot.activeObjectId!, frame, "ROTATION_EULER"))}>Rot</button><button type="button" aria-label="插入缩放关键帧" onClick={() => onCommand(animationRenderCommands.insertKeyframe(snapshot.activeObjectId!, frame, "SCALE"))}>Scale</button><button type="button" aria-label="删除当前关键帧" onClick={() => onCommand(animationRenderCommands.deleteKeyframe(snapshot.activeObjectId!, frame))}>Del Key</button></> : null}</div>
<input className="frame-slider" type="range" min={start} max={end} value={Math.min(end, Math.max(start, frame))} onChange={(event) => onFrameChange(Number(event.target.value))} aria-label="当前帧" />
<div className="timeline-scale"><span>{start}</span><span>{mid}</span><span>{second}</span><span>{third}</span><span>{fourth}</span><span>{end}</span></div>
<div className="dope-sheet" aria-label="Dope Sheet"><span className="channel-name">{greasePencil?.name ?? animation?.name ?? "No Action"}</span><div className="key-track">{keyframes.map((keyframe) => <button key={keyframe} type="button" className={keyframe === frame ? "key-dot active" : "key-dot"} style={{ left: `${((keyframe - start) / Math.max(1, end - start)) * 100}%` }} aria-label={`${greasePencil ? "Grease Pencil 帧" : "关键帧"} ${keyframe}`} onClick={() => onFrameChange(keyframe)} />)}</div>{!greasePencil && animation?.channels[0] ? <select aria-label="FCurve 插值" value={animation.channels[0].interpolation ?? "BEZIER"} onChange={(event) => onCommand({ type: "setFCurveInterpolation", animationId: animation.id, path: animation.channels[0].path, interpolation: event.target.value as "CONSTANT" | "LINEAR" | "BEZIER" })}><option value="CONSTANT">Constant</option><option value="LINEAR">Linear</option><option value="BEZIER">Bezier</option></select> : <span />}{snapshot?.activeObjectId && actions.length > 0 ? <select aria-label="活动 Action" defaultValue="" onChange={(event) => onCommand(animationRenderCommands.setActiveAction(snapshot.activeObjectId!, event.target.value || null))}><option value="">选择 Action...</option>{actions.map((action) => <option key={action.id} value={action.id}>{action.name}</option>)}</select> : null}{snapshot?.activeObjectId && nlaTracks.length > 0 ? <button type="button" onClick={() => onCommand(animationRenderCommands.setNLAStack(snapshot.activeObjectId!, nlaTracks, snapshot.revision))}>提交 NLA</button> : null}</div>
<div className="dope-sheet" aria-label="Dope Sheet"><span className="channel-name">{greasePencil?.name ?? animation?.name ?? "No Action"}</span><div className="key-track">{keyframes.map((keyframe) => <button key={keyframe} type="button" className={keyframe === frame ? "key-dot active" : "key-dot"} style={{ left: `${((keyframe - start) / Math.max(1, end - start)) * 100}%` }} aria-label={`${greasePencil ? "Grease Pencil 帧" : "关键帧"} ${keyframe}`} onClick={() => onFrameChange(keyframe)} />)}</div>{!greasePencil && animation?.channels[0] ? <select aria-label="FCurve 插值" value={animation.channels[0].interpolation ?? "BEZIER"} onChange={(event) => onCommand({ type: "setFCurveInterpolation", animationId: animation.id, path: animation.channels[0].path, interpolation: event.target.value as "CONSTANT" | "LINEAR" | "BEZIER" })}><option value="CONSTANT">Constant</option><option value="LINEAR">Linear</option><option value="BEZIER">Bezier</option></select> : <span />}{snapshot?.activeObjectId ? <><input aria-label="Action name" value={actionNameDraft} placeholder="Action name" onChange={(event) => setActionNameDraft(event.target.value)} /><button type="button" onClick={() => { const name = actionNameDraft.trim(); if (!name) return; onCommand(animationRenderCommands.createAction(name, snapshot.activeObjectId!)); setActionNameDraft(""); }}>New Action</button>{animation ? <button type="button" onClick={() => onCommand(animationRenderCommands.removeAction(animation.id))}>Remove Action</button> : null}{actions.length > 0 ? <select aria-label="活动 Action" defaultValue="" onChange={(event) => onCommand(animationRenderCommands.setActiveAction(snapshot.activeObjectId!, event.target.value || null))}><option value="">选择 Action...</option>{actions.map((action) => <option key={action.id} value={action.id}>{action.name}</option>)}</select> : null}</> : null}{snapshot?.activeObjectId && nlaTracks.length > 0 ? <button type="button" onClick={() => onCommand(animationRenderCommands.setNLAStack(snapshot.activeObjectId!, nlaTracks, snapshot.revision))}>提交 NLA</button> : null}</div>
{!greasePencil && animation?.channels[0] ? <div className="keyframe-editor" aria-label="Keyframe Editor"><label>Frame<input aria-label="Keyframe frame" type="number" value={frame} onChange={(event) => onFrameChange(Number(event.target.value))} /></label><label>Value<input aria-label="Keyframe value" type="number" value={activeKeyframe?.value[0] ?? 0} onChange={(event) => onCommand(animationRenderCommands.setKeyframe(animation.id, animation.channels[0].path, frame, { value: [Number(event.target.value)] }))} /></label><label>Left X<input aria-label="Keyframe left handle frame" type="number" value={activeKeyframe?.handleLeft?.[0] ?? frame} onChange={(event) => onCommand(animationRenderCommands.setKeyframe(animation.id, animation.channels[0].path, frame, { handleLeft: [Number(event.target.value), activeKeyframe?.handleLeft?.[1] ?? activeKeyframe?.value[0] ?? 0] }))} /></label><label>Left Y<input aria-label="Keyframe left handle value" type="number" value={activeKeyframe?.handleLeft?.[1] ?? activeKeyframe?.value[0] ?? 0} onChange={(event) => onCommand(animationRenderCommands.setKeyframe(animation.id, animation.channels[0].path, frame, { handleLeft: [activeKeyframe?.handleLeft?.[0] ?? frame, Number(event.target.value)] }))} /></label><label>Right X<input aria-label="Keyframe right handle frame" type="number" value={activeKeyframe?.handleRight?.[0] ?? frame} onChange={(event) => onCommand(animationRenderCommands.setKeyframe(animation.id, animation.channels[0].path, frame, { handleRight: [Number(event.target.value), activeKeyframe?.handleRight?.[1] ?? activeKeyframe?.value[0] ?? 0] }))} /></label><label>Right Y<input aria-label="Keyframe right handle value" type="number" value={activeKeyframe?.handleRight?.[1] ?? activeKeyframe?.value[0] ?? 0} onChange={(event) => onCommand(animationRenderCommands.setKeyframe(animation.id, animation.channels[0].path, frame, { handleRight: [activeKeyframe?.handleRight?.[0] ?? frame, Number(event.target.value)] }))} /></label><label>Interpolation<select aria-label="Keyframe interpolation" value={activeKeyframe?.interpolation ?? animation.channels[0].interpolation ?? "BEZIER"} onChange={(event) => onCommand(animationRenderCommands.setKeyframe(animation.id, animation.channels[0].path, frame, { interpolation: event.target.value as "CONSTANT" | "LINEAR" | "BEZIER" }))}><option value="CONSTANT">Constant</option><option value="LINEAR">Linear</option><option value="BEZIER">Bezier</option></select></label></div> : null}
{activeScene ? <div className="timeline-markers" aria-label="Timeline Markers"><input aria-label="Marker name" value={markerNameDraft} placeholder="Marker name" onChange={(event) => setMarkerNameDraft(event.target.value)} /><button type="button" onClick={() => { const name = markerNameDraft.trim(); if (!name) return; onCommand(animationRenderCommands.setTimelineMarkers(activeScene.id, [...markers, { frame, name }])); setMarkerNameDraft(""); }}>Add Marker</button>{markers.map((marker, index) => <div className="timeline-marker" key={`${marker.frame}:${index}`}><input aria-label={`Marker ${index + 1} name`} value={marker.name} onChange={(event) => onCommand(animationRenderCommands.setTimelineMarkers(activeScene.id, markers.map((entry, markerIndex) => markerIndex === index ? { ...entry, name: event.target.value } : entry)))} /><input aria-label={`Marker ${index + 1} frame`} type="number" value={marker.frame} onChange={(event) => onCommand(animationRenderCommands.setTimelineMarkers(activeScene.id, markers.map((entry, markerIndex) => markerIndex === index ? { ...entry, frame: Number(event.target.value) } : entry)))} /><button type="button" aria-label={`Delete marker ${index + 1}`} onClick={() => onCommand(animationRenderCommands.setTimelineMarkers(activeScene.id, markers.filter((_, markerIndex) => markerIndex !== index)))}>×</button></div>)}</div> : null}
{activeScene && snapshot ? <SequencerAuthoringPanel scene={activeScene} revision={snapshot.revision} frame={frame} onCommand={onCommand} /> : null}
{snapshot?.activeObjectId && nlaTracks.length > 0 ? <div className="nla-strip-editor" aria-label="NLA Strip 编辑"><label>NLA Track<select data-testid="nla-track-select" aria-label="NLA Track" value={selectedNlaTrackId ?? ""} onChange={(event) => { const trackId = event.target.value || null; setSelectedNlaTrackId(trackId); const track = nlaTracks.find((candidate) => candidate.id === trackId); setSelectedNlaStripId(track?.strips.find((strip) => strip.selected)?.id ?? track?.strips[0]?.id ?? null); }}><option value="">选择 Track...</option>{nlaTracks.map((track) => <option key={track.id} value={track.id}>{track.name}</option>)}</select></label><label>NLA Strip<select data-testid="nla-strip-select" aria-label="NLA Strip" value={selectedNlaStripId ?? ""} onChange={(event) => setSelectedNlaStripId(event.target.value || null)} disabled={!selectedNlaTrack || selectedNlaTrack.strips.length === 0}><option value="">选择 Strip...</option>{selectedNlaTrack?.strips.map((strip) => <option key={strip.id} value={strip.id}>{strip.id} ({strip.frameStart}–{strip.frameEnd})</option>)}</select></label><label>Start<input data-testid="nla-strip-frame-start" aria-label="NLA Strip 起始帧" type="number" step="1" value={nlaFrameStartDraft} disabled={!selectedNlaStrip} onChange={(event) => setNlaFrameStartDraft(event.target.value)} onBlur={commitNlaFrameStart} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); commitNlaFrameStart(); } }} /></label><button type="button" data-testid="move-nla-strip" disabled={!selectedNlaStrip} onMouseDown={(event) => event.preventDefault()} onClick={commitNlaFrameStart}>移动 Strip</button></div> : null}
</div>
);
@@ -2752,7 +3153,10 @@ export function App() {
const operatorCommands = filterIOFormatOperatorCommands<OperatorCommand>([
...(["Layout", "Modeling", "Animation"] as WorkspaceId[]).filter((id) => id !== workspace).map((id) => ({ id: `workspace.${id}`, label: `Switch to ${id}`, keywords: "workspace", execute: () => dispatchUI({ type: "switchWorkspace", workspaceId: id }) })),
{ id: "mode.toggle", label: uiState.context.mode === "Object" ? "Enter Edit Mode" : "Exit Edit Mode", keywords: "mode tab", execute: () => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" }) },
...(snapshot && uiState.context.mode === "Object" && isWorkbenchCommandAvailable("createPrimitive") ? [{ id: "object.add-cube", label: "Add Cube", keywords: "object primitive mesh", execute: () => { void applyEditCommand(coreSceneCommands.createCube()); } }] : []),
...(snapshot && uiState.context.mode === "Object" && isWorkbenchCommandAvailable("createPrimitive") ? ([
["CUBE", "Cube"], ["PLANE", "Plane"], ["UV_SPHERE", "UV Sphere"], ["ICO_SPHERE", "Ico Sphere"],
["CYLINDER", "Cylinder"], ["CONE", "Cone"], ["TORUS", "Torus"],
] as ReadonlyArray<readonly [MeshPrimitive, string]>).map(([primitive, label]) => ({ id: `object.add-${primitive.toLowerCase()}`, label: `Add ${label}`, keywords: "object primitive mesh", execute: () => { void applyEditCommand(coreSceneCommands.createPrimitive(primitive)); } })) : []),
...(snapshot?.activeObjectId && isWorkbenchCommandAvailable("applyObjectTransform") ? [{ id: "object.apply-transform", label: "Apply Transform", keywords: "object location rotation scale", execute: () => { void applyEditCommand(coreSceneCommands.applyTransform(snapshot.activeObjectId!)); } }] : []),
...(snapshot ? [
{ id: "edit.undo", label: "Undo", keywords: "history", execute: () => { void applyEditCommand({ type: "undo" }); } },
@@ -2808,14 +3212,14 @@ export function App() {
{workerFault ? <div className="worker-fault-banner" role="alert" data-testid="worker-fault-banner"><span>{workerFault.source === "engine" ? "Engine Worker" : "Storage Worker"} stopped; current project list and scene are retained.</span><button type="button" data-testid="restart-and-recover" onClick={() => void restartWorkersAndRecover()} disabled={workerRecoveryStatus === "RUNNING"}>{workerRecoveryStatus === "RUNNING" ? "恢复中..." : "重启并恢复"}</button>{workerRecoveryStatus === "FAILED" ? <span data-testid="worker-recovery-error">Recovery failed; retry is safe.</span> : null}</div> : null}
{recentProjectIssues.length > 0 ? <div className="recent-project-repair-banner" role="alert" data-testid="recent-project-repair-banner"><span>最近项目中有 {recentProjectIssues.length} 个条目无法验证;当前场景不会被删除。</span><div className="recent-project-repair-list">{recentProjectIssues.map((issue) => <div key={issue.project.projectId} className="recent-project-repair-item" data-project-id={issue.project.projectId} data-issue-code={issue.code}><span>{issue.project.displayName}: {recentProjectIssueMessage(issue.code)}</span><button type="button" data-testid={`remove-recent-project-${issue.project.projectId}`} aria-label={`移除失效项目引用 ${issue.project.displayName}`} onClick={() => void removeInvalidRecentProject(issue.project.projectId)}>移除引用</button></div>)}</div></div> : null}
<StorageBudgetPanel budget={storageBudget} onCleanup={() => void cleanupProjectAssets()} />
<div className="workspace-toolbar"><span>{workspaceLabel}</span><button type="button" className="mode-chip" onClick={() => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" })}>{uiState.context.mode} Mode</button>{uiState.context.mode === "Edit" ? <><div className="segmented" aria-label="网格选择模式">{(["VERT", "EDGE", "FACE"] as MeshElementMode[]).map((mode) => <button key={mode} type="button" className={meshSelection.mode === mode ? "active" : ""} onClick={() => setMeshSelectionMode(mode)}>{mode === "VERT" ? "1 Vertex" : mode === "EDGE" ? "2 Edge" : "3 Face"}</button>)}</div><button type="button" onClick={selectAllMeshElements}>Select All</button>{(["MERGE", "DISSOLVE", "EXTRUDE", "INSET", "BEVEL", "LOOP_CUT"] as MeshEditOperation[]).map((operation) => <button key={operation} type="button" disabled={meshSelection.indices.size === 0} onClick={() => runMeshEdit(operation)}>{operation.replace("_", " ")}</button>)}<button type="button" disabled={meshSelection.mode !== "FACE" || meshSelection.indices.size === 0 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "separateMeshFaces", objectId: snapshot.activeObjectId, faceIndices: [...meshSelection.indices], name: "Separated" })}>Separate</button></> : <><button type="button" aria-label="添加立方体" onClick={() => void applyEditCommand({ type: "createPrimitive", primitive: "CUBE", location: [0, 0, 0] })}>Add Cube</button><button type="button" aria-label="复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.25, 0.25, 0] })}>Duplicate</button><button type="button" aria-label="链接复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.5, 0.5, 0], linked: true })}>Linked Duplicate</button><button type="button" aria-label="删除对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "deleteObject", objectId: snapshot.activeObjectId })}>Delete</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => { const child = snapshot?.activeObjectId; const parent = [...selectedObjectIds].find((id) => id !== child); if (child && parent) void applyEditCommand({ type: "setParent", objectId: child, parentId: parent, keepTransform: true }); }}>Parent</button><button type="button" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "setParent", objectId: snapshot.activeObjectId, parentId: null, keepTransform: true })}>Unparent</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "joinObjects", activeObjectId: snapshot.activeObjectId, objectIds: [...selectedObjectIds] })}>Join</button><button type="button" onClick={() => void applyEditCommand({ type: "createCollection", name: `Collection ${(snapshot?.collections.length ?? 0) + 1}` })}>New Collection</button></>}<span className="toolbar-spacer" /><span>{uiState.context.mode === "Edit" ? `${meshSelection.greasePencilPoints?.length ?? meshSelection.indices.size} ${meshSelection.mode.toLowerCase()} selected` : `${selectedObjectIds.size} selected`}</span><output data-testid="dirty-status">{saved ? "已保存" : "未保存"}</output></div>
<div className="workspace-toolbar"><span>{workspaceLabel}</span><button type="button" className="mode-chip" onClick={() => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" })}>{uiState.context.mode} Mode</button>{uiState.context.mode === "Edit" ? <><div className="segmented" aria-label="网格选择模式">{(["VERT", "EDGE", "FACE"] as MeshElementMode[]).map((mode) => <button key={mode} type="button" className={meshSelection.mode === mode ? "active" : ""} onClick={() => setMeshSelectionMode(mode)}>{mode === "VERT" ? "1 Vertex" : mode === "EDGE" ? "2 Edge" : "3 Face"}</button>)}</div><button type="button" onClick={selectAllMeshElements}>Select All</button>{(["MERGE", "DISSOLVE", "EXTRUDE", "INSET", "BEVEL", "LOOP_CUT", "SUBDIVIDE", "FILL", "TRIANGULATE", "RECALCULATE_NORMALS", "BRIDGE_LOOPS", "DELETE"] as MeshEditOperation[]).map((operation) => <button key={operation} type="button" disabled={meshSelection.indices.size === 0 && operation !== "RECALCULATE_NORMALS"} onClick={() => runMeshEdit(operation)}>{operation.replaceAll("_", " ")}</button>)}<button type="button" disabled={meshSelection.mode !== "FACE" || meshSelection.indices.size === 0 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "separateMeshFaces", objectId: snapshot.activeObjectId, faceIndices: [...meshSelection.indices], name: "Separated" })}>Separate</button></> : <>{(["CUBE", "PLANE", "UV_SPHERE", "ICO_SPHERE", "CYLINDER", "CONE", "TORUS"] as MeshPrimitive[]).map((primitive) => <button key={primitive} type="button" aria-label={`添加 ${primitive.toLowerCase().replaceAll("_", " ")}`} onClick={() => void applyEditCommand(coreSceneCommands.createPrimitive(primitive))}>Add {primitive.replaceAll("_", " ")}</button>)}<button type="button" aria-label="复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.25, 0.25, 0] })}>Duplicate</button><button type="button" aria-label="链接复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.5, 0.5, 0], linked: true })}>Linked Duplicate</button><button type="button" aria-label="删除对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "deleteObject", objectId: snapshot.activeObjectId })}>Delete</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => { const child = snapshot?.activeObjectId; const parent = [...selectedObjectIds].find((id) => id !== child); if (child && parent) void applyEditCommand({ type: "setParent", objectId: child, parentId: parent, keepTransform: true }); }}>Parent</button><button type="button" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "setParent", objectId: snapshot.activeObjectId, parentId: null, keepTransform: true })}>Unparent</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "joinObjects", activeObjectId: snapshot.activeObjectId, objectIds: [...selectedObjectIds] })}>Join</button><button type="button" onClick={() => void applyEditCommand({ type: "createCollection", name: `Collection ${(snapshot?.collections.length ?? 0) + 1}` })}>New Collection</button></>}<span className="toolbar-spacer" /><span>{uiState.context.mode === "Edit" ? `${meshSelection.greasePencilPoints?.length ?? meshSelection.indices.size} ${meshSelection.mode.toLowerCase()} selected` : `${selectedObjectIds.size} selected`}</span><output data-testid="dirty-status">{saved ? "已保存" : "未保存"}</output></div>
<div className="workspace-grid">
<Area className="viewport-area" editor="3D Viewport"><ViewportPlaceholder snapshot={preview?.snapshot ?? snapshot} geometryBuffers={preview?.geometryBuffers ?? geometryBuffers} nonMeshGeometryBuffers={preview?.nonMeshGeometryBuffers ?? nonMeshGeometryBuffers} textureAssets={gpuTextureAssets} volumeProject={volumeProject} lodLevels={preview ? null : lodLevels} selectedObjectIds={selectedObjectIds} editMode={uiState.context.mode === "Edit"} meshSelection={meshSelection} greasePencilSelectionRevision={greasePencilSelection?.revision ?? 0} onSelect={selectObject} onElementSelect={selectMeshElement} onGreasePencilPointSelect={selectGreasePencilPoint} onGreasePencilMarqueeSelect={selectGreasePencilMarquee} onTransform={transformActive} onDiagnostic={recordDiagnostic} /></Area>
<Area className="outliner-area" editor="Outliner"><Outliner snapshot={snapshot} onSelect={selectObject} onToggleVisibility={(id, visible) => void applyEditCommand({ type: "setObjectVisibility", objectId: id, visible })} /></Area>
<Area className="properties-area" editor="Properties"><Properties snapshot={snapshot} editorWorkflow={editorWorkflow} selectedFaceIndices={meshSelection.mode === "FACE" ? [...meshSelection.indices] : []} selectedVertexIndices={meshSelection.mode === "VERT" ? [...meshSelection.indices] : []} greasePencilSelection={greasePencilSelection} onGreasePencilCanvasPointSelect={(point, additive, baseSelectionRevision) => { commitGreasePencilSelection("CANVAS_2D", additive ? "TOGGLE" : "REPLACE", [point], point, baseSelectionRevision); }} onCommand={(command) => void applyEditCommand(command)} onImportImage={(file) => void importImage(file)} onImportVFont={(file) => void importVFont(file)} onRequestAsset={(assetId) => { const client = webClientRef.current; if (!client) return; void client.requestAsset(assetId).then((asset) => setEngineStatus(`Asset: ${asset.assetId} (${asset.status})`)).catch((error: unknown) => setEngineStatus(recordDiagnostic("ENGINE", "PBR_ASSET_INVALID", error, { assetId }))); }} onAppendLibraryObject={async (source, request) => { const client = webClientRef.current; if (!client) return; try { const result = await client.appendLibraryObject(source, request); setSnapshot(result.snapshot); setGeometryBuffers(result.geometryBuffers); setNonMeshGeometryBuffers(result.nonMeshGeometryBuffers ?? []); setFrame(result.snapshot.frame.current); projectRevisionRef.current = result.snapshot.revision; setEngineStatus(`Library append: ${result.receipt.sourceDataBlockId}`); } catch (error) { setEngineStatus(recordDiagnostic("ENGINE", "COMMAND_FAILED", error)); } }} onQueryRenderCapability={(request) => { const client = webClientRef.current; if (!client) return; void client.queryRenderCapability(request).then((gate) => setEngineStatus(`Render: ${gate.status} (${gate.capability})`)).catch((error: unknown) => setEngineStatus(recordDiagnostic("ENGINE", "GLB_EXPORT_BLOCKED", error))); }} onApplyDecimate={applyDecimate} onPreviewDecimate={previewDecimate} onGenerateLOD={(meshId, triangleCount) => void generateLOD(meshId, triangleCount)} onSetModifierEnabled={setModifierEnabled} previewActive={Boolean(preview)} onCancelPreview={() => { setPreview(null); setLodLevels(null); setEngineStatus(`Engine: SceneIR r${snapshot?.revision ?? 0}`); }} /></Area>
<Area className="timeline-area" editor="Timeline"><Timeline snapshot={snapshot} frame={frame} start={frameRange.start} end={frameRange.end} onFrameChange={(value) => void applyEditCommand({ type: "setFrame", frame: value })} onCommand={(command) => void applyEditCommand(command)} /></Area>
</div>
{uiState.operatorSearchOpen ? <OperatorSearch commands={operatorCommands} onClose={closeOperatorSearch} /> : null}
{uiState.operatorSearchOpen ? <BlenderOperatorPalette client={webClientRef.current} commands={operatorCommands} onClose={closeOperatorSearch} onApplied={(result, descriptor) => { setSnapshot(result.snapshot); setGeometryBuffers(result.geometryBuffers); setNonMeshGeometryBuffers(result.nonMeshGeometryBuffers ?? []); setFrame(result.snapshot.frame.current); projectRevisionRef.current = result.snapshot.revision; setEngineStatus(`Operator: ${descriptor.id}`); }} /> : null}
<footer className="status-bar"><span>Web Blender Modeler V1</span><span data-testid="scene-stats">Objects {objectCount} · Vertices {vertexCount} · Faces {faceCount}</span>{openProgress ? <span className="open-progress" data-testid="open-progress" data-phase={openProgress.phase} data-stage={openProgress.stage} data-cancellable={openProgress.cancellable} data-bytes-read={openProgress.bytesRead} data-total-bytes={openProgress.totalBytes}><progress aria-label="打开文件进度" max={(openProgress.totalBytes ?? 0) > 0 ? openProgress.totalBytes : 1} value={openProgress.bytesRead ?? openProgress.fraction ?? 0} /><span>{openProgress.message ?? "Opening"}</span>{canCancelOpenRead ? <button type="button" aria-label="取消打开" title="取消打开" onClick={cancelOpenFileRead}>×</button> : null}</span> : null}<span className="status-spacer" /><span>{manifestStatus}</span><span>{wasmStatus}</span><span data-testid="engine-status">{engineStatus}</span><span>{storageStatus}</span></footer>
</main>
);

View File

@@ -3,8 +3,45 @@
"task": "M12-05C",
"parentMatrixSha256": "139c9d764736da176b32414ecda840c07eb0ba5f3864da2dc08ce04bae161366",
"projectFileAccept": ".blend,application/octet-stream",
"importRoutes": [],
"importRoutes": [
{
"format": "GLTF",
"operation": "IMPORT",
"execution": "LOCAL",
"extensions": [".gltf"]
},
{
"format": "GLB",
"operation": "IMPORT",
"execution": "LOCAL",
"extensions": [".glb"]
},
{
"format": "OBJ",
"operation": "IMPORT",
"execution": "LOCAL",
"extensions": [".obj"]
},
{
"format": "STL",
"operation": "IMPORT",
"execution": "LOCAL",
"extensions": [".stl"]
},
{
"format": "PLY",
"operation": "IMPORT",
"execution": "LOCAL",
"extensions": [".ply"]
}
],
"exportRoutes": [
{
"format": "GLTF",
"operation": "EXPORT",
"execution": "LOCAL",
"extensions": [".gltf"]
},
{
"format": "GLB",
"operation": "EXPORT",
@@ -12,6 +49,24 @@
"extensions": [
".glb"
]
},
{
"format": "OBJ",
"operation": "EXPORT",
"execution": "LOCAL",
"extensions": [".obj"]
},
{
"format": "STL",
"operation": "EXPORT",
"execution": "LOCAL",
"extensions": [".stl"]
},
{
"format": "PLY",
"operation": "EXPORT",
"execution": "LOCAL",
"extensions": [".ply"]
}
]
}

View File

@@ -34,6 +34,7 @@ import type {
} from "../../../protocol/paint-stroke-session";
import type { PaintPBVHCapabilityRequest } from "../../../protocol/paint-pbvh-capability";
import type { LibraryMainAppendReceiptIR, LibraryMainAppendRequestIR } from "../../../protocol/library-main-append";
import type { BlenderOperatorDescriptor, BlenderOperatorInvocation, BlenderOperatorPropertySpec, BlenderOperatorPropertyType } from "../../../protocol/blender-operator";
interface PendingRequest {
resolve: (result: WebEngineResult) => void;
@@ -61,6 +62,55 @@ export interface BlendOpenResult {
simplify?: SimplifyResult;
}
function normalizeOperatorPropertyType(value: unknown, raw: Record<string, unknown>): BlenderOperatorPropertyType {
const type = String(value ?? "string").toLowerCase();
if (type === "boolean" || type === "bool") return "boolean";
if (type === "enum" || Array.isArray(raw.enumItems) || Array.isArray(raw.items) || Array.isArray(raw.enum)) return "enum";
if (type === "number" || type === "integer" || type === "float" || type === "double" || type.startsWith("float[")) return "number";
return "string";
}
function normalizeOperatorCatalog(value: unknown): BlenderOperatorDescriptor[] {
const root = value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
const entries = Array.isArray(value) ? value : root && Array.isArray(root.operators) ? root.operators : [];
return entries.flatMap((entry): BlenderOperatorDescriptor[] => {
if (!entry || typeof entry !== "object") return [];
const raw = entry as Record<string, unknown>;
const id = String(raw.id ?? raw.operator ?? raw.name ?? "").trim();
if (!id) return [];
const properties = Array.isArray(raw.properties) ? raw.properties.flatMap((property): BlenderOperatorPropertySpec[] => {
if (!property || typeof property !== "object") return [];
const spec = property as Record<string, unknown>;
const name = String(spec.name ?? "").trim();
if (!name) return [];
const enumValues = spec.enumItems ?? spec.items ?? spec.enum;
const enumItems = Array.isArray(enumValues) ? enumValues.flatMap((item): { value: string; label: string }[] => {
if (typeof item === "string" || typeof item === "number") return [{ value: String(item), label: String(item) }];
if (!item || typeof item !== "object") return [];
const candidate = item as Record<string, unknown>;
const option = String(candidate.value ?? candidate.identifier ?? candidate.id ?? "");
return option ? [{ value: option, label: String(candidate.label ?? candidate.name ?? option) }] : [];
}) : undefined;
const type = normalizeOperatorPropertyType(spec.type, spec);
return [{
name,
type,
required: Boolean(spec.required),
label: typeof spec.label === "string" ? spec.label : undefined,
description: typeof spec.description === "string" ? spec.description : undefined,
defaultValue: typeof spec.defaultValue === "string" || typeof spec.defaultValue === "number" || typeof spec.defaultValue === "boolean"
? spec.defaultValue
: typeof spec.default === "string" || typeof spec.default === "number" || typeof spec.default === "boolean" ? spec.default : undefined,
min: typeof spec.min === "number" ? spec.min : typeof spec.minValue === "number" ? spec.minValue : undefined,
max: typeof spec.max === "number" ? spec.max : typeof spec.maxValue === "number" ? spec.maxValue : undefined,
enumItems,
}];
}) : [];
const context = raw.context === "object" || raw.context === "main" ? raw.context : "scene";
return [{ id, label: String(raw.label ?? raw.name ?? id), description: typeof raw.description === "string" ? raw.description : undefined, family: String(raw.family ?? "Blender"), context, backgroundSafe: true, properties }];
});
}
export class WebEngineClient {
private readonly timeoutMs: number;
private readonly workerFactory: () => Worker;
@@ -128,6 +178,30 @@ export class WebEngineClient {
return { status: result.status, result: result.json as T };
}
async listBlenderOperators(): Promise<{ status: WebEngineStatus; operators: BlenderOperatorDescriptor[] }> {
const { status, result } = await this.callJson<unknown>("listBlenderOperators");
return { status, operators: normalizeOperatorCatalog(result) };
}
async invokeBlenderOperator(invocation: BlenderOperatorInvocation): Promise<BlendOpenResult & { delta: SceneDelta; operatorResult?: unknown }> {
if (!invocation.operator.trim()) throw this.report("INVALID_ARGUMENT", "Blender operator 不能为空", true);
const result = await this.request({ type: "jsonCommand", cmd: "invokeBlenderOperator", params: invocation as unknown as Record<string, unknown> });
if (!result.snapshot || !result.delta) throw this.report("INVALID_ARGUMENT", "Blender operator 未返回场景结果", true);
this.geometryBuffers = result.geometryDelta
? applyMeshGeometryDelta(this.geometryBuffers, result.geometryDelta)
: result.geometryBuffers ?? this.geometryBuffers;
this.nonMeshGeometryBuffers = result.nonMeshGeometryBuffers ?? this.nonMeshGeometryBuffers;
return {
status: result.status,
snapshot: result.snapshot,
geometryBuffers: [...this.geometryBuffers],
nonMeshGeometryBuffers: [...this.nonMeshGeometryBuffers],
delta: result.delta,
simplify: result.simplify,
operatorResult: result.json,
};
}
async delta(): Promise<{ status: WebEngineStatus; delta: SceneDelta }> {
const result = await this.request({ type: "delta" });
if (!result.delta) throw this.report("INVALID_ARGUMENT", "WebEngine 未返回 SceneDelta", true);

View File

@@ -11,6 +11,9 @@ export type {
SequencerCodecProbeRequestIR,
SequencerCodecProbeResultIR,
SequencerFrameStripIR,
SequencerEditIR,
SequencerStripIR,
SequencerStripType,
SequencerTimelineIR,
SequencerTransitionFrameIR,
} from "../../../protocol/sequencer";

View File

@@ -1,7 +1,8 @@
import type { ErrorReport } from "../../../protocol/error";
import { parseSceneDelta, type SceneDelta } from "../../../protocol/scene-delta";
import type { MeshGeometryBuffer, WebEngineEditCommand, WebEngineJsonCommand, WebEngineLODError, WebEngineLODLevelResult, WebEngineOpenResourceStatus, WebEngineRequest, WebEngineResponse, WebEngineResult, WebEngineStatus } from "../../../protocol/web-engine";
import type { MeshGeometryBuffer, WebEngineEditCommand, WebEngineJsonCommand, WebEngineLODError, WebEngineLODLevelResult, WebEngineOpenResourceStatus, WebEngineOperatorInvocation, WebEngineOperatorProperty, WebEngineRequest, WebEngineResponse, WebEngineResult, WebEngineStatus } from "../../../protocol/web-engine";
import { parseSceneSnapshotIR } from "../../../protocol/scene-ir";
import { parseSequencerTimeline } from "../../../protocol/sequencer";
import { parseEditorWorkflow, EditorWorkflowValidationError } from "../../../protocol/editor-workflow";
import { parseLODManifest, type SimplifyResult } from "../../../protocol/simplify";
import type { LODGenerationRequest } from "../../../protocol/lod";
@@ -151,6 +152,42 @@ function validPropertyKeys(value: unknown, allowed: readonly string[]): value is
Object.keys(value).length > 0 && Object.keys(value).every((key) => allowed.includes(key));
}
function validOperatorJson(value: unknown, depth = 0): value is WebEngineOperatorProperty {
if (depth > 8 || value === null || typeof value === "string" || typeof value === "boolean") return true;
if (typeof value === "number") return Number.isFinite(value);
if (Array.isArray(value)) return value.length <= 1024 && value.every((item) => validOperatorJson(item, depth + 1));
if (typeof value === "object") {
const entries = Object.entries(value);
return entries.length <= 128 && entries.every(([key, item]) => key.length > 0 && key.length <= 64 && validOperatorJson(item, depth + 1));
}
return false;
}
function validateOperatorInvocation(value: unknown): WebEngineOperatorInvocation {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw report("INVALID_ARGUMENT", "invokeBlenderOperator requires an object params payload", true);
}
const params = value as Record<string, unknown>;
if (Object.keys(params).some((key) => !["operator", "properties", "baseRevision"].includes(key))) {
throw report("INVALID_ARGUMENT", "invokeBlenderOperator received an unknown parameter", true);
}
if (typeof params.operator !== "string" || params.operator.length === 0 || params.operator.length > 128) {
throw report("INVALID_ARGUMENT", "invokeBlenderOperator requires a bounded operator identifier", true);
}
const properties = params.properties === undefined ? {} : params.properties;
if (typeof properties !== "object" || properties === null || Array.isArray(properties) || !validOperatorJson(properties)) {
throw report("INVALID_ARGUMENT", "invokeBlenderOperator properties must be typed JSON", true);
}
if (params.baseRevision !== undefined && (!Number.isSafeInteger(params.baseRevision) || params.baseRevision < 0)) {
throw report("REVISION_CONFLICT", "invokeBlenderOperator baseRevision must be a non-negative integer", true);
}
return {
operator: params.operator,
properties: properties as Record<string, WebEngineOperatorProperty>,
...(params.baseRevision === undefined ? {} : { baseRevision: params.baseRevision as number }),
};
}
function reportProtocolError(error: unknown, fallbackCode: ErrorReport["code"]): never {
if (typeof error === "object" && error !== null && "code" in error && typeof (error as { code?: unknown }).code === "string") {
const candidate = error as { code: ErrorReport["code"]; message?: unknown };
@@ -246,13 +283,25 @@ function sculptMeshGate(meshId: string): CapabilityGateResult {
}
function assertFutureCapability(payload: Extract<WebEngineRequest["command"], { type: "applyCommand" }>["payload"]): void {
if (payload.type === "createAction") {
if (typeof payload.name !== "string" || payload.name.trim().length === 0 || payload.name.length > 256) throw report("ANIMATION_ACTION_INVALID", "Action name is required");
if (payload.objectId !== undefined && typeof payload.objectId !== "string") throw report("ANIMATION_ACTION_INVALID", "Action objectId is invalid");
}
if (payload.type === "removeAction" && (typeof payload.actionId !== "string" || payload.actionId.length === 0)) throw report("ANIMATION_ACTION_INVALID", "Action id is required");
if (payload.type === "setKeyframe") {
if (typeof payload.animationId !== "string" || typeof payload.path !== "string" || !Number.isFinite(payload.frame)) throw report("ANIMATION_KEYFRAME_INVALID", "Keyframe identity is invalid");
if (payload.value !== undefined && (!Array.isArray(payload.value) || !payload.value.every((value) => typeof value === "number" && Number.isFinite(value)))) throw report("ANIMATION_KEYFRAME_INVALID", "Keyframe value is invalid");
for (const handle of [payload.handleLeft, payload.handleRight]) if (handle !== undefined && (!Array.isArray(handle) || handle.length !== 2 || !handle.every((value) => typeof value === "number" && Number.isFinite(value)))) throw report("ANIMATION_KEYFRAME_INVALID", "Keyframe handle is invalid");
}
if (payload.type === "setTimelineMarkers") {
if (typeof payload.dataId !== "string" || !Array.isArray(payload.markers) || payload.markers.length > 10000 || payload.markers.some((marker) => !marker || !Number.isFinite(marker.frame) || typeof marker.name !== "string" || marker.name.length > 256)) throw report("ANIMATION_MARKER_INVALID", "Timeline markers are invalid");
}
const modifierAuthoringCommands = new Set(["addModifier", "removeModifier", "moveModifier", "setModifierParameters"]);
if (modifierAuthoringCommands.has(payload.type)) return;
switch (payload.type) {
case "sculptStroke": {
try {
const stroke = parseSculptStroke(payload.stroke);
if (!stroke.mirrorObjectSpace) {
throw report("CAPABILITY_MISSING", "N-011 bounded Sculpt strokes require object-space mirror samples");
}
const gate = sculptMeshGate(stroke.meshId);
if (gate.status === "BLOCKED") rejectCapabilityGate(gate);
const mesh = currentSnapshot?.meshes.find((candidate) => candidate.id === stroke.meshId);
@@ -466,6 +515,9 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
if (payload.velocityScale !== undefined && (!Number.isFinite(payload.velocityScale) || payload.velocityScale < -1_000_000 || payload.velocityScale > 1_000_000)) throw report("NON_MESH_PROPERTY_INVALID", "Volume velocity scale is outside the bounded range");
return;
}
case "setArmatureBones":
case "setPoseBoneTransform":
return;
case "setMetaballElements": {
const data = currentSnapshot?.nonMeshData?.find((candidate) => candidate.id === payload.dataId);
if (!data || data.type !== "METABALL") throw report("NON_MESH_DATA_UNSUPPORTED", `N-015 Metaball data block is unavailable: ${payload.dataId}`);
@@ -904,6 +956,19 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
if (gate.status === "BLOCKED") rejectCapabilityGate(gate);
return;
}
case "setSequencerStrips": {
const scene = currentSnapshot?.scenes.find((candidate) => candidate.id === payload.sceneId);
if (!scene) throw report("SEQUENCER_SCHEMA_INVALID", `Sequencer scene does not exist: ${payload.sceneId}`);
try {
const timeline = parseSequencerTimeline(payload.timeline);
if (timeline.revision !== currentSnapshot?.revision) throw report("REVISION_CONFLICT", "Sequencer timeline revision does not match the current SceneIR");
if (timeline.id !== `sequencer:${scene.id}`) throw report("SEQUENCER_SCHEMA_INVALID", "Sequencer timeline does not target the requested scene");
}
catch (error) {
reportProtocolError(error, "SEQUENCER_SCHEMA_INVALID");
}
return;
}
}
}
@@ -1024,8 +1089,9 @@ function callUnifiedJson<T>(cmd: string, params: Record<string, unknown> = {}, t
}
}
const READ_ONLY_JSON_COMMANDS: ReadonlySet<WebEngineJsonCommand> = new Set(["snapshot", "delta", "depsgraph", "memoryStats"]);
const STATEFUL_JSON_COMMANDS: ReadonlySet<WebEngineJsonCommand> = new Set(["applyCommand", "undo", "redo"]);
type WebEngineRouteJsonCommand = WebEngineJsonCommand | "importScene" | "exportScene";
const READ_ONLY_JSON_COMMANDS: ReadonlySet<WebEngineRouteJsonCommand> = new Set(["snapshot", "delta", "depsgraph", "memoryStats", "listBlenderOperators", "exportScene"]);
const STATEFUL_JSON_COMMANDS: ReadonlySet<WebEngineRouteJsonCommand> = new Set(["applyCommand", "undo", "redo", "invokeBlenderOperator", "importScene"]);
function readSnapshot(): ReturnType<typeof parseSceneSnapshotIR> {
const snapshot = parseSceneSnapshotIR(callUnifiedJson<unknown>("snapshot"));
@@ -1510,15 +1576,23 @@ async function handleRequest(event: MessageEvent<WebEngineRequest>): Promise<voi
break;
case "jsonCommand": {
await initialize();
if (!READ_ONLY_JSON_COMMANDS.has(request.command.cmd) && !STATEFUL_JSON_COMMANDS.has(request.command.cmd)) {
throw report("INVALID_ARGUMENT", `Unsupported JSON command: ${request.command.cmd}`, true);
const commandName = request.command.cmd as WebEngineRouteJsonCommand;
if (!READ_ONLY_JSON_COMMANDS.has(commandName) && !STATEFUL_JSON_COMMANDS.has(commandName)) {
throw report("INVALID_ARGUMENT", `Unsupported JSON command: ${commandName}`, true);
}
if (STATEFUL_JSON_COMMANDS.has(request.command.cmd) && request.command.cmd === "applyCommand" &&
if (STATEFUL_JSON_COMMANDS.has(commandName) && commandName === "applyCommand" &&
(!request.command.params || typeof request.command.params.command !== "object" || request.command.params.command === null)) {
throw report("INVALID_ARGUMENT", "cmd json applyCommand requires params.command", true);
}
const jsonResult = callUnifiedJson<unknown>(request.command.cmd, request.command.params ?? {});
if (STATEFUL_JSON_COMMANDS.has(request.command.cmd)) {
if (commandName === "invokeBlenderOperator") validateOperatorInvocation(request.command.params);
if (commandName === "importScene" && (!request.command.params || typeof request.command.params.format !== "string" || typeof request.command.params.base64 !== "string")) {
throw report("INVALID_ARGUMENT", "importScene requires format and base64 payload", true);
}
if (commandName === "exportScene" && (!request.command.params || typeof request.command.params.format !== "string")) {
throw report("INVALID_ARGUMENT", "exportScene requires format", true);
}
const jsonResult = callUnifiedJson<unknown>(commandName, request.command.params ?? {});
if (STATEFUL_JSON_COMMANDS.has(commandName)) {
const scene = await readSnapshotResult();
currentSnapshot = scene.snapshot;
result = {

View File

@@ -11,6 +11,18 @@ export const animationRenderCommands = Object.freeze({
deleteKeyframe(objectId: string, frame: number, property?: AnimationCommand<"deleteObjectKeyframe">["property"]): AnimationCommand<"deleteObjectKeyframe"> {
return { type: "deleteObjectKeyframe", objectId, frame, ...(property ? { property } : {}) };
},
createAction(name: string, objectId?: string): AnimationCommand<"createAction"> {
return { type: "createAction", name, ...(objectId ? { objectId } : {}) };
},
removeAction(actionId: string): AnimationCommand<"removeAction"> {
return { type: "removeAction", actionId };
},
setKeyframe(animationId: string, path: string, frame: number, options: Omit<AnimationCommand<"setKeyframe">, "type" | "animationId" | "path" | "frame"> = {}): AnimationCommand<"setKeyframe"> {
return { type: "setKeyframe", animationId, path, frame, ...options };
},
setTimelineMarkers(dataId: string, markers: AnimationCommand<"setTimelineMarkers">["markers"]): AnimationCommand<"setTimelineMarkers"> {
return { type: "setTimelineMarkers", dataId, markers: markers.map((marker) => ({ ...marker })) };
},
setConstraint(objectId: string, constraintName: string, enabled: boolean, influence: number): AnimationCommand<"setConstraint"> {
return { type: "setConstraint", objectId, constraintName, enabled, influence };
},

View File

@@ -0,0 +1,36 @@
export type BlenderOperatorPropertyType = "boolean" | "number" | "enum" | "string";
export type BlenderOperatorPropertyValue = string | number | boolean;
export interface BlenderOperatorEnumItem {
value: string;
label: string;
}
export interface BlenderOperatorPropertySpec {
name: string;
type: BlenderOperatorPropertyType;
required: boolean;
label?: string;
description?: string;
defaultValue?: BlenderOperatorPropertyValue;
min?: number;
max?: number;
enumItems?: BlenderOperatorEnumItem[];
}
export interface BlenderOperatorDescriptor {
id: string;
label: string;
description?: string;
family: string;
context: "scene" | "object" | "main";
backgroundSafe: true;
properties: BlenderOperatorPropertySpec[];
}
export interface BlenderOperatorInvocation {
operator: string;
properties?: Record<string, BlenderOperatorPropertyValue>;
baseRevision?: number;
}

View File

@@ -1,4 +1,4 @@
import type { WebEngineEditCommand } from "./web-engine";
import type { MeshPrimitive, WebEngineEditCommand } from "./web-engine";
type CoreCommand<T extends WebEngineEditCommand["type"]> = Extract<WebEngineEditCommand, { type: T }>;
type Vector3 = [number, number, number];
@@ -7,6 +7,9 @@ const vector3 = (value: readonly number[]): Vector3 => [value[0] ?? 0, value[1]
/** Typed constructors used by the workbench for the source-backed scene API. */
export const coreSceneCommands = Object.freeze({
createPrimitive(primitive: MeshPrimitive, location: readonly number[] = [0, 0, 0], name?: string): CoreCommand<"createPrimitive"> {
return { type: "createPrimitive", primitive, location: vector3(location), ...(name ? { name } : {}) };
},
createCube(location: readonly number[] = [0, 0, 0], name?: string): CoreCommand<"createPrimitive"> {
return { type: "createPrimitive", primitive: "CUBE", location: vector3(location), ...(name ? { name } : {}) };
},

View File

@@ -1,8 +1,70 @@
import type { GeometryNodeGraphIR } from "./geometry-nodes";
import { GEOMETRY_NODE_GRAPH_SCHEMA, type GeometryNodeDataType, type GeometryNodeGraphIR, type GeometryNodeIR, type GeometryNodeLinkIR, type GeometryNodeSocketIR } from "./geometry-nodes";
import type { WebEngineEditCommand } from "./web-engine";
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
const socket = (id: string, name: string, direction: "INPUT" | "OUTPUT", dataType: GeometryNodeDataType, defaultValue?: boolean | number | string | number[]): GeometryNodeSocketIR => ({ id, name, direction, dataType, ...(defaultValue === undefined ? {} : { defaultValue }) });
const nodeSockets = (type: string): GeometryNodeSocketIR[] => {
if (type === "NodeGroupInput") return [socket("Geometry", "Geometry", "OUTPUT", "GEOMETRY")];
if (type === "NodeGroupOutput") return [socket("Geometry", "Geometry", "INPUT", "GEOMETRY")];
if (type === "GeometryNodeTransform") return [socket("Geometry", "Geometry", "INPUT", "GEOMETRY"), socket("Translation", "Translation", "INPUT", "VECTOR", [0, 0, 0]), socket("Rotation", "Rotation", "INPUT", "VECTOR", [0, 0, 0]), socket("Scale", "Scale", "INPUT", "VECTOR", [1, 1, 1]), socket("Geometry", "Geometry", "OUTPUT", "GEOMETRY")];
if (type === "GeometryNodeSetPosition") return [socket("Geometry", "Geometry", "INPUT", "GEOMETRY"), socket("Offset", "Offset", "INPUT", "VECTOR", [0, 0, 0]), socket("Position", "Position", "INPUT", "VECTOR", [0, 0, 0]), socket("Geometry", "Geometry", "OUTPUT", "GEOMETRY")];
if (type === "GeometryNodeJoinGeometry") return [socket("Geometry", "Geometry", "INPUT", "GEOMETRY"), socket("Geometry", "Geometry", "OUTPUT", "GEOMETRY")];
if (type === "GeometryNodeRealizeInstances") return [socket("Geometry", "Geometry", "INPUT", "GEOMETRY"), socket("Geometry", "Geometry", "OUTPUT", "GEOMETRY")];
if (type === "FunctionNodeInputInt") return [socket("Value", "Value", "OUTPUT", "INT", 0)];
if (type === "FunctionNodeInputVector") return [socket("Vector", "Vector", "OUTPUT", "VECTOR", [0, 0, 0])];
if (type === "ShaderNodeValue") return [socket("Value", "Value", "OUTPUT", "FLOAT", 0)];
if (type === "ShaderNodeMath") return [socket("Value", "Value", "INPUT", "FLOAT", 0), socket("Value_001", "Value", "INPUT", "FLOAT", 0), socket("Value", "Value", "OUTPUT", "FLOAT")];
return [];
};
function uniqueId(base: string, ids: ReadonlySet<string>): string {
let id = base;
let suffix = 2;
while (ids.has(id)) id = `${base}-${suffix++}`;
return id;
}
/** Workbench constructors for validated Geometry Node graph transactions. */
export const geometryNodeCommands = Object.freeze({
createGraph(meshId: string, name = "Web Geometry") : GeometryNodeGraphIR {
const input = { id: "group-input", type: "NodeGroupInput", name: "Group Input", sockets: [socket("Geometry", "Geometry", "OUTPUT", "GEOMETRY")] };
const output = { id: "group-output", type: "NodeGroupOutput", name: "Group Output", sockets: [socket("Geometry", "Geometry", "INPUT", "GEOMETRY")] };
return { schemaVersion: GEOMETRY_NODE_GRAPH_SCHEMA, id: `geometry-graph:${meshId}`, name, interfaceInputs: [socket("Geometry", "Geometry", "INPUT", "GEOMETRY")], interfaceOutputs: [socket("Geometry", "Geometry", "OUTPUT", "GEOMETRY")], nodes: [input, output], links: [{ fromNodeId: input.id, fromSocketId: "Geometry", toNodeId: output.id, toSocketId: "Geometry" }] };
},
addNode(graph: GeometryNodeGraphIR, type: string, name = type): GeometryNodeGraphIR {
const next = clone(graph);
const ids = new Set(next.nodes.map((node) => node.id));
const id = uniqueId(`node-${type.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`, ids);
const node: GeometryNodeIR = { id, type, name: name.trim() || type, sockets: nodeSockets(type) };
next.nodes.push(node);
return next;
},
removeNode(graph: GeometryNodeGraphIR, nodeId: string): GeometryNodeGraphIR {
const next = clone(graph);
next.nodes = next.nodes.filter((node) => node.id !== nodeId);
next.links = next.links.filter((link) => link.fromNodeId !== nodeId && link.toNodeId !== nodeId);
return next;
},
connect(graph: GeometryNodeGraphIR, link: GeometryNodeLinkIR): GeometryNodeGraphIR {
const next = clone(graph);
next.links = next.links.filter((item) => !(item.toNodeId === link.toNodeId && item.toSocketId === link.toSocketId));
if (!next.links.some((item) => item.fromNodeId === link.fromNodeId && item.fromSocketId === link.fromSocketId && item.toNodeId === link.toNodeId && item.toSocketId === link.toSocketId)) next.links.push(link);
return next;
},
disconnect(graph: GeometryNodeGraphIR, link: GeometryNodeLinkIR): GeometryNodeGraphIR {
const next = clone(graph);
next.links = next.links.filter((item) => !(item.fromNodeId === link.fromNodeId && item.fromSocketId === link.fromSocketId && item.toNodeId === link.toNodeId && item.toSocketId === link.toSocketId));
return next;
},
setSocketValue(graph: GeometryNodeGraphIR, nodeId: string, socketId: string, value: GeometryNodeSocketIR["defaultValue"]): GeometryNodeGraphIR {
const next = clone(graph);
const node = next.nodes.find((candidate) => candidate.id === nodeId);
const target = node?.sockets.find((candidate) => candidate.id === socketId && candidate.direction === "INPUT");
if (target) target.defaultValue = value;
return next;
},
setGraph(meshId: string, graph: GeometryNodeGraphIR): Extract<WebEngineEditCommand, { type: "setGeometryNodeGraph" }> {
return { type: "setGeometryNodeGraph", meshId, graph };
},

View File

@@ -51,6 +51,9 @@ const FORMAT_EXTENSIONS: Record<IOFormatMatrixFormat, readonly string[]> = {
ALEMBIC: [".abc"],
};
/** Formats with a browser file boundary and a native WebEngine command route. */
const LOCAL_SCENE_FORMATS: readonly IOFormatMatrixFormat[] = ["GLTF", "GLB", "OBJ", "STL", "PLY"];
function routeFor(
matrix: IOFormatCapabilityMatrixIR,
operation: IOFormatMatrixOperation,
@@ -60,7 +63,8 @@ function routeFor(
.filter((entry) => {
const route = entry.operations[operation][execution.toLowerCase() as "local" | "server"];
const runtimeStatus = operation === "IMPORT" ? entry.runtimeImportStatus : entry.runtimeExportStatus;
return runtimeStatus === "AVAILABLE" && route.status === "READY" && route.execution === execution;
return route.status === "READY" && route.execution === execution &&
(execution === "SERVER" || (runtimeStatus === "AVAILABLE" && LOCAL_SCENE_FORMATS.includes(entry.format)));
})
.map((entry) => ({
format: entry.format,
@@ -70,6 +74,15 @@ function routeFor(
}));
}
function browserSceneRoutes(operation: IOFormatMatrixOperation): IOFormatUIRouteIR[] {
return LOCAL_SCENE_FORMATS.map((format) => ({
format,
operation,
execution: "LOCAL" as const,
extensions: [...FORMAT_EXTENSIONS[format]],
}));
}
export function buildIOFormatUIRegistry(matrix: IOFormatCapabilityMatrixIR, parentMatrixSha256: string): IOFormatUIRegistryIR {
if (!SHA256.test(parentMatrixSha256)) throw new IOFormatUIGateError("parent matrix SHA-256 is invalid");
return {
@@ -77,8 +90,8 @@ export function buildIOFormatUIRegistry(matrix: IOFormatCapabilityMatrixIR, pare
task: IO_FORMAT_UI_TASK,
parentMatrixSha256,
projectFileAccept: IO_FORMAT_PROJECT_ACCEPT,
importRoutes: routeFor(matrix, "IMPORT", "LOCAL"),
exportRoutes: routeFor(matrix, "EXPORT", "LOCAL"),
importRoutes: [...browserSceneRoutes("IMPORT"), ...routeFor(matrix, "IMPORT", "SERVER")],
exportRoutes: [...browserSceneRoutes("EXPORT"), ...routeFor(matrix, "EXPORT", "SERVER")],
};
}
@@ -96,9 +109,9 @@ function record(value: unknown, path: string): Record<string, unknown> {
function parseRoute(value: unknown, path: string, operation: IOFormatMatrixOperation): IOFormatUIRouteIR {
const input = record(value, path);
exactKeys(input, ["format", "operation", "execution", "extensions"], path);
if (typeof input.format !== "string" || !(input.format in FORMAT_EXTENSIONS) || input.operation !== operation || input.execution !== "LOCAL") throw new IOFormatUIGateError(`${path} route identity is invalid`);
if (typeof input.format !== "string" || !(input.format in FORMAT_EXTENSIONS) || input.operation !== operation || !["LOCAL", "SERVER"].includes(input.execution as string)) throw new IOFormatUIGateError(`${path} route identity is invalid`);
if (!Array.isArray(input.extensions) || input.extensions.length !== FORMAT_EXTENSIONS[input.format as IOFormatMatrixFormat].length || input.extensions.some((extension, index) => extension !== FORMAT_EXTENSIONS[input.format as IOFormatMatrixFormat][index])) throw new IOFormatUIGateError(`${path}.extensions are invalid`);
return { format: input.format as IOFormatMatrixFormat, operation, execution: "LOCAL", extensions: [...input.extensions as string[]] };
return { format: input.format as IOFormatMatrixFormat, operation, execution: input.execution as "LOCAL" | "SERVER", extensions: [...input.extensions as string[]] };
}
export function parseIOFormatUIRegistry(value: unknown): IOFormatUIRegistryIR {
@@ -135,11 +148,11 @@ export function filterIOFormatOperatorCommands<T extends { ioFormat?: IOFormatUI
return commands.filter((command) => !command.ioFormat || routeMatches(registry, command.ioFormat));
}
export function gateIOFormatFileSelection(fileName: string, registry: IOFormatUIRegistryIR): { status: "READY"; kind: "BLEND" } | { status: "BLOCKED"; code: "IO_FORMAT_UNSUPPORTED"; extension: string } {
export function gateIOFormatFileSelection(fileName: string, registry: IOFormatUIRegistryIR): { status: "READY"; kind: "BLEND" } | { status: "READY"; kind: "SCENE"; route: IOFormatUIRouteIR } | { status: "BLOCKED"; code: "IO_FORMAT_UNSUPPORTED"; extension: string } {
const extension = fileName.trim().toLowerCase().match(/\.[a-z0-9]+$/)?.[0] ?? "";
if (extension === ".blend") return { status: "READY", kind: "BLEND" };
const route = registry.importRoutes.find((candidate) => candidate.extensions.includes(extension));
if (route) return { status: "BLOCKED", code: "IO_FORMAT_UNSUPPORTED", extension };
if (route) return { status: "READY", kind: "SCENE", route };
return { status: "BLOCKED", code: "IO_FORMAT_UNSUPPORTED", extension };
}

View File

@@ -2,8 +2,32 @@ import type { WebEngineEditCommand } from "./web-engine";
type IOCommand<T extends WebEngineEditCommand["type"]> = Extract<WebEngineEditCommand, { type: T }>;
export type IOSceneFormat = "GLTF" | "GLB" | "OBJ" | "STL" | "PLY" | "USD" | "ALEMBIC";
export interface IOSceneImportCommand {
type: "importScene";
format: IOSceneFormat;
fileName: string;
mimeType: string;
base64: string;
}
export interface IOSceneExportCommand {
type: "exportScene";
format: IOSceneFormat;
fileName: string;
}
export type IOSceneCommand = IOSceneImportCommand | IOSceneExportCommand;
/** Typed constructors for media import and persistence-facing edit commands. */
export const ioMediaCommands = Object.freeze({
importScene(format: IOSceneFormat, fileName: string, mimeType: string, base64: string): IOSceneImportCommand {
return { type: "importScene", format, fileName, mimeType, base64 };
},
exportScene(format: IOSceneFormat, fileName: string): IOSceneExportCommand {
return { type: "exportScene", format, fileName };
},
importImage(name: string, mimeType: IOCommand<"importImage">["mimeType"], width: number, height: number, base64: string): IOCommand<"importImage"> {
return { type: "importImage", name, mimeType, width, height, base64 };
},

View File

@@ -9,6 +9,36 @@ function fnv1a(value: string): number {
return hash >>> 0;
}
export type ModifierParameterMetadata = {
key: string;
value: unknown;
kind: "boolean" | "number" | "string" | "enum" | "object";
options?: string[];
};
/** Derives editable fields from the metadata carried by the authoritative modifier IR. */
export function modifierParameterMetadata(modifier: ModifierIR): ModifierParameterMetadata[] {
return Object.entries(modifier.parameters ?? {}).map(([key, rawValue]) => {
// Main may carry a descriptor when RNA exposes enum choices alongside the
// current value. Keep the descriptor opaque to callers while deriving the
// editable value and choices from it.
const descriptor = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) &&
"value" in rawValue && ("kind" in rawValue || "options" in rawValue)
? rawValue as { value: unknown; kind?: ModifierParameterMetadata["kind"]; options?: unknown[] }
: undefined;
const rawOptions = descriptor?.options ?? (Array.isArray(rawValue) && rawValue.every((entry) => typeof entry === "string") ? rawValue : undefined);
const options = rawOptions?.map(String);
const value = descriptor?.value ?? (options ? options[0] : rawValue);
const kind = descriptor?.kind ?? (
typeof value === "boolean" ? "boolean" :
typeof value === "number" ? "number" :
typeof value === "string" ? (options ? "enum" : "string") :
"object"
);
return { key, value, kind, options: options ?? undefined };
});
}
/** Hashes evaluated modifier identity/state, excluding display timestamps. */
export function modifierStackHash(stack: readonly ModifierIR[] | undefined): string {
const canonical = (stack ?? []).map((modifier) => ({

View File

@@ -1,4 +1,4 @@
import type { SculptBrush, SculptMeshAttributesIR, SculptStrokeIR } from "./sculpt";
import type { SculptBrush, SculptMeshAttributesIR, SculptStrokeIR, SculptStrokeSampleIR } from "./sculpt";
export const PAINT_BUDGET = {
maxSamples: 100_000,
@@ -106,12 +106,30 @@ export function createSculptStroke(
pressure: number,
symmetry: [boolean, boolean, boolean],
mirrorObjectSpace: boolean,
cursorSamples: ReadonlyArray<SculptStrokeSampleIR> = [],
): SculptStrokeIR {
const samples = cursorSamples.length > 0
? cursorSamples.slice(0, 2048).map((sample) => ({
position: [...sample.position] as [number, number, number],
normal: [...sample.normal] as [number, number, number],
radius,
strength,
pressure,
time: sample.time,
}))
: Array.from({ length: 3 }, (_, index) => ({
position: [0, 0, index * 0.01] as [number, number, number],
normal: [0, 0, 1] as [number, number, number],
radius,
strength,
pressure,
time: index,
}));
return {
schemaVersion: 1,
meshId,
brush,
samples: [{ position: [0, 0, 0], normal: [0, 0, 1], radius, strength, pressure, time: 0 }],
samples,
symmetry,
mirrorObjectSpace,
};

View File

@@ -48,6 +48,7 @@ export interface SceneNodeIR {
transform: SceneTransformIR;
assetData?: AssetMetadataIR;
constraints?: ConstraintIR[];
drivers?: DriverIR[];
lightgroup?: string | null;
}
@@ -72,6 +73,39 @@ export interface ConstraintIR {
targetObjectId?: string | null;
}
export interface DriverVariableIR {
name: string;
type: number;
targetCount: number;
targets?: DriverTargetIR[];
}
export interface DriverTargetIR {
idName: string;
dataPath: string;
boneTarget: string;
transformType: number;
rotationMode: number;
flags: number;
options: number;
idType: number;
contextProperty: number;
fallbackValue: number;
}
export interface DriverIR {
path: string;
arrayIndex: number;
editable: boolean;
enabled: boolean;
expression: string;
type: string;
typeCode: number;
flags: number;
influence: number;
variables: DriverVariableIR[];
}
export interface ModifierIR {
uuid: string;
type: string;
@@ -101,6 +135,8 @@ export interface ArmatureBoneIR {
parentId: string | null;
head: [number, number, number];
tail: [number, number, number];
roll?: number;
connected?: boolean;
restMatrix: number[];
poseMatrix?: number[];
}
@@ -444,6 +480,12 @@ export interface SpeakerResourceIR {
pitch: number;
}
export interface TextLineIR {
index: number;
body: string;
byteLength: number;
}
export interface TextResourceIR {
id: string;
name: string;
@@ -451,6 +493,7 @@ export interface TextResourceIR {
sourceSha256: string;
byteLength: number;
lineCount: number;
lines?: TextLineIR[];
internal: boolean;
sourcePath?: string;
isDirty: boolean;
@@ -530,7 +573,9 @@ export interface AnimationIR {
targetId: string;
frameStart: number;
frameEnd: number;
channels: Array<{ path: string; editable?: boolean; enabled?: boolean; selected?: boolean; group?: string; expanded?: boolean; interpolation?: "CONSTANT" | "LINEAR" | "BEZIER" | "MIXED"; keyframes: Array<{ frame: number; value: number[]; interpolation?: "CONSTANT" | "LINEAR" | "BEZIER" }> }>;
slots?: Array<{ identifier: string; idtype: number; handle: number; flags: number }>;
strips?: Array<{ name: string; type: "KEYFRAME"; frameStart: number; frameEnd: number; dataIndex: number }>;
channels: Array<{ path: string; editable?: boolean; enabled?: boolean; selected?: boolean; group?: string; expanded?: boolean; keyframeSource?: "BEZIER" | "SAMPLED"; modifiers?: Array<{ name: string; type: string; typeCode: number; flags: number; expanded: boolean; influence: number; frameStart: number; frameEnd: number; blendIn: number; blendOut: number }>; interpolation?: "CONSTANT" | "LINEAR" | "BEZIER" | "MIXED"; keyframes: Array<{ frame: number; value: number[]; handleLeft?: [number, number]; handleRight?: [number, number]; interpolation?: "CONSTANT" | "LINEAR" | "BEZIER" }> }>;
}
export interface CollectionIR {
@@ -590,6 +635,7 @@ export interface SceneIR {
previewStart: number;
previewEnd: number;
};
timelineMarkers?: Array<{ frame: number; name: string }>;
pipelineSettings?: {
useCompositor: boolean;
useSequencer: boolean;
@@ -654,7 +700,7 @@ export interface SceneSnapshotIR {
editorWorkflowStatus?: "AVAILABLE" | "BLOCKED";
scriptSources?: ScriptSourceInventoryIR;
scriptSourceStatus?: "AVAILABLE" | "BLOCKED";
renderRoutingStatus?: "AVAILABLE" | "BLOCKED";
renderRoutingStatus?: "AVAILABLE" | "BLOCKED" | "MISSING";
renderRouting?: {
schemaVersion: 1;
receiptId: "workflow:render_capability_routing";
@@ -665,7 +711,7 @@ export interface SceneSnapshotIR {
recovery: { cancellation: "OPEN_CANCELLED"; deviceLoss: "RELEASED"; [key: string]: unknown };
[key: string]: unknown;
};
serverJobBindingStatus?: "AVAILABLE" | "BLOCKED";
serverJobBindingStatus?: "AVAILABLE" | "BLOCKED" | "MISSING";
serverJobBinding?: {
schemaVersion: 1;
receiptId: "workflow:server_job_result_binding";
@@ -677,7 +723,7 @@ export interface SceneSnapshotIR {
lifecycle: { duplicate: "REUSED"; stale: "BLOCKED"; cancel: "CANCELLED"; timeout: "TIMEOUT"; oom: "OOM"; publishedOnFailure: false; [key: string]: unknown };
[key: string]: unknown;
};
scriptingPolicyStatus?: "AVAILABLE" | "BLOCKED";
scriptingPolicyStatus?: "AVAILABLE" | "BLOCKED" | "MISSING";
scriptingPolicy?: {
schemaVersion: 1;
policyId: "workflow:scripting_default_deny_audit";
@@ -697,7 +743,7 @@ export interface SceneSnapshotIR {
policyHash: string;
[key: string]: unknown;
};
performanceFaultMatrixStatus?: "AVAILABLE" | "BLOCKED";
performanceFaultMatrixStatus?: "AVAILABLE" | "BLOCKED" | "MISSING";
performanceFaultMatrix?: {
schemaVersion: 1;
receiptId: "workflow:performance_fault_recovery_matrix";
@@ -708,7 +754,7 @@ export interface SceneSnapshotIR {
matrixHash: string;
[key: string]: unknown;
};
releaseCandidateStatus?: "AVAILABLE" | "BLOCKED";
releaseCandidateStatus?: "AVAILABLE" | "BLOCKED" | "MISSING";
releaseCandidate?: {
schemaVersion: 1;
receiptId: "workflow:offline_ci_sbom_and_rollback_release";
@@ -720,7 +766,7 @@ export interface SceneSnapshotIR {
rollback: { runbookSha256: string; [key: string]: unknown };
[key: string]: unknown;
};
gapInventoryStatus?: "AVAILABLE" | "BLOCKED";
gapInventoryStatus?: "AVAILABLE" | "BLOCKED" | "MISSING";
gapInventory?: {
schemaVersion: 1;
receiptId: "workflow:cluster_full_blender_gap_inventory";
@@ -751,7 +797,7 @@ export interface SceneSnapshotIR {
nextTask: "WBV2-P7-002";
[key: string]: unknown;
};
atomicGapQueueStatus?: "AVAILABLE" | "BLOCKED";
atomicGapQueueStatus?: "AVAILABLE" | "BLOCKED" | "MISSING";
atomicGapQueue?: {
schemaVersion: 1;
receiptId: "workflow:generate_atomic_family_gap_queue";
@@ -762,7 +808,7 @@ export interface SceneSnapshotIR {
nextTask: "WBV2-P7-003";
[key: string]: unknown;
};
parityReleaseGateStatus?: "AVAILABLE" | "BLOCKED";
parityReleaseGateStatus?: "AVAILABLE" | "BLOCKED" | "MISSING";
parityReleaseGate?: {
schemaVersion: 1;
receiptId: "workflow:full_parity_candidate_release_gate";
@@ -1072,6 +1118,16 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
const lineCount = requireNumber(text.lineCount, `texts[${index}].lineCount`);
if (!Number.isSafeInteger(byteLength) || byteLength !== new TextEncoder().encode(source).byteLength || byteLength > 1_048_576) throw new Error(`SceneIR.texts[${index}].byteLength is invalid`);
if (!Number.isSafeInteger(lineCount) || lineCount < 1 || lineCount > 65_536) throw new Error(`SceneIR.texts[${index}].lineCount is invalid`);
if (text.lines !== undefined) {
if (!Array.isArray(text.lines) || text.lines.length !== lineCount) throw new Error(`SceneIR.texts[${index}].lines is invalid`);
text.lines.forEach((line, lineIndex) => {
if (!isRecord(line)) throw new Error(`SceneIR.texts[${index}].lines[${lineIndex}] must be an object`);
if (line.index !== lineIndex) throw new Error(`SceneIR.texts[${index}].lines[${lineIndex}].index is invalid`);
const body = requireString(line.body, `texts[${index}].lines[${lineIndex}].body`);
const lineByteLength = requireNumber(line.byteLength, `texts[${index}].lines[${lineIndex}].byteLength`);
if (!Number.isSafeInteger(lineByteLength) || lineByteLength !== new TextEncoder().encode(body).byteLength) throw new Error(`SceneIR.texts[${index}].lines[${lineIndex}].byteLength is invalid`);
});
}
requireBoolean(text.internal, `texts[${index}].internal`);
if (text.sourcePath !== undefined) requireString(text.sourcePath, `texts[${index}].sourcePath`);
requireBoolean(text.isDirty, `texts[${index}].isDirty`);
@@ -1153,6 +1209,34 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
requireTuple(node.transform.rotationEuler, 3, `nodes[${index}].transform.rotationEuler`);
requireTuple(node.transform.scale, 3, `nodes[${index}].transform.scale`);
requireNumber(node.transform.rotationMode, `nodes[${index}].transform.rotationMode`);
if (node.drivers !== undefined) {
const drivers = requireArray(node.drivers, `nodes[${index}].drivers`);
if (drivers.length > 256) throw new Error(`SceneIR.nodes[${index}].drivers exceeds the budget`);
for (const [driverIndex, driver] of drivers.entries()) {
if (!isRecord(driver)) throw new Error(`SceneIR.nodes[${index}].drivers[${driverIndex}] must be an object`);
for (const field of ["path", "expression", "type"] as const) requireString(driver[field], `nodes[${index}].drivers[${driverIndex}].${field}`);
for (const field of ["arrayIndex", "typeCode", "flags", "influence"] as const) requireNumber(driver[field], `nodes[${index}].drivers[${driverIndex}].${field}`);
requireBoolean(driver.editable, `nodes[${index}].drivers[${driverIndex}].editable`);
requireBoolean(driver.enabled, `nodes[${index}].drivers[${driverIndex}].enabled`);
const variables = requireArray(driver.variables, `nodes[${index}].drivers[${driverIndex}].variables`);
if (variables.length > 64) throw new Error(`SceneIR.nodes[${index}].drivers[${driverIndex}].variables exceeds the budget`);
for (const [variableIndex, variable] of variables.entries()) {
if (!isRecord(variable)) throw new Error(`SceneIR.nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}] must be an object`);
requireString(variable.name, `nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].name`);
requireNumber(variable.type, `nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].type`);
requireNumber(variable.targetCount, `nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].targetCount`);
if (variable.targets !== undefined) {
const targets = requireArray(variable.targets, `nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].targets`);
if (targets.length !== variable.targetCount) throw new Error(`SceneIR.nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].targets count mismatch`);
for (const [targetIndex, target] of targets.entries()) {
if (!isRecord(target)) throw new Error(`SceneIR.nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].targets[${targetIndex}] must be an object`);
for (const field of ["idName", "dataPath", "boneTarget"] as const) requireString(target[field], `nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].targets[${targetIndex}].${field}`);
for (const field of ["transformType", "rotationMode", "flags", "options", "idType", "contextProperty", "fallbackValue"] as const) requireNumber(target[field], `nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].targets[${targetIndex}].${field}`);
}
}
}
}
}
}
for (const [index, mesh] of meshes.entries()) {
if (!isRecord(mesh)) throw new Error(`SceneIR.meshes[${index}] must be an object`);
@@ -1287,6 +1371,8 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
if (bone.parentId !== null) requireString(bone.parentId, `armatures[${index}].bones[${boneIndex}].parentId`);
requireTuple(bone.head, 3, `armatures[${index}].bones[${boneIndex}].head`);
requireTuple(bone.tail, 3, `armatures[${index}].bones[${boneIndex}].tail`);
if (bone.roll !== undefined) requireNumber(bone.roll, `armatures[${index}].bones[${boneIndex}].roll`);
if (bone.connected !== undefined) requireBoolean(bone.connected, `armatures[${index}].bones[${boneIndex}].connected`);
requireTuple(bone.restMatrix, 16, `armatures[${index}].bones[${boneIndex}].restMatrix`);
if (bone.poseMatrix !== undefined) requireTuple(bone.poseMatrix, 16, `armatures[${index}].bones[${boneIndex}].poseMatrix`);
}
@@ -1624,6 +1710,14 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
for (const field of ["fps", "fpsBase", "frameStep", "previewStart", "previewEnd"] as const) requireNumber(scene.timelineSettings[field], `scenes[${index}].timelineSettings.${field}`);
requireBoolean(scene.timelineSettings.usePreviewRange, `scenes[${index}].timelineSettings.usePreviewRange`);
}
if (scene.timelineMarkers !== undefined) {
const markers = requireArray(scene.timelineMarkers, `scenes[${index}].timelineMarkers`);
for (const [markerIndex, marker] of markers.entries()) {
if (!isRecord(marker)) throw new Error(`SceneIR.scenes[${index}].timelineMarkers[${markerIndex}] must be an object`);
requireNumber(marker.frame, `scenes[${index}].timelineMarkers[${markerIndex}].frame`);
requireString(marker.name, `scenes[${index}].timelineMarkers[${markerIndex}].name`);
}
}
if (scene.pipelineSettings !== undefined) {
if (!isRecord(scene.pipelineSettings)) throw new Error(`SceneIR.scenes[${index}].pipelineSettings must be an object`);
for (const field of ["useCompositor", "useSequencer", "noOverwrite", "saveOutput", "alphaPremultiplied"] as const) requireBoolean(scene.pipelineSettings[field], `scenes[${index}].pipelineSettings.${field}`);
@@ -1719,10 +1813,45 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
requireString(animation.targetId, `animations[${index}].targetId`);
requireNumber(animation.frameStart, `animations[${index}].frameStart`);
requireNumber(animation.frameEnd, `animations[${index}].frameEnd`);
if (animation.slots !== undefined) {
const slots = requireArray(animation.slots, `animations[${index}].slots`);
if (slots.length > 1024) throw new Error(`animations[${index}].slots exceeds the budget`);
for (const [slotIndex, slot] of slots.entries()) {
if (!isRecord(slot)) throw new Error(`animations[${index}].slots[${slotIndex}] must be an object`);
requireString(slot.identifier, `animations[${index}].slots[${slotIndex}].identifier`);
for (const field of ["idtype", "handle", "flags"]) requireNumber(slot[field], `animations[${index}].slots[${slotIndex}].${field}`);
}
}
if (animation.strips !== undefined) {
const strips = requireArray(animation.strips, `animations[${index}].strips`);
if (strips.length > 1024) throw new Error(`animations[${index}].strips exceeds the budget`);
for (const [stripIndex, strip] of strips.entries()) {
if (!isRecord(strip)) throw new Error(`animations[${index}].strips[${stripIndex}] must be an object`);
requireString(strip.name, `animations[${index}].strips[${stripIndex}].name`);
if (strip.type !== "KEYFRAME") throw new Error(`animations[${index}].strips[${stripIndex}].type is invalid`);
requireNumber(strip.frameStart, `animations[${index}].strips[${stripIndex}].frameStart`);
requireNumber(strip.frameEnd, `animations[${index}].strips[${stripIndex}].frameEnd`);
requireNumber(strip.dataIndex, `animations[${index}].strips[${stripIndex}].dataIndex`);
}
}
const channels = requireArray(animation.channels, `animations[${index}].channels`);
for (const [channelIndex, channel] of channels.entries()) {
if (!isRecord(channel)) throw new Error(`SceneIR.animations[${index}].channels[${channelIndex}] must be an object`);
requireString(channel.path, `animations[${index}].channels[${channelIndex}].path`);
if (channel.keyframeSource !== undefined && channel.keyframeSource !== "BEZIER" && channel.keyframeSource !== "SAMPLED") {
throw new Error(`animations[${index}].channels[${channelIndex}].keyframeSource is invalid`);
}
if (channel.modifiers !== undefined) {
const modifiers = requireArray(channel.modifiers, `animations[${index}].channels[${channelIndex}].modifiers`);
if (modifiers.length > 64) throw new Error(`animations[${index}].channels[${channelIndex}].modifiers exceeds the budget`);
for (const [modifierIndex, modifier] of modifiers.entries()) {
if (!isRecord(modifier)) throw new Error(`animations[${index}].channels[${channelIndex}].modifiers[${modifierIndex}] must be an object`);
requireString(modifier.name, `animations[${index}].channels[${channelIndex}].modifiers[${modifierIndex}].name`);
requireString(modifier.type, `animations[${index}].channels[${channelIndex}].modifiers[${modifierIndex}].type`);
for (const field of ["typeCode", "flags", "influence", "frameStart", "frameEnd", "blendIn", "blendOut"]) requireNumber(modifier[field], `animations[${index}].channels[${channelIndex}].modifiers[${modifierIndex}].${field}`);
if (typeof modifier.expanded !== "boolean") throw new Error(`animations[${index}].channels[${channelIndex}].modifiers[${modifierIndex}].expanded is invalid`);
}
}
const keyframes = requireArray(channel.keyframes, `animations[${index}].channels[${channelIndex}].keyframes`);
for (const [keyframeIndex, keyframe] of keyframes.entries()) {
if (!isRecord(keyframe)) throw new Error(`SceneIR.animations[${index}].channels[${channelIndex}].keyframes[${keyframeIndex}] must be an object`);
@@ -1809,7 +1938,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
parseScriptSourceInventory(value.scriptSources);
if (value.scriptSourceStatus !== "AVAILABLE") throw new Error("SceneIR.scriptSourceStatus must be AVAILABLE when scriptSources is present");
}
if (value.renderRoutingStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.renderRoutingStatus as string)) {
if (value.renderRoutingStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.renderRoutingStatus as string)) {
throw new Error("SceneIR.renderRoutingStatus is invalid");
}
if (value.renderRouting !== undefined) {
@@ -1825,7 +1954,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
}
if (value.renderRoutingStatus !== "AVAILABLE") throw new Error("SceneIR.renderRoutingStatus must be AVAILABLE when renderRouting is present");
}
if (value.serverJobBindingStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.serverJobBindingStatus as string)) {
if (value.serverJobBindingStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.serverJobBindingStatus as string)) {
throw new Error("SceneIR.serverJobBindingStatus is invalid");
}
if (value.serverJobBinding !== undefined) {
@@ -1843,7 +1972,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
}
if (value.serverJobBindingStatus !== "AVAILABLE") throw new Error("SceneIR.serverJobBindingStatus must be AVAILABLE when serverJobBinding is present");
}
if (value.scriptingPolicyStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.scriptingPolicyStatus as string)) {
if (value.scriptingPolicyStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.scriptingPolicyStatus as string)) {
throw new Error("SceneIR.scriptingPolicyStatus is invalid");
}
if (value.scriptingPolicy !== undefined) {
@@ -1871,7 +2000,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
}
if (value.scriptingPolicyStatus !== "AVAILABLE") throw new Error("SceneIR.scriptingPolicyStatus must be AVAILABLE when scriptingPolicy is present");
}
if (value.performanceFaultMatrixStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.performanceFaultMatrixStatus as string)) {
if (value.performanceFaultMatrixStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.performanceFaultMatrixStatus as string)) {
throw new Error("SceneIR.performanceFaultMatrixStatus is invalid");
}
if (value.performanceFaultMatrix !== undefined) {
@@ -1892,7 +2021,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
}
if (value.performanceFaultMatrixStatus !== "AVAILABLE") throw new Error("SceneIR.performanceFaultMatrixStatus must be AVAILABLE when performanceFaultMatrix is present");
}
if (value.releaseCandidateStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.releaseCandidateStatus as string)) {
if (value.releaseCandidateStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.releaseCandidateStatus as string)) {
throw new Error("SceneIR.releaseCandidateStatus is invalid");
}
if (value.releaseCandidate !== undefined) {
@@ -1917,7 +2046,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
}
if (value.releaseCandidateStatus !== "AVAILABLE") throw new Error("SceneIR.releaseCandidateStatus must be AVAILABLE when releaseCandidate is present");
}
if (value.gapInventoryStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.gapInventoryStatus as string)) {
if (value.gapInventoryStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.gapInventoryStatus as string)) {
throw new Error("SceneIR.gapInventoryStatus is invalid");
}
if (value.gapInventory !== undefined) {
@@ -1953,7 +2082,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
}
if (value.gapInventoryStatus !== "AVAILABLE") throw new Error("SceneIR.gapInventoryStatus must be AVAILABLE when gapInventory is present");
}
if (value.atomicGapQueueStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.atomicGapQueueStatus as string)) {
if (value.atomicGapQueueStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.atomicGapQueueStatus as string)) {
throw new Error("SceneIR.atomicGapQueueStatus is invalid");
}
if (value.atomicGapQueue !== undefined) {
@@ -1988,7 +2117,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
throw new Error("SceneIR.atomicGapQueueStatus must be AVAILABLE for a valid queue");
}
}
if (value.parityReleaseGateStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.parityReleaseGateStatus as string)) {
if (value.parityReleaseGateStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.parityReleaseGateStatus as string)) {
throw new Error("SceneIR.parityReleaseGateStatus is invalid");
}
if (value.parityReleaseGate !== undefined) {

View File

@@ -1,15 +1,66 @@
import type { ShaderGraphIR, ShaderSocketIR } from "./shader-graph";
import { SHADER_GRAPH_SCHEMA, type ShaderGraphIR, type ShaderLinkIR, type ShaderNodeIR, type ShaderNodeType, type ShaderSocketIR } from "./shader-graph";
const input = (id: string, name: string, dataType: ShaderSocketIR["dataType"], defaultValue?: ShaderSocketIR["defaultValue"]): ShaderSocketIR => ({ id, name, direction: "INPUT", dataType, ...(defaultValue === undefined ? {} : { defaultValue }) });
const output = (id: string, name: string, dataType: ShaderSocketIR["dataType"]): ShaderSocketIR => ({ id, name, direction: "OUTPUT", dataType });
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
const nodeSockets = (type: ShaderNodeType): ShaderSocketIR[] => {
if (type === "RGB") return [output("Color", "Color", "COLOR")];
if (type === "VALUE") return [output("Value", "Value", "VALUE")];
if (type === "MATH") return [input("Value", "Value", "VALUE", 0), input("Value_001", "Value", "VALUE", 0), output("Value", "Value", "VALUE")];
if (type === "PRINCIPLED") return [input("Base Color", "Base Color", "COLOR", [0.8, 0.8, 0.8, 1]), input("Metallic", "Metallic", "VALUE", 0), input("Roughness", "Roughness", "VALUE", 0.4), output("BSDF", "BSDF", "SHADER")];
if (type === "MATERIAL_OUTPUT") return [input("Surface", "Surface", "SHADER")];
if (type === "IMAGE_TEXTURE") return [input("Vector", "Vector", "VECTOR", [0, 0, 0]), output("Color", "Color", "COLOR")];
return [];
};
function uniqueId(base: string, ids: ReadonlySet<string>): string {
let id = base;
let suffix = 2;
while (ids.has(id)) id = `${base}-${suffix++}`;
return id;
}
/** Build a valid bounded graph for the source-backed Principled writer. */
export const shaderGraphCommands = Object.freeze({
addNode(graph: ShaderGraphIR, type: ShaderNodeType, name = type): ShaderGraphIR {
const next = clone(graph);
const id = uniqueId(`node-${type.toLowerCase()}`, new Set(next.nodes.map((node) => node.id)));
const node: ShaderNodeIR = { id, type, name: name.trim() || type, sockets: nodeSockets(type), ...(type === "MATH" ? { properties: { operation: "ADD" } } : {}) };
next.nodes.push(node);
if (type === "MATERIAL_OUTPUT") next.outputNodeId = id;
return next;
},
removeNode(graph: ShaderGraphIR, nodeId: string): ShaderGraphIR {
const next = clone(graph);
next.nodes = next.nodes.filter((node) => node.id !== nodeId);
next.links = next.links.filter((link) => link.fromNodeId !== nodeId && link.toNodeId !== nodeId);
if (next.outputNodeId === nodeId) next.outputNodeId = next.nodes.find((node) => node.type === "MATERIAL_OUTPUT")?.id;
return next;
},
connect(graph: ShaderGraphIR, link: ShaderLinkIR): ShaderGraphIR {
const next = clone(graph);
next.links = next.links.filter((item) => !(item.toNodeId === link.toNodeId && item.toSocketId === link.toSocketId));
if (!next.links.some((item) => item.fromNodeId === link.fromNodeId && item.fromSocketId === link.fromSocketId && item.toNodeId === link.toNodeId && item.toSocketId === link.toSocketId)) next.links.push(link);
return next;
},
disconnect(graph: ShaderGraphIR, link: ShaderLinkIR): ShaderGraphIR {
const next = clone(graph);
next.links = next.links.filter((item) => !(item.fromNodeId === link.fromNodeId && item.fromSocketId === link.fromSocketId && item.toNodeId === link.toNodeId && item.toSocketId === link.toSocketId));
return next;
},
setSocketValue(graph: ShaderGraphIR, nodeId: string, socketId: string, value: ShaderSocketIR["defaultValue"]): ShaderGraphIR {
const next = clone(graph);
const node = next.nodes.find((candidate) => candidate.id === nodeId);
const target = node?.sockets.find((candidate) => candidate.id === socketId && candidate.direction === "INPUT");
if (target) target.defaultValue = value;
return next;
},
setPrincipledGraph(materialId: string, baseColor: readonly number[], roughness: number, metallic: number): ShaderGraphIR {
const principled = "shader:principled";
const materialOutput = "shader:material-output";
return {
schemaVersion: 1,
schemaVersion: SHADER_GRAPH_SCHEMA,
id: `shader-graph:${materialId}`,
materialId,
nodes: [

View File

@@ -4,6 +4,13 @@ export const WEB_ENGINE_CAPABILITY_REGISTRY_SCHEMA = 1 as const;
export type WebEngineCommandType = WebEngineEditCommand["type"];
export type WebEngineCommandRoute = "MAIN_AUTHORITATIVE" | "MAIN_GUARDED" | "LOCAL_HISTORY" | "PREVIEW";
export type WebEngineOperatorCommand = "listBlenderOperators" | "invokeBlenderOperator";
export interface WebEngineOperatorCapability {
readonly route: "MAIN_READ" | "MAIN_AUTHORITATIVE";
readonly implementation: "SOURCE_BACKED";
}
export interface WebEngineCommandCapability {
readonly family: string;
readonly route: WebEngineCommandRoute;
@@ -117,6 +124,12 @@ export const WEB_ENGINE_COMMAND_CAPABILITIES = {
redo: { family: "HISTORY", route: "LOCAL_HISTORY", implementation: "SOURCE_BACKED" },
} satisfies Record<WebEngineCommandType, WebEngineCommandCapability>;
/** Native operators are deliberately a separate registry from edit commands. */
export const WEB_ENGINE_OPERATOR_CAPABILITIES = {
listBlenderOperators: { route: "MAIN_READ", implementation: "SOURCE_BACKED" },
invokeBlenderOperator: { route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
} satisfies Record<WebEngineOperatorCommand, WebEngineOperatorCapability>;
export function getWebEngineCommandCapability(type: unknown): WebEngineCommandCapability | null {
if (typeof type !== "string") return null;
return Object.prototype.hasOwnProperty.call(WEB_ENGINE_COMMAND_CAPABILITIES, type)

View File

@@ -25,6 +25,8 @@ import type {
} from "./paint-stroke-session";
import type { PaintPBVHCapabilityRequest } from "./paint-pbvh-capability";
import type { LibraryMainAppendReceiptIR, LibraryMainAppendRequestIR } from "./library-main-append";
import type { BlenderOperatorDescriptor, BlenderOperatorPropertySpec, BlenderOperatorInvocation, BlenderOperatorPropertyValue } from "./blender-operator";
import type { SequencerTimelineIR } from "./sequencer";
export interface MeshGeometryBuffer {
schemaVersion: 1;
@@ -75,20 +77,28 @@ export interface MeshGeometryDelta {
}
export type MeshElementMode = "VERT" | "EDGE" | "FACE";
export type MeshEditOperation = "MERGE" | "DISSOLVE" | "EXTRUDE" | "INSET" | "BEVEL" | "LOOP_CUT";
export type MeshPrimitive = "CUBE" | "PLANE" | "UV_SPHERE" | "ICO_SPHERE" | "CYLINDER" | "CONE" | "TORUS";
export type MeshEditOperation = "MERGE" | "DISSOLVE" | "EXTRUDE" | "INSET" | "BEVEL" | "LOOP_CUT" | "SUBDIVIDE" | "FILL" | "TRIANGULATE" | "RECALCULATE_NORMALS" | "BRIDGE_LOOPS" | "DELETE";
export type FCurveInterpolation = "CONSTANT" | "LINEAR" | "BEZIER";
export type KeyframeHandle = [number, number];
export type WebEngineEditCommand =
| { type: "setFrame"; frame: number }
| { type: "setObjectVisibility"; objectId: string; visible: boolean }
| { type: "setModifierEnabled"; meshId: string; modifierUuid: string; enabled: boolean }
| { type: "setModifierVisibility"; meshId: string; modifierUuid: string; showViewport: boolean; showRender: boolean; showEditMode: boolean; showOnCage: boolean }
| { type: "addModifier"; meshId: string; modifierType: string; name?: string }
| { type: "removeModifier"; meshId: string; modifierUuid: string }
| { type: "moveModifier"; meshId: string; modifierUuid: string; direction?: "UP" | "DOWN"; index?: number }
| { type: "setModifierParameters"; meshId: string; modifierUuid: string; parameters: Record<string, unknown> }
| { type: "decimateMesh"; meshId: string; profile: SimplifyProfile }
| { type: "previewDecimateMesh"; meshId: string; profile: SimplifyProfile }
| { type: "createPrimitive"; primitive: "CUBE" | "PLANE"; name?: string; location: [number, number, number] }
| { type: "createPrimitive"; primitive: MeshPrimitive; name?: string; location: [number, number, number] }
| { type: "duplicateObject"; objectId: string; offset?: [number, number, number]; linked?: boolean }
| { type: "deleteObject"; objectId: string }
| { type: "setObjectTransform"; objectId: string; translation: [number, number, number]; rotationEuler: [number, number, number]; scale: [number, number, number] }
| { type: "setArmatureBones"; objectId: string; bones: Array<{ name: string; parentName: string | null; head: [number, number, number]; tail: [number, number, number]; roll: number; connected?: boolean }> }
| { type: "setPoseBoneTransform"; objectId: string; boneName: string; translation: [number, number, number]; rotationEuler: [number, number, number]; scale: [number, number, number] }
| { type: "translateMeshVertices"; meshId: string; vertexIndices: number[]; offset: [number, number, number] }
| { type: "meshEdit"; meshId: string; operation: MeshEditOperation; selectionMode: MeshElementMode; elementIndices: number[]; offset?: [number, number, number]; amount?: number; segments?: number }
| { type: "createUVMap"; meshId: string; name: string }
@@ -102,6 +112,10 @@ export type WebEngineEditCommand =
| { type: "setMaterialImageNode"; materialId: string; imageId: string; usage: "BASE_COLOR" | "NORMAL"; uvMap?: string }
| { type: "insertObjectKeyframe"; objectId: string; frame: number; property: "LOCATION" | "ROTATION_EULER" | "SCALE"; interpolation?: FCurveInterpolation }
| { type: "deleteObjectKeyframe"; objectId: string; frame: number; property?: "LOCATION" | "ROTATION_EULER" | "SCALE" }
| { type: "createAction"; name: string; objectId?: string }
| { type: "removeAction"; actionId: string }
| { type: "setKeyframe"; animationId: string; path: string; frame: number; value?: number[]; handleLeft?: KeyframeHandle; handleRight?: KeyframeHandle; interpolation?: FCurveInterpolation }
| { type: "setTimelineMarkers"; dataId: string; markers: Array<{ frame: number; name: string }> }
| { type: "setFCurveInterpolation"; animationId: string; path: string; interpolation: FCurveInterpolation }
| { type: "setActiveAction"; objectId: string; actionId: string | null }
| { type: "setConstraint"; objectId: string; constraintName: string; enabled: boolean; influence: number }
@@ -149,6 +163,7 @@ export type WebEngineEditCommand =
| { type: "setSceneUnitSettings"; dataId: string; properties: { system?: number; scaleLength?: number; rotationSystem?: number; lengthUnit?: number; massUnit?: number; timeUnit?: number; temperatureUnit?: number } }
| { type: "setSceneTimelineSettings"; dataId: string; properties: { fps?: number; fpsBase?: number; frameStep?: number; usePreviewRange?: boolean; previewStart?: number; previewEnd?: number } }
| { type: "setScenePipelineSettings"; dataId: string; properties: { useCompositor?: boolean; useSequencer?: boolean; noOverwrite?: boolean; saveOutput?: boolean; alphaPremultiplied?: boolean } }
| { type: "setSequencerStrips"; sceneId: string; timeline: SequencerTimelineIR }
| { type: "setSceneBindings"; dataId: string; properties: { cameraObjectId?: string | null; worldId?: string | null } }
| { type: "setSceneViewLayerSettings"; dataId: string; layerName: string; properties: { useRender?: boolean; useFreestyle?: boolean; samples?: number } }
| { type: "setSceneCollectionSettings"; dataId: string; layerName: string; collectionId: string; properties: { exclude?: boolean; holdout?: boolean; indirectOnly?: boolean; hide?: boolean } }
@@ -180,13 +195,33 @@ export type WebEngineEditCommand =
| { type: "undo" }
| { type: "redo" };
/** JSON values accepted by the allowlisted native operator bridge. */
export type WebEngineOperatorProperty = BlenderOperatorPropertyValue | null | WebEngineOperatorProperty[] | { [key: string]: WebEngineOperatorProperty };
export type WebEngineOperatorPropertySpec = BlenderOperatorPropertySpec;
export type WebEngineOperatorDescriptor = BlenderOperatorDescriptor;
export type WebEngineOperatorInvocation = BlenderOperatorInvocation;
/** Read-only commands supported by the stable Worker-to-WASM JSON facade. */
export type WebEngineJsonCommand =
| "snapshot"
| "delta"
| "depsgraph"
| "memoryStats"
| "applyCommand"
| "undo"
| "redo"
| "listBlenderOperators"
| "invokeBlenderOperator";
export type WebEngineRequest =
| { requestId: string; command: { type: "init" } }
| { requestId: string; command: { type: "loadWasmModule"; id: string } }
| { requestId: string; command: { type: "crashForTest" } }
| { requestId: string; command: { type: "openBlend"; buffer: ArrayBuffer }; }
| { requestId: string; command: { type: "cancelOpen"; targetRequestId: string } }
| { requestId: string; command: { type: "openResourceStatus" } }
| { requestId: string; command: { type: "snapshot" } }
| { requestId: string; command: { type: "jsonCommand"; cmd: WebEngineJsonCommand; params?: Record<string, unknown> } }
| { requestId: string; command: { type: "applyCommand"; payload: WebEngineEditCommand } }
| { requestId: string; command: { type: "appendLibraryObject"; request: LibraryMainAppendRequestIR; source: ArrayBuffer } }
| { requestId: string; command: { type: "beginPaintStroke"; session: PaintStrokeSessionBeginIR } }
@@ -262,12 +297,14 @@ export interface WebEngineResult {
lod?: WebEngineLODResult;
asset?: AssetRequestResult;
capabilityGate?: CapabilityGateResult;
json?: unknown;
shaderCompile?: ShaderCompileReport;
depsgraph?: DepsgraphEvaluationIR;
blend?: ArrayBuffer;
openResources?: WebEngineOpenResourceStatus;
paintStrokeSession?: PaintStrokeSessionReceiptIR;
libraryAppend?: LibraryMainAppendReceiptIR;
wasmModules?: Array<{ id: string; lifecycle: "complete" | "debug"; status: "pending" | "loaded" | "failed"; byteLength: number; error?: string }>;
}
export type WebEngineResponse =