From 2029360d585d96e6c68f8f85e844be8a2107881c Mon Sep 17 00:00:00 2001 From: Tamely Date: Sat, 16 May 2026 14:36:53 -0500 Subject: [PATCH 1/8] Drag and drop HDR settings --- .../components/panels/world-details-panel.tsx | 108 +++++++++++++++--- 1 file changed, 93 insertions(+), 15 deletions(-) diff --git a/EditorFrontend/components/panels/world-details-panel.tsx b/EditorFrontend/components/panels/world-details-panel.tsx index 6ea12c8..e6448a3 100644 --- a/EditorFrontend/components/panels/world-details-panel.tsx +++ b/EditorFrontend/components/panels/world-details-panel.tsx @@ -102,22 +102,11 @@ export function WorldDetailsPanel() { HDR Sky - setHdrPath(e.target.value)} - onBlur={(e) => commitHdrPath(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - commitHdrPath((e.target as HTMLInputElement).value) - ;(e.target as HTMLInputElement).blur() - } else if (e.key === "Escape") { - setHdrPath(remoteHdrPath) - ;(e.target as HTMLInputElement).blur() - } - }} + onLocalChange={setHdrPath} + onCommit={commitHdrPath} + onRevert={() => setHdrPath(remoteHdrPath)} />

@@ -131,6 +120,95 @@ export function WorldDetailsPanel() { ) } +function isHdrAssetDrag(e: React.DragEvent): boolean { + const types = e.dataTransfer.types + if (!types.includes("axiom/asset-path")) { + // Browser may have hidden the data during dragover; fall back to the + // window-global the content browser sets alongside dataTransfer. + const fallback = (window as unknown as { __axiomDragAsset?: { kind: string; path: string } }) + .__axiomDragAsset + return !!fallback && fallback.kind === "texture" && /\.hdr$/i.test(fallback.path) + } + // dataTransfer.getData is unavailable during dragover for security; we can + // only inspect during drop. Allow any texture-kind drag during dragover and + // re-validate on drop. + const kind = types.includes("axiom/asset-kind") + ? (window as unknown as { __axiomDragAsset?: { kind: string; path: string } }) + .__axiomDragAsset?.kind + : undefined + return kind === undefined || kind === "texture" +} + +function HdrPathInput({ + value, + onLocalChange, + onCommit, + onRevert, +}: { + value: string + onLocalChange: (next: string) => void + onCommit: (next: string) => void + onRevert: () => void +}) { + const [dragHover, setDragHover] = useState(false) + + return ( + onLocalChange(e.target.value)} + onBlur={(e) => onCommit(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + onCommit((e.target as HTMLInputElement).value) + ;(e.target as HTMLInputElement).blur() + } else if (e.key === "Escape") { + onRevert() + ;(e.target as HTMLInputElement).blur() + } + }} + onDragEnter={(e) => { + if (isHdrAssetDrag(e)) { + e.preventDefault() + setDragHover(true) + } + }} + onDragOver={(e) => { + if (isHdrAssetDrag(e)) { + e.preventDefault() + e.dataTransfer.dropEffect = "copy" + } + }} + onDragLeave={() => setDragHover(false)} + onDrop={(e) => { + setDragHover(false) + const path = + e.dataTransfer.getData("axiom/asset-path") || + (window as unknown as { __axiomDragAsset?: { kind: string; path: string } }) + .__axiomDragAsset?.path || + "" + const kind = + e.dataTransfer.getData("axiom/asset-kind") || + (window as unknown as { __axiomDragAsset?: { kind: string; path: string } }) + .__axiomDragAsset?.kind || + "" + if (!path) return + if (kind && kind !== "texture") return + if (!/\.hdr$/i.test(path)) return + e.preventDefault() + onLocalChange(path) + onCommit(path) + }} + /> + ) +} + function ColorRow({ label, value, From 131798f00c7c56556010a88901912cdb86b65afe Mon Sep 17 00:00:00 2001 From: Tamely Date: Sat, 16 May 2026 14:53:30 -0500 Subject: [PATCH 2/8] Picker menu for HDR and scripts --- EditorFrontend/components/engine/details.tsx | 48 ++++- .../components/panels/asset-picker.tsx | 107 +++++++++++ .../components/panels/world-details-panel.tsx | 175 +++++++++++------- EditorFrontend/next-env.d.ts | 2 +- 4 files changed, 254 insertions(+), 78 deletions(-) create mode 100644 EditorFrontend/components/panels/asset-picker.tsx diff --git a/EditorFrontend/components/engine/details.tsx b/EditorFrontend/components/engine/details.tsx index ae91865..ae6bb5d 100644 --- a/EditorFrontend/components/engine/details.tsx +++ b/EditorFrontend/components/engine/details.tsx @@ -10,6 +10,7 @@ import { } from "./remote-viewport-context" import { useProjectSession } from "./project-session-context" import { useDock } from "./dock/dock-context" +import { AssetPickerButton, type AssetPickerItem } from "@/components/panels/asset-picker" function fallbackUserLabel(userId: number) { return userId === 1 ? "Host" : `User ${userId - 1}` @@ -375,6 +376,17 @@ function ScriptSection({ [availableClasses, currentValue] ) + const scriptPickerItems = useMemo( + () => + availableClasses.map((entry) => ({ + key: entry.className, + label: entry.className, + sublabel: entry.path, + selectValue: entry.className, + })), + [availableClasses] + ) + async function applyScriptClass() { setIsSaving(true) try { @@ -410,15 +422,33 @@ function ScriptSection({

Class
- setDraftClass(event.target.value)} - placeholder="e.g. MyGame.RotatorScript" - type="text" - value={draftClass} - /> +
+ setDraftClass(event.target.value)} + placeholder="e.g. MyGame.RotatorScript" + type="text" + value={draftClass} + /> + setDraftClass(value)} + onOpen={() => void loadScriptClasses()} + triggerLabel="Browse project script classes" + searchPlaceholder="Search script classes..." + emptyMessage={ + classesLoading + ? "Loading classes..." + : "No attachable script classes found" + } + /> +
{availableClasses.map((entry) => (
-
-
-

- Environment -

- -
+
+
+
+ +

+ Sky Gradient +

+
+
+
-
- +
+
+ +

HDR Sky - - setHdrPath(remoteHdrPath)} - /> +

+
+
+
+ File + setHdrPath(remoteHdrPath)} + pickerItems={hdrItems} + onPickerOpen={() => void listAssets()} + /> +
+

+ Drop a .hdr from the content browser or pick one with the folder icon. + Leave empty to use the gradient above. +

-

- Content-relative path to an equirectangular .hdr (or cooked .wtex). Leave empty - to use the gradient colors. -

@@ -123,15 +148,10 @@ export function WorldDetailsPanel() { function isHdrAssetDrag(e: React.DragEvent): boolean { const types = e.dataTransfer.types if (!types.includes("axiom/asset-path")) { - // Browser may have hidden the data during dragover; fall back to the - // window-global the content browser sets alongside dataTransfer. const fallback = (window as unknown as { __axiomDragAsset?: { kind: string; path: string } }) .__axiomDragAsset return !!fallback && fallback.kind === "texture" && /\.hdr$/i.test(fallback.path) } - // dataTransfer.getData is unavailable during dragover for security; we can - // only inspect during drop. Allow any texture-kind drag during dragover and - // re-validate on drop. const kind = types.includes("axiom/asset-kind") ? (window as unknown as { __axiomDragAsset?: { kind: string; path: string } }) .__axiomDragAsset?.kind @@ -144,35 +164,23 @@ function HdrPathInput({ onLocalChange, onCommit, onRevert, + pickerItems, + onPickerOpen, }: { value: string onLocalChange: (next: string) => void onCommit: (next: string) => void onRevert: () => void + pickerItems: AssetPickerItem[] + onPickerOpen?: () => void }) { const [dragHover, setDragHover] = useState(false) return ( - onLocalChange(e.target.value)} - onBlur={(e) => onCommit(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - onCommit((e.target as HTMLInputElement).value) - ;(e.target as HTMLInputElement).blur() - } else if (e.key === "Escape") { - onRevert() - ;(e.target as HTMLInputElement).blur() - } - }} onDragEnter={(e) => { if (isHdrAssetDrag(e)) { e.preventDefault() @@ -202,10 +210,35 @@ function HdrPathInput({ if (kind && kind !== "texture") return if (!/\.hdr$/i.test(path)) return e.preventDefault() - onLocalChange(path) onCommit(path) }} - /> + > + onLocalChange(e.target.value)} + onBlur={(e) => onCommit(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + onCommit((e.target as HTMLInputElement).value) + ;(e.target as HTMLInputElement).blur() + } else if (e.key === "Escape") { + onRevert() + ;(e.target as HTMLInputElement).blur() + } + }} + /> + +
) } @@ -221,29 +254,35 @@ function ColorRow({ popoverTitle: string }) { return ( -
- {label} +
+ {label} - +
-
{popoverTitle}
+

+ {popoverTitle} +

-
- HEX +
+ HEX onChange(e.target.value)} /> diff --git a/EditorFrontend/next-env.d.ts b/EditorFrontend/next-env.d.ts index c4b7818..9edff1c 100644 --- a/EditorFrontend/next-env.d.ts +++ b/EditorFrontend/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From acd87fd4fe654e00e18ba3e4a8e6b36563da16a5 Mon Sep 17 00:00:00 2001 From: Tamely Date: Sat, 16 May 2026 14:58:48 -0500 Subject: [PATCH 3/8] Updaet docs --- Docs/DistributedWraithEngineDesign.md | 4 ++++ README.md | 2 ++ 2 files changed, 6 insertions(+) diff --git a/Docs/DistributedWraithEngineDesign.md b/Docs/DistributedWraithEngineDesign.md index 1a60c79..c8c236d 100644 --- a/Docs/DistributedWraithEngineDesign.md +++ b/Docs/DistributedWraithEngineDesign.md @@ -83,6 +83,10 @@ - Physics authoring is now available through authoritative scene details: body type, collider type, box half extents, sphere radius, mass, friction, and restitution persist through save/load - Imported mesh assets now default to static box collision that covers the authored mesh bounds; older scenes are migrated to that default on load only when the mesh had no authored physics yet - Generated mesh children from multi-mesh imports remain read-only and inherit physics authoring from their imported root mesh object; the browser inspector now surfaces that inheritance instead of hiding physics entirely +- A configurable sky background is now wired end-to-end: `RenderScene::SkyboxColorTop/Bottom` plus an optional `HDRTextureSourceDataRef SkyboxHDRTexture` feed two `DrawBackground` paths in the Vulkan backend — the existing `gradient_color.comp` produces a vertical blend, and a new `skybox_hdr.comp` unprojects each pixel through `inverse(proj * view)` and equirectangularly samples a `VK_FORMAT_R32G32B32A32_SFLOAT` image so the HDR is preserved at full float precision and rotates with the camera; `SetWorldSettingsCommand` carries both the colors and a content-relative `SkyboxHDRPath`, `EditorSession` loads HDR data on path change (with previous-path caching to avoid redundant disk hits), and `HeadlessSessionLayer` republishes the scene's HDR ref each frame via `RenderCommand::SetSkyboxHDR`; HDR uploads/swaps are deferred onto the per-frame `DeletionQueue` so in-flight command buffers can't reference a freed image +- The cooked-texture format (`.wtex`) was extended to v2 with a `PixelFormat` field appended after the existing v1 header; legacy v1 LDR files continue to load unchanged, while HDR textures cook as v2 with full-float (`RGBA32F`) pixel data through new `SaveCookedHDRTextureAsset` / `LoadCookedHDRTextureAsset` / `CookHDRTextureAsset` paths, so the editor's authored HDR keeps its full dynamic range for future image-based lighting reuse +- `.hdr` is now a first-class asset extension: `LocalAssetSource` indexes it as `AssetKind::Texture`, `RemoteViewportServer` accepts it through the upload endpoint, and the content browser picker filters it natively +- A new `WorldDetailsPanel` (docked alongside the Details inspector) hosts a `Sky Gradient` section with linked color pickers and an `HDR Sky` section that combines a typed content-relative path input, drag-drop from the content browser (consuming the existing `axiom/asset-path` / `axiom/asset-kind` dataTransfer payload), and a folder-icon `AssetPickerButton` popover that lists every `.hdr` in the project searchably; the same `AssetPickerButton` is also wired into the script-class field on the Details panel so script attachment no longer requires typing a fully-qualified class name; the panel reuses the canonical Details-style section/input chrome and forces the shadcn popover surfaces into dark theme so they match the rest of the editor ## 1. Executive Summary WraithEngine will evolve from a single-process native editor into a distributed platform with one shared C++ engine runtime that supports two execution styles: diff --git a/README.md b/README.md index 212ed65..190c3c3 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ AxiomRemoteViewportServer (C++) - Session-wide Play / Pause / Stop with authoritative edit-snapshot restore - Runtime-only Jolt physics stepping with pause / resume support - Default static box collision for imported mesh assets, with load-time migration for older meshes that had no authored physics yet +- Configurable sky background — a two-color vertical gradient (compute-shader blended) or an equirectangular HDR (`.hdr`) sampled from a world-space ray; HDR data is preserved end-to-end as float pixels through a v2 cooked `.wtex` so future image-based lighting can reuse the same asset **Browser editor** - Dockable panels: outliner, details/property inspector, content browser, toolbar @@ -54,6 +55,7 @@ AxiomRemoteViewportServer (C++) - Script class attachment and hot-reload button - Inspector-driven physics authoring: body type, collider type, extents/radius, mass, friction, bounce - Read-only physics visibility for generated mesh children, with inheritance hints pointing back to the authored root mesh object +- World Details panel for editing the skybox: color pickers for the gradient mode, plus an HDR file slot that accepts drag-drop from the content browser, a searchable folder-icon picker listing every `.hdr` in the project, or a typed content-relative path ## Prerequisites From 4a31673328da60dd0698856bd77a9f814277ee0a Mon Sep 17 00:00:00 2001 From: Tamely Date: Sun, 17 May 2026 01:00:43 -0500 Subject: [PATCH 4/8] Orthographic camera --- Axiom/Renderer/Camera.cpp | 10 ++++ Axiom/Renderer/Camera.h | 9 ++++ .../Renderer/Vulkan/VulkanRendererBackend.cpp | 3 +- Axiom/Session/EditorCommand.h | 6 +++ Axiom/Session/EditorSession.cpp | 20 +++++++ Axiom/Session/EditorSession.h | 4 ++ Axiom/Session/MeshPicking.h | 19 ++++++- .../engine/remote-viewport-context.tsx | 15 ++++++ EditorFrontend/components/engine/viewport.tsx | 53 +++++++++++++++++-- EditorFrontend/next-env.d.ts | 2 +- Headless/HeadlessCommandProtocol.cpp | 25 +++++++++ Headless/HeadlessCommandProtocol.h | 2 + Headless/RemoteViewportServer.cpp | 3 ++ 13 files changed, 164 insertions(+), 7 deletions(-) diff --git a/Axiom/Renderer/Camera.cpp b/Axiom/Renderer/Camera.cpp index 121117c..4f5f29f 100644 --- a/Axiom/Renderer/Camera.cpp +++ b/Axiom/Renderer/Camera.cpp @@ -27,6 +27,16 @@ void Camera::SetPerspective(float VerticalFovDegrees, float AspectRatio, m_Projection = glm::perspective(glm::radians(VerticalFovDegrees), AspectRatio, NearPlane, FarPlane); m_Projection[1][1] *= -1.0f; + m_ProjectionType = CameraProjectionType::Perspective; +} + +void Camera::SetOrthographic(float OrthoHeight, float AspectRatio, + float NearPlane, float FarPlane) { + const float HalfH = OrthoHeight * 0.5f; + const float HalfW = HalfH * AspectRatio; + m_Projection = glm::ortho(-HalfW, HalfW, -HalfH, HalfH, NearPlane, FarPlane); + m_Projection[1][1] *= -1.0f; // Vulkan Y-flip + m_ProjectionType = CameraProjectionType::Orthographic; } void Camera::SetPosition(const glm::vec3 &Position) { diff --git a/Axiom/Renderer/Camera.h b/Axiom/Renderer/Camera.h index 3ef9cdb..99043c4 100644 --- a/Axiom/Renderer/Camera.h +++ b/Axiom/Renderer/Camera.h @@ -4,6 +4,8 @@ #include namespace Axiom { +enum class CameraProjectionType { Perspective, Orthographic }; + class Camera { public: Camera() = default; @@ -12,6 +14,8 @@ class Camera { const glm::vec3 &Up = glm::vec3(0.0f, 1.0f, 0.0f)); void SetPerspective(float VerticalFovDegrees, float AspectRatio, float NearPlane, float FarPlane); + void SetOrthographic(float OrthoHeight, float AspectRatio, + float NearPlane, float FarPlane); void SetPosition(const glm::vec3 &Position); void SetRotation(float YawDegrees, float PitchDegrees); void MoveLocal(const glm::vec3 &LocalOffset); @@ -26,6 +30,10 @@ class Camera { const glm::mat4 &GetViewMatrix() const { return m_View; } const glm::mat4 &GetProjectionMatrix() const { return m_Projection; } glm::mat4 GetViewProjectionMatrix() const { return m_Projection * m_View; } + CameraProjectionType GetProjectionType() const { return m_ProjectionType; } + bool IsOrthographic() const { + return m_ProjectionType == CameraProjectionType::Orthographic; + } private: void UpdateOrientationVectors(); @@ -39,5 +47,6 @@ class Camera { float m_PitchDegrees{0.0f}; glm::mat4 m_View{1.0f}; glm::mat4 m_Projection{1.0f}; + CameraProjectionType m_ProjectionType{CameraProjectionType::Perspective}; }; } // namespace Axiom diff --git a/Axiom/Renderer/Vulkan/VulkanRendererBackend.cpp b/Axiom/Renderer/Vulkan/VulkanRendererBackend.cpp index fa3f59d..62c90c1 100644 --- a/Axiom/Renderer/Vulkan/VulkanRendererBackend.cpp +++ b/Axiom/Renderer/Vulkan/VulkanRendererBackend.cpp @@ -1118,7 +1118,8 @@ void VulkanRendererBackend::DrawBackground(VkCommandBuffer CommandBuffer) { const bool UseHDR = m_LoadedHDRSkyboxData != nullptr && m_HDRSkyboxDescriptorSet != VK_NULL_HANDLE && m_ActiveScene != nullptr && - m_ActiveScene->ActiveCamera != nullptr; + m_ActiveScene->ActiveCamera != nullptr && + !m_ActiveScene->ActiveCamera->IsOrthographic(); if (UseHDR) { vkCmdBindPipeline(CommandBuffer, VK_PIPELINE_BIND_POINT_COMPUTE, diff --git a/Axiom/Session/EditorCommand.h b/Axiom/Session/EditorCommand.h index 99c0c9e..688fa6f 100644 --- a/Axiom/Session/EditorCommand.h +++ b/Axiom/Session/EditorCommand.h @@ -1,5 +1,6 @@ #pragma once +#include "Renderer/Camera.h" #include "Session/SessionTypes.h" #include @@ -128,12 +129,17 @@ struct ResumeSessionCommand {}; struct StopSessionCommand {}; +struct SetCameraProjectionCommand { + CameraProjectionType ProjectionType{CameraProjectionType::Perspective}; +}; + struct SetWorldSettingsCommand { EditorWorldSettings Settings; }; using EditorCommandPayload = std::variant(Payload)) { return "set_viewport_camera_pose"; } + if (std::holds_alternative(Payload)) { + return "set_camera_projection"; + } if (std::holds_alternative(Payload)) { return "set_look_active"; } @@ -1691,6 +1694,23 @@ void EditorSession::HandleCommand( }}); } +void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, + const SetCameraProjectionCommand &Command) { + EditorViewportState &Viewport = EnsureViewport(QueuedCommand.Context.User); + Viewport.ProjectionType = Command.ProjectionType; + if (Command.ProjectionType == CameraProjectionType::Orthographic) { + Viewport.Camera.SetOrthographic(Viewport.OrthoHeight, + m_Config.CameraAspectRatio, + m_Config.CameraNearPlane, + m_Config.CameraFarPlane); + } else { + Viewport.Camera.SetPerspective(m_Config.CameraVerticalFovDegrees, + m_Config.CameraAspectRatio, + m_Config.CameraNearPlane, + m_Config.CameraFarPlane); + } +} + void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, const SetLookActiveCommand &Command) { EditorViewportState &Viewport = EnsureViewport(QueuedCommand.Context.User); diff --git a/Axiom/Session/EditorSession.h b/Axiom/Session/EditorSession.h index b0944e6..86e03a1 100644 --- a/Axiom/Session/EditorSession.h +++ b/Axiom/Session/EditorSession.h @@ -34,6 +34,8 @@ struct EditorSessionConfig { struct EditorViewportState { Camera Camera; + CameraProjectionType ProjectionType{CameraProjectionType::Perspective}; + float OrthoHeight{20.0f}; bool IsLooking{false}; glm::dvec2 LastCursorPosition{0.0, 0.0}; bool HasLastCursorPosition{false}; @@ -257,6 +259,8 @@ class EditorSession final : public IEditorCommandSink { const UpdateViewportCameraCommand &Command); void HandleCommand(const QueuedEditorCommand &QueuedCommand, const SetViewportCameraPoseCommand &Command); + void HandleCommand(const QueuedEditorCommand &QueuedCommand, + const SetCameraProjectionCommand &Command); void HandleCommand(const QueuedEditorCommand &QueuedCommand, const SetLookActiveCommand &Command); void HandleCommand(const QueuedEditorCommand &QueuedCommand, diff --git a/Axiom/Session/MeshPicking.h b/Axiom/Session/MeshPicking.h index 81e38f4..f34e8c0 100644 --- a/Axiom/Session/MeshPicking.h +++ b/Axiom/Session/MeshPicking.h @@ -37,7 +37,6 @@ BuildViewportRay(const Camera &Cam, uint32_t VpWidth, uint32_t VpHeight, return std::nullopt; } - 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 = @@ -47,6 +46,17 @@ BuildViewportRay(const Camera &Cam, uint32_t VpWidth, uint32_t VpHeight, } const glm::vec3 WorldPt = glm::vec3(WorldH) / WorldH.w; + + if (Cam.IsOrthographic()) { + const glm::vec3 RayDir = glm::normalize(Cam.GetForward()); + if (!std::isfinite(RayDir.x) || !std::isfinite(RayDir.y) || + !std::isfinite(RayDir.z)) { + return std::nullopt; + } + return ViewportRay{.Origin = WorldPt, .Direction = RayDir}; + } + + const glm::vec3 RayOrigin = Cam.GetPosition(); const glm::vec3 RayDir = glm::normalize(WorldPt - RayOrigin); if (!std::isfinite(RayDir.x) || !std::isfinite(RayDir.y) || !std::isfinite(RayDir.z)) { @@ -148,6 +158,13 @@ inline float ComputeBillboardHalfSizeWorld(const Camera &Cam, return 0.1f; } + if (Cam.IsOrthographic()) { + // For ortho: |Projection[1][1]| = 1/HalfH, so world units per pixel = 2/(ProjectionY * VpHeight) + const float WorldUnitsPerPixel = + 2.0f / (ProjectionY * static_cast(VpHeight)); + return std::max(0.01f, PixelSize * 0.5f * WorldUnitsPerPixel); + } + const float TanHalfFov = 1.0f / ProjectionY; const float WorldUnitsPerPixel = (2.0f * Distance * TanHalfFov) / static_cast(VpHeight); diff --git a/EditorFrontend/components/engine/remote-viewport-context.tsx b/EditorFrontend/components/engine/remote-viewport-context.tsx index 7dd28c8..b07d550 100644 --- a/EditorFrontend/components/engine/remote-viewport-context.tsx +++ b/EditorFrontend/components/engine/remote-viewport-context.tsx @@ -33,6 +33,7 @@ export type RemoteSessionState = export type RemoteRuntimeState = "edit" | "playing" | "paused" export type RemoteViewportViewMode = "lit" | "unlit" | "wireframe" +export type RemoteViewportProjectionType = "perspective" | "orthographic" export type RemoteViewportGizmoMode = "translate" | "scale" | "rotate" export type SessionSceneItemKind = "folder" | "mesh" | "light" | "camera" | "actor" @@ -151,6 +152,7 @@ interface RemoteViewportActions { reconnect: () => Promise toggleLook: () => Promise setMode: (mode: RemoteViewportViewMode) => Promise + setProjectionType: (type: RemoteViewportProjectionType) => Promise setShowColliders: (showColliders: boolean) => Promise setGizmoMode: (mode: RemoteViewportGizmoMode) => Promise setGridSnapSettings: (settings: RemoteViewportGridSnapSettings) => Promise @@ -215,6 +217,7 @@ interface RemoteViewportContextValue { runtimeState: RemoteRuntimeState canControlRuntime: boolean viewMode: RemoteViewportViewMode + projectionType: RemoteViewportProjectionType showColliders: boolean gizmoMode: RemoteViewportGizmoMode gridSnapSettings: RemoteViewportGridSnapSettings @@ -295,6 +298,7 @@ interface RemoteViewportContextValue { reconnect: () => Promise toggleLook: () => Promise setMode: (mode: RemoteViewportViewMode) => Promise + setProjectionType: (type: RemoteViewportProjectionType) => Promise setShowColliders: (showColliders: boolean) => Promise setGizmoMode: (mode: RemoteViewportGizmoMode) => Promise setGridSnapSettings: (settings: RemoteViewportGridSnapSettings) => Promise @@ -369,6 +373,7 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { reconnect: async () => {}, toggleLook: async () => {}, setMode: async () => {}, + setProjectionType: async () => {}, setShowColliders: async () => {}, setGizmoMode: async () => {}, setGridSnapSettings: async () => {}, @@ -409,6 +414,7 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { ) const [runtimeState, setRuntimeState] = useState("edit") const [viewMode, setViewMode] = useState("lit") + const [projectionType, setProjectionTypeState] = useState("perspective") const [showColliders, setShowCollidersState] = useState(true) const [gizmoMode, setGizmoModeState] = useState("translate") const [gridSnapSettings, setGridSnapSettingsState] = @@ -538,6 +544,11 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { await actionsRef.current.setMode(mode) }, []) + const setProjectionType = useCallback(async (type: RemoteViewportProjectionType) => { + setProjectionTypeState(type) + await actionsRef.current.setProjectionType(type) + }, []) + const setShowColliders = useCallback(async (nextValue: boolean) => { setShowCollidersState(nextValue) await actionsRef.current.setShowColliders(nextValue) @@ -687,6 +698,7 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { runtimeState, canControlRuntime, viewMode, + projectionType, showColliders, gizmoMode, gridSnapSettings, @@ -753,6 +765,7 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { reconnect, toggleLook, setMode, + setProjectionType, setShowColliders, setGizmoMode: setGizmoModeAction, setGridSnapSettings, @@ -826,6 +839,7 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { gizmoMode, showColliders, setMode, + setProjectionType, setShowColliders, setGizmoModeAction, setGridSnapSettings, @@ -841,6 +855,7 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { deleteObject, reparentObject, viewMode, + projectionType, ] ) diff --git a/EditorFrontend/components/engine/viewport.tsx b/EditorFrontend/components/engine/viewport.tsx index c9c28eb..168dab2 100644 --- a/EditorFrontend/components/engine/viewport.tsx +++ b/EditorFrontend/components/engine/viewport.tsx @@ -22,6 +22,7 @@ import { type SessionObjectDetails, type RemoteViewportConnectionState, type RemoteViewportViewMode, + type RemoteViewportProjectionType, type RemoteViewportGizmoMode, type SessionParticipant, type SessionSceneItem, @@ -36,6 +37,7 @@ const SESSION_POLL_INTERVAL_MS = 1500 const CLIENT_ID_CLAIM_TIMEOUT_MS = 100 type ConnectionState = RemoteViewportConnectionState type ViewMode = RemoteViewportViewMode +type ProjectionType = RemoteViewportProjectionType type GizmoMode = RemoteViewportGizmoMode type ChannelPreference = "reliable" | "unreliable" @@ -101,6 +103,10 @@ type RemoteViewportCommand = type: "set_view_mode" viewMode: ViewMode } + | { + type: "set_camera_projection" + projectionType: ProjectionType + } | { type: "set_show_colliders" showColliders: boolean @@ -280,6 +286,7 @@ export function Viewport() { const pendingLookDeltaRef = useRef({ x: 0, y: 0 }) const isLookingRef = useRef(false) const viewModeRef = useRef("lit") + const projectionTypeRef = useRef("perspective") const gizmoModeRef = useRef("translate") const setGizmoModeCtxRef = useRef<(mode: GizmoMode) => Promise>(async () => { }) const notifyServerOnDestroyRef = useRef(true) @@ -293,6 +300,7 @@ export function Viewport() { detailText, frameText, viewMode, + projectionType, showColliders, gizmoMode, isLooking, @@ -323,6 +331,7 @@ export function Viewport() { setSessionStatusText, setSessionDetailText, setGizmoMode: setGizmoModeCtx, + setProjectionType: setProjectionTypeCtx, runtimeState, } = useRemoteViewport() const [serverOrigin] = useState(getServerOrigin) @@ -336,6 +345,10 @@ export function Viewport() { viewModeRef.current = viewMode }, [viewMode]) + useEffect(() => { + projectionTypeRef.current = projectionType + }, [projectionType]) + useEffect(() => { isLookingRef.current = isLooking }, [isLooking]) @@ -1747,6 +1760,19 @@ export function Viewport() { "reliable" ) }, + setProjectionType: async (nextType) => { + if (projectionTypeRef.current === nextType) { + return + } + projectionTypeRef.current = nextType + await sendCommand( + { + type: "set_camera_projection", + projectionType: nextType, + }, + "reliable" + ) + }, setShowColliders: async (nextValue) => { await sendCommand( { @@ -2071,10 +2097,29 @@ export function Viewport() {
- + + + + + + void setProjectionTypeCtx(value as ProjectionType)} + > + Perspective + Orthographic + + + + {fileMenuOpen && ( +
- {fileMenuOpen ? ( -
- + +
+ )} +
+ + {/* Window */} +
+ + {windowMenuOpen && ( +
+ {PANEL_ENTRIES.map(({ id, label, Icon }) => { + const isOpen = openPanelIds.has(id) + return ( -
- ) : null} + ) + })}
- ) : item === "Build" ? ( -
+ )} +
+ + {/* Build */} +
+ + {buildMenuOpen && ( +
+ - {buildMenuOpen ? ( -
- - -
- ) : null}
- ) : ( - - ) + )} +
+ + {/* Inert stubs */} + {["Edit", "Tools", "Select", "Actor", "Help"].map((item) => ( + ))}
+
- + Project: {activeProject?.name ?? "None"} - +
diff --git a/EditorFrontend/components/panels/place-actors-panel.tsx b/EditorFrontend/components/panels/place-actors-panel.tsx new file mode 100644 index 0000000..d91a40f --- /dev/null +++ b/EditorFrontend/components/panels/place-actors-panel.tsx @@ -0,0 +1,192 @@ +"use client" + +import { useState, useMemo } from "react" +import { + Box, + Lightbulb, + Camera, + User, + Folder, + Search, + Layers, +} from "lucide-react" +import { useRemoteViewport } from "@/components/engine/remote-viewport-context" + +type CategoryId = "all" | "geometry" | "lights" | "cameras" | "actors" | "utility" + +interface PlaceableItem { + id: string + label: string + description: string + templateId: string + categoryId: CategoryId + Icon: React.ComponentType<{ className?: string }> +} + +const ITEMS: PlaceableItem[] = [ + { + id: "mesh", + label: "Mesh", + description: "Empty mesh object", + templateId: "Mesh", + categoryId: "geometry", + Icon: Box, + }, + { + id: "light", + label: "Light", + description: "Scene light source", + templateId: "Light", + categoryId: "lights", + Icon: Lightbulb, + }, + { + id: "camera", + label: "Camera", + description: "Camera actor", + templateId: "Camera", + categoryId: "cameras", + Icon: Camera, + }, + { + id: "actor", + label: "Actor", + description: "Scriptable game object", + templateId: "Actor", + categoryId: "actors", + Icon: User, + }, + { + id: "folder", + label: "Folder", + description: "Organizer / group", + templateId: "Folder", + categoryId: "utility", + Icon: Folder, + }, +] + +interface Category { + id: CategoryId + label: string + Icon: React.ComponentType<{ className?: string }> +} + +const CATEGORIES: Category[] = [ + { id: "all", label: "All", Icon: Layers }, + { id: "geometry", label: "Geometry", Icon: Box }, + { id: "lights", label: "Lights", Icon: Lightbulb }, + { id: "cameras", label: "Cameras", Icon: Camera }, + { id: "actors", label: "Actors", Icon: User }, + { id: "utility", label: "Utility", Icon: Folder }, +] + +export function PlaceActorsPanel() { + const { createObject } = useRemoteViewport() + const [search, setSearch] = useState("") + const [activeCategory, setActiveCategory] = useState("all") + const [placingId, setPlacingId] = useState(null) + + const filtered = useMemo(() => { + const q = search.toLowerCase().trim() + return ITEMS.filter((item) => { + const matchesCategory = + activeCategory === "all" || item.categoryId === activeCategory + const matchesSearch = + !q || item.label.toLowerCase().includes(q) || item.description.toLowerCase().includes(q) + return matchesCategory && matchesSearch + }) + }, [search, activeCategory]) + + async function handlePlace(item: PlaceableItem) { + if (placingId) return + setPlacingId(item.id) + try { + await createObject(item.templateId) + } finally { + setPlacingId(null) + } + } + + return ( +
+ {/* Header */} +
+ + Place Actors +
+ + {/* Search */} +
+
+ + setSearch(e.target.value)} + placeholder="Search..." + className="min-w-0 flex-1 bg-transparent text-xs text-neutral-300 placeholder-neutral-600 outline-none" + /> +
+
+ + {/* Category tabs */} +
+
+ {CATEGORIES.map((cat) => { + const active = activeCategory === cat.id + return ( + + ) + })} +
+
+ + {/* Item list */} +
+ {filtered.length === 0 ? ( +
+ No results +
+ ) : ( +
+ {filtered.map((item) => { + const isPlacing = placingId === item.id + return ( + + ) + })} +
+ )} +
+
+ ) +} From d5a6379c93e8e92efcd8c4cd2619594cff19837e Mon Sep 17 00:00:00 2001 From: Tamely Date: Sun, 17 May 2026 01:33:35 -0500 Subject: [PATCH 6/8] Make place actors actually place actors, not primitives --- Axiom/Session/EditorCommand.h | 8 +- Axiom/Session/EditorSession.cpp | 82 ++++++++++++++++++- Axiom/Session/EditorSession.h | 2 + .../engine/remote-viewport-context.tsx | 9 ++ EditorFrontend/components/engine/viewport.tsx | 52 ++++++++++++ .../components/panels/place-actors-panel.tsx | 11 ++- Headless/HeadlessCommandProtocol.cpp | 21 +++++ Headless/HeadlessCommandProtocol.h | 2 + Headless/RemoteViewportServer.cpp | 34 ++++++++ Headless/RemoteViewportServer.h | 2 + 10 files changed, 219 insertions(+), 4 deletions(-) diff --git a/Axiom/Session/EditorCommand.h b/Axiom/Session/EditorCommand.h index 688fa6f..c174157 100644 --- a/Axiom/Session/EditorCommand.h +++ b/Axiom/Session/EditorCommand.h @@ -137,6 +137,11 @@ struct SetWorldSettingsCommand { EditorWorldSettings Settings; }; +struct PlaceActorCommand { + std::string ChildTemplateId; // empty = bare Actor, no child + glm::vec3 Location{0.0f}; +}; + using EditorCommandPayload = std::variant; + StopSessionCommand, SetWorldSettingsCommand, + PlaceActorCommand>; struct EditorCommand { EditorCommandPayload Payload; diff --git a/Axiom/Session/EditorSession.cpp b/Axiom/Session/EditorSession.cpp index 6d52a8f..8f2df42 100644 --- a/Axiom/Session/EditorSession.cpp +++ b/Axiom/Session/EditorSession.cpp @@ -218,6 +218,9 @@ std::string CommandTypeName(const EditorCommandPayload &Payload) { if (std::holds_alternative(Payload)) { return "set_world_settings"; } + if (std::holds_alternative(Payload)) { + return "place_actor"; + } return "set_transform"; } @@ -237,7 +240,8 @@ bool IsAuthoringMutationCommand(const EditorCommandPayload &Payload) { std::holds_alternative(Payload) || std::holds_alternative(Payload) || std::holds_alternative(Payload) || - std::holds_alternative(Payload); + std::holds_alternative(Payload) || + std::holds_alternative(Payload); } EditorSceneItemKind KindForClassName(std::string_view ClassName) { @@ -2311,6 +2315,82 @@ void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, } } +void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, + const PlaceActorCommand &Command) { + EnsurePresence(QueuedCommand.Context.User); + Instance *WorldFolder = EnsureWorldFolder(); + if (!WorldFolder) return; + + // Create the Actor parent + const std::string ActorId = BuildUniqueObjectId("Actor"); + const std::string ActorDisplayName = BuildUniqueDisplayName("Actor"); + const EditorTransformDetails ActorTransform{.Location = Command.Location}; + m_State.Scene.ObjectDetailsById.emplace( + ActorId, + EditorObjectDetails{ + .ObjectId = ActorId, + .DisplayName = ActorDisplayName, + .Kind = EditorSceneItemKind::Actor, + .Visible = true, + .SupportsTransform = true, + .TransformReadOnly = false, + .Transform = ActorTransform, + .WorldTransform = ActorTransform, + }); + Instance *ActorNode = CreateInstanceForTemplate("Actor", ActorId); + if (ActorNode) ActorNode->SetParent(WorldFolder); + + // Create the child object (if a template was specified) + std::string ChildId; + std::string ChildDisplayName; + if (!Command.ChildTemplateId.empty()) { + const EditorSceneItemKind ChildKind = KindForTemplateId(Command.ChildTemplateId); + ChildId = BuildUniqueObjectId(Command.ChildTemplateId); + ChildDisplayName = BuildUniqueDisplayName(Command.ChildTemplateId); + const bool ChildTransformable = SupportsTransformForKind(ChildKind); + m_State.Scene.ObjectDetailsById.emplace( + ChildId, + EditorObjectDetails{ + .ObjectId = ChildId, + .DisplayName = ChildDisplayName, + .Kind = ChildKind, + .Visible = true, + .SupportsTransform = ChildTransformable, + .TransformReadOnly = false, + .Transform = ChildTransformable + ? std::optional{EditorTransformDetails{}} + : std::nullopt, + .WorldTransform = ChildTransformable + ? std::optional{EditorTransformDetails{}} + : std::nullopt, + }); + if (Instance *ChildNode = + CreateInstanceForTemplate(Command.ChildTemplateId, ChildId)) { + ChildNode->SetParent(ActorNode ? ActorNode : WorldFolder); + } + } + + SyncItemsFromTree(); + PublishEvent({.Payload = ObjectCreatedEvent{ + .User = QueuedCommand.Context.User, + .ObjectId = ActorId, + .DisplayName = ActorDisplayName, + }}); + if (!ChildId.empty()) { + PublishEvent({.Payload = ObjectCreatedEvent{ + .User = QueuedCommand.Context.User, + .ObjectId = ChildId, + .DisplayName = ChildDisplayName, + }}); + } + + // Apply world-space location to the actor + HandleCommand(QueuedCommand, SetTransformCommand{ + .ObjectId = ActorId, + .Location = Command.Location, + }); +} + void EditorSession::SetContentDir(std::filesystem::path ContentDir) { m_ContentDir = std::move(ContentDir); } diff --git a/Axiom/Session/EditorSession.h b/Axiom/Session/EditorSession.h index 86e03a1..4abf71b 100644 --- a/Axiom/Session/EditorSession.h +++ b/Axiom/Session/EditorSession.h @@ -305,6 +305,8 @@ class EditorSession final : public IEditorCommandSink { const StopSessionCommand &Command); void HandleCommand(const QueuedEditorCommand &QueuedCommand, const SetWorldSettingsCommand &Command); + void HandleCommand(const QueuedEditorCommand &QueuedCommand, + const PlaceActorCommand &Command); void ApplyWorldTransform(std::string_view ObjectId, const EditorTransformDetails &WorldTransform, SessionUserId User, bool PublishEvent); diff --git a/EditorFrontend/components/engine/remote-viewport-context.tsx b/EditorFrontend/components/engine/remote-viewport-context.tsx index b07d550..a5c609f 100644 --- a/EditorFrontend/components/engine/remote-viewport-context.tsx +++ b/EditorFrontend/components/engine/remote-viewport-context.tsx @@ -163,6 +163,7 @@ interface RemoteViewportActions { goToParticipantCamera: (userId: number) => Promise updateTransform: (details: SessionObjectTransformUpdate) => Promise createObject: (templateId: string) => Promise + placeActor: (templateId: string, mouseX: number, mouseY: number) => Promise duplicateObject: (objectId: string) => Promise deleteObject: (objectId: string) => Promise reparentObject: (objectId: string, newParentId: string) => Promise @@ -309,6 +310,7 @@ interface RemoteViewportContextValue { goToParticipantCamera: (userId: number) => Promise updateTransform: (details: SessionObjectTransformUpdate) => Promise createObject: (templateId: string) => Promise + placeActor: (templateId: string, mouseX: number, mouseY: number) => Promise duplicateObject: (objectId: string) => Promise deleteObject: (objectId: string) => Promise reparentObject: (objectId: string, newParentId: string) => Promise @@ -384,6 +386,7 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { goToParticipantCamera: async () => false, updateTransform: async () => false, createObject: async () => false, + placeActor: async () => false, duplicateObject: async () => false, deleteObject: async () => false, reparentObject: async () => false, @@ -592,6 +595,10 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { return actionsRef.current.createObject(templateId) }, []) + const placeActor = useCallback(async (templateId: string, mouseX: number, mouseY: number) => { + return actionsRef.current.placeActor(templateId, mouseX, mouseY) + }, []) + const duplicateObject = useCallback(async (objectId: string) => { return actionsRef.current.duplicateObject(objectId) }, []) @@ -776,6 +783,7 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { goToParticipantCamera, updateTransform, createObject, + placeActor, duplicateObject, deleteObject, reparentObject, @@ -851,6 +859,7 @@ export function RemoteViewportProvider({ children }: { children: ReactNode }) { toggleLook, updateTransform, createObject, + placeActor, duplicateObject, deleteObject, reparentObject, diff --git a/EditorFrontend/components/engine/viewport.tsx b/EditorFrontend/components/engine/viewport.tsx index 168dab2..d8cc795 100644 --- a/EditorFrontend/components/engine/viewport.tsx +++ b/EditorFrontend/components/engine/viewport.tsx @@ -230,6 +230,12 @@ type RemoteViewportCommand = objectId: string textureAssetPath: string } + | { + type: "place_actor" + templateId: string + mouseX: number + mouseY: number + } | { type: "drop_mesh" mouseX: number @@ -1626,6 +1632,21 @@ export function Viewport() { } return accepted }, + placeActor: async (templateId, mouseX, mouseY) => { + const accepted = await sendCommand( + { + type: "place_actor", + templateId, + mouseX, + mouseY, + }, + "reliable" + ) + if (accepted) { + await refreshSessionSnapshotSafely("command") + } + return accepted + }, duplicateObject: async (objectId) => { const accepted = await sendCommand( { @@ -1990,6 +2011,10 @@ export function Viewport() { const handleDocDragOver = (event: DragEvent) => { lastDragX = event.clientX lastDragY = event.clientY + if (event.dataTransfer?.types.includes("application/x-place-actor")) { + event.preventDefault() + event.dataTransfer.dropEffect = "copy" + } } ; (window as any).__axiomViewportDropHandler = (clientX: number, clientY: number, kind: string, path: string) => { @@ -2016,9 +2041,35 @@ export function Viewport() { void sendCommand({ type: "drop_texture", mouseX, mouseY, textureAssetPath: path }, "reliable") } + const handleDocDrop = (event: DragEvent) => { + const templateId = event.dataTransfer?.getData("application/x-place-actor") + if (!templateId) return + event.preventDefault() + const x = event.clientX + const y = event.clientY + const s = viewportShellRef.current + const v = videoRef.current + if (!s || !v || !v.videoWidth || !v.videoHeight) { + void sendCommand({ type: "place_actor", templateId, mouseX: -1, mouseY: -1 }, "reliable") + 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: "place_actor", templateId, mouseX, mouseY }, "reliable") + } + video?.addEventListener("loadedmetadata", handleLoadedMetadata) video?.addEventListener("resize", handleResize) document.addEventListener("dragover", handleDocDragOver) + document.addEventListener("drop", handleDocDrop) document.addEventListener("mousedown", handleMouseDown) document.addEventListener("mouseup", handleMouseUp) document.addEventListener("contextmenu", handleContextMenu) @@ -2035,6 +2086,7 @@ export function Viewport() { video?.removeEventListener("loadedmetadata", handleLoadedMetadata) video?.removeEventListener("resize", handleResize) document.removeEventListener("dragover", handleDocDragOver) + document.removeEventListener("drop", handleDocDrop) ; (window as any).__axiomViewportDropHandler = null document.removeEventListener("mousedown", handleMouseDown) document.removeEventListener("mouseup", handleMouseUp) diff --git a/EditorFrontend/components/panels/place-actors-panel.tsx b/EditorFrontend/components/panels/place-actors-panel.tsx index d91a40f..ddfda4a 100644 --- a/EditorFrontend/components/panels/place-actors-panel.tsx +++ b/EditorFrontend/components/panels/place-actors-panel.tsx @@ -82,7 +82,7 @@ const CATEGORIES: Category[] = [ ] export function PlaceActorsPanel() { - const { createObject } = useRemoteViewport() + const { placeActor } = useRemoteViewport() const [search, setSearch] = useState("") const [activeCategory, setActiveCategory] = useState("all") const [placingId, setPlacingId] = useState(null) @@ -102,12 +102,17 @@ export function PlaceActorsPanel() { if (placingId) return setPlacingId(item.id) try { - await createObject(item.templateId) + await placeActor(item.templateId, -1, -1) } finally { setPlacingId(null) } } + function handleDragStart(event: React.DragEvent, item: PlaceableItem) { + event.dataTransfer.setData("application/x-place-actor", item.templateId) + event.dataTransfer.effectAllowed = "copy" + } + return (
{/* Header */} @@ -166,6 +171,8 @@ export function PlaceActorsPanel() { return (