diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/nodeTransformGeometry.ts b/apps/ai-game-creator-shell/src/features/ui-editor/nodeTransformGeometry.ts new file mode 100644 index 000000000..4a0b09382 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/nodeTransformGeometry.ts @@ -0,0 +1,203 @@ +import type { Node } from './types/Node'; +import type { NodeId } from './types/NodeId'; +import type { Transform } from './types/Transform'; + +export type ResizeAxis = 'horizontal' | 'vertical'; + +export type ResizeHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'; + +export type PageRect = { + min: [number, number]; + max: [number, number]; +}; + +export type NodePageContext = { + node: Node; + rect: PageRect; + parentRect: PageRect; +}; + +const MIN_NODE_SIZE = 1; + +export function pageRectFromSize(size: [number, number]): PageRect { + return { min: [0, 0], max: [...size] }; +} + +export function pageRectSize(rect: PageRect): [number, number] { + return [rect.max[0] - rect.min[0], rect.max[1] - rect.min[1]]; +} + +export function isValidPageRect(rect: PageRect): boolean { + return [...rect.min, ...rect.max].every(Number.isFinite); +} + +export function resolvePageRect( + transform: Transform, + parentRect: PageRect, +): PageRect { + const [parentWidth, parentHeight] = pageRectSize(parentRect); + return { + min: [ + parentRect.min[0] + + parentWidth * transform.anchor_min[0] + + transform.offset_min[0], + parentRect.min[1] + + parentHeight * transform.anchor_min[1] + + transform.offset_min[1], + ], + max: [ + parentRect.min[0] + + parentWidth * transform.anchor_max[0] + + transform.offset_max[0], + parentRect.min[1] + + parentHeight * transform.anchor_max[1] + + transform.offset_max[1], + ], + }; +} + +export function findNodePageContext( + node: Node, + nodeId: NodeId, + parentRect: PageRect, +): NodePageContext | null { + const rect = resolvePageRect(node.layout.transform, parentRect); + if (node.id === nodeId) return { node, rect, parentRect }; + for (const child of node.children) { + const found = findNodePageContext(child, nodeId, rect); + if (found) return found; + } + return null; +} + +export function setOffsetsForPageRect( + transform: Transform, + rect: PageRect, + parentRect: PageRect, +): Transform { + const [parentWidth, parentHeight] = pageRectSize(parentRect); + return { + ...structuredClone(transform), + offset_min: [ + rect.min[0] - parentRect.min[0] - parentWidth * transform.anchor_min[0], + rect.min[1] - parentRect.min[1] - parentHeight * transform.anchor_min[1], + ], + offset_max: [ + rect.max[0] - parentRect.min[0] - parentWidth * transform.anchor_max[0], + rect.max[1] - parentRect.min[1] - parentHeight * transform.anchor_max[1], + ], + }; +} + +export function resolveAnchorPresetTransform( + transform: Transform, + anchorMin: [number, number], + anchorMax: [number, number], + parentSize?: { width: number; height: number }, +): Transform { + const next: Transform = { + ...structuredClone(transform), + anchor_min: [...anchorMin], + anchor_max: [...anchorMax], + }; + if (!parentSize || parentSize.width <= 0 || parentSize.height <= 0) { + return next; + } + return setOffsetsForPageRect( + next, + resolvePageRect( + transform, + pageRectFromSize([parentSize.width, parentSize.height]), + ), + pageRectFromSize([parentSize.width, parentSize.height]), + ); +} + +/** Converts a same-page reparent operation into the target parent's local transform. */ +export function resolveReparentTransform( + transform: Transform, + nodeRect: PageRect, + targetParentRect: PageRect, +): Transform { + return setOffsetsForPageRect(transform, nodeRect, targetParentRect); +} + +/** Keeps each direct child's page rectangle stable while its parent changes rectangle. */ +export function resolveChildrenTransformsForParentRect( + children: readonly Node[], + oldParentRect: PageRect, + newParentRect: PageRect, +): Array<{ id: NodeId; transform: Transform }> { + return children.map((child) => ({ + id: child.id, + transform: setOffsetsForPageRect( + child.layout.transform, + resolvePageRect(child.layout.transform, oldParentRect), + newParentRect, + ), + })); +} + +export function resolveProportionalResizeAxis( + startSize: [number, number], + delta: [number, number], +): ResizeAxis { + const startWidth = Math.max(MIN_NODE_SIZE, startSize[0]); + const startHeight = Math.max(MIN_NODE_SIZE, startSize[1]); + return Math.abs(delta[0]) / startWidth >= Math.abs(delta[1]) / startHeight + ? 'horizontal' + : 'vertical'; +} + +export function resizePageRect( + startRect: PageRect, + handle: ResizeHandle, + delta: [number, number], + keepRatio: boolean, + ratioAxis?: ResizeAxis, +): PageRect { + const next: PageRect = { min: [...startRect.min], max: [...startRect.max] }; + if (handle.includes('w')) { + next.min[0] = Math.min( + startRect.max[0] - MIN_NODE_SIZE, + startRect.min[0] + delta[0], + ); + } else if (handle.includes('e')) { + next.max[0] = Math.max( + startRect.min[0] + MIN_NODE_SIZE, + startRect.max[0] + delta[0], + ); + } + if (handle.includes('n')) { + next.min[1] = Math.min( + startRect.max[1] - MIN_NODE_SIZE, + startRect.min[1] + delta[1], + ); + } else if (handle.includes('s')) { + next.max[1] = Math.max( + startRect.min[1] + MIN_NODE_SIZE, + startRect.max[1] + delta[1], + ); + } + + if (keepRatio && handle.length === 2) { + const [startWidth, startHeight] = pageRectSize(startRect); + const [nextWidth, nextHeight] = pageRectSize(next); + const axis = + ratioAxis ?? + resolveProportionalResizeAxis([startWidth, startHeight], delta); + const ratio = + Math.max(MIN_NODE_SIZE, startWidth) / + Math.max(MIN_NODE_SIZE, startHeight); + const width = + axis === 'horizontal' + ? Math.max(MIN_NODE_SIZE, nextWidth) + : Math.max(MIN_NODE_SIZE, nextHeight) * ratio; + const height = width / ratio; + if (handle.includes('w')) next.min[0] = startRect.max[0] - width; + else next.max[0] = startRect.min[0] + width; + if (handle.includes('n')) next.min[1] = startRect.max[1] - height; + else next.max[1] = startRect.min[1] + height; + } + return next; +} 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 33319f4f7..98edb788e 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 @@ -1,5 +1,14 @@ import { useCallback, useRef, useState } from 'react'; +import { + findNodePageContext, + isValidPageRect, + pageRectFromSize, + pageRectSize, + resolveChildrenTransformsForParentRect, + resolvePageRect, + resolveReparentTransform, +} from './nodeTransformGeometry'; import { validateSpriteBorder } from './spriteBorder'; import type { ChildrenDisplayMode } from './types/ChildrenDisplayMode'; import type { Component } from './types/Component'; @@ -58,11 +67,6 @@ export type NodeTransformOptions = { export type NodeLayoutPatch = Partial; -type Rect = { - min: [number, number]; - size: [number, number]; -}; - type NodeLocation = { node: Node; parent: Node | null; @@ -183,33 +187,6 @@ function synchronizeDesignImageTrees(state: State): void { } } -function resolveNodeRect( - transform: Node['layout']['transform'], - parent: Rect, -): Rect { - const min: [number, number] = [ - parent.min[0] + - parent.size[0] * transform.anchor_min[0] + - transform.offset_min[0], - parent.min[1] + - parent.size[1] * transform.anchor_min[1] + - transform.offset_min[1], - ]; - const max: [number, number] = [ - parent.min[0] + - parent.size[0] * transform.anchor_max[0] + - transform.offset_max[0], - parent.min[1] + - parent.size[1] * transform.anchor_max[1] + - transform.offset_max[1], - ]; - return { min, size: [max[0] - min[0], max[1] - min[1]] }; -} - -function isValidRect(rect: Rect): boolean { - return rect.min.every(Number.isFinite) && rect.size.every(Number.isFinite); -} - function findNodeLocation( node: Node, id: NodeId, @@ -229,54 +206,10 @@ function findNodeLocation( return null; } -function findNodePageRect( - node: Node, - id: NodeId, - parentRect: Rect, -): Rect | null { - const rect = resolveNodeRect(node.layout.transform, parentRect); - if (node.id === id) return rect; - for (const child of node.children) { - const found = findNodePageRect(child, id, rect); - if (found) return found; - } - return null; -} - function containsNode(root: Node, id: NodeId): boolean { return findNodeLocation(root, id) !== null; } -function setOffsetsForPageRect( - transform: Node['layout']['transform'], - pageRect: Rect, - parentPageRect: Rect, -): Node['layout']['transform'] | null { - if (!isValidRect(pageRect) || !isValidRect(parentPageRect)) return null; - const next = structuredClone(transform); - next.offset_min = [ - pageRect.min[0] - - parentPageRect.min[0] - - parentPageRect.size[0] * next.anchor_min[0], - pageRect.min[1] - - parentPageRect.min[1] - - parentPageRect.size[1] * next.anchor_min[1], - ]; - const pageMax: [number, number] = [ - pageRect.min[0] + pageRect.size[0], - pageRect.min[1] + pageRect.size[1], - ]; - next.offset_max = [ - pageMax[0] - - parentPageRect.min[0] - - parentPageRect.size[0] * next.anchor_max[0], - pageMax[1] - - parentPageRect.min[1] - - parentPageRect.size[1] * next.anchor_max[1], - ]; - return next; -} - export type DesignImageInput = { id: UIDesignImageId; image: UIDesignImage; @@ -977,53 +910,38 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { ) { return { ok: false, reason: 'invalid' }; } - const pageRect: Rect = { - min: [0, 0], - size: [ - image.pixel_size[0] / image.pixels_per_unit, - image.pixel_size[1] / image.pixels_per_unit, - ], - }; - const oldParentRect = findNodePageRect( + const pageRect = pageRectFromSize([ + image.pixel_size[0] / image.pixels_per_unit, + image.pixel_size[1] / image.pixels_per_unit, + ]); + const oldParentContext = findNodePageContext( tree.root, location.parent.id, pageRect, ); - const oldNodeRect = findNodePageRect(tree.root, nodeId, pageRect); + const oldNodeContext = findNodePageContext(tree.root, nodeId, pageRect); if ( - !oldParentRect || - !isValidRect(oldParentRect) || - !oldNodeRect || - !isValidRect(oldNodeRect) + !oldParentContext || + !isValidPageRect(oldParentContext.rect) || + !oldNodeContext || + !isValidPageRect(oldNodeContext.rect) ) { return { ok: false, reason: 'invalid' }; } - const newNodeRect = resolveNodeRect(transform, oldParentRect); - if (!isValidRect(newNodeRect)) { - return { ok: false, reason: 'invalid' }; - } - const childTransforms = location.node.children.map((child) => { - const childRect = resolveNodeRect( - child.layout.transform, - oldNodeRect, - ); - return { - id: child.id, - transform: setOffsetsForPageRect( - child.layout.transform, - childRect, - newNodeRect, - ), - }; - }); - if (childTransforms.some((child) => child.transform === null)) { + const newNodeRect = resolvePageRect(transform, oldParentContext.rect); + if (!isValidPageRect(newNodeRect)) { return { ok: false, reason: 'invalid' }; } + const childTransforms = resolveChildrenTransformsForParentRect( + location.node.children, + oldNodeContext.rect, + newNodeRect, + ); for (const child of childTransforms) { const nextChild = nextNode.children.find( (candidate) => candidate.id === child.id, ); - if (!nextChild || !child.transform) { + if (!nextChild) { return { ok: false, reason: 'invalid' }; } nextChild.layout.transform = child.transform; @@ -1332,39 +1250,39 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { if (sourceTreeId === targetTreeId) { const image = current.ui_design_images[sourceTreeId]; if (!image) return { ok: false, reason: 'missing' }; - const logicalSize: Rect = { - min: [0, 0], - size: [ - image.pixel_size[0] / image.pixels_per_unit, - image.pixel_size[1] / image.pixels_per_unit, - ], - }; + const logicalSize = pageRectFromSize([ + image.pixel_size[0] / image.pixels_per_unit, + image.pixel_size[1] / image.pixels_per_unit, + ]); if ( - !isValidRect(logicalSize) || - logicalSize.size.some((value) => value <= 0) + !isValidPageRect(logicalSize) || + pageRectSize(logicalSize).some((value) => value <= 0) ) { return { ok: false, reason: 'invalid' }; } - const pageRect = findNodePageRect(sourceTree.root, nodeId, logicalSize); - const targetPageRect = findNodePageRect( + const pageContext = findNodePageContext( + sourceTree.root, + nodeId, + logicalSize, + ); + const targetContext = findNodePageContext( targetTree.root, targetParentId, logicalSize, ); if ( - !pageRect || - pageRect.size.some((value) => value <= 0) || - !targetPageRect || - targetPageRect.size.some((value) => value <= 0) + !pageContext || + pageRectSize(pageContext.rect).some((value) => value <= 0) || + !targetContext || + pageRectSize(targetContext.rect).some((value) => value <= 0) ) { return { ok: false, reason: 'invalid' }; } - nextTransform = setOffsetsForPageRect( + nextTransform = resolveReparentTransform( source.node.layout.transform, - pageRect, - targetPageRect, + pageContext.rect, + targetContext.rect, ); - if (!nextTransform) return { ok: false, reason: 'invalid' }; } const next = cloneState(current); const nextSourceTree = next.ui_trees.find( diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx index 967a3e08f..e3d744a30 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Transform/TransformEditor.tsx @@ -9,6 +9,12 @@ import { } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; +import { + pageRectFromSize, + pageRectSize, + resolveAnchorPresetTransform, + resolvePageRect, +} from '../../../../../features/ui-editor/nodeTransformGeometry'; import type { Transform } from '../../../../../features/ui-editor/types/Transform'; import { type AnchorMode, AnchorPresetIcon } from './AnchorPresetIcon'; @@ -123,33 +129,14 @@ function applyPreset( preset: AnchorPreset, parentSize?: TransformEditorParentSize, ): Transform { - const next = cloneTransform(transform); const [nextAnchorMinX, nextAnchorMaxX] = modeAnchors(preset.x); const [nextAnchorMinY, nextAnchorMaxY] = modeAnchors(preset.y); - - if (hasUsableParentSize(parentSize)) { - const currentMinX = - parentSize.width * transform.anchor_min[0] + transform.offset_min[0]; - const currentMinY = - parentSize.height * transform.anchor_min[1] + transform.offset_min[1]; - const currentMaxX = - parentSize.width * transform.anchor_max[0] + transform.offset_max[0]; - const currentMaxY = - parentSize.height * transform.anchor_max[1] + transform.offset_max[1]; - - next.offset_min = [ - currentMinX - parentSize.width * nextAnchorMinX, - currentMinY - parentSize.height * nextAnchorMinY, - ]; - next.offset_max = [ - currentMaxX - parentSize.width * nextAnchorMaxX, - currentMaxY - parentSize.height * nextAnchorMaxY, - ]; - } - - next.anchor_min = [nextAnchorMinX, nextAnchorMinY]; - next.anchor_max = [nextAnchorMaxX, nextAnchorMaxY]; - return next; + return resolveAnchorPresetTransform( + transform, + [nextAnchorMinX, nextAnchorMinY], + [nextAnchorMaxX, nextAnchorMaxY], + parentSize, + ); } function resolvedGeometry( @@ -159,20 +146,17 @@ function resolvedGeometry( if (!hasUsableParentSize(parentSize)) { return null; } - const left = - parentSize.width * transform.anchor_min[0] + transform.offset_min[0]; - const top = - parentSize.height * transform.anchor_min[1] + transform.offset_min[1]; - const right = - parentSize.width * transform.anchor_max[0] + transform.offset_max[0]; - const bottom = - parentSize.height * transform.anchor_max[1] + transform.offset_max[1]; + const rect = resolvePageRect( + transform, + pageRectFromSize([parentSize.width, parentSize.height]), + ); + const [width, height] = pageRectSize(rect); return { - left, - top, - width: right - left, - height: bottom - top, - invalid: right < left || bottom < top, + left: rect.min[0], + top: rect.min[1], + width, + height, + invalid: width < 0 || height < 0, }; } diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx index fa405eaa0..9d616253b 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx @@ -23,11 +23,11 @@ import { useState, } from 'react'; +import { findNodePageContext } from '../../../../features/ui-editor/nodeTransformGeometry'; import type { NodeId } from '../../../../features/ui-editor/types/NodeId'; import type { UiEditorPageController } from '../../useUiEditorPage'; import { UiNodeContextMenu } from '../UiNodeContextMenu'; import { ExclusiveChildrenTabs } from './ExclusiveChildrenTabs'; -import { findNodePageContext } from './nodeTransformGeometry'; import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer'; import { useNodeTransformInteraction } from './useNodeTransformInteraction'; diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx index 12beb29c3..b822c853b 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx @@ -3,6 +3,7 @@ import type { PointerEvent as ReactPointerEvent, } from 'react'; +import type { ResizeHandle } from '../../../../features/ui-editor/nodeTransformGeometry'; import type { Node as UiNode } from '../../../../features/ui-editor/types/Node'; import type { NodeId } from '../../../../features/ui-editor/types/NodeId'; import type { UITree } from '../../../../features/ui-editor/types/UITree'; @@ -14,9 +15,8 @@ import { } from '../../../../features/ui-editor/utils/layout/controlLayoutToCss'; import { ComponentView } from './components/ComponentView'; import type { PreviewComponentResources } from './components/types'; -import type { ResizeHandle } from './nodeTransformGeometry'; -export type { ResizeHandle } from './nodeTransformGeometry'; +export type { ResizeHandle } from '../../../../features/ui-editor/nodeTransformGeometry'; export type UiEditorRenderMode = 'editor-overlay' | 'final-preview'; diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/nodeTransformGeometry.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/nodeTransformGeometry.ts deleted file mode 100644 index e28b3b8ce..000000000 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/nodeTransformGeometry.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type { Node as UiNode } from '../../../../features/ui-editor/types/Node'; -import type { ResizeAxis } from './proportionalResize'; -import { resolveProportionalResize } from './proportionalResize'; - -export type ResizeHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'; - -export type PageRect = { - min: [number, number]; - max: [number, number]; -}; - -export type NodePageContext = { - rect: PageRect; - parentRect: PageRect; -}; - -const MIN_NODE_SIZE = 1; - -export function resolvePageRect( - transform: UiNode['layout']['transform'], - parentRect: PageRect, -): PageRect { - return { - min: [ - parentRect.min[0] + - (parentRect.max[0] - parentRect.min[0]) * transform.anchor_min[0] + - transform.offset_min[0], - parentRect.min[1] + - (parentRect.max[1] - parentRect.min[1]) * transform.anchor_min[1] + - transform.offset_min[1], - ], - max: [ - parentRect.min[0] + - (parentRect.max[0] - parentRect.min[0]) * transform.anchor_max[0] + - transform.offset_max[0], - parentRect.min[1] + - (parentRect.max[1] - parentRect.min[1]) * transform.anchor_max[1] + - transform.offset_max[1], - ], - }; -} - -export function findNodePageContext( - node: UiNode, - nodeId: string, - parentRect: PageRect, -): NodePageContext | null { - const rect = resolvePageRect(node.layout.transform, parentRect); - if (node.id === nodeId) return { rect, parentRect }; - for (const child of node.children) { - const found = findNodePageContext(child, nodeId, rect); - if (found) return found; - } - return null; -} - -export function setOffsetsForPageRect( - transform: UiNode['layout']['transform'], - rect: PageRect, - parentRect: PageRect, -): UiNode['layout']['transform'] { - const parentWidth = parentRect.max[0] - parentRect.min[0]; - const parentHeight = parentRect.max[1] - parentRect.min[1]; - const next = structuredClone(transform); - next.offset_min = [ - rect.min[0] - parentRect.min[0] - parentWidth * next.anchor_min[0], - rect.min[1] - parentRect.min[1] - parentHeight * next.anchor_min[1], - ]; - next.offset_max = [ - rect.max[0] - parentRect.min[0] - parentWidth * next.anchor_max[0], - rect.max[1] - parentRect.min[1] - parentHeight * next.anchor_max[1], - ]; - return next; -} - -export function resizePageRect( - startRect: PageRect, - handle: ResizeHandle, - delta: [number, number], - keepRatio: boolean, - ratioAxis?: ResizeAxis, -): PageRect { - const next: PageRect = { - min: [startRect.min[0], startRect.min[1]], - max: [startRect.max[0], startRect.max[1]], - }; - if (handle.includes('w')) { - next.min[0] = Math.min( - startRect.max[0] - MIN_NODE_SIZE, - startRect.min[0] + delta[0], - ); - } else if (handle.includes('e')) { - next.max[0] = Math.max( - startRect.min[0] + MIN_NODE_SIZE, - startRect.max[0] + delta[0], - ); - } - if (handle.includes('n')) { - next.min[1] = Math.min( - startRect.max[1] - MIN_NODE_SIZE, - startRect.min[1] + delta[1], - ); - } else if (handle.includes('s')) { - next.max[1] = Math.max( - startRect.min[1] + MIN_NODE_SIZE, - startRect.max[1] + delta[1], - ); - } - - if (keepRatio && handle.length === 2) { - const { - size: [width, height], - } = resolveProportionalResize({ - startSize: [ - startRect.max[0] - startRect.min[0], - startRect.max[1] - startRect.min[1], - ], - size: [next.max[0] - next.min[0], next.max[1] - next.min[1]], - delta, - axis: ratioAxis, - }); - if (handle.includes('w')) next.min[0] = startRect.max[0] - width; - else next.max[0] = startRect.min[0] + width; - if (handle.includes('n')) next.min[1] = startRect.max[1] - height; - else next.max[1] = startRect.min[1] + height; - } - return next; -} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/proportionalResize.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/proportionalResize.ts deleted file mode 100644 index ee8ea28ca..000000000 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/proportionalResize.ts +++ /dev/null @@ -1,46 +0,0 @@ -export type ResizeAxis = 'horizontal' | 'vertical'; - -const MIN_SIZE = 1; - -export type ProportionalResizeInput = { - startSize: [number, number]; - size: [number, number]; - delta: [number, number]; - axis?: ResizeAxis; -}; - -export type ProportionalResizeResult = { - axis: ResizeAxis; - size: [number, number]; -}; - -export function resolveProportionalResizeAxis( - startSize: [number, number], - delta: [number, number], -): ResizeAxis { - const startWidth = Math.max(MIN_SIZE, startSize[0]); - const startHeight = Math.max(MIN_SIZE, startSize[1]); - return Math.abs(delta[0]) / startWidth >= Math.abs(delta[1]) / startHeight - ? 'horizontal' - : 'vertical'; -} - -export function resolveProportionalResize({ - startSize, - size, - delta, - axis, -}: ProportionalResizeInput): ProportionalResizeResult { - const startWidth = Math.max(MIN_SIZE, startSize[0]); - const startHeight = Math.max(MIN_SIZE, startSize[1]); - const resolvedAxis = axis ?? resolveProportionalResizeAxis(startSize, delta); - const ratio = startWidth / startHeight; - const width = - resolvedAxis === 'horizontal' - ? Math.max(MIN_SIZE, size[0]) - : Math.max(MIN_SIZE, size[1]) * ratio; - return { - axis: resolvedAxis, - size: [width, width / ratio], - }; -} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts index 87bdeb9bb..36d9fda8d 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts @@ -6,21 +6,18 @@ import { useRef, } from 'react'; -import type { Node as UiNode } from '../../../../features/ui-editor/types/Node'; -import type { UITree } from '../../../../features/ui-editor/types/UITree'; -import type { UiEditorPageController } from '../../useUiEditorPage'; import { findNodePageContext, type PageRect, + type ResizeAxis, type ResizeHandle, resizePageRect, - setOffsetsForPageRect, -} from './nodeTransformGeometry'; -import { - type ResizeAxis, resolveProportionalResizeAxis, -} from './proportionalResize'; - + setOffsetsForPageRect, +} from '../../../../features/ui-editor/nodeTransformGeometry'; +import type { Node as UiNode } from '../../../../features/ui-editor/types/Node'; +import type { UITree } from '../../../../features/ui-editor/types/UITree'; +import type { UiEditorPageController } from '../../useUiEditorPage'; type ViewportScale = { scale: number }; type GestureBase = { diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts index 9edc004fb..540fd8973 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts @@ -9,6 +9,11 @@ import { prepareSpriteAssetBatch, } from '../../features/ui-editor/importAdapter'; import { applyMergeResult } from '../../features/ui-editor/merge'; +import { + findNodePageContext, + pageRectFromSize, + pageRectSize, +} from '../../features/ui-editor/nodeTransformGeometry'; import { applyRecognitionResult } from '../../features/ui-editor/recognition'; import type { StageStatusField } from '../../features/ui-editor/stageStatusOverview'; import { collectUiNodeIds } from '../../features/ui-editor/treeUtils'; @@ -436,42 +441,24 @@ export function useUiEditorPage( setNodePreviewVisible, ], ); - const findNodeContext = useCallback(function findNodeContext( - node: UiNode, - nodeId: NodeId, - parentSize: { width: number; height: number }, - ): { node: UiNode; parentSize: { width: number; height: number } } | null { - if (node.id === nodeId) return { node, parentSize }; - const width = - parentSize.width * - (node.layout.transform.anchor_max[0] - - node.layout.transform.anchor_min[0]) + - node.layout.transform.offset_max[0] - - node.layout.transform.offset_min[0]; - const height = - parentSize.height * - (node.layout.transform.anchor_max[1] - - node.layout.transform.anchor_min[1]) + - node.layout.transform.offset_max[1] - - node.layout.transform.offset_min[1]; - for (const child of node.children) { - const found = findNodeContext(child, nodeId, { width, height }); - if (found) return found; - } - return null; - }, []); - const selectedNodeContext = useMemo(() => { if (!activeImage || !treeForActiveImage || !selectedNodeId) return null; const ppu = activeImage.pixels_per_unit; const width = activeImage.pixel_size[0] / ppu; const height = activeImage.pixel_size[1] / ppu; if (!Number.isFinite(width) || !Number.isFinite(height)) return null; - return findNodeContext(treeForActiveImage.root, selectedNodeId, { - width, - height, - }); - }, [activeImage, findNodeContext, selectedNodeId, treeForActiveImage]); + const context = findNodePageContext( + treeForActiveImage.root, + selectedNodeId, + pageRectFromSize([width, height]), + ); + if (!context) return null; + const [parentWidth, parentHeight] = pageRectSize(context.parentRect); + return { + node: context.node, + parentSize: { width: parentWidth, height: parentHeight }, + }; + }, [activeImage, selectedNodeId, treeForActiveImage]); async function importAssets(imported: ImportedAsset[]) { if (!importKind || !projectPath) return; diff --git a/apps/ai-game-creator-shell/tests/nodeTransformGeometry.test.ts b/apps/ai-game-creator-shell/tests/nodeTransformGeometry.test.ts new file mode 100644 index 000000000..cb414da56 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/nodeTransformGeometry.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; + +import { + findNodePageContext, + pageRectFromSize, + resolveAnchorPresetTransform, + resolveChildrenTransformsForParentRect, + resolvePageRect, + resolveReparentTransform, +} from '../src/features/ui-editor/nodeTransformGeometry'; +import type { Node } from '../src/features/ui-editor/types/Node'; +import type { Transform } from '../src/features/ui-editor/types/Transform'; + +const ROOT_TRANSFORM: Transform = { + anchor_min: [0, 0], + anchor_max: [1, 1], + offset_min: [0, 0], + offset_max: [0, 0], +}; + +function node(id: string, transform: Transform, children: Node[] = []): Node { + return { + id, + layout: { transform } as Node['layout'], + metadata: {} as Node['metadata'], + components: [], + children_display_mode: 'Stack', + children, + }; +} + +describe('nodeTransformGeometry', () => { + it('resolves nested context and inverts page offsets at the same seam', () => { + const childTransform: Transform = { + anchor_min: [0.5, 0.5], + anchor_max: [0.5, 0.5], + offset_min: [-10, -15], + offset_max: [30, 25], + }; + const root = node('root', ROOT_TRANSFORM, [ + node( + 'parent', + { + anchor_min: [0, 0], + anchor_max: [0, 0], + offset_min: [20, 30], + offset_max: [220, 130], + }, + [node('child', childTransform)], + ), + ]); + + const context = findNodePageContext( + root, + 'child', + pageRectFromSize([400, 300]), + ); + + expect(context).toMatchObject({ + parentRect: { min: [20, 30], max: [220, 130] }, + rect: { min: [110, 65], max: [150, 105] }, + }); + expect( + resolveReparentTransform( + childTransform, + context!.rect, + context!.parentRect, + ), + ).toEqual(childTransform); + }); + + it('preserves direct child page rectangles when a parent changes', () => { + const child = node('child', { + anchor_min: [0.25, 0], + anchor_max: [0.75, 1], + offset_min: [5, 10], + offset_max: [-10, -15], + }); + const oldParentRect = { + min: [20, 30] as [number, number], + max: [220, 130] as [number, number], + }; + const newParentRect = { + min: [40, 50] as [number, number], + max: [340, 250] as [number, number], + }; + const oldChildRect = resolvePageRect(child.layout.transform, oldParentRect); + + const [{ transform }] = resolveChildrenTransformsForParentRect( + [child], + oldParentRect, + newParentRect, + ); + + expect(resolvePageRect(transform, newParentRect)).toEqual(oldChildRect); + }); + + it('keeps the page rectangle stable when inspector anchors change', () => { + const transform: Transform = { + anchor_min: [0.5, 0.5], + anchor_max: [0.5, 0.5], + offset_min: [-60, -20], + offset_max: [40, 30], + }; + const parentSize = { width: 300, height: 200 }; + const pageRect = pageRectFromSize([parentSize.width, parentSize.height]); + + const next = resolveAnchorPresetTransform( + transform, + [0, 0], + [1, 1], + parentSize, + ); + + expect(resolvePageRect(next, pageRect)).toEqual( + resolvePageRect(transform, pageRect), + ); + }); +}); diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index a1c7e642e..1fa2d8785 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -2,6 +2,12 @@ ## 2026-08-19 UI Editor 节点右键菜单 +## 2026-08-19 UI Editor 节点几何唯一 seam + +`features/ui-editor/nodeTransformGeometry.ts` 是 UI Editor 节点几何的唯一 seam:它统一解析页面 rect 和节点 context、按 anchor / offset 反算 transform、同树 reparent 意图、父节点变换时的直接子节点页面位置保持,以及预览 resize 的等比约束。页面 Inspector、预览 pointer interaction 与状态迁移分别作为该 Module 的 adapter;它们不得再递归计算节点尺寸、各自反算 offset 或复制子节点保持逻辑。跨界面移动仍只保留原局部 transform,不进入 page rect 换算。 + +此 seam 的纯测试覆盖嵌套 context、offset 反算、同树重挂载、子节点页面位置保持与 Inspector anchor preset 保位;几何修复应在此 Module 局部验证后由三个 adapter 复用。 + 左侧 `UI Tree` 与预览画布复用同一个节点右键菜单组件和条目模型,顺序固定为“新增子节点 → 新增同级节点 → 删除节点及子节点”。页面根仅显示“新增子节点”;虚拟超级根不提供菜单。预览仅在“编辑叠加”模式中响应已渲染节点的鼠标右键,右键会选中该节点并阻止嵌套节点事件冒泡;“最终预览”以及画布空白处保留原有行为,空白处不接管浏览器原生菜单。 菜单由页面级 portal 呈现,使用屏幕坐标且在视口边缘内收,避免受预览画布缩放和容器裁切影响。点击菜单外、按 Escape、窗口失焦、滚动或调整窗口大小均关闭菜单。全局 `controller.editor.isLocked` 时,节点仍可选中和查看菜单,但所有结构修改项禁用;状态层的 mutation 校验继续作为最终防线。点击可用项先关闭菜单再执行现有 controller 动作,不改变既有新增或删除后的选择策略。