From 528de2cab564e8eafcdcdd8d5962510327e6ffa2 Mon Sep 17 00:00:00 2001 From: fengyuzhe Date: Thu, 21 May 2026 15:32:02 +0800 Subject: [PATCH 01/11] Add PreviewService with standalone resource preview page. --- e2e/mcp/api/resource-preview.e2e.test.ts | 84 +++ .../assets/asset-handler/assets/effect.ts | 40 +- src/core/assets/asset-handler/index.ts | 14 +- src/core/engine/index.ts | 2 + src/core/scene/common/editor/index.ts | 4 +- src/core/scene/common/engine.ts | 3 + src/core/scene/common/index.ts | 1 + src/core/scene/common/preview.ts | 24 + src/core/scene/common/scene-view.ts | 4 +- .../scene/main-process/proxy/engine-proxy.ts | 8 +- .../scene/scene-process/engine-bootstrap.ts | 241 ++++++++ .../service/camera/camera-controller-3d.ts | 13 +- .../scene/scene-process/service/editor.ts | 4 +- src/core/scene/scene-process/service/index.ts | 1 + .../scene/scene-process/service/interfaces.ts | 4 + .../scene-process/service/preview/buffer.ts | 307 ++++++++++ .../scene-process/service/preview/grid.ts | 182 ++++++ .../scene-process/service/preview/index.ts | 143 +++++ .../service/preview/interactive-preview.ts | 543 ++++++++++++++++++ .../service/preview/material-preview.ts | 248 ++++++++ .../service/preview/mesh-preview.ts | 58 ++ .../service/preview/mini-preview/apply.ts | 49 ++ .../service/preview/mini-preview/index.ts | 144 +++++ .../service/preview/mini-preview/private.ts | 12 + .../service/preview/model-preview.ts | 85 +++ .../service/preview/prefab-preview.ts | 69 +++ .../service/preview/preview-axis.ts | 188 ++++++ .../service/preview/preview-base.ts | 22 + .../service/preview/scene-preview.ts | 45 ++ .../service/preview/skeleton-preview.ts | 100 ++++ .../service/preview/spine-preview.ts | 147 +++++ .../scene/scene-process/service/scene-view.ts | 26 +- src/core/scene/scene.scripting.middleware.ts | 35 ++ static/web/gizmo-test.js | 2 +- static/web/index.ejs | 387 ++++++++++++- static/web/preview-app.js | 236 ++++++++ static/web/preview.ejs | 122 ++++ 37 files changed, 3569 insertions(+), 28 deletions(-) create mode 100644 e2e/mcp/api/resource-preview.e2e.test.ts create mode 100644 src/core/scene/common/preview.ts create mode 100644 src/core/scene/scene-process/service/preview/buffer.ts create mode 100644 src/core/scene/scene-process/service/preview/grid.ts create mode 100644 src/core/scene/scene-process/service/preview/index.ts create mode 100644 src/core/scene/scene-process/service/preview/interactive-preview.ts create mode 100644 src/core/scene/scene-process/service/preview/material-preview.ts create mode 100644 src/core/scene/scene-process/service/preview/mesh-preview.ts create mode 100644 src/core/scene/scene-process/service/preview/mini-preview/apply.ts create mode 100644 src/core/scene/scene-process/service/preview/mini-preview/index.ts create mode 100644 src/core/scene/scene-process/service/preview/mini-preview/private.ts create mode 100644 src/core/scene/scene-process/service/preview/model-preview.ts create mode 100644 src/core/scene/scene-process/service/preview/prefab-preview.ts create mode 100644 src/core/scene/scene-process/service/preview/preview-axis.ts create mode 100644 src/core/scene/scene-process/service/preview/preview-base.ts create mode 100644 src/core/scene/scene-process/service/preview/scene-preview.ts create mode 100644 src/core/scene/scene-process/service/preview/skeleton-preview.ts create mode 100644 src/core/scene/scene-process/service/preview/spine-preview.ts create mode 100644 static/web/preview-app.js create mode 100644 static/web/preview.ejs diff --git a/e2e/mcp/api/resource-preview.e2e.test.ts b/e2e/mcp/api/resource-preview.e2e.test.ts new file mode 100644 index 000000000..8a3979983 --- /dev/null +++ b/e2e/mcp/api/resource-preview.e2e.test.ts @@ -0,0 +1,84 @@ +import { AssetsTestContext, setupAssetsTestEnvironment, teardownAssetsTestEnvironment } from '../../helpers/test-utils'; +import { E2E_TIMEOUTS } from '../../config'; + +describe('Resource Preview', () => { + let context: AssetsTestContext; + let serverBaseUrl: string; + + beforeAll(async () => { + context = await setupAssetsTestEnvironment(); + serverBaseUrl = `http://localhost:${context.mcpClient.getPort()}`; + }, E2E_TIMEOUTS.SERVER_START); + + afterAll(async () => { + await teardownAssetsTestEnvironment(context); + }); + + describe('preview route', () => { + test('GET /preview should return valid HTML page', async () => { + const res = await fetch(`${serverBaseUrl}/preview`); + expect(res.status).toBe(200); + + const html = await res.text(); + expect(html).toContain(''); + expect(html).toContain('Resource Preview'); + expect(html).toContain('GameCanvas'); + expect(html).toContain('preview-app.js'); + }); + + test('preview page should include toolbar controls', async () => { + const res = await fetch(`${serverBaseUrl}/preview`); + const html = await res.text(); + + expect(html).toContain('pvType'); + expect(html).toContain('pvUuid'); + expect(html).toContain('pvW'); + expect(html).toContain('pvH'); + expect(html).toContain('pvPrimitive'); + expect(html).toContain('previewResult'); + }); + + test('preview page should inject serverURL', async () => { + const res = await fetch(`${serverBaseUrl}/preview`); + const html = await res.text(); + + expect(html).toContain('window.WebEnv'); + expect(html).toContain('serverURL'); + expect(html).toContain(serverBaseUrl); + }); + }); + + describe('preview static assets', () => { + test('GET /static/web/preview-app.js should return JS', async () => { + const res = await fetch(`${serverBaseUrl}/static/web/preview-app.js`); + expect(res.status).toBe(200); + + const js = await res.text(); + expect(js).toContain('PREVIEW_TYPES'); + expect(js).toContain('doPreview'); + expect(js).toContain('initPreviewApp'); + }); + + test('GET /static/web/boot.js should return JS', async () => { + const res = await fetch(`${serverBaseUrl}/static/web/boot.js`); + expect(res.status).toBe(200); + }); + }); + + describe('preview via MCP (generateThumbnail)', () => { + test('should query material assets for preview', async () => { + const result = await context.mcpClient.callTool('assets-query-by-type', { + assetType: 'cc.Material', + }); + + expect(result.code).toBe(200); + expect(result.data).toBeDefined(); + + if (Array.isArray(result.data) && result.data.length > 0) { + const materialUuid = result.data[0].uuid; + expect(materialUuid).toBeDefined(); + expect(typeof materialUuid).toBe('string'); + } + }); + }); +}); diff --git a/src/core/assets/asset-handler/assets/effect.ts b/src/core/assets/asset-handler/assets/effect.ts index 75a640dad..1e593fca4 100644 --- a/src/core/assets/asset-handler/assets/effect.ts +++ b/src/core/assets/asset-handler/assets/effect.ts @@ -12,7 +12,7 @@ import { buildLayoutGraphData, getLayoutGraphDataVersion, } from 'cc/editor/custom-pipeline'; -import { existsSync, readFileSync, writeFileSync, ensureDir, readJSON, writeFile } from 'fs-extra'; +import { existsSync, readFileSync, readdirSync, writeFileSync, ensureDir, readJSON, writeFile } from 'fs-extra'; import { basename, dirname, extname, join, relative, resolve } from 'path'; import { buildEffect, options, addChunk } from '../../effect-compiler'; @@ -276,11 +276,43 @@ export async function afterImport(force?: boolean) { } }); }); - if (!effectList.length) { - console.debug('no effect to compile'); + if (effectList.length) { + await recompileAllEffects(effectList, force); return; } - await recompileAllEffects(effectList, force); + // Fallback: scan pre-built .effect.meta files from each DB's target directory. + // The internal DB ships with a pre-built library so effect.bin can be generated + // even when the full import pipeline hasn't processed .effect files. + const fallbackEffects = collectPrebuiltEffects(); + if (fallbackEffects.length) { + console.debug(`[effect] Using ${fallbackEffects.length} pre-built effect library files`); + await recompileAllEffects(fallbackEffects as any, force); + return; + } + console.debug('no effect to compile'); +} + +function collectPrebuiltEffects(): Array<{ imported: boolean; library: string }> { + const effects: Array<{ imported: boolean; library: string }> = []; + for (const dbInfo of assetConfig.data.assetDBList) { + if (!dbInfo.library) continue; + const effectsDir = join(dbInfo.target, 'effects'); + if (!existsSync(effectsDir)) continue; + try { + const allFiles = readdirSync(effectsDir, { recursive: true, encoding: 'utf-8' }); + for (const relFile of allFiles) { + if (!relFile.endsWith('.effect.meta')) continue; + try { + const meta = JSON.parse(readFileSync(join(effectsDir, relFile), 'utf-8')); + if (meta.importer !== 'effect' || !meta.imported || !meta.uuid) continue; + const libraryPath = join(dbInfo.library!, meta.uuid.substring(0, 2), meta.uuid); + if (!existsSync(libraryPath + '.json')) continue; + effects.push({ imported: true, library: libraryPath }); + } catch { /* skip invalid meta */ } + } + } catch { /* skip inaccessible dirs */ } + } + return effects; } function forceRecompileEffects(file: string): boolean { diff --git a/src/core/assets/asset-handler/index.ts b/src/core/assets/asset-handler/index.ts index 80f623e2c..e7749a4e5 100644 --- a/src/core/assets/asset-handler/index.ts +++ b/src/core/assets/asset-handler/index.ts @@ -1,11 +1,17 @@ export async function compileEffect(force?: boolean) { - // TODO 暂不支持 effect 导入 - // 需要做好容错,要保证能执行这个返回数据的函数,否则后续流启动程会被中断 - const { afterImport } = await import('./assets/effect'); + const { afterImport, autoGenEffectBinInfo } = await import('./assets/effect'); try { await afterImport(force); + const { existsSync, statSync } = await import('fs-extra'); + const binPath = autoGenEffectBinInfo.effectBinPath; + if (existsSync(binPath)) { + const size = statSync(binPath).size; + console.log(`[compileEffect] effect.bin generated: ${binPath} (${size} bytes)`); + } else { + console.warn(`[compileEffect] effect.bin NOT generated at: ${binPath}`); + } } catch (error) { - console.error(error); + console.error('[compileEffect] Failed:', error); } } diff --git a/src/core/engine/index.ts b/src/core/engine/index.ts index db3afba27..c271cbe77 100644 --- a/src/core/engine/index.ts +++ b/src/core/engine/index.ts @@ -466,6 +466,8 @@ class EngineManager implements IEngine { rendering: { renderMode: 2, highQualityMode: highQuality, + customPipeline: true, + effectSettingsPath: `${serverURL}/scripting/effect-settings`, }, physics: { ...physicsConfig, diff --git a/src/core/scene/common/editor/index.ts b/src/core/scene/common/editor/index.ts index 7b778053e..402ad24cf 100644 --- a/src/core/scene/common/editor/index.ts +++ b/src/core/scene/common/editor/index.ts @@ -19,10 +19,10 @@ export * from './scene'; * 事件类型 */ export interface IEditorEvents { - 'editor:open': []; + 'editor:open': [scene?: any]; 'editor:close': []; 'editor:save': []; - 'editor:reload': []; + 'editor:reload': [scene?: any]; } /** diff --git a/src/core/scene/common/engine.ts b/src/core/scene/common/engine.ts index 1a1282a12..28fdf76bf 100644 --- a/src/core/scene/common/engine.ts +++ b/src/core/scene/common/engine.ts @@ -17,4 +17,7 @@ export interface IEngineService extends IServiceEvents { * 让引擎执行一帧 */ repaintInEditMode(): Promise; + + pause(): void; + resume(): void; } diff --git a/src/core/scene/common/index.ts b/src/core/scene/common/index.ts index 663b10aa4..c24a55d82 100644 --- a/src/core/scene/common/index.ts +++ b/src/core/scene/common/index.ts @@ -13,3 +13,4 @@ export * from './undo'; export * from './camera'; export * from './gizmo'; export * from './scene-view'; +export * from './preview'; diff --git a/src/core/scene/common/preview.ts b/src/core/scene/common/preview.ts new file mode 100644 index 000000000..7b3791112 --- /dev/null +++ b/src/core/scene/common/preview.ts @@ -0,0 +1,24 @@ +export interface IPreviewService { + init(): void; + queryPreviewData(previewName: string, info: any): Promise; + callPreviewFunction(previewName: string, funcName: string, ...args: any[]): Promise; + queryMaterialPreview(uuid: string, width: number, height: number): Promise; + queryModelPreview(uuid: string, width: number, height: number): Promise; + queryMeshPreview(uuid: string, width: number, height: number): Promise; + querySkeletonPreview(uuid: string, width: number, height: number): Promise; + queryPrefabPreview(uuid: string, width: number, height: number): Promise; + querySpinePreview(uuid: string, width: number, height: number): Promise; + queryScenePreview(width: number, height: number): Promise; + switchMaterialPrimitive(type: string): void; + generateThumbnail(uuid: string, assetType: string, width?: number, height?: number): Promise; +} + +export type IPublicPreviewService = Pick; + +export interface IPreviewEvents { +} diff --git a/src/core/scene/common/scene-view.ts b/src/core/scene/common/scene-view.ts index 115b6f115..18765e5a0 100644 --- a/src/core/scene/common/scene-view.ts +++ b/src/core/scene/common/scene-view.ts @@ -6,8 +6,8 @@ export interface ISceneViewService { saveConfig(): Promise; setSceneLightOn(enable: boolean): void; querySceneLightOn(): boolean; - onSceneOpened(scene: any): void; - onSceneClosed(): void; + onEditorOpened(): void; + onEditorClosed(): void; onComponentAdded(comp: Component): void; onComponentRemoved(comp: Component): void; } diff --git a/src/core/scene/main-process/proxy/engine-proxy.ts b/src/core/scene/main-process/proxy/engine-proxy.ts index 405485be8..641118703 100644 --- a/src/core/scene/main-process/proxy/engine-proxy.ts +++ b/src/core/scene/main-process/proxy/engine-proxy.ts @@ -7,5 +7,11 @@ export const EngineProxy: IPublicEngineService = { }, repaintInEditMode() { return Rpc.getInstance().request('Engine', 'repaintInEditMode'); - } + }, + pause() { + Rpc.getInstance().request('Engine', 'pause'); + }, + resume() { + Rpc.getInstance().request('Engine', 'resume'); + }, }; diff --git a/src/core/scene/scene-process/engine-bootstrap.ts b/src/core/scene/scene-process/engine-bootstrap.ts index 002746d2c..185e31be8 100644 --- a/src/core/scene/scene-process/engine-bootstrap.ts +++ b/src/core/scene/scene-process/engine-bootstrap.ts @@ -62,6 +62,13 @@ export async function startup(options: { } } + // Get decodeCCONBinary for CCONB binary format support (.bin library files) + let decodeCCONBinary: ((bytes: Uint8Array) => any) | null = null; + try { + const cconModule: any = await System.import('cc/editor/serialization'); + decodeCCONBinary = cconModule?.decodeCCONBinary ?? null; + } catch { /* module may not be available */ } + // ---- hack creator 使用的一些 engine 参数 await import('cc/polyfill/engine'); // overwrite @@ -83,6 +90,7 @@ export async function startup(options: { await Rpc.startup({ serverURL }); cc.physics.selector.runInEditor = true; + await cc.game.init(config); let backend = 'builtin'; @@ -113,9 +121,242 @@ export async function startup(options: { cc.view.setDesignResolutionSize(drWidth, drHeight, drPolicy); await cc.game.run(); + // Stop the engine's built-in mainLoop immediately — it would render frames + // without a loaded scene, causing FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT. + // Our own edit-mode tick loop (Engine.startTick) takes over later. + cc.game.pause(); + + // Load and register all effect assets so materials (e.g. builtin-standard) + // are available before preview services initialize. + await (async () => { + try { + const res = await fetch('/query-asset-infos/cc.EffectAsset'); + if (!res.ok) return; + const effectInfos: any[] = await res.json(); + if (!effectInfos.length) return; + const classFinder = (id: string): any => cc.js?.getClassById?.(id) ?? null; + await Promise.all(effectInfos.map(async (info: any) => { + try { + const uuid: string = info.uuid; + if (!uuid) return; + const lib = info.library; + if (!lib || (!lib['.json'] && !lib['.bin'])) return; + + const encodedUuid = encodeURIComponent(uuid); + const ext = (lib['.bin'] && !lib['.json']) ? 'bin' : 'json'; + + const r = await fetch(`/import/${encodedUuid}.${ext}?isBrowser=true`); + if (!r.ok) return; + + const isBinary = ext === 'bin'; + let deserializeData: any; + if (isBinary && decodeCCONBinary) { + deserializeData = decodeCCONBinary(new Uint8Array(await r.arrayBuffer())); + } else { + deserializeData = await r.json(); + } + + const asset = cc.deserialize(deserializeData, undefined, { classFinder }); + asset._uuid = uuid; + cc.assetManager.assets.add(uuid, asset); + try { + if (asset.onLoaded) asset.onLoaded(); + } catch (e) { + console.warn(`[Effects] onLoaded failed for ${asset._name || uuid}:`, e); + try { cc.EffectAsset.register(asset); } catch {} + } + } catch { /* skip individual effect */ } + })); + const count = Object.keys(cc.EffectAsset.getAll()).length; + console.log(`[Effects] Registered ${count} effects`); + } catch (e: any) { + console.warn('[Effects] Failed to load effects:', e); + } + })(); + + function stripNullComponents(node: any) { + if (node._components) { + node._components = node._components.filter((c: any) => c != null); + } + if (node._children) { + for (const child of node._children) { + stripNullComponents(child); + } + } + } + + const origRunSceneImmediate = cc.director.runSceneImmediate.bind(cc.director); + cc.director.runSceneImmediate = function (scene: any, ...args: any[]) { + stripNullComponents(scene); + return origRunSceneImmediate(scene, ...args); + }; + await DecoratorService.Engine.init(); + // Pause the custom tick loop during service initialization — preview + // services create cameras that would otherwise render on mainWindow + // before any scene is loaded, causing FRAMEBUFFER_INCOMPLETE errors. + DecoratorService.Engine.pause(); await serviceManager.initAllServices(); + // Override assetManager.loadAny to fetch project assets from the server + // when they aren't found in any loaded bundle (e.g., main bundle not loaded). + const am = cc.assetManager; + const origLoadAny = am.loadAny.bind(am); + + function tryDecompress(uuid: string): string { + if (uuid.includes('-')) return uuid; + try { + return (EditorExtends.UuidUtils as any)?.decompressUuid?.(uuid) ?? uuid; + } catch { return uuid; } + } + + function isUuidInBundles(uuid: string): boolean { + const variants = [uuid, uuid.split('@')[0]]; + const dec = tryDecompress(uuid); + if (dec !== uuid) variants.push(dec, dec.split('@')[0]); + + let found = false; + am.bundles.forEach((bundle: any) => { + if (found) return; + for (const v of variants) { + if (bundle.getAssetInfo(v)) { found = true; return; } + } + }); + return found; + } + + const silentClassFinder = (id: string) => cc.js?.getClassById?.(id) ?? cc._MissingScript ?? null; + + async function loadNativeAsset(asset: any, uuid: string): Promise { + const nativeExt: string | undefined = asset._native; + if (!nativeExt) return; + + const encodedUuid = encodeURIComponent(uuid); + const isSubAsset = nativeExt.length > 0 && nativeExt[0] !== '.'; + const nativeUrl = isSubAsset + ? `/native/${encodedUuid}/${nativeExt}?isBrowser=true` + : `/native/${encodedUuid}${nativeExt}?isBrowser=true`; + + try { + const res = await fetch(nativeUrl); + if (!res.ok) return; + + const ext = nativeExt.split('.').pop()?.toLowerCase() ?? ''; + const imageExts = ['png', 'jpg', 'jpeg', 'bmp', 'webp', 'gif']; + + if (imageExts.includes(ext)) { + const blob = await res.blob(); + const img = new Image(); + img.crossOrigin = 'anonymous'; + await new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = reject; + img.src = URL.createObjectURL(blob); + }); + asset._nativeAsset = img; + } else { + asset._nativeAsset = await res.arrayBuffer(); + } + } catch { /* native data unavailable */ } + } + + async function loadFromServer(uuid: string, onComplete: any) { + try { + const encodedUuid = encodeURIComponent(uuid); + + // Query the correct file extension — assets may be stored as + // binary (.bin/cconb) instead of .json. + let ext = 'json'; + try { + const extRes = await fetch(`/query-extname/${encodedUuid}`); + const queryExt = (await extRes.text()).trim(); + if (queryExt === '.cconb') ext = 'bin'; + } catch { /* default to json */ } + + const res = await fetch(`/import/${encodedUuid}.${ext}?isBrowser=true`); + if (!res.ok) throw new Error(`Asset fetch failed (${res.status}): ${uuid}`); + + const isBinary = ext === 'bin'; + let deserializeInput: any; + if (isBinary) { + const rawBytes = new Uint8Array(await res.arrayBuffer()); + if (decodeCCONBinary) { + deserializeInput = decodeCCONBinary(rawBytes); + } else { + console.warn(`[loadFromServer] decodeCCONBinary not available, cannot decode CCONB for ${uuid}`); + onComplete?.(new Error('decodeCCONBinary not available'), null); + return; + } + } else { + deserializeInput = await res.json(); + } + + const Details = cc.deserialize?.Details; + let asset; + const deserializeOpts = { classFinder: silentClassFinder }; + + if (Details) { + const details = Details.pool?.get?.() ?? new Details(); + if (details.reset) details.reset(); + asset = cc.deserialize(deserializeInput, details, deserializeOpts); + + const uuidList = details.uuidList; + if (uuidList && uuidList.length > 0) { + const depMap: Record = {}; + await Promise.all( + uuidList + .filter((id: any) => typeof id === 'string') + .map((depUuid: string) => new Promise((resolve) => { + am.loadAny(depUuid, (err: any, depAsset: any) => { + if (!err && depAsset) depMap[depUuid] = depAsset; + resolve(); + }); + })), + ); + if (details.assignAssetsBy) { + details.assignAssetsBy((depUuid: string) => depMap[depUuid] ?? null); + } + } + Details.pool?.put?.(details); + } else { + asset = cc.deserialize(deserializeInput, undefined, deserializeOpts); + } + + asset._uuid = uuid; + am.assets.add(uuid, asset); + stripNullComponents(asset); + if (asset.data) stripNullComponents(asset.data); + await loadNativeAsset(asset, uuid); + try { if (asset.onLoaded) asset.onLoaded(); } catch { /* some assets need specific native data */ } + onComplete?.(null, asset); + } catch (e: any) { + console.warn(`[AssetFallback] load failed for ${uuid}:`, e); + onComplete?.(e, null); + } + } + + am.loadAny = function (requests: any, options: any, onComplete: any) { + if (typeof options === 'function') { + onComplete = options; + options = null; + } + const uuid = typeof requests === 'string' ? requests + : Array.isArray(requests) ? requests[0] + : requests?.uuid || requests; + + if (typeof uuid === 'string' && !isUuidInBundles(uuid)) { + const dec = tryDecompress(uuid); + const cached = am.assets.get(uuid) ?? am.assets.get(dec); + if (cached) { + onComplete?.(null, cached); + return; + } + loadFromServer(uuid, onComplete); + return; + } + origLoadAny(requests, options, onComplete); + }; + const canvas = document.getElementById('GameCanvas') as HTMLCanvasElement | null; if (canvas && DecoratorService.Operation) { await new Promise((resolve, reject) => { diff --git a/src/core/scene/scene-process/service/camera/camera-controller-3d.ts b/src/core/scene/scene-process/service/camera/camera-controller-3d.ts index 4588b4993..9397d1d6b 100644 --- a/src/core/scene/scene-process/service/camera/camera-controller-3d.ts +++ b/src/core/scene/scene-process/service/camera/camera-controller-3d.ts @@ -167,6 +167,8 @@ export class CameraController3D extends CameraControllerBase { private _curEye = new Vec3(); private _lineColor = new Color(255, 255, 255, 50); + get lineColor() { return this._lineColor; } + set lineColor(value: Color) { this._lineColor = value; } // 预分配临时变量,避免高频方法中反复 new 产生 GC 压力 private v3a = new Vec3(); @@ -455,6 +457,7 @@ export class CameraController3D extends CameraControllerBase { // ---------- 模式切换 ---------- async changeMode(command: string) { + if (!this._modeFSM) return; await this._modeFSM.issueCommand(command); this.emit('mode', command); } @@ -765,15 +768,17 @@ export class CameraController3D extends CameraControllerBase { // ---------- 鼠标/键盘事件 ---------- isMoving(): boolean { - return this._modeFSM.currentState !== this._idleMode; + return this._modeFSM?.currentState !== this._idleMode; } onMouseDBlDown(event: ISceneMouseEvent) { + if (!this._modeFSM) return; const currentMode = this._modeFSM.currentState as ModeBase3D; currentMode.onMouseDBlDown(event); } onMouseDown(event: ISceneMouseEvent) { + if (!this._modeFSM) return; this.mousePressing = true; this.shiftKey = event.shiftKey; this.altKey = event.altKey; @@ -800,6 +805,7 @@ export class CameraController3D extends CameraControllerBase { } onMouseMove(event: ISceneMouseEvent) { + if (!this._modeFSM) return; this.shiftKey = event.shiftKey; this.altKey = event.altKey; @@ -810,6 +816,7 @@ export class CameraController3D extends CameraControllerBase { } onMouseUp(event: ISceneMouseEvent) { + if (!this._modeFSM) return; this.mousePressing = false; const currentMode = this._modeFSM.currentState as ModeBase3D; @@ -822,6 +829,7 @@ export class CameraController3D extends CameraControllerBase { } onMouseWheel(event: ISceneMouseEvent) { + if (!this._modeFSM) return; const currentMode = this._modeFSM.currentState as ModeBase3D; if (currentMode.modeName === CameraMoveMode.WANDER) { // 漫游模式下滚轮调节速度 @@ -836,16 +844,19 @@ export class CameraController3D extends CameraControllerBase { } onKeyDown(event: ISceneKeyboardEvent) { + if (!this._modeFSM) return; const currentMode = this._modeFSM.currentState as ModeBase3D; currentMode.onKeyDown(event); } onKeyUp(event: ISceneKeyboardEvent) { + if (!this._modeFSM) return; const currentMode = this._modeFSM.currentState as ModeBase3D; currentMode.onKeyUp(event); } onUpdate(deltaTime: number) { + if (!this._modeFSM) return; const currentMode = this._modeFSM.currentState as ModeBase3D; currentMode.onUpdate(deltaTime); } diff --git a/src/core/scene/scene-process/service/editor.ts b/src/core/scene/scene-process/service/editor.ts index a01eec6fc..816b214b0 100644 --- a/src/core/scene/scene-process/service/editor.ts +++ b/src/core/scene/scene-process/service/editor.ts @@ -158,7 +158,7 @@ export class EditorService extends BaseService implements IEditor const encode = await editor.open(assetInfo); // 设置当前打开的编辑器 this.currentEditorUuid = assetInfo.uuid; - this.emit('editor:open'); + this.emit('editor:open', cc.director.getScene()); this.isOpen = true; console.log(`打开 ${assetInfo.url}`); return encode; @@ -277,7 +277,7 @@ export class EditorService extends BaseService implements IEditor currentParams = null; } - this.emit('editor:reload'); + this.emit('editor:reload', cc.director.getScene()); this.broadcast('editor:reload'); console.log(`重载 ${assetInfo.url}`); } diff --git a/src/core/scene/scene-process/service/index.ts b/src/core/scene/scene-process/service/index.ts index f12422623..00f6f047d 100644 --- a/src/core/scene/scene-process/service/index.ts +++ b/src/core/scene/scene-process/service/index.ts @@ -13,4 +13,5 @@ export * from './camera'; export * from './gizmo'; export * from './scene-view'; export * from './particle'; +export * from './preview'; export * from './core/global-events'; diff --git a/src/core/scene/scene-process/service/interfaces.ts b/src/core/scene/scene-process/service/interfaces.ts index 9850ae2db..6a6df7bef 100644 --- a/src/core/scene/scene-process/service/interfaces.ts +++ b/src/core/scene/scene-process/service/interfaces.ts @@ -25,6 +25,8 @@ import { IGizmoService, IPublicSceneViewService, ISceneViewService, + IPublicPreviewService, + IPreviewService, } from '../../common'; /** @@ -44,6 +46,7 @@ export interface IPublicServiceManager { Camera: IPublicCameraService, Gizmo: IPublicGizmoService, SceneView: IPublicSceneViewService, + Preview: IPublicPreviewService, } export interface IServiceManager { @@ -60,4 +63,5 @@ export interface IServiceManager { Camera: ICameraService, Gizmo: IGizmoService, SceneView: ISceneViewService, + Preview: IPreviewService, } diff --git a/src/core/scene/scene-process/service/preview/buffer.ts b/src/core/scene/scene-process/service/preview/buffer.ts new file mode 100644 index 000000000..4a7bbac49 --- /dev/null +++ b/src/core/scene/scene-process/service/preview/buffer.ts @@ -0,0 +1,307 @@ +import { EventEmitter } from 'events'; +import { gfx, renderer } from 'cc'; +import { Service } from '../core/decorator'; +import { ServiceEvents } from '../core/global-events'; + +export interface IWindowInfo { + index: number; + uuid: string; + name: string; + window?: any; +} + +class PreviewBuffer extends EventEmitter { + private _name: string; + device = cc.director.root.device; + width = Math.floor(cc.director.root.mainWindow.width); + height = Math.floor(cc.director.root.mainWindow.height); + data = new Uint8Array(this.width * this.height * 4); + renderScene: any = null; + scene: any = null; + windows: Record = {}; + window: any = null; + regions = [new gfx.BufferTextureCopy()]; + renderData: any; + queue: any[]; + lock = false; + _registerName?: string; + + constructor(registerName: string, name: string, scene: any = null) { + super(); + this.renderData = { + width: this.width, + height: this.height, + buffer: this.data, + }; + this._name = name; + this._registerName = registerName; + + if (!scene) { + const onLoad = (loadedScene: any) => this.onLoadScene(loadedScene); + ServiceEvents.on('editor:open', onLoad); + ServiceEvents.on('editor:reload', onLoad); + } else { + this.onLoadScene(scene); + } + this.regions[0].texExtent.width = this.width; + this.regions[0].texExtent.height = this.height; + this.queue = []; + } + + public resize(width: number, height: number, window: any = null) { + window || (window = this.window); + if (!window) { return; } + width = Math.floor(width); + height = Math.floor(height); + this.renderData.width = this.width = width; + this.renderData.height = this.height = height; + this.regions[0].texExtent.width = width; + this.regions[0].texExtent.height = height; + window.resize(width, height); + this.renderData.buffer = this.data = new Uint8Array(this.width * this.height * 4); + } + + public clear() { + this.resize(0, 0, this.window); + this.resize(this.width, this.height, this.window); + } + + ensureWindow(width?: number, height?: number) { + if (this.window) return; + if (width && height) { + this.width = Math.floor(width); + this.height = Math.floor(height); + this.renderData.width = this.width; + this.renderData.height = this.height; + this.renderData.buffer = this.data = new Uint8Array(this.width * this.height * 4); + this.regions[0].texExtent.width = this.width; + this.regions[0].texExtent.height = this.height; + } + this.createWindow(); + } + + createWindow(uuid: string | null = null) { + if (uuid && this.windows[uuid]) { + this.window = this.windows[uuid]; + return; + } + const root = cc.director.root; + const renderPassInfo = new gfx.RenderPassInfo( + [new gfx.ColorAttachment(root.mainWindow.swapchain.colorTexture.format)], + new gfx.DepthStencilAttachment(root.mainWindow.swapchain.depthStencilTexture.format), + ); + renderPassInfo.colorAttachments[0].barrier = root.device.getGeneralBarrier( + new gfx.GeneralBarrierInfo(0, gfx.AccessFlagBit.FRAGMENT_SHADER_READ_TEXTURE), + ); + const window = root.createWindow({ + title: this._name, + width: this.width, + height: this.height, + renderPassInfo, + isOffscreen: true, + }); + this.window = window; + if (uuid) { this.windows[uuid] = window; } + } + + removeWindow(uuid: string) { + if (uuid && this.windows[uuid]) { + cc.director.root.destroyWindow(this.windows[uuid]); + if (this.windows[uuid] === this.window) { this.window = null; } + delete this.windows[uuid]; + } + } + + destroyWindow(window?: any) { + window = window || this.window; + if (window) { + cc.director.root.destroyWindow(window); + if (window === this.window) { this.window = null; } + } + } + + onLoadScene(scene: any) { + if (!scene || !scene.renderScene) { + console.warn(`[PreviewBuffer:${this._name}] onLoadScene: invalid scene`, scene); + return; + } + const root = cc.director.root; + for (const [, window] of Object.entries(this.windows)) { + root.destroyWindow(window); + } + this.windows = {}; + + this.scene = scene; + this.renderScene = scene.renderScene; + this.emit('loadScene', scene); + } + + switchCameras(camera: any, currWindow: any) { + if (currWindow) { + camera.isWindowSize = false; + camera.enabled = true; + camera.changeTargetWindow(currWindow); + cc.director.root.tempWindow = currWindow; + } + } + + public needInvertGFXApi = [ + gfx.API.GLES2, + gfx.API.GLES3, + gfx.API.WEBGL, + gfx.API.WEBGL2, + ]; + + copyFrameBuffer(window: any = null) { + window || (window = this.window); + if (!window || !window.framebuffer) { return this.renderData; } + + const destBuffer = new Uint8Array(this.renderData.buffer.buffer); + + const colorTex = window.framebuffer.colorTextures[0]; + if (colorTex) { + const gpuTex = colorTex.gpuTexture || colorTex._gpuTexture; + const gl = (this.device as any).gl as WebGL2RenderingContext | undefined; + + if (gl && gpuTex?.glTexture) { + const tempFBO = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, tempFBO); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, gpuTex.glTexture, 0); + gl.colorMask(true, true, true, true); + gl.disable(gl.SCISSOR_TEST); + gl.readPixels(0, 0, this.renderData.width, this.renderData.height, gl.RGBA, gl.UNSIGNED_BYTE, destBuffer); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.deleteFramebuffer(tempFBO); + } else { + this.device.copyTextureToBuffers(colorTex, [destBuffer], this.regions); + } + } + + this.formatBuffer( + this.renderData.buffer, + this.needInvertGFXApi.includes(this.device.gfxAPI), + this.device.gfxAPI === gfx.API.METAL, + ); + + this.emit('getData', this, this.data); + return this.renderData; + } + + static indexOfRGBA = [0, 1, 2, 3]; + static indexOfBGRA = [2, 1, 0, 3]; + + formatBuffer(buffer: Uint8Array, needInvert: boolean, conversionBGRA: boolean) { + if (!needInvert) { return buffer; } + + let startIndex, invertIndex; + const V_U_Vec4 = { r: 0, g: 0, b: 0, a: 0 }; + + const indexArr = conversionBGRA ? PreviewBuffer.indexOfBGRA : PreviewBuffer.indexOfRGBA; + + for (let w = 0; w < this.renderData.width; w++) { + for (let h = 0; h < this.renderData.height / 2; h++) { + startIndex = (h * this.renderData.width + w) * 4; + invertIndex = ((this.renderData.height - 1 - h) * this.renderData.width + w) * 4; + + V_U_Vec4.r = buffer[startIndex + indexArr[0]]; + V_U_Vec4.g = buffer[startIndex + indexArr[1]]; + V_U_Vec4.b = buffer[startIndex + indexArr[2]]; + V_U_Vec4.a = buffer[startIndex + indexArr[3]]; + + buffer[startIndex + 0] = buffer[invertIndex + indexArr[0]]; + buffer[startIndex + 1] = buffer[invertIndex + indexArr[1]]; + buffer[startIndex + 2] = buffer[invertIndex + indexArr[2]]; + buffer[startIndex + 3] = buffer[invertIndex + indexArr[3]]; + + buffer[invertIndex + 0] = V_U_Vec4.r; + buffer[invertIndex + 1] = V_U_Vec4.g; + buffer[invertIndex + 2] = V_U_Vec4.b; + buffer[invertIndex + 3] = V_U_Vec4.a; + } + } + + return buffer; + } + + getImageDataInQueue(width: number, height: number): Promise { + return new Promise((resolve) => { + const params = { + width: Math.floor(width), + height: Math.floor(height), + }; + this.queue.push({ params, resolve }); + this.step(); + }); + } + + async step() { + if (this.lock) { + return; + } + this.lock = true; + const item = this.queue.shift(); + if (!item) { + this.lock = false; + return; + } + const { params, resolve } = item; + const data = await this.getImageData(params.width, params.height); + resolve(data); + this.lock = false; + this.step(); + } + + async getImageData(width: number, height: number) { + if (!this.renderScene) { + return this.renderData; + } + + this.ensureWindow(width, height); + + const root = this.renderScene.root; + const currWindow = this.window; + if (!currWindow) { + return this.renderData; + } + + let cameras: renderer.scene.Camera[] = []; + if (root) { + for (const window of root.windows) { + if (window.cameras.length > 0 && window === currWindow) { + cameras = window.cameras; + } + } + } + + if (!cameras.length) { + return this.renderData; + } + + const needResize = width && height && (width !== this.width || height !== this.height); + if (needResize) { + this.resize(width, height, currWindow); + } + + for (let i = 0; i < cameras.length; i++) { + const curWindowCamera = cameras[i]; + this.switchCameras(curWindowCamera, currWindow); + if (curWindowCamera.width !== this.width || curWindowCamera.height !== this.height) { + curWindowCamera.resize(width, height); + } + curWindowCamera.update(true); + } + + const prevTempWindow = cc.director.root.tempWindow; + cc.director.root.tempWindow = currWindow; + Service.Engine.repaintInEditMode(); + + return await new Promise((resolve) => { + cc.director.once(cc.Director.EVENT_AFTER_DRAW, () => { + cc.director.root.tempWindow = prevTempWindow; + resolve(this.copyFrameBuffer(this.window)); + }); + }); + } +} + +export default PreviewBuffer; diff --git a/src/core/scene/scene-process/service/preview/grid.ts b/src/core/scene/scene-process/service/preview/grid.ts new file mode 100644 index 000000000..83a167884 --- /dev/null +++ b/src/core/scene/scene-process/service/preview/grid.ts @@ -0,0 +1,182 @@ +import { Camera, Color, Node, Vec3, gfx, MeshRenderer, Layers, utils } from 'cc'; +import { CameraUtils } from '../camera/utils'; +import LinearTicks from '../camera/grid/linear-ticks'; + +const _lineEnd = 1000000; +const tempV3 = new Vec3(); + +export class Grid { + private _gridMeshComp: MeshRenderer; + private synchronizeCamera: Camera; + private _lineColor = cc.color().fromHEX('#A6A6A6'); + private _useFallback = false; + hTicks?: LinearTicks; + vTicks?: LinearTicks; + + constructor(rootNode: Node, synchronizeCamera: Camera) { + this._gridMeshComp = CameraUtils.createGrid('internal/editor/grid', rootNode); + this._gridMeshComp.node.layer = Layers.Enum.DEFAULT; + this._gridMeshComp.node.setRotationFromEuler(new Vec3(90, 0, 0)); + + this.synchronizeCamera = synchronizeCamera; + + if (!this._gridMeshComp.material) { + this._useFallback = true; + this.createFallbackGrid(rootNode); + } + + if (!this._useFallback) { + this.hTicks = new LinearTicks().initTicks([5, 2], 1, 10000).spacing(15, 80); + this.vTicks = new LinearTicks().initTicks([5, 2], 1, 10000).spacing(15, 80); + this.synchronizeCamera.node.on('transform-changed', this.updateGrid, this); + } + } + + private createFallbackGrid(rootNode: Node) { + this._gridMeshComp.node.destroy(); + + const node = new Node('Fallback Grid'); + node.layer = Layers.Enum.DEFAULT; + node.parent = rootNode; + node.setWorldPosition(new Vec3(0, 0, 0)); + + const comp = node.addComponent(MeshRenderer); + const positions: number[] = []; + const indices: number[] = []; + const gridSize = 10; + const step = 1; + let idx = 0; + + for (let i = -gridSize; i <= gridSize; i += step) { + positions.push(i, 0, -gridSize, i, 0, gridSize); + indices.push(idx++, idx++); + positions.push(-gridSize, 0, i, gridSize, 0, i); + indices.push(idx++, idx++); + } + + comp.mesh = utils.createMesh({ + positions, + indices, + primitiveMode: gfx.PrimitiveMode.LINE_LIST, + }); + + const mtl = new cc.Material(); + mtl.initialize({ + effectName: 'builtin-unlit', + states: { primitive: gfx.PrimitiveMode.LINE_LIST }, + }); + try { mtl.setProperty('mainColor', new Color(166, 166, 166, 120)); } catch { /* ignore */ } + comp.material = mtl; + + this._gridMeshComp = comp; + } + + private _hide = false; + + hide() { + this._hide = true; + this._gridMeshComp.node.active = false; + } + + show() { + this._hide = false; + this._gridMeshComp.node.active = true; + if (!this._useFallback) { + this.updateGrid(); + } + } + + public _updateGridData(positions: number[], colors: number[], lineColor: Color, lineEnd: number | null = null) { + const hTicks = this.hTicks; + const vTicks = this.vTicks; + + this.synchronizeCamera.node.getWorldPosition(tempV3); + const cameraPos = tempV3; + + const distance = cameraPos.y; + const scale = distance / 500; + + const range = 5000; + const scaleRange = (range * scale) | 0; + + const curStartX = -scaleRange + cameraPos.x; + const curEndX = scaleRange + cameraPos.x; + const curStartY = -scaleRange + cameraPos.z; + const curEndY = scaleRange + cameraPos.z; + hTicks!.range(curStartX, curEndX, range); + vTicks!.range(curStartY, curEndY, range); + + const tempColor = lineColor.clone(); + tempColor.a = 0; + + const lineOpacity = 200; + for (let i = hTicks!.minTickLevel; i <= hTicks!.maxTickLevel; ++i) { + const ratio = hTicks!.tickRatios[i]; + if (ratio > 0) { + const ticks = hTicks!.ticksAtLevel(i, true); + for (let j = 0; j < ticks.length; ++j) { + const tick = ticks[j]; + + const color = lineColor.clone(); + color.a = ratio * lineOpacity; + + const dist = Math.abs(tick - cameraPos.x); + color.a *= 1 - dist / scaleRange; + // x + positions.push(tick, cameraPos.z); + positions.push(tick, curStartY); + positions.push(tick, cameraPos.z); + positions.push(tick, curEndY); + colors.push(color.x, color.y, color.z, color.w); + colors.push(tempColor.x, tempColor.y, tempColor.z, tempColor.w); + colors.push(color.x, color.y, color.z, color.w); + colors.push(tempColor.x, tempColor.y, tempColor.z, tempColor.w); + } + } + } + + for (let i = vTicks!.minTickLevel; i <= vTicks!.maxTickLevel; ++i) { + const ratio = vTicks!.tickRatios[i]; + if (ratio > 0) { + const ticks = vTicks!.ticksAtLevel(i, true); + for (let j = 0; j < ticks.length; ++j) { + const tick = ticks[j]; + + const color = lineColor.clone(); + color.a = ratio * lineOpacity; + + const dist = Math.abs(tick - cameraPos.z); + color.a *= 1 - dist / scaleRange; + + // y + positions.push(cameraPos.x, tick); + positions.push(curStartX, tick); + positions.push(cameraPos.x, tick); + positions.push(curEndX, tick); + colors.push(color.x, color.y, color.z, color.w); + colors.push(tempColor.x, tempColor.y, tempColor.z, tempColor.w); + colors.push(color.x, color.y, color.z, color.w); + colors.push(tempColor.x, tempColor.y, tempColor.z, tempColor.w); + } + } + } + } + + public updateGrid() { + if (this._hide || this._useFallback) { return; } + const positions: number[] = []; + const colors: number[] = []; + const indices: number[] = []; + this._updateGridData(positions, colors, this._lineColor, _lineEnd); + + if (positions.length > 0) { + for (let i = 0; i < positions.length; i += 2) { + indices.push(i / 2); + } + + CameraUtils.updateVBAttr(this._gridMeshComp, gfx.AttributeName.ATTR_POSITION, positions); + CameraUtils.updateVBAttr(this._gridMeshComp, gfx.AttributeName.ATTR_COLOR, colors); + CameraUtils.updateIB(this._gridMeshComp, indices); + } + } +} diff --git a/src/core/scene/scene-process/service/preview/index.ts b/src/core/scene/scene-process/service/preview/index.ts new file mode 100644 index 000000000..80d911494 --- /dev/null +++ b/src/core/scene/scene-process/service/preview/index.ts @@ -0,0 +1,143 @@ +import { PreviewBase } from './preview-base'; +import { scenePreview, ScenePreview } from './scene-preview'; +import { MiniPreview } from './mini-preview'; +import { MaterialPreview } from './material-preview'; +import { ModelPreview } from './model-preview'; +import { MeshPreview } from './mesh-preview'; +import { SkeletonPreview } from './skeleton-preview'; +import { PrefabPreview } from './prefab-preview'; +import { SpinePreview } from './spine-preview'; +import { BaseService, register } from '../core'; +import type { IPreviewService, IPreviewEvents } from '../../../common/preview'; + +@register('Preview') +export class PreviewService extends BaseService implements IPreviewService { + private _previewMap: Map = new Map(); + private _initialized = false; + + scenePreview = scenePreview; + materialPreview = new MaterialPreview(); + miniPreview = new MiniPreview(); + modelPreview = new ModelPreview(); + meshPreview = new MeshPreview(); + skeletonPreview = new SkeletonPreview(); + prefabPreview = new PrefabPreview(); + spinePreview = new SpinePreview(); + + init() { + if (this._initialized) return; + this._initialized = true; + this.initPreview('scene:preview', 'query-preview-data', this.scenePreview); + this.initPreview('scene:mini-preview', 'query-mini-preview-data', this.miniPreview); + this.initPreview('scene:material-preview', 'query-material-preview-data', this.materialPreview); + this.initPreview('scene:model-preview', 'query-model-preview-data', this.modelPreview); + this.initPreview('scene:mesh-preview', 'query-mesh-preview-data', this.meshPreview); + this.initPreview('scene:skeleton-preview', 'query-skeleton-preview-data', this.skeletonPreview); + this.initPreview('scene:prefab-preview', 'query-prefab-preview-data', this.prefabPreview); + this.initPreview('scene:spine-preview', 'query-spine-preview-data', this.spinePreview); + console.log('[Preview] PreviewService initialized'); + } + + private initPreview(registerName: string, queryName: string, mgr: PreviewBase) { + this._previewMap.set(registerName, mgr); + mgr.init(registerName, queryName); + } + + public async queryPreviewData(previewName: string, info: any) { + if (this._previewMap.has(previewName)) { + const preview = this._previewMap.get(previewName); + return await preview?.queryPreviewData(info); + } + return null; + } + + public async callPreviewFunction(previewName: string, funcName: string, ...args: any[]) { + if (this._previewMap.has(previewName)) { + const preview: any = this._previewMap.get(previewName); + if (preview[funcName]) { + return await preview[funcName](...args); + } + } + return false; + } + + // --- 资源预览快捷方法 --- + + public async queryMaterialPreview(uuid: string, width: number, height: number) { + await this.materialPreview.setMaterialByUuid(uuid); + return await this.materialPreview.queryPreviewData({ width, height }); + } + + public async queryModelPreview(uuid: string, width: number, height: number) { + await this.modelPreview.setModel(uuid); + return await this.modelPreview.queryPreviewData({ width, height }); + } + + public async queryMeshPreview(uuid: string, width: number, height: number) { + await this.meshPreview.setMesh(uuid); + return await this.meshPreview.queryPreviewData({ width, height }); + } + + public async querySkeletonPreview(uuid: string, width: number, height: number) { + await this.skeletonPreview.setSkeleton(uuid); + return await this.skeletonPreview.queryPreviewData({ width, height }); + } + + public async queryPrefabPreview(uuid: string, width: number, height: number) { + await this.prefabPreview.setPrefab(uuid); + return await this.prefabPreview.queryPreviewData({ width, height }); + } + + public async querySpinePreview(uuid: string, width: number, height: number) { + await this.spinePreview.setSpine(uuid); + return await this.spinePreview.queryPreviewData({ width, height }); + } + + public async queryScenePreview(width: number, height: number) { + return await this.scenePreview.queryPreviewData({ width, height }); + } + + public switchMaterialPrimitive(type: string) { + this.materialPreview.switchPrimitive(type); + } + + // --- 缩略图生成 --- + + public async generateThumbnail(uuid: string, assetType: string, width = 128, height = 128) { + switch (assetType) { + case 'cc.Material': + return await this.queryMaterialPreview(uuid, width, height); + case 'cc.Mesh': + return await this.queryMeshPreview(uuid, width, height); + case 'cc.Prefab': + return await this.queryPrefabPreview(uuid, width, height); + case 'cc.Skeleton': + return await this.querySkeletonPreview(uuid, width, height); + case 'sp.SkeletonData': + return await this.querySpinePreview(uuid, width, height); + default: + // 对于 fbx/gltf 等模型资源 + if (['cc.FBX', 'cc.GLTF', 'cc.ModelAsset'].includes(assetType)) { + return await this.queryModelPreview(uuid, width, height); + } + return null; + } + } + + // --- Service 事件钩子 --- + + onComponentAdded(comp: any) { + this.scenePreview.onComponentAdded(comp); + } +} + +export { PreviewBase } from './preview-base'; +export { InteractivePreview } from './interactive-preview'; +export { ScenePreview } from './scene-preview'; +export { MiniPreview } from './mini-preview'; +export { MaterialPreview } from './material-preview'; +export { ModelPreview } from './model-preview'; +export { MeshPreview } from './mesh-preview'; +export { SkeletonPreview } from './skeleton-preview'; +export { PrefabPreview } from './prefab-preview'; +export { SpinePreview } from './spine-preview'; diff --git a/src/core/scene/scene-process/service/preview/interactive-preview.ts b/src/core/scene/scene-process/service/preview/interactive-preview.ts new file mode 100644 index 000000000..76856c6cf --- /dev/null +++ b/src/core/scene/scene-process/service/preview/interactive-preview.ts @@ -0,0 +1,543 @@ +import { Camera, Color, geometry, Node, Quat, renderer, Scene, Layers, Vec3, EventMouse, SkyboxInfo, UITransform } from 'cc'; +import PreviewBuffer from './buffer'; +import { PreviewBase } from './preview-base'; +import { PreviewWorldAxis } from './preview-axis'; +import { Grid } from './grid'; +import { smoothMouseWheelScale } from '../camera/camera-controller-3d'; +import { Service } from '../core/decorator'; + +const tempVec3A = new Vec3(); +const tempVec3B = new Vec3(); + +function getBoundaryOfMeshNodes(nodes: Node[]): geometry.AABB | null { + let minPos = new Vec3(Infinity, Infinity, Infinity); + let maxPos = new Vec3(-Infinity, -Infinity, -Infinity); + let found = false; + + for (const node of nodes) { + const renderers = node.getComponentsInChildren('cc.MeshRenderer'); + for (const mr of renderers) { + const model = (mr as any).model; + if (model && model.worldBounds) { + const bounds = model.worldBounds as geometry.AABB; + const bMin = new Vec3( + bounds.center.x - bounds.halfExtents.x, + bounds.center.y - bounds.halfExtents.y, + bounds.center.z - bounds.halfExtents.z, + ); + const bMax = new Vec3( + bounds.center.x + bounds.halfExtents.x, + bounds.center.y + bounds.halfExtents.y, + bounds.center.z + bounds.halfExtents.z, + ); + Vec3.min(minPos, minPos, bMin); + Vec3.max(maxPos, maxPos, bMax); + found = true; + } + } + } + if (!found) return null; + + const center = new Vec3(); + Vec3.add(center, minPos, maxPos); + Vec3.multiplyScalar(center, center, 0.5); + const halfExtents = new Vec3(); + Vec3.subtract(halfExtents, maxPos, minPos); + Vec3.multiplyScalar(halfExtents, halfExtents, 0.5); + return new geometry.AABB(center.x, center.y, center.z, halfExtents.x, halfExtents.y, halfExtents.z); +} + +function makeVec3InRange(v: Vec3, min: number, max: number) { + v.x = Math.max(min, Math.min(max, v.x)); + v.y = Math.max(min, Math.min(max, v.y)); + v.z = Math.max(min, Math.min(max, v.z)); +} + +class InteractivePreview extends PreviewBase { + protected scene!: Scene; + protected cameraComp!: Camera; + protected camera: renderer.scene.Camera | any; + + protected isMouseLeft = false; + protected isMouseMiddle = false; + + protected enableResetCamera = true; + protected enableViewToggle = true; + + protected enableGrid = true; + protected grid: Grid | null = null; + + protected enableAxis = true; + protected worldAxis: PreviewWorldAxis | null = null; + + protected is2D = false; + + protected enableSkybox = true; + protected skybox: SkyboxInfo | null = null; + + public async queryPreviewData(info: any) { + this.ensurePreviewGlobalsActive(); + this.previewBuffer.ensureWindow(info.width, info.height); + this.ensureCameraAttached(); + if (this.worldAxis && this.previewBuffer?.window) { + this.worldAxis._sceneGizmoCamera.camera.changeTargetWindow(this.previewBuffer.window); + if (this.enableAxis) { + this.worldAxis.show(); + } + } + return super.queryPreviewData(info); + } + + private ensurePreviewGlobalsActive() { + if (!this.enableSkybox) return; + const psd = (cc.director.root as any)?.pipeline?.pipelineSceneData; + if (!psd?.skybox) return; + if (!psd.skybox.enabled) { + this.scene.globals.activate(this.scene); + } + } + + protected readonly _minScalar = 1; + protected orthoScale = 0.1; + + private get isOrtho() { + return this.cameraComp.projection === Camera.ProjectionType.ORTHO; + } + + protected get wheelSpeed() { + try { + const cam = Service.Camera as any; + if (this.isOrtho) { + return cam.controller2D?.wheelSpeed ?? 6; + } + return cam.controller3D?.wheelSpeed ?? 0.01; + } catch { + return this.isOrtho ? 6 : 0.01; + } + } + + protected get scale2D(): number { + try { + return Service.Gizmo?.transformToolData?.scale2D ?? 1; + } catch { + return 1; + } + } + + protected wheelBaseScale = 1 / 12; + private lastMouseWheelDeltaY = 0; + private maxMouseWheelDeltaY = 1000; + + protected disableMouseWheel = false; + protected disableRotate = false; + protected disablePan = false; + + public queryViewToolState() { + return { + enableResetCamera: this.enableResetCamera, + enableViewToggle: this.enableViewToggle, + }; + } + + public is2DView() { + return this.is2D; + } + + public viewToggle() { + this.is2D = !this.is2D; + this.switchViewModeState(); + this.initCamera(); + this.initSceneCamera(); + this.initGrid(); + this.initPreviewWorldAxis(); + if (this._modelNode) { + this.autoPerfectCameraViewOnModel(this._modelNode); + } + } + + public initScene(registerName: string, queryName: string) { + this.scene = new Scene(registerName); + if (this.enableSkybox) { + this.skybox = this.scene.globals.skybox; + this.scene.globals.skybox.enabled = true; + } + this.previewBuffer = new PreviewBuffer(registerName, queryName, this.scene); + } + + public createNodes(scene: Scene) { + } + + public createCamera(registerName: string) { + this.cameraComp = new Node(registerName + 'camera').addComponent(Camera); + this.cameraComp.node.setParent(this.scene); + } + + public initCamera() { + if (this.is2D) { + this.cameraComp.node.setPosition(0, 0, 1000); + this.cameraComp.orthoHeight = 0; + this.cameraComp.node.setRotation(0, 0, 0, 0); + this.cameraComp.projection = Camera.ProjectionType.ORTHO; + this.cameraComp.clearFlags = Camera.ClearFlag.SOLID_COLOR; + } else { + this.cameraComp.node.setPosition(0, 1, 2.5); + this.cameraComp.node.lookAt(Vec3.ZERO); + this.cameraComp.projection = Camera.ProjectionType.PERSPECTIVE; + this.cameraComp.clearFlags = Camera.ClearFlag.SKYBOX; + } + this.cameraComp.clearColor = new Color(76, 76, 76, 255); + this.cameraComp.near = 0.01; + this.cameraComp.far = 10000; + this.cameraComp.visibility = Layers.makeMaskExclude([Layers.BitMask.PROFILER, Layers.Enum.GIZMOS, Layers.Enum.SCENE_GIZMO]); + } + + public initSceneCamera() { + this.camera = this.cameraComp.camera; + if (!this.camera) { + console.warn(`[InteractivePreview] initSceneCamera: cameraComp.camera is null, forcing _createCamera`); + (this.cameraComp as any)._createCamera(); + this.camera = this.cameraComp.camera; + } + this.camera.isWindowSize = false; + this.camera.cameraUsage = renderer.scene.CameraUsage.EDITOR; + // Disable until a preview window is available — scene._activate() already + // enabled the camera targeting mainWindow, which causes framebuffer errors. + this.camera.enabled = false; + this.ensureCameraAttached(); + } + + protected ensureCameraAttached() { + if (!this.camera || !this.previewBuffer?.window) return; + + this.cameraComp.enabled = true; + if (!this.camera.scene && this.scene?.renderScene) { + this.scene.renderScene.addCamera(this.camera); + } + this.camera.changeTargetWindow(this.previewBuffer.window); + this.camera.enabled = true; + } + + public loadScene() { + // @ts-ignore + this.scene._load(); + // @ts-ignore + this.scene._activate(); + + if (this.enableSkybox) { + this.ensureSkyboxMaterial(); + this.loadDefaultEnvmap(); + } + } + + private loadDefaultEnvmap() { + const skyboxInfo = this.scene.globals.skybox; + if (!skyboxInfo) return; + const envmapUuid = 'd032ac98-05e1-4090-88bb-eb640dcb5fc1@b47c0'; + cc.assetManager.loadAny(envmapUuid, (err: any, asset: any) => { + if (err || !asset) return; + skyboxInfo.envmap = asset; + }); + } + + private ensureSkyboxMaterial() { + const skyboxInfo = this.scene.globals.skybox; + const resource = (skyboxInfo as any)?._resource; + if (!resource) { + console.warn(`[InteractivePreview] ensureSkyboxMaterial: no skybox resource`); + return; + } + + const model = resource.model; + const subModels = model?.subModels; + const passes = subModels?.[0]?.passes; + const passCount = passes?.length ?? 0; + + if (model && subModels?.length > 0 && passCount === 0) { + const skyboxEffect = cc.EffectAsset?.get?.('pipeline/skybox'); + if (skyboxEffect) { + const isRGBE = resource._default?.isRGBE ?? false; + const mat = new cc.Material(); + mat.initialize({ effectName: 'pipeline/skybox', defines: { USE_RGBE_CUBEMAP: isRGBE } }); + if (mat.passes?.length > 0) { + resource._editableMaterial = mat; + resource._updatePipeline(); + } + } + } + } + + protected switchViewModeState() { + if (this.is2D) { + this.enableGrid = false; + this.enableAxis = false; + this.disablePan = true; + this.disableRotate = true; + } else { + this.enableGrid = true; + this.enableAxis = true; + this.disablePan = false; + this.disableRotate = false; + } + } + + public init(registerName: string, queryName: string) { + this.switchViewModeState(); + this.initScene(registerName, queryName); + this.createCamera(registerName); + this.createNodes(this.scene); + this.initCamera(); + // Disable camera component before scene activation so that _activate() + // does not add the camera to mainWindow's render list. + this.cameraComp.enabled = false; + this.loadScene(); + this.initSceneCamera(); + this.initPreviewWorldAxis(); + this.initGrid(); + } + + public initPreviewWorldAxis() { + if (!this.worldAxis) { + this.worldAxis = new PreviewWorldAxis(this.scene, this.cameraComp); + } + if (this.previewBuffer?.window) { + this.worldAxis._sceneGizmoCamera.camera.changeTargetWindow(this.previewBuffer.window); + if (this.enableAxis) { + this.worldAxis.show(); + } else { + this.worldAxis.hide(); + } + } else { + this.worldAxis.hide(); + } + } + + public initGrid() { + if (!this.grid) { + this.grid = new Grid(this.scene, this.cameraComp); + } + if (this.enableGrid) { + this.grid.show(); + } else { + this.grid.hide(); + } + } + + resetCamera(modelNode: Node) { + if (this.isOrtho) { + tempVec3A.set(0, 0, 0); + } else { + tempVec3A.set(0, 1, 2.5); + } + this.cameraComp.node.setPosition(tempVec3A); + this.cameraComp.node.lookAt(Vec3.ZERO); + modelNode.getWorldPosition(tempVec3B); + Vec3.set(this.viewCenter, 0, 0, 0); + this.viewDist = Vec3.distance(tempVec3A, tempVec3B); + Service.Engine.repaintInEditMode(); + } + + protected autoPerfectCameraViewOnModel(model: Node) { + this.perfectCameraView(getBoundaryOfMeshNodes([model])); + } + + public panningSpeed = 4; + public orbitRotateSpeed = 0.01; + public viewDist = 10; + public viewCenter = new Vec3(); + + private _isMouseDown = false; + private _right: Vec3 = new Vec3(); + private _up: Vec3 = new Vec3(); + private _v3a = cc.v3(); + private _v3b = cc.v3(); + private _curPos = cc.v3(); + private _curRot = new Quat(); + private _forward = cc.v3(Vec3.UNIT_Z); + + protected perfectCameraView(boundary: geometry.AABB | null | undefined) { + if (boundary) { + const radius = Math.max(boundary.halfExtents.x, boundary.halfExtents.y, boundary.halfExtents.z); + const fov = this.cameraComp.fov * Math.PI / 180; + const requiredDist = radius / Math.tan(fov / 2); + const dist = Vec3.distance(this.cameraComp.node.worldPosition, boundary.center); + this.viewDist = Math.max(dist, requiredDist); + } else if (this._modelNode) { + const uiTransform = this._modelNode.getComponent(UITransform); + if (uiTransform) { + const bbox = uiTransform.getBoundingBoxToWorld(); + Vec3.set(this.viewCenter, bbox.x + bbox.width / 2, bbox.y + bbox.height / 2, 0); + } else { + const pos = this._modelNode.worldPosition; + Vec3.set(this.viewCenter, pos.x, pos.y, pos.z); + } + } + + if (this.isOrtho) { + const position = this.cameraComp.node.position.clone(); + position.x = this.viewCenter.x; + position.y = this.viewCenter.y; + position.z = 1000; + this.cameraComp.node.position = position; + const uiTransform = this._modelNode && this._modelNode.getComponent(UITransform); + if (uiTransform) { + const bbox = uiTransform.getBoundingBoxToWorld(); + this.cameraComp.orthoHeight = Math.max(1, bbox.height / 2); + } else { + this.cameraComp.orthoHeight = Math.max(1, this.viewCenter.y); + } + } else { + this.cameraComp.node.getWorldRotation(this._curRot); + Vec3.transformQuat(tempVec3A, Vec3.UNIT_Z, this._curRot); + Vec3.multiplyScalar(tempVec3A, tempVec3A, this.viewDist); + Vec3.add(tempVec3B, this.viewCenter, tempVec3A); + this.cameraComp.node.setWorldPosition(tempVec3B); + this.cameraComp.node.lookAt(this.viewCenter); + } + Service.Engine.repaintInEditMode(); + } + + public onMouseDown(event: any) { + this._isMouseDown = true; + this.cameraComp.node.getWorldRotation(this._curRot); + this.cameraComp.node.getWorldPosition(this._curPos); + + if ((event.button === EventMouse.BUTTON_LEFT || !event.button) && !this.disableRotate) { + this.isMouseLeft = true; + } + + if (event.button === EventMouse.BUTTON_MIDDLE && !this.disablePan) { + this.isMouseMiddle = true; + Vec3.transformQuat(this._right, Vec3.UNIT_X, this._curRot); + Vec3.normalize(this._right, this._right); + Vec3.transformQuat(this._up, Vec3.UNIT_Y, this._curRot); + Vec3.normalize(this._up, this._up); + } + } + + public onMouseMove(event: any) { + if (!this._isMouseDown) { return; } + + if (this.isMouseMiddle && !this.disablePan) { + this.pan(event.movementX | 0, event.movementY | 0); + } + if (this.isMouseLeft) { + this.rotate(event.movementX | 0, event.movementY | 0); + } + } + + public onMouseUp(event: any) { + this._isMouseDown = false; + this.isMouseLeft = false; + this.isMouseMiddle = false; + } + + public onMouseWheel(event: any) { + if (this.disableMouseWheel) { return; } + + let deltaY = event.wheelDeltaY; + if (Math.abs(deltaY - this.lastMouseWheelDeltaY) > this.maxMouseWheelDeltaY) { + deltaY = this.lastMouseWheelDeltaY + Math.sign(deltaY) * this.maxMouseWheelDeltaY; + } + this.scale(deltaY * this.wheelBaseScale); + } + + protected _modelNode: Node | undefined; + + public onKeyDown(event: any) { + } + + smoothScale2D(curScale: number, delta: number) { + return Math.pow(2, delta * 0.002) * curScale; + } + + protected scale(delta: number) { + if (this.isOrtho) { + const newScale = this.smoothScale2D(this.scale2D, delta); + let newOrthoHeight = this.cameraComp.orthoHeight; + newOrthoHeight += delta * this.wheelSpeed * newScale * this.orthoScale; + if (newOrthoHeight < 0) { + newOrthoHeight = 0.01; + } + this.cameraComp.orthoHeight = newOrthoHeight; + } else { + let scalar = this.viewDist; + if (Math.abs(scalar) < this._minScalar) { + scalar = 1; + } + + delta = smoothMouseWheelScale(delta); + + const cameraNode = this.cameraComp.node; + cameraNode.getWorldPosition(this._curPos); + cameraNode.getWorldRotation(this._curRot); + Vec3.transformQuat(this._forward, Vec3.UNIT_Z, this._curRot); + + Vec3.multiplyScalar(this._v3a, this._forward, delta * this.wheelSpeed * scalar); + Vec3.add(this._curPos, this._curPos, this._v3a); + makeVec3InRange(this._curPos, -1e12, 1e12); + + this.viewDist = Vec3.distance(this._curPos, this.viewCenter); + cameraNode.setWorldPosition(this._curPos); + } + } + + protected rotate(dx: number, dy: number) { + if (!this._isMouseDown && !this.isMouseLeft) { return; } + this.cameraComp.node.getWorldRotation(this._curRot); + const rot = this._curRot; + const euler = cc.v3(); + + Quat.rotateX(rot, rot, -dy * this.orbitRotateSpeed); + Quat.rotateAround(rot, rot, Vec3.UNIT_Y, -dx * this.orbitRotateSpeed); + Quat.toEuler(euler, rot); + + Quat.fromEuler(rot, euler.x, euler.y, 0); + const offset = cc.v3(0, 0, 1); + Vec3.transformQuat(offset, offset, rot); + Vec3.normalize(offset, offset); + + Vec3.multiplyScalar(offset, offset, this.viewDist); + Vec3.add(this._curPos, this.viewCenter, offset); + this.cameraComp.node.setWorldPosition(this._curPos); + + const up = cc.v3(0, 1, 0); + Vec3.transformQuat(up, up, rot); + Vec3.normalize(up, up); + this.cameraComp.node.lookAt(this.viewCenter, up); + } + + protected pan(dx: number, dy: number) { + if (!this._isMouseDown && !this.isMouseMiddle) { return; } + const scalar = this.viewDist / 800; + const node = this.cameraComp.node; + const curPos = this._curPos; + + Vec3.multiplyScalar(this._v3a, this._right, -dx * this.panningSpeed * scalar); + Vec3.multiplyScalar(this._v3b, this._up, dy * this.panningSpeed * scalar); + + node.getWorldPosition(curPos); + Vec3.add(curPos, curPos, this._v3a); + Vec3.add(curPos, curPos, this._v3b); + node.setWorldPosition(curPos); + + Vec3.add(this.viewCenter, this.viewCenter, this._v3a); + Vec3.add(this.viewCenter, this.viewCenter, this._v3b); + this.viewDist = Vec3.distance(curPos, this.viewCenter); + } + + public updateViewCenterByDist(viewDist: number) { + const node = this.cameraComp.node; + const curPos = this._curPos; + node.getWorldPosition(curPos); + node.getWorldRotation(this._curRot); + Vec3.transformQuat(this._forward, Vec3.UNIT_Z, this._curRot); + Vec3.multiplyScalar(this._v3a, this._forward, viewDist); + Vec3.add(this.viewCenter, curPos, this._v3a); + } + + public hide() { + this.cameraComp.enabled = false; + } +} + +export { InteractivePreview, getBoundaryOfMeshNodes }; diff --git a/src/core/scene/scene-process/service/preview/material-preview.ts b/src/core/scene/scene-process/service/preview/material-preview.ts new file mode 100644 index 000000000..97c203c2e --- /dev/null +++ b/src/core/scene/scene-process/service/preview/material-preview.ts @@ -0,0 +1,248 @@ +import { InteractivePreview } from './interactive-preview'; +import { + DirectionalLight, + gfx, + Material, + Mesh, + MeshRenderer, + primitives, + Quat, + utils, + Vec3, + Scene, + Node, + renderer, + director, +} from 'cc'; + +const regions = [new gfx.BufferTextureCopy()]; +regions[0].texExtent.depth = 1; + +function insertAdditionals(geometry: primitives.IGeometry) { + if (!geometry.customAttributes) { + geometry.customAttributes = []; + } + const EditorExtends = (cc as any).EditorExtends || (globalThis as any).EditorExtends; + if (EditorExtends?.GeometryUtils?.calculateTangents) { + geometry.customAttributes.push({ + attr: new gfx.Attribute(gfx.AttributeName.ATTR_TANGENT, gfx.Format.RGBA32F), + values: EditorExtends.GeometryUtils.calculateTangents( + geometry.positions, geometry.indices!, geometry.normals!, geometry.uvs!, + ) as number[], + }); + } + return geometry; +} + +interface IPrimitiveInfo { + mesh: Mesh; + scale: Vec3; +} + +let primitiveData: Record | null = null; + +function getPrimitiveData(): Record { + if (!primitiveData) { + primitiveData = { + box: { + mesh: utils.createMesh(insertAdditionals(primitives.box())), + scale: new Vec3(1, 1, 1), + }, + sphere: { + mesh: utils.createMesh(insertAdditionals(primitives.sphere())), + scale: new Vec3(1, 1, 1), + }, + capsule: { + mesh: utils.createMesh(insertAdditionals(primitives.capsule())), + scale: new Vec3(0.8, 0.8, 0.8), + }, + cylinder: { + mesh: utils.createMesh(insertAdditionals(primitives.cylinder())), + scale: new Vec3(0.8, 0.8, 0.8), + }, + torus: { + mesh: utils.createMesh(insertAdditionals(primitives.torus())), + scale: new Vec3(1, 1, 1), + }, + cone: { + mesh: utils.createMesh(insertAdditionals(primitives.cone())), + scale: new Vec3(1, 1, 1), + }, + quad: { + mesh: utils.createMesh(insertAdditionals(primitives.quad())), + scale: new Vec3(1, 1, 1), + }, + }; + } + return primitiveData; +} + +const tempVec3A = new Vec3(); +const tempVec3B = new Vec3(); + +export class MaterialPreview extends InteractivePreview { + private lightComp!: DirectionalLight; + private modelComp!: MeshRenderer; + private currentPrimitive = 'sphere'; + private material: Material | null = null; + + private dummyUniformBuffer!: gfx.Buffer; + private dummyStorageTexture!: gfx.Texture; + private dummySampleTexture!: gfx.Texture; + private dummySampler!: gfx.Sampler; + private dummyStorageBuffer!: gfx.Buffer; + private uniformBuffer!: gfx.Buffer; + private storageBuffer!: gfx.Buffer; + + protected enableGrid = false; + disablePan = true; + disableMouseWheel = true; + + public init(registerName: string, queryName: string) { + super.init(registerName, queryName); + const device = director.root!.device; + + this.uniformBuffer = device.createBuffer(new gfx.BufferInfo( + gfx.BufferUsageBit.UNIFORM, + gfx.MemoryUsageBit.HOST | gfx.MemoryUsageBit.DEVICE, + 16, + )); + this.dummyUniformBuffer = device.createBuffer(new gfx.BufferViewInfo(this.uniformBuffer, 0, this.uniformBuffer.size)); + + this.storageBuffer = device.createBuffer(new gfx.BufferInfo( + gfx.BufferUsageBit.UNIFORM, + gfx.MemoryUsageBit.HOST | gfx.MemoryUsageBit.DEVICE, + 16, + )); + this.dummyStorageBuffer = device.createBuffer(new gfx.BufferViewInfo(this.storageBuffer, 0, this.storageBuffer.size)); + + this.dummySampleTexture = device.createTexture(new gfx.TextureInfo( + gfx.TextureType.TEX2D, + gfx.TextureUsageBit.SAMPLED, + gfx.Format.RGBA8, + 4, 4, + )); + this.dummyStorageTexture = device.createTexture(new gfx.TextureInfo( + gfx.TextureType.TEX2D, + gfx.TextureUsageBit.SAMPLED, + gfx.Format.RGBA8, + 4, 4, + )); + this.dummySampler = device.getSampler(new gfx.SamplerInfo()); + } + + public createNodes(scene: Scene) { + this.lightComp = new Node('Material Preview Light').addComponent(DirectionalLight); + this.lightComp.node.setRotationFromEuler(-45, -45, 0); + this.lightComp.node.setParent(scene); + + this.modelComp = new Node('Material Preview Model').addComponent(MeshRenderer); + this.modelComp.mesh = getPrimitiveData().sphere.mesh; + const material = new Material(); + material.initialize({ effectName: 'builtin-standard' }); + this.modelComp.material = material; + this.setMaterial(material); + + this.modelComp.node.setParent(this.scene); + this._modelNode = this.modelComp.node; + } + + public setMaterial(material: Material | null) { + if (material && material !== this.material) { + const comp = this.modelComp; + const _matInsInfo = { + parent: material, + owner: comp as any, + subModelIdx: 0, + }; + const instantiated = new renderer.MaterialInstance(_matInsInfo); + comp.material = instantiated; + this.material = material; + this.updateDs(); + this.cameraComp.enabled = true; + this.cameraComp.node.getWorldPosition(tempVec3A); + this.modelComp.node.getWorldPosition(tempVec3B); + this.viewDist = Vec3.distance(tempVec3A, tempVec3B); + } + } + + public updateDs() { + const model = this.modelComp.model; + if (model) { + for (let i = 0; i < model.subModels.length; i++) { + const ds = model.subModels[i].descriptorSet; + const bindings = ds.layout.bindings; + const device = director.root!.device; + for (let j = 0; j < bindings.length; j++) { + const desc = bindings[j]; + const binding = desc.binding; + const dsType = desc.descriptorType; + if (dsType & gfx.DescriptorType.UNIFORM_BUFFER || + dsType & gfx.DescriptorType.DYNAMIC_UNIFORM_BUFFER) { + if (!ds.getBuffer(binding)) { ds.bindBuffer(binding, this.dummyUniformBuffer); } + } else if (dsType & gfx.DescriptorType.STORAGE_BUFFER || + dsType & gfx.DescriptorType.DYNAMIC_STORAGE_BUFFER) { + if (!ds.getBuffer(binding)) { ds.bindBuffer(binding, this.dummyStorageBuffer); } + } else if (dsType & (gfx as any).DESCRIPTOR_SAMPLER_TYPE) { + if (!ds.getTexture(binding)) { + if (dsType & gfx.DescriptorType.SAMPLER_TEXTURE || + dsType & gfx.DescriptorType.TEXTURE) { + ds.bindTexture(binding, this.dummySampleTexture); + } else if (dsType & gfx.DescriptorType.STORAGE_IMAGE) { + ds.bindTexture(binding, this.dummyStorageTexture); + } + } + if (!ds.getSampler(binding)) { ds.bindSampler(binding, this.dummySampler); } + } + } + ds.update(); + } + } + } + + public async setMaterialByUuid(uuid: string) { + if (!uuid) { + console.warn(`Failed to set material in Material preview, by uuid: ${uuid}`); + return; + } + try { + const material = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(`Load material timeout: ${uuid}`)), 10000); + cc.assetManager.loadAny(uuid, (err: any, asset: any) => { + clearTimeout(timeout); + if (err) reject(err); + else resolve(asset); + }); + }); + this.setMaterial(material); + this.resetCameraView(); + } catch (e) { + console.warn(`[MaterialPreview] setMaterial failed:`, e); + this.resetCameraView(); + } + } + + public switchPrimitive(type: string) { + const data = getPrimitiveData(); + if (!data[type]) return; + this.currentPrimitive = type; + this.modelComp.mesh = data[type].mesh; + this.updateDs(); + this.modelComp.node.setScale(data[type].scale); + this.cameraComp.enabled = true; + this.resetCameraView(); + } + + public setLightEnable(enable: boolean) { + if (this.lightComp.enabled !== enable) { + this.lightComp.enabled = enable; + } + } + + public resetCameraView() { + if (this._modelNode) { + this.resetCamera(this._modelNode); + this.autoPerfectCameraViewOnModel(this._modelNode); + } + } +} diff --git a/src/core/scene/scene-process/service/preview/mesh-preview.ts b/src/core/scene/scene-process/service/preview/mesh-preview.ts new file mode 100644 index 000000000..e7ad8751e --- /dev/null +++ b/src/core/scene/scene-process/service/preview/mesh-preview.ts @@ -0,0 +1,58 @@ +import { InteractivePreview, getBoundaryOfMeshNodes } from './interactive-preview'; +import { DirectionalLight, Material, Mesh, MeshRenderer, Scene, Node, assetManager } from 'cc'; + +export class MeshPreview extends InteractivePreview { + private lightComp: DirectionalLight | any; + private _modelComp!: MeshRenderer; + private _defaultMat!: Material; + + public createNodes(scene: Scene) { + this.lightComp = new Node('Mesh Preview Light').addComponent(DirectionalLight); + this.lightComp.node.setRotationFromEuler(-45, -45, 0); + this.lightComp.node.parent = scene; + + this._modelNode = new Node('Mesh Preview Mesh'); + this._modelNode.parent = scene; + this._modelComp = this._modelNode.addComponent(MeshRenderer); + this._defaultMat = new Material(); + this._defaultMat.initialize({ effectName: 'builtin-standard' }); + this._modelComp.material = this._defaultMat; + } + + public async setMesh(uuid: string) { + if (!uuid) { + console.warn(`Failed to set mesh in Mesh preview, by uuid: ${uuid}`); + return null; + } + + try { + assetManager.assets.remove(uuid); + const meshAsset = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(`Load mesh timeout: ${uuid}`)), 10000); + assetManager.loadAny(uuid, (err: any, asset: any) => { + clearTimeout(timeout); + if (err) reject(err); + else resolve(asset); + }); + }); + + this._modelComp.mesh = meshAsset; + this._modelNode!.parent = this.scene; + + for (let i = 0; i < this._modelComp.mesh!.struct.primitives.length; i++) { + this._modelComp.setMaterial(this._defaultMat, i); + } + this.cameraComp.enabled = true; + this.resetCameraView(); + } catch (e) { + console.warn(e); + } + } + + public resetCameraView() { + if (this._modelNode) { + this.resetCamera(this._modelNode); + this.perfectCameraView(getBoundaryOfMeshNodes([this._modelNode])); + } + } +} diff --git a/src/core/scene/scene-process/service/preview/mini-preview/apply.ts b/src/core/scene/scene-process/service/preview/mini-preview/apply.ts new file mode 100644 index 000000000..d12f27b46 --- /dev/null +++ b/src/core/scene/scene-process/service/preview/mini-preview/apply.ts @@ -0,0 +1,49 @@ +import { renderer } from 'cc'; + +const _d2r = Math.PI / 180.0; + +function toRadian(a: number) { + return a * _d2r; +} + +export function applyCamera(cameraComponent: any, camera: renderer.scene.Camera | null = null) { + if (camera) { + camera.setViewportInOrientedSpace(cameraComponent.rect); + camera.fov = toRadian(cameraComponent.fov); + camera.fovAxis = cameraComponent.fovAxis; + camera.orthoHeight = cameraComponent.orthoHeight; + camera.nearClip = cameraComponent.near; + camera.farClip = cameraComponent.far; + camera.projectionType = cameraComponent.camera ? cameraComponent.camera.projectionType : 1; + const x = cameraComponent.clearColor.x; + const y = cameraComponent.clearColor.y; + const z = cameraComponent.clearColor.z; + const w = cameraComponent.clearColor.w; + camera.clearColor = { x, y, z, w }; + camera.clearDepth = cameraComponent.clearDepth; + camera.clearStencil = cameraComponent.clearStencil; + camera.clearFlag = cameraComponent.clearFlags; + camera.visibility = cameraComponent.visibility; + camera.aperture = cameraComponent.aperture; + camera.shutter = cameraComponent.shutter; + camera.iso = cameraComponent.iso; + } + return camera; +} + +export function attachToScene(node: any, camera: any) { + if (!node.scene || !node._camera) { + return; + } + if (camera && camera.scene) { + camera.scene.removeCamera(camera); + } + const scene = node.scene.renderScene(); + scene.addCamera(camera); +} + +export function detachFromScene(camera: any) { + if (camera && camera.scene) { + camera.scene.removeCamera(camera); + } +} diff --git a/src/core/scene/scene-process/service/preview/mini-preview/index.ts b/src/core/scene/scene-process/service/preview/mini-preview/index.ts new file mode 100644 index 000000000..a66ea0c1c --- /dev/null +++ b/src/core/scene/scene-process/service/preview/mini-preview/index.ts @@ -0,0 +1,144 @@ +import * as apply from './apply'; +import { createPreviewNode } from './private'; +import { Camera, CameraComponent, Node, renderer } from 'cc'; +import { PreviewBase } from '../preview-base'; +import PreviewBuffer from '../buffer'; +import { Service } from '../../core/decorator'; + +export class MiniPreview extends PreviewBase { + previewNodes: any = {}; + scene: any = null; + renderScene: any = null; + currNode: any = null; + _previewInfo: any; + + public init(registerName: string, queryName: string) { + this.previewBuffer = new PreviewBuffer(registerName, queryName); + if (this.previewBuffer.window) { + cc.director.root.destroyWindow(this.previewBuffer.window); + } + this._previewInfo = { + width: 320, + height: 240, + }; + } + + public setPreviewResolution(width: number, height: number) { + this._previewInfo = { width, height }; + } + + setAspect(srcCamCom: any, tarCam: any) { + if (srcCamCom.targetTexture) { + tarCam._aspect = srcCamCom.camera.aspect; + } else { + tarCam._aspect = this._previewInfo.width / this._previewInfo.height; + } + } + + public onNodeChanged(node: Node, opts: any) { + if (!node) return; + const srcCamera = node.getComponent('cc.Camera') as CameraComponent; + if (!srcCamera) return; + if (node === this.currNode && srcCamera) { + if (!this.previewNodes[srcCamera.uuid]) { + this.createPreviewNode(srcCamera); + } + const previewNode = this.previewNodes[srcCamera.uuid]; + apply.applyCamera(srcCamera, previewNode.camera); + this.setAspect(srcCamera, previewNode.camera); + Service.Engine.repaintInEditMode(); + } + } + + public onNodeRemoved(node: Node) { + const srcCamera = node.getComponent('cc.Camera') as CameraComponent; + if (!srcCamera) return; + this.removePreviewNode(srcCamera); + } + + handleSelect(uuid: string) { + const EditorExtends = (cc as any).EditorExtends || (globalThis as any).EditorExtends; + const currComp = EditorExtends?.Component?.getComponent?.(uuid); + if (!currComp) return; + if (!currComp.node.active || !currComp.node.activeInHierarchy || !currComp.enabled) { + return; + } + Service.Engine.repaintInEditMode(); + this.createPreviewNode(currComp as CameraComponent); + } + + handleUnselect(uuid: string) { + const EditorExtends = (cc as any).EditorExtends || (globalThis as any).EditorExtends; + const currComp = EditorExtends?.Component?.getComponent?.(uuid); + if (!currComp || !(currComp instanceof Camera)) return; + this.removePreviewNode(currComp); + } + + public onComponentRemoved(comp: CameraComponent) { + if (!(comp instanceof Camera)) return; + Service.Engine.repaintInEditMode(); + this.removePreviewNode(comp); + } + + private clearByComponent(comp: CameraComponent) { + if (comp instanceof Camera) { + const { uuid } = comp; + if (this.previewBuffer.windows[uuid]) { + this.previewBuffer.removeWindow(uuid); + } + if (this.previewNodes[uuid]) { + this.previewNodes[uuid].node.destroy(); + this.previewNodes[uuid].camera.destroy(); + delete this.previewNodes[uuid]; + } + } + } + + removePreviewNode(srcCamera: CameraComponent) { + const currNode = srcCamera.node; + for (let i = 0; i < currNode.children.length; ++i) { + const privateCamera = currNode.children[i].getComponent('cc.Camera'); + // @ts-expect-error + if (currNode.children[i].isPrivatePreview && privateCamera) { + const privateNode = currNode.children[i]; + privateNode.destroy(); + srcCamera.node.removeChild(privateNode); + } + } + this.currNode = null; + this.clearByComponent(srcCamera); + } + + createPreviewNode(srcCamera: CameraComponent) { + this.clearByComponent(srcCamera); + const name = srcCamera.node.name; + const privateNode = createPreviewNode(name); + const privateCamera = privateNode.addComponent('cc.Camera') as Camera; + srcCamera.node.addChild(privateNode); + + if (!privateCamera.camera) { + return; + } + + privateCamera.camera.cameraUsage = renderer.scene.CameraUsage.PREVIEW; + this.previewNodes[srcCamera.uuid] = { node: privateNode, camera: privateCamera.camera }; + + const previewNode = this.previewNodes[srcCamera.uuid]; + apply.applyCamera(srcCamera, previewNode.camera); + this.setAspect(srcCamera, previewNode.camera); + + if (!this.previewBuffer.windows[srcCamera.uuid]) { + this.previewBuffer.createWindow(srcCamera.uuid); + } else { + this.previewBuffer.window = this.previewBuffer.windows[srcCamera.uuid]; + this.clearPreviewBuffer(); + } + this.previewBuffer.switchCameras(previewNode.camera, this.previewBuffer.window); + this.currNode = srcCamera.node; + return previewNode; + } + + public getPreviewInfo() { + return this._previewInfo; + } +} diff --git a/src/core/scene/scene-process/service/preview/mini-preview/private.ts b/src/core/scene/scene-process/service/preview/mini-preview/private.ts new file mode 100644 index 000000000..475a5751b --- /dev/null +++ b/src/core/scene/scene-process/service/preview/mini-preview/private.ts @@ -0,0 +1,12 @@ +import { CCObject, Node } from 'cc'; + +const DontSave = CCObject.Flags.DontSave; +const HideInHierarchy = CCObject.Flags.HideInHierarchy; + +export function createPreviewNode(name: string): Node { + const node = new Node(name); + // @ts-ignore + node.isPrivatePreview = true; + node.objFlags |= DontSave | HideInHierarchy; + return node; +} diff --git a/src/core/scene/scene-process/service/preview/model-preview.ts b/src/core/scene/scene-process/service/preview/model-preview.ts new file mode 100644 index 000000000..315ec77d8 --- /dev/null +++ b/src/core/scene/scene-process/service/preview/model-preview.ts @@ -0,0 +1,85 @@ +import { InteractivePreview, getBoundaryOfMeshNodes } from './interactive-preview'; +import { DirectionalLight, Scene, Node, Prefab, assetManager, instantiate } from 'cc'; +import { Service } from '../core/decorator'; +import { Rpc } from '../../rpc'; + +export class ModelPreview extends InteractivePreview { + private lightComp: DirectionalLight | any; + + public createNodes(scene: Scene) { + this.lightComp = new Node('Model Preview Light').addComponent(DirectionalLight); + this.lightComp.node.setRotationFromEuler(-45, -45, 0); + this.lightComp.node.parent = scene; + } + + // For gltf/fbx root assets, resolve to the Prefab sub-asset UUID + // (the root asset has no .json library file — only sub-assets do) + private async resolvePrefabUuid(uuid: string): Promise { + try { + const assetInfo = await Rpc.getInstance().request('assetManager', 'queryAssetInfo', [uuid, ['subAssets']]); + if (assetInfo?.subAssets) { + for (const name of Object.keys(assetInfo.subAssets)) { + const sub = assetInfo.subAssets[name]; + if (sub.importer === 'gltf-scene' || sub.type === 'cc.Prefab') { + return sub.uuid; + } + } + } + } catch (e) { + console.warn('[ModelPreview] Failed to resolve prefab sub-asset:', e); + } + return uuid; + } + + public async setModel(uuid: string) { + if (!uuid) { + console.warn(`Failed to set model in Model preview, by uuid: ${uuid}`); + return null; + } + + const prefabUuid = await this.resolvePrefabUuid(uuid); + + assetManager.assets.remove(prefabUuid); + const prefabAsset = await new Promise((resolve, reject) => { + assetManager.loadAny(prefabUuid, { reloadAsset: true }, (err: any, result: any) => { + if (err) reject(err); + else resolve(result); + }); + }); + + this.cameraComp.enabled = true; + + if (this._modelNode) { + this.scene.removeChild(this._modelNode); + if (this._modelNode.isValid) { + this._modelNode.destroy(); + } + } + + this._modelNode = instantiate(prefabAsset) as Node; + this._modelNode.parent = this.scene; + + this.resetCamera(this._modelNode); + + Service.Engine.repaintInEditMode(); + return await new Promise((resolve) => { + cc.director.once(cc.Director.EVENT_AFTER_DRAW, () => { + this.perfectCameraView(getBoundaryOfMeshNodes([this._modelNode!])); + resolve(null); + }); + }); + } + + public resetCameraView() { + if (this._modelNode) { + this.resetCamera(this._modelNode); + this.perfectCameraView(getBoundaryOfMeshNodes([this._modelNode])); + } + } + + public setLightEnable(enable: boolean) { + if (this.lightComp.enabled !== enable) { + this.lightComp.enabled = enable; + } + } +} diff --git a/src/core/scene/scene-process/service/preview/prefab-preview.ts b/src/core/scene/scene-process/service/preview/prefab-preview.ts new file mode 100644 index 000000000..3a66b09ec --- /dev/null +++ b/src/core/scene/scene-process/service/preview/prefab-preview.ts @@ -0,0 +1,69 @@ +import { InteractivePreview, getBoundaryOfMeshNodes } from './interactive-preview'; +import { DirectionalLight, Scene, assetManager, Prefab, instantiate, UITransform, Canvas, Node } from 'cc'; + +export class PrefabPreview extends InteractivePreview { + private lightComp: DirectionalLight | any; + private canvasNode: Node | null = null; + + public createNodes(scene: Scene) { + this.lightComp = new Node('Prefab Preview Light').addComponent(DirectionalLight); + this.lightComp.node.setRotationFromEuler(-45, -45, 0); + this.lightComp.node.parent = scene; + } + + public async setPrefab(uuid: string) { + if (!uuid) { + console.warn(`Failed to instantiate prefab in Prefab preview, by uuid: ${uuid}`); + return null; + } + + if (this._modelNode && this._modelNode.isValid) { + this._modelNode.destroy(); + this._modelNode.parent = null; + } + if (this.canvasNode && this.canvasNode.isValid) { + this.canvasNode.destroy(); + this.canvasNode.parent = null; + } + + if (assetManager.assets.has(uuid)) { + assetManager.releaseAsset(assetManager.assets.get(uuid)!); + assetManager.assets.remove(uuid); + } + try { + const prefabAsset = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(`Load prefab timeout: ${uuid}`)), 10000); + assetManager.loadAny(uuid, (err: any, asset: any) => { + clearTimeout(timeout); + if (err) reject(err); + else resolve(asset); + }); + }); + this._modelNode = instantiate(prefabAsset); + + const needCreateCanvas = this._modelNode.getComponentsInChildren(UITransform).length > 0 + && this._modelNode.getComponentsInChildren(Canvas).length === 0; + if (needCreateCanvas) { + this.canvasNode = new Node('New Canvas'); + this.canvasNode.addComponent(Canvas); + this.scene.addChild(this.canvasNode); + this.canvasNode.addChild(this._modelNode); + } else { + this.scene.addChild(this._modelNode); + } + + this._modelNode.setPosition(0, 0, 0); + this.cameraComp.enabled = true; + this.resetCameraView(); + } catch (e) { + console.warn(e); + } + } + + public resetCameraView() { + if (this._modelNode) { + this.resetCamera(this._modelNode); + this.perfectCameraView(getBoundaryOfMeshNodes([this._modelNode])); + } + } +} diff --git a/src/core/scene/scene-process/service/preview/preview-axis.ts b/src/core/scene/scene-process/service/preview/preview-axis.ts new file mode 100644 index 000000000..ab43d5521 --- /dev/null +++ b/src/core/scene/scene-process/service/preview/preview-axis.ts @@ -0,0 +1,188 @@ +import { Node, Vec3, Camera, Color, Quat, assetManager, Layers, gfx, Rect } from 'cc'; +import ControllerBase from '../gizmo/controller/base'; +import ControllerUtils from '../gizmo/utils/controller-utils'; +import ControllerShape from '../gizmo/utils/controller-shape'; +import { setNodeOpacity, setMaterialProperty, create3DNode, setMeshColor } from '../gizmo/utils/engine-utils'; +import { Service } from '../core/decorator'; + +const axisDirMap = ControllerUtils.axisDirectionMap; +const AxisName = ControllerUtils.AxisName; + +function clamp(v: number, min: number, max: number): number { + return Math.min(max, Math.max(min, v)); +} + +function LimitLerp(a: number, b: number, t: number, tMin: number, tMax: number) { + t = clamp((t - tMin) / (tMax - tMin), 0, 1); + return a * (1 - t) + b * t; +} + +const camera_forward = new Vec3(0, 0, -1); +const tempVec3_a = new Vec3(); +const tempVec3_b = new Vec3(); +const tempQuat_a = new Quat(); + +export class PreviewWorldAxis extends ControllerBase { + public _sceneGizmoCamera: Camera; + private _cameraOffset: Vec3 = new Vec3(0, 0, 40); + private _textNodeMap: Map = new Map(); + private synchronizeCamera: Camera; + + constructor(rootNode: Node, synchronizeCamera: Camera) { + super(rootNode); + this._sceneGizmoCamera = new Node('axis-camera').addComponent(Camera); + this._sceneGizmoCamera.node.parent = rootNode; + this._sceneGizmoCamera.camera.visibility = Layers.Enum.SCENE_GIZMO; + this._sceneGizmoCamera.camera.clearFlag = gfx.ClearFlagBit.DEPTH_STENCIL; + this._sceneGizmoCamera.clearColor = synchronizeCamera.clearColor; + + const curWindow = (cc as any).director.root.curWindow; + const winWidth = curWindow?.width ?? 800; + const winHeight = curWindow?.height ?? 600; + const height = winHeight / 3; + const heightPercent = height / winHeight; + const delta = ((winWidth - winHeight) * heightPercent) / 2 / winWidth; + const dpr = typeof window !== 'undefined' ? window.devicePixelRatio : 1; + const padding = 30 * dpr / winHeight; + this._sceneGizmoCamera.rect = new Rect(1 - heightPercent + delta, padding, heightPercent, heightPercent); + + this.synchronizeCamera = synchronizeCamera; + + this.initShape(); + } + + initShape() { + this.createShapeNode('PreviewWorldAxis'); + // x axis + this.createAxis('x', cc.Color.RED, cc.v3(0, 0, -90)); + + // y axis + this.createAxis('y', cc.Color.GREEN, cc.v3()); + + // z axis + this.createAxis('z', cc.Color.BLUE, cc.v3(90, 0, 0)); + + this.createAxisText(AxisName.x, 'ac74fa2b-1f5b-4ff5-a3f0-f127f4483e91@6c48a', Color.RED); + this.createAxisText(AxisName.y, '7b5313d0-f1aa-4b1b-a3c8-59d523c35301@6c48a', Color.GREEN); + this.createAxisText(AxisName.z, '389d5fee-e29c-4221-b397-a4934a0a5694@6c48a', Color.BLUE); + + this.registerCameraMovedEvent(); + } + + private _hide = false; + + public hide(): void { + if (this.shape) { + this.shape.active = false; + } + this._hide = true; + this._sceneGizmoCamera.enabled = false; + } + + public show(): void { + if (this.shape) { + this.shape.active = true; + } + this._hide = false; + this._sceneGizmoCamera.enabled = true; + } + + public createShapeNode(name: string) { + const node = create3DNode(name); + node.parent = this._rootNode; + this.shape = node; + } + + createAxis(axisName: string, color: Color, rotation: Vec3) { + const baseArrowBodyHeight = 6; + + const axisNode = new Node(); + // line + const lineData = ControllerShape.calcLineData(new Vec3(0, 0, 0), new Vec3(0, baseArrowBodyHeight, 0)); + const bodyOpts: any = { noDepthTestForLines: true, forwardPipeline: true, bodyBBSize: 0 }; + const lineNode = ControllerUtils.createShapeByData(lineData, color, bodyOpts); + lineNode.name = 'ArrowLine'; + lineNode.parent = axisNode; + setMeshColor(lineNode, color); + + axisNode.name = axisName + 'Axis'; + axisNode.children.forEach((node: Node) => { + node.layer = cc.Layers.Enum.SCENE_GIZMO; + }); + axisNode.parent = this.shape; + axisNode.setRotationFromEuler(rotation); + + this.initHandle(axisNode, axisName); + } + + createAxisText(axis: string, uuid: string, color: Color) { + const axisNode = this._handleDataMap[axis]; + const textNode = ControllerUtils.quad(Vec3.ZERO, 3, 3, Vec3.UNIT_Z, color, { texture: true, needBoundingBox: false }); + this.setTextureByUUID(textNode, uuid); + textNode.setPosition(0, 9, 0); + textNode.parent = axisNode.topNode; + textNode.layer = cc.Layers.Enum.SCENE_GIZMO; + this._textNodeMap.set(axis, textNode); + } + + setTextureByUUID(node: Node, uuid: string) { + assetManager.loadAny(uuid, (err: any, img: any) => { + if (img) { + setMaterialProperty(node, 'mainTexture', img); + if (!this._hide) { + Service.Engine.repaintInEditMode(); + } + } + }); + } + + public registerCameraMovedEvent() { + this.synchronizeCamera.node.on('transform-changed', this.onEditorCameraMoved, this); + } + + onEditorCameraMoved() { + if (this._hide) { return; } + const cameraRot = tempQuat_a; + this.adjustControllerSize(); + this.synchronizeCamera.camera.node.getWorldRotation(cameraRot); + + // face text to camera + this._textNodeMap.forEach((textNode: Node) => { + textNode?.setWorldRotation(cameraRot); + }); + + // alpha + Vec3.transformQuat(tempVec3_a, camera_forward, cameraRot); + Object.keys(this._handleDataMap).forEach((key) => { + const axisData = this._handleDataMap[key]; + const dir = axisDirMap[key]; + if (dir) { + const opacity = LimitLerp(1, 0, Math.abs(Vec3.dot(tempVec3_a, dir)), 0.9, 1.0) * 255; + + const rendererNodes = axisData.rendererNodes; + if (rendererNodes) { + rendererNodes.forEach((node: Node, index: number) => { + if (opacity < 10) { + node.active = false; + } else { + node.active = true; + setNodeOpacity(node, opacity); + axisData.oriOpacities[index] = opacity; + } + }); + } + } + }); + + // sync rotation of Editor Camera + const sceneGizmoCameraNode = this._sceneGizmoCamera!.node; + + Vec3.transformQuat(tempVec3_b, this._cameraOffset, cameraRot); + Vec3.add(tempVec3_b, this.getPosition(), tempVec3_b); + sceneGizmoCameraNode.setWorldPosition(tempVec3_b); + + Vec3.transformQuat(tempVec3_b, Vec3.UNIT_Y, cameraRot); + Vec3.normalize(tempVec3_b, tempVec3_b); + sceneGizmoCameraNode.lookAt(this.getPosition(), tempVec3_b); + } +} diff --git a/src/core/scene/scene-process/service/preview/preview-base.ts b/src/core/scene/scene-process/service/preview/preview-base.ts new file mode 100644 index 000000000..27477c9d0 --- /dev/null +++ b/src/core/scene/scene-process/service/preview/preview-base.ts @@ -0,0 +1,22 @@ +import PreviewBuffer from './buffer'; + +class PreviewBase { + protected previewBuffer!: PreviewBuffer; + + public async queryPreviewData(info: any) { + const data = await this.previewBuffer.getImageData(info.width, info.height); + return data; + } + + public queryPreviewDataQueue(info: any): Promise { + return this.previewBuffer.getImageDataInQueue(info.width, info.height); + } + + clearPreviewBuffer() { + this.previewBuffer.clear(); + } + + public init(registerName: string, queryName: string) { } +} + +export { PreviewBase }; diff --git a/src/core/scene/scene-process/service/preview/scene-preview.ts b/src/core/scene/scene-process/service/preview/scene-preview.ts new file mode 100644 index 000000000..b99650277 --- /dev/null +++ b/src/core/scene/scene-process/service/preview/scene-preview.ts @@ -0,0 +1,45 @@ +import { Layers, Camera, CameraComponent } from 'cc'; +import PreviewBuffer from './buffer'; +import { PreviewBase } from './preview-base'; + +const editorMask = Layers.makeMaskInclude([Layers.Enum.GIZMOS, Layers.Enum.SCENE_GIZMO, Layers.Enum.EDITOR]); + +export class ScenePreview extends PreviewBase { + device: any; + width = 0; + height = 0; + + public init(registerName: string, queryName: string) { + this.device = cc.director.root.device; + this.width = this.device.width; + this.height = this.device.height; + + this.previewBuffer = new PreviewBuffer(registerName, queryName); + this.previewBuffer.on('loadScene', this.detachSceneCameras.bind(this)); + } + + public onComponentAdded(comp: CameraComponent) { + if (!comp) return; + if (comp instanceof Camera) { + Promise.resolve().then(() => { + if (comp.camera) comp.camera.detachCamera(); + }); + } + } + + detachSceneCameras() { + const cameras = this.previewBuffer.renderScene!.cameras; + for (const camera of cameras) { + if (camera.node.layer & editorMask) { + continue; + } + const comp = camera.node.getComponent('cc.Camera'); + if (comp && !camera.node.isPrivatePreview) { + camera.detachCamera(); + } + } + cc.director.root.tempWindow = this.previewBuffer.window; + } +} + +export const scenePreview = new ScenePreview(); diff --git a/src/core/scene/scene-process/service/preview/skeleton-preview.ts b/src/core/scene/scene-process/service/preview/skeleton-preview.ts new file mode 100644 index 000000000..ab8d9df62 --- /dev/null +++ b/src/core/scene/scene-process/service/preview/skeleton-preview.ts @@ -0,0 +1,100 @@ +import { InteractivePreview } from './interactive-preview'; +import { DirectionalLight, Scene, Node, assetManager, Vec3 } from 'cc'; +import { Service } from '../core/decorator'; + +export class SkeletonPreview extends InteractivePreview { + private lightComp: DirectionalLight | any; + private jointNodes: Node[] = []; + + public createNodes(scene: Scene) { + this.lightComp = new Node('Skeleton Preview Light').addComponent(DirectionalLight); + this.lightComp.node.setRotationFromEuler(-45, -45, 0); + this.lightComp.node.parent = scene; + } + + public async setSkeleton(uuid: string) { + if (!uuid) { + console.warn(`Failed to set skeleton in Skeleton preview, by uuid: ${uuid}`); + return; + } + + this.clearJoints(); + + try { + const skeleton = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(`Load skeleton timeout: ${uuid}`)), 10000); + assetManager.loadAny(uuid, (err: any, asset: any) => { + clearTimeout(timeout); + if (err) reject(err); + else resolve(asset); + }); + }); + + if (!skeleton || !skeleton.joints) { + return; + } + + this._modelNode = new Node('Skeleton Root'); + this._modelNode.parent = this.scene; + + const joints = skeleton.joints; + const bindposes = skeleton.bindposes; + + for (let i = 0; i < joints.length; i++) { + const jointNode = new Node(`Joint_${i}`); + jointNode.parent = this._modelNode; + + if (bindposes && bindposes[i]) { + const bp = bindposes[i]; + jointNode.setWorldPosition(new Vec3(bp.m12, bp.m13, bp.m14)); + } + this.jointNodes.push(jointNode); + } + + this.cameraComp.enabled = true; + this.resetCameraView(); + + const geometryRenderer = (Service.Engine as any).getGeometryRenderer?.(); + if (geometryRenderer) { + this.drawSkeletonLines(geometryRenderer); + } + } catch (e) { + console.warn(e); + } + } + + private drawSkeletonLines(geometryRenderer: any) { + if (!this.jointNodes.length) return; + + for (let i = 1; i < this.jointNodes.length; i++) { + const joint = this.jointNodes[i]; + const parent = this.jointNodes[0]; + if (joint && parent) { + try { + geometryRenderer.addLine( + parent.worldPosition, + joint.worldPosition, + cc.Color.GREEN, + ); + } catch { + // geometryRenderer may not support addLine + } + } + } + } + + private clearJoints() { + if (this._modelNode && this._modelNode.isValid) { + this._modelNode.destroy(); + this._modelNode.parent = null; + } + this.jointNodes = []; + } + + public resetCameraView() { + if (this._modelNode) { + this.resetCamera(this._modelNode); + this.autoPerfectCameraViewOnModel(this._modelNode); + } + } +} diff --git a/src/core/scene/scene-process/service/preview/spine-preview.ts b/src/core/scene/scene-process/service/preview/spine-preview.ts new file mode 100644 index 000000000..4405d66ad --- /dev/null +++ b/src/core/scene/scene-process/service/preview/spine-preview.ts @@ -0,0 +1,147 @@ +import { InteractivePreview, getBoundaryOfMeshNodes } from './interactive-preview'; +import { Scene, Node, assetManager, DirectionalLight, Canvas } from 'cc'; +import { Service } from '../core/decorator'; + +export class SpinePreview extends InteractivePreview { + protected is2D = true; + protected enableViewToggle = false; + protected orthoScale = 0.6; + + private skeletonComponent: any = null; + private _spineData: any = null; + private _animTimer: NodeJS.Timeout | null = null; + + public createNodes(scene: Scene) { + const lightNode = new Node('Spine Preview Light'); + lightNode.addComponent(DirectionalLight); + lightNode.setRotationFromEuler(-45, -45, 0); + scene.addChild(lightNode); + + const canvasNode = new Node('Canvas'); + canvasNode.addComponent(Canvas); + scene.addChild(canvasNode); + + this._modelNode = new Node('Spine'); + this._modelNode.setPosition(0, 0, 0); + canvasNode.addChild(this._modelNode); + + const SpineClass = cc.js.getClassByName('sp.Skeleton'); + if (SpineClass) { + this.skeletonComponent = this._modelNode.addComponent(SpineClass as any); + } + } + + public async setSpine(uuid: string) { + if (!uuid) { + console.warn(`Failed to set spine in Spine preview, by uuid: ${uuid}`); + return; + } + + if (!this.skeletonComponent) { + console.warn('[SpinePreview] sp.Skeleton component not available'); + return; + } + + this.close(); + + try { + const skeletonData = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(`Load spine timeout: ${uuid}`)), 10000); + assetManager.loadAny(uuid, (err: any, asset: any) => { + clearTimeout(timeout); + if (err) reject(err); + else resolve(asset); + }); + }); + + this.skeletonComponent.node.active = true; + this.skeletonComponent.skeletonData = skeletonData; + + this.cameraComp.enabled = true; + this.resetCamera(this.skeletonComponent.node); + this.perfectCameraView(getBoundaryOfMeshNodes([this.skeletonComponent.node])); + + this._spineData = { + skins: this.skeletonComponent.getSkins?.() || [], + animations: this.skeletonComponent.getAnimations?.() || [], + }; + + if (this._spineData.animations.length > 0) { + this.skeletonComponent.animation = this._spineData.animations[0]; + } + + this.startAnimationUpdate(); + } catch (e) { + console.warn(e); + } + } + + public getSpineData() { + return this._spineData; + } + + public play() { + if (this.skeletonComponent) { + this.skeletonComponent.paused = false; + } + } + + public pause() { + if (this.skeletonComponent) { + this.skeletonComponent.paused = true; + } + } + + public stop() { + if (this.skeletonComponent) { + this.skeletonComponent.clearAnimation(0); + } + } + + public setSkinIndex(index: number) { + if (this.skeletonComponent && this._spineData?.skins?.[index]) { + this.skeletonComponent.setSkin(this._spineData.skins[index]); + } + } + + public setAnimationIndex(index: number) { + if (this.skeletonComponent && this._spineData?.animations?.[index]) { + this.skeletonComponent.animation = this._spineData.animations[index]; + } + } + + private startAnimationUpdate() { + this.stopAnimationUpdate(); + this._animTimer = setInterval(() => { + Service.Engine.repaintInEditMode(); + }, 1000 / 30); + } + + private stopAnimationUpdate() { + if (this._animTimer) { + clearInterval(this._animTimer); + this._animTimer = null; + } + } + + public close() { + this.stopAnimationUpdate(); + if (this.skeletonComponent) { + if (this.skeletonComponent.skeletonData) { + const uuid = this.skeletonComponent.skeletonData.uuid; + if (uuid && assetManager.assets.has(uuid)) { + assetManager.releaseAsset(assetManager.assets.get(uuid)!); + assetManager.assets.remove(uuid); + } + } + this.skeletonComponent.node.active = false; + } + this._spineData = null; + } + + public resetCameraView() { + if (!this.skeletonComponent) return; + this.resetCamera(this.skeletonComponent.node); + this.perfectCameraView(getBoundaryOfMeshNodes([this.skeletonComponent.node])); + } +} diff --git a/src/core/scene/scene-process/service/scene-view.ts b/src/core/scene/scene-process/service/scene-view.ts index e84e5cfa3..6689b9593 100644 --- a/src/core/scene/scene-process/service/scene-view.ts +++ b/src/core/scene/scene-process/service/scene-view.ts @@ -54,23 +54,31 @@ export class SceneViewService extends BaseService implements I return sceneViewData.isSceneLightOn; } - onSceneOpened(scene: any): void { - lightManager.onSceneOpened(scene, sceneViewData.isSceneLightOn); + onEditorOpened(): void { + const scene = (cc as any).director?.getScene(); + if (scene) { + lightManager.onSceneOpened(scene, sceneViewData.isSceneLightOn); + } - // Parent light node to scene if not already parented - if (this._lightNode && !this._lightNode.parent) { + // Parent light node to editor camera node (aligned with editor's init()) + if (this._lightNode) { try { - const sceneNode = (cc as any).director?.getScene(); - if (sceneNode) { - this._lightNode.parent = sceneNode; + const cameraNode = (Service as any).Camera?.camera?.node; + if (cameraNode) { + this._lightNode.parent = cameraNode; + } else if (!this._lightNode.parent) { + const sceneNode = (cc as any).director?.getScene(); + if (sceneNode) { + this._lightNode.parent = sceneNode; + } } } catch (e) { - // Scene not ready + // Camera not ready } } } - onSceneClosed(): void { + onEditorClosed(): void { // Nothing to clean up } diff --git a/src/core/scene/scene.scripting.middleware.ts b/src/core/scene/scene.scripting.middleware.ts index 5df1fe495..3d687c9ab 100644 --- a/src/core/scene/scene.scripting.middleware.ts +++ b/src/core/scene/scene.scripting.middleware.ts @@ -26,6 +26,24 @@ export default { } }, }, + { + url: '/preview', + async handler(req: Request, res: Response, next: NextFunction) { + try { + const { default: scripting } = await import('../../core/scripting'); + const serverBaseUrl = `${req.protocol}://${req.get('host')}`; + const renderData = { + title: `Resource Preview - ${basename(scripting.projectPath)}`, + serverURL: serverBaseUrl + }; + const templatePath = join(GlobalPaths.workspace, 'static', 'web', 'preview.ejs'); + const html = await ejs.renderFile(templatePath, renderData); + res.status(200).send(html); + } catch (err) { + next(err); + } + }, + }, { url: '/scripting/web-env', async handler(req: Request, res: Response, next: NextFunction) { @@ -61,6 +79,23 @@ export default { } }, }, + { + url: '/scripting/effect-settings', + async handler(req: Request, res: Response, next: NextFunction) { + try { + const { default: scripting } = await import('../../core/scripting'); + const effectBinPath = join(scripting.projectPath, 'temp', 'cli', 'asset-db', 'effect', 'effect.bin'); + if (await pathExists(effectBinPath)) { + res.setHeader('Content-Type', 'application/octet-stream'); + res.sendFile(effectBinPath); + } else { + res.status(404).send('effect.bin not found'); + } + } catch (err) { + next(err); + } + }, + }, { url: '/scripting/engine/game-config', async handler(req: Request, res: Response) { diff --git a/static/web/gizmo-test.js b/static/web/gizmo-test.js index 4f957c62d..1387e2eae 100644 --- a/static/web/gizmo-test.js +++ b/static/web/gizmo-test.js @@ -77,7 +77,7 @@ async function scanGizmoComponents() { } try { - const root = await window.cli.Scene.Node.queryNode({ + const root = await window.cli.Scene.Node.query({ path: '/', queryChildren: true, queryComponent: true, diff --git a/static/web/index.ejs b/static/web/index.ejs index befe5b8ea..2a08cfba7 100644 --- a/static/web/index.ejs +++ b/static/web/index.ejs @@ -4,7 +4,7 @@ <%= title %> - + @@ -309,6 +309,58 @@ + +
+

Preview

+
+
+ + +
+
+ + +
+
+ + + x + +
+ +
+ + + + + - +
+
+ +
+
+
+

Rect Test

@@ -389,6 +441,9 @@ + + + From b5da3d9b0f345a30131ffe10a1fa9dd9cb09716d Mon Sep 17 00:00:00 2001 From: fengyuzhe Date: Thu, 21 May 2026 16:11:37 +0800 Subject: [PATCH 02/11] refine --- .../scene-process/service/camera/camera-controller-3d.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/core/scene/scene-process/service/camera/camera-controller-3d.ts b/src/core/scene/scene-process/service/camera/camera-controller-3d.ts index 665c9586f..851af1e1c 100644 --- a/src/core/scene/scene-process/service/camera/camera-controller-3d.ts +++ b/src/core/scene/scene-process/service/camera/camera-controller-3d.ts @@ -185,11 +185,6 @@ export class CameraController3D extends CameraControllerBase { get lineColor() { return this._lineColor; } set lineColor(value: Color) { this._lineColor = value; } - // 预分配临时变量,避免高频方法中反复 new 产生 GC 压力 - private v3a = new Vec3(); - private v3b = new Vec3(); - private q1 = new Quat(); - public lastMouseWheelDeltaY = 0; public maxMouseWheelDeltaY = 1000; From 57fcab4494915ea8b26868e40406abac9c9d602a Mon Sep 17 00:00:00 2001 From: fengyuzhe Date: Thu, 21 May 2026 17:08:30 +0800 Subject: [PATCH 03/11] refine --- tests/__snapshots__/dts-snapshot.test.ts.snap | 42 +++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/tests/__snapshots__/dts-snapshot.test.ts.snap b/tests/__snapshots__/dts-snapshot.test.ts.snap index 78d5d8748..12416669c 100644 --- a/tests/__snapshots__/dts-snapshot.test.ts.snap +++ b/tests/__snapshots__/dts-snapshot.test.ts.snap @@ -5366,6 +5366,8 @@ export declare interface IEditorService extends IServiceEvents { export declare interface IEngineService extends IServiceEvents { init(): Promise; repaintInEditMode(): Promise; + pause(): void; + resume(): void; } export declare interface IExecuteComponentMethodOptions { path: string; @@ -5534,6 +5536,20 @@ export declare interface IPrefabStateInfo { isNested: boolean; assetUuid: string; } +export declare interface IPreviewService { + init(): void; + queryPreviewData(previewName: string, info: any): Promise; + callPreviewFunction(previewName: string, funcName: string, ...args: any[]): Promise; + queryMaterialPreview(uuid: string, width: number, height: number): Promise; + queryModelPreview(uuid: string, width: number, height: number): Promise; + queryMeshPreview(uuid: string, width: number, height: number): Promise; + querySkeletonPreview(uuid: string, width: number, height: number): Promise; + queryPrefabPreview(uuid: string, width: number, height: number): Promise; + querySpinePreview(uuid: string, width: number, height: number): Promise; + queryScenePreview(width: number, height: number): Promise; + switchMaterialPrimitive(type: string): void; + generateThumbnail(uuid: string, assetType: string, width?: number, height?: number): Promise; +} export declare interface IProperty { value: { [key: string]: IPropertyValueType } | IPropertyValueType; default?: any; @@ -5662,8 +5678,8 @@ export declare interface ISceneViewService { saveConfig(): Promise; setSceneLightOn(enable: boolean): void; querySceneLightOn(): boolean; - onSceneOpened(scene: any): void; - onSceneClosed(): void; + onEditorOpened(): void; + onEditorClosed(): void; onComponentAdded(comp: Component): void; onComponentRemoved(comp: Component): void; } @@ -5729,6 +5745,7 @@ export declare interface IServiceManager { Camera: ICameraService; Gizmo: IGizmoService; SceneView: ISceneViewService; + Preview: IPreviewService; } export declare interface ISetPropertyOptions { nodePath: string; @@ -7279,6 +7296,8 @@ export declare interface IEngineProjectConfig extends Exclude; repaintInEditMode(): Promise; + pause(): void; + resume(): void; } export declare interface IExecuteComponentMethodOptions { path: string; @@ -7535,6 +7554,20 @@ export declare interface IPrefabStateInfo { isNested: boolean; assetUuid: string; } +export declare interface IPreviewService { + init(): void; + queryPreviewData(previewName: string, info: any): Promise; + callPreviewFunction(previewName: string, funcName: string, ...args: any[]): Promise; + queryMaterialPreview(uuid: string, width: number, height: number): Promise; + queryModelPreview(uuid: string, width: number, height: number): Promise; + queryMeshPreview(uuid: string, width: number, height: number): Promise; + querySkeletonPreview(uuid: string, width: number, height: number): Promise; + queryPrefabPreview(uuid: string, width: number, height: number): Promise; + querySpinePreview(uuid: string, width: number, height: number): Promise; + queryScenePreview(width: number, height: number): Promise; + switchMaterialPrimitive(type: string): void; + generateThumbnail(uuid: string, assetType: string, width?: number, height?: number): Promise; +} export declare interface IProject { get path(): string; get type(): '2d' | '3d'; @@ -7676,8 +7709,8 @@ export declare interface ISceneViewService { saveConfig(): Promise; setSceneLightOn(enable: boolean): void; querySceneLightOn(): boolean; - onSceneOpened(scene: any): void; - onSceneClosed(): void; + onEditorOpened(): void; + onEditorClosed(): void; onComponentAdded(comp: Component): void; onComponentRemoved(comp: Component): void; } @@ -7743,6 +7776,7 @@ export declare interface IServiceManager { Camera: ICameraService; Gizmo: IGizmoService; SceneView: ISceneViewService; + Preview: IPreviewService; } export declare interface ISetPropertyOptions { nodePath: string; From 0ee36f0c66cd5af528ebcb31d5d4ba2b2da850df Mon Sep 17 00:00:00 2001 From: fengyuzhe Date: Thu, 21 May 2026 18:12:45 +0800 Subject: [PATCH 04/11] refine --- .../scene/scene-process/engine-bootstrap.ts | 27 +- static/web/index.ejs | 380 ------------------ static/web/preview-app.js | 23 +- 3 files changed, 29 insertions(+), 401 deletions(-) diff --git a/src/core/scene/scene-process/engine-bootstrap.ts b/src/core/scene/scene-process/engine-bootstrap.ts index 185e31be8..74fadaa60 100644 --- a/src/core/scene/scene-process/engine-bootstrap.ts +++ b/src/core/scene/scene-process/engine-bootstrap.ts @@ -62,12 +62,17 @@ export async function startup(options: { } } - // Get decodeCCONBinary for CCONB binary format support (.bin library files) - let decodeCCONBinary: ((bytes: Uint8Array) => any) | null = null; - try { - const cconModule: any = await System.import('cc/editor/serialization'); - decodeCCONBinary = cconModule?.decodeCCONBinary ?? null; - } catch { /* module may not be available */ } + let _decodeCCONBinaryCached = false; + let _decodeCCONBinary: ((bytes: Uint8Array) => any) | null = null; + async function getDecodeCCONBinary(): Promise<((bytes: Uint8Array) => any) | null> { + if (_decodeCCONBinaryCached) return _decodeCCONBinary; + try { + const m: any = await System.import('cc/editor/serialization'); + _decodeCCONBinary = m?.decodeCCONBinary ?? null; + } catch { _decodeCCONBinary = null; } + _decodeCCONBinaryCached = true; + return _decodeCCONBinary; + } // ---- hack creator 使用的一些 engine 参数 await import('cc/polyfill/engine'); @@ -150,8 +155,9 @@ export async function startup(options: { const isBinary = ext === 'bin'; let deserializeData: any; - if (isBinary && decodeCCONBinary) { - deserializeData = decodeCCONBinary(new Uint8Array(await r.arrayBuffer())); + const decode = isBinary ? await getDecodeCCONBinary() : null; + if (isBinary && decode) { + deserializeData = decode(new Uint8Array(await r.arrayBuffer())); } else { deserializeData = await r.json(); } @@ -280,8 +286,9 @@ export async function startup(options: { let deserializeInput: any; if (isBinary) { const rawBytes = new Uint8Array(await res.arrayBuffer()); - if (decodeCCONBinary) { - deserializeInput = decodeCCONBinary(rawBytes); + const decode = await getDecodeCCONBinary(); + if (decode) { + deserializeInput = decode(rawBytes); } else { console.warn(`[loadFromServer] decodeCCONBinary not available, cannot decode CCONB for ${uuid}`); onComplete?.(new Error('decodeCCONBinary not available'), null); diff --git a/static/web/index.ejs b/static/web/index.ejs index 2a08cfba7..59e553cbe 100644 --- a/static/web/index.ejs +++ b/static/web/index.ejs @@ -309,58 +309,6 @@
- -
-

Preview

-
-
- - -
-
- - -
-
- - - x - -
- -
- - - - - - -
-
- -
-
-
-

Rect Test

@@ -774,334 +722,6 @@ } window.refreshRectInfo = refreshRectInfo; - /* ── Preview ── */ - function onPreviewTypeChange() { - var type = document.getElementById('previewType').value; - document.getElementById('previewUuidRow').style.display = type === 'scene' ? 'none' : 'flex'; - document.getElementById('previewPrimitiveRow').style.display = type === 'material' ? 'flex' : 'none'; - } - window.onPreviewTypeChange = onPreviewTypeChange; - - function renderPreviewData(data) { - if (!data) { - log('Preview returned null/undefined', 'status-warn'); - document.getElementById('previewStatus').textContent = 'no data'; - return; - } - if (!data.buffer) { - log('Preview data has no buffer, keys: ' + Object.keys(data).join(','), 'status-warn'); - document.getElementById('previewStatus').textContent = 'no buffer'; - return; - } - var canvas = document.getElementById('previewCanvas'); - var w = data.width; - var h = data.height; - log('Preview data: ' + w + 'x' + h + ' buffer.length=' + data.buffer.length, 'status-ok'); - var nonZero = 0; - var buf = data.buffer; - for (var i = 0; i < Math.min(buf.length, 4000); i++) { - if (buf[i] !== 0) nonZero++; - } - log('Buffer check: nonZero in first 4000 bytes = ' + nonZero + ', type=' + (buf.constructor ? buf.constructor.name : typeof buf), 'status-ok'); - // Dump first 5 pixels RGBA to diagnose color/alpha issues - var pixelSamples = []; - for (var p = 0; p < 5 && p * 4 + 3 < buf.length; p++) { - var off = p * 4; - pixelSamples.push('px' + p + '=(' + buf[off] + ',' + buf[off+1] + ',' + buf[off+2] + ',' + buf[off+3] + ')'); - } - // Also sample center pixel - var cx = Math.floor(w / 2), cy = Math.floor(h / 2); - var coff = (cy * w + cx) * 4; - if (coff + 3 < buf.length) { - pixelSamples.push('center=(' + buf[coff] + ',' + buf[coff+1] + ',' + buf[coff+2] + ',' + buf[coff+3] + ')'); - } - log('Pixel samples: ' + pixelSamples.join(' '), 'status-ok'); - if (w <= 0 || h <= 0) { - log('Preview data has zero dimensions', 'status-warn'); - document.getElementById('previewStatus').textContent = 'zero size'; - return; - } - canvas.width = w; - canvas.height = h; - var ctx = canvas.getContext('2d'); - var imgData = ctx.createImageData(w, h); - var buf = data.buffer; - for (var i = 0; i < buf.length && i < imgData.data.length; i++) { - imgData.data[i] = buf[i]; - } - ctx.putImageData(imgData, 0, 0); - // Verify canvas actually has data - var readback = ctx.getImageData(0, 0, Math.min(w, 4), 1).data; - var rbSamples = []; - for (var ri = 0; ri < readback.length; ri += 4) { - rbSamples.push('(' + readback[ri] + ',' + readback[ri+1] + ',' + readback[ri+2] + ',' + readback[ri+3] + ')'); - } - log('Canvas readback: ' + rbSamples.join(' '), 'status-ok'); - document.getElementById('previewStatus').textContent = w + 'x' + h + ' ok'; - log('Preview rendered ' + w + 'x' + h, 'status-ok'); - } - - async function doPreview() { - if (!window.cli || !window.cli.Scene) { - log('cli.Scene not ready', 'status-warn'); - return; - } - var preview; - try { - preview = window.cli.Scene.Preview; - } catch(e) { - log('Preview service not registered: ' + e.message, 'status-err'); - return; - } - if (!preview) { - log('Preview service is null', 'status-warn'); - return; - } - var type = document.getElementById('previewType').value; - var uuid = document.getElementById('previewUuid').value.trim(); - var w = parseInt(document.getElementById('previewW').value) || 256; - var h = parseInt(document.getElementById('previewH').value) || 256; - var status = document.getElementById('previewStatus'); - status.textContent = 'loading...'; - status.className = 'info-text status-warn'; - log('Preview: type=' + type + ' uuid=' + uuid + ' size=' + w + 'x' + h, 'status-ok'); - try { - var preview = window.cli.Scene.Preview; - var data; - switch (type) { - case 'scene': - data = await preview.queryScenePreview(w, h); - break; - case 'material': - if (!uuid) { log('UUID required', 'status-warn'); status.textContent = 'need uuid'; return; } - data = await preview.queryMaterialPreview(uuid, w, h); - break; - case 'model': - if (!uuid) { log('UUID required', 'status-warn'); status.textContent = 'need uuid'; return; } - data = await preview.queryModelPreview(uuid, w, h); - break; - case 'mesh': - if (!uuid) { log('UUID required', 'status-warn'); status.textContent = 'need uuid'; return; } - data = await preview.queryMeshPreview(uuid, w, h); - break; - case 'skeleton': - if (!uuid) { log('UUID required', 'status-warn'); status.textContent = 'need uuid'; return; } - data = await preview.querySkeletonPreview(uuid, w, h); - break; - case 'prefab': - if (!uuid) { log('UUID required', 'status-warn'); status.textContent = 'need uuid'; return; } - data = await preview.queryPrefabPreview(uuid, w, h); - break; - case 'spine': - if (!uuid) { log('UUID required', 'status-warn'); status.textContent = 'need uuid'; return; } - data = await preview.querySpinePreview(uuid, w, h); - break; - } - renderPreviewData(data); - status.className = 'info-text status-ok'; - } catch (e) { - status.textContent = 'error'; - status.className = 'info-text status-err'; - log('Preview error: ' + e.message, 'status-err'); - console.error('Preview error:', e); - } - } - window.doPreview = doPreview; - - async function doThumbnail() { - if (!window.cli || !window.cli.Scene || !window.cli.Scene.Preview) { - log('Preview service not ready', 'status-warn'); - return; - } - var type = document.getElementById('previewType').value; - var uuid = document.getElementById('previewUuid').value.trim(); - var w = parseInt(document.getElementById('previewW').value) || 128; - var h = parseInt(document.getElementById('previewH').value) || 128; - if (type === 'scene') { log('Thumbnail not supported for scene', 'status-warn'); return; } - if (!uuid) { log('UUID required', 'status-warn'); return; } - var assetTypeMap = { - material: 'cc.Material', - model: 'cc.Prefab', - mesh: 'cc.Mesh', - skeleton: 'cc.Skeleton', - prefab: 'cc.Prefab', - spine: 'sp.SkeletonData', - }; - var status = document.getElementById('previewStatus'); - status.textContent = 'generating...'; - try { - var data = await window.cli.Scene.Preview.generateThumbnail(uuid, assetTypeMap[type], w, h); - renderPreviewData(data); - status.textContent = w + 'x' + h + ' thumb ok'; - status.className = 'info-text status-ok'; - } catch (e) { - status.textContent = 'error'; - status.className = 'info-text status-err'; - log('Thumbnail error: ' + e.message, 'status-err'); - } - } - window.doThumbnail = doThumbnail; - - function doSwitchPrimitive() { - var type = document.getElementById('previewPrimitive').value; - safeCall('Preview', 'switchMaterialPrimitive', type); - } - window.doSwitchPrimitive = doSwitchPrimitive; - - async function doTestRender() { - try { - var preview = window.cli.Scene.Preview; - var mp = preview.materialPreview; - if (!mp) { log('materialPreview is null', 'status-err'); return; } - - var buf = mp.previewBuffer; - var root = cc.director.root; - var rootScenes = root.scenes || []; - var sceneInRoot = false; - for (var i = 0; i < rootScenes.length; i++) { - if (rootScenes[i] === buf.renderScene) sceneInRoot = true; - } - log('Root scenes: ' + rootScenes.length + ', previewScene in root: ' + sceneInRoot, 'status-ok'); - - // Check pipeline type - var pipeline = root.pipeline; - var pipelineType = pipeline ? (pipeline.constructor.name || typeof pipeline) : 'null'; - log('Pipeline: ' + pipelineType, 'status-ok'); - - // Check camera details - var cam = mp.cameraComp; - if (cam) { - var pos = cam.node.worldPosition; - var internalCam = cam.camera; - log('Camera pos: ' + pos.x.toFixed(1) + ',' + pos.y.toFixed(1) + ',' + pos.z.toFixed(1), 'status-ok'); - log('Internal cam: enabled=' + internalCam.isEnable + ' scene=' + !!internalCam.scene + ' window=' + !!internalCam.window, 'status-ok'); - log('Cam.window === buf.window: ' + (internalCam.window === buf.window), 'status-ok'); - } - - // Test 1: copy from main window to see if copyTextureToBuffers works at all - var mainWin = root.mainWindow; - if (mainWin && mainWin.framebuffer && mainWin.framebuffer.colorTextures && mainWin.framebuffer.colorTextures.length > 0) { - var mainTex = mainWin.framebuffer.colorTextures[0]; - var testBuf = new Uint8Array(100 * 100 * 4); - var regions = [new cc.gfx.BufferTextureCopy()]; - regions[0].texExtent.width = 100; - regions[0].texExtent.height = 100; - try { - root.device.copyTextureToBuffers(mainTex, [testBuf], regions); - var mainNonZero = 0; - for (var i = 0; i < testBuf.length; i++) { if (testBuf[i] !== 0) mainNonZero++; } - log('MainWindow copy test: nonZero=' + mainNonZero + '/' + testBuf.length, mainNonZero > 0 ? 'status-ok' : 'status-err'); - } catch (e) { - log('MainWindow copy test FAILED: ' + e.message, 'status-err'); - } - } else { - log('MainWindow has no framebuffer/colorTextures', 'status-warn'); - } - - // Test 2: check renderScene cameras - if (buf.renderScene) { - var rsCameras = buf.renderScene.cameras || []; - log('RenderScene cameras: ' + rsCameras.length, 'status-ok'); - for (var i = 0; i < rsCameras.length; i++) { - var rsc = rsCameras[i]; - log(' cam[' + i + ']: enabled=' + rsc.isEnable + ' window=' + (rsc.window === buf.window) + ' visibility=' + rsc.visibility, 'status-ok'); - } - } - - // Test 3: call queryPreviewData - log('Calling queryPreviewData...', 'status-ok'); - var data = await mp.queryPreviewData({ width: 256, height: 256 }); - renderPreviewData(data); - } catch (e) { - log('TestRender error: ' + e.message, 'status-err'); - console.error(e); - } - } - window.doTestRender = doTestRender; - - function doPreviewDebug() { - try { - var preview = window.cli.Scene.Preview; - log('=== Preview Debug ===', 'status-ok'); - log('initialized: ' + preview._initialized, 'status-ok'); - log('previewMap keys: ' + Array.from(preview._previewMap.keys()).join(', '), 'status-ok'); - - // Check effect system - var effectCount = 0; - try { - var effectAsset = cc.EffectAsset; - if (effectAsset && effectAsset.getAll) { - var allEffects = effectAsset.getAll(); - effectCount = Object.keys(allEffects).length; - } else if (effectAsset && effectAsset._effects) { - effectCount = Object.keys(effectAsset._effects).length; - } - } catch(e) {} - log('Effects loaded: ' + effectCount, effectCount > 0 ? 'status-ok' : 'status-err'); - - // Check builtin assets - try { - var effects = cc.EffectAsset.getAll ? cc.EffectAsset.getAll() : (cc.EffectAsset._effects || {}); - var names = Object.values(effects).map(function(e) { return e.name; }).sort(); - log('All effects (' + names.length + '): ' + names.join(', '), 'status-ok'); - var builtinStd = names.some(function(n) { return n && n.indexOf('standard') >= 0; }); - log('Any "standard" effect: ' + builtinStd, builtinStd ? 'status-ok' : 'status-err'); - } catch(e) { log('Cannot read effects: ' + e.message, 'status-err'); } - - // Check internal bundle - var internalBundle = cc.assetManager.getBundle('internal'); - log('internal bundle: ' + (internalBundle ? 'loaded' : 'NOT LOADED'), internalBundle ? 'status-ok' : 'status-err'); - var mainBundle = cc.assetManager.getBundle('main'); - log('main bundle: ' + (mainBundle ? 'loaded' : 'NOT LOADED'), mainBundle ? 'status-ok' : 'status-err'); - - // Check material preview meshComp - var mp = preview.materialPreview; - if (mp && mp.meshComp) { - var mat = mp.meshComp.material; - log('MeshRenderer material: ' + (mat ? (mat.name || mat.effectName || 'unnamed') : 'NULL'), mat ? 'status-ok' : 'status-err'); - if (mat && mat.effectAsset) { - log('Effect asset: ' + mat.effectAsset.name, 'status-ok'); - } else if (mat) { - log('Effect asset: NULL (error shader)', 'status-err'); - } - } - - var previews = { - material: preview.materialPreview, - model: preview.modelPreview, - mesh: preview.meshPreview, - skeleton: preview.skeletonPreview, - prefab: preview.prefabPreview, - scene: preview.scenePreview, - }; - Object.keys(previews).forEach(function(name) { - var p = previews[name]; - if (!p) { log(name + ': null', 'status-err'); return; } - var buf = p.previewBuffer; - var info = name + ': '; - if (!buf) { log(info + 'no previewBuffer', 'status-err'); return; } - info += 'renderScene=' + (buf.renderScene ? 'yes' : 'NO'); - info += ' window=' + (buf.window ? 'yes' : 'NO'); - info += ' size=' + buf.width + 'x' + buf.height; - if (buf.window) { - info += ' winCams=' + (buf.window.cameras ? buf.window.cameras.length : '?'); - } - var hasScene = p.scene || p._scene; - info += ' scene=' + (hasScene ? 'yes' : 'NO'); - if (p.cameraComp) { - info += ' cam=yes enabled=' + p.cameraComp.enabled; - } else { - info += ' cam=NO'; - } - log(info, buf.renderScene && buf.window ? 'status-ok' : 'status-warn'); - }); - } catch(e) { - log('Debug error: ' + e.message, 'status-err'); - console.error('Preview debug error:', e); - } - } - window.doPreviewDebug = doPreviewDebug; - /* ── Init Panel ── */ async function initPanel() { refreshState(); diff --git a/static/web/preview-app.js b/static/web/preview-app.js index ed8894c21..ee803d883 100644 --- a/static/web/preview-app.js +++ b/static/web/preview-app.js @@ -1,12 +1,12 @@ /* global window, document, cc */ const PREVIEW_TYPES = { - material: { method: 'queryMaterialPreview', needsUuid: true, instance: 'materialPreview' }, - model: { method: 'queryModelPreview', needsUuid: true, instance: 'modelPreview' }, - mesh: { method: 'queryMeshPreview', needsUuid: true, instance: 'meshPreview' }, - prefab: { method: 'queryPrefabPreview', needsUuid: true, instance: 'prefabPreview' }, - skeleton: { method: 'querySkeletonPreview', needsUuid: true, instance: 'skeletonPreview' }, - spine: { method: 'querySpinePreview', needsUuid: true, instance: 'spinePreview' }, + material: { setup: 'setMaterialByUuid', instance: 'materialPreview' }, + model: { setup: 'setModel', instance: 'modelPreview' }, + mesh: { setup: 'setMesh', instance: 'meshPreview' }, + prefab: { setup: 'setPrefab', instance: 'prefabPreview' }, + skeleton: { setup: 'setSkeleton', instance: 'skeletonPreview' }, + spine: { setup: 'setSpine', instance: 'spinePreview' }, }; var _activePreviewInstance = null; @@ -73,7 +73,7 @@ async function doPreview() { log('Unknown preview type: ' + type, 'err'); return null; } - if (info.needsUuid && !uuid) { + if (!uuid) { log('UUID is required for ' + type + ' preview', 'warn'); return null; } @@ -87,11 +87,12 @@ async function doPreview() { _activePreviewInstance.cameraComp.enabled = false; } - // Load the asset into the preview instance (this calls setModel/setMesh/etc.) - var previewInstance = info.instance ? preview[info.instance] : null; + var previewInstance = preview[info.instance]; - // Use the query method to trigger asset loading (setModel, setMesh, etc.) - await preview[info.method](uuid, 256, 256); + // Call the setup method directly (setModel, setMesh, etc.) + // This skips the offscreen render + gl.readPixels pipeline + // that queryPreviewData uses for thumbnail generation. + await previewInstance[info.setup](uuid); _activePreviewInstance = previewInstance; From 2e46a4db67f7fd813d060065491c45dbec0a5833 Mon Sep 17 00:00:00 2001 From: fengyuzhe Date: Sat, 23 May 2026 09:00:35 +0800 Subject: [PATCH 05/11] refine --- src/core/scene/common/preview.ts | 15 ++----------- .../scene-process/service/preview/buffer.ts | 1 - .../scene-process/service/preview/index.ts | 22 ++++++------------- 3 files changed, 9 insertions(+), 29 deletions(-) diff --git a/src/core/scene/common/preview.ts b/src/core/scene/common/preview.ts index 7b3791112..2b13afa14 100644 --- a/src/core/scene/common/preview.ts +++ b/src/core/scene/common/preview.ts @@ -1,24 +1,13 @@ export interface IPreviewService { init(): void; - queryPreviewData(previewName: string, info: any): Promise; - callPreviewFunction(previewName: string, funcName: string, ...args: any[]): Promise; - queryMaterialPreview(uuid: string, width: number, height: number): Promise; - queryModelPreview(uuid: string, width: number, height: number): Promise; - queryMeshPreview(uuid: string, width: number, height: number): Promise; - querySkeletonPreview(uuid: string, width: number, height: number): Promise; - queryPrefabPreview(uuid: string, width: number, height: number): Promise; - querySpinePreview(uuid: string, width: number, height: number): Promise; - queryScenePreview(width: number, height: number): Promise; switchMaterialPrimitive(type: string): void; generateThumbnail(uuid: string, assetType: string, width?: number, height?: number): Promise; } export type IPublicPreviewService = Pick; +// eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface IPreviewEvents { } diff --git a/src/core/scene/scene-process/service/preview/buffer.ts b/src/core/scene/scene-process/service/preview/buffer.ts index 4a7bbac49..4f717d134 100644 --- a/src/core/scene/scene-process/service/preview/buffer.ts +++ b/src/core/scene/scene-process/service/preview/buffer.ts @@ -153,7 +153,6 @@ class PreviewBuffer extends EventEmitter { ]; copyFrameBuffer(window: any = null) { - window || (window = this.window); if (!window || !window.framebuffer) { return this.renderData; } const destBuffer = new Uint8Array(this.renderData.buffer.buffer); diff --git a/src/core/scene/scene-process/service/preview/index.ts b/src/core/scene/scene-process/service/preview/index.ts index 80d911494..876ba7f8d 100644 --- a/src/core/scene/scene-process/service/preview/index.ts +++ b/src/core/scene/scene-process/service/preview/index.ts @@ -43,14 +43,6 @@ export class PreviewService extends BaseService implements IPrev mgr.init(registerName, queryName); } - public async queryPreviewData(previewName: string, info: any) { - if (this._previewMap.has(previewName)) { - const preview = this._previewMap.get(previewName); - return await preview?.queryPreviewData(info); - } - return null; - } - public async callPreviewFunction(previewName: string, funcName: string, ...args: any[]) { if (this._previewMap.has(previewName)) { const preview: any = this._previewMap.get(previewName); @@ -63,37 +55,37 @@ export class PreviewService extends BaseService implements IPrev // --- 资源预览快捷方法 --- - public async queryMaterialPreview(uuid: string, width: number, height: number) { + async queryMaterialPreview(uuid: string, width: number, height: number) { await this.materialPreview.setMaterialByUuid(uuid); return await this.materialPreview.queryPreviewData({ width, height }); } - public async queryModelPreview(uuid: string, width: number, height: number) { + async queryModelPreview(uuid: string, width: number, height: number) { await this.modelPreview.setModel(uuid); return await this.modelPreview.queryPreviewData({ width, height }); } - public async queryMeshPreview(uuid: string, width: number, height: number) { + async queryMeshPreview(uuid: string, width: number, height: number) { await this.meshPreview.setMesh(uuid); return await this.meshPreview.queryPreviewData({ width, height }); } - public async querySkeletonPreview(uuid: string, width: number, height: number) { + async querySkeletonPreview(uuid: string, width: number, height: number) { await this.skeletonPreview.setSkeleton(uuid); return await this.skeletonPreview.queryPreviewData({ width, height }); } - public async queryPrefabPreview(uuid: string, width: number, height: number) { + async queryPrefabPreview(uuid: string, width: number, height: number) { await this.prefabPreview.setPrefab(uuid); return await this.prefabPreview.queryPreviewData({ width, height }); } - public async querySpinePreview(uuid: string, width: number, height: number) { + async querySpinePreview(uuid: string, width: number, height: number) { await this.spinePreview.setSpine(uuid); return await this.spinePreview.queryPreviewData({ width, height }); } - public async queryScenePreview(width: number, height: number) { + async queryScenePreview(width: number, height: number) { return await this.scenePreview.queryPreviewData({ width, height }); } From 7dec1e6820028419026f508f7b4d05227cb9d9bd Mon Sep 17 00:00:00 2001 From: fengyuzhe Date: Sat, 23 May 2026 11:05:28 +0800 Subject: [PATCH 06/11] refine --- e2e/mcp/api/resource-preview.e2e.test.ts | 84 ------------------------ 1 file changed, 84 deletions(-) delete mode 100644 e2e/mcp/api/resource-preview.e2e.test.ts diff --git a/e2e/mcp/api/resource-preview.e2e.test.ts b/e2e/mcp/api/resource-preview.e2e.test.ts deleted file mode 100644 index 8a3979983..000000000 --- a/e2e/mcp/api/resource-preview.e2e.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { AssetsTestContext, setupAssetsTestEnvironment, teardownAssetsTestEnvironment } from '../../helpers/test-utils'; -import { E2E_TIMEOUTS } from '../../config'; - -describe('Resource Preview', () => { - let context: AssetsTestContext; - let serverBaseUrl: string; - - beforeAll(async () => { - context = await setupAssetsTestEnvironment(); - serverBaseUrl = `http://localhost:${context.mcpClient.getPort()}`; - }, E2E_TIMEOUTS.SERVER_START); - - afterAll(async () => { - await teardownAssetsTestEnvironment(context); - }); - - describe('preview route', () => { - test('GET /preview should return valid HTML page', async () => { - const res = await fetch(`${serverBaseUrl}/preview`); - expect(res.status).toBe(200); - - const html = await res.text(); - expect(html).toContain(''); - expect(html).toContain('Resource Preview'); - expect(html).toContain('GameCanvas'); - expect(html).toContain('preview-app.js'); - }); - - test('preview page should include toolbar controls', async () => { - const res = await fetch(`${serverBaseUrl}/preview`); - const html = await res.text(); - - expect(html).toContain('pvType'); - expect(html).toContain('pvUuid'); - expect(html).toContain('pvW'); - expect(html).toContain('pvH'); - expect(html).toContain('pvPrimitive'); - expect(html).toContain('previewResult'); - }); - - test('preview page should inject serverURL', async () => { - const res = await fetch(`${serverBaseUrl}/preview`); - const html = await res.text(); - - expect(html).toContain('window.WebEnv'); - expect(html).toContain('serverURL'); - expect(html).toContain(serverBaseUrl); - }); - }); - - describe('preview static assets', () => { - test('GET /static/web/preview-app.js should return JS', async () => { - const res = await fetch(`${serverBaseUrl}/static/web/preview-app.js`); - expect(res.status).toBe(200); - - const js = await res.text(); - expect(js).toContain('PREVIEW_TYPES'); - expect(js).toContain('doPreview'); - expect(js).toContain('initPreviewApp'); - }); - - test('GET /static/web/boot.js should return JS', async () => { - const res = await fetch(`${serverBaseUrl}/static/web/boot.js`); - expect(res.status).toBe(200); - }); - }); - - describe('preview via MCP (generateThumbnail)', () => { - test('should query material assets for preview', async () => { - const result = await context.mcpClient.callTool('assets-query-by-type', { - assetType: 'cc.Material', - }); - - expect(result.code).toBe(200); - expect(result.data).toBeDefined(); - - if (Array.isArray(result.data) && result.data.length > 0) { - const materialUuid = result.data[0].uuid; - expect(materialUuid).toBeDefined(); - expect(typeof materialUuid).toBe('string'); - } - }); - }); -}); From 199108ac54f8c3c7882db26217b83497cf509c8d Mon Sep 17 00:00:00 2001 From: fengyuzhe Date: Sat, 23 May 2026 14:59:57 +0800 Subject: [PATCH 07/11] refine --- src/core/scene/common/preview.ts | 4 +- .../scene-process/service/preview/index.ts | 158 +++++++++++++----- static/web/preview-app.js | 128 ++++---------- static/web/preview.ejs | 10 -- tests/__snapshots__/dts-snapshot.test.ts.snap | 4 + 5 files changed, 152 insertions(+), 152 deletions(-) diff --git a/src/core/scene/common/preview.ts b/src/core/scene/common/preview.ts index 2b13afa14..12d6c854c 100644 --- a/src/core/scene/common/preview.ts +++ b/src/core/scene/common/preview.ts @@ -1,11 +1,13 @@ export interface IPreviewService { init(): void; + open(uuid: string): Promise; switchMaterialPrimitive(type: string): void; + switchLight(enabled: boolean): void; generateThumbnail(uuid: string, assetType: string, width?: number, height?: number): Promise; } export type IPublicPreviewService = Pick; // eslint-disable-next-line @typescript-eslint/no-empty-object-type diff --git a/src/core/scene/scene-process/service/preview/index.ts b/src/core/scene/scene-process/service/preview/index.ts index 876ba7f8d..277c9b66e 100644 --- a/src/core/scene/scene-process/service/preview/index.ts +++ b/src/core/scene/scene-process/service/preview/index.ts @@ -7,13 +7,22 @@ import { MeshPreview } from './mesh-preview'; import { SkeletonPreview } from './skeleton-preview'; import { PrefabPreview } from './prefab-preview'; import { SpinePreview } from './spine-preview'; -import { BaseService, register } from '../core'; +import { BaseService, register, Service } from '../core'; +import { Rpc } from '../../rpc'; +import type { InteractivePreview } from './interactive-preview'; import type { IPreviewService, IPreviewEvents } from '../../../common/preview'; +interface PreviewTypeEntry { + instance: PreviewBase; + setup: string; +} + @register('Preview') export class PreviewService extends BaseService implements IPreviewService { private _previewMap: Map = new Map(); + private _typeMap: Map = new Map(); private _initialized = false; + private _activePreview: PreviewBase | null = null; scenePreview = scenePreview; materialPreview = new MaterialPreview(); @@ -24,6 +33,10 @@ export class PreviewService extends BaseService implements IPrev prefabPreview = new PrefabPreview(); spinePreview = new SpinePreview(); + get activePreview(): PreviewBase | null { + return this._activePreview; + } + init() { if (this._initialized) return; this._initialized = true; @@ -35,9 +48,50 @@ export class PreviewService extends BaseService implements IPrev this.initPreview('scene:skeleton-preview', 'query-skeleton-preview-data', this.skeletonPreview); this.initPreview('scene:prefab-preview', 'query-prefab-preview-data', this.prefabPreview); this.initPreview('scene:spine-preview', 'query-spine-preview-data', this.spinePreview); + this.initTypeMap(); console.log('[Preview] PreviewService initialized'); } + private initTypeMap() { + const entries: [string[], PreviewTypeEntry][] = [ + [['material', 'cc.Material'], { instance: this.materialPreview, setup: 'setMaterialByUuid' }], + [['model', 'cc.FBX', 'cc.GLTF', 'cc.ModelAsset'], { instance: this.modelPreview, setup: 'setModel' }], + [['mesh', 'cc.Mesh'], { instance: this.meshPreview, setup: 'setMesh' }], + [['prefab', 'cc.Prefab'], { instance: this.prefabPreview, setup: 'setPrefab' }], + [['skeleton', 'cc.Skeleton'], { instance: this.skeletonPreview, setup: 'setSkeleton' }], + [['spine', 'sp.SkeletonData'], { instance: this.spinePreview, setup: 'setSpine' }], + ]; + for (const [keys, entry] of entries) { + for (const key of keys) { + this._typeMap.set(key, entry); + } + } + } + + // importer name → preview type 的映射(用于 assetType 为 cc.Asset 等泛型的回退) + private static readonly IMPORTER_MAP: Record = { + 'gltf': 'model', + 'fbx': 'model', + 'spine-data': 'spine', + }; + + private resolvePreview(assetType: string): PreviewTypeEntry | null { + return this._typeMap.get(assetType) ?? null; + } + + private async resolveAssetType(uuid: string): Promise { + const info = await Rpc.getInstance().request('assetManager', 'queryAssetInfo', [uuid]); + if (!info) return null; + // 优先用 type 匹配;若 type 为泛型(如 cc.Asset),用 importer 回退 + if (info.type && this._typeMap.has(info.type)) { + return info.type; + } + if (info.importer && PreviewService.IMPORTER_MAP[info.importer]) { + return PreviewService.IMPORTER_MAP[info.importer]; + } + return info.type ?? null; + } + private initPreview(registerName: string, queryName: string, mgr: PreviewBase) { this._previewMap.set(registerName, mgr); mgr.init(registerName, queryName); @@ -53,67 +107,81 @@ export class PreviewService extends BaseService implements IPrev return false; } - // --- 资源预览快捷方法 --- + // --- 上屏预览 --- - async queryMaterialPreview(uuid: string, width: number, height: number) { - await this.materialPreview.setMaterialByUuid(uuid); - return await this.materialPreview.queryPreviewData({ width, height }); - } + async open(uuid: string): Promise { + const assetType = await this.resolveAssetType(uuid); + if (!assetType) { + console.warn(`[Preview] Cannot resolve asset type for uuid: ${uuid}`); + return null; + } - async queryModelPreview(uuid: string, width: number, height: number) { - await this.modelPreview.setModel(uuid); - return await this.modelPreview.queryPreviewData({ width, height }); - } + const entry = this.resolvePreview(assetType); + if (!entry) { + console.warn(`[Preview] Unsupported asset type: ${assetType}`); + return null; + } - async queryMeshPreview(uuid: string, width: number, height: number) { - await this.meshPreview.setMesh(uuid); - return await this.meshPreview.queryPreviewData({ width, height }); - } + // 清理上一个预览的相机 + if (this._activePreview) { + const prev = this._activePreview as any; + if (prev.cameraComp) { + prev.cameraComp.enabled = false; + } + } - async querySkeletonPreview(uuid: string, width: number, height: number) { - await this.skeletonPreview.setSkeleton(uuid); - return await this.skeletonPreview.queryPreviewData({ width, height }); - } + // 设置资源 + await (entry.instance as any)[entry.setup](uuid); + this._activePreview = entry.instance; - async queryPrefabPreview(uuid: string, width: number, height: number) { - await this.prefabPreview.setPrefab(uuid); - return await this.prefabPreview.queryPreviewData({ width, height }); - } + // 将相机挂到 mainWindow 上屏渲染 + this.attachToMainWindow(entry.instance as InteractivePreview); + Service.Engine.repaintInEditMode(); - async querySpinePreview(uuid: string, width: number, height: number) { - await this.spinePreview.setSpine(uuid); - return await this.spinePreview.queryPreviewData({ width, height }); + return entry.instance; } - async queryScenePreview(width: number, height: number) { - return await this.scenePreview.queryPreviewData({ width, height }); + private attachToMainWindow(previewInstance: InteractivePreview) { + const inst = previewInstance as any; + if (!inst?.cameraComp) return; + + const mainWindow = cc.director.root.mainWindow; + const camera = inst.cameraComp.camera || inst.camera; + if (!camera || !mainWindow) return; + + camera.changeTargetWindow(mainWindow); + camera.isWindowSize = true; + camera.enabled = true; + inst.cameraComp.enabled = true; + + if (inst.scene?.renderScene && !camera.scene) { + inst.scene.renderScene.addCamera(camera); + } + + if (inst.worldAxis) { + inst.worldAxis._sceneGizmoCamera.camera.changeTargetWindow(mainWindow); + if (inst.enableAxis) { + inst.worldAxis.show(); + } + } } public switchMaterialPrimitive(type: string) { this.materialPreview.switchPrimitive(type); } + public switchLight(enabled: boolean) { + this.materialPreview.setLightEnable(enabled); + Service.Engine.repaintInEditMode(); + } + // --- 缩略图生成 --- public async generateThumbnail(uuid: string, assetType: string, width = 128, height = 128) { - switch (assetType) { - case 'cc.Material': - return await this.queryMaterialPreview(uuid, width, height); - case 'cc.Mesh': - return await this.queryMeshPreview(uuid, width, height); - case 'cc.Prefab': - return await this.queryPrefabPreview(uuid, width, height); - case 'cc.Skeleton': - return await this.querySkeletonPreview(uuid, width, height); - case 'sp.SkeletonData': - return await this.querySpinePreview(uuid, width, height); - default: - // 对于 fbx/gltf 等模型资源 - if (['cc.FBX', 'cc.GLTF', 'cc.ModelAsset'].includes(assetType)) { - return await this.queryModelPreview(uuid, width, height); - } - return null; - } + const entry = this.resolvePreview(assetType); + if (!entry) return null; + await (entry.instance as any)[entry.setup](uuid); + return await entry.instance.queryPreviewData({ width, height }); } // --- Service 事件钩子 --- diff --git a/static/web/preview-app.js b/static/web/preview-app.js index ee803d883..d27598ae5 100644 --- a/static/web/preview-app.js +++ b/static/web/preview-app.js @@ -1,16 +1,5 @@ /* global window, document, cc */ -const PREVIEW_TYPES = { - material: { setup: 'setMaterialByUuid', instance: 'materialPreview' }, - model: { setup: 'setModel', instance: 'modelPreview' }, - mesh: { setup: 'setMesh', instance: 'meshPreview' }, - prefab: { setup: 'setPrefab', instance: 'prefabPreview' }, - skeleton: { setup: 'setSkeleton', instance: 'skeletonPreview' }, - spine: { setup: 'setSpine', instance: 'spinePreview' }, -}; - -var _activePreviewInstance = null; - function log(msg, level) { if (level === 'err') console.error('[Preview]', msg); else if (level === 'warn') console.warn('[Preview]', msg); @@ -25,36 +14,6 @@ function getPreviewService() { } } -// ── Redirect preview camera to main window ── - -function attachToMainWindow(previewInstance) { - if (!previewInstance || !previewInstance.cameraComp) return; - - var mainWindow = cc.director.root.mainWindow; - var camera = previewInstance.cameraComp.camera || previewInstance.camera; - if (!camera || !mainWindow) return; - - camera.changeTargetWindow(mainWindow); - camera.isWindowSize = true; - camera.enabled = true; - previewInstance.cameraComp.enabled = true; - - if (previewInstance.scene && previewInstance.scene.renderScene) { - if (!camera.scene) { - previewInstance.scene.renderScene.addCamera(camera); - } - } - - if (previewInstance.worldAxis) { - previewInstance.worldAxis._sceneGizmoCamera.camera.changeTargetWindow(mainWindow); - if (previewInstance.enableAxis) { - previewInstance.worldAxis.show(); - } - } - - log('Attached preview camera to mainWindow'); -} - // ── Preview execution ── async function doPreview() { @@ -64,44 +23,25 @@ async function doPreview() { return null; } - var type = document.getElementById('pvType').value; var uuid = document.getElementById('pvUuid').value.trim(); var status = document.getElementById('pvStatus'); - var info = PREVIEW_TYPES[type]; - if (!info) { - log('Unknown preview type: ' + type, 'err'); - return null; - } if (!uuid) { - log('UUID is required for ' + type + ' preview', 'warn'); + log('UUID is required', 'warn'); return null; } status.textContent = 'Loading...'; - log('Preview: type=' + type + ' uuid=' + uuid); + log('Preview: uuid=' + uuid); try { - // Detach previous preview from mainWindow - if (_activePreviewInstance && _activePreviewInstance.cameraComp) { - _activePreviewInstance.cameraComp.enabled = false; + var instance = await preview.open(uuid); + if (!instance) { + status.textContent = 'unsupported type'; + return null; } - - var previewInstance = preview[info.instance]; - - // Call the setup method directly (setModel, setMesh, etc.) - // This skips the offscreen render + gl.readPixels pipeline - // that queryPreviewData uses for thumbnail generation. - await previewInstance[info.setup](uuid); - - _activePreviewInstance = previewInstance; - - // Redirect the preview camera to render on the main canvas - attachToMainWindow(previewInstance); - - window.cli.Scene.Engine.repaintInEditMode(); - status.textContent = type + ' ok'; - return null; + status.textContent = 'ok'; + return instance; } catch (e) { log('Preview error: ' + e.message, 'err'); status.textContent = 'error'; @@ -119,16 +59,14 @@ function switchPrimitive(type) { } } +var _lightOn = true; + function toggleLight() { var preview = getPreviewService(); if (!preview) return; - var mp = preview.materialPreview; - if (mp && mp.lightComp) { - var on = !mp.lightComp.enabled; - mp.setLightEnable(on); - window.cli.Scene.Engine.repaintInEditMode(); - log('Light: ' + (on ? 'ON' : 'OFF')); - } + _lightOn = !_lightOn; + preview.switchLight(_lightOn); + log('Light: ' + (_lightOn ? 'ON' : 'OFF')); } function toggle2D3D() { @@ -137,7 +75,7 @@ function toggle2D3D() { var mp = preview.materialPreview; if (mp && mp.viewToggle) { mp.viewToggle(); - attachToMainWindow(mp); + window.cli.Scene.Engine.repaintInEditMode(); log('Toggled 2D/3D view'); } } @@ -145,28 +83,35 @@ function toggle2D3D() { // ── Mouse event forwarding to InteractivePreview ── function bindPreviewMouseEvents(canvas) { + function getActive() { + var preview = getPreviewService(); + return preview && preview.activePreview; + } + canvas.addEventListener('mousedown', function(e) { - if (!_activePreviewInstance) return; - _activePreviewInstance.onMouseDown(e); + var active = getActive(); + if (active) active.onMouseDown(e); }); canvas.addEventListener('mousemove', function(e) { - if (!_activePreviewInstance) return; - _activePreviewInstance.onMouseMove(e); - if (_activePreviewInstance._isMouseDown) { + var active = getActive(); + if (!active) return; + active.onMouseMove(e); + if (active._isMouseDown) { window.cli.Scene.Engine.repaintInEditMode(); } }); canvas.addEventListener('mouseup', function(e) { - if (!_activePreviewInstance) return; - _activePreviewInstance.onMouseUp(e); + var active = getActive(); + if (active) active.onMouseUp(e); }); canvas.addEventListener('wheel', function(e) { - if (!_activePreviewInstance) return; + var active = getActive(); + if (!active) return; e.preventDefault(); - _activePreviewInstance.onMouseWheel({ + active.onMouseWheel({ wheelDeltaY: -e.deltaY, }); window.cli.Scene.Engine.repaintInEditMode(); @@ -206,32 +151,23 @@ export default function initPreviewApp() { // Parse URL params for auto-preview var params = new URLSearchParams(window.location.search); - var type = params.get('type'); var uuid = params.get('uuid'); - if (type && PREVIEW_TYPES[type]) { - document.getElementById('pvType').value = type; - } if (uuid) { document.getElementById('pvUuid').value = uuid; - } - - if (type && uuid) { - log('Auto-preview from URL params: type=' + type + ' uuid=' + uuid); + log('Auto-preview from URL params: uuid=' + uuid); setTimeout(function() { doPreview(); }, 100); } // Expose API for external automation window.previewAPI = { doPreview: doPreview, - preview: function(type, uuid) { - document.getElementById('pvType').value = type; + open: function(uuid) { document.getElementById('pvUuid').value = uuid || ''; return doPreview(); }, switchPrimitive: switchPrimitive, toggleLight: toggleLight, toggle2D3D: toggle2D3D, - getPreviewTypes: function() { return Object.keys(PREVIEW_TYPES); }, }; } diff --git a/static/web/preview.ejs b/static/web/preview.ejs index 785741266..28163f4b6 100644 --- a/static/web/preview.ejs +++ b/static/web/preview.ejs @@ -75,16 +75,6 @@
- - - diff --git a/tests/__snapshots__/dts-snapshot.test.ts.snap b/tests/__snapshots__/dts-snapshot.test.ts.snap index 81ffc8e1f..f02559209 100644 --- a/tests/__snapshots__/dts-snapshot.test.ts.snap +++ b/tests/__snapshots__/dts-snapshot.test.ts.snap @@ -5605,7 +5605,9 @@ export declare interface IPrefabStateInfo { } export declare interface IPreviewService { init(): void; + open(uuid: string): Promise; switchMaterialPrimitive(type: string): void; + switchLight(enabled: boolean): void; generateThumbnail(uuid: string, assetType: string, width?: number, height?: number): Promise; } export declare interface IProperty { @@ -7699,7 +7701,9 @@ export declare interface IPrefabStateInfo { } export declare interface IPreviewService { init(): void; + open(uuid: string): Promise; switchMaterialPrimitive(type: string): void; + switchLight(enabled: boolean): void; generateThumbnail(uuid: string, assetType: string, width?: number, height?: number): Promise; } export declare interface IProject { From 8fbcf872dd2b1bc6554d8cbe8617ae4c7b1f5b73 Mon Sep 17 00:00:00 2001 From: fengyuzhe Date: Sat, 23 May 2026 15:31:05 +0800 Subject: [PATCH 08/11] refine --- src/core/scene/common/preview.ts | 32 +++++++++++++--- .../scene-process/service/preview/index.ts | 21 +++------- .../service/preview/interactive-preview.ts | 6 ++- .../service/preview/material-preview.ts | 4 +- .../service/preview/spine-preview.ts | 3 +- static/web/preview-app.js | 38 +++++++++---------- tests/__snapshots__/dts-snapshot.test.ts.snap | 30 +++++++++++---- 7 files changed, 83 insertions(+), 51 deletions(-) diff --git a/src/core/scene/common/preview.ts b/src/core/scene/common/preview.ts index 12d6c854c..47d40e81d 100644 --- a/src/core/scene/common/preview.ts +++ b/src/core/scene/common/preview.ts @@ -1,13 +1,35 @@ +export interface IPreviewInstance { + onMouseDown(event: any): void; + onMouseMove(event: any): void; + onMouseUp(event: any): void; + onMouseWheel(event: any): void; + viewToggle(): void; + is2DView(): boolean; + resetCameraView(): void; + hide(): void; +} + +export interface IMaterialPreviewInstance extends IPreviewInstance { + switchPrimitive(type: string): void; + setLightEnable(enabled: boolean): void; +} + +export interface ISpinePreviewInstance extends IPreviewInstance { + play(): void; + pause(): void; + stop(): void; + setSkinIndex(index: number): void; + setAnimationIndex(index: number): void; + close(): void; +} + export interface IPreviewService { - init(): void; - open(uuid: string): Promise; - switchMaterialPrimitive(type: string): void; - switchLight(enabled: boolean): void; + open(uuid: string): Promise; generateThumbnail(uuid: string, assetType: string, width?: number, height?: number): Promise; } export type IPublicPreviewService = Pick; // eslint-disable-next-line @typescript-eslint/no-empty-object-type diff --git a/src/core/scene/scene-process/service/preview/index.ts b/src/core/scene/scene-process/service/preview/index.ts index 277c9b66e..7c07cf340 100644 --- a/src/core/scene/scene-process/service/preview/index.ts +++ b/src/core/scene/scene-process/service/preview/index.ts @@ -10,7 +10,7 @@ import { SpinePreview } from './spine-preview'; import { BaseService, register, Service } from '../core'; import { Rpc } from '../../rpc'; import type { InteractivePreview } from './interactive-preview'; -import type { IPreviewService, IPreviewEvents } from '../../../common/preview'; +import type { IPreviewService, IPreviewEvents, IPreviewInstance } from '../../../common/preview'; interface PreviewTypeEntry { instance: PreviewBase; @@ -22,7 +22,7 @@ export class PreviewService extends BaseService implements IPrev private _previewMap: Map = new Map(); private _typeMap: Map = new Map(); private _initialized = false; - private _activePreview: PreviewBase | null = null; + private _activePreview: IPreviewInstance | null = null; scenePreview = scenePreview; materialPreview = new MaterialPreview(); @@ -33,7 +33,7 @@ export class PreviewService extends BaseService implements IPrev prefabPreview = new PrefabPreview(); spinePreview = new SpinePreview(); - get activePreview(): PreviewBase | null { + get activePreview(): IPreviewInstance | null { return this._activePreview; } @@ -109,7 +109,7 @@ export class PreviewService extends BaseService implements IPrev // --- 上屏预览 --- - async open(uuid: string): Promise { + async open(uuid: string): Promise { const assetType = await this.resolveAssetType(uuid); if (!assetType) { console.warn(`[Preview] Cannot resolve asset type for uuid: ${uuid}`); @@ -132,13 +132,13 @@ export class PreviewService extends BaseService implements IPrev // 设置资源 await (entry.instance as any)[entry.setup](uuid); - this._activePreview = entry.instance; + this._activePreview = entry.instance as unknown as IPreviewInstance; // 将相机挂到 mainWindow 上屏渲染 this.attachToMainWindow(entry.instance as InteractivePreview); Service.Engine.repaintInEditMode(); - return entry.instance; + return this._activePreview; } private attachToMainWindow(previewInstance: InteractivePreview) { @@ -166,15 +166,6 @@ export class PreviewService extends BaseService implements IPrev } } - public switchMaterialPrimitive(type: string) { - this.materialPreview.switchPrimitive(type); - } - - public switchLight(enabled: boolean) { - this.materialPreview.setLightEnable(enabled); - Service.Engine.repaintInEditMode(); - } - // --- 缩略图生成 --- public async generateThumbnail(uuid: string, assetType: string, width = 128, height = 128) { diff --git a/src/core/scene/scene-process/service/preview/interactive-preview.ts b/src/core/scene/scene-process/service/preview/interactive-preview.ts index 76856c6cf..7b1b3a6c2 100644 --- a/src/core/scene/scene-process/service/preview/interactive-preview.ts +++ b/src/core/scene/scene-process/service/preview/interactive-preview.ts @@ -5,6 +5,7 @@ import { PreviewWorldAxis } from './preview-axis'; import { Grid } from './grid'; import { smoothMouseWheelScale } from '../camera/camera-controller-3d'; import { Service } from '../core/decorator'; +import type { IPreviewInstance } from '../../../common/preview'; const tempVec3A = new Vec3(); const tempVec3B = new Vec3(); @@ -53,7 +54,7 @@ function makeVec3InRange(v: Vec3, min: number, max: number) { v.z = Math.max(min, Math.min(max, v.z)); } -class InteractivePreview extends PreviewBase { +class InteractivePreview extends PreviewBase implements IPreviewInstance { protected scene!: Scene; protected cameraComp!: Camera; protected camera: renderer.scene.Camera | any; @@ -538,6 +539,9 @@ class InteractivePreview extends PreviewBase { public hide() { this.cameraComp.enabled = false; } + + public resetCameraView() { + } } export { InteractivePreview, getBoundaryOfMeshNodes }; diff --git a/src/core/scene/scene-process/service/preview/material-preview.ts b/src/core/scene/scene-process/service/preview/material-preview.ts index 97c203c2e..1daaf2fcd 100644 --- a/src/core/scene/scene-process/service/preview/material-preview.ts +++ b/src/core/scene/scene-process/service/preview/material-preview.ts @@ -80,7 +80,9 @@ function getPrimitiveData(): Record { const tempVec3A = new Vec3(); const tempVec3B = new Vec3(); -export class MaterialPreview extends InteractivePreview { +import type { IMaterialPreviewInstance } from '../../../common/preview'; + +export class MaterialPreview extends InteractivePreview implements IMaterialPreviewInstance { private lightComp!: DirectionalLight; private modelComp!: MeshRenderer; private currentPrimitive = 'sphere'; diff --git a/src/core/scene/scene-process/service/preview/spine-preview.ts b/src/core/scene/scene-process/service/preview/spine-preview.ts index 4405d66ad..ae503916f 100644 --- a/src/core/scene/scene-process/service/preview/spine-preview.ts +++ b/src/core/scene/scene-process/service/preview/spine-preview.ts @@ -1,8 +1,9 @@ import { InteractivePreview, getBoundaryOfMeshNodes } from './interactive-preview'; import { Scene, Node, assetManager, DirectionalLight, Canvas } from 'cc'; import { Service } from '../core/decorator'; +import type { ISpinePreviewInstance } from '../../../common/preview'; -export class SpinePreview extends InteractivePreview { +export class SpinePreview extends InteractivePreview implements ISpinePreviewInstance { protected is2D = true; protected enableViewToggle = false; protected orthoScale = 0.6; diff --git a/static/web/preview-app.js b/static/web/preview-app.js index d27598ae5..b59e90835 100644 --- a/static/web/preview-app.js +++ b/static/web/preview-app.js @@ -14,6 +14,11 @@ function getPreviewService() { } } +function getActive() { + var preview = getPreviewService(); + return preview && preview.activePreview; +} + // ── Preview execution ── async function doPreview() { @@ -51,30 +56,28 @@ async function doPreview() { } function switchPrimitive(type) { - var preview = getPreviewService(); - if (preview) { - preview.switchMaterialPrimitive(type); + var active = getActive(); + if (active && active.switchPrimitive) { + active.switchPrimitive(type); window.cli.Scene.Engine.repaintInEditMode(); log('Switched primitive: ' + type); } } -var _lightOn = true; - function toggleLight() { - var preview = getPreviewService(); - if (!preview) return; - _lightOn = !_lightOn; - preview.switchLight(_lightOn); - log('Light: ' + (_lightOn ? 'ON' : 'OFF')); + var active = getActive(); + if (!active || !active.setLightEnable) return; + var light = active.lightComp; + var on = light ? !light.enabled : true; + active.setLightEnable(on); + window.cli.Scene.Engine.repaintInEditMode(); + log('Light: ' + (on ? 'ON' : 'OFF')); } function toggle2D3D() { - var preview = getPreviewService(); - if (!preview) return; - var mp = preview.materialPreview; - if (mp && mp.viewToggle) { - mp.viewToggle(); + var active = getActive(); + if (active && active.viewToggle) { + active.viewToggle(); window.cli.Scene.Engine.repaintInEditMode(); log('Toggled 2D/3D view'); } @@ -83,11 +86,6 @@ function toggle2D3D() { // ── Mouse event forwarding to InteractivePreview ── function bindPreviewMouseEvents(canvas) { - function getActive() { - var preview = getPreviewService(); - return preview && preview.activePreview; - } - canvas.addEventListener('mousedown', function(e) { var active = getActive(); if (active) active.onMouseDown(e); diff --git a/tests/__snapshots__/dts-snapshot.test.ts.snap b/tests/__snapshots__/dts-snapshot.test.ts.snap index f02559209..78b3fc4f3 100644 --- a/tests/__snapshots__/dts-snapshot.test.ts.snap +++ b/tests/__snapshots__/dts-snapshot.test.ts.snap @@ -5603,11 +5603,18 @@ export declare interface IPrefabStateInfo { isNested: boolean; assetUuid: string; } +export declare interface IPreviewInstance { + onMouseDown(event: any): void; + onMouseMove(event: any): void; + onMouseUp(event: any): void; + onMouseWheel(event: any): void; + viewToggle(): void; + is2DView(): boolean; + resetCameraView(): void; + hide(): void; +} export declare interface IPreviewService { - init(): void; - open(uuid: string): Promise; - switchMaterialPrimitive(type: string): void; - switchLight(enabled: boolean): void; + open(uuid: string): Promise; generateThumbnail(uuid: string, assetType: string, width?: number, height?: number): Promise; } export declare interface IProperty { @@ -7699,11 +7706,18 @@ export declare interface IPrefabStateInfo { isNested: boolean; assetUuid: string; } +export declare interface IPreviewInstance { + onMouseDown(event: any): void; + onMouseMove(event: any): void; + onMouseUp(event: any): void; + onMouseWheel(event: any): void; + viewToggle(): void; + is2DView(): boolean; + resetCameraView(): void; + hide(): void; +} export declare interface IPreviewService { - init(): void; - open(uuid: string): Promise; - switchMaterialPrimitive(type: string): void; - switchLight(enabled: boolean): void; + open(uuid: string): Promise; generateThumbnail(uuid: string, assetType: string, width?: number, height?: number): Promise; } export declare interface IProject { From de93983db5201f687ee092e82051faf0ebc51f48 Mon Sep 17 00:00:00 2001 From: fengyuzhe Date: Mon, 1 Jun 2026 15:08:36 +0800 Subject: [PATCH 09/11] refine --- .../__snapshots__/dts-snapshot.test.ts.snap | 3 +- src/core/scene/common/engine.ts | 2 +- .../scene/main-process/proxy/engine-proxy.ts | 6 ---- .../scene/scene-process/service/engine.ts | 28 +++++++++---------- workflow/generate-dts-postprocess.ts | 6 ++-- 5 files changed, 20 insertions(+), 25 deletions(-) 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 f91313a19..3f4fbd0fa 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 @@ -777,8 +777,7 @@ export { } exports[`DTS API compatibility builder.d.ts should match snapshot 1`] = ` "import EventEmitter from 'events'; import { EventEmitter as EventEmitter_2 } from 'stream'; -import { IAssetDeleteOptions } from './filesystem'; -import { IAssetWriteFileOptions } from './filesystem'; +import { IAssetDeleteOptions, IAssetWriteFileOptions } from '@cocos/asset-db/libs/filesystem'; import type { PluginItem } from '@babel/core'; import { SpriteFrame } from 'cc'; export declare interface AcornNode { diff --git a/src/core/scene/common/engine.ts b/src/core/scene/common/engine.ts index 28fdf76bf..7b66440ee 100644 --- a/src/core/scene/common/engine.ts +++ b/src/core/scene/common/engine.ts @@ -5,7 +5,7 @@ export interface IEngineEvents { 'engine:ticked': []; } -export interface IPublicEngineService extends Omit {} +export interface IPublicEngineService extends Omit {} export interface IEngineService extends IServiceEvents { /** diff --git a/src/core/scene/main-process/proxy/engine-proxy.ts b/src/core/scene/main-process/proxy/engine-proxy.ts index 641118703..22bdeb957 100644 --- a/src/core/scene/main-process/proxy/engine-proxy.ts +++ b/src/core/scene/main-process/proxy/engine-proxy.ts @@ -8,10 +8,4 @@ export const EngineProxy: IPublicEngineService = { repaintInEditMode() { return Rpc.getInstance().request('Engine', 'repaintInEditMode'); }, - pause() { - Rpc.getInstance().request('Engine', 'pause'); - }, - resume() { - Rpc.getInstance().request('Engine', 'resume'); - }, }; diff --git a/src/core/scene/scene-process/service/engine.ts b/src/core/scene/scene-process/service/engine.ts index 9143f6e68..ad33f575e 100644 --- a/src/core/scene/scene-process/service/engine.ts +++ b/src/core/scene/scene-process/service/engine.ts @@ -54,7 +54,7 @@ export class EngineService extends BaseService implements IEngine public setTimeout(callback: any, time: number) { if (this._capture) { - // eslint-disable-next-line no-undef + this._rafId = requestAnimationFrame(callback); } else { this._setTimeoutId = setTimeout(callback, time); @@ -67,7 +67,7 @@ export class EngineService extends BaseService implements IEngine this._setTimeoutId = null; } if (this._rafId) { - // eslint-disable-next-line no-undef + cancelAnimationFrame(this._rafId); this._rafId = null; } @@ -127,6 +127,7 @@ export class EngineService extends BaseService implements IEngine this._paused = true; } + // 与 cocos-editor 一致:检查节点是否含有粒子/地形组件,控制连续 tick public checkToSetAnimState(nodes: Node[]) { let hasParticleComp = false; @@ -170,8 +171,8 @@ export class EngineService extends BaseService implements IEngine this.broadcast('engine:update'); // Dispatch per-frame updates to Camera and Gizmo services - try { Service.Camera?.onUpdate?.(Time.deltaTime); } catch (e) { /* not registered yet */ } - try { Service.Gizmo?.onUpdate?.(Time.deltaTime); } catch (e) { /* not registered yet */ } + try { Service.Camera?.onUpdate?.(Time.deltaTime); } catch { /* not registered yet */ } + try { Service.Gizmo?.onUpdate?.(Time.deltaTime); } catch { /* not registered yet */ } } this.broadcast('engine:ticked'); } catch (e) { @@ -326,21 +327,20 @@ export class EngineService extends BaseService implements IEngine // 与 cocos-editor ParticleManager.getSelectedParticleSystemComponents 一致 private _getSelectedParticleSystems(): Component[] { const result: Component[] = []; - const self = this; - function addUnique(comps: Component[]) { + const addUnique = (comps: Component[]) => { for (const comp of comps) { if (!result.includes(comp)) { result.push(comp); } } - } + }; - function collectInChildren(node: Node): Component[] { + const collectInChildren = (node: Node): Component[] => { const found: Component[] = []; if (node.components) { for (const comp of node.components) { - if (self._isParticleSystem3D(comp)) { + if (this._isParticleSystem3D(comp)) { found.push(comp); } } @@ -351,19 +351,19 @@ export class EngineService extends BaseService implements IEngine } } return found; - } + }; - function recursivelyAdd(node: Node) { - const hasParticle = node.components?.some((c: Component) => self._isParticleSystem3D(c)); + const recursivelyAdd = (node: Node) => { + const hasParticle = node.components?.some((c: Component) => this._isParticleSystem3D(c)); if (hasParticle) { const parent = node.parent; - if (parent && parent.components?.some((c: Component) => self._isParticleSystem3D(c))) { + if (parent && parent.components?.some((c: Component) => this._isParticleSystem3D(c))) { recursivelyAdd(parent); } else { addUnique(collectInChildren(node)); } } - } + }; for (const uuid of this._particleSelectedUUIDs) { const node = this._getNodeByUuid(uuid); diff --git a/workflow/generate-dts-postprocess.ts b/workflow/generate-dts-postprocess.ts index 7057815ce..d14a9f037 100644 --- a/workflow/generate-dts-postprocess.ts +++ b/workflow/generate-dts-postprocess.ts @@ -21,11 +21,13 @@ function replaceTopLevelBlock( } function ensureBuilderFilesystemImports(content: string): string { + // Remove any legacy relative filesystem imports that may have been generated previously + content = content.replace(/import\s*\{[^}]*\}\s*from\s*'\.\/filesystem';\s*/g, ''); const imports = [ - "import { IAssetDeleteOptions } from './filesystem';", - "import { IAssetWriteFileOptions } from './filesystem';", + "import { IAssetDeleteOptions, IAssetWriteFileOptions } from '@cocos/asset-db/libs/filesystem';", ]; + const missingImports = imports.filter((line) => !content.includes(line)); if (missingImports.length === 0) { return content; From 4edb90c12b6028321efd0e697e917cd013105545 Mon Sep 17 00:00:00 2001 From: fengyuzhe Date: Mon, 1 Jun 2026 15:35:56 +0800 Subject: [PATCH 10/11] refine --- tests/generate-dts-postprocess.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/generate-dts-postprocess.test.ts b/tests/generate-dts-postprocess.test.ts index 15e8f3eef..3bcd11441 100644 --- a/tests/generate-dts-postprocess.test.ts +++ b/tests/generate-dts-postprocess.test.ts @@ -41,8 +41,7 @@ describe('normalizeDtsRollupContent', () => { const normalized = normalizeDtsRollupContent('builder.d.ts', macBuilderRollup); - expect(normalized).toContain("import { IAssetDeleteOptions } from './filesystem';"); - expect(normalized).toContain("import { IAssetWriteFileOptions } from './filesystem';"); + expect(normalized).toContain("import { IAssetDeleteOptions, IAssetWriteFileOptions } from '@cocos/asset-db/libs/filesystem';"); expect(normalized).toContain('save(): Promise;'); expect(normalized).toContain('write(path: string, options?: IAssetWriteFileOptions): Promise;'); expect(normalized).toContain('remove(path: string, options?: IAssetDeleteOptions): Promise;'); From d0eeb70d14c8a4c3dc62025d8160e01dcdfeddfd Mon Sep 17 00:00:00 2001 From: fengyuzhe Date: Mon, 1 Jun 2026 16:26:41 +0800 Subject: [PATCH 11/11] refine --- .github/workflows/check-dts.yml | 11 +++ .../__snapshots__/dts-snapshot.test.ts.snap | 8 +- .../cocos-cli-types/__tests__/assets.test.ts | 98 ++++++++++++++++++- .../cocos-cli-types/__tests__/builder.test.ts | 62 +++++++++++- .../cocos-cli-types/__tests__/cli.test.ts | 53 +++++++++- .../__tests__/configuration.test.ts | 46 ++++++++- .../cocos-cli-types/__tests__/engine.test.ts | 51 +++++++++- .../cocos-cli-types/__tests__/index.test.ts | 27 +++-- .../cocos-cli-types/__tests__/project.test.ts | 25 +++++ .../__tests__/scripting.test.ts | 50 +++++++++- workflow/generate-dts-postprocess.ts | 31 ++++++ workflow/generate-dts.ts | 2 +- 12 files changed, 435 insertions(+), 29 deletions(-) diff --git a/.github/workflows/check-dts.yml b/.github/workflows/check-dts.yml index b19201072..5d36160a3 100644 --- a/.github/workflows/check-dts.yml +++ b/.github/workflows/check-dts.yml @@ -38,6 +38,17 @@ jobs: npx jest --roots packages/cocos-cli-types --testPathPattern dts-snapshot --no-cache -u npx jest packages/cocos-cli-types --roots="/packages/cocos-cli-types" + - name: Typecheck DTS declarations + shell: bash + run: | + errors=$(npx tsc -p packages/cocos-cli-types/tsconfig.typecheck.json --noEmit 2>&1 | grep "^packages/cocos-cli-types/" || true) + if [ -n "$errors" ]; then + echo "::error::DTS typecheck failed — type errors found in cocos-cli-types declarations:" + echo "$errors" + exit 1 + fi + echo "DTS typecheck passed." + - name: Check DTS API compatibility shell: bash env: 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 3f4fbd0fa..766a7ee4b 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 @@ -1349,7 +1349,7 @@ export declare interface BundleRenderConfig { maxOptionList: Record; minOptionList?: Record; } -export declare type CCEnvConstants = ConstantManager.CCEnvConstants; +export declare type CCEnvConstants = StatsQuery.ConstantManager.CCEnvConstants; export declare type ChangeEvent = 'create' | 'update' | 'delete'; export declare interface ChokidarOptions { alwaysStat?: boolean; @@ -1430,7 +1430,7 @@ export declare interface Config { optimizeDecorators: IOptimizeDecorators; treeShake?: ITreeShakeConfig; } -declare namespace ConfigInterface { +export declare namespace ConfigInterface { export { Config, IndexConfig, @@ -4190,7 +4190,7 @@ export declare type ResolveImportMetaHook = ( prop: string | null, options: { chunkId: string; format: InternalModuleFormat; moduleId: string } ) => string | null | void; -declare namespace rollup { +export declare namespace rollup { export { rollup_2 as rollup, watch, @@ -4632,7 +4632,7 @@ export declare class StatsQuery { private _featureUnits; private _editorPublicModules; } -declare namespace StatsQuery { +export declare namespace StatsQuery { namespace ConstantManager { type PlatformType = 'WEB_EDITOR' | 'WEB_MOBILE' | 'WEB_DESKTOP' | 'WECHAT' | 'WECHAT_MINI_PROGRAM' | 'BYTEDANCE' | 'ALIPAY' | 'TAOBAO' | 'TAOBAO_MINIGAME' | 'OPPO' | 'VIVO' | 'HUAWEI' | 'HONOR' | 'COCOS_RUNTIME' | 'SUD' | 'SUDV2' | 'NATIVE_EDITOR' | 'ANDROID' | 'WINDOWS' | 'IOS' | 'MAC' | 'OHOS' | 'OPEN_HARMONY' | 'LINUX' | 'HTML5' | 'NATIVE' | 'NODEJS' | 'INVALID_PLATFORM'; type IPlatformConfig = { [key in PlatformType]: boolean }; diff --git a/packages/cocos-cli-types/__tests__/assets.test.ts b/packages/cocos-cli-types/__tests__/assets.test.ts index 7cedde18e..efcf84067 100644 --- a/packages/cocos-cli-types/__tests__/assets.test.ts +++ b/packages/cocos-cli-types/__tests__/assets.test.ts @@ -1,6 +1,55 @@ -import type { AssetHandlerType, AssetDBOptions, IAssetInfo, IAssetType } from '../assets'; +type AssetsModule = typeof import('../assets'); +import type { AssetHandlerType, AssetDBOptions, IAssetInfo, IAssetType, IAssetMeta, AssetUserDataMap, CreateAssetOptions, DeleteAssetOptions, QueryAssetsOption } from '../assets'; describe('cocos-cli-types: assets', () => { + it('should be able to import api functions', () => { + let _init: AssetsModule['init'] | undefined = undefined; + let _createAsset: AssetsModule['createAsset'] | undefined = undefined; + let _createAssetByType: AssetsModule['createAssetByType'] | undefined = undefined; + let _deleteAsset: AssetsModule['deleteAsset'] | undefined = undefined; + let _importAsset: AssetsModule['importAsset'] | undefined = undefined; + let _queryAssetInfo: AssetsModule['queryAssetInfo'] | undefined = undefined; + let _queryAssetInfos: AssetsModule['queryAssetInfos'] | undefined = undefined; + let _queryAssetMeta: AssetsModule['queryAssetMeta'] | undefined = undefined; + let _saveAsset: AssetsModule['saveAsset'] | undefined = undefined; + let _moveAsset: AssetsModule['moveAsset'] | undefined = undefined; + let _renameAsset: AssetsModule['renameAsset'] | undefined = undefined; + let _refresh: AssetsModule['refresh'] | undefined = undefined; + let _reimportAsset: AssetsModule['reimportAsset'] | undefined = undefined; + let _queryUUID: AssetsModule['queryUUID'] | undefined = undefined; + let _queryPath: AssetsModule['queryPath'] | undefined = undefined; + let _queryUrl: AssetsModule['queryUrl'] | undefined = undefined; + let _generateThumbnail: AssetsModule['generateThumbnail'] | undefined = undefined; + let _onAssetAdded: AssetsModule['onAssetAdded'] | undefined = undefined; + let _onAssetChanged: AssetsModule['onAssetChanged'] | undefined = undefined; + let _onAssetRemoved: AssetsModule['onAssetRemoved'] | undefined = undefined; + let _onReady: AssetsModule['onReady'] | undefined = undefined; + let _onDBReady: AssetsModule['onDBReady'] | undefined = undefined; + + expect(_init).toBeUndefined(); + expect(_createAsset).toBeUndefined(); + expect(_createAssetByType).toBeUndefined(); + expect(_deleteAsset).toBeUndefined(); + expect(_importAsset).toBeUndefined(); + expect(_queryAssetInfo).toBeUndefined(); + expect(_queryAssetInfos).toBeUndefined(); + expect(_queryAssetMeta).toBeUndefined(); + expect(_saveAsset).toBeUndefined(); + expect(_moveAsset).toBeUndefined(); + expect(_renameAsset).toBeUndefined(); + expect(_refresh).toBeUndefined(); + expect(_reimportAsset).toBeUndefined(); + expect(_queryUUID).toBeUndefined(); + expect(_queryPath).toBeUndefined(); + expect(_queryUrl).toBeUndefined(); + expect(_generateThumbnail).toBeUndefined(); + expect(_onAssetAdded).toBeUndefined(); + expect(_onAssetChanged).toBeUndefined(); + expect(_onAssetRemoved).toBeUndefined(); + expect(_onReady).toBeUndefined(); + expect(_onDBReady).toBeUndefined(); + }); + it('should be able to import AssetHandlerType', () => { let type: AssetHandlerType = 'database'; expect(type).toBe('database'); @@ -10,21 +59,60 @@ describe('cocos-cli-types: assets', () => { let options: Partial = { name: 'test-db', target: 'path/to/target', - level: 3 + level: 3, }; expect(options.name).toBe('test-db'); }); - - it('should be able to import IAssetInfo', () => { + + it('IAssetInfo should have core properties', () => { + const keys: (keyof IAssetInfo)[] = [ + 'name', 'source', 'url', 'file', 'uuid', + 'importer', 'imported', 'invalid', 'type', + 'isDirectory', 'library', + ]; + expect(keys.length).toBeGreaterThan(0); + let info: Partial = { name: 'test-asset', uuid: 'test-uuid', + type: 'cc.Texture2D', }; expect(info.name).toBe('test-asset'); }); - + it('should be able to import IAssetType', () => { let type: IAssetType = 'cc.Texture2D'; expect(type).toBe('cc.Texture2D'); }); + + it('IAssetMeta should have meta properties', () => { + const keys: (keyof IAssetMeta)[] = [ + 'ver', 'importer', 'imported', 'uuid', 'files', 'subMetas', 'userData', + ]; + expect(keys.length).toBeGreaterThan(0); + }); + + it('CreateAssetOptions should have target', () => { + let options: CreateAssetOptions = { + target: 'db://assets/test.png', + uuid: 'test-uuid', + }; + expect(options.target).toBe('db://assets/test.png'); + }); + + it('AssetUserDataMap should support known asset types', () => { + type ImageUserData = AssetUserDataMap['image']; + let _data: Partial = { type: 'texture' }; + expect(_data.type).toBe('texture'); + }); + + it('DeleteAssetOptions should be importable', () => { + let options: DeleteAssetOptions = { useTrash: true }; + expect(options.useTrash).toBe(true); + }); + + it('QueryAssetsOption should be importable', () => { + let _options: Partial = {}; + expect(_options).toBeDefined(); + }); }); diff --git a/packages/cocos-cli-types/__tests__/builder.test.ts b/packages/cocos-cli-types/__tests__/builder.test.ts index 272076f16..48574460b 100644 --- a/packages/cocos-cli-types/__tests__/builder.test.ts +++ b/packages/cocos-cli-types/__tests__/builder.test.ts @@ -1,5 +1,5 @@ type BuilderModule = typeof import('../builder'); -import type { IBuildTaskOption, IBuildResultData } from '../builder'; +import type { IBuildTaskOption, IBuildResultData, IBuildResult, IBuildCommonOptions, IBuilder, BuildConfiguration, StatsQuery } from '../builder'; describe('cocos-cli-types: builder', () => { it('should be able to import build task api functions', () => { @@ -8,12 +8,22 @@ describe('cocos-cli-types: builder', () => { let _make: BuilderModule['make'] | undefined = undefined; let _run: BuilderModule['run'] | undefined = undefined; let _queryBuildConfig: BuilderModule['queryBuildConfig'] | undefined = undefined; + let _init: BuilderModule['init'] | undefined = undefined; + let _createBuildTask: BuilderModule['createBuildTask'] | undefined = undefined; + let _getPreviewSettings: BuilderModule['getPreviewSettings'] | undefined = undefined; + let _getRegisteredPlatforms: BuilderModule['getRegisteredPlatforms'] | undefined = undefined; + let _executeBuildStageTask: BuilderModule['executeBuildStageTask'] | undefined = undefined; expect(_build).toBeUndefined(); expect(_buildBundleOnly).toBeUndefined(); expect(_make).toBeUndefined(); expect(_run).toBeUndefined(); expect(_queryBuildConfig).toBeUndefined(); + expect(_init).toBeUndefined(); + expect(_createBuildTask).toBeUndefined(); + expect(_getPreviewSettings).toBeUndefined(); + expect(_getRegisteredPlatforms).toBeUndefined(); + expect(_executeBuildStageTask).toBeUndefined(); }); it('should be able to import IBuildTaskOption', () => { @@ -22,11 +32,55 @@ describe('cocos-cli-types: builder', () => { }; expect(options.buildPath).toBe('build'); }); - + it('should be able to import IBuildResultData', () => { - // IBuildResultData is a branded string or a complex type depending on compilation output. - // We will just verify it can be declared as a type. let result: IBuildResultData | undefined = undefined; expect(result).toBeUndefined(); }); + + it('IBuildCommonOptions should have key properties', () => { + const keys: (keyof IBuildCommonOptions)[] = [ + 'name', 'outputName', 'buildPath', 'platform', + 'skipCompressTexture', 'packAutoAtlas', 'sourceMaps', + 'debug', 'md5Cache', 'startScene', 'packages', + ]; + expect(keys.length).toBeGreaterThan(0); + }); + + it('IBuildResult should have result methods', () => { + const keys: (keyof IBuildResult)[] = [ + 'dest', 'paths', 'containsAsset', + 'getRawAssetPaths', 'getJsonPathInfo', + 'getImportAssetPaths', 'getAssetPathInfo', + ]; + expect(keys.length).toBeGreaterThan(0); + }); + + it('IBuilder should have builder properties', () => { + const keys: (keyof IBuilder)[] = [ + 'cache', 'result', 'options', 'bundleManager', + 'hooksInfo', 'buildTemplate', 'id', 'utils', + 'updateProcess', 'break', + ]; + expect(keys.length).toBeGreaterThan(0); + }); + + it('BuildConfiguration should have config sections', () => { + const keys: (keyof BuildConfiguration)[] = [ + 'common', 'platforms', 'bundleConfig', 'textureCompressConfig', + ]; + expect(keys.length).toBeGreaterThan(0); + }); + + it('StatsQuery should be importable as a class', () => { + let _query: StatsQuery | undefined = undefined; + expect(_query).toBeUndefined(); + }); + + it('StatsQuery.ConstantManager namespace types should be accessible', () => { + let _platformType: StatsQuery.ConstantManager.PlatformType = 'WEB_MOBILE'; + let _valueType: StatsQuery.ConstantManager.ValueType = true; + expect(_platformType).toBe('WEB_MOBILE'); + expect(_valueType).toBe(true); + }); }); diff --git a/packages/cocos-cli-types/__tests__/cli.test.ts b/packages/cocos-cli-types/__tests__/cli.test.ts index dcf06d2e0..aa72e26d7 100644 --- a/packages/cocos-cli-types/__tests__/cli.test.ts +++ b/packages/cocos-cli-types/__tests__/cli.test.ts @@ -6,19 +6,20 @@ describe('cocos-cli-types: cli', () => { expect(cli).toBeDefined(); }); - it('ICLI.Scene should have all 13 service modules', () => { + it('IServiceManager should have all 15 service modules', () => { const scene: Partial = {}; const serviceKeys: (keyof IServiceManager)[] = [ 'Editor', 'Node', 'Component', 'Script', 'Asset', 'Engine', 'Prefab', 'Selection', - 'Operation', 'Undo', 'Camera', 'Gizmo', 'SceneView', + 'Operation', 'Undo', 'Camera', 'Gizmo', + 'SceneView', 'Preview', 'UI', ]; for (const key of serviceKeys) { expect(key).toBeDefined(); } - expect(serviceKeys).toHaveLength(13); + expect(serviceKeys).toHaveLength(15); expect(scene).toBeDefined(); }); @@ -30,16 +31,58 @@ describe('cocos-cli-types: cli', () => { it('IServiceManager.Node should have CRUD methods', () => { type NodeKeys = keyof IServiceManager['Node']; - const nodeMethods: NodeKeys[] = ['createByType', 'createByAsset', 'delete', 'query', 'queryNodeTree']; + const nodeMethods: NodeKeys[] = [ + 'createByType', 'createByAsset', 'delete', 'query', 'queryNodeTree', + 'setProperty', 'previewSetProperty', 'cancelPreviewSetProperty', + 'reset', 'resetProperty', 'getPathByUuid', + ]; expect(nodeMethods.length).toBeGreaterThan(0); }); it('IServiceManager.Component should have component methods', () => { type CompKeys = keyof IServiceManager['Component']; - const compMethods: CompKeys[] = ['add', 'remove', 'setProperty', 'query', 'queryAll']; + const compMethods: CompKeys[] = [ + 'add', 'remove', 'setProperty', 'query', 'queryAll', + 'reset', 'queryClasses', 'queryComponents', 'hasScript', + ]; expect(compMethods.length).toBeGreaterThan(0); }); + it('IServiceManager.Prefab should have prefab methods', () => { + type PrefabKeys = keyof IServiceManager['Prefab']; + const prefabMethods: PrefabKeys[] = [ + 'createPrefabFromNode', 'applyPrefabChanges', 'revertToPrefab', + 'unpackPrefabInstance', 'isPrefabInstance', 'getPrefabInfo', + ]; + expect(prefabMethods).toHaveLength(6); + }); + + it('IServiceManager.Camera should have camera methods', () => { + type CameraKeys = keyof IServiceManager['Camera']; + const cameraMethods: CameraKeys[] = [ + 'init', 'focus', 'changeProjection', 'queryConfig', + 'zoomUp', 'zoomDown', 'zoomReset', + ]; + expect(cameraMethods.length).toBeGreaterThan(0); + }); + + it('IServiceManager.Selection should have selection methods', () => { + type SelectionKeys = keyof IServiceManager['Selection']; + const selectionMethods: SelectionKeys[] = [ + 'select', 'unselect', 'clear', 'query', 'isSelect', 'reset', + ]; + expect(selectionMethods).toHaveLength(6); + }); + + it('IServiceManager.Undo should have undo methods', () => { + type UndoKeys = keyof IServiceManager['Undo']; + const undoMethods: UndoKeys[] = [ + 'beginRecording', 'endRecording', 'cancelRecording', + 'undo', 'redo', 'snapshot', 'reset', 'isDirty', + ]; + expect(undoMethods).toHaveLength(8); + }); + it('GlobalEventManager should have event methods', () => { type EventKeys = keyof GlobalEventManager; const eventMethods: EventKeys[] = ['on', 'once', 'off', 'emit', 'broadcast', 'clear']; diff --git a/packages/cocos-cli-types/__tests__/configuration.test.ts b/packages/cocos-cli-types/__tests__/configuration.test.ts index 853ffeaa9..257cd91e1 100644 --- a/packages/cocos-cli-types/__tests__/configuration.test.ts +++ b/packages/cocos-cli-types/__tests__/configuration.test.ts @@ -1,21 +1,63 @@ type ConfigurationModule = typeof import('../configuration'); -import type { IConfiguration } from '../configuration'; +import type { IConfiguration, IBaseConfiguration, ConfigurationScope, ICocosConfigurationNode, ICocosConfigurationPropertySchema } from '../configuration'; describe('cocos-cli-types: configuration', () => { it('should be able to import api functions', () => { let _init: ConfigurationModule['init'] | undefined = undefined; let _migrateFromProject: ConfigurationModule['migrateFromProject'] | undefined = undefined; let _reload: ConfigurationModule['reload'] | undefined = undefined; + let _get: ConfigurationModule['get'] | undefined = undefined; + let _set: ConfigurationModule['set'] | undefined = undefined; + let _remove: ConfigurationModule['remove'] | undefined = undefined; + let _save: ConfigurationModule['save'] | undefined = undefined; + let _migrate: ConfigurationModule['migrate'] | undefined = undefined; + let _getConfigPath: ConfigurationModule['getConfigPath'] | undefined = undefined; + let _onDidSave: ConfigurationModule['onDidSave'] | undefined = undefined; + let _getMetadata: ConfigurationModule['getMetadata'] | undefined = undefined; expect(_init).toBeUndefined(); expect(_migrateFromProject).toBeUndefined(); expect(_reload).toBeUndefined(); + expect(_get).toBeUndefined(); + expect(_set).toBeUndefined(); + expect(_remove).toBeUndefined(); + expect(_save).toBeUndefined(); + expect(_migrate).toBeUndefined(); + expect(_getConfigPath).toBeUndefined(); + expect(_onDidSave).toBeUndefined(); + expect(_getMetadata).toBeUndefined(); }); it('should be able to import IConfiguration', () => { let options: Partial = { - name: 'test-config' + name: 'test-config', }; expect(options.name).toBe('test-config'); }); + + it('IBaseConfiguration should have core methods', () => { + const keys: (keyof IBaseConfiguration)[] = [ + 'moduleName', 'getDefaultConfig', 'mergeDefaultConfig', + 'get', 'getAll', 'set', 'remove', 'save', + 'on', 'off', 'once', 'emit', + ]; + expect(keys.length).toBeGreaterThan(0); + }); + + it('ConfigurationScope should be a string union', () => { + let scope: ConfigurationScope = 'default'; + expect(scope).toBe('default'); + scope = 'project'; + expect(scope).toBe('project'); + }); + + it('ICocosConfigurationNode should have schema structure', () => { + const keys: (keyof ICocosConfigurationNode)[] = ['id', 'title', 'group', 'properties']; + expect(keys).toHaveLength(4); + }); + + it('ICocosConfigurationPropertySchema should have type field', () => { + const keys: (keyof ICocosConfigurationPropertySchema)[] = ['type', 'default', 'title', 'description']; + expect(keys.length).toBeGreaterThan(0); + }); }); diff --git a/packages/cocos-cli-types/__tests__/engine.test.ts b/packages/cocos-cli-types/__tests__/engine.test.ts index 5c2227dcc..df44efa71 100644 --- a/packages/cocos-cli-types/__tests__/engine.test.ts +++ b/packages/cocos-cli-types/__tests__/engine.test.ts @@ -1,8 +1,57 @@ type EngineModule = typeof import('../engine'); +import type { EngineInfo, IEngineConfig, IEngineModuleConfig, IDesignResolution, ModuleRenderConfig, IPhysicsConfig } from '../engine'; describe('cocos-cli-types: engine', () => { - it('should be able to import init', () => { + it('should be able to import api functions', () => { let _init: EngineModule['init'] | undefined = undefined; + let _getConfig: EngineModule['getConfig'] | undefined = undefined; + let _getInfo: EngineModule['getInfo'] | undefined = undefined; + let _getRenderConfig: EngineModule['getRenderConfig'] | undefined = undefined; + let _initEngine: EngineModule['initEngine'] | undefined = undefined; + let _startEngineCompilation: EngineModule['startEngineCompilation'] | undefined = undefined; + let _queryLayerBuiltin: EngineModule['queryLayerBuiltin'] | undefined = undefined; + expect(_init).toBeUndefined(); + expect(_getConfig).toBeUndefined(); + expect(_getInfo).toBeUndefined(); + expect(_getRenderConfig).toBeUndefined(); + expect(_initEngine).toBeUndefined(); + expect(_startEngineCompilation).toBeUndefined(); + expect(_queryLayerBuiltin).toBeUndefined(); + }); + + it('EngineInfo should have typescript and native fields', () => { + const keys: (keyof EngineInfo)[] = ['typescript', 'native', 'tmpDir', 'version']; + expect(keys).toHaveLength(4); + }); + + it('IEngineConfig should extend IEngineModuleConfig', () => { + const moduleKeys: (keyof IEngineModuleConfig)[] = ['includeModules']; + const configKeys: (keyof IEngineConfig)[] = [ + 'includeModules', 'physicsConfig', 'designResolution', + 'splashScreen', 'highQuality', 'macroCustom', + ]; + expect(moduleKeys.length).toBeGreaterThan(0); + expect(configKeys.length).toBeGreaterThan(0); + }); + + it('IDesignResolution should have dimension fields', () => { + let res: IDesignResolution = { height: 720, width: 1280 }; + expect(res.height).toBe(720); + expect(res.width).toBe(1280); + }); + + it('ModuleRenderConfig should have features and categories', () => { + const keys: (keyof ModuleRenderConfig)[] = ['features', 'categories', 'version']; + expect(keys).toHaveLength(3); + }); + + it('IPhysicsConfig should have physics properties', () => { + const keys: (keyof IPhysicsConfig)[] = [ + 'gravity', 'allowSleep', 'sleepThreshold', + 'autoSimulation', 'fixedTimeStep', 'maxSubSteps', + 'useNodeChains', 'collisionMatrix', 'physicsEngine', + ]; + expect(keys.length).toBeGreaterThan(0); }); }); diff --git a/packages/cocos-cli-types/__tests__/index.test.ts b/packages/cocos-cli-types/__tests__/index.test.ts index 936ee2877..33abc699f 100644 --- a/packages/cocos-cli-types/__tests__/index.test.ts +++ b/packages/cocos-cli-types/__tests__/index.test.ts @@ -2,7 +2,7 @@ import { Assets, Configuration } from '../index'; import type { BuildTemplateConfig } from '../builder'; describe('cocos-cli-types', () => { - it('should be able to import types from index', () => { + it('should be able to import types from Assets namespace', () => { let typeInfo: Assets.IAssetType = 'cc.Texture2D'; expect(typeInfo).toBeDefined(); @@ -15,22 +15,37 @@ describe('cocos-cli-types', () => { let assetOptions: Assets.CreateAssetOptions = { target: 'db://assets/test.png', - uuid: 'test-uuid' + uuid: 'test-uuid', }; expect(assetOptions).toBeDefined(); }); it('should be able to import types from builder', () => { let templateConfig: Partial = { - version: '1.0.0' + version: '1.0.0', }; expect(templateConfig).toBeDefined(); }); - - it('should be able to import types from configuration', () => { + + it('should be able to import types from Configuration namespace', () => { let packageConfig: Configuration.IConfiguration = { - test: 'value' + test: 'value', }; expect(packageConfig.test).toBe('value'); }); + + it('Assets namespace should have key exports', () => { + const keys: (keyof typeof Assets)[] = [ + 'init', 'createAsset', 'deleteAsset', 'queryAssetInfo', + 'queryAssetInfos', 'moveAsset', 'renameAsset', + ]; + expect(keys.length).toBeGreaterThan(0); + }); + + it('Configuration namespace should have key exports', () => { + const keys: (keyof typeof Configuration)[] = [ + 'init', 'migrateFromProject', 'reload', 'get', 'set', 'remove', 'save', + ]; + expect(keys.length).toBeGreaterThan(0); + }); }); diff --git a/packages/cocos-cli-types/__tests__/project.test.ts b/packages/cocos-cli-types/__tests__/project.test.ts index ec9c77515..150d2c184 100644 --- a/packages/cocos-cli-types/__tests__/project.test.ts +++ b/packages/cocos-cli-types/__tests__/project.test.ts @@ -1,13 +1,38 @@ type ProjectModule = typeof import('../project'); +import type { IProject, ProjectInfo, ProjectType } from '../project'; describe('cocos-cli-types: project', () => { it('should be able to import api functions', () => { let _init: ProjectModule['init'] | undefined = undefined; let _open: ProjectModule['open'] | undefined = undefined; let _close: ProjectModule['close'] | undefined = undefined; + let _get: ProjectModule['get'] | undefined = undefined; + let _getInfo: ProjectModule['getInfo'] | undefined = undefined; expect(_init).toBeUndefined(); expect(_open).toBeUndefined(); expect(_close).toBeUndefined(); + expect(_get).toBeUndefined(); + expect(_getInfo).toBeUndefined(); + }); + + it('IProject should have core properties and methods', () => { + const keys: (keyof IProject)[] = [ + 'path', 'type', 'pkgPath', 'tmpDir', 'libraryDir', + 'open', 'close', 'getInfo', 'updateInfo', + ]; + expect(keys.length).toBeGreaterThan(0); + }); + + it('ProjectInfo should be importable', () => { + let info: Partial = {}; + expect(info).toBeDefined(); + }); + + it('ProjectType should be a union of 2d and 3d', () => { + let type: ProjectType = '2d'; + expect(type).toBe('2d'); + type = '3d'; + expect(type).toBe('3d'); }); }); diff --git a/packages/cocos-cli-types/__tests__/scripting.test.ts b/packages/cocos-cli-types/__tests__/scripting.test.ts index 0faa1ecc9..f63378626 100644 --- a/packages/cocos-cli-types/__tests__/scripting.test.ts +++ b/packages/cocos-cli-types/__tests__/scripting.test.ts @@ -1,8 +1,56 @@ type ScriptingModule = typeof import('../scripting'); +import type { ProgrammingFacet, AssetChangeInfo, SharedSettings, ImportMap, AssetActionEnum } from '../scripting'; describe('cocos-cli-types: scripting', () => { - it('should be able to import init', () => { + it('should be able to import api functions', () => { let _init: ScriptingModule['init'] | undefined = undefined; + let _getProgrammingFacet: ScriptingModule['getProgrammingFacet'] | undefined = undefined; + let _initProgrammingFacet: ScriptingModule['initProgrammingFacet'] | undefined = undefined; + let _startCompileScript: ScriptingModule['startCompileScript'] | undefined = undefined; + let _onCompiled: ScriptingModule['onCompiled'] | undefined = undefined; + let _onCompileStart: ScriptingModule['onCompileStart'] | undefined = undefined; + let _onPackBuildEnd: ScriptingModule['onPackBuildEnd'] | undefined = undefined; + let _onPackBuildStart: ScriptingModule['onPackBuildStart'] | undefined = undefined; + expect(_init).toBeUndefined(); + expect(_getProgrammingFacet).toBeUndefined(); + expect(_initProgrammingFacet).toBeUndefined(); + expect(_startCompileScript).toBeUndefined(); + expect(_onCompiled).toBeUndefined(); + expect(_onCompileStart).toBeUndefined(); + expect(_onPackBuildEnd).toBeUndefined(); + expect(_onPackBuildStart).toBeUndefined(); + }); + + it('ProgrammingFacet should have key properties', () => { + const keys: (keyof ProgrammingFacet)[] = [ + 'engineRoot', 'engineDistRoot', + 'systemJsHomeDir', 'systemJsIndexFile', + 'engineImportMapURL', 'packImportMapURL', + 'loadPackResource', 'getGlobalImportMap', + ]; + expect(keys.length).toBeGreaterThan(0); + }); + + it('AssetChangeInfo should have change fields', () => { + const keys: (keyof AssetChangeInfo)[] = [ + 'type', 'uuid', 'filePath', 'importer', 'userData', + ]; + expect(keys).toHaveLength(5); + }); + + it('SharedSettings should be importable', () => { + let _settings: Partial = {}; + expect(_settings).toBeDefined(); + }); + + it('ImportMap should have imports field', () => { + let map: ImportMap = { imports: { 'cc': './cc.js' } }; + expect(map.imports).toBeDefined(); + }); + + it('AssetActionEnum should be importable', () => { + let _action: AssetActionEnum | undefined = undefined; + expect(_action).toBeUndefined(); }); }); diff --git a/workflow/generate-dts-postprocess.ts b/workflow/generate-dts-postprocess.ts index d14a9f037..2f5739062 100644 --- a/workflow/generate-dts-postprocess.ts +++ b/workflow/generate-dts-postprocess.ts @@ -51,6 +51,35 @@ function ensureBuilderFilesystemImports(content: string): string { return `${prefix}${insertion}${suffix}`; } +function fixBareNamespaceReferences(content: string): string { + const bareRef = /(?