From 5cf4ac4d2e74ab5c1e82b9e8574baf42b3470007 Mon Sep 17 00:00:00 2001 From: Aiden Date: Tue, 19 May 2026 17:18:25 +1000 Subject: [PATCH] transform values --- README.md | 4 + src/App.tsx | 248 +++++++++++++++--- src/components/DetailsPanel.tsx | 421 ++++++++++++++++++++++++++----- src/hooks/useDetailsEditing.ts | 160 ++++++++++++ src/hooks/useTransformEditing.ts | 122 +++++++++ src/services/actorCache.ts | 118 +++++++++ src/services/components.ts | 73 ++++++ src/services/details.ts | 97 ++++++- src/services/detailsCache.ts | 12 + src/services/remoteControl.ts | 11 + src/services/transform.ts | 257 +++++++++++++++++++ src/services/unrealActors.ts | 8 +- src/styles.css | 104 +++++--- src/types.ts | 38 +++ 14 files changed, 1529 insertions(+), 144 deletions(-) create mode 100644 src/hooks/useDetailsEditing.ts create mode 100644 src/hooks/useTransformEditing.ts create mode 100644 src/services/actorCache.ts create mode 100644 src/services/components.ts create mode 100644 src/services/transform.ts diff --git a/README.md b/README.md index 2c71f5f..0e33f99 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,10 @@ Details payloads are cached in browser `localStorage` for five minutes per objec Asset-looking property values also request `/remote/object/thumbnail` and cache thumbnail data in `localStorage`, so mesh/material fields can render closer to Unreal's Details panel. +Common Details values are editable from the web UI. The app writes via `/remote/object/property` using `WRITE_TRANSACTION_ACCESS`, applies optimistic row updates, then refreshes from Unreal. Selected-object properties poll roughly once per second when no edit is dirty, and the Outliner refreshes in the background to catch actor changes. + +The Details panel also has a pinned Transform section. It reads and writes Location, Rotation, and Scale through common actor/component Remote Control function calls, falling back across actor and scene-component variants when available. + ## Run Start Unreal Editor, enable the Remote Control API, and make sure it is listening on `127.0.0.1:30010`. diff --git a/src/App.tsx b/src/App.tsx index 304cb65..1e30f6e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,12 +3,18 @@ import { ChevronDown, ChevronRight, Eye, Layers, Mountain, PackagePlus, RefreshC import type { ActorReference, ActorRow, ObjectDetails, RemoteTransport, TreeNode } from "./types"; import { DetailsPanel } from "./components/DetailsPanel"; import { OutlinerRow } from "./components/OutlinerRow"; +import { useDetailsEditing } from "./hooks/useDetailsEditing"; +import { useTransformEditing } from "./hooks/useTransformEditing"; import { extractObjectPath, extractReturnValue } from "./lib/remoteValues"; import { buildTree, findNodeById, visibleCount } from "./models/outlinerTree"; import { normalizeDetails } from "./services/details"; +import { applyCachedActor } from "./services/actorCache"; import { readCachedDetails, writeCachedDetails } from "./services/detailsCache"; +import { discoverComponents } from "./services/components"; import { describeRemoteObject, getActiveTransport, readRemoteObjectProperties } from "./services/remoteControl"; -import { enrichActors, getAllLevelActors, normalizeActor } from "./services/unrealActors"; +import { createDefaultTransform, readObjectTransform } from "./services/transform"; +import { enrichActor, getAllLevelActors, normalizeActor } from "./services/unrealActors"; +import type { ObjectComponent, ObjectTransform, TransformTargetKind } from "./types"; const SPLIT_STORAGE_KEY = "unreal-outliner.split-ratio"; const DEFAULT_SPLIT_RATIO = 0.52; @@ -21,6 +27,19 @@ function readSavedSplitRatio(): number { return Number.isFinite(value) ? Math.min(MAX_SPLIT_RATIO, Math.max(MIN_SPLIT_RATIO, value)) : DEFAULT_SPLIT_RATIO; } +function mergeTransformFallback(nextTransform: ObjectTransform | null, previousTransform: ObjectTransform | null): ObjectTransform | null { + if (!nextTransform || !previousTransform || nextTransform.objectPath !== previousTransform.objectPath) { + return nextTransform; + } + + return { + ...nextTransform, + location: nextTransform.readStatus.location ? nextTransform.location : previousTransform.location, + rotation: nextTransform.readStatus.rotation ? nextTransform.rotation : previousTransform.rotation, + scale: nextTransform.readStatus.scale ? nextTransform.scale : previousTransform.scale, + }; +} + export function App() { const [actors, setActors] = React.useState([]); const [query, setQuery] = React.useState(""); @@ -34,69 +53,174 @@ export function App() { const [detailsError, setDetailsError] = React.useState(null); const [isDetailsLoading, setIsDetailsLoading] = React.useState(false); const [splitRatio, setSplitRatio] = React.useState(readSavedSplitRatio); + const [selectedObjectPath, setSelectedObjectPath] = React.useState(null); + const [components, setComponents] = React.useState([]); + const [transform, setTransform] = React.useState(null); const mainContentRef = React.useRef(null); + const selectedObjectPathRef = React.useRef(null); const tree = React.useMemo(() => buildTree(actors), [actors]); const selectedNode = React.useMemo(() => findNodeById(tree, selectedId), [tree, selectedId]); + const selectedTransformTargetKind = React.useCallback( + (objectPath: string | null): TransformTargetKind => (objectPath && selectedNode?.objectPath === objectPath ? "actor" : "component"), + [selectedNode?.objectPath], + ); - const refresh = React.useCallback(async () => { - setIsLoading(true); - setError(null); - setStatus("Loading actors..."); - try { - const payload = await getAllLevelActors(); - const actorRefs: ActorReference[] = extractReturnValue(payload).map((raw) => ({ raw, objectPath: extractObjectPath(raw) })); - const rows = await enrichActors(actorRefs.map(({ raw }, index) => normalizeActor(raw, index))); - setTransport(getActiveTransport()); - setActors(rows); - setExpanded((current) => new Set([...current, "world"])); - setStatus(`Synced ${rows.length} actor${rows.length === 1 ? "" : "s"}`); - } catch (reason) { - const message = reason instanceof Error ? reason.message : "Could not reach Unreal Remote Control"; - setError(message); - setStatus("Connection failed"); - } finally { - setIsLoading(false); + const enrichActorsInBackground = React.useCallback(async (rows: ActorRow[]) => { + for (let index = 0; index < rows.length; index += 4) { + const enrichedBatch = await Promise.all(rows.slice(index, index + 4).map(enrichActor)); + setActors((currentActors) => { + const byId = new Map(enrichedBatch.map((actor) => [actor.id, actor])); + return currentActors.map((actor) => byId.get(actor.id) ?? actor); + }); + await new Promise((resolve) => window.setTimeout(resolve, 20)); } }, []); + const loadActors = React.useCallback(async (silent = false) => { + if (!silent) { + setIsLoading(true); + setError(null); + setStatus("Loading actors..."); + } + + try { + const payload = await getAllLevelActors(); + const actorRefs: ActorReference[] = extractReturnValue(payload).map((raw) => ({ raw, objectPath: extractObjectPath(raw) })); + const rows = actorRefs.map(({ raw }, index) => applyCachedActor(normalizeActor(raw, index))); + setTransport(getActiveTransport()); + setActors(rows); + setExpanded((current) => new Set([...current, "world"])); + if (!silent) { + setStatus(`Synced ${rows.length} actor${rows.length === 1 ? "" : "s"}`); + } + if (!silent) { + void enrichActorsInBackground(rows); + } + } catch (reason) { + if (!silent) { + const message = reason instanceof Error ? reason.message : "Could not reach Unreal Remote Control"; + setError(message); + setStatus("Connection failed"); + } + } finally { + if (!silent) { + setIsLoading(false); + } + } + }, [enrichActorsInBackground]); + + const refresh = React.useCallback(() => loadActors(false), [loadActors]); + React.useEffect(() => { - refresh(); - }, [refresh]); + void loadActors(false); + }, [loadActors]); + + React.useEffect(() => { + const intervalId = window.setInterval(() => { + void loadActors(true); + }, 4000); + + return () => window.clearInterval(intervalId); + }, [loadActors]); + + React.useEffect(() => { + setSelectedObjectPath(selectedNode?.objectPath ?? null); + setComponents([]); + setTransform(selectedNode?.objectPath ? createDefaultTransform(selectedNode.objectPath, "actor") : null); + }, [selectedNode?.objectPath]); + + React.useEffect(() => { + selectedObjectPathRef.current = selectedObjectPath; + }, [selectedObjectPath]); + + const loadDetails = React.useCallback( + async (objectPath: string, useCache: boolean) => { + if (useCache) { + const cachedDetails = readCachedDetails(objectPath); + if (cachedDetails && selectedObjectPathRef.current === objectPath) { + setDetails(cachedDetails); + setIsDetailsLoading(false); + } else if (selectedObjectPathRef.current === objectPath) { + setDetails(null); + setIsDetailsLoading(true); + } + } else { + setIsDetailsLoading(false); + } + + setDetailsError(null); + const [description, properties] = await Promise.all([describeRemoteObject(objectPath), readRemoteObjectProperties(objectPath)]); + const freshDetails = normalizeDetails(objectPath, description, properties); + writeCachedDetails(objectPath, freshDetails); + if (selectedObjectPathRef.current !== objectPath) { + return freshDetails; + } + setDetails(freshDetails); + setTransport(getActiveTransport()); + + if (selectedNode?.objectPath && objectPath === selectedNode.objectPath) { + setComponents(await discoverComponents(objectPath, freshDetails.properties)); + } + + return freshDetails; + }, + [selectedNode?.objectPath], + ); + + const reloadSelectedDetails = React.useCallback(async () => { + if (!selectedObjectPath) { + return; + } + + try { + await loadDetails(selectedObjectPath, false); + const nextTransform = await readObjectTransform(selectedObjectPath, selectedTransformTargetKind(selectedObjectPath)); + setTransform((current) => mergeTransformFallback(nextTransform, current)); + } catch (reason) { + setDetailsError(reason instanceof Error ? reason.message : "Could not load object properties"); + } finally { + setIsDetailsLoading(false); + } + }, [loadDetails, selectedObjectPath]); + + const { editProperty, rowStates, hasPendingEdits, markExternalUpdate } = useDetailsEditing({ + details, + setDetails, + onCommitted: reloadSelectedDetails, + }); + + const { editTransform, transformRowStates, hasPendingTransformEdits, markTransformExternalUpdate } = useTransformEditing({ + transform, + setTransform, + onCommitted: reloadSelectedDetails, + }); React.useEffect(() => { let isCancelled = false; - if (!selectedNode?.objectPath) { + if (!selectedObjectPath) { setDetails(null); + setTransform(null); setDetailsError(null); setIsDetailsLoading(false); return; } - const objectPath = selectedNode.objectPath; - const cachedDetails = readCachedDetails(objectPath); - if (cachedDetails) { - setDetails(cachedDetails); - setIsDetailsLoading(false); - } else { - setDetails(null); - setIsDetailsLoading(true); - } - setDetailsError(null); + const targetKind = selectedTransformTargetKind(selectedObjectPath); + setTransform((current) => (current?.objectPath === selectedObjectPath ? current : createDefaultTransform(selectedObjectPath, targetKind))); - Promise.all([describeRemoteObject(objectPath), readRemoteObjectProperties(objectPath)]) - .then(([description, properties]) => { + const previousTransform = transform; + Promise.all([loadDetails(selectedObjectPath, true), readObjectTransform(selectedObjectPath, targetKind)]) + .then(([, nextTransform]) => { if (!isCancelled) { - const freshDetails = normalizeDetails(description, properties); - writeCachedDetails(objectPath, freshDetails); - setDetails(freshDetails); - setTransport(getActiveTransport()); + setTransform((current) => mergeTransformFallback(nextTransform, current ?? previousTransform)); } }) .catch((reason) => { if (!isCancelled) { setDetails(null); + setTransform(null); setDetailsError(reason instanceof Error ? reason.message : "Could not load object properties"); } }) @@ -109,7 +233,38 @@ export function App() { return () => { isCancelled = true; }; - }, [selectedNode?.objectPath]); + }, [loadDetails, selectedObjectPath, selectedTransformTargetKind]); + + React.useEffect(() => { + if (!selectedObjectPath || hasPendingEdits || hasPendingTransformEdits) { + return; + } + + const intervalId = window.setInterval(() => { + Promise.all([ + describeRemoteObject(selectedObjectPath), + readRemoteObjectProperties(selectedObjectPath), + readObjectTransform(selectedObjectPath, selectedTransformTargetKind(selectedObjectPath)), + ]) + .then(([description, properties, nextTransform]) => { + const freshDetails = normalizeDetails(selectedObjectPath, description, properties); + markExternalUpdate(freshDetails); + if (nextTransform) { + const mergedTransform = mergeTransformFallback(nextTransform, transform) ?? nextTransform; + markTransformExternalUpdate(mergedTransform); + setTransform(mergedTransform); + } + setDetails(freshDetails); + writeCachedDetails(selectedObjectPath, freshDetails); + setTransport(getActiveTransport()); + }) + .catch(() => { + // Background refresh is best effort; foreground loads surface errors. + }); + }, 1000); + + return () => window.clearInterval(intervalId); + }, [hasPendingEdits, hasPendingTransformEdits, markExternalUpdate, markTransformExternalUpdate, selectedObjectPath, selectedTransformTargetKind, transform]); const toggleNode = (node: TreeNode) => { setExpanded((current) => { @@ -268,7 +423,20 @@ export function App() { - +