修复资源依赖图布局初始化与拖动性能
将任务依赖深度迁移到 Rust SCC 压缩图并返回稳定结果 为 dependency 模式增加图加载屏障并重派生自动布局 通过 DOM 热路径和局部 SVG 缓存避免拖动全量 React 重渲染 补充 4096 资源、异步初始化及布局保持回归测试 同步工作台 PRD、技术方案与共享项目记忆
This commit is contained in:
@@ -50,6 +50,7 @@ pub(crate) struct ProjectResourceConnectionIndex {
|
||||
pub(crate) struct ProjectResourceProducerAssignment {
|
||||
pub resource_id: String,
|
||||
pub task_id: String,
|
||||
pub dependency_depth: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
@@ -77,6 +78,7 @@ struct DirectedEdge {
|
||||
struct CycleAnalysis {
|
||||
cyclic_node_ids: BTreeSet<String>,
|
||||
cyclic_edge_ids: BTreeSet<String>,
|
||||
component_by_node: BTreeMap<String, usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
@@ -185,9 +187,68 @@ fn analyze_directed_cycles<'a>(
|
||||
result.cyclic_edge_ids.insert(edge.id.clone());
|
||||
}
|
||||
}
|
||||
result.component_by_node = component_by_node;
|
||||
result
|
||||
}
|
||||
|
||||
fn dependency_depth_by_node(
|
||||
analysis: &CycleAnalysis,
|
||||
edges: &[DirectedEdge],
|
||||
) -> BTreeMap<String, u32> {
|
||||
let component_count = analysis
|
||||
.component_by_node
|
||||
.values()
|
||||
.copied()
|
||||
.max()
|
||||
.map_or(0, |max_component| max_component + 1);
|
||||
let mut outgoing = vec![BTreeSet::<usize>::new(); component_count];
|
||||
let mut indegree = vec![0usize; component_count];
|
||||
for edge in edges {
|
||||
let Some(&source_component) = analysis.component_by_node.get(&edge.source_id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(&target_component) = analysis.component_by_node.get(&edge.target_id) else {
|
||||
continue;
|
||||
};
|
||||
if source_component != target_component
|
||||
&& outgoing[source_component].insert(target_component)
|
||||
{
|
||||
indegree[target_component] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut ready = indegree
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(component, degree)| (*degree == 0).then_some(component))
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut depth_by_component = vec![0u32; component_count];
|
||||
while let Some(component) = ready.pop_first() {
|
||||
for &target in &outgoing[component] {
|
||||
depth_by_component[target] =
|
||||
depth_by_component[target].max(depth_by_component[component].saturating_add(1));
|
||||
indegree[target] -= 1;
|
||||
if indegree[target] == 0 {
|
||||
ready.insert(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
analysis
|
||||
.component_by_node
|
||||
.iter()
|
||||
.map(|(node_id, component)| {
|
||||
(
|
||||
node_id.clone(),
|
||||
depth_by_component
|
||||
.get(*component)
|
||||
.copied()
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn audit_asset_producers(
|
||||
records: &[serde_json::Value],
|
||||
task_ids: &BTreeSet<String>,
|
||||
@@ -403,6 +464,7 @@ pub(crate) fn build_project_resource_graph(
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let task_cycles = analyze_directed_cycles(task_by_id.keys(), &task_dependency_edges);
|
||||
let task_dependency_depths = dependency_depth_by_node(&task_cycles, &task_dependency_edges);
|
||||
let task_flows = task_dependency_edges
|
||||
.iter()
|
||||
.filter_map(|edge| {
|
||||
@@ -476,6 +538,10 @@ pub(crate) fn build_project_resource_graph(
|
||||
.into_iter()
|
||||
.map(|(resource_id, task_id)| ProjectResourceProducerAssignment {
|
||||
resource_id,
|
||||
dependency_depth: task_dependency_depths
|
||||
.get(&task_id)
|
||||
.copied()
|
||||
.unwrap_or_default(),
|
||||
task_id,
|
||||
})
|
||||
.collect(),
|
||||
@@ -610,6 +676,14 @@ mod tests {
|
||||
assert_eq!(graph.task_flows.len(), 1);
|
||||
assert_eq!(graph.task_flows[0].source_task_id, "art-director");
|
||||
assert_eq!(graph.task_flows[0].target_task_id, "design-foundation");
|
||||
assert_eq!(
|
||||
graph
|
||||
.producer_assignments
|
||||
.iter()
|
||||
.map(|assignment| (assignment.resource_id.as_str(), assignment.dependency_depth,))
|
||||
.collect::<BTreeMap<_, _>>(),
|
||||
BTreeMap::from([("asset:spec", 0), ("asset:ui", 1)]),
|
||||
);
|
||||
assert!(graph
|
||||
.producer_assignments
|
||||
.iter()
|
||||
@@ -734,5 +808,46 @@ mod tests {
|
||||
.connection_index
|
||||
.iter()
|
||||
.all(|index| index.task_flow_ids.len() <= 2));
|
||||
assert_eq!(
|
||||
graph
|
||||
.producer_assignments
|
||||
.iter()
|
||||
.find(|assignment| assignment.resource_id == "resource:4095")
|
||||
.map(|assignment| assignment.dependency_depth),
|
||||
Some(4095),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_depth_collapses_cycles_before_following_downstream_tasks() {
|
||||
let graph = build_project_resource_graph(
|
||||
&manifest(
|
||||
vec![
|
||||
task("source", &[]),
|
||||
task("cycle-a", &["source", "cycle-b"]),
|
||||
task("cycle-b", &["cycle-a"]),
|
||||
task("target", &["cycle-b"]),
|
||||
],
|
||||
Vec::new(),
|
||||
),
|
||||
vec![
|
||||
resource("source-resource", None, Some("source")),
|
||||
resource("cycle-a-resource", None, Some("cycle-a")),
|
||||
resource("cycle-b-resource", None, Some("cycle-b")),
|
||||
resource("target-resource", None, Some("target")),
|
||||
],
|
||||
&[],
|
||||
false,
|
||||
);
|
||||
|
||||
let depths = graph
|
||||
.producer_assignments
|
||||
.iter()
|
||||
.map(|assignment| (assignment.resource_id.as_str(), assignment.dependency_depth))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
assert_eq!(depths["source-resource"], 0);
|
||||
assert_eq!(depths["cycle-a-resource"], 1);
|
||||
assert_eq!(depths["cycle-b-resource"], 1);
|
||||
assert_eq!(depths["target-resource"], 2);
|
||||
}
|
||||
}
|
||||
|
||||
+142
-65
@@ -1,4 +1,13 @@
|
||||
import { useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useId,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type {
|
||||
ProjectResourceCanvasPosition,
|
||||
@@ -34,11 +43,21 @@ type RectLookup = {
|
||||
export type ResourceDependencyOverlayProps = {
|
||||
graph: ProjectResourceGraph;
|
||||
positions: readonly ProjectResourceCanvasPosition[];
|
||||
dragPreview: (Point & { resourceId: string }) | null;
|
||||
visibleResourceIds: ReadonlySet<string>;
|
||||
selectedResourceId: string | null;
|
||||
};
|
||||
|
||||
export type ResourceDependencyOverlayHandle = {
|
||||
updateDragPreview: (preview: Point & { resourceId: string }) => void;
|
||||
clearDragPreview: () => void;
|
||||
};
|
||||
|
||||
type TaskFlowPathRefs = {
|
||||
sourceBranches: Map<string, SVGPathElement>;
|
||||
targetBranches: Map<string, SVGPathElement>;
|
||||
trunk: SVGPathElement | null;
|
||||
};
|
||||
|
||||
const SECTION_SELECTOR = '[data-resource-section-plane]';
|
||||
const TASK_FLOW_HUB_GAP = 20;
|
||||
const CONNECTION_MAX_HANDLE = 180;
|
||||
@@ -200,18 +219,25 @@ function taskFlowGeometry(
|
||||
return { sourceAnchors, targetAnchors, sourceHub, targetHub };
|
||||
}
|
||||
|
||||
export function ResourceDependencyOverlay({
|
||||
graph,
|
||||
positions,
|
||||
dragPreview,
|
||||
visibleResourceIds,
|
||||
selectedResourceId,
|
||||
}: ResourceDependencyOverlayProps) {
|
||||
export const ResourceDependencyOverlay = forwardRef<
|
||||
ResourceDependencyOverlayHandle,
|
||||
ResourceDependencyOverlayProps
|
||||
>(function ResourceDependencyOverlay(
|
||||
{ graph, positions, visibleResourceIds, selectedResourceId },
|
||||
ref,
|
||||
) {
|
||||
const markerPrefix = useId().replace(/[^a-zA-Z0-9_-]/gu, '');
|
||||
const overlayRef = useRef<SVGSVGElement>(null);
|
||||
const referencePathRefs = useRef(new Map<string, SVGPathElement>());
|
||||
const taskFlowGroupRefs = useRef(new Map<string, SVGGElement>());
|
||||
const activeDragResourceIdRef = useRef<string | null>(null);
|
||||
const taskFlowPathRefs = useRef(new Map<string, TaskFlowPathRefs>());
|
||||
const activeDragPreviewRef = useRef<
|
||||
(Point & { resourceId: string }) | null
|
||||
>(null);
|
||||
const graphRef = useRef(graph);
|
||||
const positionByResourceIdRef = useRef(
|
||||
new Map(positions.map((position) => [position.resourceId, position])),
|
||||
);
|
||||
const rectByResourceIdRef = useRef<Map<string, Rect>>(new Map());
|
||||
const [sectionOrigins, setSectionOrigins] = useState<SectionOrigins>({});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -290,6 +316,11 @@ export function ResourceDependencyOverlay({
|
||||
}
|
||||
return result;
|
||||
}, [graph.resourceIds, positions, sectionOrigins, visibleResourceIds]);
|
||||
graphRef.current = graph;
|
||||
positionByResourceIdRef.current = new Map(
|
||||
positions.map((position) => [position.resourceId, position]),
|
||||
);
|
||||
rectByResourceIdRef.current = rectByResourceId;
|
||||
|
||||
const neighbors = useMemo(
|
||||
() => projectResourceGraphNeighbors(graph, selectedResourceId),
|
||||
@@ -312,13 +343,6 @@ export function ResourceDependencyOverlay({
|
||||
}${flow.cyclic ? ' is-cyclic' : ''}`;
|
||||
return (
|
||||
<g
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
taskFlowGroupRefs.current.set(flow.id, node);
|
||||
} else {
|
||||
taskFlowGroupRefs.current.delete(flow.id);
|
||||
}
|
||||
}}
|
||||
key={flow.id}
|
||||
className={className}
|
||||
data-edge-kind="task-flow"
|
||||
@@ -332,6 +356,22 @@ export function ResourceDependencyOverlay({
|
||||
}`}</title>
|
||||
{geometry.sourceAnchors.map(({ resourceId, point }) => (
|
||||
<path
|
||||
ref={(node) => {
|
||||
let refs = taskFlowPathRefs.current.get(flow.id);
|
||||
if (!refs) {
|
||||
refs = {
|
||||
sourceBranches: new Map(),
|
||||
targetBranches: new Map(),
|
||||
trunk: null,
|
||||
};
|
||||
taskFlowPathRefs.current.set(flow.id, refs);
|
||||
}
|
||||
if (node) {
|
||||
refs.sourceBranches.set(resourceId, node);
|
||||
} else {
|
||||
refs.sourceBranches.delete(resourceId);
|
||||
}
|
||||
}}
|
||||
key={`source:${resourceId}`}
|
||||
className="game-resource-dependency-branch"
|
||||
data-branch-side="source"
|
||||
@@ -340,11 +380,39 @@ export function ResourceDependencyOverlay({
|
||||
/>
|
||||
))}
|
||||
<path
|
||||
ref={(node) => {
|
||||
let refs = taskFlowPathRefs.current.get(flow.id);
|
||||
if (!refs) {
|
||||
refs = {
|
||||
sourceBranches: new Map(),
|
||||
targetBranches: new Map(),
|
||||
trunk: null,
|
||||
};
|
||||
taskFlowPathRefs.current.set(flow.id, refs);
|
||||
}
|
||||
refs.trunk = node;
|
||||
}}
|
||||
className="game-resource-dependency-trunk"
|
||||
d={connectionPath(geometry.sourceHub, geometry.targetHub)}
|
||||
/>
|
||||
{geometry.targetAnchors.map(({ resourceId, point }) => (
|
||||
<path
|
||||
ref={(node) => {
|
||||
let refs = taskFlowPathRefs.current.get(flow.id);
|
||||
if (!refs) {
|
||||
refs = {
|
||||
sourceBranches: new Map(),
|
||||
targetBranches: new Map(),
|
||||
trunk: null,
|
||||
};
|
||||
taskFlowPathRefs.current.set(flow.id, refs);
|
||||
}
|
||||
if (node) {
|
||||
refs.targetBranches.set(resourceId, node);
|
||||
} else {
|
||||
refs.targetBranches.delete(resourceId);
|
||||
}
|
||||
}}
|
||||
key={`target:${resourceId}`}
|
||||
className="game-resource-dependency-branch"
|
||||
data-branch-side="target"
|
||||
@@ -410,49 +478,49 @@ export function ResourceDependencyOverlay({
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const affectedResourceIds = new Set<string>();
|
||||
if (activeDragResourceIdRef.current) {
|
||||
affectedResourceIds.add(activeDragResourceIdRef.current);
|
||||
}
|
||||
if (dragPreview) {
|
||||
affectedResourceIds.add(dragPreview.resourceId);
|
||||
}
|
||||
activeDragResourceIdRef.current = dragPreview?.resourceId ?? null;
|
||||
if (affectedResourceIds.size === 0) {
|
||||
return;
|
||||
const activeFlowIds = new Set(graph.taskFlows.map((flow) => flow.id));
|
||||
for (const flowId of taskFlowPathRefs.current.keys()) {
|
||||
if (!activeFlowIds.has(flowId)) {
|
||||
taskFlowPathRefs.current.delete(flowId);
|
||||
}
|
||||
}
|
||||
}, [graph.taskFlows]);
|
||||
|
||||
const updateAffectedGeometry = useCallback(
|
||||
(
|
||||
affectedResourceIds: ReadonlySet<string>,
|
||||
dragPreview: (Point & { resourceId: string }) | null,
|
||||
) => {
|
||||
const currentGraph = graphRef.current;
|
||||
const currentRects = rectByResourceIdRef.current;
|
||||
const dragBasePosition = dragPreview
|
||||
? positions.find(
|
||||
(position) => position.resourceId === dragPreview.resourceId,
|
||||
)
|
||||
? positionByResourceIdRef.current.get(dragPreview.resourceId)
|
||||
: undefined;
|
||||
const rectLookup = {
|
||||
get(resourceId: string) {
|
||||
const rect = rectByResourceId.get(resourceId);
|
||||
const rect = currentRects.get(resourceId);
|
||||
if (!rect) {
|
||||
return undefined;
|
||||
}
|
||||
return dragPreview?.resourceId === resourceId
|
||||
? {
|
||||
...rect,
|
||||
x:
|
||||
rect.x - (dragBasePosition?.x ?? 0) + dragPreview.x,
|
||||
y:
|
||||
rect.y - (dragBasePosition?.y ?? 0) + dragPreview.y,
|
||||
x: rect.x - (dragBasePosition?.x ?? 0) + dragPreview.x,
|
||||
y: rect.y - (dragBasePosition?.y ?? 0) + dragPreview.y,
|
||||
}
|
||||
: rect;
|
||||
},
|
||||
};
|
||||
const affectedEdgeIds = new Set<string>();
|
||||
for (const resourceId of affectedResourceIds) {
|
||||
const index = graph.connectionIndex.get(resourceId);
|
||||
const index = currentGraph.connectionIndex.get(resourceId);
|
||||
index?.referenceEdgeIds.forEach((edgeId) =>
|
||||
affectedEdgeIds.add(edgeId),
|
||||
);
|
||||
index?.taskFlowIds.forEach((flowId) => affectedEdgeIds.add(flowId));
|
||||
}
|
||||
for (const edgeId of affectedEdgeIds) {
|
||||
const referenceEdge = graph.referenceEdgeById.get(edgeId);
|
||||
const referenceEdge = currentGraph.referenceEdgeById.get(edgeId);
|
||||
if (referenceEdge) {
|
||||
const geometry = referenceGeometry(referenceEdge, rectLookup);
|
||||
const path = referencePathRefs.current.get(edgeId);
|
||||
@@ -461,45 +529,29 @@ export function ResourceDependencyOverlay({
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const flow = graph.taskFlowById.get(edgeId);
|
||||
const group = taskFlowGroupRefs.current.get(edgeId);
|
||||
if (!flow || !group) {
|
||||
const flow = currentGraph.taskFlowById.get(edgeId);
|
||||
const paths = taskFlowPathRefs.current.get(edgeId);
|
||||
if (!flow || !paths) {
|
||||
continue;
|
||||
}
|
||||
const geometry = taskFlowGeometry(flow, rectLookup);
|
||||
if (!geometry) {
|
||||
continue;
|
||||
}
|
||||
const sourcePathByResourceId = new Map<string, SVGPathElement>();
|
||||
const targetPathByResourceId = new Map<string, SVGPathElement>();
|
||||
group
|
||||
.querySelectorAll<SVGPathElement>('.game-resource-dependency-branch')
|
||||
.forEach((path) => {
|
||||
const resourceId = path.dataset.resourceId;
|
||||
if (!resourceId) {
|
||||
return;
|
||||
}
|
||||
(path.dataset.branchSide === 'source'
|
||||
? sourcePathByResourceId
|
||||
: targetPathByResourceId
|
||||
).set(resourceId, path);
|
||||
});
|
||||
geometry.sourceAnchors.forEach(({ resourceId, point }) => {
|
||||
sourcePathByResourceId
|
||||
paths.sourceBranches
|
||||
.get(resourceId)
|
||||
?.setAttribute(
|
||||
'd',
|
||||
taskFlowBranchPath(point, geometry.sourceHub),
|
||||
);
|
||||
});
|
||||
group
|
||||
.querySelector<SVGPathElement>('.game-resource-dependency-trunk')
|
||||
?.setAttribute(
|
||||
'd',
|
||||
connectionPath(geometry.sourceHub, geometry.targetHub),
|
||||
);
|
||||
paths.trunk?.setAttribute(
|
||||
'd',
|
||||
connectionPath(geometry.sourceHub, geometry.targetHub),
|
||||
);
|
||||
geometry.targetAnchors.forEach(({ resourceId, point }) => {
|
||||
targetPathByResourceId
|
||||
paths.targetBranches
|
||||
.get(resourceId)
|
||||
?.setAttribute(
|
||||
'd',
|
||||
@@ -507,7 +559,32 @@ export function ResourceDependencyOverlay({
|
||||
);
|
||||
});
|
||||
}
|
||||
}, [dragPreview, graph, positions, rectByResourceId]);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
updateDragPreview(preview) {
|
||||
const affectedResourceIds = new Set<string>();
|
||||
if (activeDragPreviewRef.current) {
|
||||
affectedResourceIds.add(activeDragPreviewRef.current.resourceId);
|
||||
}
|
||||
affectedResourceIds.add(preview.resourceId);
|
||||
activeDragPreviewRef.current = preview;
|
||||
updateAffectedGeometry(affectedResourceIds, preview);
|
||||
},
|
||||
clearDragPreview() {
|
||||
const active = activeDragPreviewRef.current;
|
||||
activeDragPreviewRef.current = null;
|
||||
if (active) {
|
||||
updateAffectedGeometry(new Set([active.resourceId]), null);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[updateAffectedGeometry],
|
||||
);
|
||||
|
||||
return (
|
||||
<svg
|
||||
@@ -550,4 +627,4 @@ export function ResourceDependencyOverlay({
|
||||
{renderReferenceEdges}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+22
-7
@@ -33,6 +33,7 @@ export type ProjectResourceConnectionIndexDto = {
|
||||
export type ProjectResourceProducerAssignment = {
|
||||
resourceId: string;
|
||||
taskId: string;
|
||||
dependencyDepth: number;
|
||||
};
|
||||
|
||||
export type ProjectResourceGraphReadModel = {
|
||||
@@ -62,6 +63,7 @@ export type ProjectResourceGraph = {
|
||||
taskFlowById: ReadonlyMap<string, ProjectResourceTaskFlow>;
|
||||
connectionIndex: ReadonlyMap<string, ProjectResourceConnectionIndex>;
|
||||
producerTaskIdByResourceId: ReadonlyMap<string, string>;
|
||||
dependencyDepthByResourceId: ReadonlyMap<string, number>;
|
||||
unresolvedReferenceResourceIds: string[];
|
||||
cyclicResourceIds: ReadonlySet<string>;
|
||||
cyclicTaskIds: ReadonlySet<string>;
|
||||
@@ -76,6 +78,7 @@ export type ProjectResourceGraphNeighbors = {
|
||||
|
||||
const emptyStringSet: ReadonlySet<string> = new Set<string>();
|
||||
const emptyStringMap: ReadonlyMap<string, string> = new Map<string, string>();
|
||||
const emptyNumberMap: ReadonlyMap<string, number> = new Map<string, number>();
|
||||
const emptyConnectionMap: ReadonlyMap<string, ProjectResourceConnectionIndex> =
|
||||
new Map<string, ProjectResourceConnectionIndex>();
|
||||
const emptyTaskFlowMap: ReadonlyMap<string, ProjectResourceTaskFlow> = new Map<
|
||||
@@ -102,6 +105,7 @@ export const EMPTY_PROJECT_RESOURCE_GRAPH: ProjectResourceGraph = {
|
||||
taskFlowById: emptyTaskFlowMap,
|
||||
connectionIndex: emptyConnectionMap,
|
||||
producerTaskIdByResourceId: emptyStringMap,
|
||||
dependencyDepthByResourceId: emptyNumberMap,
|
||||
unresolvedReferenceResourceIds: [],
|
||||
cyclicResourceIds: emptyStringSet,
|
||||
cyclicTaskIds: emptyStringSet,
|
||||
@@ -174,13 +178,23 @@ export function normalizeProjectResourceGraph(
|
||||
),
|
||||
});
|
||||
}
|
||||
const producerTaskIdByResourceId = new Map(
|
||||
readModel.producerAssignments.flatMap((assignment) =>
|
||||
resourceIds.has(assignment.resourceId) && assignment.taskId
|
||||
? [[assignment.resourceId, assignment.taskId] as const]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
const producerTaskIdByResourceId = new Map<string, string>();
|
||||
const dependencyDepthByResourceId = new Map<string, number>();
|
||||
for (const assignment of readModel.producerAssignments) {
|
||||
if (!resourceIds.has(assignment.resourceId) || !assignment.taskId) {
|
||||
continue;
|
||||
}
|
||||
producerTaskIdByResourceId.set(assignment.resourceId, assignment.taskId);
|
||||
if (
|
||||
Number.isSafeInteger(assignment.dependencyDepth) &&
|
||||
assignment.dependencyDepth >= 0
|
||||
) {
|
||||
dependencyDepthByResourceId.set(
|
||||
assignment.resourceId,
|
||||
assignment.dependencyDepth,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
resourceIds,
|
||||
@@ -190,6 +204,7 @@ export function normalizeProjectResourceGraph(
|
||||
taskFlowById: new Map(taskFlows.map((flow) => [flow.id, flow])),
|
||||
connectionIndex,
|
||||
producerTaskIdByResourceId,
|
||||
dependencyDepthByResourceId,
|
||||
unresolvedReferenceResourceIds: uniqueSorted(
|
||||
readModel.unresolvedReferenceResourceIds,
|
||||
),
|
||||
|
||||
+103
-20
@@ -81,16 +81,58 @@ function layoutMatchesScope(
|
||||
return layout.projectId === scope.projectId && layout.mode === scope.mode;
|
||||
}
|
||||
|
||||
function positionsEqual(
|
||||
left: ProjectResourceCanvasLayout['positions'],
|
||||
right: ProjectResourceCanvasLayout['positions'],
|
||||
) {
|
||||
return (
|
||||
left.length === right.length &&
|
||||
left.every((position, index) => {
|
||||
const other = right[index];
|
||||
return (
|
||||
other?.resourceId === position.resourceId &&
|
||||
other.section === position.section &&
|
||||
other.x === position.x &&
|
||||
other.y === position.y &&
|
||||
other.manuallyPlaced === position.manuallyPlaced
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function reconcileLayout(
|
||||
source: ProjectResourceCanvasLayout,
|
||||
resources: ResourceCanvasItem[],
|
||||
rederiveAutomaticPositions: boolean,
|
||||
) {
|
||||
if (!rederiveAutomaticPositions) {
|
||||
return reconcileResourceCanvasLayout(source, resources);
|
||||
}
|
||||
const manualSource = {
|
||||
...source,
|
||||
positions: source.positions.filter((position) => position.manuallyPlaced),
|
||||
};
|
||||
const reconciled = reconcileResourceCanvasLayout(manualSource, resources);
|
||||
return {
|
||||
layout: reconciled.layout,
|
||||
changed: !positionsEqual(source.positions, reconciled.layout.positions),
|
||||
};
|
||||
}
|
||||
|
||||
export function useProjectResourceCanvasLayout({
|
||||
projectPath,
|
||||
projectId,
|
||||
mode,
|
||||
resources,
|
||||
initializationReady = true,
|
||||
rederiveAutomaticPositions = false,
|
||||
}: {
|
||||
projectPath: string;
|
||||
projectId: string;
|
||||
mode: ProjectResourceCanvasLayoutMode;
|
||||
resources: ResourceCanvasItem[];
|
||||
initializationReady?: boolean;
|
||||
rederiveAutomaticPositions?: boolean;
|
||||
}) {
|
||||
const scopeKey = createScopeKey(projectPath, projectId, mode);
|
||||
const resourceSignature = useMemo(
|
||||
@@ -98,13 +140,18 @@ export function useProjectResourceCanvasLayout({
|
||||
[resources],
|
||||
);
|
||||
const fallback = useMemo(
|
||||
() =>
|
||||
reconcileResourceCanvasLayout(
|
||||
createEmptyResourceCanvasLayout(projectId, mode),
|
||||
resources,
|
||||
).layout,
|
||||
[mode, projectId, resources],
|
||||
);
|
||||
() => {
|
||||
const empty = createEmptyResourceCanvasLayout(projectId, mode);
|
||||
return initializationReady
|
||||
? reconcileLayout(empty, resources, rederiveAutomaticPositions).layout
|
||||
: empty;
|
||||
}, [
|
||||
initializationReady,
|
||||
mode,
|
||||
projectId,
|
||||
rederiveAutomaticPositions,
|
||||
resources,
|
||||
]);
|
||||
const [layout, setLayout] = useState<ProjectResourceCanvasLayout>(fallback);
|
||||
const [notice, setNotice] = useState<LayoutNotice>('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -151,9 +198,10 @@ export function useProjectResourceCanvasLayout({
|
||||
if (scope.epoch !== scopeEpoch) {
|
||||
return;
|
||||
}
|
||||
let next = reconcileResourceCanvasLayout(
|
||||
let next = reconcileLayout(
|
||||
persistedLayoutRef.current,
|
||||
resourcesRef.current,
|
||||
rederiveAutomaticPositions,
|
||||
).layout;
|
||||
for (const intent of writeQueueRef.current) {
|
||||
if (intent.scopeEpoch === scopeEpoch && intent.kind === 'manual') {
|
||||
@@ -168,7 +216,7 @@ export function useProjectResourceCanvasLayout({
|
||||
}
|
||||
applyLayout(next);
|
||||
},
|
||||
[applyLayout],
|
||||
[applyLayout, rederiveAutomaticPositions],
|
||||
);
|
||||
|
||||
enqueueResourceSyncRef.current = (scopeEpoch, conflictRetries = 0) => {
|
||||
@@ -229,9 +277,10 @@ export function useProjectResourceCanvasLayout({
|
||||
return;
|
||||
}
|
||||
|
||||
const reconciled = reconcileResourceCanvasLayout(
|
||||
const reconciled = reconcileLayout(
|
||||
persistedLayoutRef.current,
|
||||
resourcesRef.current,
|
||||
rederiveAutomaticPositions,
|
||||
);
|
||||
if (intent.kind === 'resources' && !reconciled.changed) {
|
||||
removeWriteIntent(intent);
|
||||
@@ -330,7 +379,11 @@ export function useProjectResourceCanvasLayout({
|
||||
setNotice('布局已保存');
|
||||
}
|
||||
if (
|
||||
reconcileResourceCanvasLayout(result.layout, resourcesRef.current)
|
||||
reconcileLayout(
|
||||
result.layout,
|
||||
resourcesRef.current,
|
||||
rederiveAutomaticPositions,
|
||||
)
|
||||
.changed
|
||||
) {
|
||||
enqueueResourceSyncRef.current(currentScope.epoch);
|
||||
@@ -346,9 +399,10 @@ export function useProjectResourceCanvasLayout({
|
||||
queued.scopeEpoch !== currentScope.epoch ||
|
||||
queued.kind !== 'manual',
|
||||
);
|
||||
const needsResourceSync = reconcileResourceCanvasLayout(
|
||||
const needsResourceSync = reconcileLayout(
|
||||
result.layout,
|
||||
resourcesRef.current,
|
||||
rederiveAutomaticPositions,
|
||||
).changed;
|
||||
const nextRetry =
|
||||
intent.kind === 'resources' ? intent.conflictRetries + 1 : 0;
|
||||
@@ -435,9 +489,18 @@ export function useProjectResourceCanvasLayout({
|
||||
writeQueueRef.current = [];
|
||||
activeWriteIntentRef.current = null;
|
||||
redragRequiredScopeEpochRef.current = null;
|
||||
const initialFallback = reconcileResourceCanvasLayout(
|
||||
createEmptyResourceCanvasLayout(projectId, mode),
|
||||
const emptyLayout = createEmptyResourceCanvasLayout(projectId, mode);
|
||||
if (!initializationReady) {
|
||||
persistedLayoutRef.current = emptyLayout;
|
||||
applyLayout(emptyLayout);
|
||||
setNotice('');
|
||||
setSaving(false);
|
||||
return undefined;
|
||||
}
|
||||
const initialFallback = reconcileLayout(
|
||||
emptyLayout,
|
||||
resourcesRef.current,
|
||||
rederiveAutomaticPositions,
|
||||
).layout;
|
||||
persistedLayoutRef.current = initialFallback;
|
||||
applyLayout(initialFallback);
|
||||
@@ -469,7 +532,11 @@ export function useProjectResourceCanvasLayout({
|
||||
persistedLayoutRef.current = loaded;
|
||||
initializedScopeEpochRef.current = epoch;
|
||||
if (
|
||||
reconcileResourceCanvasLayout(loaded, resourcesRef.current).changed
|
||||
reconcileLayout(
|
||||
loaded,
|
||||
resourcesRef.current,
|
||||
rederiveAutomaticPositions,
|
||||
).changed
|
||||
) {
|
||||
enqueueResourceSyncRef.current(epoch);
|
||||
}
|
||||
@@ -491,10 +558,12 @@ export function useProjectResourceCanvasLayout({
|
||||
};
|
||||
}, [
|
||||
applyLayout,
|
||||
initializationReady,
|
||||
mode,
|
||||
projectId,
|
||||
projectPath,
|
||||
rebuildOptimisticLayout,
|
||||
rederiveAutomaticPositions,
|
||||
scopeKey,
|
||||
]);
|
||||
|
||||
@@ -502,25 +571,34 @@ export function useProjectResourceCanvasLayout({
|
||||
const scope = scopeRef.current;
|
||||
if (
|
||||
scope.key !== scopeKey ||
|
||||
!initializationReady ||
|
||||
initializedScopeEpochRef.current !== scope.epoch
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const reconciledCurrent = reconcileResourceCanvasLayout(
|
||||
const reconciledCurrent = reconcileLayout(
|
||||
layoutRef.current,
|
||||
resourcesRef.current,
|
||||
rederiveAutomaticPositions,
|
||||
);
|
||||
applyLayout(reconciledCurrent.layout);
|
||||
if (
|
||||
window.__TAURI__?.core?.invoke &&
|
||||
reconcileResourceCanvasLayout(
|
||||
reconcileLayout(
|
||||
persistedLayoutRef.current,
|
||||
resourcesRef.current,
|
||||
rederiveAutomaticPositions,
|
||||
).changed
|
||||
) {
|
||||
enqueueResourceSyncRef.current(scope.epoch);
|
||||
}
|
||||
}, [applyLayout, resourceSignature, scopeKey]);
|
||||
}, [
|
||||
applyLayout,
|
||||
initializationReady,
|
||||
rederiveAutomaticPositions,
|
||||
resourceSignature,
|
||||
scopeKey,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!notice) {
|
||||
@@ -544,7 +622,11 @@ export function useProjectResourceCanvasLayout({
|
||||
y: number,
|
||||
) => {
|
||||
const scope = scopeRef.current;
|
||||
if (scope.key !== scopeKey) {
|
||||
if (
|
||||
scope.key !== scopeKey ||
|
||||
!initializationReady ||
|
||||
initializedScopeEpochRef.current !== scope.epoch
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const queuedIntent = writeQueueRef.current.find(
|
||||
@@ -580,10 +662,11 @@ export function useProjectResourceCanvasLayout({
|
||||
setSaving(true);
|
||||
pumpWritesRef.current();
|
||||
},
|
||||
[applyLayout, scopeKey],
|
||||
[applyLayout, initializationReady, scopeKey],
|
||||
);
|
||||
|
||||
const scopeMatches =
|
||||
initializationReady &&
|
||||
scopeRef.current.key === scopeKey &&
|
||||
layout.projectId === projectId &&
|
||||
layout.mode === mode;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
type ProjectResourceGraph,
|
||||
type ProjectResourceGraphReadModel,
|
||||
} from '../src/view/project-development/resourceDependencyGraphModel';
|
||||
import { ResourceDependencyOverlay } from '../src/view/project-development/ResourceDependencyOverlay';
|
||||
import {
|
||||
ResourceDependencyOverlay,
|
||||
type ResourceDependencyOverlayHandle,
|
||||
} from '../src/view/project-development/ResourceDependencyOverlay';
|
||||
|
||||
function position(
|
||||
resourceId: string,
|
||||
@@ -103,22 +106,22 @@ function OverlayHarness({
|
||||
positions,
|
||||
visibleResourceIds,
|
||||
selectedResourceId = null,
|
||||
dragPreview = null,
|
||||
overlayRef,
|
||||
}: {
|
||||
graph: ProjectResourceGraph;
|
||||
positions: ProjectResourceCanvasPosition[];
|
||||
visibleResourceIds: ReadonlySet<string>;
|
||||
selectedResourceId?: string | null;
|
||||
dragPreview?: { resourceId: string; x: number; y: number } | null;
|
||||
overlayRef?: React.Ref<ResourceDependencyOverlayHandle>;
|
||||
}) {
|
||||
return React.createElement(
|
||||
'div',
|
||||
null,
|
||||
React.createElement('div', { 'data-resource-section-plane': 'art' }),
|
||||
React.createElement(ResourceDependencyOverlay, {
|
||||
ref: overlayRef,
|
||||
graph,
|
||||
positions,
|
||||
dragPreview,
|
||||
visibleResourceIds,
|
||||
selectedResourceId,
|
||||
}),
|
||||
@@ -130,14 +133,14 @@ function overlayView(
|
||||
positions: ProjectResourceCanvasPosition[],
|
||||
visibleResourceIds: ReadonlySet<string>,
|
||||
selectedResourceId: string | null = null,
|
||||
dragPreview: { resourceId: string; x: number; y: number } | null = null,
|
||||
overlayRef?: React.Ref<ResourceDependencyOverlayHandle>,
|
||||
) {
|
||||
return React.createElement(OverlayHarness, {
|
||||
graph,
|
||||
positions,
|
||||
visibleResourceIds,
|
||||
selectedResourceId,
|
||||
dragPreview,
|
||||
overlayRef,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -181,8 +184,15 @@ describe('ResourceDependencyOverlay', () => {
|
||||
position('source:one', 0, 0),
|
||||
position('target:one', 240, 0),
|
||||
];
|
||||
const overlayRef = React.createRef<ResourceDependencyOverlayHandle>();
|
||||
const view = render(
|
||||
overlayView(graph, positions, new Set(['source:one', 'target:one'])),
|
||||
overlayView(
|
||||
graph,
|
||||
positions,
|
||||
new Set(['source:one', 'target:one']),
|
||||
null,
|
||||
overlayRef,
|
||||
),
|
||||
);
|
||||
const overlay = await screen.findByTestId('resource-dependency-overlay');
|
||||
const firstPath = await waitFor(() => {
|
||||
@@ -193,14 +203,12 @@ describe('ResourceDependencyOverlay', () => {
|
||||
return path?.getAttribute('d');
|
||||
});
|
||||
|
||||
view.rerender(
|
||||
overlayView(
|
||||
graph,
|
||||
positions,
|
||||
new Set(['source:one', 'target:one']),
|
||||
null,
|
||||
{ resourceId: 'source:one', x: 80, y: 40 },
|
||||
),
|
||||
act(() =>
|
||||
overlayRef.current?.updateDragPreview({
|
||||
resourceId: 'source:one',
|
||||
x: 80,
|
||||
y: 40,
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
@@ -242,7 +250,8 @@ describe('ResourceDependencyOverlay', () => {
|
||||
position('target:one', 240, 0),
|
||||
];
|
||||
const visible = new Set(['source:one', 'target:one']);
|
||||
const view = render(overlayView(graph, positions, visible));
|
||||
const overlayRef = React.createRef<ResourceDependencyOverlayHandle>();
|
||||
render(overlayView(graph, positions, visible, null, overlayRef));
|
||||
const overlay = await screen.findByTestId('resource-dependency-overlay');
|
||||
const selector = '[data-edge-kind="asset-reference"]';
|
||||
await waitFor(() =>
|
||||
@@ -251,8 +260,8 @@ describe('ResourceDependencyOverlay', () => {
|
||||
),
|
||||
);
|
||||
|
||||
view.rerender(
|
||||
overlayView(graph, positions, visible, null, {
|
||||
act(() =>
|
||||
overlayRef.current?.updateDragPreview({
|
||||
resourceId: 'source:one',
|
||||
x: 80,
|
||||
y: 40,
|
||||
@@ -375,7 +384,8 @@ describe('ResourceDependencyOverlay', () => {
|
||||
position('resource:2049', 440, 0),
|
||||
];
|
||||
const visible = new Set(positions.map(({ resourceId }) => resourceId));
|
||||
const view = render(overlayView(graph, positions, visible));
|
||||
const overlayRef = React.createRef<ResourceDependencyOverlayHandle>();
|
||||
render(overlayView(graph, positions, visible, null, overlayRef));
|
||||
const overlay = await screen.findByTestId('resource-dependency-overlay');
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
@@ -387,8 +397,8 @@ describe('ResourceDependencyOverlay', () => {
|
||||
'setAttribute',
|
||||
);
|
||||
try {
|
||||
view.rerender(
|
||||
overlayView(graph, positions, visible, null, {
|
||||
act(() =>
|
||||
overlayRef.current?.updateDragPreview({
|
||||
resourceId: 'resource:2048',
|
||||
x: 260,
|
||||
y: 32,
|
||||
@@ -419,23 +429,24 @@ describe('ResourceDependencyOverlay', () => {
|
||||
const positions = Array.from(graph.resourceIds).map(
|
||||
(resourceId, index) => position(resourceId, index * 200, 0),
|
||||
);
|
||||
const overlayRef = React.createRef<ResourceDependencyOverlayHandle>();
|
||||
const view = render(
|
||||
overlayView(
|
||||
graph,
|
||||
positions,
|
||||
new Set(graph.resourceIds),
|
||||
null,
|
||||
overlayRef,
|
||||
),
|
||||
);
|
||||
|
||||
expect(observe).toHaveBeenCalledTimes(2);
|
||||
view.rerender(
|
||||
overlayView(
|
||||
graph,
|
||||
positions,
|
||||
new Set(graph.resourceIds),
|
||||
null,
|
||||
{ resourceId: 'source:one', x: 32, y: 24 },
|
||||
),
|
||||
act(() =>
|
||||
overlayRef.current?.updateDragPreview({
|
||||
resourceId: 'source:one',
|
||||
x: 32,
|
||||
y: 24,
|
||||
}),
|
||||
);
|
||||
expect(observe).toHaveBeenCalledTimes(2);
|
||||
view.unmount();
|
||||
|
||||
@@ -801,7 +801,9 @@ export function registerHomeProjectCreationTests() {
|
||||
expect(screen.getByLabelText('项目总控消息').textContent).toContain(
|
||||
'第一行\n第二行\n第三行',
|
||||
);
|
||||
expect(screen.getByText('assets/uploads/reference.png')).not.toBeNull();
|
||||
expect(
|
||||
await screen.findByText('assets/uploads/reference.png'),
|
||||
).not.toBeNull();
|
||||
expect(invoke).toHaveBeenCalledWith('init_local_game_project', {
|
||||
projectPath: '/tmp/home-created-game',
|
||||
projectId: 'local-project-draft',
|
||||
|
||||
@@ -56,6 +56,40 @@ import {
|
||||
within,
|
||||
} from './harness';
|
||||
|
||||
function resourceGraphForInputs(args?: Record<string, unknown>) {
|
||||
const resources =
|
||||
(args?.resources as
|
||||
| Array<{ resourceId: string; producerTaskId: string | null }>
|
||||
| undefined) ?? [];
|
||||
return {
|
||||
resourceIds: resources.map(({ resourceId }) => resourceId),
|
||||
referenceEdges: [],
|
||||
taskFlows: [],
|
||||
connectionIndex: resources.map(({ resourceId }) => ({
|
||||
resourceId,
|
||||
upstreamReferenceResourceIds: [],
|
||||
downstreamReferenceResourceIds: [],
|
||||
referenceEdgeIds: [],
|
||||
taskFlowIds: [],
|
||||
})),
|
||||
producerAssignments: resources.flatMap((resource) =>
|
||||
resource.producerTaskId
|
||||
? [
|
||||
{
|
||||
resourceId: resource.resourceId,
|
||||
taskId: resource.producerTaskId,
|
||||
dependencyDepth: 0,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
unresolvedReferenceResourceIds: [],
|
||||
cyclicResourceIds: [],
|
||||
cyclicTaskIds: [],
|
||||
producerMappingTruncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
function gameChatRuntimeEvent({
|
||||
agentId = 'project-supervisor',
|
||||
taskId = agentId,
|
||||
@@ -869,10 +903,12 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
{
|
||||
resourceId: 'asset:dependency-spec',
|
||||
taskId: 'art-director',
|
||||
dependencyDepth: 0,
|
||||
},
|
||||
{
|
||||
resourceId: 'asset:dependency-ui',
|
||||
taskId: 'design-foundation',
|
||||
dependencyDepth: 1,
|
||||
},
|
||||
],
|
||||
unresolvedReferenceResourceIds: [],
|
||||
@@ -1025,6 +1061,204 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
expect(nextOverlay.querySelector('[data-edge-kind]')).toBeNull();
|
||||
});
|
||||
|
||||
it('waits for the scoped resource graph before initializing dependency layout', async () => {
|
||||
const projectId = 'workbench-delayed-resource-graph';
|
||||
const projectPath = '/tmp/workbench-delayed-resource-graph';
|
||||
const manifest = createGameCreationAppManifest(projectId, '延迟依赖图测试');
|
||||
const agentResults = [
|
||||
{
|
||||
agentId: 'design-foundation',
|
||||
runId: 'delayed-graph-run',
|
||||
label: '玩法策划 Agent',
|
||||
title: '延迟依赖图回执',
|
||||
content: '图就绪后再初始化布局',
|
||||
updatedAt: 1,
|
||||
},
|
||||
];
|
||||
let resolveGraph: (() => void) | null = null;
|
||||
let layoutReads = 0;
|
||||
let layoutRevision = 0;
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'read_local_project_resource_graph') {
|
||||
return await new Promise((resolve) => {
|
||||
resolveGraph = () => resolve(resourceGraphForInputs(args));
|
||||
});
|
||||
}
|
||||
if (command === 'read_local_project_resource_canvas_layout') {
|
||||
layoutReads += 1;
|
||||
return {
|
||||
schemaVersion: 'game-creator-resource-layout.v1',
|
||||
projectId,
|
||||
mode: 'dependency',
|
||||
revision: layoutRevision,
|
||||
positions: [],
|
||||
updatedAt: 0,
|
||||
};
|
||||
}
|
||||
if (command === 'update_local_project_resource_canvas_layout') {
|
||||
layoutRevision += 1;
|
||||
return {
|
||||
status: 'updated',
|
||||
layout: {
|
||||
schemaVersion: 'game-creator-resource-layout.v1',
|
||||
projectId,
|
||||
mode: 'dependency',
|
||||
revision: layoutRevision,
|
||||
positions: args?.positions,
|
||||
updatedAt: layoutRevision,
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
|
||||
render(
|
||||
React.createElement(ProjectDevelopmentView, {
|
||||
projectName: '延迟依赖图测试',
|
||||
projectPath,
|
||||
manifest,
|
||||
attachments: [],
|
||||
agentResults,
|
||||
supervisor: React.createElement('div', null, '项目总控'),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(resolveGraph).not.toBeNull());
|
||||
expect(layoutReads).toBe(0);
|
||||
expect(screen.queryByText('延迟依赖图回执')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
resolveGraph?.();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(await screen.findByText('延迟依赖图回执')).not.toBeNull();
|
||||
expect(layoutReads).toBe(1);
|
||||
});
|
||||
|
||||
it(
|
||||
'keeps 4096 real resource cards out of React commits during preview frames',
|
||||
async () => {
|
||||
const projectId = 'workbench-resource-drag-performance';
|
||||
const projectPath = '/tmp/workbench-resource-drag-performance';
|
||||
const manifest = createGameCreationAppManifest(
|
||||
projectId,
|
||||
'资源拖动性能测试',
|
||||
);
|
||||
const agentResults = Array.from({ length: 4096 }, (_, index) => ({
|
||||
agentId: 'design-foundation',
|
||||
runId: `performance-run-${index}`,
|
||||
label: `性能 Agent ${index}`,
|
||||
title: `性能资源 ${index}`,
|
||||
content: `性能正文 ${index}`,
|
||||
updatedAt: index,
|
||||
}));
|
||||
let layoutRevision = 0;
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'read_local_project_resource_graph') {
|
||||
return resourceGraphForInputs(args);
|
||||
}
|
||||
if (command === 'read_local_project_resource_canvas_layout') {
|
||||
return {
|
||||
schemaVersion: 'game-creator-resource-layout.v1',
|
||||
projectId,
|
||||
mode: 'dependency',
|
||||
revision: layoutRevision,
|
||||
positions: [],
|
||||
updatedAt: 0,
|
||||
};
|
||||
}
|
||||
if (command === 'update_local_project_resource_canvas_layout') {
|
||||
layoutRevision += 1;
|
||||
return {
|
||||
status: 'updated',
|
||||
layout: {
|
||||
schemaVersion: 'game-creator-resource-layout.v1',
|
||||
projectId,
|
||||
mode: 'dependency',
|
||||
revision: layoutRevision,
|
||||
positions: args?.positions,
|
||||
updatedAt: layoutRevision,
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
const commits: string[] = [];
|
||||
|
||||
render(
|
||||
React.createElement(
|
||||
React.Profiler,
|
||||
{
|
||||
id: 'resource-workbench',
|
||||
onRender: () => commits.push('commit'),
|
||||
},
|
||||
React.createElement(ProjectDevelopmentView, {
|
||||
projectName: '资源拖动性能测试',
|
||||
projectPath,
|
||||
manifest,
|
||||
attachments: [],
|
||||
agentResults,
|
||||
supervisor: React.createElement('div', null, '项目总控'),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const target = (await screen.findByText('性能资源 2048')).closest(
|
||||
'button',
|
||||
);
|
||||
expect(target).not.toBeNull();
|
||||
expect(document.querySelectorAll('.game-resource-card')).toHaveLength(
|
||||
4096,
|
||||
);
|
||||
commits.length = 0;
|
||||
let previewFrame: FrameRequestCallback | null = null;
|
||||
const requestAnimationFrame = vi
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation((callback) => {
|
||||
previewFrame = callback;
|
||||
return 77;
|
||||
});
|
||||
const cancelAnimationFrame = vi
|
||||
.spyOn(window, 'cancelAnimationFrame')
|
||||
.mockImplementation(() => undefined);
|
||||
try {
|
||||
fireEvent.pointerDown(target!, {
|
||||
pointerId: 88,
|
||||
button: 0,
|
||||
clientX: 20,
|
||||
clientY: 30,
|
||||
});
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
fireEvent.pointerMove(target!, {
|
||||
pointerId: 88,
|
||||
clientX: 40 + index,
|
||||
clientY: 60 + index,
|
||||
});
|
||||
}
|
||||
act(() => previewFrame?.(16));
|
||||
|
||||
expect(commits).toHaveLength(1);
|
||||
expect(target?.getAttribute('style')).toContain('--resource-x: 119px');
|
||||
expect(requestAnimationFrame).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
fireEvent.pointerCancel(target!, {
|
||||
pointerId: 88,
|
||||
clientX: 139,
|
||||
clientY: 159,
|
||||
});
|
||||
requestAnimationFrame.mockRestore();
|
||||
cancelAnimationFrame.mockRestore();
|
||||
}
|
||||
},
|
||||
15_000,
|
||||
);
|
||||
|
||||
it('persists a resource position with CAS and restores it after remount', async () => {
|
||||
const projectId = 'workbench-layout-persistence';
|
||||
const projectPath = '/tmp/workbench-layout-persistence';
|
||||
@@ -1058,6 +1292,9 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
};
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'read_local_project_resource_graph') {
|
||||
return resourceGraphForInputs(args);
|
||||
}
|
||||
if (command === 'read_local_project_resource_canvas_layout') {
|
||||
return structuredClone(persistedLayout);
|
||||
}
|
||||
@@ -1097,7 +1334,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
}
|
||||
|
||||
renderWorkbench();
|
||||
const card = screen.getByText('布局持久化回执').closest('button');
|
||||
const card = (await screen.findByText('布局持久化回执')).closest('button');
|
||||
expect(card).not.toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(card?.getAttribute('style')).toContain('--resource-x: 12px');
|
||||
@@ -1142,7 +1379,9 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
|
||||
cleanup();
|
||||
renderWorkbench();
|
||||
const restoredCard = screen.getByText('布局持久化回执').closest('button');
|
||||
const restoredCard = (
|
||||
await screen.findByText('布局持久化回执')
|
||||
).closest('button');
|
||||
await waitFor(() => {
|
||||
expect(restoredCard?.getAttribute('style')).toContain(
|
||||
'--resource-x: 92px',
|
||||
@@ -1174,7 +1413,11 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
],
|
||||
updatedAt: 400,
|
||||
};
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'read_local_project_resource_graph') {
|
||||
return resourceGraphForInputs(args);
|
||||
}
|
||||
if (command === 'read_local_project_resource_canvas_layout') {
|
||||
return {
|
||||
...structuredClone(latestLayout),
|
||||
@@ -1192,7 +1435,8 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
return { status: 'conflict', layout: structuredClone(latestLayout) };
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
});
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
|
||||
render(
|
||||
@@ -1214,7 +1458,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
supervisor: React.createElement('div', null, '项目总控'),
|
||||
}),
|
||||
);
|
||||
const card = screen.getByText('布局冲突回执').closest('button');
|
||||
const card = (await screen.findByText('布局冲突回执')).closest('button');
|
||||
await waitFor(() => {
|
||||
expect(card?.getAttribute('style')).toContain('--resource-x: 20px');
|
||||
});
|
||||
@@ -1280,7 +1524,11 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
}) => void)
|
||||
| null = null;
|
||||
let updateCalls = 0;
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'read_local_project_resource_graph') {
|
||||
return resourceGraphForInputs(args);
|
||||
}
|
||||
if (command === 'read_local_project_resource_canvas_layout') {
|
||||
return {
|
||||
...structuredClone(latestLayout),
|
||||
@@ -1295,7 +1543,8 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
});
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
|
||||
render(
|
||||
@@ -1317,7 +1566,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
supervisor: React.createElement('div', null, '项目总控'),
|
||||
}),
|
||||
);
|
||||
const card = screen.getByText('排队冲突回执').closest('button');
|
||||
const card = (await screen.findByText('排队冲突回执')).closest('button');
|
||||
await waitFor(() => {
|
||||
expect(card?.getAttribute('style')).toContain('--resource-x: 20px');
|
||||
});
|
||||
@@ -1391,6 +1640,9 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
}> = [];
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'read_local_project_resource_graph') {
|
||||
return resourceGraphForInputs(args);
|
||||
}
|
||||
const mode = args?.mode as 'dependency' | 'type';
|
||||
if (command === 'read_local_project_resource_canvas_layout') {
|
||||
return {
|
||||
@@ -1464,7 +1716,11 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
it('keeps newly reconciled resources visible when their automatic layout save fails', async () => {
|
||||
const projectId = 'workbench-layout-save-failure';
|
||||
const manifest = createGameCreationAppManifest(projectId, '布局失败测试');
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'read_local_project_resource_graph') {
|
||||
return resourceGraphForInputs(args);
|
||||
}
|
||||
if (command === 'read_local_project_resource_canvas_layout') {
|
||||
return {
|
||||
schemaVersion: 'game-creator-resource-layout.v1',
|
||||
@@ -1479,7 +1735,8 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
throw new Error('disk full');
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
});
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
|
||||
render(
|
||||
|
||||
@@ -132,9 +132,21 @@ describe('resource dependency graph model', () => {
|
||||
readModel({
|
||||
resourceIds: ['asset:spec', 'asset:ui'],
|
||||
producerAssignments: [
|
||||
{ resourceId: 'asset:spec', taskId: 'art-director' },
|
||||
{ resourceId: 'asset:ui', taskId: 'design-foundation' },
|
||||
{ resourceId: 'asset:deleted', taskId: 'task-1' },
|
||||
{
|
||||
resourceId: 'asset:spec',
|
||||
taskId: 'art-director',
|
||||
dependencyDepth: 0,
|
||||
},
|
||||
{
|
||||
resourceId: 'asset:ui',
|
||||
taskId: 'design-foundation',
|
||||
dependencyDepth: 1,
|
||||
},
|
||||
{
|
||||
resourceId: 'asset:deleted',
|
||||
taskId: 'task-1',
|
||||
dependencyDepth: 99,
|
||||
},
|
||||
],
|
||||
producerMappingTruncated: true,
|
||||
}),
|
||||
@@ -146,6 +158,12 @@ describe('resource dependency graph model', () => {
|
||||
['asset:ui', 'design-foundation'],
|
||||
]),
|
||||
);
|
||||
expect(graph.dependencyDepthByResourceId).toEqual(
|
||||
new Map([
|
||||
['asset:spec', 0],
|
||||
['asset:ui', 1],
|
||||
]),
|
||||
);
|
||||
expect(graph.producerMappingTruncated).toBe(true);
|
||||
expect(projectResourceGraphNeighbors(graph, 'asset:deleted')).toEqual({
|
||||
upstreamResourceIds: new Set(),
|
||||
|
||||
@@ -58,6 +58,17 @@ function position(
|
||||
};
|
||||
}
|
||||
|
||||
function automaticPosition(
|
||||
resourceId: string,
|
||||
x: number,
|
||||
y: number,
|
||||
): ProjectResourceCanvasPosition {
|
||||
return {
|
||||
...position(resourceId, x, y),
|
||||
manuallyPlaced: false,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.__TAURI__ = undefined;
|
||||
@@ -72,6 +83,117 @@ describe('useProjectResourceCanvasLayout', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('waits for dependency graph initialization before reading or writing layout', async () => {
|
||||
const updates: ProjectResourceCanvasPosition[][] = [];
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'read_local_project_resource_canvas_layout') {
|
||||
return persistedLayout('dependency', 0, []);
|
||||
}
|
||||
if (command === 'update_local_project_resource_canvas_layout') {
|
||||
const positions = structuredClone(
|
||||
args?.positions as ProjectResourceCanvasPosition[],
|
||||
);
|
||||
updates.push(positions);
|
||||
return {
|
||||
status: 'updated',
|
||||
layout: persistedLayout('dependency', 1, positions),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
const shallowResource = resource('resource-a');
|
||||
const deepResource = { ...shallowResource, dependencyDepth: 2 };
|
||||
const { result, rerender } = renderHook(
|
||||
({ initializationReady, resources }) =>
|
||||
useProjectResourceCanvasLayout({
|
||||
projectPath,
|
||||
projectId,
|
||||
mode: 'dependency',
|
||||
resources,
|
||||
initializationReady,
|
||||
rederiveAutomaticPositions: true,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
initializationReady: false,
|
||||
resources: [shallowResource],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
expect(result.current.layout.positions).toEqual([]);
|
||||
|
||||
rerender({ initializationReady: true, resources: [deepResource] });
|
||||
await waitFor(() => expect(updates).toHaveLength(1));
|
||||
expect(result.current.layout.positions[0]).toMatchObject({
|
||||
resourceId: 'resource-a',
|
||||
x: 392,
|
||||
manuallyPlaced: false,
|
||||
});
|
||||
expect(invoke.mock.calls[0]?.[0]).toBe(
|
||||
'read_local_project_resource_canvas_layout',
|
||||
);
|
||||
});
|
||||
|
||||
it('rederives automatic dependency positions while preserving manual positions', async () => {
|
||||
const resourceA = { ...resource('resource-a'), dependencyDepth: 2 };
|
||||
const resourceB = resource('resource-b');
|
||||
const updates: ProjectResourceCanvasPosition[][] = [];
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'read_local_project_resource_canvas_layout') {
|
||||
return persistedLayout('dependency', 4, [
|
||||
automaticPosition('resource-a', 0, 0),
|
||||
position('resource-b', 600, 40),
|
||||
]);
|
||||
}
|
||||
if (command === 'update_local_project_resource_canvas_layout') {
|
||||
const positions = structuredClone(
|
||||
args?.positions as ProjectResourceCanvasPosition[],
|
||||
);
|
||||
updates.push(positions);
|
||||
return {
|
||||
status: 'updated',
|
||||
layout: persistedLayout('dependency', 5, positions),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useProjectResourceCanvasLayout({
|
||||
projectPath,
|
||||
projectId,
|
||||
mode: 'dependency',
|
||||
resources: [resourceA, resourceB],
|
||||
rederiveAutomaticPositions: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(updates).toHaveLength(1));
|
||||
expect(result.current.layout.positions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
resourceId: 'resource-a',
|
||||
x: 392,
|
||||
manuallyPlaced: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
resourceId: 'resource-b',
|
||||
x: 600,
|
||||
y: 40,
|
||||
manuallyPlaced: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an unsafe revision from the initial IPC read without writing', async () => {
|
||||
const resourceA = resource('resource-a');
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AI 游戏创作项目开发工作台 PRD
|
||||
|
||||
更新时间:`2026-07-31`
|
||||
更新时间:`2026-08-03`
|
||||
|
||||
## 1. 产品定位
|
||||
|
||||
@@ -153,7 +153,7 @@ P0 中 `approvalMode` 只能有效写入 `strict`;其它值只能作为不可
|
||||
|
||||
### 5.2 资源画布布局(P1)
|
||||
|
||||
实现状态(2026-07-31):本节布局合同已在独立客户端落地,dependency / type 双模式通过项目内 CAS sidecar 独立持久化;dependency 模式由 Tauri Rust 只读构建关系拓扑、前端 SVG 派生几何,图结构和线段均不写入布局 sidecar。资源替换、缩放 / 平移等其余 P1 能力仍按本文非目标保持未实现。
|
||||
实现状态(2026-08-03):本节布局合同已在独立客户端落地,dependency / type 双模式通过项目内 CAS sidecar 独立持久化;dependency 模式由 Tauri Rust 只读构建关系拓扑与确定性依赖深度、前端 SVG 派生几何,图结构和线段均不写入布局 sidecar。依赖图加载完成前设布局初始化屏障,避免以临时 `dependencyDepth=0` 生成并持久化错误坐标。资源替换、缩放 / 平移等其余 P1 能力仍按本文非目标保持未实现。
|
||||
|
||||
```ts
|
||||
type ProjectResourceCanvasLayout = {
|
||||
@@ -237,10 +237,10 @@ type UpdateProjectResourceCanvasLayoutResult =
|
||||
- 资源卡改用 Pointer Events 驱动二维拖动;超过统一移动阈值后才进入拖动态,普通点击仍打开唯一资源详情浮层,`pointercancel` 恢复拖动前位置。
|
||||
- 资源只能在原 `section` 内拖动。不同 section 之间既不能通过指针拖入,也不能通过持久 payload 改变当前资源的前端分类事实。
|
||||
- dependency 默认布局按 `dependencyDepth` 形成横向层级,同层资源纵向寻找第一个不重叠位置;type 默认布局固定按“资源子类型 -> 媒体类型 -> 名称 -> 资源 ID”稳定排序,在分区内从左到右、从上到下寻找第一个空位。布局模型的 `subtype` 必填:manifest 资产使用 `asset.kind`,任务产物、导入附件与 Agent 文本成果分别使用稳定的 `task-artifact`、`attachment`、`agent-result`,不得以缺失值或显示文案兜底;资源协调签名必须包含 subtype。卡片尺寸、间距和拖动阈值必须由单一前端布局模型常量维护。
|
||||
- 资源集合变化时保留全部仍存在的坐标,只为新 ID 计算默认位置,并删除已确认失效的旧 ID;无论 `manuallyPlaced` 为何,已经写入的现存坐标都不得因重新排序、模式切换或新增资源被自动改写。
|
||||
- type 模式资源集合变化时保留全部仍存在的坐标,只为新 ID 计算默认位置,并删除已确认失效的旧 ID。dependency 模式只永久保留 `manuallyPlaced=true` 的用户坐标;`manuallyPlaced=false` 属于可派生自动位置,在 Rust 关系图首次就绪或可信 producer / dependency depth 变化后按最终拓扑确定性重算。自动重算不得移动手动坐标,协调结果与持久布局逐项一致时不得产生 CAS 写入。
|
||||
- 搜索或筛选只隐藏卡片,不删除、压缩或重排其坐标;清空搜索后恢复原位置。
|
||||
- 窗口尺寸变化只改变可视范围和分区滚动边界,不回写、裁切或缩放持久坐标。当前客户端继续以 `1280×800` 横屏合同验收。
|
||||
- 打开项目、切换 mode 或当前 mode 首次出现新资源时执行“读取 -> 协调 -> 必要时 CAS 写入”;项目或 mode 已切换后返回的旧异步结果必须丢弃。
|
||||
- 打开项目、切换 mode 或当前 mode 首次出现新资源时执行“读取 -> 协调 -> 必要时 CAS 写入”;dependency 模式必须先等待与当前 `projectPath + projectId + resource inputs` 匹配的 Rust 图进入 `ready` 或 `failed` 终态,等待期间不得创建 fallback、读取 sidecar、协调资源或入队保存。`failed` 只允许以空图降级初始化一次。项目或 mode 已切换后返回的旧异步结果必须丢弃。
|
||||
- 同一 `projectPath + projectId + mode` 的首次读取与资源集合协调必须分开:资源集合变化不得取消已经发出的读取或保存。同一 scope 内全部手动拖动和资源自动协调写入使用同一 FIFO,任一时刻最多一个 CAS 在途,后一笔必须使用前一笔成功返回的 revision,不能用“最后请求获胜”跳过中间 CAS。切换项目或 mode 后,旧 scope 的在途请求不能阻塞新 scope 队列;前端放弃旧请求槽位并丢弃其迟到响应,后端继续依靠 `expectedProjectId + expectedRevision + 系统锁` 仲裁已发出的请求。
|
||||
- 某笔 CAS 在途期间,同一 scope 内对相同 `resourceId + section` 重复产生但尚未发送的拖动意图必须折叠为最后坐标;已经在途的请求不得取消,不同资源的顺序不得跨越。队列增长必须受当前资源与分区数量约束,不能随连续 pointer 事件无界累积。
|
||||
- 用户拖动结束后先乐观更新,再立即提交一次 CAS。成功后以返回布局更新 revision;普通写入失败时恢复最近可信持久布局并提示“布局保存失败,已恢复上次布局”。
|
||||
@@ -250,7 +250,7 @@ type UpdateProjectResourceCanvasLayoutResult =
|
||||
#### 5.2.5 资源依赖关系图层
|
||||
|
||||
- 图层只在 dependency 模式挂载;type 模式不得渲染 SVG、连线或 marker。切换 mode、切换项目或卸载工作台时必须销毁旧图层,并清理尺寸观察和窗口事件监听。
|
||||
- 输入固定为当前资源投影的全部卡片身份 / 坐标与 Tauri Rust 返回的 `ProjectResourceGraph` 只读 DTO;Rust 负责资源过滤、去重、环检测、任务流聚合和一跳连接索引,前端只负责 DTO 防御归一化、浏览器几何与原生 SVG path / marker。SVG 叠加在资源卡底层并设置 `pointer-events: none`,不得引入 D3、React Flow 等图表库,也不得阻断卡片点击和 Pointer Events 拖动。
|
||||
- 输入固定为当前资源投影的全部卡片身份 / 坐标与 Tauri Rust 返回的 `ProjectResourceGraph` 只读 DTO;Rust 负责资源过滤、去重、迭代式环检测、SCC 压缩后的确定性依赖深度、任务流聚合和一跳连接索引,前端只负责 DTO 防御归一化、浏览器几何与原生 SVG path / marker。SVG 叠加在资源卡底层并设置 `pointer-events: none`,不得引入 D3、React Flow 等图表库,也不得阻断卡片点击和 Pointer Events 拖动。实时拖动坐标属于 DOM 临时状态,不得逐帧通过 Tauri IPC 交给 Rust。
|
||||
- `asset-reference` 表示精确资源引用,使用橙色实线与连续贝塞尔曲线。`GameCreationAppAssetManifestEntry.source.referenceResourceIds` 中的外部资源 ID 必须先唯一匹配另一项资产的 `source.resourceId`,再映射为当前资源卡 ID;缺失、重复或已删除的目标均不得渲染幽灵连线。
|
||||
- `task-flow` 表示任务产物流转,使用灰色圆头虚线。任务依赖按 `sourceTaskId -> targetTaskId` 聚合为一条主线,两端资源仅绘制平滑曲线分支,不得出现直角折线;禁止对上下游资源生成笛卡尔积连线。任务主线与分支可以使用不同线宽和透明度表达聚合层级,但不能改变端点或方向语义。
|
||||
- 画布资产 producer 只能来自 `agent.runtime.canvas.asset_generate` 的 `assetId -> agentId` 审计且 `agentId` 必须存在于当前 manifest;External Editor `source.taskId` 属于平台生成任务命名空间,禁止当作 manifest task ID。证据缺失、冲突或有界审计读取未覆盖时不生成对应 task flow,不猜测归属。
|
||||
@@ -259,7 +259,7 @@ type UpdateProjectResourceCanvasLayoutResult =
|
||||
- 搜索只允许为当前可见端点生成几何;任一精确引用端点隐藏时该线隐藏,聚合任务流只保留仍可见的两端分支,任一侧没有可见资源时整条任务流隐藏。
|
||||
- 选中资源后,高亮其直接上游、直接下游卡片和关联边,弱化其余边;不做跨多层递归高亮。选中 ID 已失效时按未选中处理。
|
||||
- 拖动预览坐标必须直接进入 SVG 几何计算,使连线随 pointer move 实时更新;拖动结束仍只保存卡片布局坐标,不持久化 path、marker、section 原点或任何图结构。
|
||||
- Pointer Move 必须按动画帧合并;基础 positions 不随每帧复制,静态 SVG 拓扑保持复用,每帧只更新当前资源局部索引关联的 path。`ResizeObserver` 在单个图层生命周期只允许构造一次。dependency section 额外提供至少 `64px` 右侧视觉 gutter,确保最右侧自环和箭头可完整滚动显示,但不得修改卡片坐标或布局 sidecar。
|
||||
- Pointer Move 必须按动画帧合并并完全避开工作台父组件 state:拖动卡片通过 ref 直接更新 CSS 坐标,SVG 通过命令式句柄只更新当前资源局部索引关联的 path。基础 positions 不随每帧复制,静态卡片和 SVG 拓扑保持复用,非拖动卡片不得因预览帧重新渲染。`ResizeObserver` 在单个图层生命周期只允许构造一次。dependency section 额外提供至少 `64px` 右侧视觉 gutter,确保最右侧自环和箭头可完整滚动显示,但不得修改卡片坐标或布局 sidecar。
|
||||
|
||||
### 5.3 资源类型与替换兼容性(P1)
|
||||
|
||||
@@ -405,6 +405,7 @@ type ProjectAgentMudPointAttribution = {
|
||||
5. 搜索、选择和拖动分别触发端点过滤、直接上下游高亮和实时几何更新;原有点击、详情浮层与拖动保存行为不回归。
|
||||
6. 切换布局模式或项目后旧 SVG、ResizeObserver 与窗口监听全部清理;图层从不写入 layout sidecar、manifest 或其它持久化。
|
||||
7. 4096 资源链式 fixture 下,拖动一张卡片只更新它关联的线段;同一图层 100 次拖动期间 Observer 仍只构造一次。真实 Chromium 目标为拖动 p95 小于 `16.7ms`、不出现超过 `50ms` 的 long task,并完整显示最右侧自环与箭头。
|
||||
8. Rust 图读取延迟时,dependency sidecar 在图进入 `ready / failed` 前没有读取或写入;首次布局直接使用 Rust 返回的最终 producer 与 dependency depth。重新打开旧布局时手动位置逐项不变,自动位置按最终拓扑协调且相同结果不增加 revision。
|
||||
|
||||
## 8. 非目标
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-08-03 依赖布局等待 Rust 图终态且拖动热路径脱离 React state
|
||||
|
||||
- 背景:资源图异步返回前,dependency 布局会先以 `dependencyDepth=0` 创建并持久化自动坐标;图返回后的 reconcile 保留既有位置,导致首次布局永久停留在错误层级。4096 张真实资源卡拖动时,逐帧父组件 state 还会重渲染全部卡片,即使 SVG 已只更新局部 path 也无法满足帧预算。
|
||||
- 决策:Rust 关系图 read model 负责在完整任务图 SCC 压缩后返回确定性 resource dependency depth;dependency 模式等待当前 scope 图进入 `ready / failed` 后才启动布局读取与协调。手动位置永久保留,自动位置允许按最终图重新派生。拖动 preview 留在前端 ref/DOM 热路径,命令式更新卡片 CSS 与局部 SVG path,不逐帧跨 Tauri IPC,也不写 layout sidecar。
|
||||
- 边界:不修改 `game-creator-resource-layout.v1`、布局 Rust 持久层、manifest、api-server 或 SpacetimeDB;type 模式不等待资源图且继续保留全部已有坐标。图失败只降级初始化一次,项目或 mode 切换后旧图结果必须丢弃。
|
||||
- 验证:延迟图 Promise 证明终态前零布局读取/写入,手动位置保持且自动位置按最终深度协调;4096 张真实卡片连续拖动证明非拖动卡片零重渲染、静态 SVG 不重建、Observer 单实例,并以 Chromium p95 `<16.7ms` 和零 `>50ms` long task 验收。
|
||||
|
||||
> 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。
|
||||
|
||||
## 记录格式
|
||||
|
||||
@@ -22,6 +22,14 @@
|
||||
- 验证:Rust fixture 把 `source.taskId` 固定为 `task-1 / task-2`,只有审计提供 `art-director / design-foundation` 后才生成 task flow;移除审计后橙色引用保留、灰色任务流消失。AppSurface 使用相同生产数据形状回归。
|
||||
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs`、`apps/ai-game-creator-shell/src/view/project-development/resourceDependencyGraphModel.ts`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。
|
||||
|
||||
## 依赖图未就绪时不能先初始化资源布局
|
||||
|
||||
- 现象:首次打开 dependency 画布时所有资源短暂按深度 0 排列;Rust 图返回后连线正确,但卡片仍停留在同一列,错误自动坐标还可能已经写入 sidecar。
|
||||
- 原因:资源图和布局读取独立异步启动,布局 Hook 在图未返回时使用空图资源创建 fallback;后续 reconcile 按旧合同保留全部已有坐标,真实 producer 与 dependency depth 无法纠正首次自动位置。
|
||||
- 处理:dependency 模式增加按项目与资源输入隔离的图加载屏障,`ready / failed` 前不启动布局 Hook 的 fallback、读取、协调或保存。Rust read model 返回确定性依赖深度;已有布局只永久保留手动位置,自动位置按最终图重新派生。type 模式不受图加载影响。
|
||||
- 验证:用 deferred graph Promise 断言终态前 Tauri layout read/update 调用均为 0;图就绪后首次坐标直接按最终深度生成,旧 scope 迟到结果无效,手动坐标不变且相同自动布局不增加 revision。
|
||||
- 关联:`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts`、`apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs`。
|
||||
|
||||
## Jenkins 异步备份不能用 nohup 脱离作业
|
||||
|
||||
- 现象:Stdb Publish 成功,上传日志只留下“已获取进程锁 / 上传已有备份 / 目标对象”,没有成功或可捕获错误;本地 tar.gz 和 `uploadStatus=deferred` manifest 每次发布后继续增长。
|
||||
|
||||
@@ -396,6 +396,10 @@ game-project/
|
||||
- 基础 positions 保持稳定,拖动预览单独传入。Pointer Move 通过 `requestAnimationFrame` 合帧;静态 task/reference React SVG 子树不依赖 drag preview,每帧只按局部连接索引重新计算当前资源关联的 edge / flow 并更新对应 path `d`。搜索、选择、项目切换、真实 positions 或 section origin 变化才允许重新协调静态图层;结束后仍只通过原布局 Hook 保存卡片坐标,SVG 几何从不持久化。
|
||||
- Rust、前端 DTO/SVG 和工作台 AppSurface 回归覆盖生产 `task-1` 数据形状、真实 producer、证据缺失、精确引用、去重、无效 ID、完整任务环、4096 链式拓扑、聚合复杂度、搜索过滤、选择高亮、局部拖动更新、稳定 Observer、type 模式卸载与项目切换销毁。真实 Chromium 性能目标为拖动 p95 `<16.7ms`、不出现 `>50ms` long task;若实测仍超过预算,再增加 viewport + overscan 边裁剪,不在首轮预先引入额外复杂度。
|
||||
|
||||
2026-08-03 评审加固:Rust read model 在现有 producer assignment 上同时返回经完整任务图 SCC 压缩计算的确定性 `dependencyDepth`,前端不再递归推导正式依赖层级。dependency 模式以 scope 化 `idle / loading / ready / failed` 状态阻断布局 Hook;图终态前不创建 fallback、不读取或写入 sidecar,图失败只以空图初始化一次。读取已有 dependency 布局时保留全部 `manuallyPlaced=true` 坐标,把 `manuallyPlaced=false` 作为可派生自动位置按最终深度重新协调;结果未变化时不写入。
|
||||
|
||||
拖动热路径不再把 preview 写入工作台父组件 state。卡片使用稳定回调与 `React.memo`,动画帧直接更新拖动卡片 CSS 变量和 section 临时 extent;SVG 图层暴露命令式 preview 句柄,复用 Rust 局部连接索引和已缓存 path 节点,只更新受影响 reference / task-flow 几何。ResizeObserver 仍为单图层单实例。4096 张真实卡片测试必须证明非拖动卡片零重渲染、静态 SVG 子树不重建,Chromium p95 继续以 `<16.7ms` 为门槛。实时 DOM 几何不得通过 Tauri IPC 往返 Rust。
|
||||
|
||||
## 分阶段实施
|
||||
|
||||
1. 在 `platform-agent` 建立游戏创作专业组与种子任务图契约。
|
||||
|
||||
Reference in New Issue
Block a user