Frame the selection in the visible area between panels when focusing on it in the 3D editor (#9157)

This commit is contained in:
Aurélien Vivet
2026-09-29 16:48:03 +02:00
committed by GitHub
parent 71b9261f59
commit 75ce1cbc93
4 changed files with 289 additions and 39 deletions
+193 -4
View File
@@ -582,6 +582,13 @@ namespace gdjs {
return gdjs.Base3DHandler.is3D(object);
};
type ScreenArea = {
minX: float;
minY: float;
maxX: float;
maxY: float;
};
type AABB3D = {
min: Point3D;
max: Point3D;
@@ -1051,6 +1058,16 @@ namespace gdjs {
private _editedLayerDataList: LayerData[] = [];
private _selectedLayerName: string = '';
private _innerArea: AABB3D | null = null;
/**
* The part of the game frame that is not covered by the editor panels,
* in ratios of the game frame size.
*/
private _visibleScreenArea: ScreenArea = {
minX: 0,
minY: 0,
maxX: 1,
maxY: 1,
};
private _threeInnerArea: THREE.Object3D | null = null;
private _unregisterContextLostListener: (() => void) | null = null;
private _tempVector2d: THREE.Vector2 = new THREE.Vector2();
@@ -1756,6 +1773,7 @@ namespace gdjs {
maxX: number;
maxY: number;
}) {
this.setVisibleScreenArea(visibleScreenArea);
if (this._innerArea) {
this.zoomToFitArea(
{
@@ -1791,6 +1809,7 @@ namespace gdjs {
maxX: number;
maxY: number;
}) {
this.setVisibleScreenArea(visibleScreenArea);
const editedInstanceContainer = this.getEditedInstanceContainer();
if (!editedInstanceContainer) return;
@@ -1807,6 +1826,7 @@ namespace gdjs {
maxX: number;
maxY: number;
}) {
this.setVisibleScreenArea(visibleScreenArea);
this.zoomToFitObjects(
this._selection.getSelectedObjects(),
visibleScreenArea,
@@ -1983,13 +2003,20 @@ namespace gdjs {
this._getEditorCamera().switchToOrbitAroundObject(object);
}
setVisibleScreenArea(visibleScreenArea: ScreenArea) {
this._visibleScreenArea = visibleScreenArea;
}
private _focusOnSelection() {
// TODO Use the center of the AABB of the whole selection instead
const selectedObject = this._selection.getLastSelectedObject();
if (!selectedObject) {
const selectedObjects = this._selection.getSelectedObjects();
if (selectedObjects.length === 0) {
return;
}
this._getEditorCamera().switchToOrbitAroundObject(selectedObject);
this._getEditorCamera().frameObjectsInScreenArea(
selectedObjects,
this._visibleScreenArea,
0.1
);
}
private _handleCameraMovement() {
@@ -4917,6 +4944,168 @@ namespace gdjs {
this.onHasCameraChanged();
}
/**
* Move the camera, keeping its orientation, so that the AABB of the objects
* fits in the given area of the screen (e.g. the space between the editor panels).
*
* @param objects The objects to frame.
* @param screenArea The area where to frame the objects, in ratios of the screen size.
* @param margin The ratio of the screen area size kept empty on each side.
*/
frameObjectsInScreenArea(
objects: Array<RuntimeObject>,
screenArea: ScreenArea,
margin: float
): void {
if (objects.length === 0) return;
let minX = Number.MAX_VALUE;
let minY = Number.MAX_VALUE;
let minZ = Number.MAX_VALUE;
let maxX = -Number.MAX_VALUE;
let maxY = -Number.MAX_VALUE;
let maxZ = -Number.MAX_VALUE;
for (const object of objects) {
const aabb = object.getAABB();
minX = Math.min(minX, aabb.min[0]);
minY = Math.min(minY, aabb.min[1]);
minZ = Math.min(minZ, is3D(object) ? object.getUnrotatedAABBMinZ() : 0);
maxX = Math.max(maxX, aabb.max[0]);
maxY = Math.max(maxY, aabb.max[1]);
maxZ = Math.max(maxZ, is3D(object) ? object.getUnrotatedAABBMaxZ() : 0);
}
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
const centerZ = (minZ + maxZ) / 2;
// Keep the orientation of the camera that is currently used.
const activeCamera = this.getActiveCamera();
const yaw = gdjs.toRad(activeCamera.rotationAngle + 90);
const elevation = gdjs.toRad(activeCamera.elevationAngle);
const forward: Point3D = [
-Math.cos(yaw) * Math.cos(elevation),
-Math.sin(yaw) * Math.cos(elevation),
-Math.sin(elevation),
];
const right: Point3D = [Math.sin(yaw), -Math.cos(yaw), 0];
// up = forward x right
const up: Point3D = [
forward[1] * right[2] - forward[2] * right[1],
forward[2] * right[0] - forward[0] * right[2],
forward[0] * right[1] - forward[1] * right[0],
];
// Express the AABB corners in the camera basis, relatively to the AABB center.
const cornersRight: Array<float> = [];
const cornersUp: Array<float> = [];
const cornersDepth: Array<float> = [];
for (const cornerX of [minX, maxX]) {
for (const cornerY of [minY, maxY]) {
for (const cornerZ of [minZ, maxZ]) {
const deltaX = cornerX - centerX;
const deltaY = cornerY - centerY;
const deltaZ = cornerZ - centerZ;
cornersRight.push(
deltaX * right[0] + deltaY * right[1] + deltaZ * right[2]
);
cornersUp.push(deltaX * up[0] + deltaY * up[1] + deltaZ * up[2]);
cornersDepth.push(
deltaX * forward[0] + deltaY * forward[1] + deltaZ * forward[2]
);
}
}
}
// The screen area in normalized device coordinates (y going up), with the margin.
const areaWidth = screenArea.maxX - screenArea.minX;
const areaHeight = screenArea.maxY - screenArea.minY;
if (areaWidth <= 0 || areaHeight <= 0) return;
const ndcLeft = 2 * (screenArea.minX + margin * areaWidth) - 1;
const ndcRight = 2 * (screenArea.maxX - margin * areaWidth) - 1;
const ndcTop = 1 - 2 * (screenArea.minY + margin * areaHeight);
const ndcBottom = 1 - 2 * (screenArea.maxY - margin * areaHeight);
const runtimeGame = this.editor.getRuntimeGame();
const aspectRatio =
runtimeGame.getGameResolutionWidth() /
runtimeGame.getGameResolutionHeight();
const tanHalfVerticalFov = Math.tan(0.5 * gdjs.toRad(editorCameraFov));
const tanHalfHorizontalFov = tanHalfVerticalFov * aspectRatio;
// For a camera distance, the range of camera offsets (along one screen axis)
// that keeps every corner inside [ndcMin, ndcMax], or null if there is none.
const getOffsetRange = (
distance: float,
cornerOffsets: Array<float>,
tanHalfFov: float,
ndcMin: float,
ndcMax: float
): [float, float] | null => {
let offsetMin = -Number.MAX_VALUE;
let offsetMax = Number.MAX_VALUE;
for (let index = 0; index < cornerOffsets.length; index++) {
const halfExtent = (distance + cornersDepth[index]) * tanHalfFov;
offsetMin = Math.max(
offsetMin,
cornerOffsets[index] - ndcMax * halfExtent
);
offsetMax = Math.min(
offsetMax,
cornerOffsets[index] - ndcMin * halfExtent
);
}
return offsetMin <= offsetMax ? [offsetMin, offsetMax] : null;
};
const getOffsetRanges = (distance: float) => {
const rightRange = getOffsetRange(
distance,
cornersRight,
tanHalfHorizontalFov,
ndcLeft,
ndcRight
);
const upRange = getOffsetRange(
distance,
cornersUp,
tanHalfVerticalFov,
ndcBottom,
ndcTop
);
return rightRange && upRange ? { rightRange, upRange } : null;
};
// Find the smallest distance for which the corners fit (the fitting is monotonic).
const minimumDistance = 10;
let nearDistance = Math.max(
minimumDistance,
-Math.min(...cornersDepth) + minimumDistance
);
let farDistance = nearDistance;
while (!getOffsetRanges(farDistance) && farDistance < 1e9) {
nearDistance = farDistance;
farDistance *= 2;
}
for (let iteration = 0; iteration < 40; iteration++) {
const middleDistance = (nearDistance + farDistance) / 2;
if (getOffsetRanges(middleDistance)) {
farDistance = middleDistance;
} else {
nearDistance = middleDistance;
}
}
const offsetRanges = getOffsetRanges(farDistance);
if (!offsetRanges) return;
const rightOffset =
(offsetRanges.rightRange[0] + offsetRanges.rightRange[1]) / 2;
const upOffset = (offsetRanges.upRange[0] + offsetRanges.upRange[1]) / 2;
this.switchToOrbitAroundPosition(
centerX + rightOffset * right[0] + upOffset * up[0],
centerY + rightOffset * right[1] + upOffset * up[1],
centerZ + rightOffset * right[2] + upOffset * up[2]
);
this.setOrbitDistance(farDistance);
}
step(): void {
const runtimeGame = this.editor.getRuntimeGame();
const inputManager = runtimeGame.getInputManager();
@@ -499,6 +499,10 @@ namespace gdjs {
if (inGameEditor) {
inGameEditor.zoomToFitContent(data.payload.visibleScreenArea);
}
} else if (data.command === 'setVisibleScreenArea') {
if (inGameEditor) {
inGameEditor.setVisibleScreenArea(data.payload.visibleScreenArea);
}
} else if (data.command === 'setSelectedLayer') {
if (inGameEditor) {
inGameEditor.setSelectedLayerName(data.payload.layerName);
@@ -11,6 +11,7 @@ import { registerOpenedDialogsCountCallback } from '../UI/Dialog';
import {
getActiveEmbeddedGameFrameHoleRect,
registerActiveEmbeddedGameFrameHoleCountCallback,
registerEmbeddedGameFrameHoleResizeCallback,
} from './EmbeddedGameFrameHole';
import KeyboardShortcuts from '../UI/KeyboardShortcuts';
import { useInGameEditorSettings } from './InGameEditorSettings';
@@ -263,6 +264,57 @@ export const EmbeddedGameFrame = ({
iframe.contentWindow.focus();
}, []);
// Send the part of the game frame that is not covered by the editor panels,
// either with a command changing the view or so that the in-game editor
// can use it later (e.g. to focus on the selection).
const sendVisibleScreenArea = React.useCallback(
(command: ChangeViewPositionCommand | 'setVisibleScreenArea') => {
const iframe = iframeRef.current;
if (!iframe || !previewDebuggerServer) return;
const embeddedGameFrameRect = iframe.getBoundingClientRect();
const embeddedGameFrameHoleRect = getActiveEmbeddedGameFrameHoleRect();
if (
!embeddedGameFrameHoleRect ||
!embeddedGameFrameRect.width ||
!embeddedGameFrameRect.height
)
return;
const visibleScreenArea = {
minX:
(embeddedGameFrameHoleRect.left - embeddedGameFrameRect.left) /
embeddedGameFrameRect.width,
minY:
(embeddedGameFrameHoleRect.top - embeddedGameFrameRect.top) /
embeddedGameFrameRect.height,
maxX:
(embeddedGameFrameHoleRect.right - embeddedGameFrameRect.left) /
embeddedGameFrameRect.width,
maxY:
(embeddedGameFrameHoleRect.bottom - embeddedGameFrameRect.top) /
embeddedGameFrameRect.height,
};
previewDebuggerServer
.getExistingEmbeddedGameFrameDebuggerIds()
.forEach(debuggerId => {
previewDebuggerServer.sendMessage(debuggerId, {
command,
payload: { visibleScreenArea },
});
});
},
[previewDebuggerServer]
);
React.useEffect(
() =>
registerEmbeddedGameFrameHoleResizeCallback(() =>
sendVisibleScreenArea('setVisibleScreenArea')
),
[sendVisibleScreenArea]
);
const inGameEditorSettings = useInGameEditorSettings();
React.useEffect(
() => {
@@ -434,6 +486,7 @@ export const EmbeddedGameFrame = ({
editorCamera3D: cameraStates.current.get(editorId),
});
});
sendVisibleScreenArea('setVisibleScreenArea');
}
};
onSwitchInGameEditorIfNoHotReloadIsNeeded = ({
@@ -476,43 +529,10 @@ export const EmbeddedGameFrame = ({
cameraState3D: cameraStates.current.get(editorId),
});
});
sendVisibleScreenArea('setVisibleScreenArea');
};
onChangeViewPosition = (command: ChangeViewPositionCommand) => {
const iframe = iframeRef.current;
if (!iframe) return;
const embeddedGameFrameRect = iframe.getBoundingClientRect();
const embeddedGameFrameHoleRect = getActiveEmbeddedGameFrameHoleRect();
if (!embeddedGameFrameHoleRect || !embeddedGameFrameRect) return;
if (!previewDebuggerServer) return;
previewDebuggerServer
.getExistingEmbeddedGameFrameDebuggerIds()
.forEach(debuggerId => {
previewDebuggerServer.sendMessage(debuggerId, {
command,
payload: {
visibleScreenArea: {
minX:
(embeddedGameFrameHoleRect.left -
embeddedGameFrameRect.left) /
embeddedGameFrameRect.width,
minY:
(embeddedGameFrameHoleRect.top -
embeddedGameFrameRect.top) /
embeddedGameFrameRect.height,
maxX:
(embeddedGameFrameHoleRect.right -
embeddedGameFrameRect.left) /
embeddedGameFrameRect.width,
maxY:
(embeddedGameFrameHoleRect.bottom -
embeddedGameFrameRect.top) /
embeddedGameFrameRect.height,
},
},
});
});
sendVisibleScreenArea(command);
};
},
[
@@ -520,6 +540,7 @@ export const EmbeddedGameFrame = ({
previewIndexHtmlLocation,
onLaunchPreviewForInGameEdition,
enabled,
sendVisibleScreenArea,
]
);
@@ -31,6 +31,23 @@ const notifyActiveEmbeddedGameFrameHoleCountCallbacks = () => {
);
};
let embeddedGameFrameHoleResizeCallbacks: Array<() => void> = [];
export const registerEmbeddedGameFrameHoleResizeCallback = (
callback: () => void
): (() => void) => {
embeddedGameFrameHoleResizeCallbacks.push(callback);
return () => {
embeddedGameFrameHoleResizeCallbacks.splice(
embeddedGameFrameHoleResizeCallbacks.indexOf(callback),
1
);
};
};
const notifyEmbeddedGameFrameHoleResizeCallbacks = () => {
embeddedGameFrameHoleResizeCallbacks.forEach(callback => callback());
};
export const getActiveEmbeddedGameFrameHoleRect = (): ?ClientRect => {
// There is only one embedded game frame hole active at a time,
// so we don't need to check if the parent scene editor is active.
@@ -67,8 +84,27 @@ export const EmbeddedGameFrameHole = (props: Props): React.MixedElement => {
[props.isActive]
);
// Notify when the hole is resized (panels opened, closed or resized), so that
// the in-game editor knows the part of the game frame that is visible.
const holeRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(
() => {
const hole = holeRef.current;
if (!props.isActive || !hole || typeof ResizeObserver === 'undefined')
return;
const resizeObserver = new ResizeObserver(
notifyEmbeddedGameFrameHoleResizeCallbacks
);
resizeObserver.observe(hole);
return () => resizeObserver.disconnect();
},
[props.isActive]
);
return (
<div
ref={holeRef}
style={{
height: `calc(100% - ${props.marginBottom || 0}px)`,
display: 'flex',