diff --git a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap index 7b9cd8e0f..f5225d8cb 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 @@ -6612,6 +6612,71 @@ export declare interface IRedoService { redo(options?: IUndoOperationOptions): Promise; canRedo(options?: IUndoOperationOptions): boolean; } +export declare interface IReferenceImageCancelOptions { + interactionId: number; +} +export declare interface IReferenceImageCommitOptions { + interactionId?: number; + patch: IReferenceImageParameters; +} +export declare interface IReferenceImageConfigItem { + path: string; + x: number; + y: number; + scaleX: number; + scaleY: number; + opacity: number; +} +export declare interface IReferenceImageError { + stage: 'config' | 'file' | 'decode'; + message: string; +} +export declare interface IReferenceImageItem extends IReferenceImageConfigItem { + missing: boolean; +} +export declare interface IReferenceImageParameters { + x?: number; + y?: number; + scaleX?: number; + scaleY?: number; + opacity?: number; +} +export declare interface IReferenceImagePathOptions { + path: string; +} +export declare interface IReferenceImagePreviewOptions { + interactionId: number; + patch: IReferenceImageParameters; +} +export declare interface IReferenceImageService extends IServiceEvents { + getState(): Promise; + addAndSelect(options: IReferenceImagePathOptions): Promise; + remove(options: IReferenceImagePathOptions): Promise; + select(options: IReferenceImagePathOptions): Promise; + clearBinding(): Promise; + setVisible(options: IReferenceImageVisibilityOptions): Promise; + refresh(): Promise; + previewParameters(options: IReferenceImagePreviewOptions): Promise; + commitParameters(options: IReferenceImageCommitOptions): Promise; + cancelPreview(options: IReferenceImageCancelOptions): Promise; +} +export declare interface IReferenceImageState { + images: IReferenceImageItem[]; + current: { + sceneUuid: string | null; + imagePath: string | null; + image: IReferenceImageItem | null; + }; + desiredVisible: boolean; + effectiveVisible: boolean; + visibilityReason: ReferenceImageVisibilityReason; + is2D: boolean; + hasOpenEditor: boolean; + error: IReferenceImageError | null; +} +export declare interface IReferenceImageVisibilityOptions { + desiredVisible: boolean; +} export declare interface IReloadOptions { urlOrUUID?: string; preserveUndoHistory?: boolean; @@ -6756,6 +6821,7 @@ export declare interface IServiceManager { SceneView: ISceneViewService; Preview: IPreviewService; UI: IUIService; + ReferenceImage: IReferenceImageService; } export declare interface ISetParentParams { paths: string[]; @@ -7090,6 +7156,7 @@ export declare enum PrefabState { PrefabInstance = 2, PrefabLostAsset = 3 } +export declare type ReferenceImageVisibilityReason = 'visible' | 'disabled' | 'no-editor' | 'not-2d' | 'unbound' | 'missing' | 'load-error'; export declare enum ReloadResult { SUCCESS = 0, FAILED = 1, diff --git a/src/api/scene/reference-image-schema.ts b/src/api/scene/reference-image-schema.ts new file mode 100644 index 000000000..5ae12c734 --- /dev/null +++ b/src/api/scene/reference-image-schema.ts @@ -0,0 +1,53 @@ +/** Runtime schemas for the public AI/MCP reference-image operations. */ +import { z } from 'zod'; + +export const SchemaReferenceImageParameters = z.object({ + x: z.number().finite().optional().describe('Horizontal offset in 2D scene world units'), + y: z.number().finite().optional().describe('Vertical offset in 2D scene world units'), + scaleX: z.number().finite().optional().describe('Horizontal scale factor'), + scaleY: z.number().finite().optional().describe('Vertical scale factor'), + opacity: z.number().min(0).max(100).optional().describe('Opacity percentage from 0 to 100'), +}).refine((value) => Object.keys(value).length > 0, { + message: 'At least one reference image parameter is required.', +}).describe('Reference image parameters'); + +export const SchemaReferenceImagePath = z.object({ + path: z.string().min(1).describe('Absolute local PNG, JPG, or JPEG file path'), +}).describe('Reference image file'); + +export const SchemaReferenceImageVisibility = z.object({ + desiredVisible: z.boolean().describe('Whether the user wants reference images visible in supported editors'), +}).describe('Reference image visibility preference'); + +const SchemaReferenceImageItem = z.object({ + path: z.string(), + x: z.number(), + y: z.number(), + scaleX: z.number(), + scaleY: z.number(), + opacity: z.number().min(0).max(100), + missing: z.boolean(), +}); + +export const SchemaReferenceImageState = z.object({ + images: z.array(SchemaReferenceImageItem), + current: z.object({ + sceneUuid: z.string().nullable(), + imagePath: z.string().nullable(), + image: SchemaReferenceImageItem.nullable(), + }), + desiredVisible: z.boolean(), + effectiveVisible: z.boolean(), + visibilityReason: z.enum(['visible', 'disabled', 'no-editor', 'not-2d', 'unbound', 'missing', 'load-error']), + is2D: z.boolean(), + hasOpenEditor: z.boolean(), + error: z.object({ + stage: z.enum(['config', 'file', 'decode']), + message: z.string(), + }).nullable(), +}).describe('Current editor reference-image state'); + +export type TReferenceImageParameters = z.infer; +export type TReferenceImagePath = z.infer; +export type TReferenceImageVisibility = z.infer; +export type TReferenceImageState = z.infer; diff --git a/src/api/scene/reference-image.ts b/src/api/scene/reference-image.ts new file mode 100644 index 000000000..239247887 --- /dev/null +++ b/src/api/scene/reference-image.ts @@ -0,0 +1,89 @@ +/** Public AI/MCP facade for formal reference-image operations. */ +import { COMMON_STATUS, CommonResultType } from '../base/schema-base'; +import { description, param, result, title, tool } from '../decorator/decorator'; +import { Scene } from '../../core/scene'; +import { + SchemaReferenceImageParameters, + SchemaReferenceImagePath, + SchemaReferenceImageState, + SchemaReferenceImageVisibility, + TReferenceImageParameters, + TReferenceImagePath, + TReferenceImageState, + TReferenceImageVisibility, +} from './reference-image-schema'; + +/** Formal, semantic MCP operations. Ephemeral preview APIs remain scene-Webview only. */ +export class ReferenceImageApi { + @tool('reference-image-query') + @title('Query reference image state') + @description('Get the current reference-image library, current binding, parameters and effective visibility.') + @result(SchemaReferenceImageState) + async query(): Promise> { + return this.execute(() => Scene.ReferenceImage.getState()); + } + + @tool('reference-image-add') + @title('Add and select reference image') + @description('Validate a local PNG, JPG, or JPEG, add it to the local library, and bind it to the current scene or prefab.') + @result(SchemaReferenceImageState) + async add(@param(SchemaReferenceImagePath) options: TReferenceImagePath): Promise> { + return this.execute(() => Scene.ReferenceImage.addAndSelect(options)); + } + + @tool('reference-image-delete') + @title('Delete reference image') + @description('Remove a reference image record and all scene bindings. The original local file is not deleted.') + @result(SchemaReferenceImageState) + async delete(@param(SchemaReferenceImagePath) options: TReferenceImagePath): Promise> { + return this.execute(() => Scene.ReferenceImage.remove(options)); + } + + @tool('reference-image-select') + @title('Select reference image') + @description('Bind an existing reference image to the current scene or prefab.') + @result(SchemaReferenceImageState) + async select(@param(SchemaReferenceImagePath) options: TReferenceImagePath): Promise> { + return this.execute(() => Scene.ReferenceImage.select(options)); + } + + @tool('reference-image-clear-binding') + @title('Clear current reference image binding') + @description('Unbind the reference image from the current scene or prefab while preserving the local library and other bindings.') + @result(SchemaReferenceImageState) + async clearBinding(): Promise> { + return this.execute(() => Scene.ReferenceImage.clearBinding()); + } + + @tool('reference-image-set-visible') + @title('Set reference image visibility') + @description('Set the persisted desired visibility. Reference images remain hidden while the editor is not in 2D mode.') + @result(SchemaReferenceImageState) + async setVisible(@param(SchemaReferenceImageVisibility) options: TReferenceImageVisibility): Promise> { + return this.execute(() => Scene.ReferenceImage.setVisible(options)); + } + + @tool('reference-image-refresh') + @title('Refresh current reference image') + @description('Reload the current reference image from its original local path.') + @result(SchemaReferenceImageState) + async refresh(): Promise> { + return this.execute(() => Scene.ReferenceImage.refresh()); + } + + @tool('reference-image-set-parameters') + @title('Set reference image parameters') + @description('Persist finite position or scale values and opacity from 0 to 100 for the image bound to the current scene or prefab.') + @result(SchemaReferenceImageState) + async setParameters(@param(SchemaReferenceImageParameters) patch: TReferenceImageParameters): Promise> { + return this.execute(() => Scene.ReferenceImage.commitParameters({ patch })); + } + + private async execute(operation: () => Promise): Promise> { + try { + return { code: COMMON_STATUS.SUCCESS, data: await operation() }; + } catch (error) { + return { code: COMMON_STATUS.FAIL, reason: error instanceof Error ? error.message : String(error) }; + } + } +} diff --git a/src/api/scene/scene.ts b/src/api/scene/scene.ts index 9ee39f740..94538dabb 100644 --- a/src/api/scene/scene.ts +++ b/src/api/scene/scene.ts @@ -22,17 +22,20 @@ import { Scene, TSceneTemplateType } from '../../core/scene'; import { ComponentApi } from './component'; import { NodeApi } from './node'; import { PrefabApi } from './prefab'; +import { ReferenceImageApi } from './reference-image'; import { options } from '../../core/builder/platforms/android/i18n/en'; export class SceneApi { public component: ComponentApi; public node: NodeApi; public prefab: PrefabApi; + public referenceImage: ReferenceImageApi; constructor() { this.component = new ComponentApi(); this.node = new NodeApi(); this.prefab = new PrefabApi(); + this.referenceImage = new ReferenceImageApi(); } @tool('scene-query-current') diff --git a/src/core/scene/common/index.ts b/src/core/scene/common/index.ts index c37aabece..04185174f 100644 --- a/src/core/scene/common/index.ts +++ b/src/core/scene/common/index.ts @@ -17,3 +17,4 @@ export * from './scene-view'; export * from './preview'; export * from './ui'; export * from './message'; +export * from './reference-image'; diff --git a/src/core/scene/common/message.ts b/src/core/scene/common/message.ts index 3c6ad6de7..778554f89 100644 --- a/src/core/scene/common/message.ts +++ b/src/core/scene/common/message.ts @@ -9,6 +9,7 @@ import type { ICameraEvents } from './camera'; import type { ISceneViewEvents } from './scene-view'; import type { IUndoEvents } from './undo'; import type { IAnimationEvents } from './animation'; +import type { IReferenceImageEvents } from './reference-image'; /** * messageManager 不在已有接口中的补充事件 @@ -35,4 +36,5 @@ export interface IMessageManagerEvents extends ISceneViewEvents, IUndoEvents, IAnimationEvents, + IReferenceImageEvents, ISceneEvents {} diff --git a/src/core/scene/common/reference-image.ts b/src/core/scene/common/reference-image.ts new file mode 100644 index 000000000..4801c03b7 --- /dev/null +++ b/src/core/scene/common/reference-image.ts @@ -0,0 +1,194 @@ +/** Shared reference-image contracts for configuration, Scene services, and Node RPC. */ +import type { IServiceEvents } from '../scene-process/service/core'; + +/** Persisted locally; external files themselves are never imported into AssetDB. */ +export interface IReferenceImageConfigItem { + path: string; + x: number; + y: number; + scaleX: number; + scaleY: number; + /** Opacity is a percentage in the inclusive 0–100 range. */ + opacity: number; +} + +export interface IReferenceImageConfig { + images: IReferenceImageConfigItem[]; + sceneBindings: Record; + desiredVisible: boolean; +} + +/** Runtime-only authority envelope; revision is never persisted in the profile. */ +export interface IReferenceImageAuthoritySnapshot { + /** Runtime-only identity for one main-process authority lifetime. */ + instanceId: string; + revision: number; + config: IReferenceImageConfig; + /** Whether the requested formal mutation changed persisted configuration. */ + changed: boolean; +} + +export type IReferenceImageAuthorityMutation = + | { type: 'add-and-select'; path: string; sceneUuid: string } + | { type: 'remove'; path: string } + | { type: 'select'; path: string; sceneUuid: string } + | { type: 'clear-binding'; sceneUuid: string } + | { type: 'set-visible'; desiredVisible: boolean } + | { type: 'commit-parameters'; sceneUuid: string; patch: IReferenceImageParameters }; + +/** Main-process-only persistence boundary used by scene Webviews. */ +export interface IReferenceImageAuthorityStore { + getSnapshot(): Promise; + mutate(options: IReferenceImageAuthorityMutation): Promise; +} + +export interface IReferenceImageItem extends IReferenceImageConfigItem { + missing: boolean; +} + +export type ReferenceImageVisibilityReason = + | 'visible' + | 'disabled' + | 'no-editor' + | 'not-2d' + | 'unbound' + | 'missing' + | 'load-error'; + +export interface IReferenceImageError { + stage: 'config' | 'file' | 'decode'; + message: string; +} + +/** A read-only snapshot; `current.image` is derived from library + binding. */ +export interface IReferenceImageState { + images: IReferenceImageItem[]; + current: { + sceneUuid: string | null; + imagePath: string | null; + image: IReferenceImageItem | null; + }; + desiredVisible: boolean; + effectiveVisible: boolean; + visibilityReason: ReferenceImageVisibilityReason; + is2D: boolean; + hasOpenEditor: boolean; + error: IReferenceImageError | null; +} + +export interface IReferenceImageParameters { + x?: number; + y?: number; + scaleX?: number; + scaleY?: number; + opacity?: number; +} + +const DEFAULT_IMAGE_PARAMETERS: Omit = { + x: 0, + y: 0, + scaleX: 1, + scaleY: 1, + opacity: 100, +}; + +export function normalizeReferenceImageConfig(value: unknown): IReferenceImageConfig { + const raw = value && typeof value === 'object' ? value as Partial : {}; + const seen = new Set(); + const images = Array.isArray(raw.images) ? raw.images.flatMap((item) => { + if (!item || typeof item.path !== 'string' || !item.path || seen.has(item.path)) return []; + seen.add(item.path); + return [{ + path: item.path, + x: finiteOrDefault(item.x, DEFAULT_IMAGE_PARAMETERS.x), + y: finiteOrDefault(item.y, DEFAULT_IMAGE_PARAMETERS.y), + scaleX: finiteOrDefault(item.scaleX, DEFAULT_IMAGE_PARAMETERS.scaleX), + scaleY: finiteOrDefault(item.scaleY, DEFAULT_IMAGE_PARAMETERS.scaleY), + opacity: opacityOrDefault(item.opacity), + }]; + }) : []; + const paths = new Set(images.map((image) => image.path)); + const sceneBindings: Record = {}; + if (raw.sceneBindings && typeof raw.sceneBindings === 'object') { + for (const [sceneUuid, imagePath] of Object.entries(raw.sceneBindings)) { + if (typeof imagePath === 'string' && paths.has(imagePath)) sceneBindings[sceneUuid] = imagePath; + } + } + return { images, sceneBindings, desiredVisible: raw.desiredVisible !== false }; +} + +export function validateReferenceImageParameters(patch: unknown): IReferenceImageParameters { + if (!patch || typeof patch !== 'object') throw new Error('Reference image parameters are required.'); + const result: IReferenceImageParameters = {}; + for (const key of ['x', 'y', 'scaleX', 'scaleY', 'opacity'] as const) { + const value = (patch as Record)[key]; + if (value === undefined) continue; + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`${key} must be a finite number.`); + } + if (key === 'opacity' && (value < 0 || value > 100)) { + throw new Error('opacity must be between 0 and 100.'); + } + result[key] = value; + } + if (Object.keys(result).length === 0) throw new Error('At least one reference image parameter is required.'); + return result; +} + +function finiteOrDefault(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +} + +function opacityOrDefault(value: unknown): number { + const opacity = finiteOrDefault(value, DEFAULT_IMAGE_PARAMETERS.opacity); + return opacity >= 0 && opacity <= 100 ? opacity : DEFAULT_IMAGE_PARAMETERS.opacity; +} + +export interface IReferenceImagePreviewOptions { + interactionId: number; + patch: IReferenceImageParameters; +} + +export interface IReferenceImageCommitOptions { + interactionId?: number; + patch: IReferenceImageParameters; +} + +export interface IReferenceImageCancelOptions { + interactionId: number; +} + +export interface IReferenceImagePathOptions { + path: string; +} + +export interface IReferenceImageVisibilityOptions { + desiredVisible: boolean; +} + +export interface IReferenceImageEvents { + 'reference-image:state-changed': [state: IReferenceImageState]; +} + +/** Scene-local service; preview APIs are intentionally restricted to Webviews. */ +export interface IReferenceImageService extends IServiceEvents { + getState(): Promise; + addAndSelect(options: IReferenceImagePathOptions): Promise; + remove(options: IReferenceImagePathOptions): Promise; + select(options: IReferenceImagePathOptions): Promise; + clearBinding(): Promise; + setVisible(options: IReferenceImageVisibilityOptions): Promise; + refresh(): Promise; + previewParameters(options: IReferenceImagePreviewOptions): Promise; + commitParameters(options: IReferenceImageCommitOptions): Promise; + cancelPreview(options: IReferenceImageCancelOptions): Promise; +} + +/** Node/MCP facade excludes ephemeral preview state and interaction generations. */ +export type IPublicReferenceImageService = Pick; + +export interface IReferenceImageFileService { + readDataUrl(path: string): Promise; +} diff --git a/src/core/scene/main-process/index.ts b/src/core/scene/main-process/index.ts index c6d14b7dd..a69ebfc95 100644 --- a/src/core/scene/main-process/index.ts +++ b/src/core/scene/main-process/index.ts @@ -6,17 +6,22 @@ import { ComponentProxy } from './proxy/component-proxy'; import { AssetProxy } from './proxy/asset-proxy'; import { EngineProxy } from './proxy/engine-proxy'; import { PrefabProxy } from './proxy/prefab-proxy'; +import { ReferenceImageProxy } from './proxy/reference-image-proxy'; import { assetManager } from '../../assets'; import scriptManager from '../../scripting'; import { sceneConfigInstance } from '../scene-configs'; import i18n from '../../base/i18n'; +import { referenceImageFiles } from './reference-image-files'; +import { referenceImageStore } from './reference-image-store'; export interface IMainModule { 'assetManager': typeof assetManager; 'programming': typeof scriptManager; 'sceneConfigInstance': typeof sceneConfigInstance; 'i18n': typeof i18n; + 'referenceImageFiles': typeof referenceImageFiles; + 'referenceImageStore': typeof referenceImageStore; } export const Scene = { @@ -25,6 +30,7 @@ export const Scene = { ...AssetProxy, ...EngineProxy, ...PrefabProxy, + ReferenceImage: ReferenceImageProxy, // 节点相关的接口 Node: NodeProxy, // 组件相关的接口 diff --git a/src/core/scene/main-process/proxy/reference-image-proxy.ts b/src/core/scene/main-process/proxy/reference-image-proxy.ts new file mode 100644 index 000000000..1a0421822 --- /dev/null +++ b/src/core/scene/main-process/proxy/reference-image-proxy.ts @@ -0,0 +1,37 @@ +/** Main-process proxy that forwards formal reference-image requests to the active Scene service. */ +import { + IPublicReferenceImageService, + IReferenceImageCommitOptions, + IReferenceImagePathOptions, + IReferenceImageState, + IReferenceImageVisibilityOptions, +} from '../../common'; +import { Rpc } from '../rpc'; + +/** Node facade for formal reference-image operations; preview remains scene-local. */ +export const ReferenceImageProxy: IPublicReferenceImageService = { + getState(): Promise { + return Rpc.getInstance().request('ReferenceImage', 'getState'); + }, + addAndSelect(options: IReferenceImagePathOptions): Promise { + return Rpc.getInstance().request('ReferenceImage', 'addAndSelect', [options]); + }, + remove(options: IReferenceImagePathOptions): Promise { + return Rpc.getInstance().request('ReferenceImage', 'remove', [options]); + }, + select(options: IReferenceImagePathOptions): Promise { + return Rpc.getInstance().request('ReferenceImage', 'select', [options]); + }, + clearBinding(): Promise { + return Rpc.getInstance().request('ReferenceImage', 'clearBinding'); + }, + setVisible(options: IReferenceImageVisibilityOptions): Promise { + return Rpc.getInstance().request('ReferenceImage', 'setVisible', [options]); + }, + refresh(): Promise { + return Rpc.getInstance().request('ReferenceImage', 'refresh'); + }, + commitParameters(options: IReferenceImageCommitOptions): Promise { + return Rpc.getInstance().request('ReferenceImage', 'commitParameters', [options]); + }, +}; diff --git a/src/core/scene/main-process/reference-image-files.ts b/src/core/scene/main-process/reference-image-files.ts new file mode 100644 index 000000000..483460c28 --- /dev/null +++ b/src/core/scene/main-process/reference-image-files.ts @@ -0,0 +1,33 @@ +/** Node-side external-image reader used by Scene services without importing files into AssetDB. */ +import { promises as fs } from 'fs'; +import path from 'path'; +import type { IReferenceImageFileService } from '../common/reference-image'; + +const MIME_BY_EXTENSION: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', +}; + +/** Node-only file boundary; it returns a JSON-safe data URL, never a Buffer. */ +export class ReferenceImageFileService implements IReferenceImageFileService { + async readDataUrl(filePath: string): Promise { + if (typeof filePath !== 'string' || !path.isAbsolute(filePath)) { + throw new Error('Reference image path must be absolute.'); + } + const mime = MIME_BY_EXTENSION[path.extname(filePath).toLowerCase()]; + if (!mime) { + throw new Error('Reference image format must be PNG, JPG, or JPEG.'); + } + let data: Buffer; + try { + data = await fs.readFile(filePath); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Unable to read reference image: ${message}`); + } + return `data:${mime};base64,${data.toString('base64')}`; + } +} + +export const referenceImageFiles = new ReferenceImageFileService(); diff --git a/src/core/scene/main-process/reference-image-store.ts b/src/core/scene/main-process/reference-image-store.ts new file mode 100644 index 000000000..c028599a5 --- /dev/null +++ b/src/core/scene/main-process/reference-image-store.ts @@ -0,0 +1,177 @@ +/** Main-process authority for the shared, project-local reference-image configuration. */ +import { + IReferenceImageAuthorityMutation, + IReferenceImageAuthoritySnapshot, + IReferenceImageAuthorityStore, + IReferenceImageConfig, + IReferenceImageConfigItem, + normalizeReferenceImageConfig, + validateReferenceImageParameters, +} from '../common/reference-image'; +import { randomUUID } from 'crypto'; +import { socketService } from '../../../server/socket'; +import { sceneConfigInstance } from '../scene-configs'; + +const DEFAULT_IMAGE_PARAMETERS: Omit = { + x: 0, + y: 0, + scaleX: 1, + scaleY: 1, + opacity: 100, +}; + +/** + * The single writer for the project-local reference-image library. Scene + * Webviews only submit intents so their independent rendering snapshots can + * never overwrite another Webview's changes. + */ +export class ReferenceImageStore implements IReferenceImageAuthorityStore { + /** Changes after a main-process restart; it is intentionally not persisted. */ + private readonly instanceId = randomUUID(); + private revision = 0; + /** Serializes the complete read-modify-write operation, not only the disk write. */ + private mutationQueue: Promise = Promise.resolve(); + + async getSnapshot(): Promise { + // Do not pair a new in-memory config value with the previous revision + // while a queued write is awaiting disk persistence. + await this.mutationQueue.catch(() => undefined); + const config = normalizeReferenceImageConfig( + await sceneConfigInstance.get('referenceImage', 'local') + ); + return this.createSnapshot(config, false); + } + + async mutate(options: IReferenceImageAuthorityMutation): Promise { + let resolveTask!: (snapshot: IReferenceImageAuthoritySnapshot) => void; + let rejectTask!: (reason: unknown) => void; + const result = new Promise((resolve, reject) => { + resolveTask = resolve; + rejectTask = reject; + }); + this.mutationQueue = this.mutationQueue + .catch(() => undefined) + .then(async () => { + try { + resolveTask(await this.mutateLatest(options)); + } catch (error) { + rejectTask(error); + } + }); + return result; + } + + private async mutateLatest(options: IReferenceImageAuthorityMutation): Promise { + // Read inside the queue so another Scene cannot overwrite this mutation with a stale snapshot. + const current = normalizeReferenceImageConfig( + await sceneConfigInstance.get('referenceImage', 'local') + ); + const next = this.applyMutation(current, options); + const changed = !configsEqual(current, next); + if (changed) { + await sceneConfigInstance.set('referenceImage', next, 'local'); + this.revision++; + socketService.io?.emit('scene:invoke', { + module: 'ReferenceImage', + method: 'syncFromAuthority', + args: [], + }); + } + return this.createSnapshot(next, changed); + } + + private applyMutation(config: IReferenceImageConfig, options: IReferenceImageAuthorityMutation): IReferenceImageConfig { + const next = cloneConfig(config); + switch (options?.type) { + case 'add-and-select': { + const path = validatePath(options.path); + const sceneUuid = validateSceneUuid(options.sceneUuid); + if (!next.images.some((image) => image.path === path)) { + next.images.push({ path, ...DEFAULT_IMAGE_PARAMETERS }); + } + next.sceneBindings[sceneUuid] = path; + return next; + } + case 'remove': { + const path = validatePath(options.path); + const index = next.images.findIndex((image) => image.path === path); + if (index === -1) return next; + next.images.splice(index, 1); + for (const [sceneUuid, imagePath] of Object.entries(next.sceneBindings)) { + if (imagePath === path) delete next.sceneBindings[sceneUuid]; + } + return next; + } + case 'select': { + const path = validatePath(options.path); + const sceneUuid = validateSceneUuid(options.sceneUuid); + if (!next.images.some((image) => image.path === path)) { + throw new Error('Reference image is not in the local image library.'); + } + next.sceneBindings[sceneUuid] = path; + return next; + } + case 'clear-binding': { + delete next.sceneBindings[validateSceneUuid(options.sceneUuid)]; + return next; + } + case 'set-visible': { + if (typeof options.desiredVisible !== 'boolean') { + throw new Error('desiredVisible must be a boolean.'); + } + next.desiredVisible = options.desiredVisible; + return next; + } + case 'commit-parameters': { + const sceneUuid = validateSceneUuid(options.sceneUuid); + const path = next.sceneBindings[sceneUuid]; + const image = path ? next.images.find((candidate) => candidate.path === path) : undefined; + if (!image) throw new Error('The current scene or prefab has no reference image binding.'); + Object.assign(image, validateReferenceImageParameters(options.patch)); + return next; + } + default: + throw new Error('Unknown reference image mutation.'); + } + } + + private createSnapshot(config: IReferenceImageConfig, changed: boolean): IReferenceImageAuthoritySnapshot { + return { instanceId: this.instanceId, revision: this.revision, config: cloneConfig(config), changed }; + } +} + +function validatePath(path: unknown): string { + if (typeof path !== 'string' || !path) throw new Error('Reference image path is required.'); + return path; +} + +function validateSceneUuid(sceneUuid: unknown): string { + if (typeof sceneUuid !== 'string' || !sceneUuid) throw new Error('No scene or prefab is currently open.'); + return sceneUuid; +} + +function cloneConfig(config: IReferenceImageConfig): IReferenceImageConfig { + return { + images: config.images.map((image) => ({ ...image })), + sceneBindings: { ...config.sceneBindings }, + desiredVisible: config.desiredVisible, + }; +} + +function configsEqual(left: IReferenceImageConfig, right: IReferenceImageConfig): boolean { + return left.desiredVisible === right.desiredVisible + && left.images.length === right.images.length + && left.images.every((image, index) => { + const candidate = right.images[index]; + return candidate?.path === image.path + && candidate.x === image.x + && candidate.y === image.y + && candidate.scaleX === image.scaleX + && candidate.scaleY === image.scaleY + && candidate.opacity === image.opacity; + }) + && Object.keys(left.sceneBindings).length === Object.keys(right.sceneBindings).length + && Object.entries(left.sceneBindings).every(([sceneUuid, path]) => right.sceneBindings[sceneUuid] === path); +} + +export const referenceImageStore = new ReferenceImageStore(); diff --git a/src/core/scene/main-process/rpc.ts b/src/core/scene/main-process/rpc.ts index a76bd0c0f..f438f0848 100644 --- a/src/core/scene/main-process/rpc.ts +++ b/src/core/scene/main-process/rpc.ts @@ -4,6 +4,8 @@ import { assetManager } from '../../assets'; import scriptManager from '../../scripting'; import { sceneConfigInstance } from '../scene-configs'; import i18n from '../../base/i18n'; +import { referenceImageFiles } from './reference-image-files'; +import { referenceImageStore } from './reference-image-store'; import type { IPublicServiceManager } from '../scene-process'; @@ -35,6 +37,9 @@ export class RpcProxy { programming: scriptManager, sceneConfigInstance: sceneConfigInstance, i18n: i18n, + // Feature-owned Node modules: external file reads and serialized local configuration writes. + referenceImageFiles, + referenceImageStore, }); console.log(`[Node] Scene Process RPC ready ${prc ? '(Attached)' : '(Detached - Web Mode)'}`); } diff --git a/src/core/scene/scene-configs.ts b/src/core/scene/scene-configs.ts index 0b23f7101..0f34efe81 100644 --- a/src/core/scene/scene-configs.ts +++ b/src/core/scene/scene-configs.ts @@ -1,5 +1,6 @@ import { configurationRegistry, ConfigurationScope, IBaseConfiguration } from '../configuration'; import { createSceneMetadataNodes } from './metadata'; +import type { IReferenceImageConfig } from './common/reference-image'; export interface IOriginAxesConfig { x: boolean; @@ -82,6 +83,8 @@ export interface ISceneConfig { * 记录过相机视角信息的节点 uuid 列表,运行期由 Camera 服务写入。 */ 'camera-uuids'?: string[]; + /** Personal editor-only reference-image library and Scene bindings; never committed with Scene data. */ + referenceImage?: IReferenceImageConfig; } class SceneConfig { @@ -131,12 +134,17 @@ class SceneConfig { // 运行期由 Camera 服务写入;提供空默认值,避免首次 get 时配置层抛错并被 RPC 中间件记为错误日志 'camera-infos': {}, 'camera-uuids': [], + referenceImage: { + images: [], + sceneBindings: {}, + desiredVisible: true, + }, }; private configInstance!: IBaseConfiguration; // 个人/本机键:存 local(profiles/),不进版本库 - private static readonly PERSONAL_KEYS = ['camera', 'gizmo', 'sceneView', 'camera-infos', 'camera-uuids']; + private static readonly PERSONAL_KEYS = ['camera', 'gizmo', 'sceneView', 'camera-infos', 'camera-uuids', 'referenceImage']; async init() { this.configInstance = await configurationRegistry.register('scene', { diff --git a/src/core/scene/scene-process/engine-bootstrap.test.ts b/src/core/scene/scene-process/engine-bootstrap.test.ts index 74d3dcc5b..6818ffd0a 100644 --- a/src/core/scene/scene-process/engine-bootstrap.test.ts +++ b/src/core/scene/scene-process/engine-bootstrap.test.ts @@ -44,6 +44,9 @@ jest.mock('./i18n', () => ({ })); jest.mock('./service', () => ({})); +jest.mock('./service/reference-image', () => ({ + ReferenceImageService: class ReferenceImageService {}, +})); jest.mock('cc/polyfill/engine', () => ({}), { virtual: true }); jest.mock('cc/overwrite', () => ({ default: (...args: any[]) => mockOverwrite(...args) }), { virtual: true }); jest.mock('../../engine/editor-extends/utils/serialize', () => ({ diff --git a/src/core/scene/scene-process/engine-bootstrap.ts b/src/core/scene/scene-process/engine-bootstrap.ts index 99c8c8656..84953ffe3 100644 --- a/src/core/scene/scene-process/engine-bootstrap.ts +++ b/src/core/scene/scene-process/engine-bootstrap.ts @@ -2,6 +2,7 @@ import * as EditorExtends from '../../engine/editor-extends'; import { Rpc } from './rpc'; import { serviceManager } from './service/service-manager'; import { Service as DecoratorService } from './service/core/decorator'; +import { ReferenceImageService } from './service/reference-image'; import { messageManager } from './service/message'; import { initLocalI18n } from './i18n'; import { CUSTOM_PIPELINE_MODULE } from '../../engine/graphics-config'; @@ -20,6 +21,10 @@ if (EditorExtends.UuidUtils) { export { serviceManager, EditorExtends }; export const Service = DecoratorService; +// This value is intentionally exported through the preview bridge. Its module +// registers the service with @register(), and this live export prevents the +// web bundle from pruning that registration side effect. +export { ReferenceImageService }; declare const cc: any; @@ -237,8 +242,12 @@ async function setupBrowserInvokeChannel(serverURL: string) { invoke(msg.module, msg.method, msg.args); } }); - // 连接建立时同步一次设计分辨率(首次进入 / 断线重连时补齐错过的变更) - socket.on('connect', () => invoke('Engine', 'syncDesignResolution', [])); + // Reconcile feature-local runtime state after first connection or reconnect. + // Reference images need this because their Sprite objects are not persisted with configuration. + socket.on('connect', () => { + invoke('Engine', 'syncDesignResolution', []); + invoke('ReferenceImage', 'syncFromAuthority', []); + }); } catch (e) { console.warn('[engine-bootstrap] setup browser-invoke channel failed:', e); } diff --git a/src/core/scene/scene-process/service/index.ts b/src/core/scene/scene-process/service/index.ts index 5aa4fe94a..90fb5273a 100644 --- a/src/core/scene/scene-process/service/index.ts +++ b/src/core/scene/scene-process/service/index.ts @@ -18,4 +18,8 @@ export * from './scene-view'; export * from './particle'; export * from './preview'; export * from './ui'; +// Keep a runtime export so the web Scene bundle follows this decorator +// registration module instead of replacing its CommonJS side-effect import +// with an empty tree-shaken namespace. +export { ReferenceImageService } from './reference-image'; 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 30ddcf09f..6dd7cc40f 100644 --- a/src/core/scene/scene-process/service/interfaces.ts +++ b/src/core/scene/scene-process/service/interfaces.ts @@ -32,6 +32,8 @@ import { IPublicUIService, IUIService, IAnimationService, + IPublicReferenceImageService, + IReferenceImageService, } from '../../common'; /** @@ -54,6 +56,7 @@ export interface IPublicServiceManager { SceneView: IPublicSceneViewService, Preview: IPublicPreviewService, UI: IPublicUIService, + ReferenceImage: IPublicReferenceImageService, } export interface IServiceManager { @@ -74,4 +77,5 @@ export interface IServiceManager { SceneView: ISceneViewService, Preview: IPreviewService, UI: IUIService, + ReferenceImage: IReferenceImageService, } diff --git a/src/core/scene/scene-process/service/reference-image.ts b/src/core/scene/scene-process/service/reference-image.ts new file mode 100644 index 000000000..d43012e74 --- /dev/null +++ b/src/core/scene/scene-process/service/reference-image.ts @@ -0,0 +1,546 @@ +/** Scene-side reference-image runtime: it renders ephemeral editor nodes from main-process authority state. */ +import { Canvas, CCObject, Color, Layers, Node, Sprite, SpriteFrame, UITransform } from 'cc'; +import { + IReferenceImageCancelOptions, + IReferenceImageAuthorityMutation, + IReferenceImageAuthoritySnapshot, + IReferenceImageCommitOptions, + IReferenceImageConfig, + IReferenceImageConfigItem, + IReferenceImageError, + IReferenceImageEvents, + IReferenceImageItem, + IReferenceImageParameters, + IReferenceImagePathOptions, + IReferenceImagePreviewOptions, + IReferenceImageService, + IReferenceImageState, + IReferenceImageVisibilityOptions, + ReferenceImageVisibilityReason, + normalizeReferenceImageConfig, + validateReferenceImageParameters, +} from '../../common'; +import { Rpc } from '../rpc'; +import { BaseService, register, Service, ServiceEvents } from './core'; +import { messageManager } from './message'; + +const DEFAULT_CONFIG: IReferenceImageConfig = { + images: [], + sceneBindings: {}, + desiredVisible: true, +}; + +type EditorSession = { uuid: string | null; generation: number }; + +/** + * Editor-only reference image overlay. It owns no scene data: all runtime nodes + * live under Gizmo.backgroundNode and are explicitly DontSave/hidden. + */ +@register('ReferenceImage') +export class ReferenceImageService extends BaseService implements IReferenceImageService { + private config: IReferenceImageConfig = DEFAULT_CONFIG; + private currentSceneUuid: string | null = null; + private canvasNode: Node | null = null; + private imageNode: Node | null = null; + private sprite: Sprite | null = null; + private spriteFrame: SpriteFrame | null = null; + private loadedPath: string | null = null; + private missingPaths = new Set(); + private error: IReferenceImageError | null = null; + /** Last main-process authority revision applied to this Webview's renderer. */ + private authorityRevision: number | null = null; + /** Runtime identity paired with authorityRevision to survive main-process restarts. */ + private authorityInstanceId: string | null = null; + private authorityApplyQueue: Promise = Promise.resolve(); + private loadGeneration = 0; + private activeInteractionId: number | null = null; + private interactionWatermark = 0; + private previewPatch: IReferenceImageParameters | null = null; + + async init(): Promise { + ServiceEvents.on('scene:dimension-changed', this.onDimensionChanged); + await this.reconcileCurrentEditor(false); + } + + async getState(): Promise { + await this.pullAuthoritySnapshot(); + return this.createState(); + } + + async addAndSelect(options: IReferenceImagePathOptions): Promise { + const path = this.validatePath(options?.path); + const sceneUuid = this.requireCurrentScene(); + const frame = await this.createSpriteFrameForPath(path); + frame.destroy(); + return this.mutateAuthority({ type: 'add-and-select', path, sceneUuid }, true); + } + + async remove(options: IReferenceImagePathOptions): Promise { + const path = this.validatePath(options?.path); + this.missingPaths.delete(path); + return this.mutateAuthority({ type: 'remove', path }, true); + } + + async select(options: IReferenceImagePathOptions): Promise { + const path = this.validatePath(options?.path); + const sceneUuid = this.requireCurrentScene(); + const frame = await this.createSpriteFrameForPath(path); + frame.destroy(); + return this.mutateAuthority({ type: 'select', path, sceneUuid }, true); + } + + async clearBinding(): Promise { + const sceneUuid = this.requireCurrentScene(); + this.invalidatePreview(); + return this.mutateAuthority({ type: 'clear-binding', sceneUuid }, true); + } + + async setVisible(options: IReferenceImageVisibilityOptions): Promise { + if (typeof options?.desiredVisible !== 'boolean') { + throw new Error('desiredVisible must be a boolean.'); + } + return this.mutateAuthority({ type: 'set-visible', desiredVisible: options.desiredVisible }, false); + } + + async refresh(): Promise { + const before = this.createRuntimeStateKey(); + const authorityChanged = await this.pullAuthoritySnapshot(); + this.invalidatePreview(); + this.error = null; + await this.loadBoundImage(true); + if (authorityChanged || before !== this.createRuntimeStateKey()) this.publishState(); + return this.createState(); + } + + async previewParameters(options: IReferenceImagePreviewOptions): Promise { + const interactionId = this.validateInteractionId(options?.interactionId); + if (interactionId <= this.interactionWatermark + || (this.activeInteractionId !== null && interactionId < this.activeInteractionId)) { + return this.createState(); + } + const patch = this.validateParameters(options?.patch); + this.requireCurrentImage(); + if (this.activeInteractionId !== interactionId) { + this.activeInteractionId = interactionId; + this.previewPatch = null; + } + this.previewPatch = { ...this.previewPatch, ...patch }; + this.applyCurrentParameters(); + return this.createState(); + } + + async commitParameters(options: IReferenceImageCommitOptions): Promise { + const interactionId = options?.interactionId === undefined ? undefined : this.validateInteractionId(options.interactionId); + if (interactionId !== undefined && interactionId <= this.interactionWatermark) { + return this.createState(); + } + const patch = this.validateParameters(options?.patch); + const sceneUuid = this.requireCurrentScene(); + this.requireCurrentImage(); + this.closeInteraction(interactionId); + this.error = null; + return this.mutateAuthority({ type: 'commit-parameters', sceneUuid, patch }, false, false, true); + } + + async cancelPreview(options: IReferenceImageCancelOptions): Promise { + const interactionId = this.validateInteractionId(options?.interactionId); + if (interactionId <= this.interactionWatermark) { + return this.createState(); + } + this.closeInteraction(interactionId); + this.applyCurrentParameters(); + return this.createState(); + } + + onEditorOpened(): void { + void this.reconcileCurrentEditor(true); + } + + onEditorClosed(): void { + this.currentSceneUuid = null; + this.invalidatePreview(); + this.clearRuntime(true); + this.error = null; + this.publishState(); + } + + private onDimensionChanged = (): void => { + void this.handleDimensionChanged(); + }; + + private async handleDimensionChanged(): Promise { + if (await this.reconcileRuntime()) this.publishState(); + } + + /** Socket entrypoint; reconnects reconcile runtime even when authority revision is unchanged. */ + async syncFromAuthority(publish = true): Promise { + const authorityChanged = await this.pullAuthoritySnapshot(); + const runtimeChanged = await this.reconcileRuntime(); + if (publish && (authorityChanged || runtimeChanged)) this.publishState(); + } + + /** Pulls authority only; callers decide whether the renderer should be reconciled. */ + private async pullAuthoritySnapshot(): Promise { + try { + const snapshot = await Rpc.getInstance().request('referenceImageStore', 'getSnapshot'); + return await this.enqueueAuthoritySnapshot(snapshot, false, false); + } catch (error) { + this.error = { stage: 'config', message: error instanceof Error ? error.message : String(error) }; + console.warn('[ReferenceImage] failed to synchronize authority:', error); + return false; + } + } + + private async reconcileCurrentEditor(publish: boolean): Promise { + let editorChanged = false; + const nextSceneUuid = this.getEditorSession().uuid; + if (nextSceneUuid !== this.currentSceneUuid) { + editorChanged = true; + this.currentSceneUuid = nextSceneUuid; + this.invalidatePreview(); + this.clearRuntime(true); + this.error = null; + } + const authorityChanged = await this.pullAuthoritySnapshot(); + const runtimeChanged = await this.reconcileRuntime(); + if (publish && (editorChanged || authorityChanged || runtimeChanged)) this.publishState(); + } + + /** Recreates or clears ephemeral editor objects without mutating authority state. */ + private async reconcileRuntime(): Promise { + const before = this.createRuntimeStateKey(); + if (this.is2D()) { + await this.loadBoundImage(); + } else { + this.applyVisibility(); + } + return before !== this.createRuntimeStateKey(); + } + + private createRuntimeStateKey(): string { + // Only these public runtime fields determine whether a lifecycle event needs a state broadcast. + const visibility = this.computeVisibility(); + return JSON.stringify({ + sceneUuid: this.currentSceneUuid, + imagePath: this.getCurrentPath(), + desiredVisible: this.config.desiredVisible, + effectiveVisible: visibility.effectiveVisible, + visibilityReason: visibility.reason, + error: this.error, + }); + } + + private async loadBoundImage(force = false): Promise { + const path = this.getCurrentPath(); + if (!path || !this.currentSceneUuid || !this.is2D() || !this.config.desiredVisible) { + this.clearRuntime(false); + this.applyVisibility(); + return; + } + if (!force && this.loadedPath === path && this.spriteFrame) { + this.applyCurrentParameters(); + return; + } + const session = this.getEditorSession(); + const generation = ++this.loadGeneration; + let dataUrl: string; + try { + dataUrl = await this.readDataUrl(path); + } catch (error) { + if (generation !== this.loadGeneration || !this.isSessionCurrent(session)) return; + this.missingPaths.add(path); + this.error = { stage: 'file', message: error instanceof Error ? error.message : String(error) }; + this.clearRuntime(false); + this.applyVisibility(); + return; + } + try { + const frame = await this.createSpriteFrame(dataUrl); + if (generation !== this.loadGeneration || !this.isSessionCurrent(session) || path !== this.getCurrentPath()) { + frame.destroy(); + return; + } + this.error = null; + this.missingPaths.delete(path); + this.replaceSpriteFrame(frame, path); + this.applyCurrentParameters(); + } catch (error) { + if (generation !== this.loadGeneration || !this.isSessionCurrent(session)) return; + this.missingPaths.delete(path); + this.error = { stage: 'decode', message: error instanceof Error ? error.message : String(error) }; + this.clearRuntime(false); + this.applyVisibility(); + } + } + + private async createSpriteFrameForPath(path: string): Promise { + return this.createSpriteFrame(await this.readDataUrl(path)); + } + + private async readDataUrl(path: string): Promise { + return Rpc.getInstance().request('referenceImageFiles', 'readDataUrl', [path]); + } + + private createSpriteFrame(dataUrl: string): Promise { + const ImageCtor = (globalThis as any).ccwindow?.Image ?? (globalThis as any).Image; + if (!ImageCtor) { + return Promise.reject(new Error('Image decoding is unavailable in the scene editor.')); + } + return new Promise((resolve, reject) => { + const image = new ImageCtor(); + image.onload = () => { + try { + resolve(SpriteFrame.createWithImage(image)); + } catch (error) { + reject(error); + } + }; + image.onerror = () => reject(new Error('Reference image decoding failed.')); + image.src = dataUrl; + }); + } + + private ensureNodes(): void { + if (this.sprite && this.imageNode && this.canvasNode) return; + const background = Service.Gizmo.backgroundNode; + if (!background) throw new Error('Editor gizmo background is unavailable.'); + const flags = CCObject.Flags.DontSave | CCObject.Flags.HideInHierarchy; + const layer = Layers.Enum.GIZMOS | Layers.Enum.UI_2D | Layers.Enum.IGNORE_RAYCAST; + this.canvasNode = new Node('Reference Image Canvas'); + this.canvasNode.objFlags |= flags; + this.canvasNode.layer = layer; + this.canvasNode.parent = background; + this.canvasNode.addComponent(Canvas); + + this.imageNode = new Node('Reference Image'); + this.imageNode.objFlags |= flags; + this.imageNode.layer = layer; + this.imageNode.parent = this.canvasNode; + this.imageNode.addComponent(UITransform); + this.sprite = this.imageNode.addComponent(Sprite); + } + + private replaceSpriteFrame(frame: SpriteFrame, path: string): void { + this.ensureNodes(); + const previous = this.spriteFrame; + this.spriteFrame = frame; + this.loadedPath = path; + this.sprite!.spriteFrame = frame; + if (previous && previous !== frame) previous.destroy(); + } + + private clearRuntime(destroyNodes: boolean): void { + this.loadGeneration++; + if (this.sprite) this.sprite.spriteFrame = null; + if (this.spriteFrame) this.spriteFrame.destroy(); + this.spriteFrame = null; + this.loadedPath = null; + if (destroyNodes && this.canvasNode) { + this.canvasNode.destroy(); + this.canvasNode = null; + this.imageNode = null; + this.sprite = null; + } + } + + private applyCurrentParameters(): void { + const parameters = this.getCurrentParameters(); + if (!parameters || !this.imageNode || !this.sprite) { + this.applyVisibility(); + return; + } + this.imageNode.setPosition(parameters.x, parameters.y, 0); + this.imageNode.setScale(parameters.scaleX, parameters.scaleY, 1); + const color = this.sprite.color.clone(); + color.a = Math.round(parameters.opacity / 100 * 255); + this.sprite.color = color; + this.applyVisibility(false); + void Service.Engine.repaintInEditMode(); + } + + private applyVisibility(repaint = true): void { + if (this.imageNode) this.imageNode.active = this.computeVisibility().effectiveVisible; + if (repaint) void Service.Engine.repaintInEditMode(); + } + + private computeVisibility(): { effectiveVisible: boolean; reason: ReferenceImageVisibilityReason } { + if (!this.currentSceneUuid) return { effectiveVisible: false, reason: 'no-editor' }; + if (!this.config.desiredVisible) return { effectiveVisible: false, reason: 'disabled' }; + if (!this.is2D()) return { effectiveVisible: false, reason: 'not-2d' }; + const path = this.getCurrentPath(); + if (!path) return { effectiveVisible: false, reason: 'unbound' }; + if (this.missingPaths.has(path)) return { effectiveVisible: false, reason: 'missing' }; + if (this.error) return { effectiveVisible: false, reason: 'load-error' }; + if (!this.spriteFrame || this.loadedPath !== path) return { effectiveVisible: false, reason: 'load-error' }; + return { effectiveVisible: true, reason: 'visible' }; + } + + private createState(): IReferenceImageState { + const visibility = this.computeVisibility(); + const currentPath = this.getCurrentPath(); + const images = this.config.images.map((image) => ({ ...image, missing: this.missingPaths.has(image.path) })); + const image = currentPath ? images.find((candidate) => candidate.path === currentPath) ?? null : null; + return { + images, + current: { sceneUuid: this.currentSceneUuid, imagePath: currentPath, image }, + desiredVisible: this.config.desiredVisible, + effectiveVisible: visibility.effectiveVisible, + visibilityReason: visibility.reason, + is2D: this.is2D(), + hasOpenEditor: this.currentSceneUuid !== null, + error: this.error, + }; + } + + private getCurrentPath(): string | null { + return this.currentSceneUuid ? this.config.sceneBindings[this.currentSceneUuid] ?? null : null; + } + + private requireCurrentScene(): string { + if (!this.currentSceneUuid) throw new Error('No scene or prefab is currently open.'); + return this.currentSceneUuid; + } + + private requireCurrentImage(): IReferenceImageConfigItem { + const path = this.getCurrentPath(); + const image = path ? this.config.images.find((candidate) => candidate.path === path) : undefined; + if (!image) throw new Error('The current scene or prefab has no reference image binding.'); + return image; + } + + private getCurrentParameters(): IReferenceImageConfigItem | null { + const image = this.getCurrentPath() + ? this.config.images.find((candidate) => candidate.path === this.getCurrentPath()) + : undefined; + return image ? { ...image, ...this.previewPatch } : null; + } + + private async mutateAuthority( + mutation: IReferenceImageAuthorityMutation, + invalidatePreview: boolean, + clearError = true, + applyRuntimeOnNoop = false, + ): Promise { + const snapshot = await Rpc.getInstance().request('referenceImageStore', 'mutate', [mutation]); + if (invalidatePreview) this.invalidatePreview(); + if (clearError && snapshot.changed) this.error = null; + const applied = await this.enqueueAuthoritySnapshot(snapshot, snapshot.changed, true); + if (applyRuntimeOnNoop && !snapshot.changed && !applied) this.applyCurrentParameters(); + return this.createState(); + } + + private async enqueueAuthoritySnapshot( + snapshot: IReferenceImageAuthoritySnapshot, + publish: boolean, + reconcileRuntime = false, + ): Promise { + let resolveTask!: (applied: boolean) => void; + let rejectTask!: (reason: unknown) => void; + const result = new Promise((resolve, reject) => { + resolveTask = resolve; + rejectTask = reject; + }); + this.authorityApplyQueue = this.authorityApplyQueue + .catch(() => undefined) + .then(async () => { + try { + resolveTask(await this.applyAuthoritySnapshot(snapshot, publish, reconcileRuntime)); + } catch (error) { + rejectTask(error); + } + }); + return result; + } + + private async applyAuthoritySnapshot( + snapshot: IReferenceImageAuthoritySnapshot, + publish: boolean, + reconcileRuntime: boolean, + ): Promise { + if (!snapshot + || typeof snapshot.instanceId !== 'string' + || !snapshot.instanceId + || !Number.isSafeInteger(snapshot.revision) + || snapshot.revision < 0) { + throw new Error('Reference image authority returned an invalid snapshot.'); + } + // A socket notification may arrive before the RPC response that caused it. + // Never let an older or already-applied response roll the renderer back. + if (this.authorityInstanceId === snapshot.instanceId + && this.authorityRevision !== null + && snapshot.revision <= this.authorityRevision) { + return false; + } + const previewTargetPath = this.activeInteractionId === null ? null : this.getCurrentPath(); + this.config = normalizeReferenceImageConfig(snapshot.config); + this.authorityInstanceId = snapshot.instanceId; + this.authorityRevision = snapshot.revision; + // Authority snapshots describe the whole shared library. Keep a local slider + // interaction alive when another Scene changes unrelated library entries; its + // commit is still applied against the Store's latest configuration. A changed + // or removed current binding is the actual boundary that invalidates the edit. + if (this.activeInteractionId !== null + && (!previewTargetPath + || this.getCurrentPath() !== previewTargetPath + || !this.config.images.some((image) => image.path === previewTargetPath))) { + this.invalidatePreview(); + } + if (reconcileRuntime) await this.reconcileRuntime(); + if (publish) this.publishState(); + return true; + } + + private publishState(): void { + const state = this.createState(); + this.broadcast('reference-image:state-changed', state); + messageManager.broadcast('reference-image:state-changed', state); + } + + private invalidatePreview(): void { + if (this.activeInteractionId !== null) { + this.interactionWatermark = Math.max(this.interactionWatermark, this.activeInteractionId); + } + this.activeInteractionId = null; + this.previewPatch = null; + } + + private closeInteraction(interactionId?: number): void { + this.interactionWatermark = Math.max(this.interactionWatermark, interactionId ?? this.activeInteractionId ?? 0); + this.activeInteractionId = null; + this.previewPatch = null; + } + + private validatePath(path: unknown): string { + if (typeof path !== 'string' || !path) throw new Error('Reference image path is required.'); + return path; + } + + private validateInteractionId(value: unknown): number { + if (!Number.isSafeInteger(value) || (value as number) <= 0) { + throw new Error('interactionId must be a positive safe integer.'); + } + return value as number; + } + + private validateParameters(patch: unknown): IReferenceImageParameters { + return validateReferenceImageParameters(patch); + } + + private is2D(): boolean { + try { + return Boolean(Service.Gizmo.is2D); + } catch { + return false; + } + } + + private getEditorSession(): EditorSession { + const editor = Service.Editor as unknown as { getEditorSession?: () => EditorSession }; + return editor.getEditorSession?.() ?? { uuid: null, generation: 0 }; + } + + private isSessionCurrent(session: EditorSession): boolean { + const editor = Service.Editor as unknown as { isCurrentEditorSession?: (value: EditorSession) => boolean }; + return editor.isCurrentEditorSession?.(session) ?? session.uuid === this.getEditorSession().uuid; + } +} diff --git a/src/core/scene/test/process-rpc.test.ts b/src/core/scene/test/process-rpc.test.ts index 17d663c20..9b8c3fa2a 100644 --- a/src/core/scene/test/process-rpc.test.ts +++ b/src/core/scene/test/process-rpc.test.ts @@ -9,6 +9,11 @@ interface INodeService { interface ISceneService { loadScene(id: string): Promise; + addReferenceImage(path: string): Promise<{ path: string; dataUrl: string }>; +} + +interface IReferenceImageFiles { + readDataUrl(path: string): Promise; } // 测试用子进程文件路径 @@ -16,7 +21,7 @@ const workerPath = path.resolve(__dirname, './process-rpc/rpc-worker.js'); describe('ProcessRPC 双向调用测试', () => { let child: ReturnType; - let rpc: ProcessRPC<{ node: INodeService; scene: ISceneService }>; + let rpc: ProcessRPC<{ node: INodeService; scene: ISceneService; referenceImageFiles: IReferenceImageFiles }>; beforeAll(() => { child = fork(workerPath, [], { stdio: ['pipe', 'pipe', 'pipe', 'ipc'] }); @@ -27,7 +32,7 @@ describe('ProcessRPC 双向调用测试', () => { child.stderr?.on('data', (chunk) => { console.log(chunk.toString()); }); - rpc = new ProcessRPC<{ node: INodeService; scene: ISceneService }>(); + rpc = new ProcessRPC<{ node: INodeService; scene: ISceneService; referenceImageFiles: IReferenceImageFiles }>(); rpc.attach(child); }); @@ -54,6 +59,22 @@ describe('ProcessRPC 双向调用测试', () => { expect(result).toBe(true); }); + test('Node → Scene → Node nested reference-image RPC completes', async () => { + rpc.register({ + referenceImageFiles: { + async readDataUrl(filePath: string) { + return `data:image/png;base64,${Buffer.from(filePath).toString('base64')}`; + }, + }, + }); + + await expect(rpc.request('scene', 'addReferenceImage', ['C:\\reference.png'], { timeout: 1000 })) + .resolves.toEqual({ + path: 'C:\\reference.png', + dataUrl: `data:image/png;base64,${Buffer.from('C:\\reference.png').toString('base64')}`, + }); + }); + test('超时处理', async () => { await expect( rpc.request('node', 'longTask', [], { timeout: 100 }) diff --git a/src/core/scene/test/process-rpc/rpc-worker.js b/src/core/scene/test/process-rpc/rpc-worker.js index ac263da41..a0509f977 100644 --- a/src/core/scene/test/process-rpc/rpc-worker.js +++ b/src/core/scene/test/process-rpc/rpc-worker.js @@ -13,6 +13,11 @@ class NodeService { async ping() { return 'pong'; } + + async addReferenceImage(path) { + const dataUrl = await rpc.request('referenceImageFiles', 'readDataUrl', [path]); + return { path, dataUrl }; + } } const rpc = new ProcessRPC(); @@ -22,12 +27,17 @@ rpc.attach({ process, }); +const nodeService = new NodeService(); + // 注册对象实例 rpc.register({ - node: new NodeService(), + node: nodeService, scene: { async loadScene(id) { return id === 'Level01'; }, + async addReferenceImage(path) { + return nodeService.addReferenceImage(path); + }, } }); diff --git a/src/core/scene/test/reference-image-service.test.ts b/src/core/scene/test/reference-image-service.test.ts new file mode 100644 index 000000000..2f994e743 --- /dev/null +++ b/src/core/scene/test/reference-image-service.test.ts @@ -0,0 +1,451 @@ +/** Targeted ReferenceImageService tests for authority sync, runtime rendering, and preview boundaries. */ +const request = jest.fn(); +const broadcast = jest.fn(); +const repaintInEditMode = jest.fn(); +const camera = { is2D: true }; +const gizmo = { is2D: true, backgroundNode: {} }; +const editor = { + getEditorSession: jest.fn(() => ({ uuid: 'scene-a', generation: 1 })), + isCurrentEditorSession: jest.fn(() => true), +}; + +jest.mock('../scene-process/rpc', () => ({ + Rpc: { getInstance: () => ({ request }) }, +})); + +jest.mock('../scene-process/service/core', () => { + class BaseService { + broadcast = broadcast; + } + return { + BaseService, + register: () => (target: unknown) => target, + Service: { + Camera: camera, + Engine: { repaintInEditMode }, + Editor: editor, + Gizmo: gizmo, + }, + ServiceEvents: { on: jest.fn() }, + }; +}); + +jest.mock('cc', () => ({ + Canvas: class {}, + CCObject: { Flags: { DontSave: 1, HideInHierarchy: 2 } }, + Color: class {}, + Layers: { Enum: { GIZMOS: 1, UI_2D: 2, IGNORE_RAYCAST: 4 } }, + Node: class {}, + Sprite: class {}, + SpriteFrame: class {}, + UITransform: class {}, +})); + +import { ReferenceImageService } from '../scene-process/service/reference-image'; + +describe('ReferenceImageService state and preview boundary', () => { + let service: ReferenceImageService; + let authority: any; + + beforeEach(() => { + request.mockReset(); + broadcast.mockReset(); + repaintInEditMode.mockReset(); + camera.is2D = true; + gizmo.is2D = true; + editor.getEditorSession.mockReturnValue({ uuid: 'scene-a', generation: 1 }); + editor.isCurrentEditorSession.mockReturnValue(true); + authority = { + instanceId: 'authority-a', + revision: 1, + changed: false, + config: { + desiredVisible: true, + images: [{ path: 'C:\\design.png', x: 2, y: 3, scaleX: 1, scaleY: 1, opacity: 75 }], + sceneBindings: { 'scene-a': 'C:\\design.png' }, + }, + }; + request.mockImplementation((module: string, method: string, args: any[] = []) => { + if (module !== 'referenceImageStore') return Promise.resolve(undefined); + if (method === 'getSnapshot') return Promise.resolve(authority); + const mutation = args[0]; + const config = { + desiredVisible: authority.config.desiredVisible, + images: authority.config.images.map((image: any) => ({ ...image })), + sceneBindings: { ...authority.config.sceneBindings }, + }; + let changed = false; + if (mutation.type === 'add-and-select') { + if (!config.images.some((image: any) => image.path === mutation.path)) { + config.images.push({ path: mutation.path, x: 0, y: 0, scaleX: 1, scaleY: 1, opacity: 100 }); + } + if (config.sceneBindings[mutation.sceneUuid] !== mutation.path) { + config.sceneBindings[mutation.sceneUuid] = mutation.path; + changed = true; + } + } else if (mutation.type === 'clear-binding' && config.sceneBindings[mutation.sceneUuid]) { + delete config.sceneBindings[mutation.sceneUuid]; + changed = true; + } else if (mutation.type === 'commit-parameters') { + const path = config.sceneBindings[mutation.sceneUuid]; + const image = config.images.find((candidate: any) => candidate.path === path); + if (image && Object.keys(mutation.patch).some((key) => image[key] !== mutation.patch[key])) { + Object.assign(image, mutation.patch); + changed = true; + } + } + if (changed) authority = { + instanceId: authority.instanceId, + revision: authority.revision + 1, + config, + changed: true, + }; + return Promise.resolve({ ...authority, changed }); + }); + service = new ReferenceImageService(); + Object.assign(service as any, { + config: { + desiredVisible: true, + images: [{ path: 'C:\\design.png', x: 2, y: 3, scaleX: 1, scaleY: 1, opacity: 75 }], + sceneBindings: { 'scene-a': 'C:\\design.png' }, + }, + authorityRevision: 1, + authorityInstanceId: 'authority-a', + currentSceneUuid: 'scene-a', + spriteFrame: { destroy: jest.fn() }, + loadedPath: 'C:\\design.png', + }); + }); + + it('derives current image from the library and scene binding', async () => { + const state = await service.getState(); + + expect(state.current).toEqual({ + sceneUuid: 'scene-a', + imagePath: 'C:\\design.png', + image: expect.objectContaining({ path: 'C:\\design.png', opacity: 75, missing: false }), + }); + expect(state.visibilityReason).toBe('visible'); + }); + + it('keeps getState as an authority query without rehydrating runtime objects', async () => { + Object.assign(service as any, { spriteFrame: null, loadedPath: null }); + authority = { + ...authority, + revision: 2, + changed: true, + config: { + ...authority.config, + images: authority.config.images.map((image: any) => ({ ...image, opacity: 50 })), + }, + }; + const reconcileRuntime = jest.spyOn(service as any, 'reconcileRuntime'); + + const state = await service.getState(); + + expect(reconcileRuntime).not.toHaveBeenCalled(); + expect(request).toHaveBeenCalledWith('referenceImageStore', 'getSnapshot'); + expect(state.current.image?.opacity).toBe(50); + }); + + it('rehydrates runtime after a same-revision socket sync without writing authority', async () => { + Object.assign(service as any, { spriteFrame: null, loadedPath: null, error: null }); + jest.spyOn(service as any, 'loadBoundImage').mockImplementation(async () => { + Object.assign(service as any, { + spriteFrame: { destroy: jest.fn() }, + loadedPath: 'C:\\design.png', + error: null, + }); + }); + + await service.syncFromAuthority(); + + expect((service as any).loadedPath).toBe('C:\\design.png'); + expect(broadcast).toHaveBeenCalledTimes(1); + expect(request).not.toHaveBeenCalledWith('referenceImageStore', 'mutate', expect.anything()); + }); + + it('rehydrates runtime when the current editor opens with an existing authority snapshot', async () => { + const reinitialized = new ReferenceImageService(); + const loadBoundImage = jest.spyOn(reinitialized as any, 'loadBoundImage').mockResolvedValue(undefined); + + await reinitialized.init(); + + expect(loadBoundImage).toHaveBeenCalledTimes(1); + expect((reinitialized as any).currentSceneUuid).toBe('scene-a'); + expect(request).not.toHaveBeenCalledWith('referenceImageStore', 'mutate', expect.anything()); + }); + + it('loads on cold editor open after Gizmo restores 2D before Camera is ready', async () => { + camera.is2D = false; + gizmo.is2D = true; + const reinitialized = new ReferenceImageService(); + const frame = { destroy: jest.fn() }; + const readDataUrl = jest.spyOn(reinitialized as any, 'readDataUrl').mockResolvedValue('data:image/png;base64,valid'); + jest.spyOn(reinitialized as any, 'createSpriteFrame').mockResolvedValue(frame); + const replaceSpriteFrame = jest.spyOn(reinitialized as any, 'replaceSpriteFrame').mockImplementation((_frame, path) => { + Object.assign(reinitialized as any, { spriteFrame: frame, loadedPath: path }); + }); + jest.spyOn(reinitialized as any, 'applyCurrentParameters').mockImplementation(() => undefined); + + await reinitialized.init(); + + expect(readDataUrl).toHaveBeenCalledWith('C:\\design.png'); + expect(replaceSpriteFrame).toHaveBeenCalledWith(frame, 'C:\\design.png'); + expect((reinitialized as any).loadedPath).toBe('C:\\design.png'); + expect((reinitialized as any).error).toBeNull(); + }); + + it('reconciles runtime for both 3D and return-to-2D dimension lifecycle events', async () => { + const loadBoundImage = jest.spyOn(service as any, 'loadBoundImage').mockResolvedValue(undefined); + + gizmo.is2D = false; + await (service as any).handleDimensionChanged(); + expect(loadBoundImage).not.toHaveBeenCalled(); + gizmo.is2D = true; + await (service as any).handleDimensionChanged(); + + expect(loadBoundImage).toHaveBeenCalledTimes(1); + expect(request).not.toHaveBeenCalledWith('referenceImageStore', 'mutate', expect.anything()); + }); + + it('keeps a preview committable after another Scene adds a library image', async () => { + await service.previewParameters({ interactionId: 12, patch: { opacity: 40 } }); + + const otherScene = new ReferenceImageService(); + Object.assign(otherScene as any, { + currentSceneUuid: 'scene-b', + authorityRevision: 1, + authorityInstanceId: 'authority-a', + }); + jest.spyOn(otherScene as any, 'createSpriteFrameForPath').mockResolvedValue({ destroy: jest.fn() }); + jest.spyOn(otherScene as any, 'loadBoundImage').mockResolvedValue(undefined); + + await otherScene.addAndSelect({ path: 'C:\\other.png' }); + await service.syncFromAuthority(false); + + expect((service as any).activeInteractionId).toBe(12); + expect((service as any).previewPatch).toEqual({ opacity: 40 }); + expect((service as any).interactionWatermark).toBe(0); + + await service.commitParameters({ interactionId: 12, patch: { opacity: 40 } }); + + expect(authority.config.images).toEqual(expect.arrayContaining([ + expect.objectContaining({ path: 'C:\\design.png', opacity: 40 }), + expect.objectContaining({ path: 'C:\\other.png' }), + ])); + }); + + it('cancels a preview when an authority snapshot removes its current binding', async () => { + await service.previewParameters({ interactionId: 13, patch: { opacity: 40 } }); + authority = { + instanceId: 'authority-a', + revision: 2, + changed: true, + config: { + desiredVisible: true, + images: [], + sceneBindings: {}, + }, + }; + + await service.syncFromAuthority(false); + + expect((service as any).activeInteractionId).toBeNull(); + expect((service as any).interactionWatermark).toBe(13); + request.mockClear(); + await service.commitParameters({ interactionId: 13, patch: { opacity: 40 } }); + expect(request).not.toHaveBeenCalledWith('referenceImageStore', 'mutate', expect.anything()); + }); + + it('does not publish for a no-op refresh or repeated 2D dimension signal', async () => { + jest.spyOn(service as any, 'loadBoundImage').mockResolvedValue(undefined); + + await service.refresh(); + await (service as any).handleDimensionChanged(); + await (service as any).handleDimensionChanged(); + + expect(broadcast).not.toHaveBeenCalled(); + }); + + it('keeps preview ephemeral and rejects a late preview after commit', async () => { + await service.previewParameters({ interactionId: 4, patch: { opacity: 40 } }); + + expect((service as any).config.images[0].opacity).toBe(75); + expect(request).not.toHaveBeenCalledWith('sceneConfigInstance', 'set', expect.anything()); + expect(broadcast).not.toHaveBeenCalled(); + + await service.commitParameters({ interactionId: 4, patch: { opacity: 40 } }); + + expect((service as any).config.images[0].opacity).toBe(40); + expect(request).toHaveBeenCalledWith('referenceImageStore', 'mutate', [ + expect.objectContaining({ type: 'commit-parameters', sceneUuid: 'scene-a', patch: { opacity: 40 } }), + ]); + expect(broadcast).toHaveBeenCalledTimes(1); + + await service.previewParameters({ interactionId: 4, patch: { opacity: 10 } }); + expect((service as any).previewPatch).toBeNull(); + expect((service as any).config.images[0].opacity).toBe(40); + + await service.previewParameters({ interactionId: 5, patch: { opacity: 30 } }); + expect((service as any).previewPatch).toEqual({ opacity: 30 }); + }); + + it('keeps the first host interaction valid and only invalidates an active ID', async () => { + (service as any).invalidatePreview(); + expect((service as any).interactionWatermark).toBe(0); + + await service.previewParameters({ interactionId: 1, patch: { opacity: 40 } }); + expect((service as any).activeInteractionId).toBe(1); + + (service as any).invalidatePreview(); + expect((service as any).interactionWatermark).toBe(1); + + await service.previewParameters({ interactionId: 2, patch: { opacity: 30 } }); + expect((service as any).activeInteractionId).toBe(2); + expect((service as any).previewPatch).toEqual({ opacity: 30 }); + }); + + it('repaints once for each applied parameter preview', async () => { + Object.assign(service as any, { + imageNode: { setPosition: jest.fn(), setScale: jest.fn(), active: true }, + sprite: { color: { clone: () => ({ a: 255 }) } }, + }); + + await service.previewParameters({ interactionId: 5, patch: { opacity: 40 } }); + + expect(repaintInEditMode).toHaveBeenCalledTimes(1); + }); + + it('restores committed runtime parameters when a preview commit is a persistence no-op', async () => { + (service as any).config.images[0].opacity = 40; + authority.config.images[0].opacity = 40; + const apply = jest.spyOn(service as any, 'applyCurrentParameters'); + + await service.previewParameters({ interactionId: 8, patch: { opacity: 30 } }); + await service.commitParameters({ interactionId: 8, patch: { opacity: 40 } }); + + expect((service as any).previewPatch).toBeNull(); + expect(apply).toHaveBeenLastCalledWith(); + }); + + it('does not roll back after a newer socket snapshot arrives before an older RPC response', async () => { + const newer = { + instanceId: 'authority-a', + revision: 2, + changed: true, + config: { + desiredVisible: true, + images: [{ path: 'C:\\new.png', x: 0, y: 0, scaleX: 1, scaleY: 1, opacity: 100 }], + sceneBindings: { 'scene-a': 'C:\\new.png' }, + }, + }; + const older = { instanceId: 'authority-a', revision: 1, changed: true, config: (service as any).config }; + + await (service as any).enqueueAuthoritySnapshot(newer, false); + await (service as any).enqueueAuthoritySnapshot(older, false); + + expect((service as any).authorityRevision).toBe(2); + expect((service as any).config.images).toEqual(newer.config.images); + }); + + it('applies a lower revision from a restarted main-process authority', async () => { + const restarted = { + instanceId: 'authority-b', + revision: 0, + changed: false, + config: { + desiredVisible: true, + images: [{ path: 'C:\\recovered.png', x: 0, y: 0, scaleX: 1, scaleY: 1, opacity: 100 }], + sceneBindings: { 'scene-a': 'C:\\recovered.png' }, + }, + }; + + await (service as any).enqueueAuthoritySnapshot(restarted, false); + + expect((service as any).authorityInstanceId).toBe('authority-b'); + expect((service as any).authorityRevision).toBe(0); + expect((service as any).config.images).toEqual(restarted.config.images); + }); + + it('clears only the current binding while preserving the image library and other scene bindings', async () => { + (service as any).config.sceneBindings['scene-b'] = 'C:\\design.png'; + authority.config.sceneBindings['scene-b'] = 'C:\\design.png'; + (service as any).error = { stage: 'decode', message: 'Reference image decoding failed.' }; + const frame = (service as any).spriteFrame; + + const state = await service.clearBinding(); + + expect(state.current).toEqual({ sceneUuid: 'scene-a', imagePath: null, image: null }); + expect(state.visibilityReason).toBe('unbound'); + expect(state.error).toBeNull(); + expect(frame.destroy).toHaveBeenCalledTimes(1); + expect((service as any).spriteFrame).toBeNull(); + expect((service as any).config.images).toEqual([ + { path: 'C:\\design.png', x: 2, y: 3, scaleX: 1, scaleY: 1, opacity: 75 }, + ]); + expect((service as any).config.sceneBindings).toEqual({ 'scene-b': 'C:\\design.png' }); + expect(request).toHaveBeenCalledWith('referenceImageStore', 'mutate', [ + { type: 'clear-binding', sceneUuid: 'scene-a' }, + ]); + expect(broadcast).toHaveBeenCalledTimes(1); + }); + + it('keeps a cleared binding unbound after reinitialization and ignores its late preview', async () => { + await service.previewParameters({ interactionId: 7, patch: { opacity: 40 } }); + await service.clearBinding(); + await service.previewParameters({ interactionId: 7, patch: { opacity: 10 } }); + + expect((await service.getState()).current.image).toBeNull(); + expect((service as any).previewPatch).toBeNull(); + + const reinitialized = new ReferenceImageService(); + await reinitialized.init(); + expect((await reinitialized.getState()).current).toEqual({ sceneUuid: 'scene-a', imagePath: null, image: null }); + }); + + it('does not persist or broadcast when the current scene is already unbound', async () => { + await service.clearBinding(); + request.mockClear(); + broadcast.mockClear(); + + const state = await service.clearBinding(); + + expect(state.visibilityReason).toBe('unbound'); + expect(request).toHaveBeenCalledWith('referenceImageStore', 'mutate', [ + { type: 'clear-binding', sceneUuid: 'scene-a' }, + ]); + expect(broadcast).not.toHaveBeenCalled(); + }); + + it('validates opacity as a percentage before changing runtime state', async () => { + await expect(service.previewParameters({ interactionId: 1, patch: { opacity: 101 } })) + .rejects.toThrow('opacity must be between 0 and 100'); + expect((service as any).previewPatch).toBeNull(); + }); + + it('reports unreadable files as missing file errors', async () => { + Object.assign(service as any, { spriteFrame: null, loadedPath: null }); + request.mockRejectedValueOnce(new Error('ENOENT: no such file')); + + await (service as any).loadBoundImage(true); + + const state = await service.getState(); + expect(state.current.image?.missing).toBe(true); + expect(state.error).toEqual({ stage: 'file', message: 'ENOENT: no such file' }); + expect(state.visibilityReason).toBe('missing'); + }); + + it('reports data URL decode failures without marking the file missing', async () => { + Object.assign(service as any, { spriteFrame: null, loadedPath: null }); + request.mockResolvedValueOnce('data:image/png;base64,invalid'); + jest.spyOn(service as any, 'createSpriteFrame').mockRejectedValueOnce(new Error('Reference image decoding failed.')); + + await (service as any).loadBoundImage(true); + + const state = await service.getState(); + expect(state.current.image?.missing).toBe(false); + expect(state.error).toEqual({ stage: 'decode', message: 'Reference image decoding failed.' }); + expect(state.visibilityReason).toBe('load-error'); + }); +}); diff --git a/src/core/scene/test/reference-image-store.test.ts b/src/core/scene/test/reference-image-store.test.ts new file mode 100644 index 000000000..4355bea3d --- /dev/null +++ b/src/core/scene/test/reference-image-store.test.ts @@ -0,0 +1,96 @@ +/** Targeted ReferenceImageStore tests for serialized project-local mutations and fan-out. */ +const get = jest.fn(); +const set = jest.fn(); +const emit = jest.fn(); + +jest.mock('../scene-configs', () => ({ + sceneConfigInstance: { get, set }, +})); + +jest.mock('../../../server/socket', () => ({ + socketService: { io: { emit } }, +})); + +import { ReferenceImageStore } from '../main-process/reference-image-store'; + +const emptyConfig = () => ({ images: [], sceneBindings: {}, desiredVisible: true }); + +describe('ReferenceImageStore', () => { + let store: ReferenceImageStore; + let persisted: any; + + beforeEach(() => { + persisted = emptyConfig(); + get.mockReset(); + set.mockReset(); + emit.mockReset(); + get.mockImplementation(async () => persisted); + set.mockImplementation(async (_path: string, value: unknown) => { + persisted = JSON.parse(JSON.stringify(value)); + return true; + }); + store = new ReferenceImageStore(); + }); + + it('serializes concurrent stale-client additions without losing either shared-library record', async () => { + await Promise.all([ + store.mutate({ type: 'add-and-select', path: 'C:\\a.png', sceneUuid: 'scene-a' }), + store.mutate({ type: 'add-and-select', path: 'C:\\b.png', sceneUuid: 'scene-b' }), + ]); + + const snapshot = await store.getSnapshot(); + expect(snapshot.config.images.map((image) => image.path)).toEqual(['C:\\a.png', 'C:\\b.png']); + expect(snapshot.config.sceneBindings).toEqual({ 'scene-a': 'C:\\a.png', 'scene-b': 'C:\\b.png' }); + expect(set).toHaveBeenCalledTimes(2); + expect(emit).toHaveBeenCalledTimes(2); + }); + + it('keeps one public library while scene bindings remain independent', async () => { + await store.mutate({ type: 'add-and-select', path: 'C:\\shared.png', sceneUuid: 'scene-a' }); + const selected = await store.mutate({ type: 'select', path: 'C:\\shared.png', sceneUuid: 'scene-b' }); + const cleared = await store.mutate({ type: 'clear-binding', sceneUuid: 'scene-a' }); + + expect(selected.config.images).toHaveLength(1); + expect(selected.config.sceneBindings).toEqual({ 'scene-a': 'C:\\shared.png', 'scene-b': 'C:\\shared.png' }); + expect(cleared.config.images).toHaveLength(1); + expect(cleared.config.sceneBindings).toEqual({ 'scene-b': 'C:\\shared.png' }); + }); + + it('does not write or fan out an idempotent no-op', async () => { + const snapshot = await store.mutate({ type: 'clear-binding', sceneUuid: 'scene-a' }); + + expect(snapshot).toMatchObject({ instanceId: expect.any(String), revision: 0, changed: false, config: emptyConfig() }); + expect(set).not.toHaveBeenCalled(); + expect(emit).not.toHaveBeenCalled(); + }); + + it('deletes a library entry and clears every binding without selecting a neighbor', async () => { + persisted = { + desiredVisible: true, + images: [ + { path: 'C:\\delete.png', x: 0, y: 0, scaleX: 1, scaleY: 1, opacity: 100 }, + { path: 'C:\\keep.png', x: 1, y: 2, scaleX: 2, scaleY: 2, opacity: 50 }, + ], + sceneBindings: { + 'scene-a': 'C:\\delete.png', + 'scene-b': 'C:\\delete.png', + 'scene-c': 'C:\\keep.png', + }, + }; + + const snapshot = await store.mutate({ type: 'remove', path: 'C:\\delete.png' }); + + expect(snapshot.config.images.map((image) => image.path)).toEqual(['C:\\keep.png']); + expect(snapshot.config.sceneBindings).toEqual({ 'scene-c': 'C:\\keep.png' }); + }); + + it('reads persisted state again when a new store instance is initialized', async () => { + await store.mutate({ type: 'add-and-select', path: 'C:\\persisted.png', sceneUuid: 'scene-a' }); + const restartedStore = new ReferenceImageStore(); + + const snapshot = await restartedStore.getSnapshot(); + expect(snapshot).toMatchObject({ instanceId: expect.any(String), revision: 0 }); + expect(snapshot.instanceId).not.toBe((await store.getSnapshot()).instanceId); + expect(snapshot.config).toEqual(persisted); + }); +}); diff --git a/src/core/scene/test/scene-configs.test.ts b/src/core/scene/test/scene-configs.test.ts index 9bf488de3..6847e005c 100644 --- a/src/core/scene/test/scene-configs.test.ts +++ b/src/core/scene/test/scene-configs.test.ts @@ -28,6 +28,7 @@ describe('SceneConfig', () => { expect(config.camera).toBeDefined(); expect(config.gizmo).toBeDefined(); expect(config.sceneView).toBeDefined(); + expect(config.referenceImage).toEqual({ images: [], sceneBindings: {}, desiredVisible: true }); }); }); @@ -126,6 +127,14 @@ describe('SceneConfig', () => { await expect(sceneConfigInstance.get('camera.fov', 'project')).rejects.toThrow(); }); + it('stores reference images only in the local scope', async () => { + const value = { images: [], sceneBindings: {}, desiredVisible: false }; + await sceneConfigInstance.set('referenceImage', value); + + expect(saveSpy).toHaveBeenLastCalledWith('local'); + expect(await sceneConfigInstance.get('referenceImage', 'local')).toEqual(value); + }); + it('should write to default scope and read back', async () => { await sceneConfigInstance.set('tick', true, 'default'); expect(await sceneConfigInstance.get('tick', 'default')).toBe(true); diff --git a/workflow/build-scene-bundle.js b/workflow/build-scene-bundle.js index 265918263..6e688b05d 100644 --- a/workflow/build-scene-bundle.js +++ b/workflow/build-scene-bundle.js @@ -23,8 +23,9 @@ async function buildSceneBundle() { virtual({ entry: ` import * as Bridge from '${bridgeFile}'; - const { startup, serviceManager, EditorExtends, Service } = Bridge; - export { startup, serviceManager, EditorExtends, Service }; + // Keep the decorated ReferenceImage service reachable so Rollup retains its registration side effect. + const { startup, serviceManager, EditorExtends, Service, ReferenceImageService } = Bridge; + export { startup, serviceManager, EditorExtends, Service, ReferenceImageService }; ` }), {