UI编辑器补充树级偏移模型

新增 Node offset min/max 数据结构

统一新树默认横向布局入口

增加树级偏移状态更新操作与实施计划
This commit is contained in:
2026-09-14 17:50:05 +08:00
parent cdf2882302
commit 46f8592375
11 changed files with 192 additions and 2 deletions
@@ -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,
@@ -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 {
@@ -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;
@@ -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<Component>,
pub children_display_mode: ChildrenDisplayMode,
pub children: Vec<Node>,
pub offset: NodeOffset,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
@@ -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],
}
}
}
@@ -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<Node>, };
export type Node = { id: NodeId, layout: ControlLayout, metadata: NodeMetadata, component: Component | null, children_display_mode: ChildrenDisplayMode, children: Array<Node>, offset: NodeOffset };
@@ -0,0 +1,4 @@
export type NodeOffset = {
min: [number, number];
max: [number, number];
};
@@ -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,
@@ -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]);
@@ -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 拖动需与画布平移和子节点拖动区分;若命中冲突,优先保留现有子节点手势。
@@ -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。