Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/core/scene/common/gizmo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface IGizmoService {
removeAllGizmoOfNode(node: any, recursive?: boolean): void;
clearAllGizmos(): void;
callAllGizmoFuncOfNode(node: any, funcName: string, ...params: any[]): boolean;
getComponentGizmo(component: any): any;
onUpdate(deltaTime: number): void;

// 与 cocos-editor GizmoManager 一致:GizmoConfig 方法
Expand Down
1 change: 1 addition & 0 deletions src/core/scene/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ export * from './gizmo';
export * from './scene-view';
export * from './preview';
export * from './ui';
export * from './terrain';
export * from './message';
28 changes: 28 additions & 0 deletions src/core/scene/common/terrain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { Terrain } from 'cc';

/** Terrain 编辑器的非 UI 接口;pink 只需要调用这些接口。 */
export interface ITerrainService {
readonly name: 'cc.Terrain';
readonly editedComponents: Terrain[];
readonly selectedComponents: Terrain[];
isTerrainChange: boolean;
select(nodeUuid: string): void;
unselect(nodeUuid: string): void;
close(): Promise<0 | 1 | 2>;
saveAsset(isClose?: boolean, component?: Terrain): Promise<0 | 1 | 2>;
saveAssetDialog(file?: string, isClose?: boolean): Promise<0 | 1 | 2>;
addAssetToComp(assetUuid: string): Promise<void>;
serialize(component: Terrain): Uint8Array;
onSculpt(node: any): void;
}

export type IPublicTerrainService = Pick<ITerrainService,
'name' | 'isTerrainChange' | 'select' | 'unselect' | 'close' |
'saveAsset' | 'saveAssetDialog' | 'addAssetToComp'
>;

export interface ITerrainEvents {
'terrain:changed': [component: Terrain];
'terrain:sculpt': [node: any];
'terrain:block-update': [];
}
9 changes: 9 additions & 0 deletions src/core/scene/scene-process/service/component/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Component, MissingScript } from 'cc';
import { IProperty } from '../../../@types/public';
import { type IComponentEvents } from '../../../common';
import { ServiceEvents } from '../core/global-events';
import { queryRegisteredService } from '../core/decorator';

export class CompManager {
protected _recycleComponent: Record<string, Component> = {};
Expand Down Expand Up @@ -213,6 +214,14 @@ export class CompManager {

const pathKeys = (name || '').split('.');
const methodName = pathKeys.pop() || '';
// 3.x terrain UI calls component methods through `gizmo.xxx`. Gizmos are
// held by GizmoService's WeakMap in CLI, so they cannot be resolved by lodash/get.
if (pathKeys.length === 1 && pathKeys[0] === 'gizmo') {
const gizmo = queryRegisteredService<any>('Gizmo')?.getComponentGizmo?.(comp);
if (gizmo && methodName && typeof gizmo[methodName] === 'function') {
return await gizmo[methodName](...(args || []));
}
}
if (pathKeys.length > 0) {
const methodObjPath = pathKeys.join('.');
const methodObj = get(comp, methodObjPath);
Expand Down
20 changes: 20 additions & 0 deletions src/core/scene/scene-process/service/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,9 @@ export class EditorService extends BaseService<IEditorEvents> implements IEditor
}

this.invalidateEditorSession();
if (params.save !== false) {
await this.saveTerrainAssets();
}
const result = await editor.close({ save: params.save ?? true });

if (editor === this.editorMap.get(currentEditorUuid)) {
Expand Down Expand Up @@ -273,6 +276,7 @@ export class EditorService extends BaseService<IEditorEvents> implements IEditor
const urlOrUUID = params.urlOrUUID ?? this.currentEditorUuid;
try {
const { assetInfo, currentEditorUuid, editor } = await this.resolveSaveTarget(urlOrUUID);
await this.saveTerrainAssets();
const result = assetInfo.uuid === currentEditorUuid
? await editor.save()
: await this.recoverDeletedSourceTo(assetInfo, currentEditorUuid, editor);
Expand All @@ -289,6 +293,22 @@ export class EditorService extends BaseService<IEditorEvents> implements IEditor
}
}

/** Terrain data lives in .terrain assets, not in the scene JSON. */
private async saveTerrainAssets(): Promise<void> {
try {
const terrain = (Service as any).Terrain;
if (!terrain?.saveAsset) return;
const result = await terrain.saveAsset(false);
if (result === 2) {
throw new Error('Terrain asset save failed or requires a Save As target.');
}
} catch (error) {
// During early bootstrap or isolated editor tests TerrainService may
// not be registered. Real terrain save failures use the explicit error above.
if (error instanceof Error && error.message.includes('requires a Save As')) throw error;
}
}

private async recoverDeletedSourceTo(assetInfo: IAssetInfo, currentEditorUuid: string, editor: SceneEditor | PrefabEditor): Promise<IAssetInfo> {
const currentAssetInfo = await Rpc.getInstance().request('assetManager', 'queryAssetInfo', [currentEditorUuid]);
if (currentAssetInfo) {
Expand Down
16 changes: 16 additions & 0 deletions src/core/scene/scene-process/service/gizmo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@ import './gizmo/components/web-view';
import './gizmo/components/light-probe-group';
import './gizmo/components/reflection-probe';

// Terrain is available only in engine versions that expose the Terrain
// component. Keep its registration conditional so lightweight CLI/test `cc`
// mocks do not eagerly load the terrain controller's rendering dependencies.
try {
if ((require('cc') as { Terrain?: unknown }).Terrain) {
require('./gizmo/components/terrain');
}
} catch {
// Terrain registration is optional when the engine module is incomplete.
}

type TGizmoType = 'icon' | 'persistent' | 'component';

// 与 cocos-editor GizmoConfig 一致:Gizmo 全局显示配置
Expand Down Expand Up @@ -821,6 +832,11 @@ export class GizmoService extends BaseService<IGizmoEvents> implements IGizmoSer
return !stopped;
}

/** Returns the component gizmo without exposing the internal WeakMap to callers. */
getComponentGizmo(component: Component): GizmoBase | null {
return getGizmoProperty('component', component) ?? null;
}

// ── Selection integration (与 cocos-editor SelectionGizmoManager 一致) ─────

querySelectNodes(): Node[] {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Terrain } from 'cc';
import GizmoBase from '../../base/gizmo-base';
import TerrainController from '../../controller/terrain';
import type { GizmoMouseEvent } from '../../utils/defines';
import { Service } from '../../../core/decorator';
import { getEditorNodePath } from '../../utils/editor-node';
import type TerrainGizmo from './gizmo-select';

/** Persistent gizmo receiving mouse input over the non-raycast Terrain component. */
export default class TerrainPersistentGizmo extends GizmoBase<Terrain> {
private _controller!: TerrainController;
private get selectGizmo(): TerrainGizmo | null {
return this.target ? Service.Gizmo.getComponentGizmo(this.target) as TerrainGizmo | null : null;
}
protected init() {
this._controller = new TerrainController(this.getGizmoRoot());
this._controller.onControllerMouseDown = this.onControllerMouseDown.bind(this);
this._controller.onControllerMouseMove = this.onControllerMouseMove.bind(this);
this._controller.onControllerMouseUp = this.onControllerMouseUp.bind(this);
this._controller.onControllerHoverOut = this.onControllerHoverOut.bind(this);
this.updateController();
}
private updateController() {
if (!this._controller) return;
if (!this.target) { this._controller.hide(); return; }
this._controller.updateWorldPosition(this.target.node.getWorldPosition());
const info = this.target.info;
this._controller.updateSize(info.size.width, info.size.height);
this._controller.show(); Service.Engine.repaintInEditMode();
}
public onTargetUpdate() { this.updateController(); }
public onNodeChanged() { this.updateController(); }
onControllerMouseDown(event: GizmoMouseEvent) {
if (!this.target) return;
const path = getEditorNodePath(this.target.node);
if (Service.Selection.query()[0] !== path) {
event.propagationStopped = true; Service.Selection.select(path); return;
}
const gizmo = this.selectGizmo; if (gizmo?.visible()) gizmo.onControllerMouseDown(event);
}
onControllerMouseMove(event: GizmoMouseEvent) { const gizmo = this.selectGizmo; if (gizmo?.visible()) gizmo.onControllerMouseMove(event); }
onControllerMouseUp(event: GizmoMouseEvent) { const gizmo = this.selectGizmo; if (gizmo?.visible()) gizmo.onControllerMouseUp(event); }
onControllerHoverOut() { const gizmo = this.selectGizmo; if (gizmo?.visible()) gizmo.onControllerHoverOut(); }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { Terrain, TerrainInfo, TerrainLayer, TERRAIN_MAX_LAYER_COUNT, assetManager } from 'cc';
import { Service } from '../../../core/decorator';
import { loadAny } from '../../../node/node-create';
import GizmoBase from '../../base/gizmo-base';
import { TerrainEditor } from './terrain-editor';
import { eTerrainEditorMode } from './terrain-editor-mode';
import { TerrainBrushType, TerrainImageBrush } from './terrain-brush';
import type { GizmoMouseEvent } from '../../utils/defines';

interface IBrush { radius: number; strength: number; _setHeight: number; }
interface ITerrainInfo { tileSize: number; weightMapSize: number; lightMapSize: number; blockCount: number[]; }

/** Component gizmo containing all Terrain editing operations. */
export default class TerrainGizmo extends GizmoBase<Terrain> {
private _editor!: TerrainEditor;
private _isEditorInit = false;
private _isShiftDown = false;
private _isConcave = false;
private _isSmooth = false;
private _isFlatten = false;
private _isSetHeight = false;

public get editor() { return this._editor; }
public get isConcave() { return this._isConcave; }
public get isSmooth() { return this._isSmooth; }
public get isFlatten() { return this._isFlatten; }
public get isSetHeight() { return this._isSetHeight; }
public applySmooth(value: boolean) { this._isSmooth = value; }
public get isTerrainChange() { return !!this.target && Service.Terrain.isTerrainChange; }
public set isTerrainChange(value: boolean) {
if (this.target) { (this.target as any).manager = Service.Terrain; (this.target as any).isTerrainChange = value; }
if (value && this.target) Service.Terrain.select(this.target.node.uuid);
}

protected init() {
this._editor = new TerrainEditor((Service.Camera as any).getCamera?.() ?? null, this);
}
protected onShow() {
this.registerCameraMovedEvent(); this.initEditor(); this._editor.updateBlockDepthOffset();
}
protected onHide() {
this._isEditorInit = false; this.unregisterCameraMoveEvent();
this._editor?.clearBrush(); this._editor?.setEditTerrain(null); this._editor?.setCurrentLayer(0);
Service.Engine.repaintInEditMode();
}
public onTargetUpdate() { if (this._isInitialized) this.initEditor(); }
public onNodeChanged() { if (this._isInitialized) this.initEditor(); }
public onEditorCameraMoved() { this._editor?.updateBlockDepthOffset(); }
private initEditor() {
if (!this._isEditorInit && this._editor) {
this._editor.setEditTerrain(this.target); this._isEditorInit = true; Service.Engine.repaintInEditMode();
}
}

async addLayerByUuid(uuid: string) {
if (!this.target) return -1;
const texture = await loadAny<any>(uuid);
const layer = new TerrainLayer(); layer.detailMap = texture; layer.tileSize = 1;
const index = this.target.addLayer(layer); this.updateTerrainAsset(); this.isTerrainChange = true; this.emitNodeChange();
Service.Engine.repaintInEditMode(); return index;
}
async setSculptBrush(uuid: string) { return this.setBrushImage(eTerrainEditorMode.SCULPT, uuid); }
async setPaintBrush(uuid: string) { return this.setBrushImage(eTerrainEditorMode.PAINT, uuid); }
private async setBrushImage(mode: eTerrainEditorMode, uuid: string) {
const editMode: any = this._editor.getMode(mode);
if (!uuid) { editMode.setBrushImage(null); return true; }
const texture = await loadAny<any>(uuid);
const imageBrush = editMode.getBrush(TerrainBrushType.IMAGE) as TerrainImageBrush;
if (imageBrush.image !== texture) editMode.setBrushImage(texture);
this.isTerrainChange = true; Service.Engine.repaintInEditMode(); return true;
}
async setSculptBrushRotation(rotation: number) {
(this._editor.getMode(eTerrainEditorMode.SCULPT) as any).setSculptBrushRotation(rotation);
}
async setLayerValue(index: number, uuid: string, extVal: any) {
if (!this.target) return null;
const layer = this.target.getLayer(index); if (!layer) return null;
if (extVal) {
if ('tileSize' in extVal) layer.tileSize = extVal.tileSize;
if ('metallic' in extVal) layer.metallic = extVal.metallic;
if ('roughness' in extVal) layer.roughness = extVal.roughness;
if ('normalMap' in extVal) layer.normalMap = extVal.normalMap ? await loadAny<any>(extVal.normalMap) : null;
}
if (uuid) layer.detailMap = await loadAny<any>(uuid);
this.updateTerrainAsset(); this.isTerrainChange = true; this.emitNodeChange(); Service.Engine.repaintInEditMode();
return index;
}
removeLayerByIndex(index: number) {
if (!this.target) return;
this.target.removeLayer(index); this.updateTerrainAsset(); this.isTerrainChange = true; this.emitNodeChange(); Service.Engine.repaintInEditMode();
}
setCurrentEditLayer(index: number) { this._editor.setCurrentLayer(index); Service.Engine.repaintInEditMode(); }
getLayers() {
if (!this.target) return [];
return Array.from({ length: TERRAIN_MAX_LAYER_COUNT }, (_, index) => {
const layer = this.target!.getLayer(index);
return layer ? {
detailMap: layer.detailMap?._uuid ?? null, metallic: layer.metallic,
normalMap: layer.normalMap?._uuid ?? null, roughness: layer.roughness, tileSize: layer.tileSize,
} : null;
});
}
getCurrentEditLayer() { return this._editor.getCurrentLayer(); }
setCurrentEditMode(mode: eTerrainEditorMode, option?: any) {
const config = Object.assign({ isSculptDown: false, isSmooth: false, isFlatten: false, isSetHeight: false }, option || {});
this._isConcave = this._isShiftDown = !!config.isSculptDown; this._isSmooth = !!config.isSmooth;
this._isFlatten = !!config.isFlatten; this._isSetHeight = !!config.isSetHeight;
this._editor.setMode(mode); Service.Engine.repaintInEditMode();
}
queryTerrainInfo(): ITerrainInfo | null {
const info = this.target?.info; return info ? {
tileSize: info.tileSize, weightMapSize: info.weightMapSize, lightMapSize: info.lightMapSize, blockCount: [...info.blockCount],
} : null;
}
changeTerrainInfo(info: any) {
if (!this.target) return;
const terrainInfo = new TerrainInfo(); Object.assign(terrainInfo, info); this.target.rebuild(terrainInfo);
this.isTerrainChange = true; this.emitNodeChange(); Service.Engine.repaintInEditMode();
}
queryBrushOfMode(mode: eTerrainEditorMode): IBrush | null {
if (mode !== eTerrainEditorMode.SCULPT && mode !== eTerrainEditorMode.PAINT) return null;
const brush = (this._editor.getMode(mode) as any).getCurrentBrush();
return { radius: brush.radius, strength: brush.strength, _setHeight: brush._setHeight };
}
setBrushOfMode(mode: eTerrainEditorMode, setting: any) {
if (mode !== eTerrainEditorMode.SCULPT && mode !== eTerrainEditorMode.PAINT) return;
const brush = (this._editor.getMode(mode) as any).getCurrentBrush();
for (const key of Object.keys(setting || {})) if (key !== 'material' && setting[key] !== undefined) brush[key] = setting[key];
}
getBlockInfo() {
const mode = this._editor.getMode(eTerrainEditorMode.SELECT); const index = mode.getCurrentBlockIndex() ?? [0, 0];
const weight = mode.getCurrentWeightData();
return {
index: { x: index[0], y: index[1] },
weight: weight ? { data: Array.from(weight.data), width: weight.width, height: weight.height } : null,
layers: mode.getCurrentLayerList().map((layer: any) => layer?._uuid ?? ''),
};
}
emitNodeChange() { if (this.target) this.onComponentChanged(this.target.node); }
onKeyDown(event: any) { if (event.shiftKey) this._isShiftDown = true; }
onKeyUp(event: any) { if (event.keyCode === 16) this._isShiftDown = this._isConcave; }
onUpdate(deltaTime: number) { this._editor?.update(deltaTime, this._isShiftDown); }
onCameraControlModeChanged(mode: number) { if (mode !== 0 && this._editor?.isChanged) this.emitNodeChange(); }
updateTerrainAsset() { if (this.target?._asset) this.target.exportLayerListToAsset(this.target._asset); }

public onControllerMouseDown(event: GizmoMouseEvent) { event.propagationStopped = true; this._isShiftDown = event.shiftKey; this._editor.onMouseDown(event.x, event.y); }
public onControllerMouseMove(event: GizmoMouseEvent) { event.propagationStopped = true; this._editor.onMouseMove(event.x, event.y); }
public onControllerMouseUp(event: GizmoMouseEvent) { event.propagationStopped = true; if (this._editor.isChanged) this.emitNodeChange(); this._editor.onMouseUp(); }
public onControllerHoverOut() { this._editor.onHoverOut(); }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use strict';

import { js, Terrain } from 'cc';
import { registerGizmo } from '../../gizmo-defines';
import TerrainGizmo from './gizmo-select';
import TerrainPersistentGizmo from './gizmo-persistent';

export const name = js.getClassName(Terrain);
export const SelectGizmo = TerrainGizmo;
export const IconGizmo = null;
export const PersistentGizmo = TerrainPersistentGizmo;

registerGizmo(name, { SelectGizmo, PersistentGizmo });
Loading
Loading