1
0

Aiden added closer matching look

This commit is contained in:
2026-05-19 16:23:51 +10:00
parent 676290faf7
commit bdf5f4b44e
6 changed files with 522 additions and 9 deletions

View File

@@ -38,6 +38,8 @@ The property request uses `READ_ACCESS` and omits `propertyName`, which asks Unr
Details payloads are cached in browser `localStorage` for five minutes per object path. When you reselect an object, cached properties render immediately and the app refreshes them from Unreal in the background. Details payloads are cached in browser `localStorage` for five minutes per object path. When you reselect an object, cached properties render immediately and the app refreshes them from Unreal in the background.
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.
## 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

@@ -1,8 +1,17 @@
import React from "react"; import React from "react";
import { ChevronDown, Search, Settings, SlidersHorizontal, 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, ObjectDetails, TreeNode } from "../types";
import { basename } from "../lib/remoteValues"; import { basename } from "../lib/remoteValues";
import { formatDetailValue } from "../services/details"; import {
detailAssetPath,
formatDetailValue,
isBooleanProperty,
isVectorLikeProperty,
normalizeThumbnailPayload,
vectorParts,
} from "../services/details";
import { readRemoteObjectThumbnail } from "../services/remoteControl";
import { readCachedThumbnail, writeCachedThumbnail } from "../services/thumbnailCache";
import { NodeIcon } from "./NodeIcon"; import { NodeIcon } from "./NodeIcon";
const emptyNode: TreeNode = { const emptyNode: TreeNode = {
@@ -15,6 +24,80 @@ const emptyNode: TreeNode = {
children: [], children: [],
}; };
const categoryTabs = ["General", "Actor", "LOD", "Misc", "Physics", "Rendering", "Streaming", "All"];
function DetailValue({ property }: { property: DetailProperty }) {
const assetPath = detailAssetPath(property);
const [thumbnail, setThumbnail] = React.useState(() => (assetPath ? readCachedThumbnail(assetPath) : ""));
React.useEffect(() => {
let isCancelled = false;
if (!assetPath) {
setThumbnail("");
return;
}
const cached = readCachedThumbnail(assetPath);
if (cached) {
setThumbnail(cached);
return;
}
readRemoteObjectThumbnail(assetPath)
.then((payload) => {
const dataUrl = normalizeThumbnailPayload(payload);
if (!isCancelled && dataUrl) {
setThumbnail(dataUrl);
writeCachedThumbnail(assetPath, dataUrl);
}
})
.catch(() => {
if (!isCancelled) {
setThumbnail("");
}
});
return () => {
isCancelled = true;
};
}, [assetPath]);
if (assetPath) {
return (
<div className="asset-value">
<div className="asset-thumb">{thumbnail ? <img src={thumbnail} alt="" /> : <Box size={28} />}</div>
<div className="asset-control">
<span>{formatDetailValue(property.value)}</span>
<ChevronDown size={16} />
</div>
</div>
);
}
if (isVectorLikeProperty(property)) {
return (
<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)) {
return <span className={`checkbox-value ${property.value ? "checked" : ""}`} />;
}
return (
<div className="text-value">
<span>{formatDetailValue(property.value)}</span>
</div>
);
}
export function DetailsPanel({ export function DetailsPanel({
node, node,
details, details,
@@ -27,10 +110,13 @@ export function DetailsPanel({
error: string | null; error: string | null;
}) { }) {
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),
@@ -43,7 +129,7 @@ export function DetailsPanel({
groups[category].push(property); groups[category].push(property);
return groups; return groups;
}, {}); }, {});
}, [details, query]); }, [activeCategory, details, query]);
return ( return (
<aside className="details-panel" aria-label="Details"> <aside className="details-panel" aria-label="Details">
@@ -67,16 +153,47 @@ export function DetailsPanel({
</div> </div>
</div> </div>
<div className="component-list">
<div className="component-row selected">
<NodeIcon node={node ?? emptyNode} />
<span>{node ? `${node.label} (Instance)` : "No Selection"}</span>
</div>
<div className="component-row child">
<Box size={18} />
<span>{node?.type ? `${node.type}Component (${node.type}Component0)` : "Component"}</span>
<a>Edit in C++</a>
</div>
</div>
<div className="details-search-row"> <div className="details-search-row">
<label className="details-search"> <label className="details-search">
<Search size={18} /> <Search size={18} />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search" /> <input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search" />
</label> </label>
<button className="details-tool" type="button" title="View Options"> <button className="details-tool" type="button" title="View Options">
<Grid2X2 size={18} />
</button>
<button className="details-tool" type="button" title="Favorites">
<Star size={18} />
</button>
<button className="details-tool" type="button" title="Settings">
<Settings size={18} /> <Settings size={18} />
</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 ? (
<div className="details-empty">Loading properties...</div> <div className="details-empty">Loading properties...</div>
@@ -96,7 +213,12 @@ export function DetailsPanel({
{properties.map((property) => ( {properties.map((property) => (
<div className="detail-row" key={property.name} title={property.description || property.type}> <div className="detail-row" key={property.name} title={property.description || property.type}>
<span className="detail-name">{property.displayName}</span> <span className="detail-name">{property.displayName}</span>
<span className="detail-value">{formatDetailValue(property.value)}</span> <span className="detail-value">
<DetailValue property={property} />
<button className="reset-button row-reset" type="button" title="Reset to default">
<RotateCcw size={15} />
</button>
</span>
</div> </div>
))} ))}
</section> </section>

View File

@@ -109,3 +109,49 @@ export function formatDetailValue(value: unknown): string {
return JSON.stringify(value); return JSON.stringify(value);
} }
export function detailAssetPath(property: DetailProperty): string {
const valuePath = extractObjectPath(property.value);
if (valuePath.startsWith("/Game/") || valuePath.startsWith("/Engine/")) {
return valuePath;
}
if (typeof property.value === "string" && (property.value.startsWith("/Game/") || property.value.startsWith("/Engine/"))) {
return property.value;
}
return "";
}
export function isBooleanProperty(property: DetailProperty): boolean {
return typeof property.value === "boolean" || property.type.toLowerCase().includes("bool");
}
export function isVectorLikeProperty(property: DetailProperty): boolean {
if (!property.value || typeof property.value !== "object" || Array.isArray(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 }> {
const record = asRecord(value);
const keys = ["X", "Y", "Z"].every((key) => key in record) ? ["X", "Y", "Z"] : ["Roll", "Pitch", "Yaw"];
return keys.map((key) => ({ label: key, value: formatDetailValue(record[key]) }));
}
export function normalizeThumbnailPayload(payload: unknown): string {
if (typeof payload === "string") {
return payload.startsWith("data:") ? payload : `data:image/png;base64,${payload}`;
}
const record = asRecord(payload);
const value = firstString(record, ["Thumbnail", "thumbnail", "Image", "image", "Data", "data", "Base64", "base64"]);
if (!value) {
return "";
}
return value.startsWith("data:") ? value : `data:image/png;base64,${value}`;
}

View File

@@ -223,3 +223,7 @@ export async function readRemoteObjectProperties(objectPath: string): Promise<un
access: "READ_ACCESS", access: "READ_ACCESS",
}); });
} }
export async function readRemoteObjectThumbnail(objectPath: string): Promise<unknown> {
return callRemoteHttpRoute("/remote/object/thumbnail", "PUT", { objectPath });
}

View File

@@ -0,0 +1,106 @@
const CACHE_PREFIX = "unreal-outliner.thumbnail.v1.";
const CACHE_TTL_MS = 60 * 60 * 1000;
const MAX_CACHE_ENTRIES = 100;
type CachedThumbnail = {
objectPath: string;
savedAt: number;
dataUrl: string;
};
function cacheKey(objectPath: string): string {
return `${CACHE_PREFIX}${objectPath}`;
}
function isCachedThumbnail(value: unknown): value is CachedThumbnail {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as CachedThumbnail;
return typeof candidate.objectPath === "string" && typeof candidate.savedAt === "number" && typeof candidate.dataUrl === "string";
}
function storageAvailable(): boolean {
try {
const key = `${CACHE_PREFIX}probe`;
window.localStorage.setItem(key, "1");
window.localStorage.removeItem(key);
return true;
} catch {
return false;
}
}
export function readCachedThumbnail(objectPath: string): string {
if (!storageAvailable()) {
return "";
}
try {
const raw = window.localStorage.getItem(cacheKey(objectPath));
if (!raw) {
return "";
}
const parsed = JSON.parse(raw);
if (!isCachedThumbnail(parsed) || parsed.objectPath !== objectPath) {
return "";
}
if (Date.now() - parsed.savedAt > CACHE_TTL_MS) {
window.localStorage.removeItem(cacheKey(objectPath));
return "";
}
return parsed.dataUrl;
} catch {
return "";
}
}
export function writeCachedThumbnail(objectPath: string, dataUrl: string): void {
if (!storageAvailable() || !dataUrl) {
return;
}
try {
window.localStorage.setItem(cacheKey(objectPath), JSON.stringify({ objectPath, savedAt: Date.now(), dataUrl }));
pruneThumbnailCache();
} catch {
pruneThumbnailCache();
}
}
function pruneThumbnailCache(): 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 parsed = JSON.parse(window.localStorage.getItem(key) ?? "");
if (isCachedThumbnail(parsed)) {
entries.push({ key, savedAt: parsed.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

@@ -281,7 +281,7 @@ input {
.details-panel { .details-panel {
min-height: 0; min-height: 0;
display: grid; display: grid;
grid-template-rows: 38px 58px 44px 1fr; grid-template-rows: 38px 48px 118px 44px 40px 1fr;
border-top: 2px solid #0d0d0d; border-top: 2px solid #0d0d0d;
background: #202020; background: #202020;
color: #cfcfcf; color: #cfcfcf;
@@ -323,7 +323,7 @@ input {
grid-template-columns: 28px 1fr; grid-template-columns: 28px 1fr;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 9px 14px; padding: 7px 14px;
border-bottom: 1px solid #171717; border-bottom: 1px solid #171717;
background: #242424; background: #242424;
} }
@@ -339,7 +339,7 @@ input {
.details-object strong { .details-object strong {
color: #f1f1f1; color: #f1f1f1;
font-size: 18px; font-size: 17px;
font-weight: 500; font-weight: 500;
} }
@@ -349,9 +349,56 @@ input {
font-size: 14px; font-size: 14px;
} }
.component-list {
min-height: 0;
padding: 6px 8px 8px;
background: #181818;
border: 1px solid #242424;
border-left: 0;
border-right: 0;
overflow: hidden;
}
.component-row {
height: 34px;
display: flex;
align-items: center;
gap: 9px;
padding: 0 10px;
color: #d2d2d2;
font-size: 18px;
}
.component-row.selected {
background: #3d5770;
color: #f1f1f1;
}
.component-row.child {
padding-left: 42px;
background: #151515;
color: #c9c9c9;
font-size: 16px;
}
.component-row span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.component-row a {
margin-left: auto;
color: #d9d9d9;
text-decoration: underline dotted;
text-underline-offset: 3px;
white-space: nowrap;
}
.details-search-row { .details-search-row {
display: grid; display: grid;
grid-template-columns: minmax(140px, 1fr) 34px; grid-template-columns: minmax(140px, 1fr) 34px 34px 34px;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 6px 12px; padding: 6px 12px;
@@ -396,6 +443,38 @@ input {
background: #363636; background: #363636;
} }
.category-tabs {
display: flex;
align-items: center;
gap: 7px;
padding: 5px 12px;
background: #262626;
border-bottom: 1px solid #141414;
overflow-x: auto;
}
.category-tabs button {
height: 29px;
min-width: 84px;
padding: 0 14px;
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;
@@ -420,7 +499,7 @@ input {
.detail-row { .detail-row {
min-height: 40px; min-height: 40px;
display: grid; display: grid;
grid-template-columns: minmax(180px, 0.45fr) minmax(220px, 0.55fr); grid-template-columns: minmax(180px, 0.48fr) minmax(220px, 0.52fr);
border-bottom: 1px solid #171717; border-bottom: 1px solid #171717;
} }
@@ -439,11 +518,165 @@ input {
.detail-name { .detail-name {
color: #c8c8c8; color: #c8c8c8;
border-right: 1px solid #151515; border-right: 1px solid #151515;
background: #242424;
} }
.detail-value { .detail-value {
color: #e0e0e0; color: #e0e0e0;
background: #242424; background: #242424;
gap: 8px;
padding: 5px 10px 5px 14px;
}
.text-value {
min-width: 0;
width: min(380px, 100%);
height: 28px;
display: flex;
align-items: center;
padding: 0 10px;
color: #e5e5e5;
background: #101010;
border: 1px solid #333;
border-radius: 4px;
box-shadow: inset 0 0 0 1px #050505;
}
.text-value span,
.asset-control span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vector-value {
min-width: 0;
width: min(420px, 100%);
display: grid;
grid-template-columns: repeat(3, minmax(64px, 1fr));
gap: 5px;
}
.axis {
height: 28px;
display: flex;
align-items: center;
min-width: 0;
overflow: hidden;
padding: 0 8px;
color: #f0f0f0;
background: #101010;
border: 1px solid #333;
border-left-width: 4px;
border-radius: 4px;
box-shadow: inset 0 0 0 1px #050505;
white-space: nowrap;
}
.axis-0 {
border-left-color: #df3b1f;
}
.axis-1 {
border-left-color: #76b900;
}
.axis-2 {
border-left-color: #0d7fe8;
}
.checkbox-value {
width: 26px;
height: 26px;
border: 1px solid #353535;
border-radius: 4px;
background: #111;
box-shadow: inset 0 0 0 1px #050505;
}
.checkbox-value.checked::after {
content: "";
display: block;
width: 13px;
height: 7px;
margin: 7px 0 0 5px;
border-left: 2px solid #d7d7d7;
border-bottom: 2px solid #d7d7d7;
transform: rotate(-45deg);
}
.asset-value {
min-width: 0;
width: min(430px, 100%);
display: grid;
grid-template-columns: 54px minmax(120px, 1fr);
align-items: center;
gap: 8px;
}
.asset-thumb {
width: 54px;
height: 54px;
display: grid;
place-items: center;
color: #bdbdbd;
background:
linear-gradient(45deg, #1a1a1a 25%, transparent 25%),
linear-gradient(-45deg, #1a1a1a 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #1a1a1a 75%),
linear-gradient(-45deg, transparent 75%, #1a1a1a 75%),
#101010;
background-position:
0 0,
0 8px,
8px -8px,
-8px 0;
background-size: 16px 16px;
border: 1px solid #313131;
border-radius: 5px;
box-shadow: inset 0 -3px 0 #16bfc4;
overflow: hidden;
}
.asset-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.asset-control {
min-width: 0;
height: 31px;
display: grid;
grid-template-columns: minmax(0, 1fr) 20px;
align-items: center;
padding: 0 8px 0 10px;
color: #e5e5e5;
background: #101010;
border: 1px solid #333;
border-radius: 5px;
box-shadow: inset 0 0 0 1px #050505;
}
.reset-button {
width: 26px;
height: 26px;
display: inline-grid;
place-items: center;
flex: 0 0 auto;
color: #bcbcbc;
background: transparent;
border: 0;
border-radius: 3px;
}
.reset-button:hover {
background: #343434;
}
.row-reset {
margin-left: auto;
} }
.details-empty { .details-empty {