From 2f2412c857903b3033404f4d2ccc4bb83ca32507 Mon Sep 17 00:00:00 2001 From: Tamely Date: Mon, 11 May 2026 11:31:05 -0500 Subject: [PATCH 01/12] Mesh hit testing --- Axiom/Session/MeshPicking.h | 97 +++++++++++++++++++++++++++++++ Editor/GlfwEditorLayer.cpp | 32 ++++++++++ Editor/GlfwEditorLayer.h | 1 + Headless/RemoteViewportServer.cpp | 91 ++++++++++++++++------------- 4 files changed, 182 insertions(+), 39 deletions(-) create mode 100644 Axiom/Session/MeshPicking.h diff --git a/Axiom/Session/MeshPicking.h b/Axiom/Session/MeshPicking.h new file mode 100644 index 00000000..aed422bb --- /dev/null +++ b/Axiom/Session/MeshPicking.h @@ -0,0 +1,97 @@ +#pragma once + +#include +#include + +#include +#include + +#include +#include +#include +#include + +namespace Axiom { + +// Returns the ObjectId of the closest mesh instance hit by a ray cast from the +// camera through MousePixel, or an empty string if no mesh is hit. Uses AABB +// slab intersection in object space to handle arbitrary transforms correctly. +inline std::string HitTestMeshes(const Camera &Cam, uint32_t VpWidth, + uint32_t VpHeight, glm::vec2 MousePixel, + const std::vector &Instances) { + if (VpWidth == 0 || VpHeight == 0 || Instances.empty()) { + return {}; + } + + const glm::vec3 RayOrigin = Cam.GetPosition(); + const float NdcX = (MousePixel.x / static_cast(VpWidth)) * 2.0f - 1.0f; + const float NdcY = (MousePixel.y / static_cast(VpHeight)) * 2.0f - 1.0f; + const glm::vec4 WorldH = + glm::inverse(Cam.GetViewProjectionMatrix()) * glm::vec4(NdcX, NdcY, 0.0f, 1.0f); + const glm::vec3 WorldPt = glm::vec3(WorldH) / WorldH.w; + const glm::vec3 RayDir = glm::normalize(WorldPt - RayOrigin); + + float BestT = std::numeric_limits::infinity(); + std::string BestId; + + for (const EditorSceneMeshInstance &Instance : Instances) { + if (Instance.Mesh.BoundsMin == Instance.Mesh.BoundsMax) { + continue; + } + + const glm::mat4 InvT = glm::inverse(Instance.Transform); + const glm::vec3 LocalOrigin = glm::vec3(InvT * glm::vec4(RayOrigin, 1.0f)); + const glm::vec3 LocalDirRaw = glm::vec3(InvT * glm::vec4(RayDir, 0.0f)); + const float LocalDirLen = glm::length(LocalDirRaw); + if (LocalDirLen < 1e-6f) { + continue; + } + const glm::vec3 LocalDir = LocalDirRaw / LocalDirLen; + + const glm::vec3 &BMin = Instance.Mesh.BoundsMin; + const glm::vec3 &BMax = Instance.Mesh.BoundsMax; + float TMin = 0.0f; + float TMax = std::numeric_limits::infinity(); + bool Miss = false; + + for (int Axis = 0; Axis < 3; ++Axis) { + const float D = LocalDir[Axis]; + const float O = LocalOrigin[Axis]; + if (glm::abs(D) < 1e-8f) { + if (O < BMin[Axis] || O > BMax[Axis]) { + Miss = true; + break; + } + } else { + const float InvD = 1.0f / D; + float T1 = (BMin[Axis] - O) * InvD; + float T2 = (BMax[Axis] - O) * InvD; + if (T1 > T2) { + std::swap(T1, T2); + } + TMin = std::max(TMin, T1); + TMax = std::min(TMax, T2); + if (TMin > TMax) { + Miss = true; + break; + } + } + } + if (Miss || TMax < 0.0f) { + continue; + } + + const float LocalT = (TMin >= 0.0f) ? TMin : TMax; + const glm::vec3 WorldHit = glm::vec3( + Instance.Transform * glm::vec4(LocalOrigin + LocalT * LocalDir, 1.0f)); + const float WorldT = glm::dot(WorldHit - RayOrigin, RayDir); + if (WorldT > 0.0f && WorldT < BestT) { + BestT = WorldT; + BestId = Instance.ObjectId; + } + } + + return BestId; +} + +} // namespace Axiom diff --git a/Editor/GlfwEditorLayer.cpp b/Editor/GlfwEditorLayer.cpp index 0b25b9ba..9fcf7199 100644 --- a/Editor/GlfwEditorLayer.cpp +++ b/Editor/GlfwEditorLayer.cpp @@ -5,7 +5,11 @@ #include #include +#define GLFW_INCLUDE_NONE +#include + #include +#include #include namespace Axiom { @@ -43,6 +47,34 @@ void GlfwEditorLayer::OnUpdate() { }); } m_Session.Tick(); + + // Left-click mesh picking — detect rising edge to select on click, not hold. + { + const bool IsLeftDown = m_WindowInputPlatform != nullptr && + m_WindowInputPlatform->IsMouseButtonPressed(GLFW_MOUSE_BUTTON_LEFT); + const bool ClickedNow = IsLeftDown && !m_LastLeftMouseDown; + m_LastLeftMouseDown = IsLeftDown; + + if (ClickedNow && Viewport != nullptr && !Viewport->IsLooking) { + const Window *Win = Application::Get().GetWindow(); + if (Win != nullptr) { + const glm::dvec2 CursorPos = m_WindowInputPlatform->GetCursorPosition(); + const std::string HitId = HitTestMeshes( + Viewport->Camera, Win->GetWidth(), Win->GetHeight(), + glm::vec2(CursorPos), m_Session.GetState().Scene.MeshInstances); + if (!HitId.empty()) { + const CommandContext Ctx{ + .Session = m_SessionId, + .User = m_LocalUserId, + .FrameIndex = Application::Get().GetFrameIndex(), + .DeltaTimeSeconds = Application::Get().GetDeltaTime(), + }; + m_Session.Submit(Ctx, EditorCommand{SelectObjectCommand{.ObjectId = HitId}}); + } + } + } + } + if (m_InputSource != nullptr) { m_InputSource->SyncViewport(m_Session.FindViewport(m_LocalUserId)); } diff --git a/Editor/GlfwEditorLayer.h b/Editor/GlfwEditorLayer.h index 730d1209..09e5984d 100644 --- a/Editor/GlfwEditorLayer.h +++ b/Editor/GlfwEditorLayer.h @@ -27,5 +27,6 @@ class GlfwEditorLayer final : public Layer { std::unique_ptr m_WindowInputPlatform; std::unique_ptr m_InputSource; float m_MoveSpeed{3.5f}; + bool m_LastLeftMouseDown{false}; }; } // namespace Axiom diff --git a/Headless/RemoteViewportServer.cpp b/Headless/RemoteViewportServer.cpp index 98d32be4..de592779 100644 --- a/Headless/RemoteViewportServer.cpp +++ b/Headless/RemoteViewportServer.cpp @@ -8,6 +8,7 @@ #include "GizmoHitTest.h" #include "HeadlessCommandProtocol.h" +#include #include #include #define STB_IMAGE_WRITE_IMPLEMENTATION @@ -2001,52 +2002,64 @@ bool RemoteViewportServer::HandleClientWebRtcMessage(std::string_view ClientId, m_Host.GetHeadlessLayer().GetSession(); const EditorViewportState *Viewport = Session.FindViewport(Client->User); + if (Viewport == nullptr) { + return true; + } const EditorObjectDetails *Selected = Session.FindSelectedObjectDetails(Client->User); const auto *DragTD = (Selected != nullptr && Selected->SupportsTransform) ? (Selected->WorldTransform.has_value() ? &*Selected->WorldTransform : (Selected->Transform.has_value() ? &*Selected->Transform : nullptr)) : nullptr; - if (Viewport == nullptr || DragTD == nullptr) { - return true; - } - const glm::vec3 &ObjPos = DragTD->Location; - const float GizmoScale = ComputeGizmoScale( - Viewport->Camera, ObjPos, m_Options.Width, m_Options.Height); - if (Client->CurrentGizmoMode == GizmoMode::Rotate) { - auto DragState = BeginGizmoRotateDrag( - Viewport->Camera, m_Options.Width, m_Options.Height, - Command->MousePosition, ObjPos, GizmoScale, ObjPos); - if (!DragState.has_value()) { - return true; - } - Client->GizmoDrag = ActiveGizmoDrag{ - .Math = *DragState, - .ObjectId = Selected->ObjectId, - .StartRotDeg = DragTD->RotationDegrees, - .StartScale = DragTD->Scale, - .Mode = GizmoMode::Rotate, - .GizmoScaleAtDragStart = GizmoScale, - }; - Session.AcquireLock(Selected->ObjectId, Client->User); - m_Host.GetHeadlessLayer().SetGizmoHoveredAxis(Client->User, DragState->Axis); - } else { - auto DragState = BeginGizmoDrag( - Viewport->Camera, m_Options.Width, m_Options.Height, - Command->MousePosition, ObjPos, GizmoScale, ObjPos); - if (!DragState.has_value()) { - return true; + + if (DragTD != nullptr) { + const glm::vec3 &ObjPos = DragTD->Location; + const float GizmoScale = ComputeGizmoScale( + Viewport->Camera, ObjPos, m_Options.Width, m_Options.Height); + if (Client->CurrentGizmoMode == GizmoMode::Rotate) { + auto DragState = BeginGizmoRotateDrag( + Viewport->Camera, m_Options.Width, m_Options.Height, + Command->MousePosition, ObjPos, GizmoScale, ObjPos); + if (DragState.has_value()) { + Client->GizmoDrag = ActiveGizmoDrag{ + .Math = *DragState, + .ObjectId = Selected->ObjectId, + .StartRotDeg = DragTD->RotationDegrees, + .StartScale = DragTD->Scale, + .Mode = GizmoMode::Rotate, + .GizmoScaleAtDragStart = GizmoScale, + }; + Session.AcquireLock(Selected->ObjectId, Client->User); + m_Host.GetHeadlessLayer().SetGizmoHoveredAxis(Client->User, DragState->Axis); + return true; + } + } else { + auto DragState = BeginGizmoDrag( + Viewport->Camera, m_Options.Width, m_Options.Height, + Command->MousePosition, ObjPos, GizmoScale, ObjPos); + if (DragState.has_value()) { + Client->GizmoDrag = ActiveGizmoDrag{ + .Math = *DragState, + .ObjectId = Selected->ObjectId, + .StartRotDeg = DragTD->RotationDegrees, + .StartScale = DragTD->Scale, + .Mode = Client->CurrentGizmoMode, + .GizmoScaleAtDragStart = GizmoScale, + }; + Session.AcquireLock(Selected->ObjectId, Client->User); + m_Host.GetHeadlessLayer().SetGizmoHoveredAxis(Client->User, DragState->Axis); + return true; + } } - Client->GizmoDrag = ActiveGizmoDrag{ - .Math = *DragState, - .ObjectId = Selected->ObjectId, - .StartRotDeg = DragTD->RotationDegrees, - .StartScale = DragTD->Scale, - .Mode = Client->CurrentGizmoMode, - .GizmoScaleAtDragStart = GizmoScale, - }; - Session.AcquireLock(Selected->ObjectId, Client->User); - m_Host.GetHeadlessLayer().SetGizmoHoveredAxis(Client->User, DragState->Axis); + } + + // No gizmo hit — fall back to mesh picking. + const std::string HitId = HitTestMeshes( + Viewport->Camera, m_Options.Width, m_Options.Height, + Command->MousePosition, Session.GetState().Scene.MeshInstances); + if (!HitId.empty()) { + m_Host.SubmitRemoteCommand(Client->User, + EditorCommand{SelectObjectCommand{.ObjectId = HitId}}); } return true; } From 416ea1affc38fc526aed68c689047eb8fbcdcabb Mon Sep 17 00:00:00 2001 From: Tamely Date: Tue, 12 May 2026 00:38:20 -0500 Subject: [PATCH 02/12] Drag and drop textures onto meshes --- .../components/engine/content-browser.tsx | 12 ++++++ EditorFrontend/components/engine/viewport.tsx | 43 ++++++++++++++++++- Headless/HeadlessCommandProtocol.cpp | 20 +++++++++ Headless/HeadlessCommandProtocol.h | 1 + Headless/RemoteViewportServer.cpp | 36 ++++++++++++++++ 5 files changed, 110 insertions(+), 2 deletions(-) diff --git a/EditorFrontend/components/engine/content-browser.tsx b/EditorFrontend/components/engine/content-browser.tsx index 1d768ede..dd96edd4 100644 --- a/EditorFrontend/components/engine/content-browser.tsx +++ b/EditorFrontend/components/engine/content-browser.tsx @@ -444,6 +444,12 @@ export function ContentBrowser() { e.dataTransfer.setData("axiom/asset-path", asset.path) e.dataTransfer.setData("axiom/asset-kind", asset.kind) e.dataTransfer.effectAllowed = "copy" + ;(window as any).__axiomDragAsset = { kind: asset.kind, path: asset.path } + }} + onDragEnd={(e) => { + ;(window as any).__axiomDragAsset = null + const h = (window as any).__axiomViewportDropHandler + if (h) h(e.clientX, e.clientY, asset.kind, asset.path) }} title={ canAssignToSelection && asset.kind === "mesh" @@ -496,6 +502,12 @@ export function ContentBrowser() { e.dataTransfer.setData("axiom/asset-path", asset.path) e.dataTransfer.setData("axiom/asset-kind", asset.kind) e.dataTransfer.effectAllowed = "copy" + ;(window as any).__axiomDragAsset = { kind: asset.kind, path: asset.path } + }} + onDragEnd={(e) => { + ;(window as any).__axiomDragAsset = null + const h = (window as any).__axiomViewportDropHandler + if (h) h(e.clientX, e.clientY, asset.kind, asset.path) }} title={ canAssignToSelection && asset.kind === "mesh" diff --git a/EditorFrontend/components/engine/viewport.tsx b/EditorFrontend/components/engine/viewport.tsx index d737d285..7612495e 100644 --- a/EditorFrontend/components/engine/viewport.tsx +++ b/EditorFrontend/components/engine/viewport.tsx @@ -201,6 +201,12 @@ type RemoteViewportCommand = objectId: string textureAssetPath: string } + | { + type: "drop_texture" + mouseX: number + mouseY: number + textureAssetPath: string + } function getServerOrigin() { const configuredOrigin = process.env.NEXT_PUBLIC_AXIOM_SERVER_ORIGIN?.trim() @@ -1787,8 +1793,37 @@ export function Viewport() { void destroyPeerConnection("page_unload") } + let lastDragX = 0 + let lastDragY = 0 + + const handleDocDragOver = (event: DragEvent) => { + lastDragX = event.clientX + lastDragY = event.clientY + } + + ;(window as any).__axiomViewportDropHandler = (clientX: number, clientY: number, kind: string, path: string) => { + if (kind !== "texture" || !path) return + const x = lastDragX || clientX + const y = lastDragY || clientY + const s = viewportShellRef.current + const v = videoRef.current + if (!s || !v || !v.videoWidth || !v.videoHeight) return + const rect = s.getBoundingClientRect() + if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return + const scale = Math.min(rect.width / v.videoWidth, rect.height / v.videoHeight) + const contentW = v.videoWidth * scale + const contentH = v.videoHeight * scale + const cssX = x - rect.left - (rect.width - contentW) / 2 + const cssY = y - rect.top - (rect.height - contentH) / 2 + if (cssX < 0 || cssY < 0 || cssX > contentW || cssY > contentH) return + const mouseX = (cssX / contentW) * v.videoWidth + const mouseY = (cssY / contentH) * v.videoHeight + void sendCommand({ type: "drop_texture", mouseX, mouseY, textureAssetPath: path }, "reliable") + } + video?.addEventListener("loadedmetadata", handleLoadedMetadata) video?.addEventListener("resize", handleResize) + document.addEventListener("dragover", handleDocDragOver) document.addEventListener("mousedown", handleMouseDown) document.addEventListener("mouseup", handleMouseUp) document.addEventListener("contextmenu", handleContextMenu) @@ -1796,7 +1831,6 @@ export function Viewport() { document.addEventListener("keydown", handleKeyDown) document.addEventListener("keyup", handleKeyUp) window.addEventListener("beforeunload", handleBeforeUnload) - void connect() return () => { @@ -1805,6 +1839,8 @@ export function Viewport() { stopViewportInputPump() video?.removeEventListener("loadedmetadata", handleLoadedMetadata) video?.removeEventListener("resize", handleResize) + document.removeEventListener("dragover", handleDocDragOver) + ;(window as any).__axiomViewportDropHandler = null document.removeEventListener("mousedown", handleMouseDown) document.removeEventListener("mouseup", handleMouseUp) document.removeEventListener("contextmenu", handleContextMenu) @@ -1892,7 +1928,10 @@ export function Viewport() { void connectRef.current()} /> -
+
- - - void toggleLook()} /> void connectRef.current()} />
diff --git a/Headless/AxiomHeadless.cpp b/Headless/AxiomHeadless.cpp index c732b53b..3f17207d 100644 --- a/Headless/AxiomHeadless.cpp +++ b/Headless/AxiomHeadless.cpp @@ -143,6 +143,11 @@ int main(int argc, char **argv) { App.Step(); Subscriber.DiscardLatestFrame(); break; + case Axiom::HeadlessCommandType::SetGridSnap: + std::cout << Axiom::SerializeError( + "`set_grid_snap` is only supported by the remote viewport server.") + << std::endl; + break; case Axiom::HeadlessCommandType::RenderFrame: { if (!SceneLoaded) { std::cout << Axiom::SerializeError( diff --git a/Headless/DevRemoteViewportClient.cpp b/Headless/DevRemoteViewportClient.cpp index e9287430..d7395c92 100644 --- a/Headless/DevRemoteViewportClient.cpp +++ b/Headless/DevRemoteViewportClient.cpp @@ -153,6 +153,11 @@ int main(int argc, char **argv) { Host.Step(); Subscriber.DiscardLatestFrame(); break; + case Axiom::HeadlessCommandType::SetGridSnap: + std::cout << Axiom::SerializeError( + "`set_grid_snap` is only supported by the remote viewport server.") + << std::endl; + break; case Axiom::HeadlessCommandType::RenderFrame: { if (!SceneLoaded) { std::cout << Axiom::SerializeError( diff --git a/Headless/HeadlessCommandProtocol.cpp b/Headless/HeadlessCommandProtocol.cpp index 210423b6..9dbb02df 100644 --- a/Headless/HeadlessCommandProtocol.cpp +++ b/Headless/HeadlessCommandProtocol.cpp @@ -964,6 +964,13 @@ std::optional ParseHeadlessCommand(std::string_view JsonLine, } return HeadlessCommand{.Type = HeadlessCommandType::SetGizmoMode, .Mode = Mode}; } + if (*Type == "set_grid_snap") { + static const std::regex EnabledPattern(R"json("enabled"\s*:\s*(true|false))json"); + const auto EnabledStr = MatchString(JsonLine, EnabledPattern); + return HeadlessCommand{ + .Type = HeadlessCommandType::SetGridSnap, + .Enabled = EnabledStr.value_or("false") == "true"}; + } Error = "Unsupported command type: " + *Type; return std::nullopt; @@ -994,6 +1001,7 @@ ParseRemoteViewportCommand(std::string_view JsonLine, std::string &Error) { case HeadlessCommandType::GizmoDragUpdate: case HeadlessCommandType::GizmoDragEnd: case HeadlessCommandType::SetGizmoMode: + case HeadlessCommandType::SetGridSnap: case HeadlessCommandType::ListAssets: case HeadlessCommandType::GetSchema: case HeadlessCommandType::SetProperty: diff --git a/Headless/HeadlessCommandProtocol.h b/Headless/HeadlessCommandProtocol.h index 7c23c69e..76328076 100644 --- a/Headless/HeadlessCommandProtocol.h +++ b/Headless/HeadlessCommandProtocol.h @@ -41,6 +41,7 @@ enum class HeadlessCommandType { GizmoDragUpdate, GizmoDragEnd, SetGizmoMode, + SetGridSnap, ListAssets, GetSchema, SetProperty, @@ -67,6 +68,7 @@ struct HeadlessCommand { RendererViewMode ViewMode{RendererViewMode::Lit}; glm::vec2 MousePosition{0.0f}; GizmoMode Mode{GizmoMode::Translate}; + bool Enabled{false}; std::string ObjectId; std::string PropertyName; std::optional PropertyVal; diff --git a/Headless/RemoteViewportServer.cpp b/Headless/RemoteViewportServer.cpp index 99999eca..6affebc9 100644 --- a/Headless/RemoteViewportServer.cpp +++ b/Headless/RemoteViewportServer.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -225,6 +226,39 @@ std::optional GetQueryParam(std::string_view Path, return std::nullopt; } +constexpr float kTranslationSnapStep = 1.0f; +constexpr float kRotationSnapStepDegrees = 15.0f; +constexpr float kScaleSnapStep = 0.1f; +constexpr float kMinimumScale = 0.001f; + +float SnapToStep(float value, float step) { + if (step <= 0.0f) { + return value; + } + return std::round(value / step) * step; +} + +void ApplyGridSnap(bool gridSnapEnabled, GizmoMode mode, int axis, + glm::vec3 &location, glm::vec3 &rotationDegrees, + glm::vec3 &scale) { + if (!gridSnapEnabled || axis < 0 || axis > 2) { + return; + } + + switch (mode) { + case GizmoMode::Translate: + location[axis] = SnapToStep(location[axis], kTranslationSnapStep); + break; + case GizmoMode::Rotate: + rotationDegrees[axis] = + SnapToStep(rotationDegrees[axis], kRotationSnapStepDegrees); + break; + case GizmoMode::Scale: + scale[axis] = std::max(kMinimumScale, SnapToStep(scale[axis], kScaleSnapStep)); + break; + } +} + std::string Trim(std::string_view Value) { while (!Value.empty() && std::isspace(static_cast(Value.front())) != 0) { @@ -984,6 +1018,7 @@ bool RemoteViewportServer::HandlePostRequest(uintptr_t ClientSocketValue, case HeadlessCommandType::GizmoDragUpdate: case HeadlessCommandType::GizmoDragEnd: case HeadlessCommandType::SetGizmoMode: + case HeadlessCommandType::SetGridSnap: break; case HeadlessCommandType::Heartbeat: case HeadlessCommandType::ListAssets: @@ -1837,6 +1872,7 @@ bool RemoteViewportServer::HandleWebSocketMessage(uintptr_t ClientSocketValue, case HeadlessCommandType::GizmoDragUpdate: case HeadlessCommandType::GizmoDragEnd: case HeadlessCommandType::SetGizmoMode: + case HeadlessCommandType::SetGridSnap: case HeadlessCommandType::Heartbeat: return false; case HeadlessCommandType::ListAssets: { @@ -2027,6 +2063,9 @@ bool RemoteViewportServer::HandleClientWebRtcMessage(std::string_view ClientId, Client->CurrentGizmoMode = Command->Mode; m_Host.GetHeadlessLayer().SetGizmoMode(Client->User, Command->Mode); return true; + case HeadlessCommandType::SetGridSnap: + Client->GridSnapEnabled = Command->Enabled; + return true; case HeadlessCommandType::GizmoHover: { if (Client->GizmoDrag.has_value()) { return true; @@ -2168,6 +2207,8 @@ bool RemoteViewportServer::HandleClientWebRtcMessage(std::string_view ClientId, Command->MousePosition.x, Command->MousePosition.y); RotDeg[Drag.Math.Axis] = Drag.StartRotDeg[Drag.Math.Axis] + DeltaDeg; } + ApplyGridSnap(Client->GridSnapEnabled, Drag.Mode, Drag.Math.Axis, Location, + RotDeg, Scale); EditorCommand Cmd; Cmd.Payload = SetTransformCommand{ .ObjectId = Drag.ObjectId, @@ -2211,6 +2252,8 @@ bool RemoteViewportServer::HandleClientWebRtcMessage(std::string_view ClientId, Command->MousePosition.x, Command->MousePosition.y); RotDeg[Drag.Math.Axis] = Drag.StartRotDeg[Drag.Math.Axis] + DeltaDeg; } + ApplyGridSnap(Client->GridSnapEnabled, Drag.Mode, Drag.Math.Axis, Location, + RotDeg, Scale); EditorCommand Cmd; Cmd.Payload = SetTransformCommand{ .ObjectId = Drag.ObjectId, diff --git a/Headless/RemoteViewportServer.h b/Headless/RemoteViewportServer.h index 2a89080a..4369b39a 100644 --- a/Headless/RemoteViewportServer.h +++ b/Headless/RemoteViewportServer.h @@ -73,6 +73,7 @@ class RemoteViewportServer final : public ISessionTransportSubscriber { std::unique_ptr VideoPacketOutput; std::optional GizmoDrag; GizmoMode CurrentGizmoMode{GizmoMode::Translate}; + bool GridSnapEnabled{false}; }; struct ClientSessionResolution { diff --git a/Tests/HeadlessProtocolTests.cpp b/Tests/HeadlessProtocolTests.cpp index 8cefa9bf..e03bba5f 100644 --- a/Tests/HeadlessProtocolTests.cpp +++ b/Tests/HeadlessProtocolTests.cpp @@ -155,6 +155,16 @@ TEST(HeadlessProtocolTests, RemoteViewportAcceptsSetTransformCommand) { EXPECT_EQ(Payload->Scale, glm::vec3(1.0f, 1.5f, 2.0f)); } +TEST(HeadlessProtocolTests, RemoteViewportAcceptsSetGridSnapCommand) { + std::string Error; + const auto Command = Axiom::ParseRemoteViewportCommand( + R"json({"type":"set_grid_snap","enabled":true})json", Error); + + ASSERT_TRUE(Command.has_value()) << Error; + EXPECT_EQ(Command->Type, Axiom::HeadlessCommandType::SetGridSnap); + EXPECT_TRUE(Command->Enabled); +} + TEST(HeadlessProtocolTests, SerializesCommandRejectedEvent) { const Axiom::PublishedEditorEvent Event{ .Id = Axiom::EventId{4}, From e6521cf75786ab77bb2ecdffc6974b8d17e165f4 Mon Sep 17 00:00:00 2001 From: Tamely Date: Tue, 12 May 2026 16:33:37 -0500 Subject: [PATCH 09/12] Grid snap settings --- .../engine/remote-viewport-context.tsx | 39 ++++-- EditorFrontend/components/engine/toolbar.tsx | 125 ++++++++++++++++-- EditorFrontend/components/engine/viewport.tsx | 17 ++- Headless/HeadlessCommandProtocol.cpp | 21 ++- Headless/HeadlessCommandProtocol.h | 3 + Headless/RemoteViewportServer.cpp | 35 ++--- Headless/RemoteViewportServer.h | 9 +- Tests/HeadlessProtocolTests.cpp | 6 +- 8 files changed, 215 insertions(+), 40 deletions(-) diff --git a/EditorFrontend/components/engine/remote-viewport-context.tsx b/EditorFrontend/components/engine/remote-viewport-context.tsx index a2cd0641..a34ceb02 100644 --- a/EditorFrontend/components/engine/remote-viewport-context.tsx +++ b/EditorFrontend/components/engine/remote-viewport-context.tsx @@ -34,6 +34,13 @@ export type RemoteViewportViewMode = "lit" | "unlit" | "wireframe" export type RemoteViewportGizmoMode = "translate" | "scale" | "rotate" export type SessionSceneItemKind = "folder" | "mesh" | "light" | "camera" | "actor" +export interface RemoteViewportGridSnapSettings { + enabled: boolean + translationStep: number + rotationStepDegrees: number + scaleStep: number +} + export interface SessionAssetDescriptor { id: number name: string @@ -130,7 +137,7 @@ interface RemoteViewportActions { toggleLook: () => Promise setMode: (mode: RemoteViewportViewMode) => Promise setGizmoMode: (mode: RemoteViewportGizmoMode) => Promise - setGridSnapEnabled: (enabled: boolean) => Promise + setGridSnapSettings: (settings: RemoteViewportGridSnapSettings) => Promise refreshSessionSnapshot: () => Promise selectObject: (objectId: string) => Promise renameObject: (objectId: string, displayName: string) => Promise @@ -182,7 +189,7 @@ interface RemoteViewportContextValue { sessionDetailText: string viewMode: RemoteViewportViewMode gizmoMode: RemoteViewportGizmoMode - gridSnapEnabled: boolean + gridSnapSettings: RemoteViewportGridSnapSettings isLooking: boolean eventLog: string[] serverOrigin: string @@ -256,7 +263,7 @@ interface RemoteViewportContextValue { toggleLook: () => Promise setMode: (mode: RemoteViewportViewMode) => Promise setGizmoMode: (mode: RemoteViewportGizmoMode) => Promise - setGridSnapEnabled: (enabled: boolean) => Promise + setGridSnapSettings: (settings: RemoteViewportGridSnapSettings) => Promise refreshSessionSnapshot: () => Promise selectObject: (objectId: string) => Promise renameObject: (objectId: string, displayName: string) => Promise @@ -301,12 +308,19 @@ function findSceneItem(items: SessionSceneItem[], objectId: string | null): Sess } export function RemoteViewportProvider({ children }: { children: ReactNode }) { + const defaultGridSnapSettings: RemoteViewportGridSnapSettings = { + enabled: true, + translationStep: 1, + rotationStepDegrees: 15, + scaleStep: 0.1, + } + const actionsRef = useRef({ reconnect: async () => {}, toggleLook: async () => {}, setMode: async () => {}, setGizmoMode: async () => {}, - setGridSnapEnabled: async () => {}, + setGridSnapSettings: async () => {}, refreshSessionSnapshot: async () => {}, selectObject: async () => false, renameObject: async () => false, @@ -339,7 +353,8 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { ) const [viewMode, setViewMode] = useState("lit") const [gizmoMode, setGizmoModeState] = useState("translate") - const [gridSnapEnabled, setGridSnapEnabledState] = useState(false) + const [gridSnapSettings, setGridSnapSettingsState] = + useState(defaultGridSnapSettings) const [isLooking, setIsLooking] = useState(false) const [eventLog, setEventLog] = useState([]) const [serverOrigin, setServerOrigin] = useState("") @@ -452,9 +467,9 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { await actionsRef.current.setGizmoMode(mode) }, []) - const setGridSnapEnabled = useCallback(async (enabled: boolean) => { - setGridSnapEnabledState(enabled) - await actionsRef.current.setGridSnapEnabled(enabled) + const setGridSnapSettings = useCallback(async (settings: RemoteViewportGridSnapSettings) => { + setGridSnapSettingsState(settings) + await actionsRef.current.setGridSnapSettings(settings) }, []) const refreshSessionSnapshot = useCallback(async () => { @@ -578,7 +593,7 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { sessionDetailText, viewMode, gizmoMode, - gridSnapEnabled, + gridSnapSettings, isLooking, eventLog, serverOrigin, @@ -632,7 +647,7 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { toggleLook, setMode, setGizmoMode: setGizmoModeAction, - setGridSnapEnabled, + setGridSnapSettings, refreshSessionSnapshot, selectObject, renameObject, @@ -658,7 +673,7 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { frameText, isLooking, participants, - gridSnapEnabled, + gridSnapSettings, sessionDetailText, sessionState, sessionStatusText, @@ -695,7 +710,7 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { gizmoMode, setMode, setGizmoModeAction, - setGridSnapEnabled, + setGridSnapSettings, setSessionDetailText, setSessionState, setSessionSnapshot, diff --git a/EditorFrontend/components/engine/toolbar.tsx b/EditorFrontend/components/engine/toolbar.tsx index d60c153d..d850f699 100644 --- a/EditorFrontend/components/engine/toolbar.tsx +++ b/EditorFrontend/components/engine/toolbar.tsx @@ -22,16 +22,33 @@ import { Sun, Camera, RefreshCw, + ChevronDown, } from "lucide-react" -import { useRemoteViewport } from "./remote-viewport-context" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + useRemoteViewport, + type RemoteViewportGridSnapSettings, +} from "./remote-viewport-context" import { PresenceRoster } from "./presence-roster" +const TRANSLATION_SNAP_OPTIONS = [0.25, 0.5, 1, 5] as const +const ROTATION_SNAP_OPTIONS = [5, 10, 15, 45] as const +const SCALE_SNAP_OPTIONS = [0.01, 0.05, 0.1, 0.25] as const + export function Toolbar() { const { gizmoMode, - gridSnapEnabled, + gridSnapSettings, setGizmoMode, - setGridSnapEnabled, + setGridSnapSettings, saveScene, saveStatus, setSaveStatus, @@ -119,11 +136,9 @@ export function Toolbar() { - void setGridSnapEnabled(!gridSnapEnabled)} + void setGridSnapSettings(settings)} /> @@ -153,6 +168,100 @@ export function Toolbar() { ) } +function GridSnapDropdown({ + settings, + onChange, +}: { + settings: RemoteViewportGridSnapSettings + onChange: (settings: RemoteViewportGridSnapSettings) => void +}) { + return ( + + + + + + + Move Snap + + + onChange({ + ...settings, + enabled: value !== "0", + translationStep: value === "0" ? settings.translationStep : Number(value), + }) + } + > + Off + {TRANSLATION_SNAP_OPTIONS.map((value) => ( + + {formatSnapValue(value)} units + + ))} + + + + + Rotate Snap + + + onChange({ + ...settings, + rotationStepDegrees: Number(value), + }) + } + > + {ROTATION_SNAP_OPTIONS.map((value) => ( + + {value} degrees + + ))} + + + + + Scale Snap + + + onChange({ + ...settings, + scaleStep: Number(value), + }) + } + > + {SCALE_SNAP_OPTIONS.map((value) => ( + + {formatSnapValue(value)} scale + + ))} + + + + ) +} + +function formatSnapValue(value: number) { + return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/0$/, "") +} + function ToolbarGroup({ children }: { children: React.ReactNode }) { return
{children}
} diff --git a/EditorFrontend/components/engine/viewport.tsx b/EditorFrontend/components/engine/viewport.tsx index cbb3f0f7..06344a97 100644 --- a/EditorFrontend/components/engine/viewport.tsx +++ b/EditorFrontend/components/engine/viewport.tsx @@ -15,6 +15,7 @@ import { } from "@/components/ui/dropdown-menu" import { useRemoteViewport, + type RemoteViewportGridSnapSettings, type SessionObjectTransformUpdate, type SessionObjectDetails, type RemoteViewportConnectionState, @@ -168,6 +169,9 @@ type RemoteViewportCommand = | { type: "set_grid_snap" enabled: boolean + translationStep: number + rotationStepDegrees: number + scaleStep: number } | { type: "heartbeat" } | { type: "list_assets" } @@ -1622,8 +1626,17 @@ export function Viewport() { gizmoModeRef.current = nextMode await sendCommand({ type: "set_gizmo_mode", mode: nextMode }, "reliable") }, - setGridSnapEnabled: async (enabled) => { - await sendCommand({ type: "set_grid_snap", enabled }, "reliable") + setGridSnapSettings: async (settings: RemoteViewportGridSnapSettings) => { + await sendCommand( + { + type: "set_grid_snap", + enabled: settings.enabled, + translationStep: settings.translationStep, + rotationStepDegrees: settings.rotationStepDegrees, + scaleStep: settings.scaleStep, + }, + "reliable" + ) }, }) setServerOrigin(serverOrigin) diff --git a/Headless/HeadlessCommandProtocol.cpp b/Headless/HeadlessCommandProtocol.cpp index 9dbb02df..2a5759d6 100644 --- a/Headless/HeadlessCommandProtocol.cpp +++ b/Headless/HeadlessCommandProtocol.cpp @@ -966,10 +966,29 @@ std::optional ParseHeadlessCommand(std::string_view JsonLine, } if (*Type == "set_grid_snap") { static const std::regex EnabledPattern(R"json("enabled"\s*:\s*(true|false))json"); + static const std::regex TranslationStepPattern( + R"json("translationStep"\s*:\s*(-?[0-9Ee.+-]+))json"); + static const std::regex RotationStepPattern( + R"json("rotationStepDegrees"\s*:\s*(-?[0-9Ee.+-]+))json"); + static const std::regex ScaleStepPattern( + R"json("scaleStep"\s*:\s*(-?[0-9Ee.+-]+))json"); const auto EnabledStr = MatchString(JsonLine, EnabledPattern); + auto ParseScalar = [&](const std::regex &pattern, double fallback) { + std::match_results match; + if (std::regex_search(JsonLine.begin(), JsonLine.end(), match, pattern)) { + if (const auto value = + ParseDouble(std::string_view(match[1].first, match[1].second))) { + return static_cast(*value); + } + } + return static_cast(fallback); + }; return HeadlessCommand{ .Type = HeadlessCommandType::SetGridSnap, - .Enabled = EnabledStr.value_or("false") == "true"}; + .Enabled = EnabledStr.value_or("false") == "true", + .TranslationStep = ParseScalar(TranslationStepPattern, 1.0), + .RotationStepDegrees = ParseScalar(RotationStepPattern, 15.0), + .ScaleStep = ParseScalar(ScaleStepPattern, 0.1)}; } Error = "Unsupported command type: " + *Type; diff --git a/Headless/HeadlessCommandProtocol.h b/Headless/HeadlessCommandProtocol.h index 76328076..921cabc5 100644 --- a/Headless/HeadlessCommandProtocol.h +++ b/Headless/HeadlessCommandProtocol.h @@ -69,6 +69,9 @@ struct HeadlessCommand { glm::vec2 MousePosition{0.0f}; GizmoMode Mode{GizmoMode::Translate}; bool Enabled{false}; + float TranslationStep{1.0f}; + float RotationStepDegrees{15.0f}; + float ScaleStep{0.1f}; std::string ObjectId; std::string PropertyName; std::optional PropertyVal; diff --git a/Headless/RemoteViewportServer.cpp b/Headless/RemoteViewportServer.cpp index 6affebc9..62f5242f 100644 --- a/Headless/RemoteViewportServer.cpp +++ b/Headless/RemoteViewportServer.cpp @@ -226,9 +226,6 @@ std::optional GetQueryParam(std::string_view Path, return std::nullopt; } -constexpr float kTranslationSnapStep = 1.0f; -constexpr float kRotationSnapStepDegrees = 15.0f; -constexpr float kScaleSnapStep = 0.1f; constexpr float kMinimumScale = 0.001f; float SnapToStep(float value, float step) { @@ -238,23 +235,23 @@ float SnapToStep(float value, float step) { return std::round(value / step) * step; } -void ApplyGridSnap(bool gridSnapEnabled, GizmoMode mode, int axis, +void ApplyGridSnap(bool enabled, float translationStep, + float rotationStepDegrees, float scaleStep, GizmoMode mode, int axis, glm::vec3 &location, glm::vec3 &rotationDegrees, glm::vec3 &scale) { - if (!gridSnapEnabled || axis < 0 || axis > 2) { + if (!enabled || axis < 0 || axis > 2) { return; } switch (mode) { case GizmoMode::Translate: - location[axis] = SnapToStep(location[axis], kTranslationSnapStep); + location[axis] = SnapToStep(location[axis], translationStep); break; case GizmoMode::Rotate: - rotationDegrees[axis] = - SnapToStep(rotationDegrees[axis], kRotationSnapStepDegrees); + rotationDegrees[axis] = SnapToStep(rotationDegrees[axis], rotationStepDegrees); break; case GizmoMode::Scale: - scale[axis] = std::max(kMinimumScale, SnapToStep(scale[axis], kScaleSnapStep)); + scale[axis] = std::max(kMinimumScale, SnapToStep(scale[axis], scaleStep)); break; } } @@ -2063,9 +2060,15 @@ bool RemoteViewportServer::HandleClientWebRtcMessage(std::string_view ClientId, Client->CurrentGizmoMode = Command->Mode; m_Host.GetHeadlessLayer().SetGizmoMode(Client->User, Command->Mode); return true; - case HeadlessCommandType::SetGridSnap: - Client->GridSnapEnabled = Command->Enabled; + case HeadlessCommandType::SetGridSnap: { + Client->GridSnap.Enabled = Command->Enabled; + Client->GridSnap.TranslationStep = + std::max(kMinimumScale, Command->TranslationStep); + Client->GridSnap.RotationStepDegrees = + std::max(0.001f, Command->RotationStepDegrees); + Client->GridSnap.ScaleStep = std::max(kMinimumScale, Command->ScaleStep); return true; + } case HeadlessCommandType::GizmoHover: { if (Client->GizmoDrag.has_value()) { return true; @@ -2207,8 +2210,9 @@ bool RemoteViewportServer::HandleClientWebRtcMessage(std::string_view ClientId, Command->MousePosition.x, Command->MousePosition.y); RotDeg[Drag.Math.Axis] = Drag.StartRotDeg[Drag.Math.Axis] + DeltaDeg; } - ApplyGridSnap(Client->GridSnapEnabled, Drag.Mode, Drag.Math.Axis, Location, - RotDeg, Scale); + ApplyGridSnap(Client->GridSnap.Enabled, Client->GridSnap.TranslationStep, + Client->GridSnap.RotationStepDegrees, Client->GridSnap.ScaleStep, + Drag.Mode, Drag.Math.Axis, Location, RotDeg, Scale); EditorCommand Cmd; Cmd.Payload = SetTransformCommand{ .ObjectId = Drag.ObjectId, @@ -2252,8 +2256,9 @@ bool RemoteViewportServer::HandleClientWebRtcMessage(std::string_view ClientId, Command->MousePosition.x, Command->MousePosition.y); RotDeg[Drag.Math.Axis] = Drag.StartRotDeg[Drag.Math.Axis] + DeltaDeg; } - ApplyGridSnap(Client->GridSnapEnabled, Drag.Mode, Drag.Math.Axis, Location, - RotDeg, Scale); + ApplyGridSnap(Client->GridSnap.Enabled, Client->GridSnap.TranslationStep, + Client->GridSnap.RotationStepDegrees, Client->GridSnap.ScaleStep, + Drag.Mode, Drag.Math.Axis, Location, RotDeg, Scale); EditorCommand Cmd; Cmd.Payload = SetTransformCommand{ .ObjectId = Drag.ObjectId, diff --git a/Headless/RemoteViewportServer.h b/Headless/RemoteViewportServer.h index 4369b39a..24a5783b 100644 --- a/Headless/RemoteViewportServer.h +++ b/Headless/RemoteViewportServer.h @@ -48,6 +48,13 @@ class RemoteViewportServer final : public ISessionTransportSubscriber { void OnSessionTransportViewportFrame(const ViewportFrame &Frame) override; private: + struct GridSnapSettings { + bool Enabled{true}; + float TranslationStep{1.0f}; + float RotationStepDegrees{15.0f}; + float ScaleStep{0.1f}; + }; + struct WebSocketClient { uintptr_t SocketValue{static_cast(~0ull)}; bool IsOpen{true}; @@ -73,7 +80,7 @@ class RemoteViewportServer final : public ISessionTransportSubscriber { std::unique_ptr VideoPacketOutput; std::optional GizmoDrag; GizmoMode CurrentGizmoMode{GizmoMode::Translate}; - bool GridSnapEnabled{false}; + GridSnapSettings GridSnap; }; struct ClientSessionResolution { diff --git a/Tests/HeadlessProtocolTests.cpp b/Tests/HeadlessProtocolTests.cpp index e03bba5f..b50d362d 100644 --- a/Tests/HeadlessProtocolTests.cpp +++ b/Tests/HeadlessProtocolTests.cpp @@ -158,11 +158,15 @@ TEST(HeadlessProtocolTests, RemoteViewportAcceptsSetTransformCommand) { TEST(HeadlessProtocolTests, RemoteViewportAcceptsSetGridSnapCommand) { std::string Error; const auto Command = Axiom::ParseRemoteViewportCommand( - R"json({"type":"set_grid_snap","enabled":true})json", Error); + R"json({"type":"set_grid_snap","enabled":true,"translationStep":0.5,"rotationStepDegrees":10.0,"scaleStep":0.05})json", + Error); ASSERT_TRUE(Command.has_value()) << Error; EXPECT_EQ(Command->Type, Axiom::HeadlessCommandType::SetGridSnap); EXPECT_TRUE(Command->Enabled); + EXPECT_FLOAT_EQ(Command->TranslationStep, 0.5f); + EXPECT_FLOAT_EQ(Command->RotationStepDegrees, 10.0f); + EXPECT_FLOAT_EQ(Command->ScaleStep, 0.05f); } TEST(HeadlessProtocolTests, SerializesCommandRejectedEvent) { From 0fa801bbf327f1bd2f8b819fd5dc8abe94707b36 Mon Sep 17 00:00:00 2001 From: Tamely Date: Tue, 12 May 2026 16:35:21 -0500 Subject: [PATCH 10/12] Fullscreen --- EditorFrontend/components/engine/viewport.tsx | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/EditorFrontend/components/engine/viewport.tsx b/EditorFrontend/components/engine/viewport.tsx index 06344a97..b68e71f9 100644 --- a/EditorFrontend/components/engine/viewport.tsx +++ b/EditorFrontend/components/engine/viewport.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState, type ElementType } from "react" import { Maximize2, + Minimize2, Camera, ChevronDown, } from "lucide-react" @@ -258,6 +259,7 @@ export function Viewport() { const rightMouseDownRef = useRef(false) const viewportFocusedRef = useRef(false) const isDraggingGizmoRef = useRef(false) + const [isFullscreen, setIsFullscreen] = useState(false) const keysRef = useRef(new Set()) const cursorRef = useRef({ x: 0, y: 0 }) const pendingLookDeltaRef = useRef({ x: 0, y: 0 }) @@ -324,6 +326,18 @@ export function Viewport() { participantsRef.current = participants }, [participants]) + useEffect(() => { + function syncFullscreenState() { + setIsFullscreen(document.fullscreenElement === viewportShellRef.current) + } + + syncFullscreenState() + document.addEventListener("fullscreenchange", syncFullscreenState) + return () => { + document.removeEventListener("fullscreenchange", syncFullscreenState) + } + }, []) + useEffect(() => { let disposed = false @@ -1912,6 +1926,20 @@ export function Viewport() { ) } + async function toggleFullscreen() { + const shell = viewportShellRef.current + if (!shell) { + return + } + + if (document.fullscreenElement === shell) { + await document.exitFullscreen() + return + } + + await shell.requestFullscreen() + } + return (
@@ -1950,7 +1978,11 @@ export function Viewport() {
- void connectRef.current()} /> + void toggleFullscreen()} + />
void }) { return (