diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts index 715bd36ff..322d76306 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts @@ -29,6 +29,11 @@ export const RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP = 48; export const RESOURCE_CANVAS_DEPENDENCY_ROW_GAP = 40; export const RESOURCE_CANVAS_DRAG_THRESHOLD = 5; export const RESOURCE_CANVAS_TYPE_COLUMNS = 3; +/** + * dependency 层内网格的列数上限。卡片是 180×128 的横向卡,6 列加层间距约 1368px, + * 与栏目页在 1x 下的一屏宽度同量级;再宽就会退化成需要横向滚动的超长横带。 + */ +export const RESOURCE_CANVAS_DEPENDENCY_LAYER_MAX_COLUMNS = 6; export const RESOURCE_CANVAS_SECTION_MIN_WIDTH = 620; export const RESOURCE_CANVAS_SECTION_MIN_HEIGHT = 108; export const RESOURCE_CANVAS_CLUSTER_GAP = 48; @@ -283,6 +288,12 @@ const RESOURCE_CANVAS_MAX_DEPENDENCY_COLUMN = Math.floor( ); const RESOURCE_CANVAS_MAX_DEPENDENCY_X = RESOURCE_CANVAS_MAX_DEPENDENCY_COLUMN * RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH; +/** + * 层内网格按槽位宽高比确定列数:让整层的块面接近方形,避免铺成横带或竖带。 + */ +const RESOURCE_CANVAS_DEPENDENCY_LAYER_SLOT_ASPECT = + RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH / + RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT; type PositionedResourceCanvasRect = { position: ProjectResourceCanvasPosition; @@ -823,28 +834,82 @@ function sortDependencyBucket( ); } -function dependencyBucketHeight( - resourceIds: readonly string[], - cardSizeByResourceId: ResourceCanvasCardSizeByResourceId, -) { - return resourceIds.reduce( - (height, resourceId, index) => - height + - resourceCanvasCardSize(resourceId, cardSizeByResourceId).height + - (index === 0 ? 0 : RESOURCE_CANVAS_DEPENDENCY_ROW_GAP), - 0, +/** + * dependency 深度归一。落位、层带与层内网格共用同一口径,避免同一深度被当成两层。 + */ +function normalizedDependencyDepth(depth: number) { + return Number.isFinite(depth) ? Math.max(0, Math.round(depth)) : 0; +} + +/** + * 层内网格列数:卡片数量 → 列数,让整层块面接近方形(槽位宽高比 ≈ 1.36),并按上限封顶。 + * 它是卡片数量的纯函数,所以同一份输入重复计算稳定,也不受资源顺序影响; + * 1 张卡只占 1 列,2 张卡仍是 1 列(`floor(sqrt(2 × 1.36)) = 1`), + * 只有真正摞成长条的层(≥3 张)才会横向展开成平面。 + */ +function dependencyLayerColumnCount(cardCount: number) { + return Math.min( + RESOURCE_CANVAS_DEPENDENCY_LAYER_MAX_COLUMNS, + Math.max( + 1, + Math.floor( + Math.sqrt(cardCount * RESOURCE_CANVAS_DEPENDENCY_LAYER_SLOT_ASPECT), + ), + ), ); } -function dependencyColumnXByDepth( +type DependencyLayerGrid = { + columns: number; + rows: number; + rowHeight: number; + height: number; +}; + +/** + * 层内网格几何。行高统一取该层最高卡,使层高只由行列数量决定、与层内顺序无关: + * 中位数扫描会重排层内次序,几何不能跟着变,否则簇高、簇间留白与居中偏移都会抖。 + */ +function dependencyLayerGrid( + resourceIds: readonly string[], + cardSizeByResourceId: ResourceCanvasCardSizeByResourceId, +): DependencyLayerGrid { + const columns = dependencyLayerColumnCount(resourceIds.length); + let rowHeight = 0; + for (const resourceId of resourceIds) { + rowHeight = Math.max( + rowHeight, + resourceCanvasCardSize(resourceId, cardSizeByResourceId).height, + ); + } + const rows = Math.max(1, Math.ceil(resourceIds.length / columns)); + return { + columns, + rows, + rowHeight, + height: rows * rowHeight + (rows - 1) * RESOURCE_CANVAS_DEPENDENCY_ROW_GAP, + }; +} + +type DependencyColumnGeometry = { + x: number; + pitch: number; +}; + +/** + * 每个深度的层带基址与层内列间距。`dependencyDepth` 仍是唯一横向层级权威: + * 层带基址只由深度决定,随深度单调递增;层内第几列由网格列数在层带内展开。 + * 层带宽度 = 列数 × 列间距,层与层之间恒定留一个列间距。当每一层都是单列时, + * 层带宽度公式退化成旧值(`maxWidth + 列间距`),坐标与改动前逐值一致。 + */ +function dependencyColumnGeometryByDepth( resources: readonly ResourceCanvasItem[], cardSizeByResourceId: ResourceCanvasCardSizeByResourceId, + columnCountByDepth: ReadonlyMap, ) { const widthByDepth = new Map(); for (const resource of resources) { - const depth = Number.isFinite(resource.dependencyDepth) - ? Math.max(0, Math.round(resource.dependencyDepth)) - : 0; + const depth = normalizedDependencyDepth(resource.dependencyDepth); widthByDepth.set( depth, Math.max( @@ -853,28 +918,31 @@ function dependencyColumnXByDepth( ), ); } - const xByDepth = new Map(); - let accumulatedWidthDelta = 0; + const geometryByDepth = new Map(); + let accumulatedBandWidthDelta = 0; for (const depth of Array.from(widthByDepth.keys()).sort( (left, right) => left - right, )) { - xByDepth.set( - depth, - Math.min( + const pitch = + (widthByDepth.get(depth) ?? RESOURCE_CANVAS_CARD_WIDTH) + + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP; + const columns = Math.max(1, columnCountByDepth.get(depth) ?? 1); + geometryByDepth.set(depth, { + x: Math.min( RESOURCE_CANVAS_MAX_DEPENDENCY_X, Math.max( 0, Math.min(depth, RESOURCE_CANVAS_MAX_DEPENDENCY_COLUMN) * RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH + - accumulatedWidthDelta, + accumulatedBandWidthDelta, ), ), - ); - accumulatedWidthDelta += - (widthByDepth.get(depth) ?? RESOURCE_CANVAS_CARD_WIDTH) - - RESOURCE_CANVAS_CARD_WIDTH; + pitch, + }); + accumulatedBandWidthDelta += + columns * pitch - RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH; } - return xByDepth; + return geometryByDepth; } function dependencyAutomaticPositions( @@ -926,22 +994,17 @@ function dependencyAutomaticPositions( bucket.push(resourceId); resourceIdsByDepth.set(depth, bucket); } + const grids = Array.from(resourceIdsByDepth.values(), (ids) => + dependencyLayerGrid(ids, cardSizeByResourceId), + ); clusters.push({ resourceIds, minimumDepth: component.minimumDepth, minimumResourceId: component.minimumResourceId, related: true, baseY: 0, - rowCount: Math.max( - 1, - ...Array.from(resourceIdsByDepth.values(), (ids) => ids.length), - ), - height: Math.max( - 1, - ...Array.from(resourceIdsByDepth.values(), (ids) => - dependencyBucketHeight(ids, cardSizeByResourceId), - ), - ), + rowCount: Math.max(1, ...grids.map((grid) => grid.rows)), + height: Math.max(1, ...grids.map((grid) => grid.height)), }); } if (isolatedResourceIds.length > 0) { @@ -952,22 +1015,17 @@ function dependencyAutomaticPositions( bucket.push(resourceId); resourceIdsByDepth.set(depth, bucket); } + const grids = Array.from(resourceIdsByDepth.values(), (ids) => + dependencyLayerGrid(ids, cardSizeByResourceId), + ); clusters.push({ resourceIds: isolatedResourceIds, minimumDepth: Number.MAX_SAFE_INTEGER, minimumResourceId: isolatedResourceIds[0] ?? '', related: false, baseY: 0, - rowCount: Math.max( - 1, - ...Array.from(resourceIdsByDepth.values(), (ids) => ids.length), - ), - height: Math.max( - 1, - ...Array.from(resourceIdsByDepth.values(), (ids) => - dependencyBucketHeight(ids, cardSizeByResourceId), - ), - ), + rowCount: Math.max(1, ...grids.map((grid) => grid.rows)), + height: Math.max(1, ...grids.map((grid) => grid.height)), }); } let nextClusterY = 0; @@ -987,6 +1045,7 @@ function dependencyAutomaticPositions( DependencyCluster, Map >(); + const gridByBucket = new Map(); for (const cluster of clusters) { const idsByDepth = new Map(); for (const resourceId of cluster.resourceIds) { @@ -1000,17 +1059,22 @@ function dependencyAutomaticPositions( } for (const [depth, resourceIds] of idsByDepth) { resourceIds.sort(compareStableIdentifier); + const grid = dependencyLayerGrid(resourceIds, cardSizeByResourceId); + gridByBucket.set(resourceIds, grid); + // 层内 rank 用"网格行号",同一行的相邻卡共享纵向位置,跨层中位数对齐仍然成立。 const bucketBaseY = cluster.baseY + (cluster.related - ? ((cluster.rowCount - resourceIds.length) * + ? ((cluster.rowCount - grid.rows) * RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT) / 2 : 0); resourceIds.forEach((resourceId, index) => rankByResourceId.set( resourceId, - (bucketBaseY + index * RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT) / + (bucketBaseY + + Math.floor(index / grid.columns) * + RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT) / RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT, ), ); @@ -1055,16 +1119,20 @@ function dependencyAutomaticPositions( topologyForSection, flowSignalByFlow, ); + const grid = gridByBucket.get(resourceIds)!; + const bucketBaseY = + cluster.baseY + + (cluster.related + ? ((cluster.rowCount - grid.rows) * + RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT) / + 2 + : 0); resourceIds.forEach((resourceId, index) => rankByResourceId.set( resourceId, - (cluster.baseY + - (cluster.related - ? ((cluster.rowCount - resourceIds.length) * - RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT) / - 2 - : 0) + - index * RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT) / + (bucketBaseY + + Math.floor(index / grid.columns) * + RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT) / RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT, ), ); @@ -1073,9 +1141,25 @@ function dependencyAutomaticPositions( } } - const dependencyColumnPositions = dependencyColumnXByDepth( + // 层带宽度要按该深度上最宽的一层预留,否则宽层会侵入下一个深度的层带。 + const layerColumnCountByDepth = new Map(); + for (const cluster of clusters) { + const idsByDepth = orderedIdsByClusterAndDepth.get(cluster)!; + for (const [depth, resourceIds] of idsByDepth) { + const normalizedDepth = normalizedDependencyDepth(depth); + layerColumnCountByDepth.set( + normalizedDepth, + Math.max( + layerColumnCountByDepth.get(normalizedDepth) ?? 1, + gridByBucket.get(resourceIds)!.columns, + ), + ); + } + } + const columnGeometryByDepth = dependencyColumnGeometryByDepth( sectionResources, cardSizeByResourceId, + layerColumnCountByDepth, ); const occupancy = new ResourceCanvasOccupancyIndex( preservedPositions, @@ -1089,27 +1173,31 @@ function dependencyAutomaticPositions( for (const [depth, resourceIds] of Array.from(idsByDepth).sort( ([left], [right]) => left - right, )) { - const normalizedDepth = Number.isFinite(depth) - ? Math.max(0, Math.round(depth)) - : 0; - const x = - dependencyColumnPositions.get(normalizedDepth) ?? - Math.min( + const normalizedDepth = normalizedDependencyDepth(depth); + const grid = gridByBucket.get(resourceIds)!; + const columnGeometry = columnGeometryByDepth.get(normalizedDepth) ?? { + x: Math.min( RESOURCE_CANVAS_MAX_DEPENDENCY_X, Math.min(normalizedDepth, RESOURCE_CANVAS_MAX_DEPENDENCY_COLUMN) * RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH, - ); - const bucketHeight = dependencyBucketHeight( - resourceIds, - cardSizeByResourceId, - ); - let nextY = Math.round( + ), + pitch: RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH, + }; + const baseY = Math.round( cluster.baseY + - (cluster.related ? (cluster.height - bucketHeight) / 2 : 0), + (cluster.related ? (cluster.height - grid.height) / 2 : 0), ); - for (const resourceId of resourceIds) { + resourceIds.forEach((resourceId, index) => { const size = resourceCanvasCardSize(resourceId, cardSizeByResourceId); - let y = nextY; + const x = Math.min( + RESOURCE_CANVAS_MAX_DEPENDENCY_X, + columnGeometry.x + (index % grid.columns) * columnGeometry.pitch, + ); + let y = + baseY + + Math.floor(index / grid.columns) * + (grid.rowHeight + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP); + // 网格之外仍可能与手动坐标或饱和层带相撞,沿用既有向下找空位的回避。 while (occupancy.overlaps(x, y, resourceId)) { y += size.height + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP; } @@ -1122,8 +1210,7 @@ function dependencyAutomaticPositions( }; positions.push(position); occupancy.add(position); - nextY = y + size.height + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP; - } + }); } } diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts index 434d2c8f6..1129408e4 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts @@ -17,6 +17,7 @@ import { RESOURCE_CANVAS_CARD_WIDTH, RESOURCE_CANVAS_COLUMN_GAP, RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP, + RESOURCE_CANVAS_DEPENDENCY_LAYER_MAX_COLUMNS, RESOURCE_CANVAS_DEPENDENCY_ROW_GAP, RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE, RESOURCE_CANVAS_SECTION_ORDER, @@ -573,14 +574,20 @@ describe('resource canvas layout model', () => { ]), ).layout; - const cycleRows = ['art:cycle-a', 'art:cycle-b'] - .map((resourceId) => positionById(layout, resourceId).y) - .sort((left, right) => left - right); - const siblingRow = positionById(layout, 'art:sibling').y; - expect(cycleRows[1] - cycleRows[0]).toBe( - RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP, - ); - expect(siblingRow).toBeGreaterThan(cycleRows[1]); + const cycleA = positionById(layout, 'art:cycle-a'); + const cycleB = positionById(layout, 'art:cycle-b'); + const sibling = positionById(layout, 'art:sibling'); + const dependencySlotWidth = + RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP; + const dependencySlotHeight = + RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP; + + // 同层 3 张卡按层内网格展开成 2 列,环成员仍占相邻两格(同排相邻列), + // 另一张共享该层的相关卡落在它们之后的下一行。 + expect(cycleB.y).toBe(cycleA.y); + expect(cycleB.x - cycleA.x).toBe(dependencySlotWidth); + expect(sibling.x).toBe(cycleA.x); + expect(sibling.y).toBe(cycleA.y + dependencySlotHeight); }); it('is deterministic, does not modify type placement, and avoids historical manual positions', () => { @@ -968,3 +975,191 @@ describe('resource canvas variable card geometry', () => { ); }); }); + +describe('resource canvas dependency layer grid', () => { + const dependencySlotWidth = + RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP; + const dependencySlotHeight = + RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP; + + function gridResources(count: number, dependencyDepth = 0) { + return Array.from({ length: count }, (_, index) => + resource( + `art:grid-${index.toString().padStart(2, '0')}`, + 'scene', + dependencyDepth, + ), + ); + } + + function expectNoOverlappingCards( + layout: ReturnType['layout'], + ) { + const rects = layout.positions.map((position) => ({ + position, + size: DEFAULT_RESOURCE_CANVAS_CARD_SIZE, + })); + rects.forEach((left, index) => { + rects.slice(index + 1).forEach((right) => { + expect( + left.position.x < + right.position.x + + right.size.width + + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP && + left.position.x + + left.size.width + + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP > + right.position.x && + left.position.y < + right.position.y + + right.size.height + + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP && + left.position.y + + left.size.height + + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP > + right.position.y, + ).toBe(false); + }); + }); + } + + it('lays a single-depth section out as a grid plane instead of one column', () => { + const resources = gridResources(12); + const layout = reconcileResourceCanvasLayout( + createEmptyResourceCanvasLayout('project-layer-grid', 'dependency'), + resources, + ).layout; + + // 12 张同深度卡:4 列 × 3 行,横向展开成平面,而不是 12 张摞成一列。 + expect( + Array.from(new Set(layout.positions.map((position) => position.x))), + ).toEqual([ + 0, + dependencySlotWidth, + 2 * dependencySlotWidth, + 3 * dependencySlotWidth, + ]); + expect( + Array.from(new Set(layout.positions.map((position) => position.y))), + ).toEqual([0, dependencySlotHeight, 2 * dependencySlotHeight]); + expect(layout.positions).toHaveLength(12); + expectNoOverlappingCards(layout); + }); + + it('is stable for repeated runs and independent of the resource input order', () => { + const resources = gridResources(9); + const layout = reconcileResourceCanvasLayout( + createEmptyResourceCanvasLayout( + 'project-layer-grid-stable', + 'dependency', + ), + resources, + ).layout; + const repeated = reconcileResourceCanvasLayout( + createEmptyResourceCanvasLayout( + 'project-layer-grid-stable', + 'dependency', + ), + resources, + ).layout; + const reversed = reconcileResourceCanvasLayout( + createEmptyResourceCanvasLayout( + 'project-layer-grid-stable', + 'dependency', + ), + [...resources].reverse(), + ).layout; + + expect(repeated.positions).toEqual(layout.positions); + expect(reversed.positions).toEqual(layout.positions); + }); + + it('keeps depth bands monotone and clear of the wrapped layer', () => { + const shallow = gridResources(9); + const deep = resource('art:deep', 'scene', 1); + const layout = reconcileResourceCanvasLayout( + createEmptyResourceCanvasLayout('project-layer-band', 'dependency'), + [...shallow, deep], + ).layout; + const shallowPositions = shallow.map((item) => + positionById(layout, item.id), + ); + const deepPosition = positionById(layout, deep.id); + + // 9 张同深度铺成 3 列,层带按 3 列预留;深度 1 的层带整体让开,仍随深度向右推进。 + expect( + Array.from(new Set(shallowPositions.map((position) => position.x))).sort( + (left, right) => left - right, + ), + ).toEqual([0, dependencySlotWidth, 2 * dependencySlotWidth]); + expect(deepPosition.x).toBe(3 * dependencySlotWidth); + expect( + Math.max(...shallowPositions.map((position) => position.x)) + + RESOURCE_CANVAS_CARD_WIDTH + + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP, + ).toBeLessThanOrEqual(deepPosition.x); + }); + + it('caps the wrapped columns so a large layer stays readable', () => { + const resources = gridResources(100); + const layout = reconcileResourceCanvasLayout( + createEmptyResourceCanvasLayout('project-layer-cap', 'dependency'), + resources, + ).layout; + const xValues = Array.from( + new Set(layout.positions.map((position) => position.x)), + ); + + // 100 张同深度卡铺成封顶的 6 列,而不是一条 100 张的超高竖带或超长横带。 + expect(xValues).toHaveLength(RESOURCE_CANVAS_DEPENDENCY_LAYER_MAX_COLUMNS); + expect(xValues).toHaveLength(6); + expect(Math.max(...xValues)).toBe( + (RESOURCE_CANVAS_DEPENDENCY_LAYER_MAX_COLUMNS - 1) * dependencySlotWidth, + ); + expectNoOverlappingCards(layout); + }); + + it('keeps manual coordinates identical while the automatic grid re-derives', () => { + const resources = gridResources(9); + const source = createEmptyResourceCanvasLayout( + 'project-layer-grid-manual', + 'dependency', + ); + source.positions = [ + { + resourceId: 'art:grid-00', + section: 'scene', + x: 900, + y: 640, + manuallyPlaced: true, + }, + ]; + const layout = reconcileResourceCanvasLayout(source, resources).layout; + // 重派生路径只保留手动坐标后重算自动坐标,手动坐标必须逐项不变。 + const redriven = reconcileResourceCanvasLayout( + { + ...layout, + positions: layout.positions.filter( + (position) => position.manuallyPlaced, + ), + }, + resources, + ).layout; + + expect(positionById(layout, 'art:grid-00')).toMatchObject({ + x: 900, + y: 640, + manuallyPlaced: true, + }); + expect(positionById(redriven, 'art:grid-00')).toMatchObject({ + x: 900, + y: 640, + manuallyPlaced: true, + }); + expectNoOverlappingCards(layout); + expectNoOverlappingCards(redriven); + expect( + redriven.positions.filter((position) => !position.manuallyPlaced), + ).toEqual(layout.positions.filter((position) => !position.manuallyPlaced)); + }); +});