1
0

transform values

This commit is contained in:
2026-05-19 17:18:25 +10:00
parent 8b08d9571e
commit 5cf4ac4d2e
14 changed files with 1529 additions and 144 deletions

View File

@@ -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. 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 ## Run
Start Unreal Editor, enable the Remote Control API, and make sure it is listening on `127.0.0.1:30010`. Start Unreal Editor, enable the Remote Control API, and make sure it is listening on `127.0.0.1:30010`.

View File

@@ -3,12 +3,18 @@ import { ChevronDown, ChevronRight, Eye, Layers, Mountain, PackagePlus, RefreshC
import type { ActorReference, ActorRow, ObjectDetails, RemoteTransport, TreeNode } from "./types"; import type { ActorReference, ActorRow, ObjectDetails, RemoteTransport, TreeNode } from "./types";
import { DetailsPanel } from "./components/DetailsPanel"; import { DetailsPanel } from "./components/DetailsPanel";
import { OutlinerRow } from "./components/OutlinerRow"; import { OutlinerRow } from "./components/OutlinerRow";
import { useDetailsEditing } from "./hooks/useDetailsEditing";
import { useTransformEditing } from "./hooks/useTransformEditing";
import { extractObjectPath, extractReturnValue } from "./lib/remoteValues"; import { extractObjectPath, extractReturnValue } from "./lib/remoteValues";
import { buildTree, findNodeById, visibleCount } from "./models/outlinerTree"; import { buildTree, findNodeById, visibleCount } from "./models/outlinerTree";
import { normalizeDetails } from "./services/details"; import { normalizeDetails } from "./services/details";
import { applyCachedActor } from "./services/actorCache";
import { readCachedDetails, writeCachedDetails } from "./services/detailsCache"; import { readCachedDetails, writeCachedDetails } from "./services/detailsCache";
import { discoverComponents } from "./services/components";
import { describeRemoteObject, getActiveTransport, readRemoteObjectProperties } from "./services/remoteControl"; 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 SPLIT_STORAGE_KEY = "unreal-outliner.split-ratio";
const DEFAULT_SPLIT_RATIO = 0.52; 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; 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() { export function App() {
const [actors, setActors] = React.useState<ActorRow[]>([]); const [actors, setActors] = React.useState<ActorRow[]>([]);
const [query, setQuery] = React.useState(""); const [query, setQuery] = React.useState("");
@@ -34,69 +53,174 @@ export function App() {
const [detailsError, setDetailsError] = React.useState<string | null>(null); const [detailsError, setDetailsError] = React.useState<string | null>(null);
const [isDetailsLoading, setIsDetailsLoading] = React.useState(false); const [isDetailsLoading, setIsDetailsLoading] = React.useState(false);
const [splitRatio, setSplitRatio] = React.useState(readSavedSplitRatio); const [splitRatio, setSplitRatio] = React.useState(readSavedSplitRatio);
const [selectedObjectPath, setSelectedObjectPath] = React.useState<string | null>(null);
const [components, setComponents] = React.useState<ObjectComponent[]>([]);
const [transform, setTransform] = React.useState<ObjectTransform | null>(null);
const mainContentRef = React.useRef<HTMLDivElement | null>(null); const mainContentRef = React.useRef<HTMLDivElement | null>(null);
const selectedObjectPathRef = React.useRef<string | null>(null);
const tree = React.useMemo(() => buildTree(actors), [actors]); const tree = React.useMemo(() => buildTree(actors), [actors]);
const selectedNode = React.useMemo(() => findNodeById(tree, selectedId), [tree, selectedId]); 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 () => { const enrichActorsInBackground = React.useCallback(async (rows: ActorRow[]) => {
setIsLoading(true); for (let index = 0; index < rows.length; index += 4) {
setError(null); const enrichedBatch = await Promise.all(rows.slice(index, index + 4).map(enrichActor));
setStatus("Loading actors..."); setActors((currentActors) => {
try { const byId = new Map(enrichedBatch.map((actor) => [actor.id, actor]));
const payload = await getAllLevelActors(); return currentActors.map((actor) => byId.get(actor.id) ?? actor);
const actorRefs: ActorReference[] = extractReturnValue(payload).map((raw) => ({ raw, objectPath: extractObjectPath(raw) })); });
const rows = await enrichActors(actorRefs.map(({ raw }, index) => normalizeActor(raw, index))); await new Promise((resolve) => window.setTimeout(resolve, 20));
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 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(() => { React.useEffect(() => {
refresh(); void loadActors(false);
}, [refresh]); }, [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(() => { React.useEffect(() => {
let isCancelled = false; let isCancelled = false;
if (!selectedNode?.objectPath) { if (!selectedObjectPath) {
setDetails(null); setDetails(null);
setTransform(null);
setDetailsError(null); setDetailsError(null);
setIsDetailsLoading(false); setIsDetailsLoading(false);
return; return;
} }
const objectPath = selectedNode.objectPath; const targetKind = selectedTransformTargetKind(selectedObjectPath);
const cachedDetails = readCachedDetails(objectPath); setTransform((current) => (current?.objectPath === selectedObjectPath ? current : createDefaultTransform(selectedObjectPath, targetKind)));
if (cachedDetails) {
setDetails(cachedDetails);
setIsDetailsLoading(false);
} else {
setDetails(null);
setIsDetailsLoading(true);
}
setDetailsError(null);
Promise.all([describeRemoteObject(objectPath), readRemoteObjectProperties(objectPath)]) const previousTransform = transform;
.then(([description, properties]) => { Promise.all([loadDetails(selectedObjectPath, true), readObjectTransform(selectedObjectPath, targetKind)])
.then(([, nextTransform]) => {
if (!isCancelled) { if (!isCancelled) {
const freshDetails = normalizeDetails(description, properties); setTransform((current) => mergeTransformFallback(nextTransform, current ?? previousTransform));
writeCachedDetails(objectPath, freshDetails);
setDetails(freshDetails);
setTransport(getActiveTransport());
} }
}) })
.catch((reason) => { .catch((reason) => {
if (!isCancelled) { if (!isCancelled) {
setDetails(null); setDetails(null);
setTransform(null);
setDetailsError(reason instanceof Error ? reason.message : "Could not load object properties"); setDetailsError(reason instanceof Error ? reason.message : "Could not load object properties");
} }
}) })
@@ -109,7 +233,38 @@ export function App() {
return () => { return () => {
isCancelled = true; 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) => { const toggleNode = (node: TreeNode) => {
setExpanded((current) => { setExpanded((current) => {
@@ -268,7 +423,20 @@ export function App() {
<span /> <span />
</div> </div>
<DetailsPanel node={selectedNode} details={details} isLoading={isDetailsLoading} error={detailsError} /> <DetailsPanel
node={selectedNode}
details={details}
components={components}
selectedObjectPath={selectedObjectPath}
transform={transform}
transformRowStates={transformRowStates}
isLoading={isDetailsLoading}
error={detailsError}
rowStates={rowStates}
onEditProperty={editProperty}
onEditTransform={editTransform}
onSelectObjectPath={setSelectedObjectPath}
/>
</div> </div>
<footer className="footer"> <footer className="footer">

View File

@@ -1,6 +1,15 @@
import React from "react"; import React from "react";
import { Box, ChevronDown, Grid2X2, RotateCcw, Search, Settings, SlidersHorizontal, Star, X } from "lucide-react"; import { Box, ChevronDown, Grid2X2, RotateCcw, Search, Settings, SlidersHorizontal, Star, X } from "lucide-react";
import type { DetailProperty, ObjectDetails, TreeNode } from "../types"; import type {
DetailProperty,
DetailRowStatus,
ObjectComponent,
ObjectDetails,
ObjectTransform,
TransformField,
TransformVector,
TreeNode,
} from "../types";
import { basename } from "../lib/remoteValues"; import { basename } from "../lib/remoteValues";
import { import {
detailAssetPath, detailAssetPath,
@@ -9,9 +18,11 @@ import {
isVectorLikeProperty, isVectorLikeProperty,
normalizeThumbnailPayload, normalizeThumbnailPayload,
vectorParts, vectorParts,
withUpdatedVectorPart,
} from "../services/details"; } from "../services/details";
import { readRemoteObjectThumbnail } from "../services/remoteControl"; import { readRemoteObjectThumbnail } from "../services/remoteControl";
import { readCachedThumbnail, writeCachedThumbnail } from "../services/thumbnailCache"; import { readCachedThumbnail, writeCachedThumbnail } from "../services/thumbnailCache";
import { detailPropertyKey } from "../hooks/useDetailsEditing";
import { NodeIcon } from "./NodeIcon"; import { NodeIcon } from "./NodeIcon";
const emptyNode: TreeNode = { const emptyNode: TreeNode = {
@@ -24,9 +35,250 @@ const emptyNode: TreeNode = {
children: [], children: [],
}; };
const categoryTabs = ["General", "Actor", "LOD", "Misc", "Physics", "Rendering", "Streaming", "All"]; function NumberInput({
property,
status,
onEdit,
}: {
property: DetailProperty;
status: DetailRowStatus;
onEdit: (property: DetailProperty, value: unknown, commitImmediately?: boolean) => void;
}) {
const [draft, setDraft] = React.useState(formatDetailValue(property.value));
function DetailValue({ property }: { property: DetailProperty }) { React.useEffect(() => {
if (status !== "dirty") {
setDraft(formatDetailValue(property.value));
}
}, [property.value, status]);
const commit = () => {
if (Number.isFinite(Number(draft))) {
onEdit(property, draft, true);
} else {
setDraft(formatDetailValue(property.value));
}
};
return (
<input
className="text-value"
disabled={!property.editable || status === "saving"}
inputMode="decimal"
onBlur={commit}
onChange={(event) => {
setDraft(event.target.value);
if (Number.isFinite(Number(event.target.value))) {
onEdit(property, event.target.value);
}
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
commit();
}
}}
type="text"
value={draft}
/>
);
}
function VectorInput({
property,
status,
onEdit,
}: {
property: DetailProperty;
status: DetailRowStatus;
onEdit: (property: DetailProperty, value: unknown, commitImmediately?: boolean) => void;
}) {
const parts = vectorParts(property.value);
const [drafts, setDrafts] = React.useState<Record<string, string>>(() =>
Object.fromEntries(parts.map((part) => [part.label, part.value])),
);
React.useEffect(() => {
if (status !== "dirty") {
setDrafts(Object.fromEntries(vectorParts(property.value).map((part) => [part.label, part.value])));
}
}, [property.value, status]);
const commit = (label: string) => {
const draft = drafts[label];
if (Number.isFinite(Number(draft))) {
onEdit(property, withUpdatedVectorPart(property, label, draft), true);
} else {
setDrafts(Object.fromEntries(vectorParts(property.value).map((part) => [part.label, part.value])));
}
};
return (
<div className="vector-value">
{parts.map((part, index) => (
<input
className={`axis axis-${index}`}
disabled={!property.editable || status === "saving"}
inputMode="decimal"
key={part.label}
onBlur={() => commit(part.label)}
onChange={(event) => {
const nextDraft = event.target.value;
setDrafts((current) => ({ ...current, [part.label]: nextDraft }));
if (Number.isFinite(Number(nextDraft))) {
onEdit(property, withUpdatedVectorPart(property, part.label, nextDraft));
}
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
commit(part.label);
}
}}
type="text"
value={drafts[part.label] ?? part.value}
/>
))}
</div>
);
}
function transformParts(value: TransformVector): Array<{ label: "x" | "y" | "z"; value: string }> {
return [
{ label: "x", value: formatDetailValue(value.x) },
{ label: "y", value: formatDetailValue(value.y) },
{ label: "z", value: formatDetailValue(value.z) },
];
}
function TransformInput({
field,
value,
status,
onEdit,
}: {
field: TransformField;
value: TransformVector;
status: DetailRowStatus;
onEdit: (field: TransformField, value: TransformVector, commitImmediately?: boolean) => void;
}) {
const parts = transformParts(value);
const focusedAxisRef = React.useRef<"x" | "y" | "z" | null>(null);
const [drafts, setDrafts] = React.useState<Record<"x" | "y" | "z", string>>({
x: parts[0].value,
y: parts[1].value,
z: parts[2].value,
});
React.useEffect(() => {
if (status !== "dirty" && focusedAxisRef.current === null) {
const nextParts = transformParts(value);
setDrafts({ x: nextParts[0].value, y: nextParts[1].value, z: nextParts[2].value });
}
}, [status, value]);
const nextVector = (axis: "x" | "y" | "z", draft: string): TransformVector | null => {
const numericValue = Number(draft);
if (!Number.isFinite(numericValue)) {
return null;
}
return {
...value,
[axis]: numericValue,
};
};
const commit = (axis: "x" | "y" | "z") => {
focusedAxisRef.current = null;
const next = nextVector(axis, drafts[axis]);
if (next) {
onEdit(field, next, true);
} else {
const nextParts = transformParts(value);
setDrafts({ x: nextParts[0].value, y: nextParts[1].value, z: nextParts[2].value });
}
};
return (
<div className="vector-value transform-vector">
{parts.map((part, index) => (
<input
className={`axis axis-${index}`}
inputMode="decimal"
key={part.label}
onFocus={() => {
focusedAxisRef.current = part.label;
}}
onBlur={() => commit(part.label)}
onChange={(event) => {
const draft = event.target.value;
setDrafts((current) => ({ ...current, [part.label]: draft }));
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
commit(part.label);
}
}}
type="text"
value={drafts[part.label]}
/>
))}
</div>
);
}
function TransformSection({
transform,
rowStates,
onEdit,
}: {
transform: ObjectTransform | null;
rowStates: Record<TransformField, { status: DetailRowStatus; error?: string }>;
onEdit: (field: TransformField, value: TransformVector, commitImmediately?: boolean) => void;
}) {
if (!transform) {
return null;
}
const rows: Array<{ field: TransformField; label: string }> = [
{ field: "location", label: "Location" },
{ field: "rotation", label: "Rotation" },
{ field: "scale", label: "Scale" },
];
return (
<section className="detail-category transform-section">
<h3>
<ChevronDown size={16} />
Transform
</h3>
{rows.map((row) => (
<div
className={`detail-row ${rowStates[row.field]?.status ?? "clean"}`}
key={row.field}
title={rowStates[row.field]?.error || row.label}
>
<span className="detail-name">{row.label}</span>
<span className="detail-value">
<TransformInput field={row.field} value={transform[row.field]} status={rowStates[row.field]?.status ?? "clean"} onEdit={onEdit} />
<button className="reset-button row-reset" type="button" title="Reset to default">
<RotateCcw size={15} />
</button>
</span>
</div>
))}
</section>
);
}
function DetailValue({
property,
status,
onEdit,
}: {
property: DetailProperty;
status: DetailRowStatus;
onEdit: (property: DetailProperty, value: unknown, commitImmediately?: boolean) => void;
}) {
const assetPath = detailAssetPath(property); const assetPath = detailAssetPath(property);
const [thumbnail, setThumbnail] = React.useState(() => (assetPath ? readCachedThumbnail(assetPath) : "")); const [thumbnail, setThumbnail] = React.useState(() => (assetPath ? readCachedThumbnail(assetPath) : ""));
@@ -76,23 +328,44 @@ function DetailValue({ property }: { property: DetailProperty }) {
} }
if (isVectorLikeProperty(property)) { if (isVectorLikeProperty(property)) {
return ( return <VectorInput property={property} status={status} onEdit={onEdit} />;
<div className="vector-value">
{vectorParts(property.value).map((part, index) => (
<span className={`axis axis-${index}`} key={part.label}>
{part.value}
</span>
))}
</div>
);
} }
if (isBooleanProperty(property)) { if (isBooleanProperty(property)) {
return <span className={`checkbox-value ${property.value ? "checked" : ""}`} />; return (
<button
className={`checkbox-value ${property.value ? "checked" : ""}`}
disabled={!property.editable || status === "saving"}
onClick={() => onEdit(property, !property.value, true)}
type="button"
/>
);
}
if (property.controlKind === "number") {
return <NumberInput property={property} status={status} onEdit={onEdit} />;
}
if (property.controlKind === "string") {
return (
<input
className="text-value"
disabled={!property.editable || status === "saving"}
onBlur={(event) => onEdit(property, event.target.value, true)}
onChange={(event) => onEdit(property, event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
onEdit(property, event.currentTarget.value, true);
}
}}
type="text"
value={String(property.value ?? "")}
/>
);
} }
return ( return (
<div className="text-value"> <div className={`text-value ${property.editable ? "" : "readonly-value"}`}>
<span>{formatDetailValue(property.value)}</span> <span>{formatDetailValue(property.value)}</span>
</div> </div>
); );
@@ -101,22 +374,35 @@ function DetailValue({ property }: { property: DetailProperty }) {
export function DetailsPanel({ export function DetailsPanel({
node, node,
details, details,
components,
selectedObjectPath,
transform,
transformRowStates,
isLoading, isLoading,
error, error,
rowStates,
onEditProperty,
onEditTransform,
onSelectObjectPath,
}: { }: {
node: TreeNode | null; node: TreeNode | null;
details: ObjectDetails | null; details: ObjectDetails | null;
components: ObjectComponent[];
selectedObjectPath: string | null;
transform: ObjectTransform | null;
transformRowStates: Record<TransformField, { status: DetailRowStatus; error?: string }>;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
rowStates: Record<string, { status: DetailRowStatus; error?: string }>;
onEditProperty: (property: DetailProperty, value: unknown, commitImmediately?: boolean) => void;
onEditTransform: (field: TransformField, value: TransformVector, commitImmediately?: boolean) => void;
onSelectObjectPath: (objectPath: string) => void;
}) { }) {
const [query, setQuery] = React.useState(""); const [query, setQuery] = React.useState("");
const [activeCategory, setActiveCategory] = React.useState("All");
const groupedProperties = React.useMemo(() => { const groupedProperties = React.useMemo(() => {
const lowerQuery = query.toLowerCase(); const lowerQuery = query.toLowerCase();
const visibleProperties = (details?.properties ?? []).filter((property) => { const visibleProperties = (details?.properties ?? []).filter((property) => {
const matchesCategory = activeCategory === "All" || property.category.toLowerCase().includes(activeCategory.toLowerCase());
if (!matchesCategory) return false;
if (!lowerQuery) return true; if (!lowerQuery) return true;
return [property.displayName, property.name, property.type, property.category, formatDetailValue(property.value)].some((field) => return [property.displayName, property.name, property.type, property.category, formatDetailValue(property.value)].some((field) =>
field.toLowerCase().includes(lowerQuery), field.toLowerCase().includes(lowerQuery),
@@ -129,7 +415,7 @@ export function DetailsPanel({
groups[category].push(property); groups[category].push(property);
return groups; return groups;
}, {}); }, {});
}, [activeCategory, details, query]); }, [details, query]);
return ( return (
<aside className="details-panel" aria-label="Details"> <aside className="details-panel" aria-label="Details">
@@ -153,16 +439,29 @@ export function DetailsPanel({
</div> </div>
</div> </div>
{node?.objectPath ? (
<div className="component-list"> <div className="component-list">
<div className="component-row selected"> <button
className={`component-row ${selectedObjectPath === node.objectPath ? "selected" : ""}`}
onClick={() => onSelectObjectPath(node.objectPath!)}
type="button"
>
<NodeIcon node={node ?? emptyNode} /> <NodeIcon node={node ?? emptyNode} />
<span>{node ? `${node.label} (Instance)` : "No Selection"}</span> <span>{node ? `${node.label} (Instance)` : "No Selection"}</span>
</div> </button>
<div className="component-row child"> {components.map((component) => (
<button
className={`component-row child ${selectedObjectPath === component.objectPath ? "selected" : ""}`}
key={component.id}
onClick={() => onSelectObjectPath(component.objectPath)}
type="button"
>
<Box size={18} /> <Box size={18} />
<span>{node?.type ? `${node.type}Component (${node.type}Component0)` : "Component"}</span> <span>{component.label}</span>
</div> </button>
))}
</div> </div>
) : null}
<div className="details-search-row"> <div className="details-search-row">
<label className="details-search"> <label className="details-search">
@@ -180,40 +479,43 @@ export function DetailsPanel({
</button> </button>
</div> </div>
<div className="category-tabs">
{categoryTabs.map((category) => (
<button
className={activeCategory === category ? "active" : ""}
key={category}
type="button"
onClick={() => setActiveCategory(category)}
>
{category}
</button>
))}
</div>
<div className="details-categories"> <div className="details-categories">
{isLoading ? ( {isLoading ? (
<>
<TransformSection transform={transform} rowStates={transformRowStates} onEdit={onEditTransform} />
<div className="details-empty">Loading properties...</div> <div className="details-empty">Loading properties...</div>
</>
) : error ? ( ) : error ? (
<div className="details-empty">{error}</div> <div className="details-empty">{error}</div>
) : !node?.objectPath ? ( ) : !node?.objectPath ? (
<div className="details-empty">Select an actor to inspect exposed properties.</div> <div className="details-empty">Select an actor to inspect exposed properties.</div>
) : Object.keys(groupedProperties).length === 0 ? ( ) : Object.keys(groupedProperties).length === 0 ? (
<div className="details-empty">No readable properties found.</div> <>
<TransformSection transform={transform} rowStates={transformRowStates} onEdit={onEditTransform} />
{!transform ? <div className="details-empty">No readable properties found.</div> : null}
</>
) : ( ) : (
Object.entries(groupedProperties).map(([category, properties]) => ( <>
<TransformSection transform={transform} rowStates={transformRowStates} onEdit={onEditTransform} />
{Object.entries(groupedProperties).map(([category, properties]) => (
<section className="detail-category" key={category}> <section className="detail-category" key={category}>
<h3> <h3>
<ChevronDown size={16} /> <ChevronDown size={16} />
{category} {category}
</h3> </h3>
{properties.map((property) => ( {properties.map((property) => (
<div className="detail-row" key={property.name} title={property.description || property.type}> <div
className={`detail-row ${rowStates[detailPropertyKey(property)]?.status ?? "clean"} ${property.editable ? "" : "read-only"}`}
key={property.name}
title={rowStates[detailPropertyKey(property)]?.error || property.description || property.type}
>
<span className="detail-name">{property.displayName}</span> <span className="detail-name">{property.displayName}</span>
<span className="detail-value"> <span className="detail-value">
<DetailValue property={property} /> <DetailValue
property={property}
status={rowStates[detailPropertyKey(property)]?.status ?? "clean"}
onEdit={onEditProperty}
/>
<button className="reset-button row-reset" type="button" title="Reset to default"> <button className="reset-button row-reset" type="button" title="Reset to default">
<RotateCcw size={15} /> <RotateCcw size={15} />
</button> </button>
@@ -221,7 +523,8 @@ export function DetailsPanel({
</div> </div>
))} ))}
</section> </section>
)) ))}
</>
)} )}
</div> </div>
</aside> </aside>

View File

@@ -0,0 +1,160 @@
import React from "react";
import type { DetailProperty, DetailRowStatus, ObjectDetails } from "../types";
import { coerceDetailInputValue } from "../services/details";
import { invalidateCachedDetails, writeCachedDetails } from "../services/detailsCache";
import { writeRemoteObjectProperty } from "../services/remoteControl";
type RowState = {
status: DetailRowStatus;
error?: string;
};
function propertyKey(property: DetailProperty): string {
return `${property.sourceObjectPath}:${property.propertyPath}`;
}
function updateDetailsProperty(details: ObjectDetails | null, key: string, value: unknown): ObjectDetails | null {
if (!details) {
return details;
}
return {
...details,
properties: details.properties.map((property) => {
if (propertyKey(property) !== key) {
return property;
}
return {
...property,
value,
writeValue: value,
};
}),
};
}
export function useDetailsEditing({
details,
setDetails,
onCommitted,
}: {
details: ObjectDetails | null;
setDetails: React.Dispatch<React.SetStateAction<ObjectDetails | null>>;
onCommitted: () => Promise<void> | void;
}) {
const [rowStates, setRowStates] = React.useState<Record<string, RowState>>({});
const debounceTimers = React.useRef<Record<string, number>>({});
const latestDetails = React.useRef<ObjectDetails | null>(details);
React.useEffect(() => {
latestDetails.current = details;
}, [details]);
React.useEffect(() => {
return () => {
Object.values(debounceTimers.current).forEach((timerId) => window.clearTimeout(timerId));
};
}, []);
const hasPendingEdits = React.useMemo(
() => Object.values(rowStates).some((rowState) => rowState.status === "dirty" || rowState.status === "saving"),
[rowStates],
);
const setRowState = React.useCallback((key: string, state: RowState) => {
setRowStates((current) => ({ ...current, [key]: state }));
}, []);
const commitProperty = React.useCallback(
async (property: DetailProperty, value: unknown) => {
const key = propertyKey(property);
if (!property.editable || !property.sourceObjectPath || !property.propertyPath) {
setRowState(key, { status: "error", error: "Property is read-only" });
return;
}
window.clearTimeout(debounceTimers.current[key]);
setRowState(key, { status: "saving" });
try {
await writeRemoteObjectProperty(property.sourceObjectPath, property.propertyPath, value);
invalidateCachedDetails(property.sourceObjectPath);
const nextDetails = updateDetailsProperty(latestDetails.current, key, value);
if (nextDetails) {
writeCachedDetails(property.sourceObjectPath, nextDetails);
}
setRowState(key, { status: "clean" });
await onCommitted();
} catch (reason) {
setRowState(key, {
status: "error",
error: reason instanceof Error ? reason.message : "Could not write property",
});
await onCommitted();
}
},
[onCommitted, setRowState],
);
const editProperty = React.useCallback(
(property: DetailProperty, value: unknown, commitImmediately = false) => {
const key = propertyKey(property);
const coercedValue = coerceDetailInputValue(property, value);
setDetails((current) => updateDetailsProperty(current, key, coercedValue));
setRowState(key, { status: "dirty" });
window.clearTimeout(debounceTimers.current[key]);
if (commitImmediately) {
void commitProperty(property, coercedValue);
return;
}
debounceTimers.current[key] = window.setTimeout(() => {
void commitProperty(property, coercedValue);
}, 250);
},
[commitProperty, setDetails, setRowState],
);
const markExternalUpdate = React.useCallback((updatedDetails: ObjectDetails) => {
const current = latestDetails.current;
if (!current || current.objectPath !== updatedDetails.objectPath || hasPendingEdits) {
return;
}
const changedKeys = new Set<string>();
for (const updatedProperty of updatedDetails.properties) {
const currentProperty = current.properties.find((property) => property.propertyPath === updatedProperty.propertyPath);
if (currentProperty && JSON.stringify(currentProperty.value) !== JSON.stringify(updatedProperty.value)) {
changedKeys.add(propertyKey(updatedProperty));
}
}
if (changedKeys.size === 0) {
return;
}
setRowStates((currentStates) => {
const nextStates = { ...currentStates };
changedKeys.forEach((key) => {
nextStates[key] = { status: "external" };
window.setTimeout(() => {
setRowStates((laterStates) => (laterStates[key]?.status === "external" ? { ...laterStates, [key]: { status: "clean" } } : laterStates));
}, 1200);
});
return nextStates;
});
}, [hasPendingEdits]);
return {
editProperty,
rowStates,
hasPendingEdits,
markExternalUpdate,
};
}
export function detailPropertyKey(property: DetailProperty): string {
return propertyKey(property);
}

View File

@@ -0,0 +1,122 @@
import React from "react";
import type { DetailRowStatus, ObjectTransform, TransformField, TransformVector } from "../types";
import { writeObjectTransformField } from "../services/transform";
type TransformRowState = Record<TransformField, { status: DetailRowStatus; error?: string }>;
const cleanStates: TransformRowState = {
location: { status: "clean" },
rotation: { status: "clean" },
scale: { status: "clean" },
};
export function useTransformEditing({
transform,
setTransform,
onCommitted,
}: {
transform: ObjectTransform | null;
setTransform: React.Dispatch<React.SetStateAction<ObjectTransform | null>>;
onCommitted: () => Promise<void> | void;
}) {
const [rowStates, setRowStates] = React.useState<TransformRowState>(cleanStates);
const timers = React.useRef<Partial<Record<TransformField, number>>>({});
const latestTransform = React.useRef<ObjectTransform | null>(transform);
React.useEffect(() => {
latestTransform.current = transform;
}, [transform]);
React.useEffect(() => {
return () => {
Object.values(timers.current).forEach((timerId) => window.clearTimeout(timerId));
};
}, []);
const hasPendingTransformEdits = React.useMemo(
() => Object.values(rowStates).some((rowState) => rowState.status === "dirty" || rowState.status === "saving"),
[rowStates],
);
const setFieldState = React.useCallback((field: TransformField, status: DetailRowStatus, error?: string) => {
setRowStates((current) => ({ ...current, [field]: { status, error } }));
}, []);
const commitTransform = React.useCallback(
async (field: TransformField, value: TransformVector) => {
const current = latestTransform.current;
if (!current) {
return;
}
window.clearTimeout(timers.current[field]);
setFieldState(field, "saving");
try {
await writeObjectTransformField(current.objectPath, current.targetKind, field, value);
setTransform((latest) =>
latest?.objectPath === current.objectPath
? {
...latest,
[field]: value,
readStatus: {
...latest.readStatus,
[field]: true,
},
}
: latest,
);
setFieldState(field, "clean");
await onCommitted();
} catch (reason) {
setFieldState(field, "error", reason instanceof Error ? reason.message : "Could not write transform");
await onCommitted();
}
},
[onCommitted, setFieldState],
);
const editTransform = React.useCallback(
(field: TransformField, value: TransformVector, commitImmediately = false) => {
setTransform((current) => (current ? { ...current, [field]: value } : current));
setFieldState(field, "dirty");
window.clearTimeout(timers.current[field]);
if (commitImmediately) {
void commitTransform(field, value);
return;
}
timers.current[field] = window.setTimeout(() => {
void commitTransform(field, value);
}, 250);
},
[commitTransform, setFieldState, setTransform],
);
const markTransformExternalUpdate = React.useCallback(
(nextTransform: ObjectTransform) => {
const current = latestTransform.current;
if (!current || current.objectPath !== nextTransform.objectPath || hasPendingTransformEdits) {
return;
}
(["location", "rotation", "scale"] as TransformField[]).forEach((field) => {
if (JSON.stringify(current[field]) !== JSON.stringify(nextTransform[field])) {
setFieldState(field, "external");
window.setTimeout(() => {
setRowStates((states) => (states[field]?.status === "external" ? { ...states, [field]: { status: "clean" } } : states));
}, 1200);
}
});
},
[hasPendingTransformEdits, setFieldState],
);
return {
editTransform,
transformRowStates: rowStates,
hasPendingTransformEdits,
markTransformExternalUpdate,
};
}

118
src/services/actorCache.ts Normal file
View File

@@ -0,0 +1,118 @@
import type { ActorRow } from "../types";
const CACHE_PREFIX = "unreal-outliner.actor.v1.";
const CACHE_TTL_MS = 10 * 60 * 1000;
const MAX_CACHE_ENTRIES = 1000;
type CachedActor = {
objectPath: string;
savedAt: number;
actor: Pick<ActorRow, "label" | "type" | "level" | "idName" | "folderPath">;
};
function cacheKey(objectPath: string): string {
return `${CACHE_PREFIX}${objectPath}`;
}
function storageAvailable(): boolean {
try {
const key = `${CACHE_PREFIX}probe`;
window.localStorage.setItem(key, "1");
window.localStorage.removeItem(key);
return true;
} catch {
return false;
}
}
function isCachedActor(value: unknown): value is CachedActor {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as CachedActor;
return typeof candidate.objectPath === "string" && typeof candidate.savedAt === "number" && Boolean(candidate.actor);
}
export function applyCachedActor(actor: ActorRow): ActorRow {
if (!actor.objectPath || !storageAvailable()) {
return actor;
}
try {
const raw = window.localStorage.getItem(cacheKey(actor.objectPath));
if (!raw) {
return actor;
}
const cached = JSON.parse(raw);
if (!isCachedActor(cached) || cached.objectPath !== actor.objectPath || Date.now() - cached.savedAt > CACHE_TTL_MS) {
window.localStorage.removeItem(cacheKey(actor.objectPath));
return actor;
}
return {
...actor,
...cached.actor,
};
} catch {
return actor;
}
}
export function writeCachedActor(actor: ActorRow): void {
if (!actor.objectPath || !storageAvailable()) {
return;
}
try {
const cached: CachedActor = {
objectPath: actor.objectPath,
savedAt: Date.now(),
actor: {
label: actor.label,
type: actor.type,
level: actor.level,
idName: actor.idName,
folderPath: actor.folderPath,
},
};
window.localStorage.setItem(cacheKey(actor.objectPath), JSON.stringify(cached));
pruneActorCache();
} catch {
pruneActorCache();
}
}
function pruneActorCache(): void {
const entries: Array<{ key: string; savedAt: number }> = [];
for (let index = 0; index < window.localStorage.length; index += 1) {
const key = window.localStorage.key(index);
if (!key?.startsWith(CACHE_PREFIX)) {
continue;
}
try {
const cached = JSON.parse(window.localStorage.getItem(key) ?? "");
if (isCachedActor(cached)) {
entries.push({ key, savedAt: cached.savedAt });
}
} catch {
window.localStorage.removeItem(key);
}
}
const expiredBefore = Date.now() - CACHE_TTL_MS;
for (const entry of entries) {
if (entry.savedAt < expiredBefore) {
window.localStorage.removeItem(entry.key);
}
}
entries
.filter((entry) => entry.savedAt >= expiredBefore)
.sort((a, b) => b.savedAt - a.savedAt)
.slice(MAX_CACHE_ENTRIES)
.forEach((entry) => window.localStorage.removeItem(entry.key));
}

View File

@@ -0,0 +1,73 @@
import type { DetailProperty, ObjectComponent } from "../types";
import { asRecord, basename, cleanObjectName, extractObjectPath, extractReturnValue, firstString } from "../lib/remoteValues";
import { callRemoteFunction, RemoteCallError } from "./remoteControl";
const disabledComponentCalls = new Set<string>();
function normalizeComponent(value: unknown, index: number): ObjectComponent | null {
const objectPath = extractObjectPath(value);
if (!objectPath) {
return null;
}
const record = asRecord(value);
const label = firstString(record, ["Name", "name", "Label", "label"], cleanObjectName(basename(objectPath)));
const type = firstString(record, ["Class", "class", "Type", "type"], cleanObjectName(basename(objectPath).replace(/\d+$/, "")));
return {
id: objectPath || `${label}-${index}`,
label,
type,
objectPath,
};
}
async function tryComponentCall(actorPath: string, functionName: string, parameters = {}): Promise<ObjectComponent[]> {
if (disabledComponentCalls.has(functionName)) {
return [];
}
try {
const payload = await callRemoteFunction(actorPath, functionName, parameters);
return extractReturnValue(payload)
.map(normalizeComponent)
.filter((component): component is ObjectComponent => Boolean(component));
} catch (reason) {
if (reason instanceof RemoteCallError && !reason.isTransportError) {
disabledComponentCalls.add(functionName);
}
return [];
}
}
function fallbackComponentsFromDetails(detailsProperties: DetailProperty[]): ObjectComponent[] {
const components = detailsProperties
.filter((property) => {
const valuePath = extractObjectPath(property.value);
return Boolean(valuePath) && (property.type.toLowerCase().includes("component") || valuePath.toLowerCase().includes("component"));
})
.map((property, index) => normalizeComponent(property.value, index))
.filter((component): component is ObjectComponent => Boolean(component));
return Array.from(new Map(components.map((component) => [component.objectPath, component])).values());
}
export async function discoverComponents(actorPath: string, detailsProperties: DetailProperty[]): Promise<ObjectComponent[]> {
if (!actorPath) {
return [];
}
const directComponents = await tryComponentCall(actorPath, "GetComponents");
if (directComponents.length > 0) {
return directComponents;
}
const actorComponents = await tryComponentCall(actorPath, "GetComponentsByClass", {
ComponentClass: "/Script/Engine.ActorComponent",
});
if (actorComponents.length > 0) {
return actorComponents;
}
return fallbackComponentsFromDetails(detailsProperties);
}

View File

@@ -1,4 +1,4 @@
import type { DetailProperty, ObjectDetails, RemoteObject } from "../types"; import type { DetailControlKind, DetailProperty, ObjectDetails, RemoteObject } from "../types";
import { asRecord, basename, cleanObjectName, extractObjectPath, firstString } from "../lib/remoteValues"; import { asRecord, basename, cleanObjectName, extractObjectPath, firstString } from "../lib/remoteValues";
function getMetadataString(property: RemoteObject, key: string): string { function getMetadataString(property: RemoteObject, key: string): string {
@@ -44,7 +44,52 @@ function extractPropertyValues(payload: unknown): RemoteObject {
return {}; return {};
} }
export function normalizeDetails(describePayload: unknown, propertyPayload: unknown): ObjectDetails { function inferControlKind(type: string, value: unknown): DetailControlKind {
const lowerType = type.toLowerCase();
if (detailAssetPath({ value, type } as DetailProperty)) {
return "asset";
}
if (typeof value === "boolean" || lowerType.includes("bool")) {
return "boolean";
}
if (typeof value === "number" || lowerType.includes("float") || lowerType.includes("double") || lowerType.includes("int")) {
return "number";
}
if (isVectorLikeValue(value)) {
return ["Roll", "Pitch", "Yaw"].every((key) => key in asRecord(value)) ? "rotator" : "vector";
}
if (typeof value === "string" || lowerType.includes("name") || lowerType.includes("string") || lowerType.includes("text")) {
return "string";
}
return "readonly";
}
function canEditProperty(name: string, value: unknown, controlKind: DetailControlKind): boolean {
if (!name || value === undefined || value === null) {
return false;
}
if (controlKind === "readonly" || controlKind === "asset") {
return false;
}
if (Array.isArray(value)) {
return false;
}
if (typeof value === "object" && !isVectorLikeValue(value)) {
return false;
}
return true;
}
function isVectorLikeValue(value: unknown): boolean {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const record = asRecord(value);
return ["X", "Y", "Z"].every((key) => key in record) || ["Roll", "Pitch", "Yaw"].every((key) => key in record);
}
export function normalizeDetails(objectPath: string, describePayload: unknown, propertyPayload: unknown): ObjectDetails {
const description = asRecord(describePayload); const description = asRecord(describePayload);
const values = extractPropertyValues(propertyPayload); const values = extractPropertyValues(propertyPayload);
const properties = Array.isArray(description.Properties) const properties = Array.isArray(description.Properties)
@@ -54,18 +99,29 @@ export function normalizeDetails(describePayload: unknown, propertyPayload: unkn
: []; : [];
return { return {
objectPath,
name: firstString(description, ["Name", "name"], "Object"), name: firstString(description, ["Name", "name"], "Object"),
className: firstString(description, ["Class", "class"], "UObject"), className: firstString(description, ["Class", "class"], "UObject"),
properties: properties.map((rawProperty): DetailProperty => { properties: properties.map((rawProperty): DetailProperty => {
const property = asRecord(rawProperty); const property = asRecord(rawProperty);
const name = firstString(property, ["Name", "name"], "Property"); const name = firstString(property, ["Name", "name"], "Property");
const type = firstString(property, ["Type", "type"], "Property");
const value = findPropertyValue(values, name);
const controlKind = inferControlKind(type, value);
const editable = canEditProperty(name, value, controlKind);
return { return {
name, name,
displayName: getMetadataString(property, "DisplayName") || prettifyPropertyName(name), displayName: getMetadataString(property, "DisplayName") || prettifyPropertyName(name),
category: getMetadataString(property, "Category") || "General", category: getMetadataString(property, "Category") || "General",
type: firstString(property, ["Type", "type"], "Property"), type,
value: findPropertyValue(values, name), value,
description: firstString(property, ["Description", "description"]), description: firstString(property, ["Description", "description"]),
access: editable ? "WRITE_TRANSACTION_ACCESS" : "READ_ACCESS",
editable,
controlKind,
propertyPath: name,
sourceObjectPath: objectPath,
writeValue: value,
}; };
}), }),
}; };
@@ -128,12 +184,7 @@ export function isBooleanProperty(property: DetailProperty): boolean {
} }
export function isVectorLikeProperty(property: DetailProperty): boolean { export function isVectorLikeProperty(property: DetailProperty): boolean {
if (!property.value || typeof property.value !== "object" || Array.isArray(property.value)) { return isVectorLikeValue(property.value);
return false;
}
const record = asRecord(property.value);
return ["X", "Y", "Z"].every((key) => key in record) || ["Roll", "Pitch", "Yaw"].every((key) => key in record);
} }
export function vectorParts(value: unknown): Array<{ label: string; value: string }> { export function vectorParts(value: unknown): Array<{ label: string; value: string }> {
@@ -155,3 +206,29 @@ export function normalizeThumbnailPayload(payload: unknown): string {
return value.startsWith("data:") ? value : `data:image/png;base64,${value}`; return value.startsWith("data:") ? value : `data:image/png;base64,${value}`;
} }
export function coerceDetailInputValue(property: DetailProperty, value: string | boolean | number | unknown): unknown {
if (property.controlKind === "boolean") {
return Boolean(value);
}
if (property.controlKind === "number") {
const numberValue = typeof value === "number" ? value : Number(value);
return Number.isFinite(numberValue) ? numberValue : property.value;
}
if (property.controlKind === "string") {
return String(value);
}
return value;
}
export function withUpdatedVectorPart(property: DetailProperty, part: string, value: string): unknown {
const numberValue = Number(value);
if (!Number.isFinite(numberValue)) {
return property.value;
}
return {
...asRecord(property.writeValue ?? property.value),
[part]: numberValue,
};
}

View File

@@ -74,6 +74,18 @@ export function writeCachedDetails(objectPath: string, details: ObjectDetails):
} }
} }
export function invalidateCachedDetails(objectPath: string): void {
if (!storageAvailable()) {
return;
}
try {
window.localStorage.removeItem(cacheKey(objectPath));
} catch {
// localStorage can be unavailable in private contexts; cache invalidation is best effort.
}
}
function pruneDetailsCache(): void { function pruneDetailsCache(): void {
const entries: Array<{ key: string; savedAt: number }> = []; const entries: Array<{ key: string; savedAt: number }> = [];

View File

@@ -224,6 +224,17 @@ export async function readRemoteObjectProperties(objectPath: string): Promise<un
}); });
} }
export async function writeRemoteObjectProperty(objectPath: string, propertyName: string, value: unknown): Promise<unknown> {
return callRemoteHttpRoute("/remote/object/property", "PUT", {
objectPath,
propertyName,
access: "WRITE_TRANSACTION_ACCESS",
propertyValue: {
[propertyName]: value,
},
});
}
export async function readRemoteObjectThumbnail(objectPath: string): Promise<unknown> { export async function readRemoteObjectThumbnail(objectPath: string): Promise<unknown> {
return callRemoteHttpRoute("/remote/object/thumbnail", "PUT", { objectPath }); return callRemoteHttpRoute("/remote/object/thumbnail", "PUT", { objectPath });
} }

257
src/services/transform.ts Normal file
View File

@@ -0,0 +1,257 @@
import type { ObjectTransform, RemoteObject, TransformField, TransformTargetKind, TransformVector } from "../types";
import { asRecord } from "../lib/remoteValues";
import { callRemoteFunction, RemoteCallError } from "./remoteControl";
const disabledTransformCalls = new Set<string>();
const actorGetAttempts = {
location: ["GetActorLocation"],
rotation: ["GetActorRotation"],
scale: ["GetActorScale3D"],
} satisfies Record<TransformField, string[]>;
const componentGetAttempts = {
location: ["GetActorLocation", "K2_GetActorLocation", "GetComponentLocation", "K2_GetComponentLocation"],
rotation: ["GetActorRotation", "K2_GetActorRotation", "GetComponentRotation", "K2_GetComponentRotation"],
scale: ["GetActorScale3D", "GetActorScale", "GetComponentScale", "GetComponentScale3D"],
} satisfies Record<TransformField, string[]>;
const actorSetAttempts = {
location: [{ functionName: "SetActorLocation", parameterName: "NewLocation" }],
rotation: [{ functionName: "SetActorRotation", parameterName: "NewRotation" }],
scale: [{ functionName: "SetActorScale3D", parameterName: "NewScale3D" }],
} satisfies Record<TransformField, Array<{ functionName: string; parameterName: string }>>;
const componentSetAttempts = {
location: [
{ functionName: "SetWorldLocation", parameterName: "NewLocation" },
{ functionName: "K2_SetWorldLocation", parameterName: "NewLocation" },
],
rotation: [
{ functionName: "SetWorldRotation", parameterName: "NewRotation" },
{ functionName: "K2_SetWorldRotation", parameterName: "NewRotation" },
],
scale: [
{ functionName: "SetActorScale3D", parameterName: "NewScale3D" },
{ functionName: "SetWorldScale3D", parameterName: "NewScale" },
],
} satisfies Record<TransformField, Array<{ functionName: string; parameterName: string }>>;
function callKey(objectPath: string, functionName: string): string {
return `${objectPath}:${functionName}`;
}
function asUnrealVector(vector: TransformVector): RemoteObject {
return {
X: vector.x,
Y: vector.y,
Z: vector.z,
};
}
function attemptsForTarget<T>(targetKind: TransformTargetKind, actorAttempts: Record<TransformField, T>, componentAttempts: Record<TransformField, T>) {
return targetKind === "actor" ? actorAttempts : componentAttempts;
}
export function createDefaultTransform(objectPath: string, targetKind: TransformTargetKind): ObjectTransform {
return {
objectPath,
targetKind,
location: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 },
readStatus: {
location: false,
rotation: false,
scale: false,
},
editable: true,
};
}
function normalizeVector(value: unknown, fallback: TransformVector): { value: TransformVector; found: boolean } {
const foundRecord = findVectorRecord(value);
const vectorRecord = foundRecord ?? asRecord(value);
const x = Number(vectorRecord.X ?? vectorRecord.x ?? vectorRecord.Roll ?? vectorRecord.roll);
const y = Number(vectorRecord.Y ?? vectorRecord.y ?? vectorRecord.Pitch ?? vectorRecord.pitch);
const z = Number(vectorRecord.Z ?? vectorRecord.z ?? vectorRecord.Yaw ?? vectorRecord.yaw);
const parsedValue = {
x: Number.isFinite(x) ? x : fallback.x,
y: Number.isFinite(y) ? y : fallback.y,
z: Number.isFinite(z) ? z : fallback.z,
};
return {
value: parsedValue,
found: Boolean(foundRecord) && Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z),
};
}
function hasVectorKeys(record: RemoteObject): boolean {
const keys = Object.keys(record);
const lowerKeys = keys.map((key) => key.toLowerCase());
return (
(lowerKeys.includes("x") && lowerKeys.includes("y") && lowerKeys.includes("z")) ||
(lowerKeys.includes("roll") && lowerKeys.includes("pitch") && lowerKeys.includes("yaw"))
);
}
function findVectorRecord(value: unknown, depth = 0): RemoteObject | null {
if (depth > 4 || !value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const record = asRecord(value);
if (hasVectorKeys(record)) {
return record;
}
const priorityKeys = ["ReturnValue", "returnValue", "Result", "result", "Location", "Rotation", "Scale", "Translation"];
for (const key of priorityKeys) {
const nested = findVectorRecord(record[key], depth + 1);
if (nested) {
return nested;
}
}
for (const nestedValue of Object.values(record)) {
const nested = findVectorRecord(nestedValue, depth + 1);
if (nested) {
return nested;
}
}
return null;
}
async function tryGetTransformField(
objectPath: string,
targetKind: TransformTargetKind,
field: TransformField,
fallback: TransformVector,
): Promise<TransformVector> {
const getAttempts = attemptsForTarget(targetKind, actorGetAttempts, componentGetAttempts);
for (const functionName of getAttempts[field]) {
const key = callKey(objectPath, functionName);
if (disabledTransformCalls.has(key)) {
continue;
}
let timeoutId = 0;
try {
const payload = await Promise.race([
callRemoteFunction(objectPath, functionName),
new Promise<never>((_, reject) => {
timeoutId = window.setTimeout(() => reject(new RemoteCallError(`Timed out reading ${field}`, true)), 1200);
}),
]);
return normalizeVector(payload, fallback).value;
} catch (reason) {
if (reason instanceof RemoteCallError && !reason.isTransportError) {
disabledTransformCalls.add(key);
}
} finally {
window.clearTimeout(timeoutId);
}
}
return fallback;
}
async function readTransformField(
objectPath: string,
targetKind: TransformTargetKind,
field: TransformField,
fallback: TransformVector,
): Promise<{ value: TransformVector; found: boolean }> {
const getAttempts = attemptsForTarget(targetKind, actorGetAttempts, componentGetAttempts);
for (const functionName of getAttempts[field]) {
const key = callKey(objectPath, functionName);
if (disabledTransformCalls.has(key)) {
continue;
}
let timeoutId = 0;
try {
const payload = await Promise.race([
callRemoteFunction(objectPath, functionName),
new Promise<never>((_, reject) => {
timeoutId = window.setTimeout(() => reject(new RemoteCallError(`Timed out reading ${field}`, true)), 1200);
}),
]);
return normalizeVector(payload, fallback);
} catch (reason) {
if (reason instanceof RemoteCallError && !reason.isTransportError) {
disabledTransformCalls.add(key);
}
} finally {
window.clearTimeout(timeoutId);
}
}
return { value: fallback, found: false };
}
export async function readObjectTransform(objectPath: string, targetKind: TransformTargetKind): Promise<ObjectTransform | null> {
if (!objectPath) {
return null;
}
const fallback = createDefaultTransform(objectPath, targetKind);
const [location, rotation, scale] = await Promise.all([
readTransformField(objectPath, targetKind, "location", { x: 0, y: 0, z: 0 }),
readTransformField(objectPath, targetKind, "rotation", { x: 0, y: 0, z: 0 }),
readTransformField(objectPath, targetKind, "scale", { x: 1, y: 1, z: 1 }),
]);
return {
...fallback,
location: location.value,
rotation: rotation.value,
scale: scale.value,
readStatus: {
location: location.found,
rotation: rotation.found,
scale: scale.found,
},
};
}
function setterParameters(parameterName: string, vector: TransformVector): RemoteObject {
return {
[parameterName]: asUnrealVector(vector),
bSweep: false,
bTeleport: true,
bTeleportPhysics: true,
SweepHitResult: {},
};
}
export async function writeObjectTransformField(
objectPath: string,
targetKind: TransformTargetKind,
field: TransformField,
value: TransformVector,
): Promise<void> {
const setAttempts = attemptsForTarget(targetKind, actorSetAttempts, componentSetAttempts);
for (const attempt of setAttempts[field]) {
const key = callKey(objectPath, attempt.functionName);
if (disabledTransformCalls.has(key)) {
continue;
}
try {
await callRemoteFunction(objectPath, attempt.functionName, setterParameters(attempt.parameterName, value));
return;
} catch (reason) {
if (reason instanceof RemoteCallError && !reason.isTransportError) {
disabledTransformCalls.add(key);
} else {
throw reason;
}
}
}
throw new Error(`No writable ${field} transform function is available for this object.`);
}

View File

@@ -10,6 +10,7 @@ import {
nestedString, nestedString,
} from "../lib/remoteValues"; } from "../lib/remoteValues";
import { callRemoteFunction, RemoteCallError } from "./remoteControl"; import { callRemoteFunction, RemoteCallError } from "./remoteControl";
import { writeCachedActor } from "./actorCache";
const EDITOR_ACTOR_SUBSYSTEM = "/Script/UnrealEd.Default__EditorActorSubsystem"; const EDITOR_ACTOR_SUBSYSTEM = "/Script/UnrealEd.Default__EditorActorSubsystem";
const EDITOR_LEVEL_LIBRARY = "/Script/UnrealEd.Default__EditorLevelLibrary"; const EDITOR_LEVEL_LIBRARY = "/Script/UnrealEd.Default__EditorLevelLibrary";
@@ -101,7 +102,7 @@ async function tryRemoteString(objectPath: string, functionName: string): Promis
} }
} }
async function enrichActor(actor: ActorRow): Promise<ActorRow> { export async function enrichActor(actor: ActorRow): Promise<ActorRow> {
if (!actor.objectPath) { if (!actor.objectPath) {
return actor; return actor;
} }
@@ -113,12 +114,15 @@ async function enrichActor(actor: ActorRow): Promise<ActorRow> {
const typeFromPath = actor.objectPath.includes(".") ? cleanObjectName(basename(actor.objectPath).replace(/_\d+$/, "")) : ""; const typeFromPath = actor.objectPath.includes(".") ? cleanObjectName(basename(actor.objectPath).replace(/_\d+$/, "")) : "";
return mergeActorDetails(actor, { const enrichedActor = mergeActorDetails(actor, {
label: label || actor.label, label: label || actor.label,
folderPath: folderPath || actor.folderPath, folderPath: folderPath || actor.folderPath,
level: deriveLevelNameFromPath(actor.objectPath) || actor.level, level: deriveLevelNameFromPath(actor.objectPath) || actor.level,
type: actor.type === "Actor" && typeFromPath ? typeFromPath : actor.type, type: actor.type === "Actor" && typeFromPath ? typeFromPath : actor.type,
}); });
writeCachedActor(enrichedActor);
return enrichedActor;
} }
export async function enrichActors(actors: ActorRow[]): Promise<ActorRow[]> { export async function enrichActors(actors: ActorRow[]): Promise<ActorRow[]> {

View File

@@ -316,7 +316,7 @@ input {
.details-panel { .details-panel {
min-height: 0; min-height: 0;
display: grid; display: grid;
grid-template-rows: 32px 41px 100px 37px 34px 1fr; grid-template-rows: 32px 41px 100px 37px 1fr;
border-top: 2px solid #0d0d0d; border-top: 2px solid #0d0d0d;
background: #202020; background: #202020;
color: #cfcfcf; color: #cfcfcf;
@@ -402,6 +402,11 @@ input {
padding: 0 9px; padding: 0 9px;
color: #d2d2d2; color: #d2d2d2;
font-size: 0.95rem; font-size: 0.95rem;
width: 100%;
border: 0;
text-align: left;
background: transparent;
cursor: default;
} }
.component-row.selected { .component-row.selected {
@@ -416,6 +421,15 @@ input {
font-size: 0.85rem; font-size: 0.85rem;
} }
.component-row.child.selected {
background: #3d5770;
color: #f1f1f1;
}
.component-row:hover:not(.selected) {
background: rgba(92, 105, 116, 0.22);
}
.component-row span { .component-row span {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@@ -478,38 +492,6 @@ input {
background: #363636; background: #363636;
} }
.category-tabs {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: #262626;
border-bottom: 1px solid #141414;
overflow-x: auto;
}
.category-tabs button {
height: 25px;
min-width: 71px;
padding: 0 12px;
color: #c7c7c7;
background: #2c2c2c;
border: 1px solid #151515;
border-radius: 5px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
cursor: pointer;
}
.category-tabs button:hover {
background: #343434;
}
.category-tabs button.active {
color: #ffffff;
background: #0877d9;
border-color: #0a5ea9;
}
.details-categories { .details-categories {
min-height: 0; min-height: 0;
overflow: auto; overflow: auto;
@@ -531,6 +513,14 @@ input {
font-weight: 600; font-weight: 600;
} }
.transform-section {
border-bottom: 1px solid #171717;
}
.transform-section h3 {
border-top: 0;
}
.detail-row { .detail-row {
min-height: 34px; min-height: 34px;
display: grid; display: grid;
@@ -538,6 +528,27 @@ input {
border-bottom: 1px solid #171717; border-bottom: 1px solid #171717;
} }
.detail-row.dirty .detail-name {
box-shadow: inset 3px 0 0 #d6a23a;
}
.detail-row.saving .detail-name {
box-shadow: inset 3px 0 0 #2d86d8;
}
.detail-row.error .detail-name {
color: #ffb3aa;
box-shadow: inset 3px 0 0 #d74630;
}
.detail-row.external .detail-name {
box-shadow: inset 3px 0 0 #5bbf70;
}
.detail-row.read-only {
opacity: 0.72;
}
.detail-name, .detail-name,
.detail-value { .detail-value {
min-width: 0; min-width: 0;
@@ -577,6 +588,27 @@ input {
box-shadow: inset 0 0 0 1px #050505; box-shadow: inset 0 0 0 1px #050505;
} }
input.text-value {
display: block;
outline: 0;
}
.text-value:disabled,
.axis:disabled,
.checkbox-value:disabled {
color: #858585;
opacity: 0.75;
}
.text-value:focus,
.axis:focus {
border-color: #2d86d8;
}
.readonly-value {
color: #a4a4a4;
}
.text-value span, .text-value span,
.asset-control span { .asset-control span {
min-width: 0; min-width: 0;
@@ -593,6 +625,10 @@ input {
gap: 4px; gap: 4px;
} }
.transform-vector {
width: min(420px, 100%);
}
.axis { .axis {
height: 24px; height: 24px;
display: flex; display: flex;
@@ -607,6 +643,7 @@ input {
border-radius: 4px; border-radius: 4px;
box-shadow: inset 0 0 0 1px #050505; box-shadow: inset 0 0 0 1px #050505;
white-space: nowrap; white-space: nowrap;
outline: 0;
} }
.axis-0 { .axis-0 {
@@ -628,6 +665,7 @@ input {
border-radius: 4px; border-radius: 4px;
background: #111; background: #111;
box-shadow: inset 0 0 0 1px #050505; box-shadow: inset 0 0 0 1px #050505;
padding: 0;
} }
.checkbox-value.checked::after { .checkbox-value.checked::after {

View File

@@ -2,6 +2,30 @@ export type RemoteObject = Record<string, unknown>;
export type RemoteTransport = "WebSocket" | "HTTP"; export type RemoteTransport = "WebSocket" | "HTTP";
export type DetailControlKind = "readonly" | "boolean" | "number" | "string" | "vector" | "rotator" | "asset";
export type DetailRowStatus = "clean" | "dirty" | "saving" | "error" | "external";
export type TransformVector = {
x: number;
y: number;
z: number;
};
export type TransformTargetKind = "actor" | "component";
export type ObjectTransform = {
objectPath: string;
targetKind: TransformTargetKind;
location: TransformVector;
rotation: TransformVector;
scale: TransformVector;
readStatus: Record<TransformField, boolean>;
editable: boolean;
};
export type TransformField = "location" | "rotation" | "scale";
export type ActorRow = { export type ActorRow = {
id: string; id: string;
label: string; label: string;
@@ -35,14 +59,28 @@ export type DetailProperty = {
type: string; type: string;
value: unknown; value: unknown;
description: string; description: string;
access: "READ_ACCESS" | "WRITE_TRANSACTION_ACCESS";
editable: boolean;
controlKind: DetailControlKind;
propertyPath: string;
sourceObjectPath: string;
writeValue: unknown;
}; };
export type ObjectDetails = { export type ObjectDetails = {
objectPath: string;
name: string; name: string;
className: string; className: string;
properties: DetailProperty[]; properties: DetailProperty[];
}; };
export type ObjectComponent = {
id: string;
label: string;
type: string;
objectPath: string;
};
export type CachedObjectDetails = { export type CachedObjectDetails = {
objectPath: string; objectPath: string;
savedAt: number; savedAt: number;