From aaac44a622640e8102b818cdbe045cdc6df85270 Mon Sep 17 00:00:00 2001 From: suzmii Date: Thu, 20 Aug 2026 22:40:48 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E8=B5=84=E6=BA=90=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E5=8D=A1=E6=8C=89=E7=9C=9F=E5=AE=9E=E6=AF=94=E4=BE=8B?= =?UTF-8?q?=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 图片预览返回并透传像素宽高。 统一计算图片卡边界并让布局碰撞、自动位置和分区范围消费实际尺寸。 依赖连线按逐资源矩形计算端点并补充模型与预览测试。 --- .../src-tauri/src/image_inspect.rs | 8 + .../src-tauri/src/tests/project_tools.rs | 6 + .../ResourceDependencyOverlay.tsx | 31 +- .../resourceCanvasLayoutModel.ts | 390 +++++++++++++++--- .../resourceCardPreviewModel.ts | 30 ++ .../useProjectResourceCardPreviews.ts | 32 ++ .../tests/ResourceDependencyOverlay.test.ts | 47 +++ .../tests/resourceCanvasLayoutModel.test.ts | 115 ++++++ .../useProjectResourceCardPreviews.test.ts | 7 + 9 files changed, 596 insertions(+), 70 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs b/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs index 752160a6e..8618b0174 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs @@ -24,6 +24,8 @@ pub(crate) struct LocalProjectImagePreview { pub(crate) path: String, pub(crate) media_type: String, pub(crate) byte_len: u64, + pub(crate) pixel_width: u32, + pub(crate) pixel_height: u32, pub(crate) data_url: String, } @@ -32,6 +34,8 @@ pub(crate) struct AgentRuntimeInspectionImage { pub(crate) sha256: String, pub(crate) byte_len: u64, pub(crate) media_type: &'static str, + pixel_width: u32, + pixel_height: u32, bytes: Vec, } @@ -86,6 +90,8 @@ pub(crate) fn load_local_project_image_preview_with_cancellation( path: image.relative_path.clone(), media_type: image.media_type.to_string(), byte_len: image.byte_len, + pixel_width: image.pixel_width, + pixel_height: image.pixel_height, data_url: image.data_url_with_cancellation(cancellation)?, }) } @@ -360,6 +366,8 @@ fn read_agent_runtime_inspection_image_with_cancellation( sha256, byte_len: bytes.len() as u64, media_type, + pixel_width: width, + pixel_height: height, bytes, }) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs index eacec8589..06d3d9ba7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs @@ -6142,6 +6142,12 @@ fn local_project_image_preview_obeys_auto_file_read_policy() { read_local_project_image_preview_at(&project_path, "assets/preview.png", &cancellation) .expect("auto preview"); assert_eq!(preview.media_type, "image/png"); + assert_eq!(preview.pixel_width, 1); + assert_eq!(preview.pixel_height, 1); + let serialized_preview = + serde_json::to_value(&preview).expect("serialize image preview"); + assert_eq!(serialized_preview["pixelWidth"], 1); + assert_eq!(serialized_preview["pixelHeight"], 1); fs::write(root.join("assets/unregistered.png"), &preview_bytes).expect("unregistered image"); let unregistered_error = read_local_project_image_preview_at( diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx index 2c892669a..fa5ce3ff5 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx @@ -13,8 +13,8 @@ import type { ProjectResourceCanvasSection, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { - RESOURCE_CANVAS_CARD_HEIGHT, - RESOURCE_CANVAS_CARD_WIDTH, + resourceCanvasCardSize, + type ResourceCanvasCardSizeByResourceId, } from './resourceCanvasLayoutModel'; import { type ProjectResourceGraph, @@ -40,6 +40,7 @@ export type ResourceDependencyOverlayProps = { positions: readonly ProjectResourceCanvasPosition[]; section: ProjectResourceCanvasSection; visibleResourceIds: ReadonlySet; + cardSizeByResourceId?: ResourceCanvasCardSizeByResourceId; geometryRevision?: string; }; @@ -458,7 +459,14 @@ export const ResourceDependencyOverlay = forwardRef< ResourceDependencyOverlayHandle, ResourceDependencyOverlayProps >(function ResourceDependencyOverlay( - { geometryRevision = '', graph, positions, section, visibleResourceIds }, + { + cardSizeByResourceId, + geometryRevision = '', + graph, + positions, + section, + visibleResourceIds, + }, ref, ) { const markerPrefix = useId().replace(/[^a-zA-Z0-9_-]/gu, ''); @@ -545,6 +553,10 @@ export const ResourceDependencyOverlay = forwardRef< ) { continue; } + const cardSize = resourceCanvasCardSize( + position.resourceId, + cardSizeByResourceId, + ); result.set(position.resourceId, { x: dragPreview?.resourceId === position.resourceId @@ -554,12 +566,19 @@ export const ResourceDependencyOverlay = forwardRef< dragPreview?.resourceId === position.resourceId ? dragPreview.y : position.y, - width: RESOURCE_CANVAS_CARD_WIDTH, - height: RESOURCE_CANVAS_CARD_HEIGHT, + width: cardSize.width, + height: cardSize.height, }); } return result; - }, [dragPreview, graph.resourceIds, positions, section, visibleResourceIds]); + }, [ + cardSizeByResourceId, + dragPreview, + graph.resourceIds, + positions, + section, + visibleResourceIds, + ]); const sectionByResourceId = useMemo( () => new Map( 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 b215228de..766a84fe8 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 @@ -9,6 +9,11 @@ import { export const RESOURCE_CANVAS_CARD_WIDTH = 180; export const RESOURCE_CANVAS_CARD_HEIGHT = 128; +export const RESOURCE_CANVAS_IMAGE_CARD_MAX_WIDTH = 220; +export const RESOURCE_CANVAS_IMAGE_CARD_MAX_HEIGHT = 180; +export const RESOURCE_CANVAS_IMAGE_CARD_MIN_SHORT_EDGE = 96; +export const RESOURCE_CANVAS_IMAGE_CARD_MIN_ASPECT_RATIO = 1 / 2; +export const RESOURCE_CANVAS_IMAGE_CARD_MAX_ASPECT_RATIO = 2; export const RESOURCE_CANVAS_COLUMN_GAP = 16; export const RESOURCE_CANVAS_ROW_GAP = 16; export const RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP = 48; @@ -27,6 +32,21 @@ const sectionOrder: ProjectResourceCanvasSection[] = [ 'audio', ]; +export type ResourceCanvasCardSize = { + width: number; + height: number; +}; + +export type ResourceCanvasImageDimensions = { + pixelWidth: number; + pixelHeight: number; +}; + +export type ResourceCanvasCardSizeByResourceId = ReadonlyMap< + string, + ResourceCanvasCardSize +>; + export type ResourceCanvasItem = { id: string; category: ProjectResourceCanvasSection; @@ -34,8 +54,111 @@ export type ResourceCanvasItem = { label: string; mediaType: string; dependencyDepth: number; + cardSize?: ResourceCanvasCardSize; }; +export const DEFAULT_RESOURCE_CANVAS_CARD_SIZE: ResourceCanvasCardSize = { + width: RESOURCE_CANVAS_CARD_WIDTH, + height: RESOURCE_CANVAS_CARD_HEIGHT, +}; + +function validResourceCanvasCardSize( + size: ResourceCanvasCardSize | undefined, +): size is ResourceCanvasCardSize { + return Boolean( + size && + Number.isFinite(size.width) && + Number.isFinite(size.height) && + size.width > 0 && + size.height > 0, + ); +} + +export function resourceCanvasImageCardSize( + dimensions: ResourceCanvasImageDimensions, +): ResourceCanvasCardSize { + if ( + !Number.isSafeInteger(dimensions.pixelWidth) || + !Number.isSafeInteger(dimensions.pixelHeight) || + dimensions.pixelWidth <= 0 || + dimensions.pixelHeight <= 0 + ) { + return DEFAULT_RESOURCE_CANVAS_CARD_SIZE; + } + const aspectRatio = Math.min( + RESOURCE_CANVAS_IMAGE_CARD_MAX_ASPECT_RATIO, + Math.max( + RESOURCE_CANVAS_IMAGE_CARD_MIN_ASPECT_RATIO, + dimensions.pixelWidth / dimensions.pixelHeight, + ), + ); + const maximumBoxAspectRatio = + RESOURCE_CANVAS_IMAGE_CARD_MAX_WIDTH / + RESOURCE_CANVAS_IMAGE_CARD_MAX_HEIGHT; + const width = + aspectRatio >= maximumBoxAspectRatio + ? RESOURCE_CANVAS_IMAGE_CARD_MAX_WIDTH + : Math.max( + RESOURCE_CANVAS_IMAGE_CARD_MIN_SHORT_EDGE, + RESOURCE_CANVAS_IMAGE_CARD_MAX_HEIGHT * aspectRatio, + ); + const height = + aspectRatio >= maximumBoxAspectRatio + ? Math.max( + RESOURCE_CANVAS_IMAGE_CARD_MIN_SHORT_EDGE, + RESOURCE_CANVAS_IMAGE_CARD_MAX_WIDTH / aspectRatio, + ) + : RESOURCE_CANVAS_IMAGE_CARD_MAX_HEIGHT; + return { + width: Math.min(RESOURCE_CANVAS_IMAGE_CARD_MAX_WIDTH, Math.round(width)), + height: Math.min(RESOURCE_CANVAS_IMAGE_CARD_MAX_HEIGHT, Math.round(height)), + }; +} + +export function createResourceCanvasCardSizeByResourceId( + resources: readonly Pick[], + imageDimensionsByResourceId: ReadonlyMap< + string, + ResourceCanvasImageDimensions + >, +): Map { + return new Map( + resources.map((resource) => { + const dimensions = imageDimensionsByResourceId.get(resource.id); + return [ + resource.id, + dimensions && resource.mediaType.toLowerCase().startsWith('image/') + ? resourceCanvasImageCardSize(dimensions) + : DEFAULT_RESOURCE_CANVAS_CARD_SIZE, + ]; + }), + ); +} + +export function resourceCanvasCardSize( + resourceId: string, + cardSizeByResourceId?: ResourceCanvasCardSizeByResourceId, +): ResourceCanvasCardSize { + const size = cardSizeByResourceId?.get(resourceId); + return validResourceCanvasCardSize(size) + ? size + : DEFAULT_RESOURCE_CANVAS_CARD_SIZE; +} + +function resourceCanvasItemCardSizes( + resources: readonly ResourceCanvasItem[], + explicitSizes?: ResourceCanvasCardSizeByResourceId, +): ResourceCanvasCardSizeByResourceId { + const sizes = new Map(); + for (const resource of resources) { + const size = explicitSizes?.get(resource.id) ?? resource.cardSize; + if (validResourceCanvasCardSize(size)) { + sizes.set(resource.id, size); + } + } + return sizes; +} + /** * This is deliberately a display-only projection of the Rust resource graph. * It never becomes a second dependency source: `dependencyDepth` remains on @@ -103,38 +226,55 @@ const RESOURCE_CANVAS_MAX_DEPENDENCY_COLUMN = Math.floor( GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE / RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH, ); +const RESOURCE_CANVAS_MAX_DEPENDENCY_X = + RESOURCE_CANVAS_MAX_DEPENDENCY_COLUMN * RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH; + +type PositionedResourceCanvasRect = { + position: ProjectResourceCanvasPosition; + size: ResourceCanvasCardSize; +}; function positionsOverlap( leftX: number, leftY: number, - right: ProjectResourceCanvasPosition, + leftSize: ResourceCanvasCardSize, + right: PositionedResourceCanvasRect, + columnGap: number, + rowGap: number, ) { return ( - leftX < right.x + RESOURCE_CANVAS_SLOT_WIDTH && - leftX + RESOURCE_CANVAS_SLOT_WIDTH > right.x && - leftY < right.y + RESOURCE_CANVAS_SLOT_HEIGHT && - leftY + RESOURCE_CANVAS_SLOT_HEIGHT > right.y + leftX < right.position.x + right.size.width + columnGap && + leftX + leftSize.width + columnGap > right.position.x && + leftY < right.position.y + right.size.height + rowGap && + leftY + leftSize.height + rowGap > right.position.y ); } class ResourceCanvasOccupancyIndex { - private readonly positionsByCell = new Map< + private readonly rectsByCell = new Map< string, - ProjectResourceCanvasPosition[] + PositionedResourceCanvasRect[] >(); - constructor(positions: ProjectResourceCanvasPosition[]) { + constructor( + positions: readonly ProjectResourceCanvasPosition[], + private readonly cardSizeByResourceId: ResourceCanvasCardSizeByResourceId, + private readonly columnGap = RESOURCE_CANVAS_COLUMN_GAP, + private readonly rowGap = RESOURCE_CANVAS_ROW_GAP, + ) { positions.forEach((position) => this.add(position)); } - private cellKeys(x: number, y: number) { + private cellKeys(x: number, y: number, size: ResourceCanvasCardSize) { + const paddedWidth = size.width + this.columnGap; + const paddedHeight = size.height + this.rowGap; const firstColumn = Math.floor(x / RESOURCE_CANVAS_SLOT_WIDTH); const lastColumn = Math.floor( - (x + RESOURCE_CANVAS_SLOT_WIDTH - 1) / RESOURCE_CANVAS_SLOT_WIDTH, + (x + paddedWidth - 1) / RESOURCE_CANVAS_SLOT_WIDTH, ); const firstRow = Math.floor(y / RESOURCE_CANVAS_SLOT_HEIGHT); const lastRow = Math.floor( - (y + RESOURCE_CANVAS_SLOT_HEIGHT - 1) / RESOURCE_CANVAS_SLOT_HEIGHT, + (y + paddedHeight - 1) / RESOURCE_CANVAS_SLOT_HEIGHT, ); const keys: string[] = []; for (let column = firstColumn; column <= lastColumn; column += 1) { @@ -146,23 +286,31 @@ class ResourceCanvasOccupancyIndex { } add(position: ProjectResourceCanvasPosition) { - for (const key of this.cellKeys(position.x, position.y)) { - const positions = this.positionsByCell.get(key); - if (positions) { - positions.push(position); + const rect = { + position, + size: resourceCanvasCardSize( + position.resourceId, + this.cardSizeByResourceId, + ), + }; + for (const key of this.cellKeys(position.x, position.y, rect.size)) { + const rects = this.rectsByCell.get(key); + if (rects) { + rects.push(rect); } else { - this.positionsByCell.set(key, [position]); + this.rectsByCell.set(key, [rect]); } } } - overlaps(x: number, y: number) { + overlaps(x: number, y: number, resourceId: string) { + const size = resourceCanvasCardSize(resourceId, this.cardSizeByResourceId); const visited = new Set(); - for (const key of this.cellKeys(x, y)) { - for (const position of this.positionsByCell.get(key) ?? []) { - if (!visited.has(position)) { - visited.add(position); - if (positionsOverlap(x, y, position)) { + for (const key of this.cellKeys(x, y, size)) { + for (const rect of this.rectsByCell.get(key) ?? []) { + if (!visited.has(rect.position)) { + visited.add(rect.position); + if (positionsOverlap(x, y, size, rect, this.columnGap, this.rowGap)) { return true; } } @@ -174,6 +322,7 @@ class ResourceCanvasOccupancyIndex { function defaultTypePosition( occupancy: ResourceCanvasOccupancyIndex, + resourceId: string, nextSlot: number, ) { let slot = nextSlot; @@ -182,7 +331,7 @@ function defaultTypePosition( const row = Math.floor(slot / RESOURCE_CANVAS_TYPE_COLUMNS); const x = column * RESOURCE_CANVAS_SLOT_WIDTH; const y = row * RESOURCE_CANVAS_SLOT_HEIGHT; - if (!occupancy.overlaps(x, y)) { + if (!occupancy.overlaps(x, y, resourceId)) { return { point: { x, y }, nextSlot: slot + 1 }; } slot += 1; @@ -237,6 +386,7 @@ type DependencyCluster = { related: boolean; baseY: number; rowCount: number; + height: number; }; const DEPENDENCY_ORDERING_SCAN_ROUNDS = 2; @@ -618,12 +768,67 @@ 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, + ); +} + +function dependencyColumnXByDepth( + resources: readonly ResourceCanvasItem[], + cardSizeByResourceId: ResourceCanvasCardSizeByResourceId, +) { + const widthByDepth = new Map(); + for (const resource of resources) { + const depth = Number.isFinite(resource.dependencyDepth) + ? Math.max(0, Math.round(resource.dependencyDepth)) + : 0; + widthByDepth.set( + depth, + Math.max( + widthByDepth.get(depth) ?? 0, + resourceCanvasCardSize(resource.id, cardSizeByResourceId).width, + ), + ); + } + const xByDepth = new Map(); + let accumulatedWidthDelta = 0; + for (const depth of Array.from(widthByDepth.keys()).sort( + (left, right) => left - right, + )) { + xByDepth.set( + depth, + Math.min( + RESOURCE_CANVAS_MAX_DEPENDENCY_X, + Math.max( + 0, + Math.min(depth, RESOURCE_CANVAS_MAX_DEPENDENCY_COLUMN) * + RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH + + accumulatedWidthDelta, + ), + ), + ); + accumulatedWidthDelta += + (widthByDepth.get(depth) ?? RESOURCE_CANVAS_CARD_WIDTH) - + RESOURCE_CANVAS_CARD_WIDTH; + } + return xByDepth; +} + function dependencyAutomaticPositions( section: ProjectResourceCanvasSection, sectionResources: readonly ResourceCanvasItem[], automaticResources: readonly ResourceCanvasItem[], preservedPositions: readonly ProjectResourceCanvasPosition[], topology: ResourceCanvasLayoutTopology | undefined, + cardSizeByResourceId: ResourceCanvasCardSizeByResourceId, ) { const resourceById = new Map( sectionResources.map((resource) => [resource.id, resource]), @@ -652,7 +857,6 @@ function dependencyAutomaticPositions( .sort(compareStableIdentifier); const clusters: DependencyCluster[] = []; - let nextClusterY = 0; for (const component of relatedComponents) { const resourceIds = component.resourceIds.filter((resourceId) => automaticIds.has(resourceId), @@ -660,42 +864,62 @@ function dependencyAutomaticPositions( if (resourceIds.length === 0) { continue; } - const bucketCounts = new Map(); + const resourceIdsByDepth = new Map(); for (const resourceId of resourceIds) { const depth = resourceById.get(resourceId)?.dependencyDepth ?? 0; - bucketCounts.set(depth, (bucketCounts.get(depth) ?? 0) + 1); + const bucket = resourceIdsByDepth.get(depth) ?? []; + bucket.push(resourceId); + resourceIdsByDepth.set(depth, bucket); } - const cluster = { + clusters.push({ resourceIds, minimumDepth: component.minimumDepth, minimumResourceId: component.minimumResourceId, related: true, - baseY: nextClusterY, - rowCount: Math.max(1, ...bucketCounts.values()), - }; - clusters.push(cluster); - nextClusterY += - cluster.rowCount * RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT + - RESOURCE_CANVAS_CLUSTER_GAP; + 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), + ), + ), + }); } if (isolatedResourceIds.length > 0) { - const isolatedBucketCounts = new Map(); + const resourceIdsByDepth = new Map(); for (const resourceId of isolatedResourceIds) { const depth = resourceById.get(resourceId)?.dependencyDepth ?? 0; - isolatedBucketCounts.set( - depth, - (isolatedBucketCounts.get(depth) ?? 0) + 1, - ); + const bucket = resourceIdsByDepth.get(depth) ?? []; + bucket.push(resourceId); + resourceIdsByDepth.set(depth, bucket); } clusters.push({ resourceIds: isolatedResourceIds, minimumDepth: Number.MAX_SAFE_INTEGER, minimumResourceId: isolatedResourceIds[0] ?? '', related: false, - baseY: nextClusterY, - rowCount: Math.max(1, ...isolatedBucketCounts.values()), + 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), + ), + ), }); } + let nextClusterY = 0; + for (const cluster of clusters) { + cluster.baseY = nextClusterY; + nextClusterY += cluster.height + RESOURCE_CANVAS_CLUSTER_GAP; + } const rankByResourceId = new Map(); for (const position of preservedPositions) { @@ -794,7 +1018,16 @@ function dependencyAutomaticPositions( } } - const occupancy = new ResourceCanvasOccupancyIndex([...preservedPositions]); + const dependencyColumnPositions = dependencyColumnXByDepth( + sectionResources, + cardSizeByResourceId, + ); + const occupancy = new ResourceCanvasOccupancyIndex( + preservedPositions, + cardSizeByResourceId, + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP, + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP, + ); const positions: ProjectResourceCanvasPosition[] = []; for (const cluster of clusters) { const idsByDepth = orderedIdsByClusterAndDepth.get(cluster)!; @@ -805,20 +1038,25 @@ function dependencyAutomaticPositions( ? Math.max(0, Math.round(depth)) : 0; const x = - Math.min(normalizedDepth, RESOURCE_CANVAS_MAX_DEPENDENCY_COLUMN) * - RESOURCE_CANVAS_DEPENDENCY_SLOT_WIDTH; - const bucketBaseY = + dependencyColumnPositions.get(normalizedDepth) ?? + 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( cluster.baseY + - (cluster.related - ? ((cluster.rowCount - resourceIds.length) * - RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT) / - 2 - : 0); - for (let index = 0; index < resourceIds.length; index += 1) { - const resourceId = resourceIds[index]!; - let y = bucketBaseY + index * RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT; - while (occupancy.overlaps(x, y)) { - y += RESOURCE_CANVAS_DEPENDENCY_SLOT_HEIGHT; + (cluster.related ? (cluster.height - bucketHeight) / 2 : 0), + ); + for (const resourceId of resourceIds) { + const size = resourceCanvasCardSize(resourceId, cardSizeByResourceId); + let y = nextY; + while (occupancy.overlaps(x, y, resourceId)) { + y += size.height + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP; } const position: ProjectResourceCanvasPosition = { resourceId, @@ -829,9 +1067,11 @@ function dependencyAutomaticPositions( }; positions.push(position); occupancy.add(position); + nextY = y + size.height + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP; } } } + return positions; } @@ -839,7 +1079,12 @@ export function reconcileResourceCanvasLayout( source: ProjectResourceCanvasLayout, resources: ResourceCanvasItem[], topology?: ResourceCanvasLayoutTopology, + explicitCardSizeByResourceId?: ResourceCanvasCardSizeByResourceId, ): ReconciledResourceCanvasLayout { + const cardSizeByResourceId = resourceCanvasItemCardSizes( + resources, + explicitCardSizeByResourceId, + ); const resourceById = new Map( resources.map((resource) => [resource.id, resource]), ); @@ -885,13 +1130,21 @@ export function reconcileResourceCanvasLayout( newResources, sectionPositions, topology, + cardSizeByResourceId, ); sectionPositions.push(...automaticPositions); continue; } - const occupancy = new ResourceCanvasOccupancyIndex(sectionPositions); + const occupancy = new ResourceCanvasOccupancyIndex( + sectionPositions, + cardSizeByResourceId, + ); for (const resource of newResources) { - const placement = defaultTypePosition(occupancy, nextTypeSlot); + const placement = defaultTypePosition( + occupancy, + resource.id, + nextTypeSlot, + ); const point = placement.point; nextTypeSlot = placement.nextSlot; const position: ProjectResourceCanvasPosition = { @@ -956,21 +1209,30 @@ export function moveResourceCanvasPosition( } export function resourceCanvasSectionExtent( - positions: ProjectResourceCanvasPosition[], + positions: readonly ProjectResourceCanvasPosition[], + cardSizeByResourceId?: ResourceCanvasCardSizeByResourceId, ) { return { width: Math.max( RESOURCE_CANVAS_SECTION_MIN_WIDTH, - ...positions.map( - (position) => - position.x + RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP, + ...positions.map((position) => + Math.ceil( + position.x + + resourceCanvasCardSize(position.resourceId, cardSizeByResourceId) + .width + + RESOURCE_CANVAS_COLUMN_GAP, + ), ), ), height: Math.max( RESOURCE_CANVAS_SECTION_MIN_HEIGHT, - ...positions.map( - (position) => - position.y + RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_ROW_GAP, + ...positions.map((position) => + Math.ceil( + position.y + + resourceCanvasCardSize(position.resourceId, cardSizeByResourceId) + .height + + RESOURCE_CANVAS_ROW_GAP, + ), ), ), }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts index d9dd4ae46..2366fc8f3 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts @@ -18,10 +18,17 @@ export type ProjectResourceCardPreviewKind = | 'version' | 'placeholder'; +export type ProjectResourceImageDimensions = { + pixelWidth: number; + pixelHeight: number; +}; + export type ProjectResourceCardPreviewPayload = { path: string; mediaType: string; byteLen: number; + pixelWidth?: number; + pixelHeight?: number; sourceUrl?: string; content?: string; }; @@ -33,6 +40,29 @@ export type ProjectResourceCardPreviewTransportPayload = Omit< dataUrl?: string; }; +export function projectResourceCardPreviewImageDimensions( + preview: Pick< + ProjectResourceCardPreviewPayload, + 'pixelWidth' | 'pixelHeight' + >, +): ProjectResourceImageDimensions | null { + const { pixelWidth, pixelHeight } = preview; + if (pixelWidth === undefined && pixelHeight === undefined) { + return null; + } + if ( + typeof pixelWidth !== 'number' || + typeof pixelHeight !== 'number' || + !Number.isSafeInteger(pixelWidth) || + !Number.isSafeInteger(pixelHeight) || + pixelWidth <= 0 || + pixelHeight <= 0 + ) { + throw new Error('图片预览像素尺寸无效'); + } + return { pixelWidth, pixelHeight }; +} + export type ProjectResourceCardPreviewCacheEntry = { identity: string; retainedBytes: number; diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts index d176a916a..98d5ad88b 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts @@ -23,6 +23,7 @@ import { PROJECT_RESOURCE_CARD_PREVIEW_QUEUE_LIMIT, projectResourceCardPreviewEvictionIdentities, projectResourceCardPreviewIdentity, + projectResourceCardPreviewImageDimensions, projectResourceCardPreviewKind, type ProjectResourceCardPreviewPayload, type ProjectResourceCardPreviewState, @@ -133,6 +134,8 @@ function materializeProjectResourceCardPreview( path: transport.path, mediaType: transport.mediaType, byteLen: transport.byteLen, + pixelWidth: transport.pixelWidth, + pixelHeight: transport.pixelHeight, content: transport.content, }, retainedBytes: @@ -142,6 +145,7 @@ function materializeProjectResourceCardPreview( objectUrl: null, }; } + const imageDimensions = projectResourceCardPreviewImageDimensions(transport); const prefix = `data:${transport.mediaType};base64,`; if (!transport.dataUrl.startsWith(prefix)) { throw new Error('媒体预览内容无效:data URL 类型与媒体类型不一致'); @@ -166,6 +170,8 @@ function materializeProjectResourceCardPreview( path: transport.path, mediaType: transport.mediaType, byteLen: transport.byteLen, + pixelWidth: imageDimensions?.pixelWidth, + pixelHeight: imageDimensions?.pixelHeight, sourceUrl: objectUrl, }, retainedBytes: blob.size, @@ -640,6 +646,31 @@ export function useProjectResourceCardPreviews(input: { requestPreview, ]); + const imageDimensionsByResourceId = useMemo(() => { + const result = new Map< + string, + NonNullable> + >(); + for (const resource of input.resources) { + const identity = identityByResourceId.get(resource.id); + const state = identity ? previews.get(identity) : undefined; + const kind = projectResourceCardPreviewKind(resource); + if ( + state?.status !== 'loaded' || + (kind !== 'raster-image' && kind !== 'media-image') + ) { + continue; + } + const dimensions = projectResourceCardPreviewImageDimensions( + state.preview, + ); + if (dimensions) { + result.set(resource.id, dimensions); + } + } + return result; + }, [identityByResourceId, input.resources, previews]); + useEffect( () => () => { cancelLocalProjectResourcePreviewScope(scopeIdRef.current); @@ -695,6 +726,7 @@ export function useProjectResourceCardPreviews(input: { return { identityByResourceId, previews, + imageDimensionsByResourceId, idlePreview: IDLE_PROJECT_RESOURCE_CARD_PREVIEW, observePreview, requestPreview, diff --git a/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts b/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts index 17e0c10e9..cb57e9957 100644 --- a/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts +++ b/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts @@ -17,6 +17,7 @@ import type { ProjectResourceCanvasSection } from '../../../packages/shared/src/ import { RESOURCE_CANVAS_CARD_WIDTH, RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP, + type ResourceCanvasCardSize, } from '../src/view/project-development/resourceCanvasLayoutModel'; import { normalizeProjectResourceGraph, @@ -538,6 +539,52 @@ describe('ResourceDependencyOverlay', () => { ); }); + it('routes references from each resource actual card rectangle', async () => { + const graph = graphFixture(); + const cardSizeByResourceId = new Map([ + ['source:one', { width: 220, height: 180 }], + ['target:one', { width: 96, height: 180 }], + ]); + render( + React.createElement( + 'div', + { 'data-testid': 'resource-dependency-overlay' }, + React.createElement( + 'div', + { 'data-resource-section-scroll': 'art' }, + React.createElement( + 'div', + { + 'data-resource-section-plane': 'art', + 'data-resource-section-scale': '1', + }, + React.createElement(ResourceDependencyOverlay, { + graph, + positions: [ + position('source:one', 0, 0), + position('target:one', 268, 0), + ], + section: 'art', + visibleResourceIds: new Set(['source:one', 'target:one']), + cardSizeByResourceId, + }), + ), + ), + ), + ); + + const overlay = await screen.findByTestId('resource-dependency-overlay'); + const reference = await waitFor(() => { + const path = overlay.querySelector( + '[data-edge-kind="asset-reference"]', + ); + expect(path).not.toBeNull(); + return path as SVGPathElement; + }); + expect(reference.getAttribute('data-route-axis')).toBe('horizontal'); + expect(reference.getAttribute('d')).toBe('M 220 90 L 258 90'); + }); + it('keeps a relationship mounted while scrolling partial cards and marks an offscreen continuation', async () => { const graph = graphFixture(); const originalGetBoundingClientRect = diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts index 4502f9f94..c2d5aa42b 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts @@ -6,6 +6,8 @@ import { } from '../../../packages/shared/src/contracts/gameCreationApp'; import { createEmptyResourceCanvasLayout, + createResourceCanvasCardSizeByResourceId, + DEFAULT_RESOURCE_CANVAS_CARD_SIZE, moveResourceCanvasPosition, reconcileResourceCanvasLayout, RESOURCE_CANVAS_CARD_HEIGHT, @@ -13,8 +15,11 @@ import { RESOURCE_CANVAS_COLUMN_GAP, RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP, RESOURCE_CANVAS_DEPENDENCY_ROW_GAP, + type ResourceCanvasCardSize, + resourceCanvasImageCardSize, type ResourceCanvasItem, type ResourceCanvasLayoutTopology, + resourceCanvasSectionExtent, } from '../src/view/project-development/resourceCanvasLayoutModel'; function resource( @@ -582,3 +587,113 @@ describe('resource canvas layout model', () => { expect(performance.now() - startedAt).toBeLessThan(2000); }); }); + +describe('resource canvas variable card geometry', () => { + it.each([ + [1, 1, { width: 180, height: 180 }], + [16, 9, { width: 220, height: 124 }], + [9, 16, { width: 101, height: 180 }], + [2, 3, { width: 120, height: 180 }], + [3, 2, { width: 220, height: 147 }], + [100, 1, { width: 220, height: 110 }], + [1, 100, { width: 96, height: 180 }], + ])( + 'bounds a %d:%d image inside the shared card limits', + (pixelWidth, pixelHeight, expected) => { + expect(resourceCanvasImageCardSize({ pixelWidth, pixelHeight })).toEqual( + expected, + ); + }, + ); + + it('keeps non-images and images without metadata at the fixed fallback size', () => { + const sizes = createResourceCanvasCardSizeByResourceId( + [resource('image', 'art'), resource('document', 'document')], + new Map([ + ['image', { pixelWidth: 1920, pixelHeight: 1080 }], + ['document', { pixelWidth: 1920, pixelHeight: 1080 }], + ]), + ); + + expect(sizes.get('image')).toEqual({ width: 220, height: 124 }); + expect(sizes.get('document')).toEqual(DEFAULT_RESOURCE_CANVAS_CARD_SIZE); + expect( + createResourceCanvasCardSizeByResourceId( + [resource('pending-image', 'art')], + new Map(), + ).get('pending-image'), + ).toEqual(DEFAULT_RESOURCE_CANVAS_CARD_SIZE); + }); + + it('uses actual widths and heights for type placement collision checks', () => { + const resources = [resource('wide-a', 'art'), resource('wide-b', 'art')]; + const cardSizes = new Map([ + ['wide-a', { width: 220, height: 180 }], + ['wide-b', { width: 220, height: 180 }], + ]); + const layout = reconcileResourceCanvasLayout( + createEmptyResourceCanvasLayout('variable-type', 'type'), + resources, + undefined, + cardSizes, + ).layout; + + expect(positionById(layout, 'wide-a')).toMatchObject({ x: 0, y: 0 }); + expect(positionById(layout, 'wide-b')).toMatchObject({ + x: 2 * (RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP), + y: 0, + }); + }); + + it('uses actual dimensions for dependency columns, rows, and section extent', () => { + const resources = [ + resource('source-a', 'art', 0), + resource('source-b', 'art', 0), + resource('target', 'art', 1), + ]; + const cardSizes = new Map([ + ['source-a', { width: 220, height: 180 }], + ['source-b', { width: 96, height: 180 }], + ['target', { width: 180, height: 128 }], + ]); + const layout = reconcileResourceCanvasLayout( + createEmptyResourceCanvasLayout('variable-dependency', 'dependency'), + resources, + undefined, + cardSizes, + ).layout; + + expect(positionById(layout, 'source-a')).toMatchObject({ x: 0, y: 0 }); + expect(positionById(layout, 'source-b')).toMatchObject({ + x: 0, + y: 180 + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP, + }); + expect(positionById(layout, 'target')).toMatchObject({ + x: 220 + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP, + y: 0, + }); + expect(resourceCanvasSectionExtent(layout.positions, cardSizes)).toEqual({ + width: 620, + height: 416, + }); + }); + + it('accepts card sizes directly on resource items for hook integration', () => { + const wide = { + ...resource('wide', 'art', 0), + cardSize: { width: 220, height: 180 }, + }; + const target = { + ...resource('target', 'art', 1), + cardSize: { width: 180, height: 128 }, + }; + const layout = reconcileResourceCanvasLayout( + createEmptyResourceCanvasLayout('item-size', 'dependency'), + [wide, target], + ).layout; + + expect(positionById(layout, 'target').x).toBe( + wide.cardSize.width + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP, + ); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts index 73a26becd..05cba939d 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts @@ -49,6 +49,9 @@ function preview(path: string, mediaType = 'image/png') { path, mediaType, byteLen: 4, + ...(mediaType.startsWith('image/') + ? { pixelWidth: 640, pixelHeight: 360 } + : {}), dataUrl: `data:${mediaType};base64,dGVzdA==`, }; } @@ -125,6 +128,10 @@ describe('useProjectResourceCardPreviews', () => { expect(result.current.previews.get(identity)?.status).toBe('loaded'), ); expect(previewReadCalls(invoke)).toHaveLength(1); + expect(result.current.imageDimensionsByResourceId.get(art.id)).toEqual({ + pixelWidth: 640, + pixelHeight: 360, + }); expect(previewReadCalls(invoke)[0]?.[1]).toMatchObject({ relativePath: art.path, });