feat(web): complete user-facing workflow command coverage
This commit is contained in:
@@ -62,8 +62,11 @@ import {
|
||||
type NanoVDBViewportAssetIR,
|
||||
type NanoVDBViewportProjectContextIR,
|
||||
} from "../volume/nanovdb-viewport";
|
||||
import { composePaintColorPatch, composePaintWeightPatch } from "../../../protocol/paint";
|
||||
import { composePaintColorPatch, composePaintWeightPatch, createSculptMeshAttributes, createSculptStroke } from "../../../protocol/paint";
|
||||
import type { SculptBrush } 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";
|
||||
import { createIMECompositionState, reduceIMEComposition, shouldBlockOperatorShortcuts, type IMECompositionEvent } from "../../../protocol/ime-composition";
|
||||
import { observeKeyboardEvent, type KeyboardObservation } from "../../../protocol/keyboard-contract";
|
||||
import { createInitialUserActionStates, reduceUserActionStates, type UserActionIdentity, type UserActionKind } from "../../../protocol/user-action-state";
|
||||
@@ -79,7 +82,6 @@ import { filterIOFormatOperatorCommands, gateIOFormatFileSelection, ioFormatUIAc
|
||||
import ioFormatRuntimeReceiptFreshnessJSON from "../capabilities/io-format-runtime-receipts-freshness.json";
|
||||
import ioFormatRuntimeReceiptFreshnessExpectedJSON from "../capabilities/io-format-runtime-receipts-freshness-expected.json";
|
||||
import { parseIOFormatReceiptFreshness, resolveFreshIOFormatRuntimeRoute, type IOFormatReceiptFreshnessExpectedIR } from "../../../protocol/io-format-receipt-freshness";
|
||||
import "./app-shell.css";
|
||||
|
||||
const IO_FORMAT_UI_REGISTRY = parseIOFormatUIRegistry(ioFormatUIRegistryJSON);
|
||||
const IO_FORMAT_RUNTIME_RECEIPTS = parseIOFormatReceiptFreshness(ioFormatRuntimeReceiptFreshnessJSON);
|
||||
@@ -643,14 +645,16 @@ function GreasePencilCanvas2D({ drawing, scope, selection, onSelect }: {
|
||||
onKeyDown={keyboardSelect} />;
|
||||
}
|
||||
|
||||
function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, greasePencilSelection, onGreasePencilCanvasPointSelect, onCommand, onImportImage, onRequestAsset, onAppendLibraryObject, onQueryRenderCapability, onApplyDecimate, onPreviewDecimate, onGenerateLOD, onSetModifierEnabled, previewActive, onCancelPreview }: {
|
||||
function Properties({ snapshot, editorWorkflow, selectedFaceIndices, selectedVertexIndices, greasePencilSelection, onGreasePencilCanvasPointSelect, onCommand, onImportImage, onImportVFont, onRequestAsset, onAppendLibraryObject, onQueryRenderCapability, onApplyDecimate, onPreviewDecimate, onGenerateLOD, onSetModifierEnabled, previewActive, onCancelPreview }: {
|
||||
snapshot: SceneSnapshotIR | null;
|
||||
editorWorkflow: EditorWorkflowIR | null;
|
||||
selectedFaceIndices: number[];
|
||||
selectedVertexIndices: number[];
|
||||
greasePencilSelection: GreasePencilSelectionStateIR | null;
|
||||
onGreasePencilCanvasPointSelect: (point: GreasePencilPointRef, additive: boolean, baseSelectionRevision: number) => void;
|
||||
onCommand: (command: WebEngineEditCommand) => void;
|
||||
onImportImage: (file: File) => void;
|
||||
onImportVFont: (file: File) => void;
|
||||
onRequestAsset: (assetId: string) => void;
|
||||
onAppendLibraryObject: (source: ArrayBuffer, request: LibraryMainAppendRequestIR) => Promise<void>;
|
||||
onQueryRenderCapability: (request: RenderCapabilityRequest) => void;
|
||||
@@ -690,6 +694,14 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea
|
||||
const [paintMirrorAxis, setPaintMirrorAxis] = useState<0 | 1 | 2>(0);
|
||||
const [paintSelectionMask, setPaintSelectionMask] = useState(0.5);
|
||||
const [paintGroup, setPaintGroup] = useState("WebPaint");
|
||||
const [sculptBrush, setSculptBrush] = useState<SculptBrush>("DRAW");
|
||||
const [sculptRadius, setSculptRadius] = useState(0.25);
|
||||
const [sculptStrength, setSculptStrength] = useState(0.5);
|
||||
const [sculptPressure, setSculptPressure] = useState(1);
|
||||
const [sculptSymmetry, setSculptSymmetry] = useState<[boolean, boolean, boolean]>([false, false, false]);
|
||||
const [sculptMirrorObjectSpace, setSculptMirrorObjectSpace] = useState(false);
|
||||
const [sculptMaskValue, setSculptMaskValue] = useState(0);
|
||||
const [sculptActiveFaceSet, setSculptActiveFaceSet] = useState(1);
|
||||
const [greasePencilLayerId, setGreasePencilLayerId] = useState("");
|
||||
const [greasePencilLayerName, setGreasePencilLayerName] = useState("Web Layer");
|
||||
const [greasePencilStrokeIndex, setGreasePencilStrokeIndex] = useState(0);
|
||||
@@ -698,15 +710,30 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea
|
||||
const [greasePencilTargetFrame, setGreasePencilTargetFrame] = useState(12);
|
||||
const [greasePencilReorderError, setGreasePencilReorderError] = useState("");
|
||||
const [curveSplineIndex, setCurveSplineIndex] = useState(0);
|
||||
const [curvePointIndex, setCurvePointIndex] = useState(0);
|
||||
const [curveHandleSide, setCurveHandleSide] = useState<"LEFT" | "RIGHT">("LEFT");
|
||||
const [surfaceSplineIndex, setSurfaceSplineIndex] = useState(0);
|
||||
const [curveTopologyError, setCurveTopologyError] = useState("");
|
||||
const [fontBody, setFontBody] = useState("");
|
||||
const [fontCharacterIndex, setFontCharacterIndex] = useState(0);
|
||||
const [fontActiveTextBox, setFontActiveTextBox] = useState(0);
|
||||
const [fontCharacterKern, setFontCharacterKern] = useState(0);
|
||||
const [fontCharacterMaterial, setFontCharacterMaterial] = useState(0);
|
||||
const [fontCharacterStyleFlags, setFontCharacterStyleFlags] = useState(0);
|
||||
const [fontTextBoxX, setFontTextBoxX] = useState(0);
|
||||
const [fontTextBoxY, setFontTextBoxY] = useState(0);
|
||||
const [fontTextBoxWidth, setFontTextBoxWidth] = useState(1);
|
||||
const [fontTextBoxHeight, setFontTextBoxHeight] = useState(1);
|
||||
const [fontLinks, setFontLinks] = useState({ regular: "", bold: "", italic: "", boldItalic: "" });
|
||||
const [textSource, setTextSource] = useState("");
|
||||
const [librarySourceFile, setLibrarySourceFile] = useState<File | null>(null);
|
||||
const [libraryRequestFile, setLibraryRequestFile] = useState<File | null>(null);
|
||||
const [libraryAppendError, setLibraryAppendError] = useState("");
|
||||
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
|
||||
const activeMesh = activeNode?.dataId ? snapshot?.meshes.find((mesh) => mesh.id === activeNode.dataId) : undefined;
|
||||
const activeNonMeshData = activeNode?.dataId ? snapshot?.nonMeshData?.find((data) => data.id === activeNode.dataId) : undefined;
|
||||
const activeCurve = activeNode?.dataId ? snapshot?.nonMeshData?.find((data) => data.id === activeNode.dataId && data.type === "CURVE") : undefined;
|
||||
const activeSurface = activeNode?.dataId ? snapshot?.nonMeshData?.find((data) => data.id === activeNode.dataId && data.type === "SURFACE") : undefined;
|
||||
const activeFont = activeNode?.dataId ? snapshot?.nonMeshData?.find((data) => data.id === activeNode.dataId && data.type === "FONT") : undefined;
|
||||
const activeText = activeNode?.dataId ? snapshot?.texts?.find((data) => data.id === activeNode.dataId) : undefined;
|
||||
const activeSound = activeNode?.dataId ? snapshot?.sounds?.find((data) => data.id === activeNode.dataId) : undefined;
|
||||
@@ -738,11 +765,32 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea
|
||||
}, [activeMaterial]);
|
||||
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]);
|
||||
useEffect(() => {
|
||||
const characterIndex = Math.min(fontCharacterIndex, Math.max(0, (activeFont?.fontCharacters?.length ?? 1) - 1));
|
||||
const character = activeFont?.fontCharacters?.[characterIndex];
|
||||
const textBoxIndex = Math.min(activeFont?.activeFontTextBox ?? 0, Math.max(0, (activeFont?.fontTextBoxes?.length ?? 1) - 1));
|
||||
const textBox = activeFont?.fontTextBoxes?.[textBoxIndex] ?? { x: 0, y: 0, width: 1, height: 1 };
|
||||
setFontCharacterIndex(characterIndex);
|
||||
setFontActiveTextBox(textBoxIndex);
|
||||
setFontCharacterKern(character?.kern ?? 0);
|
||||
setFontCharacterMaterial(character?.materialIndex ?? 0);
|
||||
setFontCharacterStyleFlags(character?.styleFlags ?? 0);
|
||||
setFontTextBoxX(textBox.x);
|
||||
setFontTextBoxY(textBox.y);
|
||||
setFontTextBoxWidth(textBox.width);
|
||||
setFontTextBoxHeight(textBox.height);
|
||||
}, [activeFont?.id, activeFont?.fontCharacters, activeFont?.fontTextBoxes, activeFont?.activeFontTextBox, fontCharacterIndex]);
|
||||
useEffect(() => setTextSource(activeText?.source ?? ""), [activeText?.id, activeText?.source]);
|
||||
useEffect(() => {
|
||||
if (!activeCurve || curveSplineIndex >= activeCurve.splineCount) setCurveSplineIndex(0);
|
||||
const pointCount = activeCurve?.handlePointIndices?.length ?? activeCurve?.pointCount ?? 0;
|
||||
if (curvePointIndex >= pointCount) setCurvePointIndex(0);
|
||||
setCurveTopologyError("");
|
||||
}, [activeCurve?.id, activeCurve?.splineCount, curveSplineIndex]);
|
||||
}, [activeCurve?.id, activeCurve?.splineCount, activeCurve?.handlePointIndices?.length, activeCurve?.pointCount, curveSplineIndex, curvePointIndex]);
|
||||
useEffect(() => {
|
||||
if (!activeSurface || surfaceSplineIndex >= (activeSurface.splineDimensions?.length ?? 0)) setSurfaceSplineIndex(0);
|
||||
}, [activeSurface?.id, activeSurface?.splineDimensions?.length, surfaceSplineIndex]);
|
||||
useEffect(() => {
|
||||
if (!activeGreasePencil) setGreasePencilLayerId("");
|
||||
else if (!activeGreasePencil.layers.some((layer) => layer.id === greasePencilLayerId)) setGreasePencilLayerId(activeGreasePencil.layers[0]?.id ?? "");
|
||||
@@ -786,6 +834,15 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea
|
||||
const patch = composePaintWeightPatch(activeNode.id, paintGroup, snapshot.revision, snapshot.revision, currentGroupWeights, selectedPaintBrushWeights, paintWeight);
|
||||
onCommand({ type: "setVertexWeights", objectId: patch.objectId, vertexGroup: patch.vertexGroup, indices: patch.indices, values: patch.values, normalize: patch.normalize });
|
||||
};
|
||||
const applySculptStroke = (): void => {
|
||||
if (!activeMesh) return;
|
||||
onCommand({ type: "sculptStroke", stroke: createSculptStroke(activeMesh.id, sculptBrush, sculptRadius, sculptStrength, sculptPressure, sculptSymmetry, sculptMirrorObjectSpace) });
|
||||
};
|
||||
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 translateGreasePencilPoint = (): void => {
|
||||
if (!activeGreasePencil || !activeGreasePencilFrame || !snapshot) return;
|
||||
const selectedPoints = greasePencilPointSelection
|
||||
@@ -872,6 +929,79 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea
|
||||
setCurveTopologyError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
};
|
||||
const curvePointIndices = activeCurve
|
||||
? activeCurve.handlePointIndices ?? Array.from({ length: activeCurve.pointCount }, (_, index) => index)
|
||||
: [];
|
||||
const nudgeCurveHandle = (): void => {
|
||||
if (!activeCurve?.handlePoints || curvePointIndices.length === 0) return;
|
||||
const pointIndex = curvePointIndices[Math.min(curvePointIndex, curvePointIndices.length - 1)];
|
||||
const packedPointIndex = curvePointIndices.indexOf(pointIndex);
|
||||
if (pointIndex === undefined || packedPointIndex < 0) return;
|
||||
const sideOffset = curveHandleSide === "RIGHT" ? 3 : 0;
|
||||
const offset = packedPointIndex * 6 + sideOffset;
|
||||
const current = activeCurve.handlePoints.slice(offset, offset + 3);
|
||||
if (current.length !== 3) return;
|
||||
const position: [number, number, number] = [current[0] + 0.1, current[1], current[2]];
|
||||
onCommand(nonMeshGeometryCommands.setCurveHandle(activeCurve.id, pointIndex, curveHandleSide, position));
|
||||
};
|
||||
const applyCurveSplines = (splineTypeOverride?: "POLY" | "BEZIER" | "NURBS"): void => {
|
||||
if (!activeCurve) return;
|
||||
const splineCount = Math.max(activeCurve.splineCount, activeCurve.splineTypes?.length ?? 0);
|
||||
const splineTypes = Array.from({ length: splineCount }, (_, index) => activeCurve.splineTypes?.[index] ?? "POLY");
|
||||
if (splineTypeOverride && splineTypes.length > 0) splineTypes[Math.min(curveSplineIndex, splineTypes.length - 1)] = splineTypeOverride;
|
||||
const pointCount = activeCurve.pointCount;
|
||||
const cyclicU = Array.from({ length: splineCount }, (_, index) => activeCurve.cyclicU?.[index] ?? false);
|
||||
const pointWeights = [...(activeCurve.pointWeights ?? Array.from({ length: pointCount }, () => 1))];
|
||||
onCommand(nonMeshGeometryCommands.setCurveSplines(
|
||||
activeCurve.id,
|
||||
splineTypes,
|
||||
activeCurve.splineOffsets ?? [],
|
||||
splineTypes.map((type) => type === "NURBS" ? 3 : 1),
|
||||
activeCurve.controlPoints ?? [],
|
||||
pointWeights,
|
||||
cyclicU,
|
||||
activeCurve.handleTypes ?? [],
|
||||
activeCurve.handlePoints ?? [],
|
||||
));
|
||||
};
|
||||
const applySurfaceTopology = (axis?: "U" | "V"): void => {
|
||||
if (!activeSurface) return;
|
||||
const splineCount = activeSurface.splineDimensions?.length ?? 0;
|
||||
const cyclicU = Array.from({ length: splineCount }, (_, index) => activeSurface.cyclicU?.[index] ?? false);
|
||||
const cyclicV = Array.from({ length: splineCount }, (_, index) => activeSurface.cyclicV?.[index] ?? false);
|
||||
const selectedIndex = Math.min(surfaceSplineIndex, Math.max(0, splineCount - 1));
|
||||
if (axis === "U" && splineCount > 0) cyclicU[selectedIndex] = !cyclicU[selectedIndex];
|
||||
if (axis === "V" && splineCount > 0) cyclicV[selectedIndex] = !cyclicV[selectedIndex];
|
||||
onCommand(nonMeshGeometryCommands.setSurfaceTopology(
|
||||
activeSurface.id,
|
||||
activeSurface.splineDimensions ?? [],
|
||||
activeSurface.controlPoints ?? [],
|
||||
activeSurface.pointWeights ?? Array.from({ length: activeSurface.pointCount }, () => 1),
|
||||
cyclicU,
|
||||
cyclicV,
|
||||
));
|
||||
};
|
||||
const applyFontAdvanced = (): void => {
|
||||
if (!activeFont) return;
|
||||
const characters = [...(activeFont.fontCharacters ?? [])];
|
||||
if (characters.length === 0) characters.push({ kern: fontCharacterKern, materialIndex: fontCharacterMaterial, styleFlags: fontCharacterStyleFlags });
|
||||
else {
|
||||
const characterIndex = Math.min(fontCharacterIndex, characters.length - 1);
|
||||
characters[characterIndex] = { ...characters[characterIndex], kern: fontCharacterKern, materialIndex: fontCharacterMaterial, styleFlags: fontCharacterStyleFlags };
|
||||
}
|
||||
const textBoxes = [...(activeFont.fontTextBoxes ?? [])];
|
||||
const activeIndex = Math.min(fontActiveTextBox, Math.max(0, textBoxes.length - 1));
|
||||
if (textBoxes.length === 0) textBoxes.push({ x: fontTextBoxX, y: fontTextBoxY, width: fontTextBoxWidth, height: fontTextBoxHeight });
|
||||
else textBoxes[activeIndex] = { x: fontTextBoxX, y: fontTextBoxY, width: fontTextBoxWidth, height: fontTextBoxHeight };
|
||||
onCommand(nonMeshGeometryCommands.setFontAdvanced(activeFont.id, characters, textBoxes, activeIndex));
|
||||
};
|
||||
const setFontLink = (slot: "regular" | "bold" | "italic" | "boldItalic", value: string): void => {
|
||||
setFontLinks((current) => ({ ...current, [slot]: value }));
|
||||
};
|
||||
const applyFontLinks = (): void => {
|
||||
if (!activeFont) return;
|
||||
onCommand(nonMeshGeometryCommands.setFontLinks(activeFont.id, fontLinks));
|
||||
};
|
||||
const appendLibrary = async (): Promise<void> => {
|
||||
if (!snapshot || !librarySourceFile || !libraryRequestFile) return;
|
||||
try {
|
||||
@@ -924,6 +1054,7 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea
|
||||
<div className="property-section"><h3>Transform</h3><label>位置 <output>0.000, 0.000, 0.000</output></label><label>旋转 <output>0°, 0°, 0°</output></label><label>缩放 <output>1.000, 1.000, 1.000</output></label></div>
|
||||
{activeNode ? <div className="property-section"><h3>Object & Hierarchy</h3><label>名称 <input aria-label="对象名称" value={renameValue} onChange={(event) => setRenameValue(event.target.value)} /></label><div className="property-actions"><button type="button" onClick={() => renameValue && onCommand({ type: "renameId", id: activeNode.id, name: renameValue })}>重命名</button><button type="button" onClick={() => onCommand({ type: "applyObjectTransform", objectId: activeNode.id })}>应用变换</button><button type="button" onClick={() => onCommand({ type: "setObjectOrigin", objectId: activeNode.id, mode: "GEOMETRY" })}>原点到几何体</button></div><label>Collection <select aria-label="移动到 Collection" value="" onChange={(event) => event.target.value && onCommand({ type: "moveObjectToCollection", objectId: activeNode.id, collectionId: event.target.value })}><option value="">选择...</option>{snapshot?.collections.map((collection) => <option key={collection.id} value={collection.id}>{collection.name}</option>)}</select></label></div> : null}
|
||||
{snapshot ? <div className="property-section" data-testid="scene-context-workbench"><h3>Scene Context</h3><output data-testid="scene-context-counts">Scenes {snapshot.scenes.length} · Collections {snapshot.collections.length} · Armatures {snapshot.armatures?.length ?? 0} · Libraries {snapshot.libraries?.length ?? 0}</output>{snapshot.scenes.map((scene) => <output key={scene.id} data-resource-id={scene.id}>Scene: {scene.name}{scene.renderEngine ? ` · ${scene.renderEngine}` : ""}</output>)}{snapshot.collections.slice(0, 32).map((collection) => <output key={collection.id} data-resource-id={collection.id}>Collection: {collection.name} · {collection.objectIds.length} objects · {collection.childCollectionIds.length} children</output>)}{snapshot.armatures?.slice(0, 16).map((armature) => <output key={armature.id} data-resource-id={armature.id}>Armature: {armature.name} · {armature.bones.length} bones</output>)}{snapshot.libraries?.slice(0, 16).map((library) => <output key={library.id} data-resource-id={library.id}>Library: {library.name} · {library.status}</output>)}</div> : null}
|
||||
{snapshot ? <div className="property-section" data-testid="editor-workflow-workbench" data-status={snapshot.editorWorkflowStatus ?? "BLOCKED"} data-revision={editorWorkflow?.context.revision ?? snapshot.revision}><h3>Editor Workflow</h3><output data-testid="editor-workflow-status">{snapshot.editorWorkflowStatus ?? "BLOCKED"}</output>{editorWorkflow ? <><output data-testid="editor-workflow-context">{editorWorkflow.context.workspaceId} · {editorWorkflow.context.activeAreaId} · {editorWorkflow.context.activeEditor}</output><output data-testid="editor-workflow-workspace-count">{editorWorkflow.workspaces.length} workspaces</output>{editorWorkflow.workspaces.slice(0, 16).map((item) => <output key={item.id} data-workspace-id={item.id}>{item.name} · {item.areas.length} areas · active {item.activeAreaId}</output>)}</> : <output data-testid="editor-workflow-unavailable">Editor workflow unavailable</output>}</div> : null}
|
||||
{snapshot ? <div className="property-section" data-testid="capability-family-workbench"><h3>Capability Families</h3><output data-testid="capability-family-command-count">{Object.keys(WEB_ENGINE_COMMAND_CAPABILITIES).length} typed Main commands</output>{[...new Set(Object.values(WEB_ENGINE_COMMAND_CAPABILITIES).map((capability) => capability.family))].sort().map((family) => <output key={family} data-capability-family={family}>{family}</output>)}</div> : null}
|
||||
{snapshot && (snapshot.releaseCandidateStatus || snapshot.scriptingPolicyStatus || snapshot.renderRoutingStatus || snapshot.serverJobBindingStatus || snapshot.performanceFaultMatrixStatus || snapshot.editorWorkflowStatus) ? <div className="property-section" data-testid="release-status-workbench"><h3>Runtime Status</h3><output data-testid="runtime-status-summary">Scripting {snapshot.scriptingPolicyStatus ?? "UNKNOWN"} · Render {snapshot.renderRoutingStatus ?? "UNKNOWN"} · Jobs {snapshot.serverJobBindingStatus ?? "UNKNOWN"}</output><output>Editor workflow {snapshot.editorWorkflowStatus ?? "UNKNOWN"} · Performance faults {snapshot.performanceFaultMatrixStatus ?? "UNKNOWN"}</output><output>Release candidate {snapshot.releaseCandidateStatus ?? "UNKNOWN"}</output></div> : null}
|
||||
{activeNode?.dataId?.startsWith("mesh:") ? <div className="property-section" data-testid="geometry-node-workbench"><h3>Geometry Nodes</h3><output data-testid="geometry-node-graph-count">{snapshot?.geometryNodeGraphs?.length ?? 0} graphs</output>{activeGeometryGraph ? <><output data-testid="geometry-node-active-graph">{activeGeometryGraph.name}</output><output>{activeGeometryGraph.nodes.length} nodes / {activeGeometryGraph.links.length} links</output><button type="button" onClick={() => onCommand(geometryNodeCommands.setGraph(activeNode.dataId!, activeGeometryGraph))}>提交当前节点图</button></> : <output>当前文件没有 Geometry Node 图</output>}</div> : null}
|
||||
@@ -959,12 +1090,19 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea
|
||||
{activeScene?.viewLayers?.filter((layer) => layer.aovs?.length).map((layer) => <div className="property-section" data-testid="scene-view-layer-aov-workbench" key={`aovs-${layer.name}`}><h3>{layer.name} AOVs</h3>{layer.aovs?.map((aov) => <output key={aov.name}>{aov.name} · {aov.type === 1 ? "Color" : "Value"}</output>)}<button type="button" onClick={() => onCommand(animationRenderCommands.setSceneViewLayerAOVs(activeScene.id, layer.name, [...(layer.aovs ?? []).map((aov) => ({ name: aov.name, type: (aov.type === 1 ? 1 : 0) as 0 | 1 })), { name: `AOV_${(layer.aovs?.length ?? 0) + 1}`, type: 1 }]))}>Add Color AOV</button></div>) ?? null}
|
||||
{activeScene?.viewLayers?.filter((layer) => layer.lightgroups?.length).map((layer) => <div className="property-section" data-testid="scene-view-layer-lightgroup-workbench" key={`lightgroups-${layer.name}`}><h3>{layer.name} Lightgroups</h3>{layer.lightgroups?.map((group) => <output key={group}>{group}</output>)}<button type="button" onClick={() => onCommand(animationRenderCommands.setSceneViewLayerLightgroups(activeScene.id, layer.name, [...(layer.lightgroups ?? []), `Lightgroup_${(layer.lightgroups?.length ?? 0) + 1}`]))}>Add Lightgroup</button></div>) ?? null}
|
||||
{activeScene?.simplifySettings ? <div className="property-section" data-testid="scene-simplify-settings-workbench"><h3>Render Simplify</h3><output>Subdivision {activeScene.simplifySettings.subsurfViewport} / {activeScene.simplifySettings.subsurfRender} · Particles {activeScene.simplifySettings.particlesViewport.toFixed(2)} / {activeScene.simplifySettings.particlesRender.toFixed(2)} · Volumes {activeScene.simplifySettings.volumes.toFixed(2)}</output><div className="property-actions"><button type="button" onClick={() => onCommand(animationRenderCommands.setSceneSimplifySettings(activeScene.id, { subsurfViewport: Math.max(0, activeScene.simplifySettings!.subsurfViewport - 1) }))}>降低细分</button><button type="button" onClick={() => onCommand(animationRenderCommands.setSceneSimplifySettings(activeScene.id, { particlesViewport: Math.max(0, activeScene.simplifySettings!.particlesViewport - 0.1), volumes: Math.max(0, activeScene.simplifySettings!.volumes - 0.1) }))}>降低复杂度</button></div></div> : null}
|
||||
{activeFont ? <div className="property-section" data-testid="font-workbench"><h3>Font</h3><label>Text <textarea aria-label="Font body" value={fontBody} maxLength={65536} onChange={(event) => setFontBody(event.target.value)} /></label><div className="property-actions"><button type="button" disabled={fontBody === activeFont.text} onClick={() => onCommand(nonMeshGeometryCommands.setFontBody(activeFont.id, fontBody))}>Apply Text</button>{activeFont.fontProperties ? <button type="button" onClick={() => onCommand(nonMeshGeometryCommands.setFontProperties(activeFont.id, { extrude: Math.min(1000, activeFont.fontProperties!.extrude + 0.01) }))}>Increase Extrude</button> : null}</div><output>{activeFont.text?.length ?? 0} characters</output></div> : null}
|
||||
{activeFont ? <>
|
||||
<div className="property-section" data-testid="font-workbench"><h3>Font</h3><label>Text <textarea aria-label="Font body" value={fontBody} maxLength={65536} onChange={(event) => setFontBody(event.target.value)} /></label><div className="property-actions"><button type="button" disabled={fontBody === activeFont.text} onClick={() => onCommand(nonMeshGeometryCommands.setFontBody(activeFont.id, fontBody))}>Apply Text</button>{activeFont.fontProperties ? <button type="button" onClick={() => onCommand(nonMeshGeometryCommands.setFontProperties(activeFont.id, { extrude: Math.min(1000, activeFont.fontProperties!.extrude + 0.01) }))}>Increase Extrude</button> : null}<label className="file-button">Import TTF/OTF/PFB<input data-testid="vfont-file-input" type="file" accept=".ttf,.otf,.pfb,font/ttf,font/otf,application/x-font-type1,application/x-font-pfb" aria-label="Import TTF OTF PFB font" onChange={(event) => { const file = event.target.files?.[0]; if (file) onImportVFont(file); event.target.value = ""; }} /></label></div><output>{activeFont.text?.length ?? 0} characters</output></div>
|
||||
<div className="property-section" data-testid="font-layout-workbench" data-command="setFontAdvanced"><h3>Advanced Font Layout</h3><label>Character <select aria-label="Font character" value={Math.min(fontCharacterIndex, Math.max(0, (activeFont.fontCharacters?.length ?? 1) - 1))} onChange={(event) => { const index = Number(event.target.value); setFontCharacterIndex(index); const character = activeFont.fontCharacters?.[index]; if (character) { setFontCharacterKern(character.kern); setFontCharacterMaterial(character.materialIndex); setFontCharacterStyleFlags(character.styleFlags); } }}>{(activeFont.fontCharacters?.length ? activeFont.fontCharacters : [{ kern: 0, materialIndex: 0, styleFlags: 0 }]).map((_, index) => <option key={index} value={index}>{index + 1}</option>)}</select></label><label>Kern <input aria-label="Font character kern" type="number" step="0.01" value={fontCharacterKern} onChange={(event) => setFontCharacterKern(Number(event.target.value) || 0)} /></label><label>Material Index <input aria-label="Font character material index" type="number" min="0" step="1" value={fontCharacterMaterial} onChange={(event) => setFontCharacterMaterial(Math.max(0, Number(event.target.value) || 0))} /></label><label>Style Flags <input aria-label="Font character style flags" type="number" min="0" step="1" value={fontCharacterStyleFlags} onChange={(event) => setFontCharacterStyleFlags(Math.max(0, Number(event.target.value) || 0))} /></label><label>Text Box <select aria-label="Font text box" value={Math.min(fontActiveTextBox, Math.max(0, (activeFont.fontTextBoxes?.length ?? 1) - 1))} onChange={(event) => { const index = Number(event.target.value); setFontActiveTextBox(index); const textBox = activeFont.fontTextBoxes?.[index]; if (textBox) { setFontTextBoxX(textBox.x); setFontTextBoxY(textBox.y); setFontTextBoxWidth(textBox.width); setFontTextBoxHeight(textBox.height); } }}>{(activeFont.fontTextBoxes?.length ? activeFont.fontTextBoxes : [{ x: 0, y: 0, width: 1, height: 1 }]).map((_, index) => <option key={index} value={index}>{index + 1}</option>)}</select></label><div className="property-actions"><label>X <input aria-label="Font text box X" type="number" step="0.01" value={fontTextBoxX} onChange={(event) => setFontTextBoxX(Number(event.target.value) || 0)} /></label><label>Y <input aria-label="Font text box Y" type="number" step="0.01" value={fontTextBoxY} onChange={(event) => setFontTextBoxY(Number(event.target.value) || 0)} /></label><label>Width <input aria-label="Font text box width" type="number" min="0" step="0.01" value={fontTextBoxWidth} onChange={(event) => setFontTextBoxWidth(Math.max(0, Number(event.target.value) || 0))} /></label><label>Height <input aria-label="Font text box height" type="number" min="0" step="0.01" value={fontTextBoxHeight} onChange={(event) => setFontTextBoxHeight(Math.max(0, Number(event.target.value) || 0))} /></label><button type="button" onClick={applyFontAdvanced}>Apply Layout</button></div><output>{activeFont.fontCharacters?.length ?? 0} character records · {activeFont.fontTextBoxes?.length ?? 0} text boxes · active {activeFont.activeFontTextBox ?? 0}</output></div>
|
||||
<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}
|
||||
{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>
|
||||
<div className="property-section"><h3>Viewport Display</h3><label>显示颜色 <span className="swatch" /></label><label>可见性 <input aria-label="可见性" type="checkbox" defaultChecked /></label></div>
|
||||
{activeCurve && curveTopologyOperatorGate("TOGGLE_CYCLIC").status === "READY" ? <div className="property-section" data-testid="curve-topology-editor" data-ready-operators="TOGGLE_CYCLIC" data-curve-id={activeCurve.id} data-cyclic-u={(activeCurve.cyclicU ?? []).join(",")} data-spline-types={(activeCurve.splineTypes ?? []).join(",")} data-point-count={activeCurve.pointCount}><h3>Curve Topology</h3><label>Spline <select aria-label="Curve spline" value={Math.min(curveSplineIndex, Math.max(0, activeCurve.splineCount - 1))} onChange={(event) => setCurveSplineIndex(Number(event.target.value))}>{Array.from({ length: activeCurve.splineCount }, (_, index) => <option key={index} value={index}>{index + 1} {activeCurve.splineTypes?.[index] ?? "UNKNOWN"}</option>)}</select></label><label>Cyclic U <input aria-label="Curve cyclic U" type="checkbox" checked={activeCurve.cyclicU?.[curveSplineIndex] ?? false} onChange={toggleCurveCyclic} /></label><output data-testid="curve-cyclic-state">{activeCurve.cyclicU?.[curveSplineIndex] ? "Cyclic" : "Open"}</output>{curveTopologyError ? <output role="alert">{curveTopologyError}</output> : null}</div> : null}
|
||||
{activeCurve ? <div className="property-section" data-testid="curve-advanced-workbench" data-command="setCurveHandle,setCurveSplines"><h3>Advanced Curve</h3><label>Handle point <select aria-label="Curve handle point" value={Math.min(curvePointIndex, Math.max(0, curvePointIndices.length - 1))} onChange={(event) => setCurvePointIndex(Number(event.target.value))}>{curvePointIndices.map((pointIndex, index) => <option key={pointIndex} value={index}>{pointIndex + 1}</option>)}</select></label><label>Handle side <select aria-label="Curve handle side" value={curveHandleSide} onChange={(event) => setCurveHandleSide(event.target.value as "LEFT" | "RIGHT")}><option value="LEFT">Left</option><option value="RIGHT">Right</option></select></label><div className="property-actions"><button type="button" disabled={!activeCurve.handlePoints || curvePointIndices.length === 0} onClick={nudgeCurveHandle}>Nudge Handle X</button><button type="button" disabled={activeCurve.splineCount === 0} onClick={() => applyCurveSplines()}>Apply Current Splines</button><button type="button" disabled={activeCurve.splineCount === 0} onClick={() => applyCurveSplines("POLY")}>Set Selected Poly</button><button type="button" disabled={activeCurve.splineCount === 0} onClick={() => applyCurveSplines("BEZIER")}>Set Selected Bezier</button><button type="button" disabled={activeCurve.splineCount === 0} onClick={() => applyCurveSplines("NURBS")}>Set Selected NURBS</button></div><output>{activeCurve.pointCount} points · {activeCurve.splineCount} splines · handles {activeCurve.handlePoints?.length ?? 0}</output></div> : null}
|
||||
{activeSurface ? <div className="property-section" data-testid="surface-topology-workbench" data-command="setSurfaceTopology"><h3>Advanced Surface</h3><label>Spline <select aria-label="Surface spline" value={Math.min(surfaceSplineIndex, Math.max(0, (activeSurface.splineDimensions?.length ?? 1) - 1))} onChange={(event) => setSurfaceSplineIndex(Number(event.target.value))}>{(activeSurface.splineDimensions ?? []).map((dimension, index) => <option key={index} value={index}>{index + 1}: {dimension.u} × {dimension.v}</option>)}</select></label><output>{activeSurface.pointCount} points · {activeSurface.splineDimensions?.length ?? 0} spline surfaces · U {(activeSurface.cyclicU ?? []).map((value) => value ? "Cyclic" : "Open").join(", ") || "n/a"} · V {(activeSurface.cyclicV ?? []).map((value) => value ? "Cyclic" : "Open").join(", ") || "n/a"}</output><div className="property-actions"><button type="button" disabled={!activeSurface.splineDimensions?.length || !activeSurface.controlPoints?.length} onClick={() => applySurfaceTopology("U")}>Toggle U Cyclic</button><button type="button" disabled={!activeSurface.splineDimensions?.length || !activeSurface.controlPoints?.length} onClick={() => applySurfaceTopology("V")}>Toggle V Cyclic</button><button type="button" disabled={!activeSurface.splineDimensions?.length || !activeSurface.controlPoints?.length} onClick={() => applySurfaceTopology()}>Apply Current Topology</button></div></div> : null}
|
||||
{activeMesh ? <div className="property-section"><h3>UV Maps</h3><label>活动 UV <select aria-label="活动 UV Map" value={activeMesh.activeUVMap ?? ""} onChange={(event) => event.target.value && onCommand({ type: "setActiveUVMap", meshId: activeMesh.id, name: event.target.value })}><option value="">None</option>{activeMesh.uvLayers?.map((layer) => <option key={layer.name} value={layer.name}>{layer.name}</option>)}</select></label><div className="property-actions"><button type="button" onClick={() => onCommand({ type: "createUVMap", meshId: activeMesh.id, name: `UVMap.${(activeMesh.uvLayers?.length ?? 0) + 1}` })}>新增 UV</button><button type="button" disabled={selectedFaceIndices.length === 0} onClick={() => onCommand({ type: "unwrapUV", meshId: activeMesh.id, faceIndices: selectedFaceIndices, method: "PLANAR" })}>Planar</button><button type="button" disabled={selectedFaceIndices.length === 0} onClick={() => onCommand({ type: "unwrapUV", meshId: activeMesh.id, faceIndices: selectedFaceIndices, method: "CUBE" })}>Cube</button></div></div> : null}
|
||||
{activeGreasePencil ? <div
|
||||
className="property-section"
|
||||
@@ -1003,6 +1141,7 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea
|
||||
<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">
|
||||
<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>
|
||||
@@ -1108,6 +1247,9 @@ function OperatorSearch({ commands, onClose }: { commands: readonly OperatorComm
|
||||
|
||||
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("");
|
||||
useEffect(() => {
|
||||
if (!playing) return undefined;
|
||||
const timer = window.setInterval(() => {
|
||||
@@ -1126,6 +1268,30 @@ function Timeline({ snapshot, frame, start, end, onFrameChange, onCommand }: { s
|
||||
const animation = snapshot?.animations.find((candidate) => candidate.targetId === snapshot.activeObjectId);
|
||||
const actions = snapshot?.animations.filter((candidate) => candidate.targetId === snapshot.activeObjectId) ?? [];
|
||||
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;
|
||||
useEffect(() => {
|
||||
const nextTrack = nlaTracks.find((track) => track.id === selectedNlaTrackId) ?? nlaTracks.find((track) => track.selected) ?? nlaTracks[0] ?? null;
|
||||
const nextTrackId = nextTrack?.id ?? null;
|
||||
if (nextTrackId !== selectedNlaTrackId) setSelectedNlaTrackId(nextTrackId);
|
||||
const nextStrip = nextTrack?.strips.find((strip) => strip.id === selectedNlaStripId) ?? nextTrack?.strips.find((strip) => strip.selected) ?? nextTrack?.strips[0] ?? null;
|
||||
const nextStripId = nextStrip?.id ?? null;
|
||||
if (nextStripId !== selectedNlaStripId) setSelectedNlaStripId(nextStripId);
|
||||
}, [nlaTracks, selectedNlaStripId, selectedNlaTrackId]);
|
||||
useEffect(() => {
|
||||
setNlaFrameStartDraft(selectedNlaStrip ? String(selectedNlaStrip.frameStart) : "");
|
||||
}, [selectedNlaStrip?.frameStart, selectedNlaStrip?.id]);
|
||||
const commitNlaFrameStart = (): void => {
|
||||
if (!snapshot?.activeObjectId || !selectedNlaTrack || !selectedNlaStrip) return;
|
||||
const frameStart = Number(nlaFrameStartDraft);
|
||||
if (!Number.isFinite(frameStart)) {
|
||||
setNlaFrameStartDraft(String(selectedNlaStrip.frameStart));
|
||||
return;
|
||||
}
|
||||
if (frameStart !== selectedNlaStrip.frameStart) {
|
||||
onCommand(animationRenderCommands.moveNLAStrip(snapshot.activeObjectId, selectedNlaTrack.id, selectedNlaStrip.id, frameStart, snapshot.revision));
|
||||
}
|
||||
};
|
||||
const activeNode = snapshot?.nodes.find((candidate) => candidate.id === snapshot.activeObjectId);
|
||||
const greasePencil = snapshot?.greasePencils?.find((candidate) => candidate.id === activeNode?.dataId);
|
||||
const keyframes = [...new Set(greasePencil
|
||||
@@ -1139,6 +1305,7 @@ function Timeline({ snapshot, frame, start, end, onFrameChange, onCommand }: { s
|
||||
<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>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -1154,6 +1321,7 @@ export function App() {
|
||||
const [imeComposition, setIMEComposition] = useState(createIMECompositionState);
|
||||
const [keyboardObservation, setKeyboardObservation] = useState<KeyboardObservation | null>(null);
|
||||
const [snapshot, setSnapshot] = useState<SceneSnapshotIR | null>(null);
|
||||
const [editorWorkflow, setEditorWorkflow] = useState<EditorWorkflowIR | null>(null);
|
||||
const [selectedObjectIds, setSelectedObjectIds] = useState<Set<string>>(() => new Set());
|
||||
const [meshSelection, setMeshSelection] = useState<MeshEditSelection>({ meshId: null, mode: "FACE", indices: new Set() });
|
||||
const [greasePencilSelection, setGreasePencilSelection] = useState<GreasePencilSelectionStateIR | null>(null);
|
||||
@@ -1240,6 +1408,19 @@ export function App() {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
const dispatchUI = (command: UICommand) => setUIState((state) => reduceUICommand(state, command));
|
||||
useEffect(() => {
|
||||
if (!snapshot?.editorWorkflow || snapshot.editorWorkflowStatus !== "AVAILABLE") {
|
||||
setEditorWorkflow(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setEditorWorkflow(parseEditorWorkflow(snapshot.editorWorkflow));
|
||||
}
|
||||
catch {
|
||||
// The Worker validates the same payload before publishing the snapshot.
|
||||
setEditorWorkflow(null);
|
||||
}
|
||||
}, [snapshot]);
|
||||
const closeOperatorSearch = (): void => {
|
||||
dispatchUI({ type: "toggleOperatorSearch", open: false });
|
||||
window.requestAnimationFrame(() => operatorSearchTriggerRef.current?.focus());
|
||||
@@ -1556,7 +1737,7 @@ export function App() {
|
||||
// Cache invalidation is best effort and never blocks an engine command.
|
||||
}
|
||||
};
|
||||
const applyEditCommand = async (command: WebEngineEditCommand): Promise<boolean> => {
|
||||
const applyEditCommand = async (command: WebEngineEditCommand, onApplied?: (snapshot: SceneSnapshotIR) => void): Promise<boolean> => {
|
||||
const client = webClientRef.current;
|
||||
if (!client || !snapshot) {
|
||||
if (command.type === "setFrame") setFrame(command.frame);
|
||||
@@ -1585,6 +1766,7 @@ export function App() {
|
||||
setPreview(null);
|
||||
setLodLevels(null);
|
||||
setSnapshot(nextSnapshot);
|
||||
onApplied?.(nextSnapshot);
|
||||
setSelectedObjectIds(new Set(nextSnapshot.activeObjectId ? [nextSnapshot.activeObjectId] : []));
|
||||
setGeometryBuffers(result.geometryBuffers);
|
||||
setNonMeshGeometryBuffers(result.nonMeshGeometryBuffers ?? []);
|
||||
@@ -1713,6 +1895,47 @@ export function App() {
|
||||
bitmap?.close();
|
||||
}
|
||||
};
|
||||
const importVFont = async (file: File): Promise<void> => {
|
||||
const identity = beginUserAction("IMPORT");
|
||||
let failureCode = "IMPORT_VFONT_FAILED";
|
||||
try {
|
||||
const extension = file.name.slice(file.name.lastIndexOf(".")).toLowerCase();
|
||||
const mimeType = extension === ".ttf"
|
||||
? "font/ttf"
|
||||
: extension === ".otf"
|
||||
? "font/otf"
|
||||
: extension === ".pfb"
|
||||
? "application/x-font-type1"
|
||||
: null;
|
||||
if (!mimeType) throw new Error("TTF, OTF or PFB font files are required");
|
||||
const storage = storageClientRef.current;
|
||||
if (!storage) throw new Error("Storage worker is unavailable for font import");
|
||||
const data = await file.arrayBuffer();
|
||||
const sourcePath = `//fonts/${file.name.replace(/[\\/]/g, "_")}`;
|
||||
const sha256 = await sha256Hex(data);
|
||||
let appliedSnapshot: SceneSnapshotIR | null = null;
|
||||
failureCode = "IMPORT_VFONT_MAIN_TRANSACTION_FAILED";
|
||||
const result = await importExternalVFontIntoMain({
|
||||
projectId: projectIdRef.current,
|
||||
request: { sourcePath, mimeType, byteLength: data.byteLength, sha256, data },
|
||||
storage,
|
||||
engine: {
|
||||
applyCommand: async (command) => {
|
||||
const applied = await applyEditCommand(command, (nextSnapshot) => { appliedSnapshot = nextSnapshot; });
|
||||
if (!applied || !appliedSnapshot) throw new Error("Blender Main did not return the imported font snapshot");
|
||||
return { snapshot: appliedSnapshot };
|
||||
},
|
||||
},
|
||||
});
|
||||
succeedUserAction(identity);
|
||||
setEngineStatus(`Font: imported ${result.vfont.name} (${extension.slice(1).toUpperCase()})`);
|
||||
void refreshStorageBudget(projectIdRef.current);
|
||||
}
|
||||
catch (error) {
|
||||
failUserAction(identity, failureCode);
|
||||
setEngineStatus(recordDiagnostic("ENGINE", "COMMAND_FAILED", error, { fileName: file.name }));
|
||||
}
|
||||
};
|
||||
const transformActive = (tool: "translate" | "rotate" | "scale", amount = 0.1, axis: 0 | 1 | 2 = tool === "rotate" ? 2 : 0, axisVector?: [number, number, number]): void => {
|
||||
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
|
||||
if (!snapshot || !activeNode) return;
|
||||
@@ -2541,7 +2764,7 @@ export function App() {
|
||||
], IO_FORMAT_UI_REGISTRY).filter((command) => !command.ioFormat || resolveFreshIOFormatRuntimeRoute(IO_FORMAT_RUNTIME_RECEIPTS, IO_FORMAT_RUNTIME_RECEIPT_EXPECTED, { format: command.ioFormat.format, operation: command.ioFormat.operation }).status === "READY");
|
||||
|
||||
return (
|
||||
<main className="blender-app" data-workspace={workspace} data-ime-composing={imeComposition.composing ? "true" : "false"} data-ime-revision={imeComposition.revision} data-ime-last-event={imeComposition.lastEvent} data-key-key={keyboardObservation?.key} data-key-code={keyboardObservation?.code} data-key-location={keyboardObservation?.location} data-key-modifiers={`${keyboardObservation?.shiftKey ? "S" : ""}${keyboardObservation?.ctrlKey ? "C" : ""}${keyboardObservation?.altKey ? "A" : ""}${keyboardObservation?.metaKey ? "M" : ""}`} data-key-dead={keyboardObservation?.deadKey ? "true" : "false"} data-ui-revision={uiState.context.revision}
|
||||
<main className="blender-app" data-workspace={workspace} data-editor-workflow-status={snapshot?.editorWorkflowStatus ?? "BLOCKED"} data-editor-workflow-workspace={editorWorkflow?.context.workspaceId} data-editor-workflow-area={editorWorkflow?.context.activeAreaId} data-editor-workflow-revision={editorWorkflow?.context.revision} data-ime-composing={imeComposition.composing ? "true" : "false"} data-ime-revision={imeComposition.revision} data-ime-last-event={imeComposition.lastEvent} data-key-key={keyboardObservation?.key} data-key-code={keyboardObservation?.code} data-key-location={keyboardObservation?.location} data-key-modifiers={`${keyboardObservation?.shiftKey ? "S" : ""}${keyboardObservation?.ctrlKey ? "C" : ""}${keyboardObservation?.altKey ? "A" : ""}${keyboardObservation?.metaKey ? "M" : ""}`} data-key-dead={keyboardObservation?.deadKey ? "true" : "false"} data-ui-revision={uiState.context.revision}
|
||||
data-user-action-import-status={userActions.IMPORT.status} data-user-action-import-id={userActions.IMPORT.identity?.actionId} data-user-action-import-error={userActions.IMPORT.errorCode ?? undefined}
|
||||
data-user-action-open-status={userActions.OPEN.status} data-user-action-open-id={userActions.OPEN.identity?.actionId} data-user-action-open-error={userActions.OPEN.errorCode ?? undefined}
|
||||
data-user-action-save-status={userActions.SAVE.status} data-user-action-save-id={userActions.SAVE.identity?.actionId} data-user-action-save-error={userActions.SAVE.errorCode ?? undefined}
|
||||
@@ -2589,7 +2812,7 @@ export function App() {
|
||||
<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} 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)} 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="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}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import type { NonMeshFontLinksIR, SceneSnapshotIR, VFontResourceIR } from "../../../protocol/scene-ir";
|
||||
import type { StorageAssetPutResult, StorageAssetReadResult, StorageAssetRecord } from "../../../protocol/storage";
|
||||
import type { WebEngineEditCommand } from "../../../protocol/web-engine";
|
||||
import { nonMeshGeometryCommands } from "../../../protocol/nonmesh-geometry-commands";
|
||||
|
||||
export interface ExternalVFontStoragePort {
|
||||
putAsset(projectId: string, data: ArrayBuffer, mimeType: string, sourcePath?: string): Promise<StorageAssetPutResult>;
|
||||
@@ -74,7 +75,7 @@ export async function importExternalVFontIntoMain(options: ImportExternalVFontOp
|
||||
);
|
||||
const mainImport = createExternalVFontMainImport(validated, asset);
|
||||
const { data, ...proof } = mainImport;
|
||||
const applied = await options.engine.applyCommand({ type: "importVFont", ...proof, base64: encodeBase64(data) });
|
||||
const applied = await options.engine.applyCommand(nonMeshGeometryCommands.importVFont({ ...proof, base64: encodeBase64(data) }));
|
||||
const vfont = applied.snapshot.vfonts?.find((candidate) =>
|
||||
candidate.sourcePath === proof.sourcePath && candidate.name === proof.name && candidate.packed &&
|
||||
candidate.packedByteLength === proof.byteLength && candidate.sha256 === proof.sha256);
|
||||
@@ -118,7 +119,7 @@ export async function replaceExternalVFontStyleInMain(
|
||||
throw new ExternalVFontValidationError("NON_MESH_PROPERTY_INVALID", "font style already uses the requested VFont");
|
||||
}
|
||||
const links = { ...previousLinks, [options.style]: vfont.id };
|
||||
const applied = await options.engine.applyCommand({ type: "setFontLinks", dataId: data.id, links });
|
||||
const applied = await options.engine.applyCommand(nonMeshGeometryCommands.setFontLinks(data.id, links));
|
||||
const appliedData = applied.snapshot.nonMeshData?.find((candidate) => candidate.id === data.id);
|
||||
const appliedVFont = applied.snapshot.vfonts?.find((candidate) => candidate.id === vfont.id);
|
||||
if (!appliedData?.fontLinks || appliedData.fontLinks[options.style] !== vfont.id ||
|
||||
|
||||
Reference in New Issue
Block a user