diff --git a/src/common/observer-unset.ts b/src/common/observer-unset.ts new file mode 100644 index 000000000..6aa09c3e6 --- /dev/null +++ b/src/common/observer-unset.ts @@ -0,0 +1,11 @@ +type Item = { + sync?: { enabled: boolean }; + unset: (path: string) => unknown; +}; + +export const unsetLocal = (item: Item, path: string) => { + const enabled = item.sync?.enabled; + if (enabled) item.sync.enabled = false; + item.unset(path); + if (enabled) item.sync.enabled = true; +}; diff --git a/src/editor-api/external-types/config.d.ts b/src/editor-api/external-types/config.d.ts index 4e7f3add4..21156ef57 100644 --- a/src/editor-api/external-types/config.d.ts +++ b/src/editor-api/external-types/config.d.ts @@ -99,6 +99,7 @@ type EditorSchemaCatalog = { settings: Record; }; assetData: Record>; + assetMeta?: Record>; [key: string]: unknown; }; diff --git a/src/editor-api/schema.ts b/src/editor-api/schema.ts index d1c9ef1a0..447a0ffc1 100644 --- a/src/editor-api/schema.ts +++ b/src/editor-api/schema.ts @@ -95,13 +95,19 @@ class Schema { return (this._schema.assetData as Field)[type.toLowerCase()] as Field | undefined; } + getAssetMeta(type: string) { + if (!isObject(this._schema.assetMeta)) return undefined; + return this._schema.assetMeta[type.toLowerCase()] as Field | undefined; + } + getComponents() { return this.resolvePath(this.getDocument('scene'), 'entities.*.components', false)?.field as Field; } getFields(field: unknown) { if (!isObject(field)) return {}; - return isObject(field.properties) ? field.properties : {}; + const value = jsonValue(field); + return isObject(value) && isObject(value.properties) ? value.properties : {}; } getMapValue(field: unknown) { @@ -124,50 +130,70 @@ class Schema { } getScope(field: unknown) { - if (!isObject(field)) return undefined; - return field['x-scope'] as string | undefined; + if (isObject(field) && typeof field['x-scope'] === 'string') return field['x-scope']; + const value = jsonValue(field); + return isObject(value) && typeof value['x-scope'] === 'string' ? (value['x-scope'] as string) : undefined; } getAssetTypes() { - const asset = this.getDocument('asset'); - const type = (asset.properties as Field)?.type; - return ((type as Field)?.enum as string[]) || []; + const asset = jsonValue(this.getDocument('asset')); + if (!isObject(asset) || !isObject(asset.properties)) return []; + const type = jsonValue(asset.properties.type); + return isObject(type) && Array.isArray(type.enum) ? (type.enum as string[]) : []; } resolvePath(root: unknown, path: string | readonly (string | number)[], strictArrays = true) { const parts = typeof path === 'string' ? path.split('.') : path; let field = root; let open = false; + let optional = false; for (const part of parts) { if (part === '' || !isObject(field)) return null; field = jsonValue(field); if (!isObject(field)) return null; + open = false; if (field['x-open-map'] === true || isObject(field.additionalProperties)) { open = true; if (!isObject(field.additionalProperties)) { - return { field: null, default: undefined, hasDefault: false, open }; + return { field: null, default: undefined, hasDefault: false, open, optional: true }; } field = field.additionalProperties; + optional = true; } else if (field.type === 'array') { if (strictArrays && (!Number.isInteger(Number(part)) || Number(part) < 0)) return null; if (!isObject(field.items)) return null; field = field.items; + optional = false; } else if (isObject(field.properties) && Object.hasOwn(field.properties, part)) { + optional = !Array.isArray(field.required) || !field.required.includes(part); field = field.properties[part]; continue; } else if (!field.type && !field.properties && !field.items && !field.anyOf) { open = true; - return { field: null, default: undefined, hasDefault: false, open }; + return { field: null, default: undefined, hasDefault: false, open, optional: true }; } else { return null; } } const result = this.getDefault(field); - return { field, default: result.value, hasDefault: result.hasDefault, open }; + return { field, default: result.value, hasDefault: result.hasDefault, open, optional }; + } + + isNullDefault(root: unknown, path: string | readonly (string | number)[]) { + const resolved = this.resolvePath(root, path); + return ( + !!resolved && + !resolved.open && + resolved.hasDefault && + resolved.default === null && + isObject(resolved.field) && + Array.isArray(resolved.field.anyOf) && + resolved.field.anyOf.some((item) => isObject(item) && item.type === 'null') + ); } /** @@ -201,14 +227,17 @@ class Schema { getMergeMethodForPath(root: unknown, path: string, strictArrays = true) { const field = this.resolvePath(root, path, strictArrays)?.field; - if (!isObject(field)) return undefined; - return field['x-merge-method'] as string | undefined; + if (isObject(field) && typeof field['x-merge-method'] === 'string') { + return field['x-merge-method']; + } + const value = jsonValue(field); + return isObject(value) && typeof value['x-merge-method'] === 'string' + ? (value['x-merge-method'] as string) + : undefined; } getScopeForPath(root: unknown, path: string, strictArrays = true) { - const field = this.resolvePath(root, path, strictArrays)?.field; - if (!isObject(field)) return undefined; - return field['x-scope'] as string | undefined; + return this.getScope(this.resolvePath(root, path, strictArrays)?.field); } } diff --git a/src/editor-api/schema/assets.ts b/src/editor-api/schema/assets.ts index bcefae45b..96d9f6325 100644 --- a/src/editor-api/schema/assets.ts +++ b/src/editor-api/schema/assets.ts @@ -45,6 +45,15 @@ class AssetsSchema { return result; } + resolveMetaPath(type: string, path: string) { + const schema = this._schemaApi.getAssetMeta(type); + const result = schema ? this._schemaApi.resolvePath(schema, path) : null; + if (result?.hasDefault) { + result.default = typeof result.default === 'function' ? result.default() : utils.deepCopy(result.default); + } + return result; + } + /** * Gets a list of fields of a particular type for an asset type. * diff --git a/src/editor-api/schema/scene.ts b/src/editor-api/schema/scene.ts index 829290af4..96ca4572d 100644 --- a/src/editor-api/schema/scene.ts +++ b/src/editor-api/schema/scene.ts @@ -25,13 +25,13 @@ class SceneSchema { _getDefaultData(schema: Field) { const result: Record = {}; for (const [key, field] of Object.entries(this._schemaApi.getFields(schema))) { - const value = this._schemaApi.getDefault(field); - if (value.hasDefault) { - result[key] = utils.deepCopy(value.value); + const nested = this._getDefaultData(field as Field); + if (Object.keys(nested).length) { + result[key] = nested; continue; } - const nested = this._getDefaultData(field as Field); - if (Object.keys(nested).length) result[key] = nested; + const value = this._schemaApi.getDefault(field); + if (value.hasDefault) result[key] = utils.deepCopy(value.value); } return result; } diff --git a/src/editor-api/schema/settings.ts b/src/editor-api/schema/settings.ts index e631088b5..0713ba4c1 100644 --- a/src/editor-api/schema/settings.ts +++ b/src/editor-api/schema/settings.ts @@ -25,13 +25,15 @@ class SettingsSchema { _getDefaultData(schema: Field, scope: string) { const result: Record = {}; for (const [key, field] of Object.entries(this._schemaApi.getFields(schema))) { - const value = this._schemaApi.getDefault(field); - if (value.hasDefault) { - if (this._schemaApi.getScope(field) === scope) result[key] = utils.deepCopy(value.value); + const nested = this._getDefaultData(field as Field, scope); + if (Object.keys(nested).length) { + result[key] = nested; continue; } - const nested = this._getDefaultData(field as Field, scope); - if (Object.keys(nested).length) result[key] = nested; + const value = this._schemaApi.getDefault(field); + if (value.hasDefault && this._schemaApi.getScope(field) === scope) { + result[key] = utils.deepCopy(value.value); + } } return result; } diff --git a/src/editor/animstategraph/parameters.ts b/src/editor/animstategraph/parameters.ts index 56070d6af..0af45f666 100644 --- a/src/editor/animstategraph/parameters.ts +++ b/src/editor/animstategraph/parameters.ts @@ -348,7 +348,10 @@ class AnimStateGraphParameters extends Panel { conditions[transitionKey] = {}; } conditions[transitionKey][conditionKey] = condition; - asset.unset(`data.transitions.${transitionKey}.conditions.${conditionKey}.parameterName`); + asset.set( + `data.transitions.${transitionKey}.conditions.${conditionKey}.parameterName`, + null + ); } }); } diff --git a/src/editor/animstategraph/view.ts b/src/editor/animstategraph/view.ts index 10285a2b0..d3de43d2a 100644 --- a/src/editor/animstategraph/view.ts +++ b/src/editor/animstategraph/view.ts @@ -367,7 +367,7 @@ class AnimStateGraphView { } if (state.defaultState) { state.defaultState = undefined; - this._assets[0].unset(`data.states.${stateKey}.defaultState`); + this._assets[0].set(`data.states.${stateKey}.defaultState`, false); } break; } diff --git a/src/editor/assets/asset-flags.ts b/src/editor/assets/asset-flags.ts new file mode 100644 index 000000000..08d62b4a0 --- /dev/null +++ b/src/editor/assets/asset-flags.ts @@ -0,0 +1,17 @@ +/** + * An imported material carries its source index. An explicit null means the + * material was created by hand, so "Re-import" does not apply. + */ +export const isImportedMaterial = (asset: any) => { + const value = asset.get('meta.index'); + return value !== null && value !== undefined; +}; + +/** + * texCoord1 is the legacy JSON-model key and is an explicit count (0 = in no + * mesh). TEXCOORD_1 is the GLB-style key inside the free-form render meta map, + * so it stays presence-based. + */ +export const hasUv1 = (asset: any) => { + return (asset.get('meta.attributes.texCoord1') ?? 0) > 0 || asset.has('meta.attributes.TEXCOORD_1'); +}; diff --git a/src/editor/assets/assets-context-menu.ts b/src/editor/assets/assets-context-menu.ts index 0dd8d4039..0ebc9161b 100644 --- a/src/editor/assets/assets-context-menu.ts +++ b/src/editor/assets/assets-context-menu.ts @@ -7,6 +7,8 @@ import type { AssetObserver } from '@/editor-api'; import { formatShortcut } from '../../common/utils'; +import { isImportedMaterial } from './asset-flags'; + editor.once('load', () => { let currentAsset = null; const legacyScripts = editor.call('settings:project').get('useLegacyScripts'); @@ -808,7 +810,7 @@ editor.once('load', () => { !source.get('meta.animation.available') ) { menuItemReImport.hidden = true; - } else if (currentAsset.get('type') === 'material' && !currentAsset.has('meta.index')) { + } else if (currentAsset.get('type') === 'material' && !isImportedMaterial(currentAsset)) { menuItemReImport.hidden = true; } else if ( source.get('type') === 'font' && diff --git a/src/editor/assets/assets-font-import.ts b/src/editor/assets/assets-font-import.ts index 8bf3e1878..8576017f5 100644 --- a/src/editor/assets/assets-font-import.ts +++ b/src/editor/assets/assets-font-import.ts @@ -4,6 +4,7 @@ // depends on the backend honouring `noConvert`, so it does not also produce a target of its own. import { isRefPath } from '@/common/referenced-font-handler'; +import { isReferencedFont } from '@/editor/inspector/assets/font-mode'; type FontDataV3 = { version: number; @@ -365,7 +366,7 @@ editor.once('load', () => { const missing = Array.from(chars).filter((c) => !v3.chars[c]); const path = font.get('path') || []; const parent = path.length ? editor.call('assets:get', path[path.length - 1]) : null; - const migrated = !font.has('data.jsonAsset'); + const migrated = !isReferencedFont(font); const id = font.get('id'); updating.add(id); diff --git a/src/editor/assets/assets-migrate.ts b/src/editor/assets/assets-migrate.ts index 7bc93fb94..5cea0a62e 100644 --- a/src/editor/assets/assets-migrate.ts +++ b/src/editor/assets/assets-migrate.ts @@ -1,7 +1,9 @@ import type { Observer } from '@playcanvas/observer'; +import { unsetLocal } from '@/common/observer-unset'; import { deepEqual, formatter as f } from '@/common/utils'; import { LOAD_SCRIPT_AS_ASSET } from '@/core/constants'; +import { isReferencedFont } from '@/editor/inspector/assets/font-mode'; const LEGACY_TINT_PROPERTIES = [ ['data.diffuseMapTint', 'data.diffuseTint'], @@ -68,7 +70,7 @@ editor.once('load', () => { ].join(' '); editor.call('console:log:asset', asset, msg); } - asset.unset(oldPath); + unsetLocal(asset, oldPath); } } @@ -131,7 +133,7 @@ editor.once('load', () => { if (asset.has('data.useGamma')) { const tonemap: boolean = asset.get('data.useGamma') ?? true; - asset.unset('data.useGamma'); + unsetLocal(asset, 'data.useGamma'); asset.set('data.useTonemap', tonemap); const msg = [ `The ${f.path('data.useGamma')} properties of material ${f.asset(asset)} is`, @@ -296,7 +298,7 @@ editor.once('load', () => { // remove fresnelModel since it is now always set to schlick if (asset.has('data.fresnelModel')) { const fresnelModel = asset.get('data.fresnelModel'); - asset.unset('data.fresnelModel'); + unsetLocal(asset, 'data.fresnelModel'); if (fresnelModel !== 2) { const msg = [ `The ${f.path('data.fresnelModel')} property of material ${f.asset(asset)}`, @@ -398,7 +400,7 @@ editor.once('load', () => { } // referenced fonts keep intensity in the json descriptor; their data holds only the refs - if (asset.has('data.jsonAsset')) { + if (isReferencedFont(asset)) { return; } diff --git a/src/editor/attributes/attributes-panel.ts b/src/editor/attributes/attributes-panel.ts index 911a41553..049c688bf 100644 --- a/src/editor/attributes/attributes-panel.ts +++ b/src/editor/attributes/attributes-panel.ts @@ -1,6 +1,7 @@ import type { Observer } from '@playcanvas/observer'; import { toLinkedFieldValue } from '@/common/pcui/compat-utils'; +import { unsetObserver } from '@/editor/driver/observer-unset'; import { createAssetInput, @@ -271,7 +272,7 @@ editor.once('load', () => { historyState(item, false); if (items[i].value === undefined) { - item.unset(path); + unsetObserver(item, path); } else { item.set(path, items[i].value); } @@ -294,11 +295,10 @@ editor.once('load', () => { historyState(item, false); if (value === undefined) { - item.unset(path); + unsetObserver(item, path); } else { item.set(path, value); } - item.set(path, value); historyState(item, true); } diff --git a/src/editor/driver/asset.ts b/src/editor/driver/asset.ts index 7320b4119..ad52e41eb 100644 --- a/src/editor/driver/asset.ts +++ b/src/editor/driver/asset.ts @@ -2,6 +2,7 @@ import { config } from '@/editor/config'; import { driver } from './driver'; import { api, log, rest, entitySummary, paginate, validatePath, writeError } from './shared'; +import { resolveUnset } from './unset'; const MAX_FILE_BYTES = 20 * 1024 * 1024; const MAX_CHUNK_BYTES = 1024 * 1024; @@ -281,8 +282,14 @@ const modifyAssets = async (edits: any[]) => { `Invalid asset path: ${edit.path}. Use name, tags, preload, exclude, i18n.*, data.*, meta.compress.*, or meta.invert.` ); } - const resolved = root === 'data' ? api.schema.assets.resolvePath(asset.get('type'), edit.path.slice(5)) : null; - if (root === 'data' && !resolved) { + const type = String(asset.get('type')); + const resolved = + root === 'data' + ? api.schema.assets.resolvePath(type, edit.path.slice(5)) + : root === 'meta' + ? api.schema.assets.resolveMetaPath(type, edit.path.slice(5)) + : null; + if ((root === 'data' || (root === 'meta' && api.schema.getAssetMeta(type))) && !resolved) { throw new Error(`Unknown ${asset.get('type')} asset path: ${edit.path}.`); } if ( @@ -301,9 +308,6 @@ const modifyAssets = async (edits: any[]) => { if (op === 'unset' && ['name', 'tags', 'preload', 'exclude'].includes(root)) { throw new Error(`Asset path ${edit.path} cannot be unset.`); } - if (op === 'unset' && root === 'data' && !resolved.hasDefault && !resolved.open) { - throw new Error(`Asset path ${edit.path} cannot be unset.`); - } if (asset.get('type') === 'animstategraph' && edit.path.startsWith('data.')) { throw new Error( 'Anim state graph data requires modify_anim_state_graph. Generic leaf writes can be ignored by the editor.' @@ -360,8 +364,22 @@ const modifyAssets = async (edits: any[]) => { value.splice(edit.to, 0, value.splice(edit.index, 1)[0]); } } - const nextOp = op === 'unset' && resolved?.hasDefault ? 'set' : op; - const value = op === 'unset' && resolved?.hasDefault ? resolved.default : edit.value; + const unsetOp = + op === 'unset' + ? resolveUnset( + resolved ?? { + hasDefault: false, + default: undefined, + open: root === 'i18n', + optional: root === 'i18n' + } + ) + : null; + if (op === 'unset' && !unsetOp) { + throw new Error(`Asset path ${edit.path} cannot be unset.`); + } + const nextOp = unsetOp ? unsetOp.op : op; + const value = unsetOp?.op === 'set' ? unsetOp.value : edit.value; if (nextOp === 'set' && Array.isArray(value)) { arrays.set(`${edit.id}:${edit.path}`, structuredClone(value)); } else if (nextOp === 'unset') { diff --git a/src/editor/driver/entity.ts b/src/editor/driver/entity.ts index 00ba70592..0cdde676d 100644 --- a/src/editor/driver/entity.ts +++ b/src/editor/driver/entity.ts @@ -1,5 +1,6 @@ import { driver } from './driver'; import { api, log, entitySummary, paginate, validatePath, writeError } from './shared'; +import { resolveUnset } from './unset'; const ENTITY_TOP_LEVEL_PATHS = ['name', 'enabled', 'position', 'rotation', 'scale', 'tags']; @@ -153,13 +154,17 @@ driver.method('entities:modify', (edits) => { throw new Error('Only component properties can be unset; set top-level entity properties explicitly.'); } const resolved = path.startsWith('components.') - ? api.schema.components.resolvePath(path.split('.')[1], path.split('.').slice(2).join('.')) + ? api.schema.resolvePath(api.schema.getDocument('scene'), ['entities', '*', ...path.split('.')]) : null; - if (op === 'unset' && !resolved?.hasDefault && !resolved?.open) { + const unsetOp = + op === 'unset' + ? resolveUnset(resolved ?? { hasDefault: false, default: undefined, open: false, optional: false }) + : null; + if (op === 'unset' && !unsetOp) { throw new Error(`Component path ${path} cannot be unset.`); } - const nextOp = op === 'unset' && resolved?.hasDefault ? 'set' : op; - const value = nextOp === 'set' && op === 'unset' ? resolved.default : edit.value; + const nextOp = unsetOp ? unsetOp.op : op; + const value = unsetOp?.op === 'set' ? unsetOp.value : edit.value; return { entity, id, diff --git a/src/editor/driver/observer-unset.ts b/src/editor/driver/observer-unset.ts new file mode 100644 index 000000000..f08130849 --- /dev/null +++ b/src/editor/driver/observer-unset.ts @@ -0,0 +1,34 @@ +import { resolveUnset } from './unset'; + +type Item = { + get: (path: string) => unknown; + has: (path: string) => boolean; + set: (path: string, value: unknown) => unknown; + unset: (path: string) => unknown; +}; + +export const unsetObserver = (item: Item, path: string) => { + const schema = editor.api.globals.schema; + let resolved; + if (item.has('resource_id')) { + resolved = schema.resolvePath(schema.getDocument('scene'), ['entities', '*', ...path.split('.')]); + } else if (item.has('type')) { + const type = String(item.get('type')); + resolved = path.startsWith('data.') + ? schema.assets.resolvePath(type, path.slice(5)) + : path.startsWith('meta.') + ? schema.assets.resolveMetaPath(type, path.slice(5)) + : schema.resolvePath(schema.getDocument('asset'), path); + } else { + const scene = item.has('physics') || item.has('render'); + resolved = schema.resolvePath( + schema.getDocument(scene ? 'scene' : 'settings'), + scene ? `settings.${path}` : path + ); + } + + const op = resolveUnset(resolved ?? { hasDefault: false, default: undefined, open: false, optional: false }); + if (!op) throw new Error(`Path ${path} cannot be unset.`); + if (op.op === 'set') item.set(path, structuredClone(op.value)); + else item.unset(path); +}; diff --git a/src/editor/driver/processing.ts b/src/editor/driver/processing.ts index bed758418..aeeaecd8f 100644 --- a/src/editor/driver/processing.ts +++ b/src/editor/driver/processing.ts @@ -1,3 +1,4 @@ +import { hasUv1 } from '@/editor/assets/asset-flags'; import { TextureCompressor } from '@/editor/assets/assets-textures-compress'; import type { Asset } from '@/editor-api'; @@ -106,7 +107,13 @@ driver.method('lightmapper:bake', async (ids?: string[]) => { const targets = entities || api.entities.list().filter((entity) => entity.get('components.model.lightmapped')); const missing = targets .map((entity) => entity.get('components.model.asset')) - .filter((id) => id && !api.assets.get(id)?.has('meta.attributes.texCoord1')); + .filter((id) => { + if (!id) { + return false; + } + const asset = api.assets.get(id); + return !asset || !hasUv1(asset); + }); let event: any; const [error] = await bounded( (done) => { diff --git a/src/editor/driver/project.ts b/src/editor/driver/project.ts index f79a035d4..f853eb7c8 100644 --- a/src/editor/driver/project.ts +++ b/src/editor/driver/project.ts @@ -1,5 +1,6 @@ import { driver } from './driver'; import { api, log, iterateObject, validatePath, writeError } from './shared'; +import { resolveUnset } from './unset'; const WRITE_SCOPES = new Set(['project', 'projectPrivate', 'scene']); @@ -45,7 +46,14 @@ const modifySettings = (scope: string, edits: any[]) => { if (op === 'set' && !Object.hasOwn(edit, 'value')) { throw new Error(`Missing value for settings path: ${edit.path}.`); } - return { ...edit, op }; + if (op !== 'unset') return { ...edit, op }; + + const root = api.schema.getDocument(scope === 'scene' ? 'scene' : 'settings'); + const path = scope === 'scene' ? `settings.${edit.path}` : edit.path; + const resolved = api.schema.resolvePath(root, path); + const unset = resolveUnset(resolved ?? { hasDefault: false, default: undefined, open: false, optional: false }); + if (!unset) throw new Error(`Settings path ${edit.path} cannot be unset.`); + return { ...edit, op: unset.op, value: unset.op === 'set' ? structuredClone(unset.value) : undefined }; }); for (let i = 0; i < prepared.length; i++) { const { path, op, value } = prepared[i]; diff --git a/src/editor/driver/unset.ts b/src/editor/driver/unset.ts new file mode 100644 index 000000000..e03cdb05e --- /dev/null +++ b/src/editor/driver/unset.ts @@ -0,0 +1,9 @@ +/** + * `unset` deletes optional fields and dynamic entries, resets required fields + * to their default, and rejects required fields without a default. + */ +export const resolveUnset = (resolved: { hasDefault: boolean; default: unknown; open: boolean; optional: boolean }) => { + if (resolved.open || resolved.optional) return { op: 'unset' as const }; + if (resolved.hasDefault) return { op: 'set' as const, value: resolved.default }; + return null; +}; diff --git a/src/editor/entities/entities-migrations.ts b/src/editor/entities/entities-migrations.ts index ab6772d7e..290474cbb 100644 --- a/src/editor/entities/entities-migrations.ts +++ b/src/editor/entities/entities-migrations.ts @@ -10,10 +10,11 @@ import { SHADOWUPDATE_REALTIME } from 'playcanvas'; +import { unsetLocal } from '@/common/observer-unset'; import { formatter as f } from '@/common/utils'; const LIGHTMAP_COMPONENTS = ['model', 'render']; -const LEGACY_LIGHTMAP_PROPERTIES = ['castShadowsLightMap', 'lightMapped', 'lightMapSizeMultiplier']; +const LEGACY_LIGHTMAP_PROPERTIES = ['castShadowsLightMap', 'lightMapped', 'lightMapSizeMultiplier', 'static']; const MIGRATION_BATCH_SIZE = 100; editor.once('load', () => { @@ -91,7 +92,7 @@ editor.once('load', () => { // affectLightmapped if (entity.has('components.light.affectLightMapped')) { entity.set('components.light.affectLightmapped', entity.get('components.light.affectLightMapped')); - entity.unset('components.light.affectLightMapped'); + unsetLocal(entity, 'components.light.affectLightMapped'); } else if (!entity.has('components.light.affectLightmapped')) { entity.set('components.light.affectLightmapped', false); } diff --git a/src/editor/inspector/assets/cubemap-face.ts b/src/editor/inspector/assets/cubemap-face.ts index 3ed536f2a..eb6c231fe 100644 --- a/src/editor/inspector/assets/cubemap-face.ts +++ b/src/editor/inspector/assets/cubemap-face.ts @@ -107,7 +107,7 @@ class CubemapFace extends Container { if (allHdr) { this._asset.set('data.rgbm', true); } else { - this._asset.unset('data.rgbm'); + this._asset.set('data.rgbm', false); } } diff --git a/src/editor/inspector/assets/font-mode.ts b/src/editor/inspector/assets/font-mode.ts new file mode 100644 index 000000000..e52d13f52 --- /dev/null +++ b/src/editor/inspector/assets/font-mode.ts @@ -0,0 +1,8 @@ +/** + * A referenced font carries a json asset id. An explicit null means the legacy + * embedded form, matching an absent key. + */ +export const isReferencedFont = (asset: any) => { + const value = asset.get('data.jsonAsset'); + return value !== null && value !== undefined; +}; diff --git a/src/editor/inspector/assets/font.ts b/src/editor/inspector/assets/font.ts index ef6812776..7681d1390 100644 --- a/src/editor/inspector/assets/font.ts +++ b/src/editor/inspector/assets/font.ts @@ -14,6 +14,8 @@ import type { History } from '@/editor-api'; import type { Attribute } from '../attribute.type.d'; import { AttributesInspector } from '../attributes-inspector'; +import { isReferencedFont } from './font-mode'; + const CLASS_ROOT = 'asset-font-inspector'; const CLASS_LOCALE_PANEL = `${CLASS_ROOT}-locale-panel`; const CLASS_CHARACTER_RANGE = `${CLASS_ROOT}-character-range`; @@ -506,7 +508,7 @@ class FontAssetInspector extends Container { const asset = this._assets[0]; // converting a legacy font rewrites data, replaces the file and drops the server-generated // descriptor, none of it through history — so it cannot be undone - if (!asset.has('data.jsonAsset')) { + if (!isReferencedFont(asset)) { editor.call( 'picker:confirm', 'Converting this font replaces its data and file with references to new JSON and texture assets. This cannot be undone.', @@ -534,7 +536,7 @@ class FontAssetInspector extends Container { if (!this._assets?.includes(asset)) { return; } - const referenced = asset.has('data.jsonAsset'); + const referenced = isReferencedFont(asset); this._sourceFilesPanel.hidden = !referenced; this._propertiesPanel.hidden = referenced; this._toggleProcessFontButton(asset); @@ -543,7 +545,7 @@ class FontAssetInspector extends Container { } _toggleProcessFontButton(asset: Observer) { - const referenced = asset.has('data.jsonAsset'); + const referenced = isReferencedFont(asset); this._processFontButton.text = this._processing ? referenced ? 'REGENERATING FONT ASSETS…' @@ -736,10 +738,10 @@ class FontAssetInspector extends Container { // only client-imported (referenced) fonts have unpacked mirror assets. gate on the ref fields // existing (not their current value) so clearing a picker doesn't hide the whole panel this._sourceFilesPanel.hidden = - assets.length > 1 || (!assets[0].has('data.jsonAsset') && !assets[0].has('data.textureAssets')); + assets.length > 1 || (!isReferencedFont(assets[0]) && !assets[0].has('data.textureAssets')); // referenced fonts derive intensity from the json descriptor, not font.data.intensity — the // slider is inert for them, so hide the PROPERTIES panel (keep it for server-pipeline fonts) - this._propertiesPanel.hidden = assets[0].has('data.jsonAsset'); + this._propertiesPanel.hidden = isReferencedFont(assets[0]); if (this._sourceFilesPanel.hidden) { this._sourceFilesWarning.hidden = true; } else { diff --git a/src/editor/inspector/components/aabb-utils.ts b/src/editor/inspector/components/aabb-utils.ts new file mode 100644 index 000000000..b79b099c1 --- /dev/null +++ b/src/editor/inspector/components/aabb-utils.ts @@ -0,0 +1,7 @@ +/** + * A custom AABB is configured when the centre field holds a vector. An explicit + * null means the feature is off — a zero vector does not. + */ +export const hasCustomAabb = (entity: any, component: 'model' | 'render') => { + return Array.isArray(entity.get(`components.${component}.aabbCenter`)); +}; diff --git a/src/editor/inspector/components/model.ts b/src/editor/inspector/components/model.ts index b2a18457c..971569c15 100644 --- a/src/editor/inspector/components/model.ts +++ b/src/editor/inspector/components/model.ts @@ -6,9 +6,11 @@ import { CLASS_ERROR } from '@/common/pcui/constants'; import { AssetInput } from '@/common/pcui/element/element-asset-input'; import type { Assets, EntityObserver } from '@/editor-api'; +import { hasUv1 } from '../../assets/asset-flags'; import type { Attribute } from '../attribute.type.d'; import { AttributesInspector } from '../attributes-inspector'; +import { hasCustomAabb } from './aabb-utils'; import { ComponentInspector } from './component'; import type { ComponentInspectorArgs } from './component'; @@ -254,7 +256,7 @@ class AssetElementToObserversBinding extends BindingElementToObservers { const mapping = latest.get('components.model.mapping'); if (mapping) { entry.mapping = mapping; - latest.unset('components.model.mapping'); + latest.set('components.model.mapping', {}); } } @@ -576,7 +578,7 @@ class ModelComponentInspector extends ComponentInspector { this._suppressCustomAabb = true; this._suppressToggleFields = true; - const customAabbs = this._entities.map((e) => e.has('components.model.aabbCenter')); + const customAabbs = this._entities.map((e) => hasCustomAabb(e, 'model')); this._field('customAabb').values = customAabbs; this._suppressCustomAabb = false; @@ -627,7 +629,7 @@ class ModelComponentInspector extends ComponentInspector { if (e.has('components.model') && e.get('components.model.type') === 'asset') { const assetId = e.get('components.model.asset'); const asset = assetId && this._assets.get(assetId); - if (asset && !asset.has('meta.attributes.texCoord1') && !asset.has('meta.attributes.TEXCOORD_1')) { + if (asset && !hasUv1(asset)) { return true; } } @@ -692,20 +694,20 @@ class ModelComponentInspector extends ComponentInspector { const history = e.history.enabled; e.history.enabled = false; if (value) { - if (!e.has('components.model.aabbCenter')) { + if (!hasCustomAabb(e, 'model')) { prev[e.get('resource_id')] = {}; e.set('components.model.aabbCenter', [0, 0, 0]); e.set('components.model.aabbHalfExtents', [0.5, 0.5, 0.5]); } } else { - if (e.has('components.model.aabbCenter')) { + if (hasCustomAabb(e, 'model')) { prev[e.get('resource_id')] = { center: e.get('components.model.aabbCenter'), halfExtents: e.get('components.model.aabbHalfExtents') }; - e.unset('components.model.aabbCenter'); - e.unset('components.model.aabbHalfExtents'); + e.set('components.model.aabbCenter', null); + e.set('components.model.aabbHalfExtents', null); } } e.history.enabled = history; @@ -729,13 +731,13 @@ class ModelComponentInspector extends ComponentInspector { if (previous.center) { e.set('components.model.aabbCenter', previous.center); } else { - e.unset('components.model.aabbCenter'); + e.set('components.model.aabbCenter', null); } if (previous.halfExtents) { e.set('components.model.aabbHalfExtents', previous.halfExtents); } else { - e.unset('components.model.aabbHalfExtents'); + e.set('components.model.aabbHalfExtents', null); } e.history.enabled = history; }); @@ -805,7 +807,7 @@ class ModelComponentInspector extends ComponentInspector { }) ); - customAabbValues.push(e.has('components.model.aabbCenter')); + customAabbValues.push(hasCustomAabb(e, 'model')); this._entityEvents.push(e.on('components.model.aabbCenter:set', this._refreshCustomAabb.bind(this))); this._entityEvents.push(e.on('components.model.aabbCenter:unset', this._refreshCustomAabb.bind(this))); diff --git a/src/editor/inspector/components/render.ts b/src/editor/inspector/components/render.ts index 8ea432069..ece61e7bc 100644 --- a/src/editor/inspector/components/render.ts +++ b/src/editor/inspector/components/render.ts @@ -9,6 +9,7 @@ import type { EntityObserver } from '@/editor-api'; import type { Attribute } from '../attribute.type.d'; import { AttributesInspector } from '../attributes-inspector'; +import { hasCustomAabb } from './aabb-utils'; import { ComponentInspector } from './component'; import type { ComponentInspectorArgs } from './component'; @@ -413,20 +414,20 @@ class RenderComponentInspector extends ComponentInspector { const history = e.history.enabled; e.history.enabled = false; if (value) { - if (!e.has('components.render.aabbCenter')) { + if (!hasCustomAabb(e, 'render')) { prev[e.get('resource_id')] = {}; e.set('components.render.aabbCenter', [0, 0, 0]); e.set('components.render.aabbHalfExtents', [0.5, 0.5, 0.5]); } } else { - if (e.has('components.render.aabbCenter')) { + if (hasCustomAabb(e, 'render')) { prev[e.get('resource_id')] = { center: e.get('components.render.aabbCenter'), halfExtents: e.get('components.render.aabbHalfExtents') }; - e.unset('components.render.aabbCenter'); - e.unset('components.render.aabbHalfExtents'); + e.set('components.render.aabbCenter', null); + e.set('components.render.aabbHalfExtents', null); } } e.history.enabled = history; @@ -450,13 +451,13 @@ class RenderComponentInspector extends ComponentInspector { if (previous.center) { e.set('components.render.aabbCenter', previous.center); } else { - e.unset('components.render.aabbCenter'); + e.set('components.render.aabbCenter', null); } if (previous.halfExtents) { e.set('components.render.aabbHalfExtents', previous.halfExtents); } else { - e.unset('components.render.aabbHalfExtents'); + e.set('components.render.aabbHalfExtents', null); } e.history.enabled = history; }); @@ -481,7 +482,7 @@ class RenderComponentInspector extends ComponentInspector { this._suppressCustomAabb = true; this._suppressToggleFields = true; - const customAabbs = this._entities.map((e) => e.has('components.render.aabbCenter')); + const customAabbs = this._entities.map((e) => hasCustomAabb(e, 'render')); this._field('customAabb').values = customAabbs; this._suppressCustomAabb = false; @@ -497,7 +498,7 @@ class RenderComponentInspector extends ComponentInspector { super.link(entities); - const customAabbs = this._entities.map((e) => e.has('components.render.aabbCenter')); + const customAabbs = this._entities.map((e) => hasCustomAabb(e, 'render')); this._field('customAabb').values = customAabbs; entities.forEach((e) => { diff --git a/src/editor/pickers/picker-node.ts b/src/editor/pickers/picker-node.ts index 974b5065c..80ee3f495 100644 --- a/src/editor/pickers/picker-node.ts +++ b/src/editor/pickers/picker-node.ts @@ -1,5 +1,7 @@ import { Overlay } from '@playcanvas/pcui'; +import { unsetObserver } from '@/editor/driver/observer-unset'; + import { ModelAssetInspectorMeshInstances } from '../inspector/assets/model-mesh-instances'; import { addSidePanelOverlayClose } from './side-panel-overlay'; @@ -105,7 +107,7 @@ editor.once('load', () => { item.history.enabled = false; if (actions[i].undo === undefined) { - item.unset(actions[i].path); + unsetObserver(item, actions[i].path); } else { item.set(actions[i].path, actions[i].undo); } diff --git a/src/editor/scene-settings/priority-scripts.ts b/src/editor/scene-settings/priority-scripts.ts new file mode 100644 index 000000000..1eaae153b --- /dev/null +++ b/src/editor/scene-settings/priority-scripts.ts @@ -0,0 +1,7 @@ +/** + * The stored document may omit the key or hold an explicit null; both mean the + * scene has no priority scripts. + */ +export const isUnsetPriorityScripts = (value: unknown) => { + return value === null || value === undefined; +}; diff --git a/src/editor/scene-settings/scene-settings.ts b/src/editor/scene-settings/scene-settings.ts index fe32b84f8..bf513a7b7 100644 --- a/src/editor/scene-settings/scene-settings.ts +++ b/src/editor/scene-settings/scene-settings.ts @@ -4,6 +4,8 @@ import { GAMMA_NONE, GAMMA_SRGB } from 'playcanvas'; import { ObserverSync } from '@/common/observer-sync'; import { formatter as f } from '@/common/utils'; +import { isUnsetPriorityScripts } from './priority-scripts'; + editor.once('load', () => { const schema = editor.api.globals.schema; const sceneSettings = { @@ -27,7 +29,7 @@ editor.once('load', () => { // remove priority_scripts if ( - editor.api.globals.realtime.scenes.current.data.settings.priority_scripts === undefined && + isUnsetPriorityScripts(editor.api.globals.realtime.scenes.current.data.settings.priority_scripts) && settings.has('priority_scripts') ) { settings.unset('priority_scripts'); diff --git a/src/editor/settings/attributes/settings-attributes-scripts-priority.ts b/src/editor/settings/attributes/settings-attributes-scripts-priority.ts index 5db13b9fe..e7a9bc361 100644 --- a/src/editor/settings/attributes/settings-attributes-scripts-priority.ts +++ b/src/editor/settings/attributes/settings-attributes-scripts-priority.ts @@ -1,3 +1,5 @@ +import { isUnsetPriorityScripts } from '@/editor/scene-settings/priority-scripts'; + import { createButton, createLabel, @@ -110,7 +112,7 @@ editor.once('load', () => { const value = asset.get('filename'); if (priorityScripts.indexOf(value) < 0) { priorityScripts.push(value); - if (sceneSettings.has('priority_scripts')) { + if (!isUnsetPriorityScripts(sceneSettings.get('priority_scripts'))) { sceneSettings.insert('priority_scripts', value); } else { sceneSettings.set('priority_scripts', priorityScripts); diff --git a/src/editor/settings/project-settings.ts b/src/editor/settings/project-settings.ts index 465dd0994..ad3b31100 100644 --- a/src/editor/settings/project-settings.ts +++ b/src/editor/settings/project-settings.ts @@ -10,6 +10,7 @@ import { script } from 'playcanvas'; +import { unsetLocal } from '@/common/observer-unset'; import { deepCopy, formatter as f, insert, remove, set, unset } from '@/common/utils'; import { config } from '@/editor/config'; @@ -73,7 +74,7 @@ editor.once('load', () => { const enableWebGl2 = settings.get('preferWebGl2'); const oldEnableWebGl2 = settings.get('enableWebGl2'); settings.set('enableWebGl2', enableWebGl2); - settings.unset('preferWebGl2'); + unsetLocal(settings, 'preferWebGl2'); let msg = `The ${f.path('preferWebGl2')} project setting has been removed`; if (oldEnableWebGl2 !== enableWebGl2) { msg += `. Setting project setting ${f.path('enableWebGl2')} from ${f.value(oldEnableWebGl2)} to ${f.value(enableWebGl2)}`; @@ -82,7 +83,7 @@ editor.once('load', () => { } if (settings.has('deviceTypes')) { const deviceTypes = settings.get('deviceTypes'); - settings.unset('deviceTypes'); + unsetLocal(settings, 'deviceTypes'); let msg = `The ${f.path('deviceTypes')} project setting has been removed`; if (deviceTypes.length) { @@ -202,7 +203,7 @@ editor.once('load', () => { } if (settings.has('useLegacyAudio')) { const useLegacyAudio = settings.get('useLegacyAudio'); - settings.unset('useLegacyAudio'); + unsetLocal(settings, 'useLegacyAudio'); if (useLegacyAudio) { const msg = `The ${f.path('useLegacyAudio')} project setting has been removed`; editor.call('console:log:settings', settings, msg); diff --git a/src/editor/templates/deep-equal-compare.ts b/src/editor/templates/deep-equal-compare.ts new file mode 100644 index 000000000..9857e8a2f --- /dev/null +++ b/src/editor/templates/deep-equal-compare.ts @@ -0,0 +1,99 @@ +type Node = Record; +type NullDefault = (path: string[]) => boolean; +type Catalog = { + isNullDefault: (root: unknown, path: string[]) => boolean; +}; + +const isMapObj = (obj: unknown): boolean => { + return typeof obj === 'object' && obj !== null && !Array.isArray(obj); +}; + +class DeepEqual { + node1: unknown; + + node2: unknown; + + bothNodes: unknown[]; + + nullDefault?: NullDefault; + + path: string[]; + + constructor(node1: unknown, node2: unknown, nullDefault?: NullDefault, path: string[] = []) { + this.node1 = node1; + + this.node2 = node2; + + this.bothNodes = [node1, node2]; + + this.nullDefault = nullDefault; + + this.path = path; + } + + run(): boolean { + if (this.node1 === this.node2) { + return true; + } + if (this.isNullish(this.node1) && this.isNullish(this.node2) && this.nullDefault?.(this.path)) { + return true; + } + if (this.areBothMaps()) { + return this.handleMaps(); + } + if (this.areBothArrays()) { + return this.handleArrays(); + } + return false; + } + + isNullish(node: unknown): boolean { + return node === null || node === undefined; + } + + handleMaps(): boolean { + const n1 = this.node1 as Node; + const n2 = this.node2 as Node; + const keys = new Set([...Object.keys(n1), ...Object.keys(n2)]); + return [...keys].every((k) => { + return new DeepEqual(n1[k], n2[k], this.nullDefault, this.path.concat(k)).run(); + }); + } + + handleArrays(): boolean { + const a1 = this.node1 as unknown[]; + const a2 = this.node2 as unknown[]; + const sameLen = a1.length === a2.length; + return ( + sameLen && + a1.every((v1, index) => { + return new DeepEqual(v1, a2[index], this.nullDefault, this.path.concat(String(index))).run(); + }) + ); + } + + areBothMaps(): boolean { + return this.bothNodes.every((h) => isMapObj(h)); + } + + areBothArrays(): boolean { + return this.bothNodes.every(Array.isArray); + } +} + +/** + * Perform a deep comparison of two nodes consisting of objects, arrays and + * scalar values. + * + * @param node1 - First value to compare + * @param node2 - Second value to compare + * @param nullDefault - Returns whether null and absence are equivalent at a path + * @returns True if the nodes are deep-equal + */ +export const isDeepEqual = (node1: unknown, node2: unknown, nullDefault?: NullDefault): boolean => { + return new DeepEqual(node1, node2, nullDefault).run(); +}; + +export const isSchemaDeepEqual = (node1: unknown, node2: unknown, schema: Catalog, root: unknown, path: string[]) => { + return isDeepEqual(node1, node2, (child) => schema.isNullDefault(root, path.concat(child))); +}; diff --git a/src/editor/templates/deep-equal.ts b/src/editor/templates/deep-equal.ts index a82367618..c8d835065 100644 --- a/src/editor/templates/deep-equal.ts +++ b/src/editor/templates/deep-equal.ts @@ -1,76 +1,6 @@ -editor.once('load', () => { - class DeepEqual { - node1: unknown; - - node2: unknown; - - bothNodes: unknown[]; - - constructor(node1: unknown, node2: unknown) { - this.node1 = node1; - - this.node2 = node2; - - this.bothNodes = [node1, node2]; - } - - run() { - if (this.node1 === this.node2) { - return true; - } - if (this.areBothMaps()) { - return this.handleMaps(); - } - if (this.areBothArrays()) { - return this.handleArrays(); - } - return false; - } - - handleMaps() { - const keys1 = Object.keys(this.node1); - - const keys2 = Object.keys(this.node2); - - const sameLen = keys1.length === keys2.length; - - return sameLen && this.compareMapsRecursively(keys1); - } - - compareMapsRecursively(keys1: string[]): boolean { - return keys1.every((k1) => { - return ( - Object.prototype.hasOwnProperty.call(this.node2, k1) && - new DeepEqual(this.node1[k1], this.node2[k1]).run() - ); - }); - } - - handleArrays() { - const sameLen = this.node1.length === this.node2.length; - - return sameLen && this.compareArraysRecursively(); - } - - compareArraysRecursively() { - return this.node1.every((v1, index) => { - const v2 = this.node2[index]; - - return new DeepEqual(v1, v2).run(); - }); - } - - areBothMaps(): boolean { - return this.bothNodes.every((h) => { - return editor.call('template:utils', 'isMapObj', h); - }); - } - - areBothArrays() { - return this.bothNodes.every(Array.isArray); - } - } +import { isDeepEqual } from './deep-equal-compare'; +editor.once('load', () => { /** * Perform a deep comparison of two nodes consisting of * objects, arrays and scalar values. @@ -80,6 +10,6 @@ editor.once('load', () => { * @returns True if the nodes are deep-equal */ editor.method('assets:isDeepEqual', (node1: unknown, node2: unknown): boolean => { - return new DeepEqual(node1, node2).run(); + return isDeepEqual(node1, node2); }); }); diff --git a/src/editor/templates/migrations/fix-corrupted-instances.ts b/src/editor/templates/migrations/fix-corrupted-instances.ts index 1e1c1d001..5fe36d58f 100644 --- a/src/editor/templates/migrations/fix-corrupted-instances.ts +++ b/src/editor/templates/migrations/fix-corrupted-instances.ts @@ -391,13 +391,13 @@ editor.once('load', () => { function unlinkEntityAndAddToReport(entity: { get: (key: string) => unknown; history: { enabled: boolean }; - unset: (key: string) => void; + set: (key: string, value: unknown) => void; }): void { if (!dryRun) { const history = entity.history.enabled; entity.history.enabled = false; - entity.unset('template_id'); - entity.unset('template_ent_ids'); + entity.set('template_id', null); + entity.set('template_ent_ids', null); entity.history.enabled = history; } diff --git a/src/editor/templates/revert-overrides.ts b/src/editor/templates/revert-overrides.ts index 5cb955c61..604c368ba 100644 --- a/src/editor/templates/revert-overrides.ts +++ b/src/editor/templates/revert-overrides.ts @@ -1,5 +1,6 @@ import type { ObserverList } from '@playcanvas/observer'; +import { unsetObserver } from '@/editor/driver/observer-unset'; import type { AssetObserver, EntityObserver } from '@/editor-api'; type TemplateOverride = Record; @@ -120,7 +121,7 @@ editor.once('load', () => { const history = entity.history.enabled; entity.history.enabled = false; - entity.unset(override.path); + unsetObserver(entity, override.path); previousIndex = entity.get('components.script.order').indexOf(scriptName); entity.removeValue('components.script.order', scriptName); @@ -532,7 +533,7 @@ editor.once('load', () => { } else if (override.path === 'template_id') { revertNewTemplateId(entity, override); } else { - entity.unset(override.path); + unsetObserver(entity, override.path); } } } else { diff --git a/src/editor/templates/template-node-traversal.ts b/src/editor/templates/template-node-traversal.ts index 245f7f05a..ac4217052 100644 --- a/src/editor/templates/template-node-traversal.ts +++ b/src/editor/templates/template-node-traversal.ts @@ -1,5 +1,7 @@ import { guid } from 'playcanvas'; +import { isSchemaDeepEqual } from './deep-equal-compare'; + editor.once('load', () => { const bothTypes = ['src', 'dst']; @@ -138,7 +140,14 @@ editor.once('load', () => { } areNodesEqual() { - return editor.call('assets:isDeepEqual', this.data.node1, this.data.node2); + const schema = editor.api.globals.schema; + return isSchemaDeepEqual( + this.data.node1, + this.data.node2, + schema, + schema.getDocument('scene'), + this.fullPath + ); } areBothNodesMaps() { diff --git a/src/editor/templates/unlink-template.ts b/src/editor/templates/unlink-template.ts index 69380a64d..e11cdec01 100644 --- a/src/editor/templates/unlink-template.ts +++ b/src/editor/templates/unlink-template.ts @@ -73,8 +73,8 @@ editor.once('load', () => { const history = entity.history.enabled; entity.history.enabled = false; - entity.unset('template_id'); - entity.unset('template_ent_ids'); + entity.set('template_id', null); + entity.set('template_ent_ids', null); entity.history.enabled = history; }); } diff --git a/src/editor/viewport/viewport-drop-material.ts b/src/editor/viewport/viewport-drop-material.ts index c1ccc32c7..707457762 100644 --- a/src/editor/viewport/viewport-drop-material.ts +++ b/src/editor/viewport/viewport-drop-material.ts @@ -2,6 +2,7 @@ import { MeshInstance } from 'playcanvas'; import type { Entity } from 'playcanvas'; import { config } from '@/editor/config'; +import { unsetObserver } from '@/editor/driver/observer-unset'; editor.once('load', () => { const app = editor.call('viewport:app'); @@ -291,7 +292,7 @@ editor.once('load', () => { item.history.enabled = false; if (undo.value === undefined) { - item.unset(undo.path); + unsetObserver(item, undo.path); } else { item.set(undo.path, undo.value); } @@ -307,7 +308,7 @@ editor.once('load', () => { const history = item.history.enabled; item.history.enabled = false; if (redo.value === undefined) { - item.unset(redo.path); + unsetObserver(item, redo.path); } else { item.set(redo.path, redo.value); } diff --git a/src/editor/viewport/viewport-entities-components-binding.ts b/src/editor/viewport/viewport-entities-components-binding.ts index a408e5c9d..e2a4491e0 100644 --- a/src/editor/viewport/viewport-entities-components-binding.ts +++ b/src/editor/viewport/viewport-entities-components-binding.ts @@ -80,7 +80,7 @@ editor.once('load', () => { const aabbCenter = obj.get(`components.${component}.aabbCenter`); const aabbHalfExtents = obj.get(`components.${component}.aabbHalfExtents`); - if (aabbCenter && aabbHalfExtents) { + if (Array.isArray(aabbCenter) && Array.isArray(aabbHalfExtents)) { entity[component].customAabb = new BoundingBox(new Vec3(aabbCenter), new Vec3(aabbHalfExtents)); } callSetter = false; diff --git a/src/editor/viewport/viewport-lightmapper.ts b/src/editor/viewport/viewport-lightmapper.ts index 3eae39c30..d0b564433 100644 --- a/src/editor/viewport/viewport-lightmapper.ts +++ b/src/editor/viewport/viewport-lightmapper.ts @@ -1,5 +1,6 @@ import { SHADOWUPDATE_THISFRAME } from 'playcanvas'; +import { hasUv1 } from '@/editor/assets/asset-flags'; import type { EntityObserver } from '@/editor-api'; editor.once('load', () => { @@ -49,7 +50,7 @@ editor.once('load', () => { } // check if asset has uv1 - const uv1 = asset.has('meta.attributes.texCoord1'); + const uv1 = hasUv1(asset); if (!uv1) { // uv1 might be missing if (!uv1MissingAssets[assetId]) { diff --git a/src/launch/assets/assets-sync.ts b/src/launch/assets/assets-sync.ts index 92c1f49bd..2759c70f6 100644 --- a/src/launch/assets/assets-sync.ts +++ b/src/launch/assets/assets-sync.ts @@ -1,6 +1,7 @@ import { Observer } from '@playcanvas/observer'; import { ObserverSync } from '@/common/observer-sync'; +import { isReferencedFont } from '@/editor/inspector/assets/font-mode'; import type { LaunchConfig } from '@/editor-api/external-types/config'; editor.once('load', () => { @@ -296,7 +297,7 @@ editor.once('load', () => { if (assetData.type === 'bundle' && assetData.data && assetData.data.assets) { assetData.data.assets = assetData.data.assets.filter((id: number) => { const member = editor.call('assets:get', id); - return !(member && member.get('type') === 'font' && member.has('data.jsonAsset')); + return !(member && member.get('type') === 'font' && isReferencedFont(member)); }); } const engineAsset = ((asset as Observer & { asset?: pc.Asset }).asset = new pc.Asset( diff --git a/src/launch/viewport/viewport-binding-components.ts b/src/launch/viewport/viewport-binding-components.ts index c0a4c6122..66977063e 100644 --- a/src/launch/viewport/viewport-binding-components.ts +++ b/src/launch/viewport/viewport-binding-components.ts @@ -117,7 +117,7 @@ editor.once('load', () => { if (property === 'aabbCenter' || property === 'aabbHalfExtents') { const aabbCenter = obj.get(`components.${component}.aabbCenter`); const aabbHalfExtents = obj.get(`components.${component}.aabbHalfExtents`); - if (aabbCenter && aabbHalfExtents) { + if (Array.isArray(aabbCenter) && Array.isArray(aabbHalfExtents)) { entity[component].customAabb = new pc.BoundingBox( new pc.Vec3(aabbCenter), new pc.Vec3(aabbHalfExtents) diff --git a/test/editor-api/api/test-schema.js b/test/editor-api/api/test-schema.js index a3760ad83..ae271227a 100644 --- a/test/editor-api/api/test-schema.js +++ b/test/editor-api/api/test-schema.js @@ -21,14 +21,17 @@ describe('api.Schema tests', function () { expect(api.globals.schema.components.resolvePath('testcomponent', 'entityRef')).to.include({ default: null, hasDefault: true, - open: false + open: false, + optional: false }); expect(api.globals.schema.components.resolvePath('testcomponent', 'nestedEntityRef.item.entity')).to.include({ hasDefault: false, - open: true + open: false, + optional: false }); expect(api.globals.schema.components.resolvePath('script', 'scripts.rotate.attributes.speed')).to.include({ - open: true + open: true, + optional: true }); expect(api.globals.schema.components.resolvePath('testcomponent', 'missing')).to.equal(null); }); @@ -39,16 +42,24 @@ describe('api.Schema tests', function () { expect(api.globals.schema.assets.resolvePath('material', 'diffuse')).to.deep.include({ default: [0, 0, 0], hasDefault: true, - open: false + open: false, + optional: false }); expect(api.globals.schema.assets.resolvePath('model', 'mapping.0.material')).to.include({ hasDefault: false, - open: false + open: false, + optional: false }); expect(api.globals.schema.assets.resolvePath('test', 'nestedAssetRef.item.asset')).to.include({ default: null, hasDefault: true, - open: true + open: false, + optional: false + }); + expect(api.globals.schema.assets.resolvePath('test', 'nestedAssetRef.item')).to.include({ + hasDefault: false, + open: true, + optional: true }); expect(api.globals.schema.assets.resolvePath('model', 'mapping.nope.material')).to.equal(null); expect(api.globals.schema.assets.resolvePath('material', 'missing')).to.equal(null); @@ -61,13 +72,73 @@ describe('api.Schema tests', function () { field: null, default: undefined, hasDefault: false, - open: true + open: true, + optional: true }); expect(api.globals.schema.assets.resolvePath('model', 'mapping.anyKey.material')).to.equal(null); expect(api.globals.schema.assets.resolvePath('model', 'mapping.-1.material')).to.equal(null); }); }); + it('only marks the final dynamic map segment as open', function () { + withSchema(() => { + const root = api.globals.schema.getDocument('settings'); + expect(api.globals.schema.resolvePath(root, 'batchGroups.group')).to.include({ + hasDefault: false, + open: true, + optional: true + }); + expect(api.globals.schema.resolvePath(root, 'batchGroups.group.maxAabbSize')).to.include({ + default: 100, + hasDefault: true, + open: false, + optional: false + }); + }); + }); + + it('resolves type-specific asset metadata defaults', function () { + withSchema(() => { + expect(api.globals.schema.assets.resolveMetaPath('texture', 'compress.quality')).to.deep.include({ + default: 128, + hasDefault: true, + open: false, + optional: false + }); + expect(api.globals.schema.assets.resolveMetaPath('font', 'invert')).to.include({ + hasDefault: false, + open: false, + optional: true + }); + expect(api.globals.schema.assets.resolveMetaPath('material', 'compress.quality')).to.equal(null); + }); + }); + + it('marks only fields omitted from the parent required list as optional', function () { + withSchema(() => { + const root = api.globals.schema.getDocument('settings'); + expect(api.globals.schema.resolvePath(root, 'nested.projectUserValue')).to.include({ optional: false }); + expect(api.globals.schema.resolvePath(root, 'nested.optionalValue')).to.include({ optional: true }); + + const scene = api.globals.schema.getDocument('scene'); + expect(api.globals.schema.resolvePath(scene, 'entities.item.components.model')).to.include({ + open: false, + optional: true + }); + }); + }); + + it('limits null-default equivalence to fixed nullable fields', function () { + withSchema(() => { + const scene = api.globals.schema.getDocument('scene'); + const isNullDefault = (path) => api.globals.schema.isNullDefault(scene, path); + expect(isNullDefault('entities.entity.components.testcomponent.entityRef')).to.equal(true); + expect(isNullDefault('entities.entity.components.testcomponent.nestedAssetRef.item.asset')).to.equal(true); + expect(isNullDefault('entities.entity.components.script.scripts.rotate.attributes.target')).to.equal(false); + expect(isNullDefault('entities.entity.components.testcomponent.enabled')).to.equal(false); + }); + }); + it('preserves falsey and nested defaults', function () { withSchema(() => { expect(api.globals.schema.assets.getDefaultData('material')).to.deep.equal({ @@ -142,4 +213,151 @@ describe('api.Schema tests', function () { ); expect(() => new api.Schema({ version: 1 })).to.throw('Unsupported Editor schema version: 1'); }); + + it('getFields sees through a nullability wrapper', function () { + const schema = new api.Schema({ + version: 1, + documents: { + asset: { type: 'object', properties: {} }, + scene: { type: 'object', properties: {} }, + settings: { + type: 'object', + properties: { + editor: { + default: null, + anyOf: [ + { type: 'object', properties: { gizmoSize: { type: 'number', default: 1 } } }, + { type: 'null' } + ] + } + } + } + }, + assetData: {} + }); + + const editorField = schema.getDocument('settings').properties.editor; + expect(Object.keys(schema.getFields(editorField))).to.deep.equal(['gizmoSize']); + }); + + it('getAssetTypes sees through a nullability wrapper', function () { + const schema = new api.Schema({ + version: 1, + documents: { + asset: { + type: 'object', + properties: { type: { anyOf: [{ type: 'string', enum: ['material', 'texture'] }, { type: 'null' }] } } + }, + scene: { type: 'object', properties: {} }, + settings: { type: 'object', properties: {} } + }, + assetData: {} + }); + + expect(schema.getAssetTypes()).to.deep.equal(['material', 'texture']); + }); + + it('getFieldsOfType finds references inside a nullability wrapper', function () { + const schema = new api.Schema({ + version: 1, + documents: { + asset: { type: 'object', properties: {} }, + scene: { + type: 'object', + properties: { + entities: { + 'x-open-map': true, + additionalProperties: { + type: 'object', + properties: { + components: { + default: null, + anyOf: [ + { + type: 'object', + properties: { + model: { + type: 'object', + properties: { + materialAsset: { type: 'number', 'x-editor-type': 'asset' } + } + } + } + }, + { type: 'null' } + ] + } + } + } + } + } + }, + settings: { type: 'object', properties: {} } + }, + assetData: {} + }); + + expect(schema.components.getFieldsOfType('model', 'asset')).to.deep.equal(['materialAsset']); + }); + + it('reads metadata from the inner branch when it is not hoisted', function () { + const schema = new api.Schema({ + version: 1, + documents: { + asset: { type: 'object', properties: {} }, + scene: { type: 'object', properties: {} }, + settings: { + type: 'object', + properties: { + loadingScreenScript: { + default: null, + anyOf: [{ type: 'string', 'x-scope': 'project' }, { type: 'null' }] + } + } + } + }, + assetData: {} + }); + + const settings = schema.getDocument('settings'); + expect(schema.getScope(settings.properties.loadingScreenScript)).to.equal('project'); + expect(schema.getScopeForPath(settings, 'loadingScreenScript')).to.equal('project'); + }); + + it('materializes children even when the container carries a default', function () { + const schema = new api.Schema({ + version: 1, + documents: { + asset: { type: 'object', properties: {} }, + scene: { type: 'object', properties: {} }, + settings: { + type: 'object', + properties: { + editor: { + default: null, + type: 'object', + properties: { + gizmoSize: { type: 'number', default: 1, 'x-scope': 'user' }, + iconSize: { type: 'number', default: 2, 'x-scope': 'user' } + } + } + } + } + }, + assetData: {} + }); + + expect(schema.settings.getDefaultUserSettings()).to.deep.equal({ + editor: { gizmoSize: 1, iconSize: 2 } + }); + }); + + it('produces unchanged settings seeds for a container-default-free catalog', function () { + // the karma fixture has no container defaults, so the merge refactor must + // reproduce today's per-scope seeds byte-for-byte + const s = new api.Schema(schema); + expect(s.settings.getDefaultProjectSettings()).to.deep.equal({ projectFlag: false }); + expect(s.settings.getDefaultUserSettings()).to.deep.equal({ userCount: 0 }); + expect(s.settings.getDefaultProjectUserSettings()).to.deep.equal({ nested: { projectUserValue: '' } }); + }); }); diff --git a/test/editor-api/lib/schema.js b/test/editor-api/lib/schema.js index 633d9f1f9..7ce9c7160 100644 --- a/test/editor-api/lib/schema.js +++ b/test/editor-api/lib/schema.js @@ -1,6 +1,7 @@ const object = (properties, data = {}) => ({ type: 'object', properties, + required: Object.keys(properties), additionalProperties: false, ...data }); @@ -49,7 +50,10 @@ const entity = object({ assetArrayRef: { type: 'string', default: [], 'x-editor-type': 'array:asset' }, nestedAssetRef: map( object({ - asset: { type: 'number', default: null, 'x-editor-type': 'asset' } + asset: nullable( + { type: 'number' }, + { default: null, 'x-editor-type': 'asset' } + ) }) ) }), @@ -59,7 +63,7 @@ const entity = object({ scripts: map({}, { default: {} }) }), zone: object({ enabled: { type: 'boolean', default: true } }) - }) + }, { required: [] }) }); window.schema = { @@ -84,14 +88,30 @@ window.schema = { settings: object({ projectFlag: { type: 'boolean', default: false, 'x-scope': 'project' }, userCount: { type: 'number', default: 0, 'x-scope': 'user' }, - nested: object({ - projectUserValue: { type: 'string', default: '', 'x-scope': 'projectUser' } - }) + batchGroups: map(object({ maxAabbSize: { type: 'number', default: 100 } })), + nested: object( + { + projectUserValue: { type: 'string', default: '', 'x-scope': 'projectUser' }, + optionalValue: { type: 'string' } + }, + { required: ['projectUserValue'] } + ) }), asset: object({ type: { type: 'string', enum: ['material', 'model', 'font', 'test'] } }) }, + assetMeta: { + font: object({ invert: { type: 'boolean' } }, { required: [] }), + texture: object({ + compress: object({ + alpha: { type: 'boolean', default: false }, + pvrBpp: { type: 'number', default: 4 }, + quality: { type: 'number', default: 128 }, + compressionMode: { type: 'string', default: 'etc' } + }) + }) + }, assetData: { animstategraph: object({ testData: { type: 'number', default: 0 } }), material: object({ diff --git a/test/editor/assets/font-mode.test.ts b/test/editor/assets/font-mode.test.ts new file mode 100644 index 000000000..32f539d22 --- /dev/null +++ b/test/editor/assets/font-mode.test.ts @@ -0,0 +1,23 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { isReferencedFont } from '../../../src/editor/inspector/assets/font-mode'; + +describe('isReferencedFont', () => { + const asset = (value: unknown) => + ({ + get: (path: string) => (path === 'data.jsonAsset' ? value : undefined) + }) as any; + + it('is false for a legacy font with an explicit null', () => { + expect(isReferencedFont(asset(null))).to.equal(false); + }); + + it('is false for a legacy font with the key absent', () => { + expect(isReferencedFont(asset(undefined))).to.equal(false); + }); + + it('is true for a referenced font', () => { + expect(isReferencedFont(asset(1234))).to.equal(true); + }); +}); diff --git a/test/editor/driver/unset-contract.test.ts b/test/editor/driver/unset-contract.test.ts new file mode 100644 index 000000000..29f89f850 --- /dev/null +++ b/test/editor/driver/unset-contract.test.ts @@ -0,0 +1,35 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { resolveUnset } from '../../../src/editor/driver/unset'; + +describe('resolveUnset', () => { + it('deletes on an open map path', () => { + expect(resolveUnset({ hasDefault: false, default: undefined, open: true, optional: true })).to.deep.equal({ + op: 'unset' + }); + }); + + it('deletes on an open path even when a default exists', () => { + expect(resolveUnset({ hasDefault: true, default: 1, open: true, optional: true })).to.deep.equal({ + op: 'unset' + }); + }); + + it('deletes a fixed optional field', () => { + expect(resolveUnset({ hasDefault: false, default: undefined, open: false, optional: true })).to.deep.equal({ + op: 'unset' + }); + }); + + it('resets a fixed defaulted child beneath a dynamic record', () => { + expect(resolveUnset({ hasDefault: true, default: 100, open: false, optional: false })).to.deep.equal({ + op: 'set', + value: 100 + }); + }); + + it('rejects a fixed required field that has no default', () => { + expect(resolveUnset({ hasDefault: false, default: undefined, open: false, optional: false })).to.equal(null); + }); +}); diff --git a/test/editor/entities/legacy-lightmap-purge.test.ts b/test/editor/entities/legacy-lightmap-purge.test.ts new file mode 100644 index 000000000..7a9d1295d --- /dev/null +++ b/test/editor/entities/legacy-lightmap-purge.test.ts @@ -0,0 +1,19 @@ +import fs from 'node:fs'; + +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +describe('legacy lightmap purge', () => { + it('drains all four legacy spellings', () => { + const source = fs.readFileSync('src/editor/entities/entities-migrations.ts', 'utf8'); + const match = source.match(/LEGACY_LIGHTMAP_PROPERTIES\s*=\s*\[([^\]]*)\]/); + expect(match, 'LEGACY_LIGHTMAP_PROPERTIES not found').to.not.equal(null); + const names = match![1] + .split(',') + .map((s) => s.trim().replace(/['"]/g, '')) + .filter(Boolean); + expect(names.sort()).to.deep.equal( + ['castShadowsLightMap', 'lightMapSizeMultiplier', 'lightMapped', 'static'].sort() + ); + }); +}); diff --git a/test/editor/fixed-unset-contract.test.ts b/test/editor/fixed-unset-contract.test.ts new file mode 100644 index 000000000..c5134923e --- /dev/null +++ b/test/editor/fixed-unset-contract.test.ts @@ -0,0 +1,49 @@ +import fs from 'node:fs'; + +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +describe('fixed-field reset contract', () => { + it('resets fixed fields instead of deleting them', () => { + const cubemap = fs.readFileSync('src/editor/inspector/assets/cubemap-face.ts', 'utf8'); + const model = fs.readFileSync('src/editor/inspector/components/model.ts', 'utf8'); + const unlink = fs.readFileSync('src/editor/templates/unlink-template.ts', 'utf8'); + const repair = fs.readFileSync('src/editor/templates/migrations/fix-corrupted-instances.ts', 'utf8'); + const states = fs.readFileSync('src/editor/animstategraph/view.ts', 'utf8'); + const params = fs.readFileSync('src/editor/animstategraph/parameters.ts', 'utf8'); + const project = fs.readFileSync('src/editor/driver/project.ts', 'utf8'); + const asset = fs.readFileSync('src/editor/driver/asset.ts', 'utf8'); + const entity = fs.readFileSync('src/editor/driver/entity.ts', 'utf8'); + const overrides = fs.readFileSync('src/editor/templates/revert-overrides.ts', 'utf8'); + const drop = fs.readFileSync('src/editor/viewport/viewport-drop-material.ts', 'utf8'); + const picker = fs.readFileSync('src/editor/pickers/picker-node.ts', 'utf8'); + + expect(cubemap).to.include("this._asset.set('data.rgbm', false)"); + expect(cubemap).not.to.include("this._asset.unset('data.rgbm')"); + expect(model).to.include("latest.set('components.model.mapping', {})"); + expect(model).not.to.include("latest.unset('components.model.mapping')"); + expect(states).to.include('`data.states.${stateKey}.defaultState`, false'); + expect(states).not.to.include('unset(`data.states.${stateKey}.defaultState`)'); + expect(params).to.include('`data.transitions.${transitionKey}.conditions.${conditionKey}.parameterName`,'); + expect(params).not.to.include( + 'unset(`data.transitions.${transitionKey}.conditions.${conditionKey}.parameterName`)' + ); + for (const source of [unlink, repair]) { + expect(source).to.include("entity.set('template_id', null)"); + expect(source).to.include("entity.set('template_ent_ids', null)"); + expect(source).not.to.include("entity.unset('template_id')"); + expect(source).not.to.include("entity.unset('template_ent_ids')"); + } + expect(project).to.include('api.schema.resolvePath(root, path)'); + expect(project).to.include('resolveUnset('); + expect(asset).to.include('api.schema.assets.resolveMetaPath(type, edit.path.slice(5))'); + expect(asset).not.to.include('META_DEFAULTS'); + expect(entity).to.include("api.schema.getDocument('scene')"); + expect(overrides).to.include('unsetObserver(entity, override.path)'); + expect(drop).to.include('unsetObserver(item, undo.path)'); + expect(drop).to.include('unsetObserver(item, redo.path)'); + expect(picker).to.include("path: 'components.model.mapping'"); + expect(picker).to.include('path: `components.model.mapping.${index}`'); + expect(picker).to.include('unsetObserver(item, actions[i].path)'); + }); +}); diff --git a/test/editor/inspector/presence-flags.test.ts b/test/editor/inspector/presence-flags.test.ts new file mode 100644 index 000000000..18951bc9b --- /dev/null +++ b/test/editor/inspector/presence-flags.test.ts @@ -0,0 +1,52 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { isImportedMaterial, hasUv1 } from '../../../src/editor/assets/asset-flags'; +import { hasCustomAabb } from '../../../src/editor/inspector/components/aabb-utils'; + +describe('hasCustomAabb', () => { + const entity = (value: unknown) => + ({ + get: (path: string) => (path === 'components.render.aabbCenter' ? value : undefined) + }) as any; + + it('is false when the field is null', () => { + expect(hasCustomAabb(entity(null), 'render')).to.equal(false); + }); + + it('is false when the field is absent', () => { + expect(hasCustomAabb(entity(undefined), 'render')).to.equal(false); + }); + + it('is true for a zero vector, which is a legitimate custom AABB', () => { + expect(hasCustomAabb(entity([0, 0, 0]), 'render')).to.equal(true); + }); + + it('is true for a real vector', () => { + expect(hasCustomAabb(entity([1, 2, 3]), 'render')).to.equal(true); + }); +}); + +describe('asset presence flags', () => { + const asset = (map: Record) => + ({ + get: (p: string) => map[p], + has: (p: string) => Object.hasOwn(map, p) + }) as any; + + it('treats a zero attribute count as no UV1', () => { + expect(hasUv1(asset({ 'meta.attributes.texCoord1': 0 }))).to.equal(false); + expect(hasUv1(asset({ 'meta.attributes.texCoord1': 4 }))).to.equal(true); + expect(hasUv1(asset({}))).to.equal(false); + }); + + it('still detects the GLB spelling in the open map', () => { + expect(hasUv1(asset({ 'meta.attributes.TEXCOORD_1': 4 }))).to.equal(true); + }); + + it('distinguishes an imported material from a hand-made one', () => { + expect(isImportedMaterial(asset({ 'meta.index': 0 }))).to.equal(true); // index 0 is valid + expect(isImportedMaterial(asset({ 'meta.index': null }))).to.equal(false); + expect(isImportedMaterial(asset({}))).to.equal(false); + }); +}); diff --git a/test/editor/observer-sync.test.ts b/test/editor/observer-sync.test.ts new file mode 100644 index 000000000..aa4445f89 --- /dev/null +++ b/test/editor/observer-sync.test.ts @@ -0,0 +1,59 @@ +import fs from 'node:fs'; + +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { unsetLocal } from '../../src/common/observer-unset'; + +describe('unsetLocal', () => { + it('removes the local value without emitting an operation and restores sync', () => { + const ops = []; + const state: Record = { legacy: true }; + const item = { + sync: { enabled: true }, + unset(path: string) { + if (this.sync.enabled) ops.push(path); + delete state[path]; + } + }; + + unsetLocal(item, 'legacy'); + + expect(state).not.to.have.property('legacy'); + expect(ops).to.deep.equal([]); + expect(item.sync.enabled).to.equal(true); + }); + + it('keeps an already-disabled sync disabled', () => { + const state: Record = { legacy: true }; + const item = { + sync: { enabled: false }, + unset(path: string) { + delete state[path]; + } + }; + + unsetLocal(item, 'legacy'); + + expect(state).not.to.have.property('legacy'); + expect(item.sync.enabled).to.equal(false); + }); + + it('is used only for retired migration keys', () => { + const assets = fs.readFileSync('src/editor/assets/assets-migrate.ts', 'utf8'); + const entities = fs.readFileSync('src/editor/entities/entities-migrations.ts', 'utf8'); + const settings = fs.readFileSync('src/editor/settings/project-settings.ts', 'utf8'); + + expect(assets).to.include('unsetLocal(asset, oldPath)'); + expect(assets).not.to.include('asset.unset(oldPath)'); + for (const path of ['data.useGamma', 'data.fresnelModel']) { + expect(assets).to.include(`unsetLocal(asset, '${path}')`); + expect(assets).not.to.include(`asset.unset('${path}')`); + } + expect(entities).to.include("unsetLocal(entity, 'components.light.affectLightMapped')"); + for (const path of ['preferWebGl2', 'deviceTypes', 'useLegacyAudio']) { + expect(settings).to.include(`unsetLocal(settings, '${path}')`); + expect(settings).not.to.include(`settings.unset('${path}')`); + } + }); +}); diff --git a/test/editor/scene-settings/priority-scripts.test.ts b/test/editor/scene-settings/priority-scripts.test.ts new file mode 100644 index 000000000..5f8c4de21 --- /dev/null +++ b/test/editor/scene-settings/priority-scripts.test.ts @@ -0,0 +1,13 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { isUnsetPriorityScripts } from '../../../src/editor/scene-settings/priority-scripts'; + +describe('priority_scripts cleanup', () => { + it('treats a raw null the same as an absent key', () => { + expect(isUnsetPriorityScripts(undefined)).to.equal(true); + expect(isUnsetPriorityScripts(null)).to.equal(true); + expect(isUnsetPriorityScripts([])).to.equal(false); + expect(isUnsetPriorityScripts(['a.js'])).to.equal(false); + }); +}); diff --git a/test/editor/templates/deep-equal.test.ts b/test/editor/templates/deep-equal.test.ts new file mode 100644 index 000000000..e38d0566c --- /dev/null +++ b/test/editor/templates/deep-equal.test.ts @@ -0,0 +1,50 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { isDeepEqual, isSchemaDeepEqual } from '../../../src/editor/templates/deep-equal-compare'; + +describe('isDeepEqual null vs absent', () => { + it('keeps null distinct from an absent key by default', () => { + expect(isDeepEqual({ a: 1, b: null }, { a: 1 })).to.equal(false); + expect(isDeepEqual({ a: 1 }, { a: 1, b: null })).to.equal(false); + }); + + it('equates null and absence only at fixed catalog paths', () => { + const paths: string[] = []; + const schema = { + isNullDefault: (_root: unknown, path: string[]) => { + const key = path.join('.'); + paths.push(key); + return key === 'entities.entity.components.testcomponent.entityRef'; + } + }; + const root = {}; + const fixed = { components: { testcomponent: { entityRef: null } } }; + const absent = { components: { testcomponent: {} } }; + const path = ['entities', 'entity']; + expect(isSchemaDeepEqual(fixed, absent, schema, root, path)).to.equal(true); + expect(isSchemaDeepEqual(absent, fixed, schema, root, path)).to.equal(true); + expect( + isSchemaDeepEqual( + { components: { script: { scripts: { rotate: { attributes: { target: null } } } } } }, + { components: { script: { scripts: { rotate: { attributes: {} } } } } }, + schema, + root, + path + ) + ).to.equal(false); + expect(paths).to.include('entities.entity.components.script.scripts.rotate.attributes.target'); + }); + + it('still reports a genuine missing-value difference', () => { + expect(isDeepEqual({ a: 1, b: 2 }, { a: 1 })).to.equal(false); + expect(isDeepEqual({ a: 1, b: null }, { a: 1, b: 2 })).to.equal(false); + }); + + it('does not conflate null with other values', () => { + const all = () => true; + expect(isDeepEqual({ a: null }, { a: 0 }, all)).to.equal(false); + expect(isDeepEqual({ a: null }, { a: false }, all)).to.equal(false); + expect(isDeepEqual({ a: null }, { a: [] }, all)).to.equal(false); + }); +});