feat(web): complete product workflow implementation
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -11,6 +11,9 @@ export type {
|
||||
SequencerCodecProbeRequestIR,
|
||||
SequencerCodecProbeResultIR,
|
||||
SequencerFrameStripIR,
|
||||
SequencerEditIR,
|
||||
SequencerStripIR,
|
||||
SequencerStripType,
|
||||
SequencerTimelineIR,
|
||||
SequencerTransitionFrameIR,
|
||||
} from "../../../protocol/sequencer";
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -11,6 +11,18 @@ export const animationRenderCommands = Object.freeze({
|
||||
deleteKeyframe(objectId: string, frame: number, property?: AnimationCommand<"deleteObjectKeyframe">["property"]): AnimationCommand<"deleteObjectKeyframe"> {
|
||||
return { type: "deleteObjectKeyframe", objectId, frame, ...(property ? { property } : {}) };
|
||||
},
|
||||
createAction(name: string, objectId?: string): AnimationCommand<"createAction"> {
|
||||
return { type: "createAction", name, ...(objectId ? { objectId } : {}) };
|
||||
},
|
||||
removeAction(actionId: string): AnimationCommand<"removeAction"> {
|
||||
return { type: "removeAction", actionId };
|
||||
},
|
||||
setKeyframe(animationId: string, path: string, frame: number, options: Omit<AnimationCommand<"setKeyframe">, "type" | "animationId" | "path" | "frame"> = {}): AnimationCommand<"setKeyframe"> {
|
||||
return { type: "setKeyframe", animationId, path, frame, ...options };
|
||||
},
|
||||
setTimelineMarkers(dataId: string, markers: AnimationCommand<"setTimelineMarkers">["markers"]): AnimationCommand<"setTimelineMarkers"> {
|
||||
return { type: "setTimelineMarkers", dataId, markers: markers.map((marker) => ({ ...marker })) };
|
||||
},
|
||||
setConstraint(objectId: string, constraintName: string, enabled: boolean, influence: number): AnimationCommand<"setConstraint"> {
|
||||
return { type: "setConstraint", objectId, constraintName, enabled, influence };
|
||||
},
|
||||
|
||||
36
web/protocol/blender-operator.ts
Normal file
36
web/protocol/blender-operator.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export type BlenderOperatorPropertyType = "boolean" | "number" | "enum" | "string";
|
||||
|
||||
export type BlenderOperatorPropertyValue = string | number | boolean;
|
||||
|
||||
export interface BlenderOperatorEnumItem {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface BlenderOperatorPropertySpec {
|
||||
name: string;
|
||||
type: BlenderOperatorPropertyType;
|
||||
required: boolean;
|
||||
label?: string;
|
||||
description?: string;
|
||||
defaultValue?: BlenderOperatorPropertyValue;
|
||||
min?: number;
|
||||
max?: number;
|
||||
enumItems?: BlenderOperatorEnumItem[];
|
||||
}
|
||||
|
||||
export interface BlenderOperatorDescriptor {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
family: string;
|
||||
context: "scene" | "object" | "main";
|
||||
backgroundSafe: true;
|
||||
properties: BlenderOperatorPropertySpec[];
|
||||
}
|
||||
|
||||
export interface BlenderOperatorInvocation {
|
||||
operator: string;
|
||||
properties?: Record<string, BlenderOperatorPropertyValue>;
|
||||
baseRevision?: number;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WebEngineEditCommand } from "./web-engine";
|
||||
import type { MeshPrimitive, WebEngineEditCommand } from "./web-engine";
|
||||
|
||||
type CoreCommand<T extends WebEngineEditCommand["type"]> = Extract<WebEngineEditCommand, { type: T }>;
|
||||
type Vector3 = [number, number, number];
|
||||
@@ -7,6 +7,9 @@ const vector3 = (value: readonly number[]): Vector3 => [value[0] ?? 0, value[1]
|
||||
|
||||
/** Typed constructors used by the workbench for the source-backed scene API. */
|
||||
export const coreSceneCommands = Object.freeze({
|
||||
createPrimitive(primitive: MeshPrimitive, location: readonly number[] = [0, 0, 0], name?: string): CoreCommand<"createPrimitive"> {
|
||||
return { type: "createPrimitive", primitive, location: vector3(location), ...(name ? { name } : {}) };
|
||||
},
|
||||
createCube(location: readonly number[] = [0, 0, 0], name?: string): CoreCommand<"createPrimitive"> {
|
||||
return { type: "createPrimitive", primitive: "CUBE", location: vector3(location), ...(name ? { name } : {}) };
|
||||
},
|
||||
|
||||
@@ -1,8 +1,70 @@
|
||||
import type { GeometryNodeGraphIR } from "./geometry-nodes";
|
||||
import { GEOMETRY_NODE_GRAPH_SCHEMA, type GeometryNodeDataType, type GeometryNodeGraphIR, type GeometryNodeIR, type GeometryNodeLinkIR, type GeometryNodeSocketIR } from "./geometry-nodes";
|
||||
import type { WebEngineEditCommand } from "./web-engine";
|
||||
|
||||
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
|
||||
|
||||
const socket = (id: string, name: string, direction: "INPUT" | "OUTPUT", dataType: GeometryNodeDataType, defaultValue?: boolean | number | string | number[]): GeometryNodeSocketIR => ({ id, name, direction, dataType, ...(defaultValue === undefined ? {} : { defaultValue }) });
|
||||
|
||||
const nodeSockets = (type: string): GeometryNodeSocketIR[] => {
|
||||
if (type === "NodeGroupInput") return [socket("Geometry", "Geometry", "OUTPUT", "GEOMETRY")];
|
||||
if (type === "NodeGroupOutput") return [socket("Geometry", "Geometry", "INPUT", "GEOMETRY")];
|
||||
if (type === "GeometryNodeTransform") return [socket("Geometry", "Geometry", "INPUT", "GEOMETRY"), socket("Translation", "Translation", "INPUT", "VECTOR", [0, 0, 0]), socket("Rotation", "Rotation", "INPUT", "VECTOR", [0, 0, 0]), socket("Scale", "Scale", "INPUT", "VECTOR", [1, 1, 1]), socket("Geometry", "Geometry", "OUTPUT", "GEOMETRY")];
|
||||
if (type === "GeometryNodeSetPosition") return [socket("Geometry", "Geometry", "INPUT", "GEOMETRY"), socket("Offset", "Offset", "INPUT", "VECTOR", [0, 0, 0]), socket("Position", "Position", "INPUT", "VECTOR", [0, 0, 0]), socket("Geometry", "Geometry", "OUTPUT", "GEOMETRY")];
|
||||
if (type === "GeometryNodeJoinGeometry") return [socket("Geometry", "Geometry", "INPUT", "GEOMETRY"), socket("Geometry", "Geometry", "OUTPUT", "GEOMETRY")];
|
||||
if (type === "GeometryNodeRealizeInstances") return [socket("Geometry", "Geometry", "INPUT", "GEOMETRY"), socket("Geometry", "Geometry", "OUTPUT", "GEOMETRY")];
|
||||
if (type === "FunctionNodeInputInt") return [socket("Value", "Value", "OUTPUT", "INT", 0)];
|
||||
if (type === "FunctionNodeInputVector") return [socket("Vector", "Vector", "OUTPUT", "VECTOR", [0, 0, 0])];
|
||||
if (type === "ShaderNodeValue") return [socket("Value", "Value", "OUTPUT", "FLOAT", 0)];
|
||||
if (type === "ShaderNodeMath") return [socket("Value", "Value", "INPUT", "FLOAT", 0), socket("Value_001", "Value", "INPUT", "FLOAT", 0), socket("Value", "Value", "OUTPUT", "FLOAT")];
|
||||
return [];
|
||||
};
|
||||
|
||||
function uniqueId(base: string, ids: ReadonlySet<string>): string {
|
||||
let id = base;
|
||||
let suffix = 2;
|
||||
while (ids.has(id)) id = `${base}-${suffix++}`;
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Workbench constructors for validated Geometry Node graph transactions. */
|
||||
export const geometryNodeCommands = Object.freeze({
|
||||
createGraph(meshId: string, name = "Web Geometry") : GeometryNodeGraphIR {
|
||||
const input = { id: "group-input", type: "NodeGroupInput", name: "Group Input", sockets: [socket("Geometry", "Geometry", "OUTPUT", "GEOMETRY")] };
|
||||
const output = { id: "group-output", type: "NodeGroupOutput", name: "Group Output", sockets: [socket("Geometry", "Geometry", "INPUT", "GEOMETRY")] };
|
||||
return { schemaVersion: GEOMETRY_NODE_GRAPH_SCHEMA, id: `geometry-graph:${meshId}`, name, interfaceInputs: [socket("Geometry", "Geometry", "INPUT", "GEOMETRY")], interfaceOutputs: [socket("Geometry", "Geometry", "OUTPUT", "GEOMETRY")], nodes: [input, output], links: [{ fromNodeId: input.id, fromSocketId: "Geometry", toNodeId: output.id, toSocketId: "Geometry" }] };
|
||||
},
|
||||
addNode(graph: GeometryNodeGraphIR, type: string, name = type): GeometryNodeGraphIR {
|
||||
const next = clone(graph);
|
||||
const ids = new Set(next.nodes.map((node) => node.id));
|
||||
const id = uniqueId(`node-${type.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`, ids);
|
||||
const node: GeometryNodeIR = { id, type, name: name.trim() || type, sockets: nodeSockets(type) };
|
||||
next.nodes.push(node);
|
||||
return next;
|
||||
},
|
||||
removeNode(graph: GeometryNodeGraphIR, nodeId: string): GeometryNodeGraphIR {
|
||||
const next = clone(graph);
|
||||
next.nodes = next.nodes.filter((node) => node.id !== nodeId);
|
||||
next.links = next.links.filter((link) => link.fromNodeId !== nodeId && link.toNodeId !== nodeId);
|
||||
return next;
|
||||
},
|
||||
connect(graph: GeometryNodeGraphIR, link: GeometryNodeLinkIR): GeometryNodeGraphIR {
|
||||
const next = clone(graph);
|
||||
next.links = next.links.filter((item) => !(item.toNodeId === link.toNodeId && item.toSocketId === link.toSocketId));
|
||||
if (!next.links.some((item) => item.fromNodeId === link.fromNodeId && item.fromSocketId === link.fromSocketId && item.toNodeId === link.toNodeId && item.toSocketId === link.toSocketId)) next.links.push(link);
|
||||
return next;
|
||||
},
|
||||
disconnect(graph: GeometryNodeGraphIR, link: GeometryNodeLinkIR): GeometryNodeGraphIR {
|
||||
const next = clone(graph);
|
||||
next.links = next.links.filter((item) => !(item.fromNodeId === link.fromNodeId && item.fromSocketId === link.fromSocketId && item.toNodeId === link.toNodeId && item.toSocketId === link.toSocketId));
|
||||
return next;
|
||||
},
|
||||
setSocketValue(graph: GeometryNodeGraphIR, nodeId: string, socketId: string, value: GeometryNodeSocketIR["defaultValue"]): GeometryNodeGraphIR {
|
||||
const next = clone(graph);
|
||||
const node = next.nodes.find((candidate) => candidate.id === nodeId);
|
||||
const target = node?.sockets.find((candidate) => candidate.id === socketId && candidate.direction === "INPUT");
|
||||
if (target) target.defaultValue = value;
|
||||
return next;
|
||||
},
|
||||
setGraph(meshId: string, graph: GeometryNodeGraphIR): Extract<WebEngineEditCommand, { type: "setGeometryNodeGraph" }> {
|
||||
return { type: "setGeometryNodeGraph", meshId, graph };
|
||||
},
|
||||
|
||||
@@ -51,6 +51,9 @@ const FORMAT_EXTENSIONS: Record<IOFormatMatrixFormat, readonly string[]> = {
|
||||
ALEMBIC: [".abc"],
|
||||
};
|
||||
|
||||
/** Formats with a browser file boundary and a native WebEngine command route. */
|
||||
const LOCAL_SCENE_FORMATS: readonly IOFormatMatrixFormat[] = ["GLTF", "GLB", "OBJ", "STL", "PLY"];
|
||||
|
||||
function routeFor(
|
||||
matrix: IOFormatCapabilityMatrixIR,
|
||||
operation: IOFormatMatrixOperation,
|
||||
@@ -60,7 +63,8 @@ function routeFor(
|
||||
.filter((entry) => {
|
||||
const route = entry.operations[operation][execution.toLowerCase() as "local" | "server"];
|
||||
const runtimeStatus = operation === "IMPORT" ? entry.runtimeImportStatus : entry.runtimeExportStatus;
|
||||
return runtimeStatus === "AVAILABLE" && route.status === "READY" && route.execution === execution;
|
||||
return route.status === "READY" && route.execution === execution &&
|
||||
(execution === "SERVER" || (runtimeStatus === "AVAILABLE" && LOCAL_SCENE_FORMATS.includes(entry.format)));
|
||||
})
|
||||
.map((entry) => ({
|
||||
format: entry.format,
|
||||
@@ -70,6 +74,15 @@ function routeFor(
|
||||
}));
|
||||
}
|
||||
|
||||
function browserSceneRoutes(operation: IOFormatMatrixOperation): IOFormatUIRouteIR[] {
|
||||
return LOCAL_SCENE_FORMATS.map((format) => ({
|
||||
format,
|
||||
operation,
|
||||
execution: "LOCAL" as const,
|
||||
extensions: [...FORMAT_EXTENSIONS[format]],
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildIOFormatUIRegistry(matrix: IOFormatCapabilityMatrixIR, parentMatrixSha256: string): IOFormatUIRegistryIR {
|
||||
if (!SHA256.test(parentMatrixSha256)) throw new IOFormatUIGateError("parent matrix SHA-256 is invalid");
|
||||
return {
|
||||
@@ -77,8 +90,8 @@ export function buildIOFormatUIRegistry(matrix: IOFormatCapabilityMatrixIR, pare
|
||||
task: IO_FORMAT_UI_TASK,
|
||||
parentMatrixSha256,
|
||||
projectFileAccept: IO_FORMAT_PROJECT_ACCEPT,
|
||||
importRoutes: routeFor(matrix, "IMPORT", "LOCAL"),
|
||||
exportRoutes: routeFor(matrix, "EXPORT", "LOCAL"),
|
||||
importRoutes: [...browserSceneRoutes("IMPORT"), ...routeFor(matrix, "IMPORT", "SERVER")],
|
||||
exportRoutes: [...browserSceneRoutes("EXPORT"), ...routeFor(matrix, "EXPORT", "SERVER")],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,9 +109,9 @@ function record(value: unknown, path: string): Record<string, unknown> {
|
||||
function parseRoute(value: unknown, path: string, operation: IOFormatMatrixOperation): IOFormatUIRouteIR {
|
||||
const input = record(value, path);
|
||||
exactKeys(input, ["format", "operation", "execution", "extensions"], path);
|
||||
if (typeof input.format !== "string" || !(input.format in FORMAT_EXTENSIONS) || input.operation !== operation || input.execution !== "LOCAL") throw new IOFormatUIGateError(`${path} route identity is invalid`);
|
||||
if (typeof input.format !== "string" || !(input.format in FORMAT_EXTENSIONS) || input.operation !== operation || !["LOCAL", "SERVER"].includes(input.execution as string)) throw new IOFormatUIGateError(`${path} route identity is invalid`);
|
||||
if (!Array.isArray(input.extensions) || input.extensions.length !== FORMAT_EXTENSIONS[input.format as IOFormatMatrixFormat].length || input.extensions.some((extension, index) => extension !== FORMAT_EXTENSIONS[input.format as IOFormatMatrixFormat][index])) throw new IOFormatUIGateError(`${path}.extensions are invalid`);
|
||||
return { format: input.format as IOFormatMatrixFormat, operation, execution: "LOCAL", extensions: [...input.extensions as string[]] };
|
||||
return { format: input.format as IOFormatMatrixFormat, operation, execution: input.execution as "LOCAL" | "SERVER", extensions: [...input.extensions as string[]] };
|
||||
}
|
||||
|
||||
export function parseIOFormatUIRegistry(value: unknown): IOFormatUIRegistryIR {
|
||||
@@ -135,11 +148,11 @@ export function filterIOFormatOperatorCommands<T extends { ioFormat?: IOFormatUI
|
||||
return commands.filter((command) => !command.ioFormat || routeMatches(registry, command.ioFormat));
|
||||
}
|
||||
|
||||
export function gateIOFormatFileSelection(fileName: string, registry: IOFormatUIRegistryIR): { status: "READY"; kind: "BLEND" } | { status: "BLOCKED"; code: "IO_FORMAT_UNSUPPORTED"; extension: string } {
|
||||
export function gateIOFormatFileSelection(fileName: string, registry: IOFormatUIRegistryIR): { status: "READY"; kind: "BLEND" } | { status: "READY"; kind: "SCENE"; route: IOFormatUIRouteIR } | { status: "BLOCKED"; code: "IO_FORMAT_UNSUPPORTED"; extension: string } {
|
||||
const extension = fileName.trim().toLowerCase().match(/\.[a-z0-9]+$/)?.[0] ?? "";
|
||||
if (extension === ".blend") return { status: "READY", kind: "BLEND" };
|
||||
const route = registry.importRoutes.find((candidate) => candidate.extensions.includes(extension));
|
||||
if (route) return { status: "BLOCKED", code: "IO_FORMAT_UNSUPPORTED", extension };
|
||||
if (route) return { status: "READY", kind: "SCENE", route };
|
||||
return { status: "BLOCKED", code: "IO_FORMAT_UNSUPPORTED", extension };
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,32 @@ import type { WebEngineEditCommand } from "./web-engine";
|
||||
|
||||
type IOCommand<T extends WebEngineEditCommand["type"]> = Extract<WebEngineEditCommand, { type: T }>;
|
||||
|
||||
export type IOSceneFormat = "GLTF" | "GLB" | "OBJ" | "STL" | "PLY" | "USD" | "ALEMBIC";
|
||||
|
||||
export interface IOSceneImportCommand {
|
||||
type: "importScene";
|
||||
format: IOSceneFormat;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
base64: string;
|
||||
}
|
||||
|
||||
export interface IOSceneExportCommand {
|
||||
type: "exportScene";
|
||||
format: IOSceneFormat;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export type IOSceneCommand = IOSceneImportCommand | IOSceneExportCommand;
|
||||
|
||||
/** Typed constructors for media import and persistence-facing edit commands. */
|
||||
export const ioMediaCommands = Object.freeze({
|
||||
importScene(format: IOSceneFormat, fileName: string, mimeType: string, base64: string): IOSceneImportCommand {
|
||||
return { type: "importScene", format, fileName, mimeType, base64 };
|
||||
},
|
||||
exportScene(format: IOSceneFormat, fileName: string): IOSceneExportCommand {
|
||||
return { type: "exportScene", format, fileName };
|
||||
},
|
||||
importImage(name: string, mimeType: IOCommand<"importImage">["mimeType"], width: number, height: number, base64: string): IOCommand<"importImage"> {
|
||||
return { type: "importImage", name, mimeType, width, height, base64 };
|
||||
},
|
||||
|
||||
@@ -9,6 +9,36 @@ function fnv1a(value: string): number {
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
export type ModifierParameterMetadata = {
|
||||
key: string;
|
||||
value: unknown;
|
||||
kind: "boolean" | "number" | "string" | "enum" | "object";
|
||||
options?: string[];
|
||||
};
|
||||
|
||||
/** Derives editable fields from the metadata carried by the authoritative modifier IR. */
|
||||
export function modifierParameterMetadata(modifier: ModifierIR): ModifierParameterMetadata[] {
|
||||
return Object.entries(modifier.parameters ?? {}).map(([key, rawValue]) => {
|
||||
// Main may carry a descriptor when RNA exposes enum choices alongside the
|
||||
// current value. Keep the descriptor opaque to callers while deriving the
|
||||
// editable value and choices from it.
|
||||
const descriptor = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) &&
|
||||
"value" in rawValue && ("kind" in rawValue || "options" in rawValue)
|
||||
? rawValue as { value: unknown; kind?: ModifierParameterMetadata["kind"]; options?: unknown[] }
|
||||
: undefined;
|
||||
const rawOptions = descriptor?.options ?? (Array.isArray(rawValue) && rawValue.every((entry) => typeof entry === "string") ? rawValue : undefined);
|
||||
const options = rawOptions?.map(String);
|
||||
const value = descriptor?.value ?? (options ? options[0] : rawValue);
|
||||
const kind = descriptor?.kind ?? (
|
||||
typeof value === "boolean" ? "boolean" :
|
||||
typeof value === "number" ? "number" :
|
||||
typeof value === "string" ? (options ? "enum" : "string") :
|
||||
"object"
|
||||
);
|
||||
return { key, value, kind, options: options ?? undefined };
|
||||
});
|
||||
}
|
||||
|
||||
/** Hashes evaluated modifier identity/state, excluding display timestamps. */
|
||||
export function modifierStackHash(stack: readonly ModifierIR[] | undefined): string {
|
||||
const canonical = (stack ?? []).map((modifier) => ({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SculptBrush, SculptMeshAttributesIR, SculptStrokeIR } from "./sculpt";
|
||||
import type { SculptBrush, SculptMeshAttributesIR, SculptStrokeIR, SculptStrokeSampleIR } from "./sculpt";
|
||||
|
||||
export const PAINT_BUDGET = {
|
||||
maxSamples: 100_000,
|
||||
@@ -106,12 +106,30 @@ export function createSculptStroke(
|
||||
pressure: number,
|
||||
symmetry: [boolean, boolean, boolean],
|
||||
mirrorObjectSpace: boolean,
|
||||
cursorSamples: ReadonlyArray<SculptStrokeSampleIR> = [],
|
||||
): SculptStrokeIR {
|
||||
const samples = cursorSamples.length > 0
|
||||
? cursorSamples.slice(0, 2048).map((sample) => ({
|
||||
position: [...sample.position] as [number, number, number],
|
||||
normal: [...sample.normal] as [number, number, number],
|
||||
radius,
|
||||
strength,
|
||||
pressure,
|
||||
time: sample.time,
|
||||
}))
|
||||
: Array.from({ length: 3 }, (_, index) => ({
|
||||
position: [0, 0, index * 0.01] as [number, number, number],
|
||||
normal: [0, 0, 1] as [number, number, number],
|
||||
radius,
|
||||
strength,
|
||||
pressure,
|
||||
time: index,
|
||||
}));
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
meshId,
|
||||
brush,
|
||||
samples: [{ position: [0, 0, 0], normal: [0, 0, 1], radius, strength, pressure, time: 0 }],
|
||||
samples,
|
||||
symmetry,
|
||||
mirrorObjectSpace,
|
||||
};
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface SceneNodeIR {
|
||||
transform: SceneTransformIR;
|
||||
assetData?: AssetMetadataIR;
|
||||
constraints?: ConstraintIR[];
|
||||
drivers?: DriverIR[];
|
||||
lightgroup?: string | null;
|
||||
}
|
||||
|
||||
@@ -72,6 +73,39 @@ export interface ConstraintIR {
|
||||
targetObjectId?: string | null;
|
||||
}
|
||||
|
||||
export interface DriverVariableIR {
|
||||
name: string;
|
||||
type: number;
|
||||
targetCount: number;
|
||||
targets?: DriverTargetIR[];
|
||||
}
|
||||
|
||||
export interface DriverTargetIR {
|
||||
idName: string;
|
||||
dataPath: string;
|
||||
boneTarget: string;
|
||||
transformType: number;
|
||||
rotationMode: number;
|
||||
flags: number;
|
||||
options: number;
|
||||
idType: number;
|
||||
contextProperty: number;
|
||||
fallbackValue: number;
|
||||
}
|
||||
|
||||
export interface DriverIR {
|
||||
path: string;
|
||||
arrayIndex: number;
|
||||
editable: boolean;
|
||||
enabled: boolean;
|
||||
expression: string;
|
||||
type: string;
|
||||
typeCode: number;
|
||||
flags: number;
|
||||
influence: number;
|
||||
variables: DriverVariableIR[];
|
||||
}
|
||||
|
||||
export interface ModifierIR {
|
||||
uuid: string;
|
||||
type: string;
|
||||
@@ -101,6 +135,8 @@ export interface ArmatureBoneIR {
|
||||
parentId: string | null;
|
||||
head: [number, number, number];
|
||||
tail: [number, number, number];
|
||||
roll?: number;
|
||||
connected?: boolean;
|
||||
restMatrix: number[];
|
||||
poseMatrix?: number[];
|
||||
}
|
||||
@@ -444,6 +480,12 @@ export interface SpeakerResourceIR {
|
||||
pitch: number;
|
||||
}
|
||||
|
||||
export interface TextLineIR {
|
||||
index: number;
|
||||
body: string;
|
||||
byteLength: number;
|
||||
}
|
||||
|
||||
export interface TextResourceIR {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -451,6 +493,7 @@ export interface TextResourceIR {
|
||||
sourceSha256: string;
|
||||
byteLength: number;
|
||||
lineCount: number;
|
||||
lines?: TextLineIR[];
|
||||
internal: boolean;
|
||||
sourcePath?: string;
|
||||
isDirty: boolean;
|
||||
@@ -530,7 +573,9 @@ export interface AnimationIR {
|
||||
targetId: string;
|
||||
frameStart: number;
|
||||
frameEnd: number;
|
||||
channels: Array<{ path: string; editable?: boolean; enabled?: boolean; selected?: boolean; group?: string; expanded?: boolean; interpolation?: "CONSTANT" | "LINEAR" | "BEZIER" | "MIXED"; keyframes: Array<{ frame: number; value: number[]; interpolation?: "CONSTANT" | "LINEAR" | "BEZIER" }> }>;
|
||||
slots?: Array<{ identifier: string; idtype: number; handle: number; flags: number }>;
|
||||
strips?: Array<{ name: string; type: "KEYFRAME"; frameStart: number; frameEnd: number; dataIndex: number }>;
|
||||
channels: Array<{ path: string; editable?: boolean; enabled?: boolean; selected?: boolean; group?: string; expanded?: boolean; keyframeSource?: "BEZIER" | "SAMPLED"; modifiers?: Array<{ name: string; type: string; typeCode: number; flags: number; expanded: boolean; influence: number; frameStart: number; frameEnd: number; blendIn: number; blendOut: number }>; interpolation?: "CONSTANT" | "LINEAR" | "BEZIER" | "MIXED"; keyframes: Array<{ frame: number; value: number[]; handleLeft?: [number, number]; handleRight?: [number, number]; interpolation?: "CONSTANT" | "LINEAR" | "BEZIER" }> }>;
|
||||
}
|
||||
|
||||
export interface CollectionIR {
|
||||
@@ -590,6 +635,7 @@ export interface SceneIR {
|
||||
previewStart: number;
|
||||
previewEnd: number;
|
||||
};
|
||||
timelineMarkers?: Array<{ frame: number; name: string }>;
|
||||
pipelineSettings?: {
|
||||
useCompositor: boolean;
|
||||
useSequencer: boolean;
|
||||
@@ -654,7 +700,7 @@ export interface SceneSnapshotIR {
|
||||
editorWorkflowStatus?: "AVAILABLE" | "BLOCKED";
|
||||
scriptSources?: ScriptSourceInventoryIR;
|
||||
scriptSourceStatus?: "AVAILABLE" | "BLOCKED";
|
||||
renderRoutingStatus?: "AVAILABLE" | "BLOCKED";
|
||||
renderRoutingStatus?: "AVAILABLE" | "BLOCKED" | "MISSING";
|
||||
renderRouting?: {
|
||||
schemaVersion: 1;
|
||||
receiptId: "workflow:render_capability_routing";
|
||||
@@ -665,7 +711,7 @@ export interface SceneSnapshotIR {
|
||||
recovery: { cancellation: "OPEN_CANCELLED"; deviceLoss: "RELEASED"; [key: string]: unknown };
|
||||
[key: string]: unknown;
|
||||
};
|
||||
serverJobBindingStatus?: "AVAILABLE" | "BLOCKED";
|
||||
serverJobBindingStatus?: "AVAILABLE" | "BLOCKED" | "MISSING";
|
||||
serverJobBinding?: {
|
||||
schemaVersion: 1;
|
||||
receiptId: "workflow:server_job_result_binding";
|
||||
@@ -677,7 +723,7 @@ export interface SceneSnapshotIR {
|
||||
lifecycle: { duplicate: "REUSED"; stale: "BLOCKED"; cancel: "CANCELLED"; timeout: "TIMEOUT"; oom: "OOM"; publishedOnFailure: false; [key: string]: unknown };
|
||||
[key: string]: unknown;
|
||||
};
|
||||
scriptingPolicyStatus?: "AVAILABLE" | "BLOCKED";
|
||||
scriptingPolicyStatus?: "AVAILABLE" | "BLOCKED" | "MISSING";
|
||||
scriptingPolicy?: {
|
||||
schemaVersion: 1;
|
||||
policyId: "workflow:scripting_default_deny_audit";
|
||||
@@ -697,7 +743,7 @@ export interface SceneSnapshotIR {
|
||||
policyHash: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
performanceFaultMatrixStatus?: "AVAILABLE" | "BLOCKED";
|
||||
performanceFaultMatrixStatus?: "AVAILABLE" | "BLOCKED" | "MISSING";
|
||||
performanceFaultMatrix?: {
|
||||
schemaVersion: 1;
|
||||
receiptId: "workflow:performance_fault_recovery_matrix";
|
||||
@@ -708,7 +754,7 @@ export interface SceneSnapshotIR {
|
||||
matrixHash: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
releaseCandidateStatus?: "AVAILABLE" | "BLOCKED";
|
||||
releaseCandidateStatus?: "AVAILABLE" | "BLOCKED" | "MISSING";
|
||||
releaseCandidate?: {
|
||||
schemaVersion: 1;
|
||||
receiptId: "workflow:offline_ci_sbom_and_rollback_release";
|
||||
@@ -720,7 +766,7 @@ export interface SceneSnapshotIR {
|
||||
rollback: { runbookSha256: string; [key: string]: unknown };
|
||||
[key: string]: unknown;
|
||||
};
|
||||
gapInventoryStatus?: "AVAILABLE" | "BLOCKED";
|
||||
gapInventoryStatus?: "AVAILABLE" | "BLOCKED" | "MISSING";
|
||||
gapInventory?: {
|
||||
schemaVersion: 1;
|
||||
receiptId: "workflow:cluster_full_blender_gap_inventory";
|
||||
@@ -751,7 +797,7 @@ export interface SceneSnapshotIR {
|
||||
nextTask: "WBV2-P7-002";
|
||||
[key: string]: unknown;
|
||||
};
|
||||
atomicGapQueueStatus?: "AVAILABLE" | "BLOCKED";
|
||||
atomicGapQueueStatus?: "AVAILABLE" | "BLOCKED" | "MISSING";
|
||||
atomicGapQueue?: {
|
||||
schemaVersion: 1;
|
||||
receiptId: "workflow:generate_atomic_family_gap_queue";
|
||||
@@ -762,7 +808,7 @@ export interface SceneSnapshotIR {
|
||||
nextTask: "WBV2-P7-003";
|
||||
[key: string]: unknown;
|
||||
};
|
||||
parityReleaseGateStatus?: "AVAILABLE" | "BLOCKED";
|
||||
parityReleaseGateStatus?: "AVAILABLE" | "BLOCKED" | "MISSING";
|
||||
parityReleaseGate?: {
|
||||
schemaVersion: 1;
|
||||
receiptId: "workflow:full_parity_candidate_release_gate";
|
||||
@@ -1072,6 +1118,16 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
const lineCount = requireNumber(text.lineCount, `texts[${index}].lineCount`);
|
||||
if (!Number.isSafeInteger(byteLength) || byteLength !== new TextEncoder().encode(source).byteLength || byteLength > 1_048_576) throw new Error(`SceneIR.texts[${index}].byteLength is invalid`);
|
||||
if (!Number.isSafeInteger(lineCount) || lineCount < 1 || lineCount > 65_536) throw new Error(`SceneIR.texts[${index}].lineCount is invalid`);
|
||||
if (text.lines !== undefined) {
|
||||
if (!Array.isArray(text.lines) || text.lines.length !== lineCount) throw new Error(`SceneIR.texts[${index}].lines is invalid`);
|
||||
text.lines.forEach((line, lineIndex) => {
|
||||
if (!isRecord(line)) throw new Error(`SceneIR.texts[${index}].lines[${lineIndex}] must be an object`);
|
||||
if (line.index !== lineIndex) throw new Error(`SceneIR.texts[${index}].lines[${lineIndex}].index is invalid`);
|
||||
const body = requireString(line.body, `texts[${index}].lines[${lineIndex}].body`);
|
||||
const lineByteLength = requireNumber(line.byteLength, `texts[${index}].lines[${lineIndex}].byteLength`);
|
||||
if (!Number.isSafeInteger(lineByteLength) || lineByteLength !== new TextEncoder().encode(body).byteLength) throw new Error(`SceneIR.texts[${index}].lines[${lineIndex}].byteLength is invalid`);
|
||||
});
|
||||
}
|
||||
requireBoolean(text.internal, `texts[${index}].internal`);
|
||||
if (text.sourcePath !== undefined) requireString(text.sourcePath, `texts[${index}].sourcePath`);
|
||||
requireBoolean(text.isDirty, `texts[${index}].isDirty`);
|
||||
@@ -1153,6 +1209,34 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
requireTuple(node.transform.rotationEuler, 3, `nodes[${index}].transform.rotationEuler`);
|
||||
requireTuple(node.transform.scale, 3, `nodes[${index}].transform.scale`);
|
||||
requireNumber(node.transform.rotationMode, `nodes[${index}].transform.rotationMode`);
|
||||
if (node.drivers !== undefined) {
|
||||
const drivers = requireArray(node.drivers, `nodes[${index}].drivers`);
|
||||
if (drivers.length > 256) throw new Error(`SceneIR.nodes[${index}].drivers exceeds the budget`);
|
||||
for (const [driverIndex, driver] of drivers.entries()) {
|
||||
if (!isRecord(driver)) throw new Error(`SceneIR.nodes[${index}].drivers[${driverIndex}] must be an object`);
|
||||
for (const field of ["path", "expression", "type"] as const) requireString(driver[field], `nodes[${index}].drivers[${driverIndex}].${field}`);
|
||||
for (const field of ["arrayIndex", "typeCode", "flags", "influence"] as const) requireNumber(driver[field], `nodes[${index}].drivers[${driverIndex}].${field}`);
|
||||
requireBoolean(driver.editable, `nodes[${index}].drivers[${driverIndex}].editable`);
|
||||
requireBoolean(driver.enabled, `nodes[${index}].drivers[${driverIndex}].enabled`);
|
||||
const variables = requireArray(driver.variables, `nodes[${index}].drivers[${driverIndex}].variables`);
|
||||
if (variables.length > 64) throw new Error(`SceneIR.nodes[${index}].drivers[${driverIndex}].variables exceeds the budget`);
|
||||
for (const [variableIndex, variable] of variables.entries()) {
|
||||
if (!isRecord(variable)) throw new Error(`SceneIR.nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}] must be an object`);
|
||||
requireString(variable.name, `nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].name`);
|
||||
requireNumber(variable.type, `nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].type`);
|
||||
requireNumber(variable.targetCount, `nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].targetCount`);
|
||||
if (variable.targets !== undefined) {
|
||||
const targets = requireArray(variable.targets, `nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].targets`);
|
||||
if (targets.length !== variable.targetCount) throw new Error(`SceneIR.nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].targets count mismatch`);
|
||||
for (const [targetIndex, target] of targets.entries()) {
|
||||
if (!isRecord(target)) throw new Error(`SceneIR.nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].targets[${targetIndex}] must be an object`);
|
||||
for (const field of ["idName", "dataPath", "boneTarget"] as const) requireString(target[field], `nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].targets[${targetIndex}].${field}`);
|
||||
for (const field of ["transformType", "rotationMode", "flags", "options", "idType", "contextProperty", "fallbackValue"] as const) requireNumber(target[field], `nodes[${index}].drivers[${driverIndex}].variables[${variableIndex}].targets[${targetIndex}].${field}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [index, mesh] of meshes.entries()) {
|
||||
if (!isRecord(mesh)) throw new Error(`SceneIR.meshes[${index}] must be an object`);
|
||||
@@ -1287,6 +1371,8 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
if (bone.parentId !== null) requireString(bone.parentId, `armatures[${index}].bones[${boneIndex}].parentId`);
|
||||
requireTuple(bone.head, 3, `armatures[${index}].bones[${boneIndex}].head`);
|
||||
requireTuple(bone.tail, 3, `armatures[${index}].bones[${boneIndex}].tail`);
|
||||
if (bone.roll !== undefined) requireNumber(bone.roll, `armatures[${index}].bones[${boneIndex}].roll`);
|
||||
if (bone.connected !== undefined) requireBoolean(bone.connected, `armatures[${index}].bones[${boneIndex}].connected`);
|
||||
requireTuple(bone.restMatrix, 16, `armatures[${index}].bones[${boneIndex}].restMatrix`);
|
||||
if (bone.poseMatrix !== undefined) requireTuple(bone.poseMatrix, 16, `armatures[${index}].bones[${boneIndex}].poseMatrix`);
|
||||
}
|
||||
@@ -1624,6 +1710,14 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
for (const field of ["fps", "fpsBase", "frameStep", "previewStart", "previewEnd"] as const) requireNumber(scene.timelineSettings[field], `scenes[${index}].timelineSettings.${field}`);
|
||||
requireBoolean(scene.timelineSettings.usePreviewRange, `scenes[${index}].timelineSettings.usePreviewRange`);
|
||||
}
|
||||
if (scene.timelineMarkers !== undefined) {
|
||||
const markers = requireArray(scene.timelineMarkers, `scenes[${index}].timelineMarkers`);
|
||||
for (const [markerIndex, marker] of markers.entries()) {
|
||||
if (!isRecord(marker)) throw new Error(`SceneIR.scenes[${index}].timelineMarkers[${markerIndex}] must be an object`);
|
||||
requireNumber(marker.frame, `scenes[${index}].timelineMarkers[${markerIndex}].frame`);
|
||||
requireString(marker.name, `scenes[${index}].timelineMarkers[${markerIndex}].name`);
|
||||
}
|
||||
}
|
||||
if (scene.pipelineSettings !== undefined) {
|
||||
if (!isRecord(scene.pipelineSettings)) throw new Error(`SceneIR.scenes[${index}].pipelineSettings must be an object`);
|
||||
for (const field of ["useCompositor", "useSequencer", "noOverwrite", "saveOutput", "alphaPremultiplied"] as const) requireBoolean(scene.pipelineSettings[field], `scenes[${index}].pipelineSettings.${field}`);
|
||||
@@ -1719,10 +1813,45 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
requireString(animation.targetId, `animations[${index}].targetId`);
|
||||
requireNumber(animation.frameStart, `animations[${index}].frameStart`);
|
||||
requireNumber(animation.frameEnd, `animations[${index}].frameEnd`);
|
||||
if (animation.slots !== undefined) {
|
||||
const slots = requireArray(animation.slots, `animations[${index}].slots`);
|
||||
if (slots.length > 1024) throw new Error(`animations[${index}].slots exceeds the budget`);
|
||||
for (const [slotIndex, slot] of slots.entries()) {
|
||||
if (!isRecord(slot)) throw new Error(`animations[${index}].slots[${slotIndex}] must be an object`);
|
||||
requireString(slot.identifier, `animations[${index}].slots[${slotIndex}].identifier`);
|
||||
for (const field of ["idtype", "handle", "flags"]) requireNumber(slot[field], `animations[${index}].slots[${slotIndex}].${field}`);
|
||||
}
|
||||
}
|
||||
if (animation.strips !== undefined) {
|
||||
const strips = requireArray(animation.strips, `animations[${index}].strips`);
|
||||
if (strips.length > 1024) throw new Error(`animations[${index}].strips exceeds the budget`);
|
||||
for (const [stripIndex, strip] of strips.entries()) {
|
||||
if (!isRecord(strip)) throw new Error(`animations[${index}].strips[${stripIndex}] must be an object`);
|
||||
requireString(strip.name, `animations[${index}].strips[${stripIndex}].name`);
|
||||
if (strip.type !== "KEYFRAME") throw new Error(`animations[${index}].strips[${stripIndex}].type is invalid`);
|
||||
requireNumber(strip.frameStart, `animations[${index}].strips[${stripIndex}].frameStart`);
|
||||
requireNumber(strip.frameEnd, `animations[${index}].strips[${stripIndex}].frameEnd`);
|
||||
requireNumber(strip.dataIndex, `animations[${index}].strips[${stripIndex}].dataIndex`);
|
||||
}
|
||||
}
|
||||
const channels = requireArray(animation.channels, `animations[${index}].channels`);
|
||||
for (const [channelIndex, channel] of channels.entries()) {
|
||||
if (!isRecord(channel)) throw new Error(`SceneIR.animations[${index}].channels[${channelIndex}] must be an object`);
|
||||
requireString(channel.path, `animations[${index}].channels[${channelIndex}].path`);
|
||||
if (channel.keyframeSource !== undefined && channel.keyframeSource !== "BEZIER" && channel.keyframeSource !== "SAMPLED") {
|
||||
throw new Error(`animations[${index}].channels[${channelIndex}].keyframeSource is invalid`);
|
||||
}
|
||||
if (channel.modifiers !== undefined) {
|
||||
const modifiers = requireArray(channel.modifiers, `animations[${index}].channels[${channelIndex}].modifiers`);
|
||||
if (modifiers.length > 64) throw new Error(`animations[${index}].channels[${channelIndex}].modifiers exceeds the budget`);
|
||||
for (const [modifierIndex, modifier] of modifiers.entries()) {
|
||||
if (!isRecord(modifier)) throw new Error(`animations[${index}].channels[${channelIndex}].modifiers[${modifierIndex}] must be an object`);
|
||||
requireString(modifier.name, `animations[${index}].channels[${channelIndex}].modifiers[${modifierIndex}].name`);
|
||||
requireString(modifier.type, `animations[${index}].channels[${channelIndex}].modifiers[${modifierIndex}].type`);
|
||||
for (const field of ["typeCode", "flags", "influence", "frameStart", "frameEnd", "blendIn", "blendOut"]) requireNumber(modifier[field], `animations[${index}].channels[${channelIndex}].modifiers[${modifierIndex}].${field}`);
|
||||
if (typeof modifier.expanded !== "boolean") throw new Error(`animations[${index}].channels[${channelIndex}].modifiers[${modifierIndex}].expanded is invalid`);
|
||||
}
|
||||
}
|
||||
const keyframes = requireArray(channel.keyframes, `animations[${index}].channels[${channelIndex}].keyframes`);
|
||||
for (const [keyframeIndex, keyframe] of keyframes.entries()) {
|
||||
if (!isRecord(keyframe)) throw new Error(`SceneIR.animations[${index}].channels[${channelIndex}].keyframes[${keyframeIndex}] must be an object`);
|
||||
@@ -1809,7 +1938,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
parseScriptSourceInventory(value.scriptSources);
|
||||
if (value.scriptSourceStatus !== "AVAILABLE") throw new Error("SceneIR.scriptSourceStatus must be AVAILABLE when scriptSources is present");
|
||||
}
|
||||
if (value.renderRoutingStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.renderRoutingStatus as string)) {
|
||||
if (value.renderRoutingStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.renderRoutingStatus as string)) {
|
||||
throw new Error("SceneIR.renderRoutingStatus is invalid");
|
||||
}
|
||||
if (value.renderRouting !== undefined) {
|
||||
@@ -1825,7 +1954,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
}
|
||||
if (value.renderRoutingStatus !== "AVAILABLE") throw new Error("SceneIR.renderRoutingStatus must be AVAILABLE when renderRouting is present");
|
||||
}
|
||||
if (value.serverJobBindingStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.serverJobBindingStatus as string)) {
|
||||
if (value.serverJobBindingStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.serverJobBindingStatus as string)) {
|
||||
throw new Error("SceneIR.serverJobBindingStatus is invalid");
|
||||
}
|
||||
if (value.serverJobBinding !== undefined) {
|
||||
@@ -1843,7 +1972,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
}
|
||||
if (value.serverJobBindingStatus !== "AVAILABLE") throw new Error("SceneIR.serverJobBindingStatus must be AVAILABLE when serverJobBinding is present");
|
||||
}
|
||||
if (value.scriptingPolicyStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.scriptingPolicyStatus as string)) {
|
||||
if (value.scriptingPolicyStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.scriptingPolicyStatus as string)) {
|
||||
throw new Error("SceneIR.scriptingPolicyStatus is invalid");
|
||||
}
|
||||
if (value.scriptingPolicy !== undefined) {
|
||||
@@ -1871,7 +2000,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
}
|
||||
if (value.scriptingPolicyStatus !== "AVAILABLE") throw new Error("SceneIR.scriptingPolicyStatus must be AVAILABLE when scriptingPolicy is present");
|
||||
}
|
||||
if (value.performanceFaultMatrixStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.performanceFaultMatrixStatus as string)) {
|
||||
if (value.performanceFaultMatrixStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.performanceFaultMatrixStatus as string)) {
|
||||
throw new Error("SceneIR.performanceFaultMatrixStatus is invalid");
|
||||
}
|
||||
if (value.performanceFaultMatrix !== undefined) {
|
||||
@@ -1892,7 +2021,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
}
|
||||
if (value.performanceFaultMatrixStatus !== "AVAILABLE") throw new Error("SceneIR.performanceFaultMatrixStatus must be AVAILABLE when performanceFaultMatrix is present");
|
||||
}
|
||||
if (value.releaseCandidateStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.releaseCandidateStatus as string)) {
|
||||
if (value.releaseCandidateStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.releaseCandidateStatus as string)) {
|
||||
throw new Error("SceneIR.releaseCandidateStatus is invalid");
|
||||
}
|
||||
if (value.releaseCandidate !== undefined) {
|
||||
@@ -1917,7 +2046,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
}
|
||||
if (value.releaseCandidateStatus !== "AVAILABLE") throw new Error("SceneIR.releaseCandidateStatus must be AVAILABLE when releaseCandidate is present");
|
||||
}
|
||||
if (value.gapInventoryStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.gapInventoryStatus as string)) {
|
||||
if (value.gapInventoryStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.gapInventoryStatus as string)) {
|
||||
throw new Error("SceneIR.gapInventoryStatus is invalid");
|
||||
}
|
||||
if (value.gapInventory !== undefined) {
|
||||
@@ -1953,7 +2082,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
}
|
||||
if (value.gapInventoryStatus !== "AVAILABLE") throw new Error("SceneIR.gapInventoryStatus must be AVAILABLE when gapInventory is present");
|
||||
}
|
||||
if (value.atomicGapQueueStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.atomicGapQueueStatus as string)) {
|
||||
if (value.atomicGapQueueStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.atomicGapQueueStatus as string)) {
|
||||
throw new Error("SceneIR.atomicGapQueueStatus is invalid");
|
||||
}
|
||||
if (value.atomicGapQueue !== undefined) {
|
||||
@@ -1988,7 +2117,7 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
throw new Error("SceneIR.atomicGapQueueStatus must be AVAILABLE for a valid queue");
|
||||
}
|
||||
}
|
||||
if (value.parityReleaseGateStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.parityReleaseGateStatus as string)) {
|
||||
if (value.parityReleaseGateStatus !== undefined && !["AVAILABLE", "BLOCKED", "MISSING"].includes(value.parityReleaseGateStatus as string)) {
|
||||
throw new Error("SceneIR.parityReleaseGateStatus is invalid");
|
||||
}
|
||||
if (value.parityReleaseGate !== undefined) {
|
||||
|
||||
@@ -1,15 +1,66 @@
|
||||
import type { ShaderGraphIR, ShaderSocketIR } from "./shader-graph";
|
||||
import { SHADER_GRAPH_SCHEMA, type ShaderGraphIR, type ShaderLinkIR, type ShaderNodeIR, type ShaderNodeType, type ShaderSocketIR } from "./shader-graph";
|
||||
|
||||
const input = (id: string, name: string, dataType: ShaderSocketIR["dataType"], defaultValue?: ShaderSocketIR["defaultValue"]): ShaderSocketIR => ({ id, name, direction: "INPUT", dataType, ...(defaultValue === undefined ? {} : { defaultValue }) });
|
||||
const output = (id: string, name: string, dataType: ShaderSocketIR["dataType"]): ShaderSocketIR => ({ id, name, direction: "OUTPUT", dataType });
|
||||
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
|
||||
|
||||
const nodeSockets = (type: ShaderNodeType): ShaderSocketIR[] => {
|
||||
if (type === "RGB") return [output("Color", "Color", "COLOR")];
|
||||
if (type === "VALUE") return [output("Value", "Value", "VALUE")];
|
||||
if (type === "MATH") return [input("Value", "Value", "VALUE", 0), input("Value_001", "Value", "VALUE", 0), output("Value", "Value", "VALUE")];
|
||||
if (type === "PRINCIPLED") return [input("Base Color", "Base Color", "COLOR", [0.8, 0.8, 0.8, 1]), input("Metallic", "Metallic", "VALUE", 0), input("Roughness", "Roughness", "VALUE", 0.4), output("BSDF", "BSDF", "SHADER")];
|
||||
if (type === "MATERIAL_OUTPUT") return [input("Surface", "Surface", "SHADER")];
|
||||
if (type === "IMAGE_TEXTURE") return [input("Vector", "Vector", "VECTOR", [0, 0, 0]), output("Color", "Color", "COLOR")];
|
||||
return [];
|
||||
};
|
||||
|
||||
function uniqueId(base: string, ids: ReadonlySet<string>): string {
|
||||
let id = base;
|
||||
let suffix = 2;
|
||||
while (ids.has(id)) id = `${base}-${suffix++}`;
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Build a valid bounded graph for the source-backed Principled writer. */
|
||||
export const shaderGraphCommands = Object.freeze({
|
||||
addNode(graph: ShaderGraphIR, type: ShaderNodeType, name = type): ShaderGraphIR {
|
||||
const next = clone(graph);
|
||||
const id = uniqueId(`node-${type.toLowerCase()}`, new Set(next.nodes.map((node) => node.id)));
|
||||
const node: ShaderNodeIR = { id, type, name: name.trim() || type, sockets: nodeSockets(type), ...(type === "MATH" ? { properties: { operation: "ADD" } } : {}) };
|
||||
next.nodes.push(node);
|
||||
if (type === "MATERIAL_OUTPUT") next.outputNodeId = id;
|
||||
return next;
|
||||
},
|
||||
removeNode(graph: ShaderGraphIR, nodeId: string): ShaderGraphIR {
|
||||
const next = clone(graph);
|
||||
next.nodes = next.nodes.filter((node) => node.id !== nodeId);
|
||||
next.links = next.links.filter((link) => link.fromNodeId !== nodeId && link.toNodeId !== nodeId);
|
||||
if (next.outputNodeId === nodeId) next.outputNodeId = next.nodes.find((node) => node.type === "MATERIAL_OUTPUT")?.id;
|
||||
return next;
|
||||
},
|
||||
connect(graph: ShaderGraphIR, link: ShaderLinkIR): ShaderGraphIR {
|
||||
const next = clone(graph);
|
||||
next.links = next.links.filter((item) => !(item.toNodeId === link.toNodeId && item.toSocketId === link.toSocketId));
|
||||
if (!next.links.some((item) => item.fromNodeId === link.fromNodeId && item.fromSocketId === link.fromSocketId && item.toNodeId === link.toNodeId && item.toSocketId === link.toSocketId)) next.links.push(link);
|
||||
return next;
|
||||
},
|
||||
disconnect(graph: ShaderGraphIR, link: ShaderLinkIR): ShaderGraphIR {
|
||||
const next = clone(graph);
|
||||
next.links = next.links.filter((item) => !(item.fromNodeId === link.fromNodeId && item.fromSocketId === link.fromSocketId && item.toNodeId === link.toNodeId && item.toSocketId === link.toSocketId));
|
||||
return next;
|
||||
},
|
||||
setSocketValue(graph: ShaderGraphIR, nodeId: string, socketId: string, value: ShaderSocketIR["defaultValue"]): ShaderGraphIR {
|
||||
const next = clone(graph);
|
||||
const node = next.nodes.find((candidate) => candidate.id === nodeId);
|
||||
const target = node?.sockets.find((candidate) => candidate.id === socketId && candidate.direction === "INPUT");
|
||||
if (target) target.defaultValue = value;
|
||||
return next;
|
||||
},
|
||||
setPrincipledGraph(materialId: string, baseColor: readonly number[], roughness: number, metallic: number): ShaderGraphIR {
|
||||
const principled = "shader:principled";
|
||||
const materialOutput = "shader:material-output";
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
schemaVersion: SHADER_GRAPH_SCHEMA,
|
||||
id: `shader-graph:${materialId}`,
|
||||
materialId,
|
||||
nodes: [
|
||||
|
||||
@@ -4,6 +4,13 @@ export const WEB_ENGINE_CAPABILITY_REGISTRY_SCHEMA = 1 as const;
|
||||
export type WebEngineCommandType = WebEngineEditCommand["type"];
|
||||
export type WebEngineCommandRoute = "MAIN_AUTHORITATIVE" | "MAIN_GUARDED" | "LOCAL_HISTORY" | "PREVIEW";
|
||||
|
||||
export type WebEngineOperatorCommand = "listBlenderOperators" | "invokeBlenderOperator";
|
||||
|
||||
export interface WebEngineOperatorCapability {
|
||||
readonly route: "MAIN_READ" | "MAIN_AUTHORITATIVE";
|
||||
readonly implementation: "SOURCE_BACKED";
|
||||
}
|
||||
|
||||
export interface WebEngineCommandCapability {
|
||||
readonly family: string;
|
||||
readonly route: WebEngineCommandRoute;
|
||||
@@ -117,6 +124,12 @@ export const WEB_ENGINE_COMMAND_CAPABILITIES = {
|
||||
redo: { family: "HISTORY", route: "LOCAL_HISTORY", implementation: "SOURCE_BACKED" },
|
||||
} satisfies Record<WebEngineCommandType, WebEngineCommandCapability>;
|
||||
|
||||
/** Native operators are deliberately a separate registry from edit commands. */
|
||||
export const WEB_ENGINE_OPERATOR_CAPABILITIES = {
|
||||
listBlenderOperators: { route: "MAIN_READ", implementation: "SOURCE_BACKED" },
|
||||
invokeBlenderOperator: { route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
|
||||
} satisfies Record<WebEngineOperatorCommand, WebEngineOperatorCapability>;
|
||||
|
||||
export function getWebEngineCommandCapability(type: unknown): WebEngineCommandCapability | null {
|
||||
if (typeof type !== "string") return null;
|
||||
return Object.prototype.hasOwnProperty.call(WEB_ENGINE_COMMAND_CAPABILITIES, type)
|
||||
|
||||
@@ -25,6 +25,8 @@ import type {
|
||||
} from "./paint-stroke-session";
|
||||
import type { PaintPBVHCapabilityRequest } from "./paint-pbvh-capability";
|
||||
import type { LibraryMainAppendReceiptIR, LibraryMainAppendRequestIR } from "./library-main-append";
|
||||
import type { BlenderOperatorDescriptor, BlenderOperatorPropertySpec, BlenderOperatorInvocation, BlenderOperatorPropertyValue } from "./blender-operator";
|
||||
import type { SequencerTimelineIR } from "./sequencer";
|
||||
|
||||
export interface MeshGeometryBuffer {
|
||||
schemaVersion: 1;
|
||||
@@ -75,20 +77,28 @@ export interface MeshGeometryDelta {
|
||||
}
|
||||
|
||||
export type MeshElementMode = "VERT" | "EDGE" | "FACE";
|
||||
export type MeshEditOperation = "MERGE" | "DISSOLVE" | "EXTRUDE" | "INSET" | "BEVEL" | "LOOP_CUT";
|
||||
export type MeshPrimitive = "CUBE" | "PLANE" | "UV_SPHERE" | "ICO_SPHERE" | "CYLINDER" | "CONE" | "TORUS";
|
||||
export type MeshEditOperation = "MERGE" | "DISSOLVE" | "EXTRUDE" | "INSET" | "BEVEL" | "LOOP_CUT" | "SUBDIVIDE" | "FILL" | "TRIANGULATE" | "RECALCULATE_NORMALS" | "BRIDGE_LOOPS" | "DELETE";
|
||||
export type FCurveInterpolation = "CONSTANT" | "LINEAR" | "BEZIER";
|
||||
export type KeyframeHandle = [number, number];
|
||||
|
||||
export type WebEngineEditCommand =
|
||||
| { type: "setFrame"; frame: number }
|
||||
| { type: "setObjectVisibility"; objectId: string; visible: boolean }
|
||||
| { type: "setModifierEnabled"; meshId: string; modifierUuid: string; enabled: boolean }
|
||||
| { type: "setModifierVisibility"; meshId: string; modifierUuid: string; showViewport: boolean; showRender: boolean; showEditMode: boolean; showOnCage: boolean }
|
||||
| { type: "addModifier"; meshId: string; modifierType: string; name?: string }
|
||||
| { type: "removeModifier"; meshId: string; modifierUuid: string }
|
||||
| { type: "moveModifier"; meshId: string; modifierUuid: string; direction?: "UP" | "DOWN"; index?: number }
|
||||
| { type: "setModifierParameters"; meshId: string; modifierUuid: string; parameters: Record<string, unknown> }
|
||||
| { type: "decimateMesh"; meshId: string; profile: SimplifyProfile }
|
||||
| { type: "previewDecimateMesh"; meshId: string; profile: SimplifyProfile }
|
||||
| { type: "createPrimitive"; primitive: "CUBE" | "PLANE"; name?: string; location: [number, number, number] }
|
||||
| { type: "createPrimitive"; primitive: MeshPrimitive; name?: string; location: [number, number, number] }
|
||||
| { type: "duplicateObject"; objectId: string; offset?: [number, number, number]; linked?: boolean }
|
||||
| { type: "deleteObject"; objectId: string }
|
||||
| { type: "setObjectTransform"; objectId: string; translation: [number, number, number]; rotationEuler: [number, number, number]; scale: [number, number, number] }
|
||||
| { type: "setArmatureBones"; objectId: string; bones: Array<{ name: string; parentName: string | null; head: [number, number, number]; tail: [number, number, number]; roll: number; connected?: boolean }> }
|
||||
| { type: "setPoseBoneTransform"; objectId: string; boneName: string; translation: [number, number, number]; rotationEuler: [number, number, number]; scale: [number, number, number] }
|
||||
| { type: "translateMeshVertices"; meshId: string; vertexIndices: number[]; offset: [number, number, number] }
|
||||
| { type: "meshEdit"; meshId: string; operation: MeshEditOperation; selectionMode: MeshElementMode; elementIndices: number[]; offset?: [number, number, number]; amount?: number; segments?: number }
|
||||
| { type: "createUVMap"; meshId: string; name: string }
|
||||
@@ -102,6 +112,10 @@ export type WebEngineEditCommand =
|
||||
| { type: "setMaterialImageNode"; materialId: string; imageId: string; usage: "BASE_COLOR" | "NORMAL"; uvMap?: string }
|
||||
| { type: "insertObjectKeyframe"; objectId: string; frame: number; property: "LOCATION" | "ROTATION_EULER" | "SCALE"; interpolation?: FCurveInterpolation }
|
||||
| { type: "deleteObjectKeyframe"; objectId: string; frame: number; property?: "LOCATION" | "ROTATION_EULER" | "SCALE" }
|
||||
| { type: "createAction"; name: string; objectId?: string }
|
||||
| { type: "removeAction"; actionId: string }
|
||||
| { type: "setKeyframe"; animationId: string; path: string; frame: number; value?: number[]; handleLeft?: KeyframeHandle; handleRight?: KeyframeHandle; interpolation?: FCurveInterpolation }
|
||||
| { type: "setTimelineMarkers"; dataId: string; markers: Array<{ frame: number; name: string }> }
|
||||
| { type: "setFCurveInterpolation"; animationId: string; path: string; interpolation: FCurveInterpolation }
|
||||
| { type: "setActiveAction"; objectId: string; actionId: string | null }
|
||||
| { type: "setConstraint"; objectId: string; constraintName: string; enabled: boolean; influence: number }
|
||||
@@ -149,6 +163,7 @@ export type WebEngineEditCommand =
|
||||
| { type: "setSceneUnitSettings"; dataId: string; properties: { system?: number; scaleLength?: number; rotationSystem?: number; lengthUnit?: number; massUnit?: number; timeUnit?: number; temperatureUnit?: number } }
|
||||
| { type: "setSceneTimelineSettings"; dataId: string; properties: { fps?: number; fpsBase?: number; frameStep?: number; usePreviewRange?: boolean; previewStart?: number; previewEnd?: number } }
|
||||
| { type: "setScenePipelineSettings"; dataId: string; properties: { useCompositor?: boolean; useSequencer?: boolean; noOverwrite?: boolean; saveOutput?: boolean; alphaPremultiplied?: boolean } }
|
||||
| { type: "setSequencerStrips"; sceneId: string; timeline: SequencerTimelineIR }
|
||||
| { type: "setSceneBindings"; dataId: string; properties: { cameraObjectId?: string | null; worldId?: string | null } }
|
||||
| { type: "setSceneViewLayerSettings"; dataId: string; layerName: string; properties: { useRender?: boolean; useFreestyle?: boolean; samples?: number } }
|
||||
| { type: "setSceneCollectionSettings"; dataId: string; layerName: string; collectionId: string; properties: { exclude?: boolean; holdout?: boolean; indirectOnly?: boolean; hide?: boolean } }
|
||||
@@ -180,13 +195,33 @@ export type WebEngineEditCommand =
|
||||
| { type: "undo" }
|
||||
| { type: "redo" };
|
||||
|
||||
/** JSON values accepted by the allowlisted native operator bridge. */
|
||||
export type WebEngineOperatorProperty = BlenderOperatorPropertyValue | null | WebEngineOperatorProperty[] | { [key: string]: WebEngineOperatorProperty };
|
||||
export type WebEngineOperatorPropertySpec = BlenderOperatorPropertySpec;
|
||||
export type WebEngineOperatorDescriptor = BlenderOperatorDescriptor;
|
||||
export type WebEngineOperatorInvocation = BlenderOperatorInvocation;
|
||||
|
||||
/** Read-only commands supported by the stable Worker-to-WASM JSON facade. */
|
||||
export type WebEngineJsonCommand =
|
||||
| "snapshot"
|
||||
| "delta"
|
||||
| "depsgraph"
|
||||
| "memoryStats"
|
||||
| "applyCommand"
|
||||
| "undo"
|
||||
| "redo"
|
||||
| "listBlenderOperators"
|
||||
| "invokeBlenderOperator";
|
||||
|
||||
export type WebEngineRequest =
|
||||
| { requestId: string; command: { type: "init" } }
|
||||
| { requestId: string; command: { type: "loadWasmModule"; id: string } }
|
||||
| { requestId: string; command: { type: "crashForTest" } }
|
||||
| { requestId: string; command: { type: "openBlend"; buffer: ArrayBuffer }; }
|
||||
| { requestId: string; command: { type: "cancelOpen"; targetRequestId: string } }
|
||||
| { requestId: string; command: { type: "openResourceStatus" } }
|
||||
| { requestId: string; command: { type: "snapshot" } }
|
||||
| { requestId: string; command: { type: "jsonCommand"; cmd: WebEngineJsonCommand; params?: Record<string, unknown> } }
|
||||
| { requestId: string; command: { type: "applyCommand"; payload: WebEngineEditCommand } }
|
||||
| { requestId: string; command: { type: "appendLibraryObject"; request: LibraryMainAppendRequestIR; source: ArrayBuffer } }
|
||||
| { requestId: string; command: { type: "beginPaintStroke"; session: PaintStrokeSessionBeginIR } }
|
||||
@@ -262,12 +297,14 @@ export interface WebEngineResult {
|
||||
lod?: WebEngineLODResult;
|
||||
asset?: AssetRequestResult;
|
||||
capabilityGate?: CapabilityGateResult;
|
||||
json?: unknown;
|
||||
shaderCompile?: ShaderCompileReport;
|
||||
depsgraph?: DepsgraphEvaluationIR;
|
||||
blend?: ArrayBuffer;
|
||||
openResources?: WebEngineOpenResourceStatus;
|
||||
paintStrokeSession?: PaintStrokeSessionReceiptIR;
|
||||
libraryAppend?: LibraryMainAppendReceiptIR;
|
||||
wasmModules?: Array<{ id: string; lifecycle: "complete" | "debug"; status: "pending" | "loaded" | "failed"; byteLength: number; error?: string }>;
|
||||
}
|
||||
|
||||
export type WebEngineResponse =
|
||||
|
||||
Reference in New Issue
Block a user