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 = {