diff --git a/e2e/mcp/api/component.e2e.test.ts b/e2e/mcp/api/component.e2e.test.ts index f3295d6a3..4b62af0fb 100644 --- a/e2e/mcp/api/component.e2e.test.ts +++ b/e2e/mcp/api/component.e2e.test.ts @@ -239,4 +239,107 @@ describe('MCP Component API', () => { expect(allResult.data).toEqual(expect.arrayContaining(['cc.Label'])); }); }); + + describe('LODGroup 专用操作', () => { + it('should recalculate empty LODGroup bounds to zero values', async () => { + const addResult = await mcpClient.callTool('scene-add-component', { + addComponentInfo: { + nodePath: testNodePath, + component: 'cc.LODGroup', + }, + }); + expect(addResult.code).toBe(200); + expect(addResult.data).toBeDefined(); + if (!addResult.data) return; + + const recalculateResult = await mcpClient.callTool('scene-recalculate-lod-group-bounds', { + options: { + path: addResult.data.path, + record: false, + }, + }); + + expect(recalculateResult.code).toBe(200); + expect(recalculateResult.data).toEqual({ + localBoundaryCenter: { x: 0, y: 0, z: 0 }, + objectSize: 0, + }); + }); + + it('should reject a non-LODGroup component', async () => { + const addResult = await mcpClient.callTool('scene-add-component', { + addComponentInfo: { + nodePath: testNodePath, + component: 'cc.Label', + }, + }); + expect(addResult.code).toBe(200); + expect(addResult.data).toBeDefined(); + if (!addResult.data) return; + + const recalculateResult = await mcpClient.callTool('scene-recalculate-lod-group-bounds', { + options: { + path: addResult.data.path, + record: false, + }, + }); + + expect(recalculateResult.code).toBe(400); + expect(recalculateResult.reason).toContain('component is not cc.LODGroup'); + }); + + it('should insert, query relative height, and erase an LOD level', async () => { + const addResult = await mcpClient.callTool('scene-add-component', { + addComponentInfo: { + nodePath: testNodePath, + component: 'cc.LODGroup', + }, + }); + expect(addResult.code).toBe(200); + expect(addResult.data).toBeDefined(); + if (!addResult.data) return; + + const insertResult = await mcpClient.callTool('scene-insert-lod', { + options: { + path: addResult.data.path, + index: 0, + record: false, + }, + }); + expect(insertResult.code).toBe(200); + expect(insertResult.data?.lodCount).toBe(4); + expect(insertResult.data?.screenUsagePercentages).toHaveLength(4); + expect(insertResult.data?.screenUsagePercentages[0]).toBe(0.25); + + const relativeHeightResult = await mcpClient.callTool('scene-query-lod-group-relative-height', { + options: { + path: addResult.data.path, + }, + }); + expect(relativeHeightResult).toEqual(expect.objectContaining({ + code: 200, + data: 0, + })); + + const secondInsertResult = await mcpClient.callTool('scene-insert-lod', { + options: { + path: addResult.data.path, + index: 1, + record: false, + }, + }); + expect(secondInsertResult.code).toBe(200); + expect(secondInsertResult.data?.lodCount).toBe(5); + + const eraseResult = await mcpClient.callTool('scene-erase-lod', { + options: { + path: addResult.data.path, + index: 1, + record: false, + }, + }); + expect(eraseResult.code).toBe(200); + expect(eraseResult.data).toEqual(insertResult.data); + }); + }); }); diff --git a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap index 7b9cd8e0f..752ee1450 100644 --- a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap +++ b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap @@ -6234,6 +6234,10 @@ export declare interface IComponentService extends IServiceEvents { setProperty(params: ISetPropertyOptions): Promise; query(params: IQueryComponentOptions | string): Promise; queryAll(): Promise; + recalculateLODGroupBounds(options: IRecalculateLODGroupBoundsOptions): Promise; + insertLOD(options: IInsertLODOptions): Promise; + eraseLOD(options: IEraseLODOptions): Promise; + queryLODGroupRelativeHeight(options: IQueryLODGroupRelativeHeightOptions): Promise; reset(params: IQueryComponentOptions): Promise; queryClasses(options?: IQueryClassesOptions): Promise<{ name: string; @@ -6317,6 +6321,11 @@ export declare interface IEngineService extends IServiceEvents { enterAnimationMode(): void; exitAnimationMode(): void; } +export declare interface IEraseLODOptions { + path: string; + index: number; + record?: boolean; +} export declare interface IExecuteComponentMethodOptions { path: string; name: string; @@ -6377,9 +6386,23 @@ export declare interface IGizmoService { hideSelectionRegion(): void; execGizmoMethods(name: string, funcName: string, params?: any[]): any; } +export declare interface IInsertLODOptions { + path: string; + index: number; + screenUsagePercentage?: number; + record?: boolean; +} export declare interface IIsPrefabInstanceParams { nodePath: string; } +export declare interface ILODGroupBoundsResult { + localBoundaryCenter: IVec3; + objectSize: number; +} +export declare interface ILODGroupLevelsResult { + lodCount: number; + screenUsagePercentages: number[]; +} export declare interface ImageAssetUserData { type: ImageImportType; flipVertical?: boolean; @@ -6594,12 +6617,19 @@ export declare interface IQueryClassesOptions { export declare interface IQueryComponentOptions { path: string; } +export declare interface IQueryLODGroupRelativeHeightOptions { + path: string; +} export declare interface IQueryNodeParams extends INodeDumpOptions { path: string; } export declare interface IQueryNodeTreeParams { path?: string; } +export declare interface IRecalculateLODGroupBoundsOptions { + path: string; + record?: boolean; +} export declare interface IRectSnapConfigData { enableSnapping: boolean; snapThreshold: number; diff --git a/src/api/scene/component-schema.ts b/src/api/scene/component-schema.ts index 9a1fb4f32..f5fd1f87e 100644 --- a/src/api/scene/component-schema.ts +++ b/src/api/scene/component-schema.ts @@ -119,6 +119,46 @@ export const SchemaComponent: z.ZodType = SchemaComponentIdentif export const SchemaQueryAllComponentResult = z.array(z.string()).describe('Collection of all components, including built-in and custom components'); // 所有组件集合,包含内置与自定义组件 +// Recalculate LODGroup bounds // 重新计算 LODGroup 包围盒 +export const SchemaRecalculateLODGroupBoundsOptions = z.object({ + path: z.string().min(1).describe('cc.LODGroup component path, e.g. "Root/LOD/cc.LODGroup"'), // cc.LODGroup 组件路径 + record: z.boolean().optional().describe('Whether to record undo, defaults to true'), // 是否记录 undo,默认 true +}).describe('Information required to recalculate cc.LODGroup bounds'); // 重新计算 cc.LODGroup 包围盒所需信息 + +export const SchemaLODGroupBoundsResult = z.object({ + localBoundaryCenter: Vec3Type.describe('Recalculated local boundary center'), // 重算后的局部边界中心 + objectSize: z.number().describe('Recalculated object size'), // 重算后的对象尺寸 +}).describe('Recalculated cc.LODGroup bounds'); // 重算后的 cc.LODGroup 包围盒 + +// Insert an LOD level // 插入 LOD 层级 +export const SchemaInsertLODOptions = z.object({ + path: z.string().min(1).describe('cc.LODGroup component path, e.g. "Root/LOD/cc.LODGroup"'), + index: z.number().int().nonnegative().describe('Insertion index, from 0 through the current lodCount'), + screenUsagePercentage: z.number().gt(0).max(1).optional() + .describe('Screen usage percentage in (0, 1]; omit it to let the engine calculate a value'), + record: z.boolean().optional().describe('Whether to record undo, defaults to true'), +}).describe('Information required to insert an LOD level'); + +// Erase an LOD level // 删除 LOD 层级 +export const SchemaEraseLODOptions = z.object({ + path: z.string().min(1).describe('cc.LODGroup component path, e.g. "Root/LOD/cc.LODGroup"'), + index: z.number().int().nonnegative().describe('Index of the LOD level to erase'), + record: z.boolean().optional().describe('Whether to record undo, defaults to true'), +}).describe('Information required to erase an LOD level'); + +// Query LODGroup relative height // 查询 LODGroup 屏幕相对高度 +export const SchemaQueryLODGroupRelativeHeightOptions = z.object({ + path: z.string().min(1).describe('cc.LODGroup component path, e.g. "Root/LOD/cc.LODGroup"'), +}).describe('Information required to query the LODGroup relative height under the editor camera'); + +export const SchemaLODGroupLevelsResult = z.object({ + lodCount: z.number().int().nonnegative().describe('Current number of LOD levels'), + screenUsagePercentages: z.array(z.number()).describe('Screen usage percentages in LOD order'), +}).describe('Current cc.LODGroup level state'); + +export const SchemaLODGroupRelativeHeightResult = z.number().finite() + .describe('Raw relative height under the current editor camera; the value is not clamped to [0, 1]'); + export const SchemaComponentResult = z.union([SchemaComponent, z.null()]).describe('Interface returned when getting current component information'); // 获取当前组件信息返回的接口 export const SchemaBooleanResult = z.boolean().describe('Interface return result'); // 接口返回结果 @@ -130,4 +170,11 @@ export type TQueryComponentOptions = z.infer; export type TSetPropertyOptions = z.infer; export type TComponentResult = z.infer; export type TQueryAllComponentResult = z.infer; -export type TBooleanResult = z.infer; \ No newline at end of file +export type TRecalculateLODGroupBoundsOptions = z.infer; +export type TLODGroupBoundsResult = z.infer; +export type TInsertLODOptions = z.infer; +export type TEraseLODOptions = z.infer; +export type TQueryLODGroupRelativeHeightOptions = z.infer; +export type TLODGroupLevelsResult = z.infer; +export type TLODGroupRelativeHeightResult = z.infer; +export type TBooleanResult = z.infer; diff --git a/src/api/scene/component.ts b/src/api/scene/component.ts index 25e7da9e9..c1f5f4d4a 100644 --- a/src/api/scene/component.ts +++ b/src/api/scene/component.ts @@ -6,6 +6,13 @@ import { SchemaQueryAllComponentResult, SchemaQueryComponent, SchemaRemoveComponent, + SchemaRecalculateLODGroupBoundsOptions, + SchemaLODGroupBoundsResult, + SchemaInsertLODOptions, + SchemaEraseLODOptions, + SchemaQueryLODGroupRelativeHeightOptions, + SchemaLODGroupLevelsResult, + SchemaLODGroupRelativeHeightResult, TAddComponentInfo, TSetPropertyOptions, @@ -13,6 +20,13 @@ import { TQueryAllComponentResult, TRemoveComponentOptions, TQueryComponentOptions, + TRecalculateLODGroupBoundsOptions, + TLODGroupBoundsResult, + TInsertLODOptions, + TEraseLODOptions, + TQueryLODGroupRelativeHeightOptions, + TLODGroupLevelsResult, + TLODGroupRelativeHeightResult, } from './component-schema'; import { description, param, result, title, tool } from '../decorator/decorator.js'; @@ -134,4 +148,100 @@ export class ComponentApi { }; } } + + /** + * Recalculate LODGroup bounds // 重新计算 LODGroup 包围盒 + */ + @tool('scene-recalculate-lod-group-bounds') + @title('Recalculate LODGroup bounds') // 重新计算 LODGroup 包围盒 + @description('Recalculate localBoundaryCenter and objectSize from all Renderers referenced by a cc.LODGroup. The path must identify a cc.LODGroup component, e.g. "Root/LOD/cc.LODGroup". Returns zero values when no valid Renderer exists.') // 根据 LODGroup 引用的 Renderer 重算边界;路径必须指向 cc.LODGroup 组件 + @result(SchemaLODGroupBoundsResult) + async recalculateLODGroupBounds( + @param(SchemaRecalculateLODGroupBoundsOptions) options: TRecalculateLODGroupBoundsOptions, + ): Promise> { + try { + const bounds = await Scene.Component.recalculateLODGroupBounds(options); + return { + code: COMMON_STATUS.SUCCESS, + data: bounds, + }; + } catch (e) { + return { + code: getCommonErrorStatus(e), + reason: e instanceof Error ? e.message : String(e), + }; + } + } + + /** + * Insert an LOD level // 插入 LOD 层级 + */ + @tool('scene-insert-lod') + @title('Insert LOD level') // 插入 LOD 层级 + @description('Insert an LOD level into a cc.LODGroup. Index must be from 0 through lodCount, at most 8 levels are allowed, and screenUsagePercentage must be in (0, 1]. Omit screenUsagePercentage to let the engine calculate it.') + @result(SchemaLODGroupLevelsResult) + async insertLOD( + @param(SchemaInsertLODOptions) options: TInsertLODOptions, + ): Promise> { + try { + const lodState = await Scene.Component.insertLOD(options); + return { + code: COMMON_STATUS.SUCCESS, + data: lodState, + }; + } catch (e) { + return { + code: getCommonErrorStatus(e), + reason: e instanceof Error ? e.message : String(e), + }; + } + } + + /** + * Erase an LOD level // 删除 LOD 层级 + */ + @tool('scene-erase-lod') + @title('Erase LOD level') // 删除 LOD 层级 + @description('Erase an LOD level from a cc.LODGroup. Index must identify an existing level, and at least one LOD level must remain.') + @result(SchemaLODGroupLevelsResult) + async eraseLOD( + @param(SchemaEraseLODOptions) options: TEraseLODOptions, + ): Promise> { + try { + const lodState = await Scene.Component.eraseLOD(options); + return { + code: COMMON_STATUS.SUCCESS, + data: lodState, + }; + } catch (e) { + return { + code: getCommonErrorStatus(e), + reason: e instanceof Error ? e.message : String(e), + }; + } + } + + /** + * Query LODGroup relative height // 查询 LODGroup 屏幕相对高度 + */ + @tool('scene-query-lod-group-relative-height') + @title('Query LODGroup relative height') // 查询 LODGroup 屏幕相对高度 + @description('Query the raw screen-relative height of a cc.LODGroup under the current editor camera. Supports perspective and orthographic cameras; the result is not clamped to [0, 1].') + @result(SchemaLODGroupRelativeHeightResult) + async queryLODGroupRelativeHeight( + @param(SchemaQueryLODGroupRelativeHeightOptions) options: TQueryLODGroupRelativeHeightOptions, + ): Promise> { + try { + const relativeHeight = await Scene.Component.queryLODGroupRelativeHeight(options); + return { + code: COMMON_STATUS.SUCCESS, + data: relativeHeight, + }; + } catch (e) { + return { + code: getCommonErrorStatus(e), + reason: e instanceof Error ? e.message : String(e), + }; + } + } } diff --git a/src/core/scene/common/component.ts b/src/core/scene/common/component.ts index e4a30419c..1319beb46 100644 --- a/src/core/scene/common/component.ts +++ b/src/core/scene/common/component.ts @@ -2,6 +2,7 @@ import type { Component, Node } from 'cc'; import type { IPropertyValueType, IProperty } from '../@types/public'; import type { IServiceEvents } from '../scene-process/service/core'; import type { IChangeNodeOptions, INodeEvents } from './node'; +import type { IVec3 } from './value-types'; /** * 编辑器使用的组件详细信息,属性值以 IProperty 编码形式呈现, @@ -43,6 +44,66 @@ export interface IQueryComponentOptions { path: string; } +/** + * 重新计算 LODGroup 包围盒的选项 + */ +export interface IRecalculateLODGroupBoundsOptions { + /** cc.LODGroup 组件路径 */ + path: string; + /** 是否记录 undo,默认 true */ + record?: boolean; +} + +/** + * LODGroup 包围盒重算结果 + */ +export interface ILODGroupBoundsResult { + localBoundaryCenter: IVec3; + objectSize: number; +} + +/** + * 插入 LODGroup 层级的选项 + */ +export interface IInsertLODOptions { + /** cc.LODGroup 组件路径 */ + path: string; + /** 插入位置,范围为 0 到当前 lodCount */ + index: number; + /** 屏幕占比,范围为 (0, 1];省略时由引擎自动计算 */ + screenUsagePercentage?: number; + /** 是否记录 undo,默认 true */ + record?: boolean; +} + +/** + * 删除 LODGroup 层级的选项 + */ +export interface IEraseLODOptions { + /** cc.LODGroup 组件路径 */ + path: string; + /** 删除位置,范围为 0 到 lodCount - 1 */ + index: number; + /** 是否记录 undo,默认 true */ + record?: boolean; +} + +/** + * 查询 LODGroup 当前编辑器相机屏占比的选项 + */ +export interface IQueryLODGroupRelativeHeightOptions { + /** cc.LODGroup 组件路径 */ + path: string; +} + +/** + * LODGroup 层级状态 + */ +export interface ILODGroupLevelsResult { + lodCount: number; + screenUsagePercentages: number[]; +} + /** * 编辑器设置组件属性的选项 */ @@ -171,6 +232,36 @@ export interface IComponentService extends IServiceEvents { */ queryAll(): Promise; + /** + * 根据 LOD 层级中的 Renderer 重新计算 cc.LODGroup 的局部包围盒 + * @param options - 重算选项 + * @param options.path - cc.LODGroup 组件路径 + * @param options.record - 是否记录 undo,默认 true + * @returns 重算后的局部边界中心和对象尺寸 + */ + recalculateLODGroupBounds(options: IRecalculateLODGroupBoundsOptions): Promise; + + /** + * 在 cc.LODGroup 中插入一级 LOD + * @param options - 插入选项 + * @returns 插入后的 LOD 层级状态 + */ + insertLOD(options: IInsertLODOptions): Promise; + + /** + * 删除 cc.LODGroup 中的一级 LOD + * @param options - 删除选项 + * @returns 删除后的 LOD 层级状态 + */ + eraseLOD(options: IEraseLODOptions): Promise; + + /** + * 查询 cc.LODGroup 在当前编辑器相机下的屏幕相对高度 + * @param options - 查询选项 + * @returns 原始相对高度;不钳制到 [0, 1] + */ + queryLODGroupRelativeHeight(options: IQueryLODGroupRelativeHeightOptions): Promise; + // ---- 编辑器相关接口 ---- /** diff --git a/src/core/scene/main-process/proxy/component-proxy.ts b/src/core/scene/main-process/proxy/component-proxy.ts index c564fa616..780d8075c 100644 --- a/src/core/scene/main-process/proxy/component-proxy.ts +++ b/src/core/scene/main-process/proxy/component-proxy.ts @@ -3,6 +3,12 @@ import { IRemoveComponentOptions, IQueryComponentOptions, IPublicComponentService, + IRecalculateLODGroupBoundsOptions, + ILODGroupBoundsResult, + IInsertLODOptions, + IEraseLODOptions, + IQueryLODGroupRelativeHeightOptions, + ILODGroupLevelsResult, } from '../../common'; import { IComponentInfo } from '../../common/cli/component'; import { ISetPropertyOptionsInfo } from '../../common/cli/component'; @@ -108,4 +114,20 @@ export const ComponentProxy: IComponentProxy = { queryAll(): Promise { return Rpc.getInstance().request('Component', 'queryAll'); }, + + recalculateLODGroupBounds(options: IRecalculateLODGroupBoundsOptions): Promise { + return Rpc.getInstance().request('Component', 'recalculateLODGroupBounds', [options]); + }, + + insertLOD(options: IInsertLODOptions): Promise { + return Rpc.getInstance().request('Component', 'insertLOD', [options]); + }, + + eraseLOD(options: IEraseLODOptions): Promise { + return Rpc.getInstance().request('Component', 'eraseLOD', [options]); + }, + + queryLODGroupRelativeHeight(options: IQueryLODGroupRelativeHeightOptions): Promise { + return Rpc.getInstance().request('Component', 'queryLODGroupRelativeHeight', [options]); + }, }; diff --git a/src/core/scene/scene-process/engine-bootstrap.test.ts b/src/core/scene/scene-process/engine-bootstrap.test.ts index 74d3dcc5b..d69923f6f 100644 --- a/src/core/scene/scene-process/engine-bootstrap.test.ts +++ b/src/core/scene/scene-process/engine-bootstrap.test.ts @@ -60,8 +60,9 @@ describe('scene-process engine bootstrap', () => { beforeEach(() => { jest.clearAllMocks(); + delete (globalThis as any).__cocosCliDeferredEngineModules; (globalThis as any).System = { - import: jest.fn(async () => ({})), + import: jest.fn(async (id: string) => ({ id })), }; (globalThis as any).fetch = jest .fn() @@ -173,6 +174,14 @@ describe('scene-process engine bootstrap', () => { })); }); + it('caches imported engine modules for synchronous deferred proxies', async () => { + await startup({ serverURL: 'http://localhost:7456' }); + + expect((globalThis as any).__cocosCliDeferredEngineModules['cc/editor/lod-group-utils']).toEqual({ + id: 'cc/editor/lod-group-utils', + }); + }); + it('keeps engine asset settings when querying scene editor settings', async () => { await startup({ serverURL: 'http://localhost:7456' }); diff --git a/src/core/scene/scene-process/engine-bootstrap.ts b/src/core/scene/scene-process/engine-bootstrap.ts index 99c8c8656..2546a8907 100644 --- a/src/core/scene/scene-process/engine-bootstrap.ts +++ b/src/core/scene/scene-process/engine-bootstrap.ts @@ -23,6 +23,8 @@ export const Service = DecoratorService; declare const cc: any; +const DEFERRED_MODULE_CACHE_KEY = '__cocosCliDeferredEngineModules'; + export async function startup(options: { serverURL: string; }) { @@ -58,6 +60,8 @@ export async function startup(options: { 'cc/editor/exotic-animation', 'cc/editor/color-utils', ]; + const deferredModuleCache: Record = Object.create(null); + (globalThis as any)[DEFERRED_MODULE_CACHE_KEY] = deferredModuleCache; // IMPORTANT: We must NOT use import() here because Rollup's // resolveId hook aliases cc/editor/* to a cc re-export stub, @@ -65,7 +69,7 @@ export async function startup(options: { // We use the __moduleImport placeholder which is replaced with SystemJS's module.import(). for (const mod of requiredModules) { try { - await System.import(mod); + deferredModuleCache[mod] = await System.import(mod); } catch (e) { console.error('Failed to load engine module:', mod, 'e:', e); } diff --git a/src/core/scene/scene-process/service/component.ts b/src/core/scene/scene-process/service/component.ts index 79eefb6b5..aaa4891ab 100644 --- a/src/core/scene/scene-process/service/component.ts +++ b/src/core/scene/scene-process/service/component.ts @@ -12,7 +12,13 @@ import { IComponent, IQueryClassesOptions, ISetPropertyOptions, - IUndoRedoResult + IUndoRedoResult, + IRecalculateLODGroupBoundsOptions, + ILODGroupBoundsResult, + IInsertLODOptions, + IEraseLODOptions, + IQueryLODGroupRelativeHeightOptions, + ILODGroupLevelsResult, } from '../../common'; import dumpUtil from './dump'; import compMgr from './component/index'; @@ -29,6 +35,14 @@ import { createUndoId, restoreComponentSnapshotDump, snapshotMapsEqual } from '. import { isUndoApplying } from './undo/applying-state'; import { broadcastAnimationPropertyCommitted } from './animation/property-commit-event'; import { isRootNodePath } from '../../../engine/editor-extends/manager/path-utils'; +import { + requireLODGroup, + queryLODGroupRelativeHeight, + serializeLODGroupBounds, + serializeLODGroupLevels, + validateLODErase, + validateLODInsert, +} from './component/lod-group'; const NodeMgr = EditorExtends.Node; @@ -468,10 +482,11 @@ export class ComponentService extends BaseService implements I private async _recordComponentSnapshot( component: Component, - options: { label: string; type: string }, + options: { label: string; type: string; path?: string; record?: boolean }, mutate: () => Promise, ): Promise { if ( + options.record === false || Service.Undo?.isApplying?.() || Service.Undo?.hasActiveRecording?.(component.node.uuid) || Service.Undo?.hasActiveRecording?.(component.uuid) @@ -479,7 +494,8 @@ export class ComponentService extends BaseService implements I return mutate(); } - const before = this._captureComponentSnapshot(component, options.type); + const snapshotPath = options.path ?? options.type; + const before = this._captureComponentSnapshot(component, snapshotPath); const result = await mutate(); if (!result) { return result; @@ -495,7 +511,7 @@ export class ComponentService extends BaseService implements I return result; } - const after = this._captureComponentSnapshot(latestComponent, options.type); + const after = this._captureComponentSnapshot(latestComponent, snapshotPath); if (this._snapshotMapsEqual(before, after)) { return result; } @@ -896,6 +912,82 @@ export class ComponentService extends BaseService implements I } } + public async recalculateLODGroupBounds( + options: IRecalculateLODGroupBoundsOptions, + ): Promise { + const comp = requireLODGroup(await this.findComponent(options.path), options.path); + + const componentIndex = comp.node.components.indexOf(comp); + await this._recordComponentSnapshot(comp, { + label: 'Recalculate LODGroup Bounds', + type: 'component:recalculate-lod-group-bounds', + path: componentIndex >= 0 ? `__comps__.${componentIndex}` : undefined, + record: options.record, + }, async () => { + comp.recalculateBounds(); + return true; + }); + + return serializeLODGroupBounds(comp); + } + + public async insertLOD(options: IInsertLODOptions): Promise { + const comp = requireLODGroup(await this.findComponent(options.path), options.path); + validateLODInsert(comp, options.index, options.screenUsagePercentage); + + const componentIndex = comp.node.components.indexOf(comp); + await this._recordComponentSnapshot(comp, { + label: 'Insert LOD', + type: 'component:insert-lod', + path: componentIndex >= 0 ? `__comps__.${componentIndex}` : undefined, + record: options.record, + }, async () => { + comp.insertLOD(options.index, options.screenUsagePercentage); + return true; + }); + + return serializeLODGroupLevels(comp); + } + + public async eraseLOD(options: IEraseLODOptions): Promise { + const comp = requireLODGroup(await this.findComponent(options.path), options.path); + validateLODErase(comp, options.index); + + const componentIndex = comp.node.components.indexOf(comp); + await this._recordComponentSnapshot(comp, { + label: 'Erase LOD', + type: 'component:erase-lod', + path: componentIndex >= 0 ? `__comps__.${componentIndex}` : undefined, + record: options.record, + }, async () => { + comp.eraseLOD(options.index); + return true; + }); + + return serializeLODGroupLevels(comp); + } + + public async queryLODGroupRelativeHeight( + options: IQueryLODGroupRelativeHeightOptions, + ): Promise { + const comp = requireLODGroup(await this.findComponent(options.path), options.path); + // ICameraService 仅声明公开能力;场景进程实现额外提供编辑器 Camera 组件。 + const editorCamera = (Service.Camera as any).getCamera?.(); + const renderCamera = editorCamera?.camera; + if (!renderCamera) { + throw new Error('Editor camera is not ready'); + } + + try { + return queryLODGroupRelativeHeight(comp, renderCamera); + } catch (error) { + if (error instanceof Error) { + throw new Error(`${error.message}: ${options.path}`); + } + throw error; + } + } + public async executeMethod(options: IExecuteComponentMethodOptions): Promise { const comp = compMgr.queryFromPath(options.path); if (!comp) { diff --git a/src/core/scene/scene-process/service/component/lod-group.ts b/src/core/scene/scene-process/service/component/lod-group.ts new file mode 100644 index 000000000..aa40f8116 --- /dev/null +++ b/src/core/scene/scene-process/service/component/lod-group.ts @@ -0,0 +1,89 @@ +import { Component, LODGroup } from 'cc'; +import { LODGroupEditorUtility } from 'cc/editor/lod-group-utils'; + +import type { ILODGroupBoundsResult, ILODGroupLevelsResult } from '../../../common'; + +const MAX_LOD_COUNT = 8; +const MIN_LOD_COUNT = 1; + +export function requireLODGroup(component: Component | null, path: string): LODGroup { + if (!component) { + throw new Error(`LODGroup component not found: ${path}`); + } + if (!(component instanceof LODGroup)) { + throw new Error(`Parameter error: component is not cc.LODGroup: ${path}`); + } + return component; +} + +export function validateLODInsert( + lodGroup: LODGroup, + index: number, + screenUsagePercentage?: number, +): void { + if (!Number.isInteger(index) || index < 0 || index > lodGroup.lodCount) { + throw new Error(`Parameter error: LOD insert index must be an integer in [0, ${lodGroup.lodCount}]: ${index}`); + } + if (lodGroup.lodCount >= MAX_LOD_COUNT) { + throw new Error(`Parameter error: LODGroup cannot contain more than ${MAX_LOD_COUNT} LOD levels`); + } + if (screenUsagePercentage !== undefined + && (!Number.isFinite(screenUsagePercentage) || screenUsagePercentage <= 0 || screenUsagePercentage > 1)) { + throw new Error(`Parameter error: screenUsagePercentage must be in (0, 1]: ${screenUsagePercentage}`); + } +} + +export function validateLODErase(lodGroup: LODGroup, index: number): void { + if (!Number.isInteger(index) || index < 0 || index >= lodGroup.lodCount) { + throw new Error(`Parameter error: LOD erase index must be an integer in [0, ${lodGroup.lodCount - 1}]: ${index}`); + } + if (lodGroup.lodCount <= MIN_LOD_COUNT) { + throw new Error(`Parameter error: LODGroup must contain at least ${MIN_LOD_COUNT} LOD level`); + } +} + +export function serializeLODGroupLevels(lodGroup: LODGroup): ILODGroupLevelsResult { + const screenUsagePercentages: number[] = []; + for (let index = 0; index < lodGroup.lodCount; index++) { + const lod = lodGroup.getLOD(index); + if (!lod) { + throw new Error(`LODGroup data is inconsistent: LOD ${index} does not exist`); + } + screenUsagePercentages.push(lod.screenUsagePercentage); + } + return { + lodCount: lodGroup.lodCount, + screenUsagePercentages, + }; +} + +export function serializeLODGroupBounds(lodGroup: LODGroup): ILODGroupBoundsResult { + // 当前 cc 模块声明遗漏了引擎中已存在的 public localBoundaryCenter getter。 + const center = (lodGroup as LODGroup & { + readonly localBoundaryCenter: Readonly<{ x: number; y: number; z: number }>; + }).localBoundaryCenter; + return { + localBoundaryCenter: { + x: center.x, + y: center.y, + z: center.z, + }, + objectSize: lodGroup.objectSize, + }; +} + +type LODRenderCamera = Parameters[1]; + +export function queryLODGroupRelativeHeight(lodGroup: LODGroup, camera: LODRenderCamera): number { + const { x, y, z } = lodGroup.node.scale; + const worldSpaceSize = Math.max(Math.abs(x), Math.abs(y), Math.abs(z)) * lodGroup.objectSize; + if (worldSpaceSize === 0) { + return 0; + } + + const relativeHeight = LODGroupEditorUtility.getRelativeHeight(lodGroup, camera); + if (relativeHeight === null || !Number.isFinite(relativeHeight)) { + throw new Error('Unable to query LODGroup relative height'); + } + return relativeHeight; +} diff --git a/src/core/scene/scene-process/service/gizmo.ts b/src/core/scene/scene-process/service/gizmo.ts index 48d6a3721..17da5d623 100644 --- a/src/core/scene/scene-process/service/gizmo.ts +++ b/src/core/scene/scene-process/service/gizmo.ts @@ -41,6 +41,7 @@ import './gizmo/components/video-player'; import './gizmo/components/web-view'; import './gizmo/components/light-probe-group'; import './gizmo/components/reflection-probe'; +import './gizmo/components/lod-group'; type TGizmoType = 'icon' | 'persistent' | 'component'; diff --git a/src/core/scene/scene-process/service/gizmo/components/lod-group/controller-lod.ts b/src/core/scene/scene-process/service/gizmo/components/lod-group/controller-lod.ts new file mode 100644 index 000000000..b19fd6f71 --- /dev/null +++ b/src/core/scene/scene-process/service/gizmo/components/lod-group/controller-lod.ts @@ -0,0 +1,68 @@ +import { Canvas, Color, Label, Node, Size, UITransform, Vec2, Vec3 } from 'cc'; + +import { RectangleController } from '../../node/rectangle-controller'; +import { create3DNode } from '../../utils/engine-utils'; +import type { IRectangleControllerOption } from '../../utils/defines'; + +const tempVec3 = new Vec3(); + +export default class LODController extends RectangleController { + static readonly LABEL_CONTENT_SIZE = new Size(180, 40); + static readonly FONT_COLOR = new Color(204, 204, 204, 255); + static readonly OUTLINE_COLOR = new Color(5, 5, 5, 220); + static readonly FONT_SIZE = 32; + + private readonly _canvasNode: Node; + private readonly _label: Label; + private readonly _labelTransform: UITransform; + + constructor(rootNode: Node, options: IRectangleControllerOption = {}) { + super(rootNode, options); + + this._canvasNode = create3DNode('LOD Gizmo Canvas'); + const canvas = this._canvasNode.addComponent(Canvas); + (canvas as Canvas & { fitDesignResolution_EDITOR?: () => void }).fitDesignResolution_EDITOR = () => {}; + this._canvasNode.setParent(rootNode); + this.shape.setParent(this._canvasNode); + this.shape.name = 'LOD Gizmo Controller'; + + const labelNode = new Node('LOD Level'); + this._label = labelNode.addComponent(Label); + this._labelTransform = this._label.getComponent(UITransform)!; + this._labelTransform.setContentSize(LODController.LABEL_CONTENT_SIZE); + this._labelTransform.anchorPoint.set(0.5, 1); + this._label.color = LODController.FONT_COLOR; + this._label.fontSize = LODController.FONT_SIZE; + this._label.lineHeight = LODController.FONT_SIZE; + this._label.horizontalAlign = Label.HorizontalAlign.CENTER; + this._label.verticalAlign = Label.VerticalAlign.CENTER; + this._label.enableOutline = true; + this._label.outlineColor = LODController.OUTLINE_COLOR; + this._label.outlineWidth = 2; + labelNode.setParent(this.shape); + } + + destroy(): void { + this.unregisterCameraMoveEvent(); + if (this._canvasNode.isValid) { + this._canvasNode.destroy(); + } + } + + setString(value: string): void { + this._label.string = value; + } + + updateSize(center: Readonly, size: Vec2): void { + super.updateSize(center, size); + tempVec3.set(0, -size.y / 2, 0); + this._label.node.setPosition(tempVec3); + } + + adjustControllerSize(): void { + super.adjustControllerSize(); + const scale = this.getDistScalar() / 4; + tempVec3.set(scale, scale, scale); + this._label.node.setScale(tempVec3); + } +} diff --git a/src/core/scene/scene-process/service/gizmo/components/lod-group/index.ts b/src/core/scene/scene-process/service/gizmo/components/lod-group/index.ts new file mode 100644 index 000000000..05e04c861 --- /dev/null +++ b/src/core/scene/scene-process/service/gizmo/components/lod-group/index.ts @@ -0,0 +1,87 @@ +import { js, LODGroup, Quat, Vec2, Vec3 } from 'cc'; +import { LODGroupEditorUtility } from 'cc/editor/lod-group-utils'; + +import GizmoBase from '../../base/gizmo-base'; +import { registerGizmo } from '../../gizmo-defines'; +import LODController from './controller-lod'; + +function getService(): any { + try { + const { Service } = require('../../../core/decorator'); + return Service; + } catch (error) { + return null; + } +} + +const tempCameraRotation = new Quat(); + +class LODGroupGizmo extends GizmoBase { + private _controller!: LODController; + + init(): void { + this._controller = new LODController(this.getGizmoRoot()); + } + + onEditorCameraMoved(): void { + this.updateController(); + } + + onShow(): void { + this._controller.show(); + this.registerCameraMovedEvent(); + this.updateController(); + } + + onHide(): void { + this.unregisterCameraMoveEvent(); + this._controller.hide(); + } + + destroy(): void { + // GizmoBase.destroy() 会先执行隐藏逻辑;随后再销毁 Canvas,避免 onHide 操作已销毁节点。 + super.destroy(); + this._controller?.destroy(); + } + + onTargetUpdate(): void { + this.updateController(); + } + + onNodeChanged(): void { + this.updateController(); + } + + private updateController(): void { + if (!this._isInitialized) { + return; + } + + const target = this.target; + const editorCamera = getService()?.Camera?.getCamera?.(); + if (!target || !editorCamera?.camera || !editorCamera.node) { + this._controller.hide(); + return; + } + + const { x, y, z } = target.node.scale; + const maxScale = Math.max(Math.abs(x), Math.abs(y), Math.abs(z)); + const size = maxScale * target.objectSize; + this._controller.show(); + this._controller.updateSize(Vec3.ZERO, new Vec2(size, size)); + + const level = LODGroupEditorUtility.getVisibleLOD(target, editorCamera.camera); + this._controller.setString(level === -1 ? 'Culled' : `LOD ${level}`); + this._controller.setPosition(target.node.getWorldPosition()); + editorCamera.node.getWorldRotation(tempCameraRotation); + this._controller.setRotation(tempCameraRotation); + getService()?.Engine?.repaintInEditMode?.(); + } +} + +export const name = js.getClassName(LODGroup); +export const SelectGizmo = LODGroupGizmo; +export const IconGizmo = null; +export const PersistentGizmo = null; + +registerGizmo(name, { SelectGizmo }); diff --git a/src/core/scene/test/component-proxy.testcase.ts b/src/core/scene/test/component-proxy.testcase.ts index dec1b342c..f16034e61 100644 --- a/src/core/scene/test/component-proxy.testcase.ts +++ b/src/core/scene/test/component-proxy.testcase.ts @@ -1253,4 +1253,146 @@ describe('Component Proxy 测试', () => { expect(result).toContain('cc.Button'); }); }); + + describe('19. recalculateLODGroupBounds - 重算 LODGroup 包围盒', () => { + it('空 LODGroup 应返回零值边界', async () => { + const component = await ComponentProxy.add({ + nodePath, + component: 'cc.LODGroup', + }); + try { + const result = await ComponentProxy.recalculateLODGroupBounds({ + path: component.path, + record: false, + }); + + expect(result).toEqual({ + localBoundaryCenter: { x: 0, y: 0, z: 0 }, + objectSize: 0, + }); + } finally { + await ComponentProxy.remove({ path: component.path }); + } + }); + + it('非 LODGroup 组件应拒绝重算', async () => { + const component = await ComponentProxy.add({ + nodePath, + component: 'cc.Label', + }); + try { + await expect(ComponentProxy.recalculateLODGroupBounds({ + path: component.path, + record: false, + })).rejects.toThrow('component is not cc.LODGroup'); + } finally { + await ComponentProxy.remove({ path: component.path }); + } + }); + }); + + describe('20. LODGroup level operations - LOD 层级操作', () => { + it('inserts and erases LOD levels with serialized state', async () => { + const component = await ComponentProxy.add({ + nodePath, + component: 'cc.LODGroup', + }); + try { + const first = await ComponentProxy.insertLOD({ + path: component.path, + index: 0, + record: false, + }); + expect(first.lodCount).toBe(4); + expect(first.screenUsagePercentages).toHaveLength(4); + expect(first.screenUsagePercentages[0]).toBe(0.25); + + const second = await ComponentProxy.insertLOD({ + path: component.path, + index: 1, + record: false, + }); + expect(second.lodCount).toBe(5); + expect(second.screenUsagePercentages).toHaveLength(5); + + const erased = await ComponentProxy.eraseLOD({ + path: component.path, + index: 1, + record: false, + }); + expect(erased).toEqual(first); + } finally { + await ComponentProxy.remove({ path: component.path }); + } + }); + + it('rejects invalid indices, zero screen usage, and erasing the final level', async () => { + const component = await ComponentProxy.add({ + nodePath, + component: 'cc.LODGroup', + }); + try { + await expect(ComponentProxy.insertLOD({ + path: component.path, + index: 4, + record: false, + })).rejects.toThrow('LOD insert index'); + await expect(ComponentProxy.insertLOD({ + path: component.path, + index: 0, + screenUsagePercentage: 0, + record: false, + })).rejects.toThrow('screenUsagePercentage must be in (0, 1]'); + + await ComponentProxy.eraseLOD({ path: component.path, index: 2, record: false }); + await ComponentProxy.eraseLOD({ path: component.path, index: 1, record: false }); + await expect(ComponentProxy.eraseLOD({ + path: component.path, + index: 0, + record: false, + })).rejects.toThrow('at least 1 LOD level'); + + for (let index = 1; index < 8; index++) { + await ComponentProxy.insertLOD({ + path: component.path, + index, + record: false, + }); + } + await expect(ComponentProxy.insertLOD({ + path: component.path, + index: 8, + record: false, + })).rejects.toThrow('more than 8 LOD levels'); + } finally { + await ComponentProxy.remove({ path: component.path }); + } + }); + + it('queries raw relative height from the current editor camera', async () => { + const component = await ComponentProxy.add({ + nodePath, + component: 'cc.LODGroup', + }); + try { + const zeroSizeResult = await ComponentProxy.queryLODGroupRelativeHeight({ + path: component.path, + }); + expect(zeroSizeResult).toBe(0); + + await ComponentProxy.setProperty({ + componentPath: component.path, + properties: { objectSize: 2 }, + record: false, + }); + const result = await ComponentProxy.queryLODGroupRelativeHeight({ + path: component.path, + }); + expect(Number.isFinite(result)).toBe(true); + expect(result).toBeGreaterThan(0); + } finally { + await ComponentProxy.remove({ path: component.path }); + } + }); + }); }); diff --git a/src/core/scene/test/gizmo-reload-events.test.ts b/src/core/scene/test/gizmo-reload-events.test.ts index 7ffee304d..9191793fb 100644 --- a/src/core/scene/test/gizmo-reload-events.test.ts +++ b/src/core/scene/test/gizmo-reload-events.test.ts @@ -140,6 +140,7 @@ jest.mock('../scene-process/service/gizmo/components/video-player', () => ({})); jest.mock('../scene-process/service/gizmo/components/web-view', () => ({})); jest.mock('../scene-process/service/gizmo/components/light-probe-group', () => ({})); jest.mock('../scene-process/service/gizmo/components/reflection-probe', () => ({})); +jest.mock('../scene-process/service/gizmo/components/lod-group', () => ({})); describe('Gizmo editor lifecycle', () => { afterEach(() => { diff --git a/src/core/scene/test/lod-group-gizmo.test.ts b/src/core/scene/test/lod-group-gizmo.test.ts new file mode 100644 index 000000000..edbdbd2fe --- /dev/null +++ b/src/core/scene/test/lod-group-gizmo.test.ts @@ -0,0 +1,245 @@ +export {}; + +const mockGetVisibleLOD = jest.fn(); +const mockRegisterGizmo = jest.fn(); +const mockControllerInstances: any[] = []; +const mockCameraNode = { + on: jest.fn(), + off: jest.fn(), + getWorldRotation: jest.fn(), +}; +const mockEditorCamera = { + camera: {}, + node: mockCameraNode, +}; +const mockService = { + Camera: { + getCamera: jest.fn(() => mockEditorCamera), + }, + Engine: { + repaintInEditMode: jest.fn(), + }, + Gizmo: { + gizmoRootNode: {}, + }, +}; + +jest.mock('cc', () => { + class MockComponent { } + class MockLODGroup extends MockComponent { } + class MockQuat { } + class MockVec2 { + constructor(public x = 0, public y = 0) { } + } + class MockVec3 { + static readonly ZERO = new MockVec3(); + + constructor(public x = 0, public y = 0, public z = 0) { } + } + + return { + Component: MockComponent, + LODGroup: MockLODGroup, + Quat: MockQuat, + Vec2: MockVec2, + Vec3: MockVec3, + js: { + getClassName: jest.fn((ctor: unknown) => ctor === MockLODGroup ? 'cc.LODGroup' : ''), + }, + }; +}); + +jest.mock('cc/editor/lod-group-utils', () => ({ + LODGroupEditorUtility: { + getVisibleLOD: (...args: unknown[]) => mockGetVisibleLOD(...args), + }, +}), { virtual: true }); + +jest.mock('../scene-process/service/core/decorator', () => ({ + Service: mockService, +})); + +jest.mock('../scene-process/service/gizmo/gizmo-defines', () => ({ + registerGizmo: (...args: unknown[]) => mockRegisterGizmo(...args), +})); + +jest.mock('../scene-process/service/gizmo/base/gizmo-base', () => ({ + __esModule: true, + default: class MockGizmoBase { + protected _isInitialized = false; + private _hidden = true; + + constructor(public target: unknown) { } + + protected getGizmoRoot(): unknown { + return mockService.Gizmo.gizmoRootNode; + } + + public initialize(): void { + if (this._isInitialized) return; + (this as any).init?.(); + this._isInitialized = true; + } + + public show(): void { + if (!this._hidden) return; + this.initialize(); + (this as any).onShow?.(); + this._hidden = false; + } + + public hide(): void { + if (this._hidden) return; + (this as any).onHide?.(); + this._hidden = true; + } + + public destroy(): void { + (this as any).onDestroy?.(); + this.hide(); + this.target = null; + } + + public registerCameraMovedEvent(): void { + mockService.Camera.getCamera()?.node?.on('transform-changed', (this as any).onEditorCameraMoved, this); + } + + public unregisterCameraMoveEvent(): void { + mockService.Camera.getCamera()?.node?.off('transform-changed', (this as any).onEditorCameraMoved, this); + } + }, +})); + +jest.mock('../scene-process/service/gizmo/components/lod-group/controller-lod', () => ({ + __esModule: true, + default: class MockLODController { + public show = jest.fn(); + public hide = jest.fn(); + public updateSize = jest.fn(); + public setString = jest.fn(); + public setPosition = jest.fn(); + public setRotation = jest.fn(); + public destroy = jest.fn(); + + constructor(public rootNode: unknown) { + mockControllerInstances.push(this); + } + }, +})); + +const { LODGroup, Vec2, Vec3 } = require('cc'); +const lodGroupGizmoModule = require('../scene-process/service/gizmo/components/lod-group'); + +describe('LODGroup Gizmo', () => { + beforeEach(() => { + mockGetVisibleLOD.mockReset(); + mockCameraNode.on.mockClear(); + mockCameraNode.off.mockClear(); + mockCameraNode.getWorldRotation.mockClear(); + mockService.Camera.getCamera.mockReset(); + mockService.Camera.getCamera.mockReturnValue(mockEditorCamera); + mockService.Engine.repaintInEditMode.mockClear(); + mockControllerInstances.length = 0; + }); + + it('registers the selected Gizmo for cc.LODGroup', () => { + expect(lodGroupGizmoModule.name).toBe('cc.LODGroup'); + expect(mockRegisterGizmo).toHaveBeenCalledWith('cc.LODGroup', { + SelectGizmo: lodGroupGizmoModule.SelectGizmo, + }); + }); + + it('ignores target and node updates before the controller is initialized', () => { + const target = Object.assign(new LODGroup(), { + objectSize: 1, + node: { + scale: { x: 1, y: 1, z: 1 }, + getWorldPosition: jest.fn(), + }, + }); + const gizmo = new lodGroupGizmoModule.SelectGizmo(target); + + expect(() => gizmo.onTargetUpdate()).not.toThrow(); + expect(() => gizmo.onNodeChanged()).not.toThrow(); + expect(mockControllerInstances).toHaveLength(0); + expect(mockService.Camera.getCamera).not.toHaveBeenCalled(); + }); + + it('shows the current LOD and refreshes from camera or node changes', () => { + const worldPosition = new Vec3(10, 20, 30); + const target = Object.assign(new LODGroup(), { + objectSize: 3, + node: { + scale: { x: -2, y: 1, z: 0.5 }, + getWorldPosition: jest.fn(() => worldPosition), + }, + }); + mockGetVisibleLOD.mockReturnValue(2); + + const gizmo = new lodGroupGizmoModule.SelectGizmo(target); + gizmo.show(); + gizmo.show(); + + const controller = mockControllerInstances[0]; + expect(controller.rootNode).toBe(mockService.Gizmo.gizmoRootNode); + expect(controller.updateSize).toHaveBeenCalledWith(Vec3.ZERO, new Vec2(6, 6)); + expect(controller.setString).toHaveBeenLastCalledWith('LOD 2'); + expect(controller.setPosition).toHaveBeenLastCalledWith(worldPosition); + expect(mockGetVisibleLOD).toHaveBeenLastCalledWith(target, mockEditorCamera.camera); + expect(mockCameraNode.on).toHaveBeenCalledTimes(1); + + mockGetVisibleLOD.mockReturnValue(-1); + gizmo.onEditorCameraMoved(); + expect(controller.setString).toHaveBeenLastCalledWith('Culled'); + + target.node.scale.x = 4; + gizmo.onNodeChanged(); + expect(controller.updateSize).toHaveBeenLastCalledWith(Vec3.ZERO, new Vec2(12, 12)); + + gizmo.hide(); + gizmo.hide(); + expect(mockCameraNode.off).toHaveBeenCalledTimes(1); + expect(controller.hide).toHaveBeenCalled(); + }); + + it('hides the controller while the editor camera is unavailable', () => { + const target = Object.assign(new LODGroup(), { + objectSize: 1, + node: { + scale: { x: 1, y: 1, z: 1 }, + getWorldPosition: jest.fn(), + }, + }); + mockService.Camera.getCamera.mockReturnValue(null as any); + + const gizmo = new lodGroupGizmoModule.SelectGizmo(target); + gizmo.show(); + + const controller = mockControllerInstances[0]; + expect(controller.hide).toHaveBeenCalled(); + expect(controller.setString).not.toHaveBeenCalled(); + expect(mockService.Engine.repaintInEditMode).not.toHaveBeenCalled(); + }); + + it('destroys the controller resources when the Gizmo is destroyed', () => { + const target = Object.assign(new LODGroup(), { + objectSize: 1, + node: { + scale: { x: 1, y: 1, z: 1 }, + getWorldPosition: jest.fn(() => new Vec3()), + }, + }); + mockGetVisibleLOD.mockReturnValue(0); + + const gizmo = new lodGroupGizmoModule.SelectGizmo(target); + gizmo.show(); + const controller = mockControllerInstances[0]; + + gizmo.destroy(); + + expect(mockCameraNode.off).toHaveBeenCalledTimes(1); + expect(controller.hide).toHaveBeenCalled(); + expect(controller.destroy).toHaveBeenCalledTimes(1); + expect(gizmo.target).toBeNull(); + }); +}); diff --git a/src/core/scene/test/service-core/message-callsite.test.ts b/src/core/scene/test/service-core/message-callsite.test.ts index a30a5bcb2..5765eb1db 100644 --- a/src/core/scene/test/service-core/message-callsite.test.ts +++ b/src/core/scene/test/service-core/message-callsite.test.ts @@ -164,6 +164,7 @@ jest.mock('../../scene-process/service/gizmo/components/video-player', () => ({} jest.mock('../../scene-process/service/gizmo/components/web-view', () => ({})); jest.mock('../../scene-process/service/gizmo/components/light-probe-group', () => ({})); jest.mock('../../scene-process/service/gizmo/components/reflection-probe', () => ({})); +jest.mock('../../scene-process/service/gizmo/components/lod-group', () => ({})); jest.mock('../../scene-process/service/dump', () => ({ __esModule: true, diff --git a/src/core/scene/test/undo-redo.testcase.ts b/src/core/scene/test/undo-redo.testcase.ts index ee1279952..39f8970e1 100644 --- a/src/core/scene/test/undo-redo.testcase.ts +++ b/src/core/scene/test/undo-redo.testcase.ts @@ -208,6 +208,18 @@ const Component = { return true; }, reset: (params: { path: string }) => request('Component', 'reset', [params]), + recalculateLODGroupBounds: (params: { path: string; record?: boolean }) => request<{ + localBoundaryCenter: { x: number; y: number; z: number }; + objectSize: number; + }>('Component', 'recalculateLODGroupBounds', [params]), + insertLOD: (params: { path: string; index: number; screenUsagePercentage?: number; record?: boolean }) => request<{ + lodCount: number; + screenUsagePercentages: number[]; + }>('Component', 'insertLOD', [params]), + eraseLOD: (params: { path: string; index: number; record?: boolean }) => request<{ + lodCount: number; + screenUsagePercentages: number[]; + }>('Component', 'eraseLOD', [params]), }; const Prefab = { @@ -248,6 +260,12 @@ async function queryComp(path: string) { } } +function getLODLevels(component: any): unknown[] { + const lodProperty = component?.properties?.LODs ?? component?.properties?._LODs; + expect(lodProperty).toBeDefined(); + return lodProperty.value; +} + async function safeDelete(path: string) { try { const node = await queryNode(path); @@ -1039,6 +1057,114 @@ describe('Undo/Redo 集成测试', () => { }); }); + // ======================================================================== + // LODGroup 包围盒重算(快照) + // ======================================================================== + describe('LODGroup recalculate bounds (snapshot)', () => { + const path = 'UndoRecalculateLODGroupNode'; + const compPath = `${path}/cc.LODGroup`; + + beforeEach(async () => { + await Node.createByType({ path, nodeType: NodeType.EMPTY }); + await Component.add({ nodePath: path, component: 'cc.LODGroup' }); + await Component.setProperty({ + componentPath: compPath, + properties: { _objectSize: 42 }, + record: false, + }); + await Undo.clearHistory(); + }); + + afterEach(async () => { + await safeDelete(path); + await Undo.clearHistory(); + }); + + it('recalculate pushes one snapshot and supports undo/redo', async () => { + expect((await queryComp(compPath))!.properties._objectSize.value).toBe(42); + + const bounds = await Component.recalculateLODGroupBounds({ path: compPath }); + expect(bounds).toEqual({ + localBoundaryCenter: { x: 0, y: 0, z: 0 }, + objectSize: 0, + }); + expect((await queryComp(compPath))!.properties._objectSize.value).toBe(0); + expect(await Undo.canUndo()).toBe(true); + + const undoResult = await Undo.undo(); + expectUndoSuccess(undoResult); + expect((await queryComp(compPath))!.properties._objectSize.value).toBe(42); + + const redoResult = await Undo.redo(); + expectUndoSuccess(redoResult); + expect((await queryComp(compPath))!.properties._objectSize.value).toBe(0); + }); + + it('record=false does not push an undo snapshot', async () => { + const bounds = await Component.recalculateLODGroupBounds({ + path: compPath, + record: false, + }); + + expect(bounds.objectSize).toBe(0); + expect(await Undo.canUndo()).toBe(false); + }); + }); + + // ======================================================================== + // LODGroup 层级增删(快照) + // ======================================================================== + describe('LODGroup level mutations (snapshot)', () => { + const path = 'UndoLODGroupLevelsNode'; + const compPath = `${path}/cc.LODGroup`; + + beforeEach(async () => { + await Node.createByType({ path, nodeType: NodeType.EMPTY }); + await Component.add({ nodePath: path, component: 'cc.LODGroup' }); + await Undo.clearHistory(); + }); + + afterEach(async () => { + await safeDelete(path); + await Undo.clearHistory(); + }); + + it('insert pushes one snapshot and supports undo/redo', async () => { + const inserted = await Component.insertLOD({ path: compPath, index: 0 }); + expect(inserted.lodCount).toBe(4); + expect(inserted.screenUsagePercentages).toHaveLength(4); + expect(getLODLevels(await queryComp(compPath))).toHaveLength(4); + expect(await Undo.canUndo()).toBe(true); + + expectUndoSuccess(await Undo.undo()); + expect(getLODLevels(await queryComp(compPath))).toHaveLength(3); + + expectUndoSuccess(await Undo.redo()); + expect(getLODLevels(await queryComp(compPath))).toHaveLength(4); + }); + + it('erase pushes one snapshot and supports undo', async () => { + await Component.insertLOD({ path: compPath, index: 0, record: false }); + await Component.insertLOD({ path: compPath, index: 1, record: false }); + await Undo.clearHistory(); + + const erased = await Component.eraseLOD({ path: compPath, index: 1 }); + expect(erased.lodCount).toBe(4); + expect(getLODLevels(await queryComp(compPath))).toHaveLength(4); + expect(await Undo.canUndo()).toBe(true); + + expectUndoSuccess(await Undo.undo()); + expect(getLODLevels(await queryComp(compPath))).toHaveLength(5); + }); + + it('record=false does not push an undo snapshot', async () => { + await Component.insertLOD({ path: compPath, index: 0, record: false }); + + expect(getLODLevels(await queryComp(compPath))).toHaveLength(4); + expect(await Undo.canUndo()).toBe(false); + }); + }); + // ======================================================================== // 递归 layer 与层级顺序操作 // ======================================================================== diff --git a/tests/component-lod-api.test.ts b/tests/component-lod-api.test.ts new file mode 100644 index 000000000..99cb4312b --- /dev/null +++ b/tests/component-lod-api.test.ts @@ -0,0 +1,146 @@ +const mockInsertLOD = jest.fn(); +const mockEraseLOD = jest.fn(); +const mockQueryLODGroupRelativeHeight = jest.fn(); + +jest.mock('../src/api/decorator/decorator.js', () => ({ + description: () => jest.fn(), + param: () => jest.fn(), + result: () => jest.fn(), + title: () => jest.fn(), + tool: () => jest.fn(), +}), { virtual: true }); + +jest.mock('../src/core/scene', () => ({ + Scene: { + Component: { + insertLOD: (...args: unknown[]) => mockInsertLOD(...args), + eraseLOD: (...args: unknown[]) => mockEraseLOD(...args), + queryLODGroupRelativeHeight: (...args: unknown[]) => mockQueryLODGroupRelativeHeight(...args), + }, + }, +})); + +import { ComponentApi } from '../src/api/scene/component'; +import { + SchemaEraseLODOptions, + SchemaInsertLODOptions, + SchemaLODGroupLevelsResult, + SchemaLODGroupRelativeHeightResult, + SchemaQueryLODGroupRelativeHeightOptions, +} from '../src/api/scene/component-schema'; +import { HTTP_STATUS } from '../src/api/base/schema-base'; + +describe('LODGroup MCP API', () => { + beforeEach(() => { + mockInsertLOD.mockReset(); + mockEraseLOD.mockReset(); + mockQueryLODGroupRelativeHeight.mockReset(); + }); + + it('validates insert, erase, query, and result schemas', () => { + expect(SchemaInsertLODOptions.parse({ + path: 'Root/LOD/cc.LODGroup', + index: 1, + screenUsagePercentage: 0.25, + record: false, + })).toEqual({ + path: 'Root/LOD/cc.LODGroup', + index: 1, + screenUsagePercentage: 0.25, + record: false, + }); + expect(() => SchemaInsertLODOptions.parse({ + path: 'Root/LOD/cc.LODGroup', + index: 1, + screenUsagePercentage: 0, + })).toThrow(); + expect(SchemaInsertLODOptions.parse({ + path: 'Root/LOD/cc.LODGroup', + index: 1, + screenUsagePercentage: 1, + }).screenUsagePercentage).toBe(1); + expect(() => SchemaInsertLODOptions.parse({ + path: 'Root/LOD/cc.LODGroup', + index: 1, + screenUsagePercentage: 1.01, + })).toThrow(); + expect(() => SchemaInsertLODOptions.parse({ + path: 'Root/LOD/cc.LODGroup', + index: 1.5, + })).toThrow(); + expect(SchemaEraseLODOptions.parse({ + path: 'Root/LOD/cc.LODGroup', + index: 0, + })).toEqual({ + path: 'Root/LOD/cc.LODGroup', + index: 0, + }); + expect(SchemaQueryLODGroupRelativeHeightOptions.parse({ + path: 'Root/LOD/cc.LODGroup', + })).toEqual({ + path: 'Root/LOD/cc.LODGroup', + }); + expect(SchemaLODGroupLevelsResult.parse({ + lodCount: 2, + screenUsagePercentages: [0.25, 0.01], + })).toEqual({ + lodCount: 2, + screenUsagePercentages: [0.25, 0.01], + }); + expect(SchemaLODGroupRelativeHeightResult.parse(1.5)).toBe(1.5); + }); + + it('forwards insert and returns the updated LOD state', async () => { + const options = { path: 'Root/LOD/cc.LODGroup', index: 1, record: false }; + const lodState = { lodCount: 2, screenUsagePercentages: [0.25, 0.01] }; + mockInsertLOD.mockResolvedValue(lodState); + + const result = await new ComponentApi().insertLOD(options); + + expect(mockInsertLOD).toHaveBeenCalledWith(options); + expect(result).toEqual({ code: HTTP_STATUS.OK, data: lodState }); + }); + + it('forwards erase and returns the updated LOD state', async () => { + const options = { path: 'Root/LOD/cc.LODGroup', index: 1, record: false }; + const lodState = { lodCount: 1, screenUsagePercentages: [0.25] }; + mockEraseLOD.mockResolvedValue(lodState); + + const result = await new ComponentApi().eraseLOD(options); + + expect(mockEraseLOD).toHaveBeenCalledWith(options); + expect(result).toEqual({ code: HTTP_STATUS.OK, data: lodState }); + }); + + it('forwards the relative-height query without clamping', async () => { + const options = { path: 'Root/LOD/cc.LODGroup' }; + mockQueryLODGroupRelativeHeight.mockResolvedValue(1.5); + + const result = await new ComponentApi().queryLODGroupRelativeHeight(options); + + expect(mockQueryLODGroupRelativeHeight).toHaveBeenCalledWith(options); + expect(result).toEqual({ code: HTTP_STATUS.OK, data: 1.5 }); + }); + + it('maps invalid LOD operation errors to client errors', async () => { + mockInsertLOD.mockRejectedValue(new Error('Parameter error: screenUsagePercentage must be in (0, 1]: 0')); + + const result = await new ComponentApi().insertLOD({ + path: 'Root/LOD/cc.LODGroup', + index: 1, + screenUsagePercentage: 0.25, + }); + + expect(result.code).toBe(HTTP_STATUS.BAD_REQUEST); + expect(result.reason).toContain('screenUsagePercentage'); + + mockEraseLOD.mockRejectedValue(new Error('Parameter error: LODGroup must contain at least 1 LOD level')); + const eraseResult = await new ComponentApi().eraseLOD({ + path: 'Root/LOD/cc.LODGroup', + index: 0, + }); + + expect(eraseResult.code).toBe(HTTP_STATUS.BAD_REQUEST); + expect(eraseResult.reason).toContain('at least 1 LOD level'); + }); +}); diff --git a/tests/component-recalculate-lod-group-bounds-api.test.ts b/tests/component-recalculate-lod-group-bounds-api.test.ts new file mode 100644 index 000000000..7f7fb40c8 --- /dev/null +++ b/tests/component-recalculate-lod-group-bounds-api.test.ts @@ -0,0 +1,87 @@ +const mockRecalculateLODGroupBounds = jest.fn(); + +jest.mock('../src/api/decorator/decorator.js', () => ({ + description: () => jest.fn(), + param: () => jest.fn(), + result: () => jest.fn(), + title: () => jest.fn(), + tool: () => jest.fn(), +}), { virtual: true }); + +jest.mock('../src/core/scene', () => ({ + Scene: { + Component: { + recalculateLODGroupBounds: (...args: unknown[]) => mockRecalculateLODGroupBounds(...args), + }, + }, +})); + +import { ComponentApi } from '../src/api/scene/component'; +import { + SchemaLODGroupBoundsResult, + SchemaRecalculateLODGroupBoundsOptions, +} from '../src/api/scene/component-schema'; +import { HTTP_STATUS } from '../src/api/base/schema-base'; + +describe('LODGroup bounds MCP API', () => { + beforeEach(() => { + mockRecalculateLODGroupBounds.mockReset(); + }); + + it('validates the dedicated input and result schemas', () => { + expect(SchemaRecalculateLODGroupBoundsOptions.parse({ + path: 'Root/LOD/cc.LODGroup', + record: false, + })).toEqual({ + path: 'Root/LOD/cc.LODGroup', + record: false, + }); + expect(() => SchemaRecalculateLODGroupBoundsOptions.parse({ path: '' })).toThrow(); + expect(SchemaLODGroupBoundsResult.parse({ + localBoundaryCenter: { x: 1, y: 2, z: 3 }, + objectSize: 4, + })).toEqual({ + localBoundaryCenter: { x: 1, y: 2, z: 3 }, + objectSize: 4, + }); + }); + + it('forwards to the public scene API and returns the recalculated bounds', async () => { + const options = { path: 'Root/LOD/cc.LODGroup', record: false }; + const bounds = { + localBoundaryCenter: { x: 1, y: 2, z: 3 }, + objectSize: 8, + }; + mockRecalculateLODGroupBounds.mockResolvedValue(bounds); + + const result = await new ComponentApi().recalculateLODGroupBounds(options); + + expect(mockRecalculateLODGroupBounds).toHaveBeenCalledWith(options); + expect(result).toEqual({ code: HTTP_STATUS.OK, data: bounds }); + }); + + it('maps an invalid component type to 400', async () => { + mockRecalculateLODGroupBounds.mockRejectedValue( + new Error('Parameter error: component is not cc.LODGroup: Root/cc.Label'), + ); + + const result = await new ComponentApi().recalculateLODGroupBounds({ + path: 'Root/cc.Label', + }); + + expect(result.code).toBe(HTTP_STATUS.BAD_REQUEST); + expect(result.reason).toContain('component is not cc.LODGroup'); + }); + + it('maps a missing component to 404', async () => { + mockRecalculateLODGroupBounds.mockRejectedValue( + new Error('LODGroup component not found: Root/LOD/cc.LODGroup'), + ); + + const result = await new ComponentApi().recalculateLODGroupBounds({ + path: 'Root/LOD/cc.LODGroup', + }); + + expect(result.code).toBe(HTTP_STATUS.NOT_FOUND); + }); +}); diff --git a/tests/workflow/deferred-module-proxy.test.js b/tests/workflow/deferred-module-proxy.test.js new file mode 100644 index 000000000..2f862a757 --- /dev/null +++ b/tests/workflow/deferred-module-proxy.test.js @@ -0,0 +1,82 @@ +const { + DEFERRED_MODULE_CACHE_KEY, + createDeferredModule, + createDeferredModuleSource, + getDeferredModule, +} = require('../../workflow/deferred-module-proxy'); + +describe('scene bundle deferred module proxy', () => { + it('reads an imported module through its resolved registry ID', () => { + const moduleId = 'cc/editor/lod-group-utils'; + const resolvedId = 'q-bundled:///fs/editor/exports/lod-group-utils.js'; + const loadedModule = { + LODGroupEditorUtility: { + getVisibleLOD: jest.fn(), + }, + }; + const system = { + resolve: jest.fn(() => resolvedId), + get: jest.fn((id) => id === resolvedId ? loadedModule : undefined), + }; + const proxy = createDeferredModule(moduleId, () => system, getDeferredModule); + + expect(proxy.LODGroupEditorUtility).toBe(loadedModule.LODGroupEditorUtility); + expect('LODGroupEditorUtility' in proxy).toBe(true); + expect(system.resolve).toHaveBeenCalledWith(moduleId); + expect(system.get).toHaveBeenCalledWith(resolvedId); + }); + + it('falls back to the original ID when the module cannot be resolved', () => { + const loadedModule = { value: 42 }; + const system = { + resolve: jest.fn(() => { + throw new Error('unresolved'); + }), + get: jest.fn((id) => id === 'named-module' ? loadedModule : undefined), + }; + + expect(getDeferredModule(system, 'named-module')).toBe(loadedModule); + expect(system.get).toHaveBeenCalledWith('named-module'); + }); + + it('reads a preloaded module when System.resolve is asynchronous', () => { + const moduleId = 'cc/editor/lod-group-utils'; + const loadedModule = { + LODGroupEditorUtility: { + getVisibleLOD: jest.fn(), + }, + }; + const system = { + resolve: jest.fn(() => Promise.resolve('q-bundled:///fs/editor/exports/lod-group-utils.js')), + get: jest.fn(), + }; + const moduleCache = { + [moduleId]: loadedModule, + }; + const proxy = createDeferredModule(moduleId, () => system, getDeferredModule, () => moduleCache); + + expect(proxy.LODGroupEditorUtility).toBe(loadedModule.LODGroupEditorUtility); + expect('LODGroupEditorUtility' in proxy).toBe(true); + expect(system.resolve).not.toHaveBeenCalled(); + expect(system.get).not.toHaveBeenCalled(); + }); + + it('does not pass an asynchronous resolver result to System.get', () => { + const system = { + resolve: jest.fn(() => Promise.resolve('resolved-module')), + get: jest.fn((id) => id === 'named-module' ? { value: 42 } : undefined), + }; + + expect(getDeferredModule(system, 'named-module')).toEqual({ value: 42 }); + expect(system.get).toHaveBeenCalledTimes(1); + expect(system.get).toHaveBeenCalledWith('named-module'); + }); + + it('generates the same resolver used by the browser bundle', () => { + const source = createDeferredModuleSource(); + + expect(source).toContain('const candidate = system.resolve(id)'); + expect(source).toContain(`globalThis[${JSON.stringify(DEFERRED_MODULE_CACHE_KEY)}]`); + expect(source).toContain('_createDeferredModule(id, _getSystem, _getDeferredModule, _getModuleCache)'); + }); +}); diff --git a/workflow/build-scene-bundle.js b/workflow/build-scene-bundle.js index 265918263..8aa6e843a 100644 --- a/workflow/build-scene-bundle.js +++ b/workflow/build-scene-bundle.js @@ -4,6 +4,7 @@ const { nodeResolve } = require('@rollup/plugin-node-resolve'); const virtual = require('@rollup/plugin-virtual'); const json = require('@rollup/plugin-json'); const path = require('path'); +const { createDeferredModuleSource } = require('./deferred-module-proxy'); async function buildSceneBundle() { const workspaceDir = path.join(__dirname, '..'); @@ -156,30 +157,7 @@ async function buildSceneBundle() { `; } if (originalId === 'cc/mods-mgr') { - return ` - function _createDeferredModule(id) { - return new Proxy({}, { - get: function(target, prop) { - if (typeof System !== 'undefined' && System.get) { - var real = System.get(id); - if (real) return real[prop]; - } - return undefined; - }, - has: function(target, prop) { - if (typeof System !== 'undefined' && System.get) { - var real = System.get(id); - if (real) return prop in real; - } - return false; - } - }); - } - export function syncImport(id) { - return _createDeferredModule(id); - } - export default { syncImport: syncImport }; - `; + return createDeferredModuleSource(); } if (originalId === 'proper-lockfile') { return ` diff --git a/workflow/deferred-module-proxy.js b/workflow/deferred-module-proxy.js new file mode 100644 index 000000000..63ff0e6aa --- /dev/null +++ b/workflow/deferred-module-proxy.js @@ -0,0 +1,67 @@ +const DEFERRED_MODULE_CACHE_KEY = '__cocosCliDeferredEngineModules'; + +function getDeferredModule(system, id, moduleCache) { + if (moduleCache && Object.prototype.hasOwnProperty.call(moduleCache, id)) { + return moduleCache[id]; + } + + if (!system || !system.get) { + return undefined; + } + + let resolvedId = id; + if (system.resolve) { + try { + const candidate = system.resolve(id); + // @cocos/systemjs resolves import maps asynchronously. A Proxy getter + // cannot await that Promise, so only use synchronous resolver results. + if (typeof candidate === 'string') { + resolvedId = candidate; + } + } catch { + // Some named modules are registered without an import-map entry. + // Fall back to the original ID for those modules. + } + } + + return system.get(resolvedId) || (resolvedId !== id ? system.get(id) : undefined); +} + +function createDeferredModule(id, getSystem, getModule, getModuleCache = () => undefined) { + return new Proxy({}, { + get(target, prop) { + const real = getModule(getSystem(), id, getModuleCache()); + return real ? real[prop] : undefined; + }, + has(target, prop) { + const real = getModule(getSystem(), id, getModuleCache()); + return real ? prop in real : false; + }, + }); +} + +function createDeferredModuleSource() { + return ` + const _getDeferredModule = ${getDeferredModule.toString()}; + const _createDeferredModule = ${createDeferredModule.toString()}; + function _getSystem() { + return typeof System === 'undefined' ? undefined : System; + } + function _getModuleCache() { + return typeof globalThis === 'undefined' + ? undefined + : globalThis[${JSON.stringify(DEFERRED_MODULE_CACHE_KEY)}]; + } + export function syncImport(id) { + return _createDeferredModule(id, _getSystem, _getDeferredModule, _getModuleCache); + } + export default { syncImport: syncImport }; + `; +} + +module.exports = { + DEFERRED_MODULE_CACHE_KEY, + createDeferredModule, + createDeferredModuleSource, + getDeferredModule, +};