UI编辑器补充树级偏移模型
新增 Node offset min/max 数据结构 统一新树默认横向布局入口 增加树级偏移状态更新操作与实施计划
This commit is contained in:
@@ -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。
|
||||
Reference in New Issue
Block a user