diff --git a/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx b/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx index 10ff7a0c7..8e69a5476 100644 --- a/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx +++ b/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx @@ -11,6 +11,7 @@ import type { ImageCanvasHostScope, ImageCanvasProjectPort, } from '@genarrative/image-canvas-core'; +import { canvasViewportToWorldTransform } from '@genarrative/image-canvas-react'; import { act, fireEvent, @@ -1125,7 +1126,7 @@ describe('Tauri 素材创作无限画布独立 Surface', () => { expect( document.querySelector('.genarrative-image-canvas__world') ?.style.transform, - ).toBe('translate(-2570px, -2495px) scale(1)'); + ).toBe(canvasViewportToWorldTransform({ x: -2570, y: -2495, scale: 1 })); expect(screen.getByText('已定位当前最终图')).toBeTruthy(); }); @@ -1170,7 +1171,7 @@ describe('Tauri 素材创作无限画布独立 Surface', () => { expect( document.querySelector('.genarrative-image-canvas__world') ?.style.transform, - ).toBe('translate(-2570px, -2495px) scale(1)'); + ).toBe(canvasViewportToWorldTransform({ x: -2570, y: -2495, scale: 1 })); expect(screen.getByText('已定位当前最终图')).toBeTruthy(); fireEvent.click(layerButton); @@ -2062,7 +2063,9 @@ describe('Tauri 素材创作无限画布独立 Surface', () => { clientY: 60, }); await waitFor(() => - expect(world.style.transform).toBe('translate(30px, 30px) scale(0.5)'), + expect(world.style.transform).toBe( + canvasViewportToWorldTransform({ x: 30, y: 30, scale: 0.5 }), + ), ); fireEvent.pointerMove(window, { pointerId: 16, @@ -2070,7 +2073,9 @@ describe('Tauri 素材创作无限画布独立 Surface', () => { clientY: 30, }); await waitFor(() => - expect(world.style.transform).toBe('translate(0px, 0px) scale(0.5)'), + expect(world.style.transform).toBe( + canvasViewportToWorldTransform({ x: 0, y: 0, scale: 0.5 }), + ), ); fireEvent.pointerUp(window, { pointerId: 16, @@ -2315,7 +2320,9 @@ describe('Tauri 素材创作无限画布独立 Surface', () => { const world = document.querySelector( '.genarrative-image-canvas__world', ) as HTMLElement; - expect(world.style.transform).toBe('translate(0px, 0px) scale(0.5)'); + expect(world.style.transform).toBe( + canvasViewportToWorldTransform({ x: 0, y: 0, scale: 0.5 }), + ); fireEvent.wheel(viewport, { deltaX: 4, @@ -2324,7 +2331,9 @@ describe('Tauri 素材创作无限画布独立 Surface', () => { clientY: 80, }); await waitFor(() => - expect(world.style.transform).toBe('translate(-4px, -10px) scale(0.5)'), + expect(world.style.transform).toBe( + canvasViewportToWorldTransform({ x: -4, y: -10, scale: 0.5 }), + ), ); fireEvent.wheel(viewport, { deltaX: 0, @@ -2334,7 +2343,9 @@ describe('Tauri 素材创作无限画布独立 Surface', () => { shiftKey: true, }); await waitFor(() => - expect(world.style.transform).toBe('translate(-14px, -10px) scale(0.5)'), + expect(world.style.transform).toBe( + canvasViewportToWorldTransform({ x: -14, y: -10, scale: 0.5 }), + ), ); fireEvent.wheel(viewport, { deltaX: 0, @@ -2344,7 +2355,13 @@ describe('Tauri 素材创作无限画布独立 Surface', () => { ctrlKey: true, }); await waitFor(() => - expect(world.style.transform).not.toContain('scale(0.5)'), + expect(world.style.transform).toBe( + canvasViewportToWorldTransform({ + x: 100 - 228 * 0.55, + y: 80 - 180 * 0.55, + scale: 0.55, + }), + ), ); }); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 801395146..7328416f7 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7951,10 +7951,10 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-09-05 共享 CanvasWorld 统一超采样与资源页迁移 -- 背景:资源总览页曾在业务层重复实现 world 的 transform、动态矩形尺寸和 2× `zoom`,与网站 / Tauri 共用的 `@genarrative/image-canvas-react` 形成第二套缩放逻辑;超采样还会让 SVG 卡片图标的线条产生视觉加粗。 -- 决策:`CanvasWorld` 默认统一采用 2× 超采样,内容按 `zoom: 2` 排版,外层使用 `viewport.scale / 2`,最终视觉比例仍由 `viewport.scale` 定义。所有调用方继续使用共享默认正方形 `CANVAS_WORLD_SIZE = 12000`,不引入 `worldWidth/worldHeight` 等矩形 API。资源页 `navigationBounds` 只保留布局、fit、依赖线 geometry 和 data 属性用途,不再驱动 world DOM 尺寸。非缩放描边只应用于资源依赖关系线等几何 overlay,静态卡片图标不继承统一 SVG 描边规则;位图维持浏览器默认插值,不全局启用 `pixelated`。 +- 背景:资源总览页曾在业务层重复实现 world 的渲染与尺寸逻辑,需与网站 / Tauri 共用的 `@genarrative/image-canvas-react` 统一。 +- 决策:`CanvasWorld` 的公开契约为逻辑 `viewport`,最终视觉比例由 `viewport.scale` 定义。渲染细节由共享包封装,调用方不得自行换算。所有调用方继续使用共享默认正方形 `CANVAS_WORLD_SIZE = 12000`,不引入 `worldWidth/worldHeight` 等矩形 API。资源页 `navigationBounds` 只保留布局、fit、依赖线 geometry 和 data 属性用途,不再驱动 world DOM 尺寸。非缩放描边只应用于资源依赖关系线等几何 overlay,静态卡片图标不继承统一 SVG 描边规则;位图维持浏览器默认插值,不全局启用 `pixelated`。 - 影响范围:资源总览迁移为直接渲染共享 `CanvasWorld`,卡片控件通过共享 inverse-scale CSS 变量保持屏幕级尺寸;Asset Canvas、UI Editor 和依赖线继续消费相同 viewport 坐标与事件换算,不改变业务状态或持久化合同。 -- 验证方式:共享包与 AGC 定向类型 / 测试、资源页集成测试、`npm run check:encoding` 和 `git diff --check`;重点检查 `scale=1/2` 的最终 transform、SVG 描边、拖拽坐标、关系线端点和默认 `12000 × 12000` world。 +- 验证方式:调用方测试以预期逻辑 `{ x, y, scale }` 调用公开 helper `canvasViewportToWorldTransform`,再与实际 world 的 `style.transform` 比较;不手写渲染换算,不以缩放按钮文案替代 viewport 断言。运行共享包与 AGC 定向类型 / 测试、资源页集成测试、`npm run check:encoding` 和 `git diff --check`,覆盖 SVG 描边、拖拽坐标、关系线端点和默认 world 尺寸。 ## 2026-08-31 AGC 错误报告与诊断上传 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index dff11a4ee..16d1896f1 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -549,7 +549,7 @@ game-project/ - 中间主视窗提供 `资源管理 / 运行` 切换。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled` 或 `aria-disabled`;完成后才允许进入运行表现层。切回资源管理只修改前端展示态,不伪造后端预览暂停结果。 - 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执和已导入附件派生资源,固定按文档、项目版本、美术资源、音乐音效资源分区;未知任务产物不再兜底为版本,任务声明中的未登记音频也不冒充正式音频。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。 - 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar;2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源卡拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供工具栏、工具侧边栏或可拖动标题栏。2026-08-10 起聚焦态以资源元数据、Rust 权威深度、同类型上下游 / 任务流和版本信息为首屏;美术图片与视频只保留卡内本体,不在详情重复放大。安全文档正文与按意图读取的音频控制位于元数据之后;美术编辑、音频编辑 / 替换、版本替换或运行模块仍不在本阶段。 -- 2026-09-05 起所有 `@genarrative/image-canvas-react` 的 `CanvasWorld` 默认使用统一 2× 超采样:内容层按 `zoom: 2` 排版,外层 transform 使用 `viewport.scale / 2`,因此 `viewport.scale` 仍表示最终视觉比例(`1` 为原始大小,`2` 为放大两倍)。world 继续使用共享默认正方形 `CANVAS_WORLD_SIZE = 12000`,不新增矩形尺寸 API;资源页的 `navigationBounds` 只用于布局、fit、关系线 geometry 和数据属性,不能再设置 world DOM 宽高。非缩放描边只应用于资源依赖关系线等几何 overlay,Lucide 等静态卡片图标保持自身正常缩放,避免被统一 SVG 规则压窄;普通位图不强制 `pixelated` 插值。依赖线、卡片拖拽、平移和滚轮锚点继续使用同一逻辑 viewport 坐标。 +- `@genarrative/image-canvas-react` 的 `CanvasWorld` 接收逻辑 `viewport`,`viewport.scale` 表示最终视觉比例;渲染细节由共享包封装。调用方测试将预期逻辑 `{ x, y, scale }` 传入公开 helper `canvasViewportToWorldTransform`,再比较实际 world 的 `style.transform`;不自行换算渲染值,不以缩放按钮文案替代 viewport 断言。world 继续使用共享默认正方形 `CANVAS_WORLD_SIZE = 12000`,不新增矩形尺寸 API;资源页的 `navigationBounds` 只用于布局、fit、关系线 geometry 和数据属性,不能再设置 world DOM 宽高。非缩放描边只应用于资源依赖关系线等几何 overlay,Lucide 等静态卡片图标保持自身正常缩放,避免被统一 SVG 规则压窄;普通位图不强制 `pixelated` 插值。依赖线、卡片拖拽、平移和滚轮锚点继续使用同一逻辑 viewport 坐标。 #### 资源管理串行改造:本体卡、分区缩放与依赖聚类 diff --git a/packages/image-canvas-react/src/CanvasWorld.tsx b/packages/image-canvas-react/src/CanvasWorld.tsx index d5ab20e97..7698a9d23 100644 --- a/packages/image-canvas-react/src/CanvasWorld.tsx +++ b/packages/image-canvas-react/src/CanvasWorld.tsx @@ -4,7 +4,10 @@ import { } from '@genarrative/image-canvas-core'; import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'; -const CANVAS_RENDER_SUPERSAMPLE = 2; +import { + CANVAS_RENDER_SUPERSAMPLE, + canvasViewportToWorldTransform, +} from './canvasWorldRendering'; export function CanvasWorld({ children, @@ -30,7 +33,7 @@ export function CanvasWorld({ ...style, width: worldSize, height: worldSize, - transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewportScale / CANVAS_RENDER_SUPERSAMPLE})`, + transform: canvasViewportToWorldTransform(viewport), '--genarrative-image-canvas-inverse-scale': String(1 / viewportScale), } as CSSProperties } diff --git a/packages/image-canvas-react/src/canvasWorldRendering.ts b/packages/image-canvas-react/src/canvasWorldRendering.ts new file mode 100644 index 000000000..83b1a120a --- /dev/null +++ b/packages/image-canvas-react/src/canvasWorldRendering.ts @@ -0,0 +1,10 @@ +import type { CanvasViewport } from '@genarrative/image-canvas-core'; + +export const CANVAS_RENDER_SUPERSAMPLE = 2; + +/** 将逻辑 viewport 转换为 world 的渲染 transform。 */ +export function canvasViewportToWorldTransform(viewport: CanvasViewport) { + const scale = + Number.isFinite(viewport.scale) && viewport.scale > 0 ? viewport.scale : 1; + return `translate(${viewport.x}px, ${viewport.y}px) scale(${scale / CANVAS_RENDER_SUPERSAMPLE})`; +} diff --git a/packages/image-canvas-react/src/index.ts b/packages/image-canvas-react/src/index.ts index bb4023c2c..ea4d68259 100644 --- a/packages/image-canvas-react/src/index.ts +++ b/packages/image-canvas-react/src/index.ts @@ -2,6 +2,7 @@ export * from './CanvasChrome'; export * from './CanvasPortal'; export * from './CanvasViewport'; export * from './CanvasWorld'; +export { canvasViewportToWorldTransform } from './canvasWorldRendering'; export * from './LayerRenderer'; export * from './Minimap'; export * from './SelectionOverlay'; diff --git a/packages/image-canvas-react/src/sharedCanvasReact.test.tsx b/packages/image-canvas-react/src/sharedCanvasReact.test.tsx index 1be34a166..56ffd5145 100644 --- a/packages/image-canvas-react/src/sharedCanvasReact.test.tsx +++ b/packages/image-canvas-react/src/sharedCanvasReact.test.tsx @@ -6,6 +6,10 @@ import { createRef } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { CanvasWorld } from './CanvasWorld'; +import { + CANVAS_RENDER_SUPERSAMPLE, + canvasViewportToWorldTransform, +} from './canvasWorldRendering'; import { useImageCanvasViewportControls } from './useImageCanvasViewportControls'; function layer(id: string, x: number): CanvasLayer { @@ -64,11 +68,13 @@ describe('shared canvas React lifecycle', () => { const content = screen.getByText('content').parentElement; expect(world?.style.width).toBe('12000px'); expect(world?.style.height).toBe('12000px'); - expect(world?.style.transform).toBe('translate(12px, 24px) scale(1)'); + expect(world?.style.transform).toBe( + canvasViewportToWorldTransform({ x: 12, y: 24, scale: 2 }), + ); expect( world?.style.getPropertyValue('--genarrative-image-canvas-inverse-scale'), ).toBe('0.5'); - expect(content?.style.zoom).toBe('2'); + expect(content?.style.zoom).toBe(String(CANVAS_RENDER_SUPERSAMPLE)); }); it('removes every native listener across repeated mounts', () => { diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 8012511cb..32d777569 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -1,5 +1,6 @@ /* @vitest-environment jsdom */ +import { canvasViewportToWorldTransform } from '@genarrative/image-canvas-react'; import { act, fireEvent, @@ -2412,11 +2413,13 @@ describe('ImageCanvasEditorView', () => { act(() => rafCallbacks.shift()?.(210)); expect(world.style.transform).not.toBe(initialTransform); expect(world.style.transform).not.toBe( - 'translate(-840px, -246.5px) scale(1)', + canvasViewportToWorldTransform({ x: -840, y: -246.5, scale: 1 }), ); act(() => rafCallbacks.shift()?.(420)); - expect(world.style.transform).toBe('translate(-840px, -246.5px) scale(1)'); + expect(world.style.transform).toBe( + canvasViewportToWorldTransform({ x: -840, y: -246.5, scale: 1 }), + ); expect(screen.getByLabelText('发送给画布 Agent')).toBeTruthy(); expect( puzzleLayer.classList.contains('image-canvas-editor__layer--selected'), @@ -2532,7 +2535,9 @@ describe('ImageCanvasEditorView', () => { clientX: 400, clientY: 280, }); - expect(world.style.transform).toBe('translate(-80px, -120px) scale(0.5)'); + expect(world.style.transform).toBe( + canvasViewportToWorldTransform({ x: -80, y: -120, scale: 0.5 }), + ); expect( screen.getByRole('button', { name: '当前缩放比例 100%' }), ).toBeTruthy(); @@ -2567,7 +2572,7 @@ describe('ImageCanvasEditorView', () => { ).toBeTruthy(); expect( (screen.getByTestId('image-canvas-world') as HTMLElement).style.transform, - ).toBe('translate(0px, 0px) scale(0.5)'); + ).toBe(canvasViewportToWorldTransform({ x: 0, y: 0, scale: 0.5 })); }); it('selects multiple canvas layers with shift click', async () => { diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index cc98a733e..ebc0b3b75 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -1,5 +1,6 @@ /* @vitest-environment jsdom */ +import { canvasViewportToWorldTransform } from '@genarrative/image-canvas-react'; import { act, fireEvent, @@ -416,13 +417,15 @@ describe('ImageCanvasWorldView', () => { ).toBe('344px'); expect(screen.getByText('生成中')).toBeTruthy(); - const world = document.querySelector('.image-canvas-editor__world'); + const world = document.querySelector( + '.image-canvas-editor__world', + ); const marquee = world?.querySelector( '.image-canvas-editor__canvas-marquee', ); - expect(world?.getAttribute('style')).toContain( - 'transform: translate(10px, 20px) scale(1.5)', + expect(world?.style.transform).toBe( + canvasViewportToWorldTransform({ x: 10, y: 20, scale: 1.5 }), ); expect((marquee as HTMLElement | null)?.style.width).toBe('100px'); expect((marquee as HTMLElement | null)?.style.height).toBe('120px');