diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs index 0a32724d6..e14781395 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs @@ -3,6 +3,7 @@ use crate::config::load_game_creator_app_config; use crate::ui_editor::commands::utils::{ parse_limited_llm_tool_arguments, request_ui_editor_llm, strict_json_schema, }; +use crate::ui_editor::layout::offset::NodeOffset; use crate::ui_editor::state::{State, UITree}; use platform_llm::{LlmFunctionTool, LlmMessage, LlmRunRequest, LlmToolChoice}; use serde::{Deserialize, Serialize}; @@ -302,6 +303,7 @@ mod materialize { component: None, children_display_mode: ChildrenDisplayMode::Exclusive, children: members.into_iter().map(|member| member.node).collect(), + offset: NodeOffset::default(), }, priority, src_ui_design, diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs index 97e7ba78c..f7acf69e9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs @@ -9,6 +9,7 @@ use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode; use crate::ui_editor::layout::control_layout::ControlLayout; use crate::ui_editor::layout::dimension::UIRect; use crate::ui_editor::layout::node::{Node as LayoutNode, NodeMetadata, NodeSource, StageStatus}; +use crate::ui_editor::layout::offset::NodeOffset; use crate::ui_editor::layout::transform::Transform; use crate::ui_editor::resource::ui_design_image::UIDesignImage; use crate::ui_editor::state::{State, UITree}; @@ -322,6 +323,7 @@ fn convert_node( component: source.component.clone().into_option(), children_display_mode: ChildrenDisplayMode::Stack, children, + offset: NodeOffset::default(), }) } @@ -858,6 +860,7 @@ pub(crate) async fn recognize_ui_impl_with_provider( component: recognition_root.component.into_option(), children_display_mode: ChildrenDisplayMode::Stack, children, + offset: NodeOffset::default(), }; // Tree identity and root identity are assigned by Rust, never chosen by the model. ui_trees.push(UITree { diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/mod.rs index b0932dc49..661e31743 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/mod.rs @@ -2,4 +2,5 @@ pub mod children_display_mode; pub mod control_layout; pub mod dimension; pub mod node; +pub mod offset; pub mod transform; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs index be70cdca6..49e499138 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs @@ -1,6 +1,7 @@ use crate::ui_editor::component::Component; use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode; use crate::ui_editor::layout::control_layout::ControlLayout; +use crate::ui_editor::layout::offset::NodeOffset; use crate::ui_editor::utils::NodeId; use serde::{Deserialize, Serialize}; use ts_rs::TS; @@ -14,6 +15,7 @@ pub struct Node { pub component: Option, pub children_display_mode: ChildrenDisplayMode, pub children: Vec, + pub offset: NodeOffset, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/offset.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/offset.rs new file mode 100644 index 000000000..1bf0e0bcb --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/offset.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct NodeOffset { + pub min: [f32; 2], + pub max: [f32; 2], +} + +impl Default for NodeOffset { + fn default() -> Self { + Self { + min: [0.0, 0.0], + max: [0.0, 0.0], + } + } +} diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/Node.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/Node.ts index bac60a9ca..dac14c50e 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/types/Node.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/Node.ts @@ -4,5 +4,6 @@ import type { Component } from "./Component"; import type { ControlLayout } from "./ControlLayout"; import type { NodeId } from "./NodeId"; import type { NodeMetadata } from "./NodeMetadata"; +import type { NodeOffset } from "./NodeOffset"; -export type Node = { id: NodeId, layout: ControlLayout, metadata: NodeMetadata, component: Component | null, children_display_mode: ChildrenDisplayMode, children: Array, }; +export type Node = { id: NodeId, layout: ControlLayout, metadata: NodeMetadata, component: Component | null, children_display_mode: ChildrenDisplayMode, children: Array, offset: NodeOffset }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/NodeOffset.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/NodeOffset.ts new file mode 100644 index 000000000..373483003 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/NodeOffset.ts @@ -0,0 +1,4 @@ +export type NodeOffset = { + min: [number, number]; + max: [number, number]; +}; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts index a83ab93b4..cb6eecdcd 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts @@ -17,6 +17,7 @@ import type { FontAssetId } from './types/FontAssetId'; import type { Node } from './types/Node'; import type { NodeId } from './types/NodeId'; import type { NodeMetadata } from './types/NodeMetadata'; +import type { NodeOffset } from './types/NodeOffset'; import type { SpriteAsset } from './types/SpriteAsset'; import type { SpriteAssetId } from './types/SpriteAssetId'; import type { SpriteBorder } from './types/SpriteBorder'; @@ -34,6 +35,7 @@ export const EMPTY_UI_EDITOR_STATE: State = { }; const MAX_HISTORY_LENGTH = 100; +export const UI_TREE_PADDING = 48; export type UiEditorOperationFailureReason = | 'locked' @@ -156,6 +158,7 @@ function createPageRoot(state: State): Node { component: null, children_display_mode: 'Stack', children: [], + offset: { min: [0, 0], max: [0, 0] }, }; } @@ -187,9 +190,53 @@ function createHumanNode(state: State): Node { component: null, children_display_mode: 'Stack', children: [], + offset: { min: [0, 0], max: [0, 0] }, }; } +function treeSize(state: State, treeId: UIDesignImageId): [number, number] { + const image = state.ui_design_images[treeId]; + if ( + !image || + !Number.isFinite(image.pixels_per_unit) || + image.pixels_per_unit <= 0 + ) { + throw new Error(`界面图 ${treeId} 缺少合法尺寸`); + } + return [ + image.pixel_size[0] / image.pixels_per_unit, + image.pixel_size[1] / image.pixels_per_unit, + ]; +} + +function deriveTreeOffset(state: State, treeId: UIDesignImageId): NodeOffset { + const [width, height] = treeSize(state, treeId); + const existing = state.ui_trees.filter( + (tree) => tree.src_ui_design !== treeId, + ); + if (existing.length === 0) return { min: [0, 0], max: [width, height] }; + const maxX = Math.max( + ...existing.map( + (tree) => + tree.root.offset.min[0] + treeSize(state, tree.src_ui_design)[0], + ), + ); + const minY = Math.min(...existing.map((tree) => tree.root.offset.min[1])); + return { + min: [maxX + UI_TREE_PADDING, minY], + max: [maxX + UI_TREE_PADDING + width, minY + height], + }; +} + +export function createTree( + state: State, + treeId: UIDesignImageId, + root = createPageRoot(state), +) { + root.offset = deriveTreeOffset(state, treeId); + return { src_ui_design: treeId, root }; +} + function synchronizeDesignImageTrees(state: State): void { const imageIds = new Set(Object.keys(state.ui_design_images)); state.ui_trees = state.ui_trees.filter((tree) => @@ -197,7 +244,7 @@ function synchronizeDesignImageTrees(state: State): void { ); for (const [id] of Object.entries(state.ui_design_images)) { if (!state.ui_trees.some((tree) => tree.src_ui_design === id)) { - state.ui_trees.push({ src_ui_design: id, root: createPageRoot(state) }); + state.ui_trees.push(createTree(state, id)); } } } @@ -951,6 +998,34 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { [commit, guard], ); + const setTreeOffset = useCallback( + ( + treeId: UIDesignImageId, + min: [number, number], + ): UiEditorOperationResult => { + const blocked = guard(); + if (blocked) return blocked; + if (!min.every(Number.isFinite)) return { ok: false, reason: 'invalid' }; + const current = stateRef.current; + const tree = current.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + ); + if (!tree) return { ok: false, reason: 'missing' }; + const size = treeSize(current, treeId); + const next = cloneState(current); + const nextTree = next.ui_trees.find( + (candidate) => candidate.src_ui_design === treeId, + )!; + nextTree.root.offset = { + min: [...min], + max: [min[0] + size[0], min[1] + size[1]], + }; + commit(next); + return { ok: true, value: undefined }; + }, + [commit, guard], + ); + const insertNodeAfter = useCallback( ( treeId: UIDesignImageId, @@ -1464,6 +1539,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { setSpriteBorder, insertNode, insertNodeAfter, + setTreeOffset, deleteNode, setNodeTransform, setNodeLayout, diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx index 79ba4206a..b84ca5252 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx @@ -70,6 +70,7 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) { component: null, children_display_mode: 'Stack', children: uiTrees.map((tree) => tree.root), + offset: { min: [0, 0], max: [0, 0] }, }; }, [uiTrees]); diff --git a/docs/project-memory/plans/【实施计划】UI编辑器多树预览与独立显示开关-2026-09-14.md b/docs/project-memory/plans/【实施计划】UI编辑器多树预览与独立显示开关-2026-09-14.md new file mode 100644 index 000000000..82a49e1f7 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】UI编辑器多树预览与独立显示开关-2026-09-14.md @@ -0,0 +1,35 @@ +# 【实施计划】UI编辑器多树预览与独立显示开关 + +| 字段 | 值 | +| --- | --- | +| Milestone | `docs/project-memory/plans/【里程碑】UI编辑器多树预览与独立显示开关-2026-09-14.md` | +| Status | ready | +| Owner | Codex | + +## 修改边界 + +- 允许修改:AGC UI editor Rust Node/UITree DTO、前端 UI editor state/session、preview 组件、相关测试与当前专题文档。 +- 明确不修改:SpacetimeDB、External v1、代码生成 runtime 语义、用户 `.env`。 + +## 实现顺序 + +1. 更新 Rust Node 与生成的 TS 类型;补齐所有 Node 构造器。 +2. 在状态层集中实现 createTree 与 root offset 更新。 +3. 将 session canvas projection 改为全树投影并提供按 treeId 的节点/根操作。 +4. 重构 preview 为联合画布、多树 wrapper、独立三个显示开关和树级拖动。 +5. 修正左侧选择与 Inspector 的 tree 反查,不让预览选择修改 activeImageId。 +6. 增加/更新定向测试与文档。 + +## 验证命令 + +1. `npm --prefix apps/ai-game-creator-shell test -- --run` +2. `npm --prefix apps/ai-game-creator-shell run typecheck` +3. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml` +4. `npm run check:encoding` +5. `git diff --check` + +## 风险与回滚点 + +- Node DTO 是生成绑定,Rust 与 TS 不一致会阻断编译;每次结构变更后立即生成并检查 diff。 +- 预览选择与 activeImageId 解耦可能暴露 Inspector 当前依赖;通过 treeId 反查补齐。 +- root 拖动需与画布平移和子节点拖动区分;若命中冲突,优先保留现有子节点手势。 diff --git a/docs/project-memory/plans/【里程碑】UI编辑器多树预览与独立显示开关-2026-09-14.md b/docs/project-memory/plans/【里程碑】UI编辑器多树预览与独立显示开关-2026-09-14.md new file mode 100644 index 000000000..6222e284c --- /dev/null +++ b/docs/project-memory/plans/【里程碑】UI编辑器多树预览与独立显示开关-2026-09-14.md @@ -0,0 +1,47 @@ +# 【里程碑】UI编辑器多树预览与独立显示开关 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | approved | +| Date | 2026-09-14 | +| Parent Spec | `docs/technical/【前端架构】UI编辑会话模块边界-2026-08-19.md` | + +## 目标 + +UI 编辑器预览同时展示全部界面树。每棵树使用 root.offset 的 min 作为树级画布位置,支持整树拖动持久化;显示原图、组件和普通框线拆成三个独立开关。 + +## 范围 + +- `Node` 增加包含 min/max 的 offset 数据,读取只使用 min,max 由 min 与源图 logical size 派生。 +- 所有新树统一由 createTree 入口创建,并按现有树最右边界加 padding 横向排列。 +- 首次进入预览和手动适配使用全部树联合边界。 +- 左侧图片选择继续维护 activeImageId;预览选择不改变它,不因选择或拖动自动 fit。 +- root 可拖动但不可 resize;子节点维持现有选择、拖动和 resize 行为。 + +## 不在范围内 + +- 旧数据 migration、offset fallback、自动 revision 修复。 +- 删除/排序后的自动重排。 +- 代码生成 runtime 的树级布局语义。 +- root/max 的独立编辑控件。 + +## 依赖与前置条件 + +- 当前资源均符合最新 Node/UITree schema。 +- 对应界面图提供合法 pixel_size 与 pixels_per_unit。 + +## 验收标准 + +- [ ] 新树默认位于所有现有树右侧并与最小 top 对齐。 +- [ ] 原图与组件随 root 一起移动,拖动结束进入 undo/redo 并可保存恢复。 +- [ ] root 不显示 resize handles,子节点 resize 不回归。 +- [ ] 三个开关默认 showFrame=true、showOriginImage=true、showComponent=false,且互相独立。 +- [ ] 预览选择不修改 activeImageId;左侧图片选择和 Inspector 既有切换保持。 +- [ ] 仅首次进入和手动适配触发联合 fit。 + +## 证据要求 + +- 自动化:UI editor preview、state、transform 相关 Vitest;Tauri Rust 单元测试;类型检查。 +- 运行时:必要时执行 AGC UI 编辑器页面 smoke。 +- 边界:非法缺失 offset 不兜底;树重叠命中顺序;root 禁止 resize。