From 3109211ff68e5ef5345a31cdc13dd2487ff8e5ab Mon Sep 17 00:00:00 2001 From: Marco Stagni Date: Mon, 11 May 2026 23:54:24 +0100 Subject: [PATCH 1/2] feat: layers and bounding boxes. Setting gizmos in separate layer, allowing bounding box to be overriden --- src/controls/Transform.js | 21 +++++++++-------- src/core/Level.js | 5 ++++ src/core/Scene.js | 48 ++++++++++++++++++++++++++++++++++++++- src/entities/Element.js | 23 +++++++++++++++++++ src/index.js | 6 +++++ src/physics/hitbox.js | 37 +++++++++++++++--------------- src/physics/index.js | 16 +++++++++++-- 7 files changed, 125 insertions(+), 31 deletions(-) diff --git a/src/controls/Transform.js b/src/controls/Transform.js index 9f1c6d50..aa38fc0c 100644 --- a/src/controls/Transform.js +++ b/src/controls/Transform.js @@ -29,10 +29,11 @@ export default class TransformControls extends Object3D { this.gizmo = new Gizmo(); this.plane = new Plane(); - // Set gizmo and plane to layer 1 ONLY so mirrors don't render them - // Main camera must enable layer 1 to see these - this.gizmo.layers.set(1); - this.plane.layers.set(1); + // Set gizmo and plane to layer 2 ONLY so they render in a dedicated pass + // on top of everything (sky, post-processing, etc.) + // Layer 1 = editor helpers (togglable), Layer 2 = gizmo (always visible) + this.gizmo.layers.set(2); + this.plane.layers.set(2); // Helper function to set depth properties on materials const setMaterialDepth = material => { @@ -46,15 +47,15 @@ export default class TransformControls extends Object3D { }); }; - // Also set layer 1 on all children recursively - // Set high renderOrder and disable depthTest so gizmos render on top of sky/water + // Also set layer 2 on all children recursively + // Disable depthTest so gizmos render on top of everything this.gizmo.traverse(child => { - child.layers.set(1); + child.layers.set(2); child.renderOrder = 999; setMaterialDepth(child.material); }); this.plane.traverse(child => { - child.layers.set(1); + child.layers.set(2); child.renderOrder = 999; setMaterialDepth(child.material); }); @@ -97,8 +98,8 @@ export default class TransformControls extends Object3D { this.setAndDispatch("showZ", true); this.ray = new Raycaster(); - // Enable layer 1 so raycaster can pick gizmo objects (which are on layer 1) - this.ray.layers.enable(1); + // Enable layer 2 so raycaster can pick gizmo objects (which are on layer 2) + this.ray.layers.enable(2); this._tempVector = new Vector3(); this._tempVector2 = new Vector3(); diff --git a/src/core/Level.js b/src/core/Level.js index 1f396bf2..ffe719e7 100644 --- a/src/core/Level.js +++ b/src/core/Level.js @@ -80,6 +80,11 @@ export class Level extends EventDispatcher { Scene.render(dt); } + // Render gizmo layer on top of everything (separate pass with depth clear) + // This ensures gizmos are always visible regardless of sky, post-processing, + // or the sortObjects=false setting used when shadows are enabled. + Scene.renderGizmoLayer(); + Particles.update(dt); this.onUpdate(dt); Scene.update(dt); diff --git a/src/core/Scene.js b/src/core/Scene.js index dab4d400..d1a1cb3e 100644 --- a/src/core/Scene.js +++ b/src/core/Scene.js @@ -199,10 +199,12 @@ export class Scene { createCamera(camera) { this.camera = camera; - // Enable layer 1 so camera can see editor-only objects (helpers, grid, gizmos) + // Enable layer 1 so camera can see editor-only objects (helpers, grid) + // Enable layer 2 so camera can see gizmos (rendered in separate pass) // Mirror cameras only use layer 0, so they won't render these if (this.camera && this.camera.getBody()) { this.camera.getBody().layers.enable(1); + this.camera.getBody().layers.enable(2); } } @@ -305,6 +307,50 @@ export class Scene { this.renderer.render(this.scene, this.camera.getBody()); } + // Render gizmo layer (layer 2) on top of everything. + // Called after main render or post-processing to ensure gizmos are always visible + // regardless of sky, post-processing, or sortObjects setting. + renderGizmoLayer() { + if (!this.renderer || !this.camera) return; + + const cameraBody = this.camera.getBody(); + + // Save current camera layer mask + const savedLayers = cameraBody.layers.mask; + + // Set camera to only see layer 2 (gizmo layer) + cameraBody.layers.set(2); + + // Ensure we render to screen (not a post-processing buffer) + this.renderer.setRenderTarget(null); + // Clear only depth so gizmo renders on top of the already-drawn frame + this.renderer.autoClear = false; + this.renderer.clearDepth(); + this.renderer.render(this.scene, cameraBody); + this.renderer.autoClear = true; + + // Restore camera layers + cameraBody.layers.mask = savedLayers; + } + + // Toggle editor helpers visibility (layer 1: grid, light helpers, camera helpers) + // Does NOT affect gizmos (layer 2) which are always visible + setHelpersVisible(visible) { + if (!this.camera || !this.camera.getBody()) return; + + const cameraBody = this.camera.getBody(); + if (visible) { + cameraBody.layers.enable(1); + } else { + cameraBody.layers.disable(1); + } + this.helpersVisible = visible; + } + + getHelpersVisible() { + return this.helpersVisible !== false; + } + setFog(color, density) { this.scene.fog = new FogExp2(color, density); Config.setConfig({ diff --git a/src/entities/Element.js b/src/entities/Element.js index 3446cc41..d8632d38 100644 --- a/src/entities/Element.js +++ b/src/entities/Element.js @@ -33,6 +33,7 @@ import { extractBiggestBoundingBox, extractBoundingSphere, extractBiggestBoundingSphere, + parseBoundingBoxSize, } from "../physics/utils"; import { clamp } from "../lib/math"; @@ -135,6 +136,27 @@ export default class Element extends Entity { } } + getComputedColliderSize() { + const result = {}; + + try { + if (this.boundingBox) { + const size = parseBoundingBoxSize(this.boundingBox); + const scale = this.getScale(); + result.width = parseFloat((size.x * scale.x).toFixed(3)); + result.height = parseFloat((size.y * scale.y).toFixed(3)); + result.length = parseFloat((size.z * scale.z).toFixed(3)); + } + if (this.boundingSphere) { + result.radius = parseFloat(this.boundingSphere.radius.toFixed(3)); + } + } catch { + // bounding box/sphere not yet available + } + + return result; + } + addToScene() { const { addUniverse = true } = this.options; @@ -1160,6 +1182,7 @@ export default class Element extends Entity { // Physics options (state is not used by Importer, only options) physics: { options: this.getPhysicsOptions(), + computedSize: this.getComputedColliderSize(), }, // Textures with serialized map textures: serializeMap(this.textures), diff --git a/src/index.js b/src/index.js index 7a29ab0d..7e3bb4c6 100644 --- a/src/index.js +++ b/src/index.js @@ -29,6 +29,9 @@ import Sound from "./audio/Sound"; import * as THREE from "three"; import { Vector3, EventDispatcher } from "three"; +import { LineSegments2 } from "three/examples/jsm/lines/LineSegments2.js"; +import { LineSegmentsGeometry } from "three/examples/jsm/lines/LineSegmentsGeometry.js"; +import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js"; import Level, { author } from "./core/Level"; import Universe from "./core/universe"; @@ -221,6 +224,9 @@ export { easing, Stats, THREE, + LineSegments2, + LineSegmentsGeometry, + LineMaterial, rxjs, xstate, map, diff --git a/src/physics/hitbox.js b/src/physics/hitbox.js index dfe70ed4..909d02bd 100644 --- a/src/physics/hitbox.js +++ b/src/physics/hitbox.js @@ -11,23 +11,23 @@ const DEFAULT_HITBOX_OPTIONS = { }; export const getBoxHitbox = element => { - const size = new Vector3(); - element.boundingBox.getSize(size); - - const scaledSize = { - x: size.x + HIT_BOX_INCREASE, - y: size.y + HIT_BOX_INCREASE, - z: size.z + HIT_BOX_INCREASE, - }; - const box = new Box( - scaledSize.x, - scaledSize.y, - scaledSize.z, - HIT_BOX_COLOR, - DEFAULT_HITBOX_OPTIONS, - ); - - //box.setQuaternion(quaternion); + const opts = element.getPhysicsOptions() || {}; + let w, h, l; + + if (opts.colliderWidth != null || opts.colliderHeight != null || opts.colliderLength != null) { + w = (opts.colliderWidth ?? 1) + HIT_BOX_INCREASE; + h = (opts.colliderHeight ?? 1) + HIT_BOX_INCREASE; + l = (opts.colliderLength ?? 1) + HIT_BOX_INCREASE; + } else { + const size = new Vector3(); + element.boundingBox.getSize(size); + w = size.x + HIT_BOX_INCREASE; + h = size.y + HIT_BOX_INCREASE; + l = size.z + HIT_BOX_INCREASE; + } + + const box = new Box(w, h, l, HIT_BOX_COLOR, DEFAULT_HITBOX_OPTIONS); + box.setWireframe(true); box.setWireframeLineWidth(2); @@ -35,7 +35,8 @@ export const getBoxHitbox = element => { }; export const getSphereHitbox = element => { - const radius = element.boundingSphere.radius; + const opts = element.getPhysicsOptions() || {}; + const radius = opts.colliderRadius != null ? opts.colliderRadius : element.boundingSphere.radius; const sphere = new Sphere(radius, HIT_BOX_COLOR, DEFAULT_HITBOX_OPTIONS); sphere.setWireframe(true); diff --git a/src/physics/index.js b/src/physics/index.js index 81a8e5e1..e489f5e0 100644 --- a/src/physics/index.js +++ b/src/physics/index.js @@ -215,14 +215,26 @@ export class Physics extends EventDispatcher { add(element, options = {}) { if (Config.physics().enabled) { - const { colliderType = COLLIDER_TYPES.BOX } = options; + const { + colliderType = COLLIDER_TYPES.BOX, + colliderWidth, + colliderHeight, + colliderLength, + colliderRadius, + ...rest + } = options; const uuid = element.uuid(); const description = { ...mapColliderTypeToDescription(colliderType)(element), - ...options, + ...rest, }; + if (colliderWidth != null) description.width = colliderWidth; + if (colliderHeight != null) description.height = colliderHeight; + if (colliderLength != null) description.length = colliderLength; + if (colliderRadius != null) description.radius = colliderRadius; + this.storeElement(element, options); this.worker.postMessage({ From 94c9232fdad99b4dbb56d58bd15124b2102a9b55 Mon Sep 17 00:00:00 2001 From: Marco Stagni Date: Mon, 11 May 2026 23:57:41 +0100 Subject: [PATCH 2/2] fix: linting issue --- src/physics/hitbox.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/physics/hitbox.js b/src/physics/hitbox.js index 909d02bd..5bde7952 100644 --- a/src/physics/hitbox.js +++ b/src/physics/hitbox.js @@ -36,7 +36,8 @@ export const getBoxHitbox = element => { export const getSphereHitbox = element => { const opts = element.getPhysicsOptions() || {}; - const radius = opts.colliderRadius != null ? opts.colliderRadius : element.boundingSphere.radius; + const radius = + opts.colliderRadius != null ? opts.colliderRadius : element.boundingSphere.radius; const sphere = new Sphere(radius, HIT_BOX_COLOR, DEFAULT_HITBOX_OPTIONS); sphere.setWireframe(true);