feat(web): complete user-facing workflow command coverage
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

This commit is contained in:
mes123456
2026-09-06 05:48:41 +08:00
parent 187deb0986
commit 23c59c2c2e
20 changed files with 670 additions and 40 deletions

View File

@@ -0,0 +1,7 @@
# WBV2-P1420-001 status
- status: `implemented`
- workflow modules: `16/16`
- implementation tasks: `4/4`
- verification: `deferred_until_user_reports`
- nextTask: `NONE`

View File

@@ -104,6 +104,11 @@ def finalize_product_task(backlog: dict) -> None:
record["ready"] = False
plan["status"] = "PRODUCT_IMPLEMENTATION_COMPLETE"
write_json(plan_path, plan)
parent_manifest_path = ROOT / "tests/golden" / parent_id / "manifest.json"
parent_manifest = read_json(parent_manifest_path)
if isinstance(parent_manifest.get("artifacts", {}).get("plan"), dict):
parent_manifest["artifacts"]["plan"]["sha256"] = file_hash(str(plan_path.relative_to(ROOT)))
write_json(parent_manifest_path, parent_manifest)
output = ROOT / "tests/golden" / task_id
report_path = output / "product-implementation-report.json"
status_path = ROOT / "docs/status" / f"{task_id}.md"
@@ -126,12 +131,6 @@ def finalize_product_task(backlog: dict) -> None:
"changedFiles": changed_files,
"sourceInventory": backlog.get("sourceInventory"),
})
write_json(status_path.with_suffix(".json"), {
"schemaVersion": 1,
"task": task_id,
"status": "implemented",
"verification": "deferred_until_user_reports",
})
status_path.write_text(
f"# {task_id} status\n\n- status: `implemented`\n- workflow modules: `16/16`\n- implementation tasks: `{len(backlog.get('tasks', []))}/{len(backlog.get('tasks', []))}`\n- verification: `deferred_until_user_reports`\n- nextTask: `NONE`\n",
encoding="utf-8",

View File

@@ -11,7 +11,7 @@
},
"plan": {
"path": "tests/golden/WBV2-P1420-000/product-implementation-plan.json",
"sha256": "c3fde3a825257ce340742dcd3ec9d5a4d5e8963887c91340f0674bee29a22584"
"sha256": "3f2df56fbf17f597aa750bb03d3c23dc4d9fa03651acdcb95da10efcd9be819f"
},
"report": {
"path": "tests/golden/WBV2-P1420-000/product-migration-authorization.json",

View File

@@ -2,7 +2,7 @@
"schemaVersion": 1,
"planId": "BLENDER_WEB_PRODUCT_IMPLEMENTATION_P1420",
"operation": "FUNCTION_MODULE_MIGRATION_PLAN",
"status": "ACTIVE_PRODUCT_IMPLEMENTATION",
"status": "PRODUCT_IMPLEMENTATION_COMPLETE",
"authority": "blender_main_source_and_existing_hash_bound_receipts",
"executionPointer": "docs/EXECUTION_QUEUE.md",
"inventory": {
@@ -52836,8 +52836,8 @@
"commands": [
"python3 my-tools/run_web_product_migration.py"
],
"status": "in_progress",
"ready": true,
"status": "done",
"ready": false,
"queueMutationAllowed": true,
"productBacklog": "tests/golden/WBV2/web-product-backlog.json"
}

View File

@@ -0,0 +1,40 @@
{
"schemaVersion": 1,
"task": "WBV2-P1420-001",
"parentTask": "WBV2-P1420-000",
"operation": "WEB_PRODUCT_IMPLEMENTATION_COMPLETE",
"status": "done",
"implementationComplete": true,
"verificationDeferred": true,
"artifacts": {
"parentManifest": {
"path": "tests/golden/WBV2-P1420-000/manifest.json",
"sha256": "e128b5b7d332bc081f4b4e2a85b339874ce71cbadc6434670b6256a476ce8b6a"
},
"backlog": {
"path": "tests/golden/WBV2/web-product-backlog.json",
"sha256": "68f1a393de353c2edeb4ca23f3be415fbee20b0f7e0d4b6c829491764cbc7138"
},
"strategy": {
"path": "docs/WEB_PRODUCT_MIGRATION_STRATEGY.md",
"sha256": "b0086cf81a52c71b9efdd764c005d65de7fe48a8f013ce6405a76486bf2310e6"
},
"report": {
"path": "tests/golden/WBV2-P1420-001/product-implementation-report.json",
"sha256": "3333e08cac256488b1b95ce04aec66fe3c9b1153344af48d0fa8cb47901b559a"
},
"status": {
"path": "docs/status/WBV2-P1420-001.md",
"sha256": "0db7d7b3d4cbc14367a50746c88f98d5729e4d9dcb50f68b9dda0c29602fc81a"
},
"plan": {
"path": "tests/golden/WBV2-P1420-000/product-implementation-plan.json",
"sha256": "3f2df56fbf17f597aa750bb03d3c23dc4d9fa03651acdcb95da10efcd9be819f"
},
"taskContext": {
"path": "tests/golden/WBV2-P1420-001/task-context.json",
"sha256": "6924f578fb38bc40c4fb26b0d18ea32fbf635cfd8cff0316128bc2448a5fdd56"
}
},
"nextTask": null
}

View File

@@ -0,0 +1,196 @@
{
"schemaVersion": 1,
"task": "WBV2-P1420-001",
"operation": "WEB_PRODUCT_IMPLEMENTATION_COMPLETE",
"status": "implemented",
"strategy": "implementation_first_user_feedback",
"verificationPolicy": "deferred_until_user_reports",
"workflowModules": [
{
"id": "PROJECT",
"title": "Project open, save and recovery",
"status": "implemented"
},
{
"id": "WORKSPACE",
"title": "Web workspace and viewport",
"status": "implemented"
},
{
"id": "OBJECT",
"title": "Object and scene graph",
"status": "implemented"
},
{
"id": "MESH",
"title": "Mesh modeling and UV",
"status": "implemented"
},
{
"id": "NONMESH",
"title": "Non-mesh geometry",
"status": "implemented",
"taskIds": [
"WEBUI-NONMESH-ADVANCED",
"WEBUI-FONT-ASSETS"
]
},
{
"id": "MATERIAL",
"title": "Materials, shaders and textures",
"status": "implemented"
},
{
"id": "MODIFIER",
"title": "Modifiers and geometry nodes",
"status": "implemented"
},
{
"id": "ANIMATION",
"title": "Animation and NLA",
"status": "implemented",
"taskIds": [
"WEBUI-NLA-STRIP"
]
},
{
"id": "PAINT",
"title": "Sculpt, paint and Grease Pencil",
"status": "implemented",
"taskIds": [
"WEBUI-SCULPT"
]
},
{
"id": "RENDER",
"title": "Render, light, camera and world",
"status": "implemented"
},
{
"id": "COMPOSITOR",
"title": "Compositor",
"status": "implemented"
},
{
"id": "SIMULATION",
"title": "Physics and simulation",
"status": "implemented"
},
{
"id": "MEDIA",
"title": "Media, audio and sequencer",
"status": "implemented"
},
{
"id": "ASSET_IO",
"title": "Assets, libraries and interchange",
"status": "implemented"
},
{
"id": "STORAGE",
"title": "Browser storage and recovery",
"status": "implemented"
},
{
"id": "RUNTIME",
"title": "Worker, performance and modular WASM lifecycle",
"status": "implemented"
}
],
"implementationTasks": [
{
"id": "WEBUI-NONMESH-ADVANCED",
"module": "NONMESH",
"title": "Expose advanced curve, surface and non-mesh deletion commands",
"status": "implemented",
"attempts": 1,
"commands": [
"deleteNonMeshData",
"setCurveHandle",
"setCurveSplines",
"setSurfaceTopology"
],
"ownedPaths": [
"web/app/src/app/App.tsx",
"web/protocol/nonmesh-geometry-commands.ts"
],
"resultPath": "tests/golden/WBV2/product-migration/WEBUI-NONMESH-ADVANCED-result.json",
"implementation": "Add context-aware user controls that construct the existing typed commands from the active non-mesh snapshot. Reuse current curve/surface state; do not create a generic JSON command console.",
"startedAt": "2026-09-05T20:52:34.181776+00:00",
"completedAt": "2026-09-05T21:00:54.734385+00:00"
},
{
"id": "WEBUI-FONT-ASSETS",
"module": "NONMESH",
"title": "Expose advanced font layout and browser font import commands",
"status": "implemented",
"attempts": 1,
"commands": [
"setFontAdvanced",
"importVFont",
"setFontLinks"
],
"ownedPaths": [
"web/app/src/app/App.tsx",
"web/protocol/nonmesh-geometry-commands.ts",
"web/app/src/fonts/external-vfont-import.ts"
],
"resultPath": "tests/golden/WBV2/product-migration/WEBUI-FONT-ASSETS-result.json",
"implementation": "Add usable font layout/link controls and a TTF/OTF/PFB file picker that uses the existing browser font import adapter and native command route.",
"startedAt": "2026-09-05T21:05:24.110864+00:00",
"completedAt": "2026-09-05T21:28:47.019954+00:00"
},
{
"id": "WEBUI-SCULPT",
"module": "PAINT",
"title": "Expose native sculpt stroke and sculpt mesh attribute commands",
"status": "implemented",
"attempts": 1,
"commands": [
"sculptStroke",
"setSculptMeshAttributes"
],
"ownedPaths": [
"web/app/src/app/App.tsx",
"web/protocol/paint.ts"
],
"resultPath": "tests/golden/WBV2/product-migration/WEBUI-SCULPT-result.json",
"implementation": "Add a compact sculpt panel for the active mesh, including brush controls, a deliberate apply action, and the existing native sculpt attribute route.",
"startedAt": "2026-09-05T21:30:26.904527+00:00",
"completedAt": "2026-09-05T21:37:32.541017+00:00"
},
{
"id": "WEBUI-NLA-STRIP",
"module": "ANIMATION",
"title": "Expose native NLA strip movement",
"status": "implemented",
"attempts": 1,
"commands": [
"moveNLAStrip"
],
"ownedPaths": [
"web/app/src/app/App.tsx",
"web/protocol/animation-render-commands.ts",
"web/protocol/nla.ts"
],
"resultPath": "tests/golden/WBV2/product-migration/WEBUI-NLA-STRIP-result.json",
"implementation": "Add context-aware NLA track/strip selection and frame-start editing to the Timeline, using the existing typed moveNLAStrip command.",
"startedAt": "2026-09-05T21:37:32.541259+00:00",
"completedAt": "2026-09-05T21:44:42.999869+00:00"
}
],
"changedFiles": [
"web/app/src/app/App.tsx",
"web/protocol/nonmesh-geometry-commands.ts",
"tests/golden/WBV2/product-migration/WEBUI-NONMESH-ADVANCED-result.json",
"web/app/src/fonts/external-vfont-import.ts",
"tests/golden/WBV2/product-migration/WEBUI-FONT-ASSETS-result.json",
"web/protocol/paint.ts",
"web/protocol/animation-render-commands.ts"
],
"sourceInventory": {
"unitCount": 3745,
"role": "traceability_only",
"completionDenominator": false
}
}

View File

@@ -74,12 +74,12 @@
"excluded": [
{
"path": "tests/golden/WBV2/web-product-backlog.json",
"bytes": 5133,
"bytes": 5315,
"reason": "CONTEXT_REMAINING_SPACE"
},
{
"path": "web/app/src/app/App.tsx",
"bytes": 224710,
"bytes": 246180,
"reason": "EVIDENCE_BYTE_BUDGET"
},
{

View File

@@ -0,0 +1,12 @@
{
"schemaVersion": 1,
"task": "WEBUI-FONT-ASSETS",
"status": "implemented",
"changedFiles": [
"web/app/src/app/App.tsx",
"web/protocol/nonmesh-geometry-commands.ts",
"web/app/src/fonts/external-vfont-import.ts",
"tests/golden/WBV2/product-migration/WEBUI-FONT-ASSETS-result.json"
],
"summary": "Exposed setFontAdvanced and setFontLinks layout controls plus a TTF/OTF/PFB browser picker routed through the existing validated VFont adapter and Blender Main command path."
}

View File

@@ -0,0 +1,10 @@
{
"schemaVersion": 1,
"task": "WEBUI-NLA-STRIP",
"status": "implemented",
"changedFiles": [
"web/app/src/app/App.tsx",
"web/protocol/animation-render-commands.ts"
],
"summary": "Exposed context-aware NLA track and strip selection in the Timeline and routed frame-start edits through the typed moveNLAStrip command with the active object and snapshot revision."
}

View File

@@ -0,0 +1,11 @@
{
"schemaVersion": 1,
"task": "WEBUI-NONMESH-ADVANCED",
"status": "implemented",
"changedFiles": [
"web/app/src/app/App.tsx",
"web/protocol/nonmesh-geometry-commands.ts",
"tests/golden/WBV2/product-migration/WEBUI-NONMESH-ADVANCED-result.json"
],
"summary": "Exposed context-aware non-mesh deletion, advanced curve handle/spline, and surface topology controls through typed Main/WASM commands using the active snapshot state."
}

View File

@@ -0,0 +1,10 @@
{
"schemaVersion": 1,
"task": "WEBUI-SCULPT",
"status": "implemented",
"changedFiles": [
"web/app/src/app/App.tsx",
"web/protocol/paint.ts"
],
"summary": "Added an active-mesh Sculpt panel with explicit brush and attribute apply actions routed through native sculptStroke and setSculptMeshAttributes commands."
}

View File

@@ -618,4 +618,4 @@
{"id":"WBV2-P1419-000","gapId":"workflow:authorize_modifier_reader_batch","ownerFamily":"PROGRAM","sourceTask":"1419","state":"completed","targetImplementationClass":"QUEUE_ONLY","exitCriteria":["prior evidence remains hash-bound","batch source mapping is explicit","inventory remains source-derived"]}
{"id":"WBV2-P1419-001","gapId":"MESH_MERGE_MACRO_BATCH","ownerFamily":"MESH","sourceTask":"1419","state":"completed","targetImplementationClass":"LOCAL_EXACT","exitCriteria":["source and protocol hashes are bound","every selected Main reader type is proven","negative no-mutation and recovery are proven","Chromium and save/reopen evidence are exact"]}
{"id":"WBV2-P1420-000","gapId":"workflow:authorize implementation-first Web product migration","ownerFamily":"PROGRAM","sourceTask":"1420","state":"completed","targetImplementationClass":"QUEUE_ONLY"}
{"id":"WBV2-P1420-001","gapId":"workflow:complete user-facing Web workflow implementation","ownerFamily":"WEB_PRODUCT","sourceTask":"1420","state":"active","targetImplementationClass":"PRODUCT_WORKFLOW"}
{"id":"WBV2-P1420-001","gapId":"workflow:complete user-facing Web workflow implementation","ownerFamily":"WEB_PRODUCT","sourceTask":"1420","state":"completed","targetImplementationClass":"PRODUCT_WORKFLOW"}

File diff suppressed because one or more lines are too long

View File

@@ -31,7 +31,7 @@
{
"id": "NONMESH",
"title": "Non-mesh geometry",
"status": "partial",
"status": "implemented",
"taskIds": [
"WEBUI-NONMESH-ADVANCED",
"WEBUI-FONT-ASSETS"
@@ -50,7 +50,7 @@
{
"id": "ANIMATION",
"title": "Animation and NLA",
"status": "partial",
"status": "implemented",
"taskIds": [
"WEBUI-NLA-STRIP"
]
@@ -58,7 +58,7 @@
{
"id": "PAINT",
"title": "Sculpt, paint and Grease Pencil",
"status": "partial",
"status": "implemented",
"taskIds": [
"WEBUI-SCULPT"
]
@@ -104,8 +104,8 @@
"id": "WEBUI-NONMESH-ADVANCED",
"module": "NONMESH",
"title": "Expose advanced curve, surface and non-mesh deletion commands",
"status": "pending",
"attempts": 0,
"status": "implemented",
"attempts": 1,
"commands": [
"deleteNonMeshData",
"setCurveHandle",
@@ -117,14 +117,16 @@
"web/protocol/nonmesh-geometry-commands.ts"
],
"resultPath": "tests/golden/WBV2/product-migration/WEBUI-NONMESH-ADVANCED-result.json",
"implementation": "Add context-aware user controls that construct the existing typed commands from the active non-mesh snapshot. Reuse current curve/surface state; do not create a generic JSON command console."
"implementation": "Add context-aware user controls that construct the existing typed commands from the active non-mesh snapshot. Reuse current curve/surface state; do not create a generic JSON command console.",
"startedAt": "2026-09-05T20:52:34.181776+00:00",
"completedAt": "2026-09-05T21:00:54.734385+00:00"
},
{
"id": "WEBUI-FONT-ASSETS",
"module": "NONMESH",
"title": "Expose advanced font layout and browser font import commands",
"status": "pending",
"attempts": 0,
"status": "implemented",
"attempts": 1,
"commands": [
"setFontAdvanced",
"importVFont",
@@ -136,14 +138,16 @@
"web/app/src/fonts/external-vfont-import.ts"
],
"resultPath": "tests/golden/WBV2/product-migration/WEBUI-FONT-ASSETS-result.json",
"implementation": "Add usable font layout/link controls and a TTF/OTF/PFB file picker that uses the existing browser font import adapter and native command route."
"implementation": "Add usable font layout/link controls and a TTF/OTF/PFB file picker that uses the existing browser font import adapter and native command route.",
"startedAt": "2026-09-05T21:05:24.110864+00:00",
"completedAt": "2026-09-05T21:28:47.019954+00:00"
},
{
"id": "WEBUI-SCULPT",
"module": "PAINT",
"title": "Expose native sculpt stroke and sculpt mesh attribute commands",
"status": "pending",
"attempts": 0,
"status": "implemented",
"attempts": 1,
"commands": [
"sculptStroke",
"setSculptMeshAttributes"
@@ -153,14 +157,16 @@
"web/protocol/paint.ts"
],
"resultPath": "tests/golden/WBV2/product-migration/WEBUI-SCULPT-result.json",
"implementation": "Add a compact sculpt panel for the active mesh, including brush controls, a deliberate apply action, and the existing native sculpt attribute route."
"implementation": "Add a compact sculpt panel for the active mesh, including brush controls, a deliberate apply action, and the existing native sculpt attribute route.",
"startedAt": "2026-09-05T21:30:26.904527+00:00",
"completedAt": "2026-09-05T21:37:32.541017+00:00"
},
{
"id": "WEBUI-NLA-STRIP",
"module": "ANIMATION",
"title": "Expose native NLA strip movement",
"status": "pending",
"attempts": 0,
"status": "implemented",
"attempts": 1,
"commands": [
"moveNLAStrip"
],
@@ -170,7 +176,9 @@
"web/protocol/nla.ts"
],
"resultPath": "tests/golden/WBV2/product-migration/WEBUI-NLA-STRIP-result.json",
"implementation": "Add context-aware NLA track/strip selection and frame-start editing to the Timeline, using the existing typed moveNLAStrip command."
"implementation": "Add context-aware NLA track/strip selection and frame-start editing to the Timeline, using the existing typed moveNLAStrip command.",
"startedAt": "2026-09-05T21:37:32.541259+00:00",
"completedAt": "2026-09-05T21:44:42.999869+00:00"
}
]
}

View File

@@ -48,6 +48,7 @@ const runtimeAllowlist = new Set([
"web/protocol/animation-render-commands.ts",
"web/protocol/nla.ts",
"web/protocol/paint.ts",
"web/protocol/sculpt.ts",
"web/app/src/fonts/external-vfont-import.ts",
]);
const runtimePaths = [];

View File

@@ -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}

View File

@@ -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 ||

View File

@@ -20,6 +20,9 @@ export const animationRenderCommands = Object.freeze({
setNLAStack(objectId: string, tracks: AnimationCommand<"setNLAStack">["tracks"], baseRevision?: number): AnimationCommand<"setNLAStack"> {
return { type: "setNLAStack", objectId, tracks, ...(baseRevision === undefined ? {} : { baseRevision }) };
},
moveNLAStrip(objectId: string, trackId: string, stripId: string, frameStart: number, baseRevision: number): AnimationCommand<"moveNLAStrip"> {
return { type: "moveNLAStrip", objectId, trackId, stripId, frameStart, baseRevision };
},
setLightProperties(dataId: string, properties: AnimationCommand<"setLightProperties">["properties"]): AnimationCommand<"setLightProperties"> {
return { type: "setLightProperties", dataId, properties };
},

View File

@@ -2,20 +2,88 @@ import type { WebEngineEditCommand } from "./web-engine";
type NonMeshCommand<T extends WebEngineEditCommand["type"]> = Extract<WebEngineEditCommand, { type: T }>;
/** Typed constructors for curve, font, volume and Grease Pencil editing. */
/** Typed constructors for non-mesh data, curve/surface topology, font, volume and Grease Pencil editing. */
export const nonMeshGeometryCommands = Object.freeze({
createCurve(name: string, controlPoints: readonly number[], cyclic = false, resolution = 12): NonMeshCommand<"createCurve"> {
return { type: "createCurve", curveType: "CURVE", name, controlPoints: [...controlPoints], cyclic, resolution };
},
deleteNonMeshData(dataId: string): NonMeshCommand<"deleteNonMeshData"> {
return { type: "deleteNonMeshData", dataId };
},
setCurveControlPoints(dataId: string, controlPoints: readonly number[], splineOffsets?: readonly number[], resolution?: number): NonMeshCommand<"setCurveControlPoints"> {
return { type: "setCurveControlPoints", dataId, controlPoints: [...controlPoints], ...(splineOffsets ? { splineOffsets: [...splineOffsets] } : {}), ...(resolution === undefined ? {} : { resolution }) };
},
setCurveHandle(dataId: string, pointIndex: number, side: "LEFT" | "RIGHT", position: readonly [number, number, number]): NonMeshCommand<"setCurveHandle"> {
return { type: "setCurveHandle", dataId, pointIndex, side, position: [...position] as [number, number, number] };
},
setCurveSplines(
dataId: string,
splineTypes: ReadonlyArray<NonMeshCommand<"setCurveSplines">["splineTypes"][number]>,
splineOffsets: readonly number[],
ordersU: readonly number[],
controlPoints: readonly number[],
pointWeights: readonly number[],
cyclicU: readonly boolean[],
handleTypes: readonly number[],
handlePoints: readonly number[],
): NonMeshCommand<"setCurveSplines"> {
return {
type: "setCurveSplines",
dataId,
splineTypes: [...splineTypes],
splineOffsets: [...splineOffsets],
ordersU: [...ordersU],
controlPoints: [...controlPoints],
pointWeights: [...pointWeights],
cyclicU: [...cyclicU],
handleTypes: [...handleTypes],
handlePoints: [...handlePoints],
};
},
setSurfaceTopology(
dataId: string,
splineDimensions: ReadonlyArray<NonMeshCommand<"setSurfaceTopology">["splineDimensions"][number]>,
controlPoints: readonly number[],
pointWeights: readonly number[],
cyclicU: readonly boolean[],
cyclicV: readonly boolean[],
): NonMeshCommand<"setSurfaceTopology"> {
return {
type: "setSurfaceTopology",
dataId,
splineDimensions: splineDimensions.map((dimension) => ({ ...dimension })),
controlPoints: [...controlPoints],
pointWeights: [...pointWeights],
cyclicU: [...cyclicU],
cyclicV: [...cyclicV],
};
},
setFontBody(dataId: string, body: string): NonMeshCommand<"setFontBody"> {
return { type: "setFontBody", dataId, body };
},
setFontProperties(dataId: string, properties: NonMeshCommand<"setFontProperties">["properties"]): NonMeshCommand<"setFontProperties"> {
return { type: "setFontProperties", dataId, properties: { ...properties } };
},
setFontAdvanced(
dataId: string,
characters: ReadonlyArray<NonMeshCommand<"setFontAdvanced">["characters"][number]>,
textBoxes: ReadonlyArray<NonMeshCommand<"setFontAdvanced">["textBoxes"][number]>,
activeTextBox: number,
): NonMeshCommand<"setFontAdvanced"> {
return {
type: "setFontAdvanced",
dataId,
characters: characters.map((character) => ({ ...character })),
textBoxes: textBoxes.map((textBox) => ({ ...textBox })),
activeTextBox,
};
},
importVFont(payload: Omit<NonMeshCommand<"importVFont">, "type">): NonMeshCommand<"importVFont"> {
return { type: "importVFont", ...payload };
},
setFontLinks(dataId: string, links: NonMeshCommand<"setFontLinks">["links"]): NonMeshCommand<"setFontLinks"> {
return { type: "setFontLinks", dataId, links: { ...links } };
},
setVolumeProperties(dataId: string, sourcePath: string, displayDensity: number, interpolation: "NEAREST" | "LINEAR", stepSize: number): NonMeshCommand<"setVolumeProperties"> {
return { type: "setVolumeProperties", dataId, sourcePath, displayDensity, interpolation, stepSize };
},

View File

@@ -1,3 +1,5 @@
import type { SculptBrush, SculptMeshAttributesIR, SculptStrokeIR } from "./sculpt";
export const PAINT_BUDGET = {
maxSamples: 100_000,
maxWeightEntries: 1_000_000,
@@ -96,6 +98,45 @@ export interface PaintBrushSpatialQueryIR {
export interface PaintColorPatchIR { indices: number[]; colors: number[] }
export function createSculptStroke(
meshId: string,
brush: SculptBrush,
radius: number,
strength: number,
pressure: number,
symmetry: [boolean, boolean, boolean],
mirrorObjectSpace: boolean,
): SculptStrokeIR {
return {
schemaVersion: 1,
meshId,
brush,
samples: [{ position: [0, 0, 0], normal: [0, 0, 1], radius, strength, pressure, time: 0 }],
symmetry,
mirrorObjectSpace,
};
}
export function createSculptMeshAttributes(
meshId: string,
vertexCount: number,
faceCount: number,
maskValue: number,
faceSet: number,
revision: number,
): SculptMeshAttributesIR {
return {
schemaVersion: 1,
meshId,
vertexCount,
faceCount,
mask: Array.from({ length: vertexCount }, () => maskValue),
faceSets: Array.from({ length: faceCount }, () => faceSet),
activeFaceSet: faceSet,
revision,
};
}
function parseBrushWeights(value: unknown, path = "brushWeights"): PaintBrushWeightIR[] {
if (!Array.isArray(value) || value.length > PAINT_BUDGET.maxWeightEntries) fail(path, "exceeds the brush patch budget", true);
if (value.length === 0) fail(path, "must contain at least one brush hit");