Add Claw3D Studio clean-room workspace

Co-authored-by: Luke The Dev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-04-18 15:48:27 +00:00
co-authored by Luke The Dev
parent e59dcbe520
commit 7ae768c6a5
13 changed files with 1988 additions and 1 deletions
+229
View File
@@ -0,0 +1,229 @@
import { NextResponse } from "next/server";
import {
createEmptyOfficeMap,
normalizeOfficeMap,
} from "@/lib/office/schema";
import {
publishOfficeVersion,
saveOfficeVersion,
upsertOffice,
} from "@/lib/office/store";
import { buildOfficeMapFromStudioProject } from "@/lib/studio-world/office";
import {
createStudioProject,
deleteStudioProject,
getStudioProject,
listStudioProjects,
} from "@/lib/studio-world/store";
import type {
StudioGenerationInput,
StudioWorldFocus,
StudioWorldScale,
StudioWorldStyle,
} from "@/lib/studio-world/types";
export const runtime = "nodejs";
const WORKSPACE_ID = "default";
const OFFICE_ID = "studio-world";
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
const asString = (value: unknown) =>
typeof value === "string" ? value.trim() : "";
const parseStyle = (value: unknown): StudioWorldStyle =>
value === "realistic" || value === "cinematic" || value === "low-poly"
? value
: "stylized";
const parseScale = (value: unknown): StudioWorldScale =>
value === "small" || value === "large" ? value : "medium";
const parseFocus = (value: unknown): StudioWorldFocus =>
value === "assets" || value === "animation" ? value : "world";
const parseGenerationInput = (value: unknown): StudioGenerationInput | null => {
if (!isRecord(value)) return null;
const name = asString(value.name) || "Untitled Studio World";
const prompt = asString(value.prompt);
if (!prompt) return null;
const rawSeed = value.seed;
const seed =
typeof rawSeed === "number" && Number.isFinite(rawSeed) ? rawSeed : null;
return {
name,
prompt,
style: parseStyle(value.style),
scale: parseScale(value.scale),
focus: parseFocus(value.focus),
seed,
};
};
const buildExportManifest = (projectId: string) => {
const project = getStudioProject(projectId);
if (!project) {
throw new Error("Studio project not found.");
}
return {
project: {
id: project.id,
name: project.name,
prompt: project.prompt,
style: project.style,
scale: project.scale,
focus: project.focus,
seed: project.seed,
updatedAt: project.updatedAt,
},
export: {
format: "glb_manifest",
generator: "claw3d-studio-clean-room-v1",
summary: project.latestJob.summary,
},
sceneDraft: project.sceneDraft,
};
};
const applyProjectToOffice = (projectId: string) => {
const project = getStudioProject(projectId);
if (!project) {
throw new Error("Studio project not found.");
}
upsertOffice({
workspaceId: WORKSPACE_ID,
officeId: OFFICE_ID,
name: `Studio Export - ${project.name}`,
});
const officeVersionId = `studio-${Date.now().toString(36)}`;
const fallback = createEmptyOfficeMap({
workspaceId: WORKSPACE_ID,
officeVersionId,
width: 1200,
height: 900,
});
const map = normalizeOfficeMap(
buildOfficeMapFromStudioProject(project, officeVersionId),
fallback,
);
const version = saveOfficeVersion({
workspaceId: WORKSPACE_ID,
officeId: OFFICE_ID,
versionId: officeVersionId,
createdBy: "studio-world",
notes: `Generated from studio project ${project.id}.`,
map,
});
const published = publishOfficeVersion({
workspaceId: WORKSPACE_ID,
officeId: OFFICE_ID,
officeVersionId: version.id,
publishedBy: "studio-world",
});
return {
office: {
workspaceId: WORKSPACE_ID,
officeId: OFFICE_ID,
officeVersionId: version.id,
publishedAt: published.publishedAt,
},
};
};
export async function GET(request: Request) {
try {
const url = new URL(request.url);
const action = asString(url.searchParams.get("action"));
const projectId = asString(url.searchParams.get("projectId"));
if (action === "export") {
if (!projectId) {
return NextResponse.json(
{ error: "projectId is required for export." },
{ status: 400 },
);
}
return NextResponse.json(
{ exportManifest: buildExportManifest(projectId) },
{ headers: { "Cache-Control": "no-store" } },
);
}
return NextResponse.json(
{ projects: listStudioProjects() },
{ headers: { "Cache-Control": "no-store" } },
);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to load studio world data.";
console.error(message);
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const rawBody = await request.text();
if (!rawBody.trim()) {
return NextResponse.json({ error: "Invalid request payload." }, { status: 400 });
}
const body = JSON.parse(rawBody) as unknown;
if (!isRecord(body)) {
return NextResponse.json({ error: "Invalid request payload." }, { status: 400 });
}
const action = asString(body.action) || "generate";
if (action === "generate") {
const input = parseGenerationInput(body.input);
if (!input) {
return NextResponse.json(
{ error: "Valid generation input is required." },
{ status: 400 },
);
}
return NextResponse.json(
{ project: createStudioProject(input) },
{ headers: { "Cache-Control": "no-store" } },
);
}
if (action === "apply_to_office") {
const projectId = asString(body.projectId);
if (!projectId) {
return NextResponse.json(
{ error: "projectId is required for office export." },
{ status: 400 },
);
}
return NextResponse.json(
applyProjectToOffice(projectId),
{ headers: { "Cache-Control": "no-store" } },
);
}
return NextResponse.json({ error: "Unsupported studio world action." }, { status: 400 });
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to handle studio world request.";
console.error(message);
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function DELETE(request: Request) {
try {
const url = new URL(request.url);
const projectId = asString(url.searchParams.get("projectId"));
if (!projectId) {
return NextResponse.json({ error: "projectId is required." }, { status: 400 });
}
const deleted = deleteStudioProject(projectId);
return NextResponse.json(
{ deleted },
{ headers: { "Cache-Control": "no-store" } },
);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to delete studio world project.";
console.error(message);
return NextResponse.json({ error: message }, { status: 500 });
}
}
+7
View File
@@ -0,0 +1,7 @@
"use client";
import { StudioWorldScreen } from "@/features/studio-world/screens/StudioWorldScreen";
export default function StudioPage() {
return <StudioWorldScreen />;
}
+28 -1
View File
@@ -1,3 +1,4 @@
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { ThemeToggle } from "@/components/theme-toggle";
import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
@@ -8,12 +9,14 @@ type HeaderBarProps = {
status: GatewayStatus;
onConnectionSettings: () => void;
showConnectionSettings?: boolean;
currentSection?: "office" | "studio";
};
export const HeaderBar = ({
status,
onConnectionSettings,
showConnectionSettings = true,
currentSection,
}: HeaderBarProps) => {
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
@@ -40,7 +43,31 @@ export const HeaderBar = ({
<div className="ui-topbar relative z-[180]">
<div className="grid h-10 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center px-3 sm:px-4 md:px-5">
<div aria-hidden="true" />
<p className="truncate text-sm font-semibold tracking-[0.01em] text-foreground">Claw3D</p>
<div className="flex items-center justify-center gap-2">
<p className="truncate text-sm font-semibold tracking-[0.01em] text-foreground">Claw3D</p>
<div className="hidden items-center gap-1 rounded-full border border-border/70 bg-card/80 p-1 md:flex">
<Link
href="/office"
className={`rounded-full px-3 py-1 font-mono text-[10px] font-semibold uppercase tracking-[0.14em] transition-colors ${
currentSection === "office"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted/60 hover:text-foreground"
}`}
>
Office
</Link>
<Link
href="/studio"
className={`rounded-full px-3 py-1 font-mono text-[10px] font-semibold uppercase tracking-[0.14em] transition-colors ${
currentSection === "studio"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted/60 hover:text-foreground"
}`}
>
Studio
</Link>
</div>
</div>
<div className="flex items-center justify-end gap-1">
{status !== "disconnected" ? (
<span
@@ -4199,6 +4199,22 @@ export function OfficeScreen({
return (
<main className="relative h-full w-full overflow-hidden bg-black">
<div className="pointer-events-none fixed left-4 top-4 z-40">
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-cyan-400/25 bg-black/72 px-2 py-2 shadow-2xl backdrop-blur">
<div className="pl-1 font-mono text-[10px] uppercase tracking-[0.18em] text-cyan-100/75">
Claw3D Studio
</div>
<button
type="button"
className="ui-btn-secondary px-3 py-1.5 text-xs font-semibold tracking-[0.05em] text-foreground"
onClick={() => {
router.push("/studio");
}}
>
Open
</button>
</div>
</div>
{showGatewayLoadingOverlay ? (
<div
className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center bg-[#120a05]/76"
@@ -0,0 +1,55 @@
"use client";
import { GLTFExporter } from "three/examples/jsm/exporters/GLTFExporter.js";
import { buildPreviewSceneGroup } from "@/features/studio-world/preview/scene-utils";
import type { StudioProjectRecord } from "@/lib/studio-world/types";
const downloadBlob = (filename: string, blob: Blob) => {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
};
const sanitizeFilename = (value: string) =>
value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48) || "claw3d-studio-world";
export const exportStudioProjectGlb = async (project: StudioProjectRecord) => {
const exporter = new GLTFExporter();
const sceneGroup = buildPreviewSceneGroup(project.sceneDraft);
sceneGroup.updateMatrixWorld(true);
const result = await new Promise<ArrayBuffer>((resolve, reject) => {
exporter.parse(
sceneGroup,
(value) => {
if (value instanceof ArrayBuffer) {
resolve(value);
return;
}
reject(new Error("GLB export did not return binary output."));
},
(error) => {
reject(error instanceof Error ? error : new Error("Failed to export GLB."));
},
{
binary: true,
trs: false,
onlyVisible: true,
},
);
});
downloadBlob(
`${sanitizeFilename(project.name)}.glb`,
new Blob([result], { type: "model/gltf-binary" }),
);
};
@@ -0,0 +1,135 @@
"use client";
import { Environment, OrbitControls } from "@react-three/drei";
import { Canvas, useFrame } from "@react-three/fiber";
import { useMemo, useRef } from "react";
import * as THREE from "three";
import type { Group } from "three";
import type { StudioWorldAssetDraft, StudioWorldDraft } from "@/lib/studio-world/types";
import { buildAssetGeometry, buildAssetMaterial, buildGlowMaterial } from "@/features/studio-world/preview/scene-utils";
type AssetMeshProps = {
asset: StudioWorldAssetDraft;
};
const AssetMesh = ({ asset }: AssetMeshProps) => {
const groupRef = useRef<Group>(null);
const geometry = useMemo(() => buildAssetGeometry(asset.kind, asset.scale), [asset.kind, asset.scale]);
const material = useMemo(
() => buildAssetMaterial(asset.color, asset.emissive ?? null),
[asset.color, asset.emissive],
);
const glowMaterial = useMemo(
() => (asset.emissive ? buildGlowMaterial(asset.emissive) : null),
[asset.emissive],
);
useFrame(({ clock }) => {
if (!groupRef.current) return;
const elapsed = clock.elapsedTime;
const [x, y, z] = asset.position;
if (asset.animation === "bob") {
groupRef.current.position.set(x, y + Math.sin(elapsed * 1.8 + x) * 0.22, z);
} else if (asset.animation === "pulse") {
const scale = 1 + Math.sin(elapsed * 2.4 + z) * 0.06;
groupRef.current.position.set(x, y, z);
groupRef.current.scale.setScalar(scale);
} else {
groupRef.current.position.set(x, y, z);
groupRef.current.scale.setScalar(1);
}
groupRef.current.rotation.y =
asset.rotationY + (asset.animation === "spin" ? elapsed * 0.75 : 0);
});
return (
<group ref={groupRef} position={asset.position}>
<mesh geometry={geometry} material={material} castShadow receiveShadow />
{glowMaterial ? (
<mesh
position={[0, Math.max(asset.scale[1] * 0.6, 0.8), 0]}
material={glowMaterial}
>
<sphereGeometry args={[Math.max(asset.scale[0] * 0.28, 0.35), 18, 18]} />
</mesh>
) : null}
</group>
);
};
const SceneContents = ({ sceneDraft }: { sceneDraft: StudioWorldDraft }) => {
const fogColor = sceneDraft.palette.fog;
return (
<>
<color attach="background" args={[sceneDraft.palette.sky]} />
<fog attach="fog" args={[fogColor, 18, 78]} />
<ambientLight intensity={0.8} color="#f4f7ff" />
<directionalLight
castShadow
position={[14, 18, 12]}
intensity={1.1}
color="#fff3dd"
shadow-mapSize-width={2048}
shadow-mapSize-height={2048}
/>
<directionalLight position={[-12, 10, -6]} intensity={0.45} color={sceneDraft.palette.glow} />
<mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[sceneDraft.worldBounds.width * 2.2, sceneDraft.worldBounds.depth * 2.2]} />
<meshStandardMaterial color={sceneDraft.palette.ground} roughness={0.95} metalness={0.02} />
</mesh>
<gridHelper
args={[
Math.max(sceneDraft.worldBounds.width, sceneDraft.worldBounds.depth) * 2,
24,
new THREE.Color(sceneDraft.palette.glow),
new THREE.Color(sceneDraft.palette.structure),
]}
position={[0, 0.02, 0]}
/>
{sceneDraft.assets.map((asset) => (
<AssetMesh key={asset.id} asset={asset} />
))}
<Environment preset="city" />
<OrbitControls
enablePan={false}
minDistance={10}
maxDistance={72}
minPolarAngle={0.35}
maxPolarAngle={1.35}
target={sceneDraft.camera.target}
/>
</>
);
};
type StudioWorldPreviewProps = {
sceneDraft: StudioWorldDraft;
};
export function StudioWorldPreview({ sceneDraft }: StudioWorldPreviewProps) {
return (
<div className="relative h-full min-h-[360px] w-full overflow-hidden rounded-2xl border border-border/60 bg-black/70">
<Canvas
shadows
camera={{
position: sceneDraft.camera.position,
fov: 42,
}}
>
<SceneContents sceneDraft={sceneDraft} />
</Canvas>
<div className="pointer-events-none absolute inset-x-0 top-0 flex items-center justify-between bg-gradient-to-b from-black/55 to-transparent px-4 py-3">
<div>
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-cyan-100/80">
Claw3D Studio Preview
</p>
<p className="mt-1 text-sm text-white/90">{sceneDraft.promptSummary}</p>
</div>
<div className="rounded-full border border-white/15 bg-black/35 px-3 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-white/75">
{sceneDraft.assets.length} assets
</div>
</div>
</div>
);
}
@@ -0,0 +1,194 @@
import * as THREE from "three";
import type { StudioWorldAssetDraft, StudioWorldDraft } from "@/lib/studio-world/types";
export const buildAssetMaterial = (
color: string,
emissive?: string | null,
) =>
new THREE.MeshStandardMaterial({
color,
emissive: emissive ?? "#000000",
emissiveIntensity: emissive ? 0.85 : 0,
roughness: 0.62,
metalness: emissive ? 0.22 : 0.08,
});
export const buildGlowMaterial = (color: string) =>
new THREE.MeshStandardMaterial({
color,
emissive: color,
emissiveIntensity: 1.2,
transparent: true,
opacity: 0.85,
roughness: 0.18,
metalness: 0.28,
});
export const buildAssetGeometry = (kind: StudioWorldAssetDraft["kind"]) => {
if (kind === "platform" || kind === "crate") {
return new THREE.BoxGeometry(1, 1, 1);
}
if (kind === "tower" || kind === "beacon") {
return new THREE.CylinderGeometry(0.5, 0.7, 1, 8);
}
if (kind === "rock") {
return new THREE.DodecahedronGeometry(0.8, 0);
}
if (kind === "tree") {
return new THREE.ConeGeometry(0.8, 1.4, 10);
}
if (kind === "portal") {
return new THREE.TorusGeometry(0.6, 0.16, 16, 32);
}
return new THREE.BoxGeometry(1, 1, 1);
};
const createAssetMesh = (asset: StudioWorldAssetDraft) => {
const material = buildAssetMaterial(asset.color, asset.emissive ?? null);
if (asset.kind === "platform") {
const mesh = new THREE.Mesh(buildAssetGeometry(asset.kind, asset.scale), material);
mesh.scale.set(asset.scale[0], asset.scale[1], asset.scale[2]);
return mesh;
}
if (asset.kind === "tower") {
const mesh = new THREE.Mesh(buildAssetGeometry(asset.kind, asset.scale), material);
mesh.scale.set(asset.scale[0], asset.scale[1], asset.scale[2]);
return mesh;
}
if (asset.kind === "arch") {
const group = new THREE.Group();
const legGeometry = new THREE.BoxGeometry(0.24, 1, 0.24);
const beamGeometry = new THREE.BoxGeometry(1.35, 0.22, 0.3);
const leftLeg = new THREE.Mesh(legGeometry, material);
const rightLeg = new THREE.Mesh(legGeometry, material);
const beam = new THREE.Mesh(beamGeometry, material);
leftLeg.position.set(-0.42, 0.5, 0);
rightLeg.position.set(0.42, 0.5, 0);
beam.position.set(0, 1.02, 0);
group.add(leftLeg, rightLeg, beam);
group.scale.set(asset.scale[0], asset.scale[1], asset.scale[2]);
return group;
}
if (asset.kind === "tree") {
const group = new THREE.Group();
const trunk = new THREE.Mesh(
new THREE.CylinderGeometry(0.18, 0.2, 1, 8),
new THREE.MeshStandardMaterial({
color: "#6a4325",
roughness: 0.86,
metalness: 0.02,
}),
);
const canopy = new THREE.Mesh(buildAssetGeometry(asset.kind, asset.scale), material);
trunk.position.y = 0.5;
canopy.position.y = 1.5;
group.add(trunk, canopy);
group.scale.set(asset.scale[0] * 0.55, asset.scale[1] * 0.65, asset.scale[2] * 0.55);
return group;
}
if (asset.kind === "rock") {
const mesh = new THREE.Mesh(buildAssetGeometry(asset.kind, asset.scale), material);
mesh.scale.set(asset.scale[0], asset.scale[1], asset.scale[2]);
return mesh;
}
if (asset.kind === "beacon") {
const group = new THREE.Group();
const base = new THREE.Mesh(buildAssetGeometry(asset.kind, asset.scale), material);
const cap = new THREE.Mesh(
new THREE.SphereGeometry(0.34, 18, 18),
new THREE.MeshStandardMaterial({
color: asset.emissive ?? asset.color,
emissive: asset.emissive ?? asset.color,
emissiveIntensity: 1.2,
roughness: 0.18,
metalness: 0.28,
}),
);
base.position.y = 0.55;
cap.position.y = 1.25;
group.add(base, cap);
group.scale.set(asset.scale[0], asset.scale[1], asset.scale[2]);
return group;
}
if (asset.kind === "crate") {
const mesh = new THREE.Mesh(buildAssetGeometry(asset.kind, asset.scale), material);
mesh.scale.set(asset.scale[0], asset.scale[1], asset.scale[2]);
return mesh;
}
const ring = new THREE.Mesh(
buildAssetGeometry(asset.kind, asset.scale),
new THREE.MeshStandardMaterial({
color: asset.color,
emissive: asset.emissive ?? asset.color,
emissiveIntensity: 1.1,
roughness: 0.18,
metalness: 0.32,
}),
);
ring.scale.set(asset.scale[0], asset.scale[1], asset.scale[2]);
return ring;
};
const applySharedMeshProperties = (root: THREE.Object3D) => {
root.traverse((child) => {
if ((child as THREE.Mesh).isMesh) {
const mesh = child as THREE.Mesh;
mesh.castShadow = true;
mesh.receiveShadow = true;
}
});
};
const applyAnimationPose = (object: THREE.Object3D, asset: StudioWorldAssetDraft) => {
if (asset.animation === "bob") {
object.position.y += 0.45;
} else if (asset.animation === "pulse") {
object.scale.multiplyScalar(1.08);
} else if (asset.animation === "spin") {
object.rotation.z += Math.PI * 0.08;
}
};
export const buildPreviewSceneGroup = (draft: StudioWorldDraft) => {
const root = new THREE.Group();
root.name = "claw3d_studio_world";
const ground = new THREE.Mesh(
new THREE.CylinderGeometry(
Math.max(draft.worldBounds.width, draft.worldBounds.depth) * 0.62,
Math.max(draft.worldBounds.width, draft.worldBounds.depth) * 0.7,
0.8,
36,
),
new THREE.MeshStandardMaterial({
color: draft.palette.ground,
roughness: 0.95,
metalness: 0.02,
}),
);
ground.position.y = -0.42;
ground.receiveShadow = true;
ground.name = "ground";
root.add(ground);
for (const asset of draft.assets) {
const node = createAssetMesh(asset);
node.name = asset.id;
node.position.set(asset.position[0], asset.position[1], asset.position[2]);
node.rotation.y = asset.rotationY;
applyAnimationPose(node, asset);
applySharedMeshProperties(node);
root.add(node);
}
return root;
};
@@ -0,0 +1,491 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { HeaderBar } from "@/features/agents/components/HeaderBar";
import { exportStudioProjectGlb } from "@/features/studio-world/export/exportGlb";
import { StudioWorldPreview } from "@/features/studio-world/preview/StudioWorldPreview";
import type {
StudioProjectRecord,
StudioWorldFocus,
StudioWorldScale,
StudioWorldStyle,
} from "@/lib/studio-world/types";
const STYLE_OPTIONS: Array<{ value: StudioWorldStyle; label: string }> = [
{ value: "stylized", label: "Stylized" },
{ value: "realistic", label: "Realistic" },
{ value: "cinematic", label: "Cinematic" },
{ value: "low-poly", label: "Low-poly" },
];
const SCALE_OPTIONS: Array<{ value: StudioWorldScale; label: string }> = [
{ value: "small", label: "Small" },
{ value: "medium", label: "Medium" },
{ value: "large", label: "Large" },
];
const FOCUS_OPTIONS: Array<{ value: StudioWorldFocus; label: string }> = [
{ value: "world", label: "World" },
{ value: "assets", label: "Assets" },
{ value: "animation", label: "Animation" },
];
type ExportManifestResponse = {
exportManifest?: unknown;
error?: string;
};
type StudioWorldResponse = {
projects?: StudioProjectRecord[];
project?: StudioProjectRecord;
office?: {
workspaceId: string;
officeId: string;
officeVersionId: string;
publishedAt: string;
};
deleted?: boolean;
error?: string;
};
const formatTimestamp = (value: string) => {
const parsed = Date.parse(value);
if (!Number.isFinite(parsed)) return value;
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(parsed);
};
const downloadJson = (filename: string, data: unknown) => {
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
};
export function StudioWorldScreen() {
const [projects, setProjects] = useState<StudioProjectRecord[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [statusLine, setStatusLine] = useState<string | null>(null);
const [showConnectionPanel, setShowConnectionPanel] = useState(false);
const [name, setName] = useState("Studio Prototype");
const [prompt, setPrompt] = useState(
"A stylized collaborative 3D studio district with hero props, modular landmarks, and export-ready assets.",
);
const [style, setStyle] = useState<StudioWorldStyle>("stylized");
const [scale, setScale] = useState<StudioWorldScale>("medium");
const [focus, setFocus] = useState<StudioWorldFocus>("world");
const [seed, setSeed] = useState("");
const selectedProject = useMemo(
() => projects.find((entry) => entry.id === selectedProjectId) ?? projects[0] ?? null,
[projects, selectedProjectId],
);
const refreshProjects = useCallback(async () => {
setLoading(true);
try {
const response = await fetch("/api/studio-world", { cache: "no-store" });
const body = (await response.json()) as StudioWorldResponse;
if (!response.ok) {
throw new Error(body.error || "Failed to load studio projects.");
}
const nextProjects = body.projects ?? [];
setProjects(nextProjects);
setSelectedProjectId((current) => current ?? nextProjects[0]?.id ?? null);
setError(null);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "Failed to load studio projects.");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refreshProjects();
}, [refreshProjects]);
const handleGenerate = async () => {
setBusy(true);
setStatusLine("Generating clean-room studio draft.");
try {
const parsedSeed = seed.trim() ? Number.parseInt(seed.trim(), 10) : null;
const response = await fetch("/api/studio-world", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "generate",
input: {
name,
prompt,
style,
scale,
focus,
seed: Number.isFinite(parsedSeed) ? parsedSeed : null,
},
}),
});
const body = (await response.json()) as StudioWorldResponse;
if (!response.ok || !body.project) {
throw new Error(body.error || "Failed to generate studio project.");
}
setProjects((current) => [body.project!, ...current.filter((entry) => entry.id !== body.project!.id)]);
setSelectedProjectId(body.project.id);
setError(null);
setStatusLine(`Generated ${body.project.name}.`);
} catch (generationError) {
setError(
generationError instanceof Error
? generationError.message
: "Failed to generate studio project.",
);
setStatusLine(null);
} finally {
setBusy(false);
}
};
const handleDelete = async (projectId: string) => {
setBusy(true);
setStatusLine("Deleting studio draft.");
try {
const response = await fetch(`/api/studio-world?projectId=${encodeURIComponent(projectId)}`, {
method: "DELETE",
});
const body = (await response.json()) as StudioWorldResponse;
if (!response.ok || !body.deleted) {
throw new Error(body.error || "Failed to delete studio project.");
}
const nextProjects = projects.filter((entry) => entry.id !== projectId);
setProjects(nextProjects);
setSelectedProjectId(nextProjects[0]?.id ?? null);
setError(null);
setStatusLine("Studio draft deleted.");
} catch (deleteError) {
setError(deleteError instanceof Error ? deleteError.message : "Failed to delete studio project.");
setStatusLine(null);
} finally {
setBusy(false);
}
};
const handleExportManifest = async (projectId: string) => {
setBusy(true);
setStatusLine("Preparing export manifest.");
try {
const response = await fetch(
`/api/studio-world?action=export&projectId=${encodeURIComponent(projectId)}`,
{ cache: "no-store" },
);
const body = (await response.json()) as ExportManifestResponse;
if (!response.ok || !body.exportManifest) {
throw new Error(body.error || "Failed to export studio project.");
}
downloadJson(`${projectId}.glb.json`, body.exportManifest);
setError(null);
setStatusLine("Export manifest downloaded.");
} catch (exportError) {
setError(exportError instanceof Error ? exportError.message : "Failed to export studio project.");
setStatusLine(null);
} finally {
setBusy(false);
}
};
const handleExportGlb = async (project: StudioProjectRecord) => {
setBusy(true);
setStatusLine("Building GLB export.");
try {
await exportStudioProjectGlb(project);
setError(null);
setStatusLine("GLB downloaded.");
} catch (exportError) {
setError(exportError instanceof Error ? exportError.message : "Failed to export GLB.");
setStatusLine(null);
} finally {
setBusy(false);
}
};
const handleApplyToOffice = async (projectId: string) => {
setBusy(true);
setStatusLine("Publishing generated layout to Claw3D office.");
try {
const response = await fetch("/api/studio-world", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "apply_to_office",
projectId,
}),
});
const body = (await response.json()) as StudioWorldResponse;
if (!response.ok || !body.office) {
throw new Error(body.error || "Failed to apply studio project to office.");
}
setError(null);
setStatusLine(`Published office version ${body.office.officeVersionId}.`);
} catch (applyError) {
setError(applyError instanceof Error ? applyError.message : "Failed to apply studio project to office.");
setStatusLine(null);
} finally {
setBusy(false);
}
};
return (
<div className="relative h-full w-full overflow-hidden bg-background">
<div className="relative z-10 flex h-full flex-col">
<HeaderBar
status="disconnected"
currentSection="studio"
onConnectionSettings={() => setShowConnectionPanel((current) => !current)}
/>
<div className="flex min-h-0 flex-1 flex-col gap-3 px-3 pb-3 pt-2 sm:px-4 sm:pb-4 sm:pt-3 md:px-5 md:pb-5 md:pt-3">
{showConnectionPanel ? (
<div className="ui-card px-4 py-3 text-sm text-muted-foreground">
Claw3D Studio uses local clean-room generation today. Remote model workers can be attached behind
`/api/studio-world` in a future provider.
</div>
) : null}
{error ? (
<div className="ui-alert-danger rounded-md px-4 py-2 text-sm">{error}</div>
) : null}
{statusLine ? (
<div className="ui-card px-4 py-2 font-mono text-[11px] tracking-[0.07em] text-muted-foreground">
{statusLine}
</div>
) : null}
<div className="grid min-h-0 flex-1 gap-3 xl:grid-cols-[340px_minmax(0,1fr)_320px]">
<section className="ui-panel ui-depth-workspace min-h-0 overflow-auto p-4">
<div className="font-mono text-[10px] uppercase tracking-[0.2em] text-muted-foreground">
Claw3D Studio
</div>
<h1 className="mt-2 text-2xl font-semibold text-foreground">Generate 3D worlds, assets, and motion.</h1>
<p className="mt-2 text-sm text-muted-foreground">
This clean-room workflow is inspired by modern world-model pipelines, but implemented as Claw3D-native
tooling and data contracts.
</p>
<div className="mt-5 space-y-4">
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-foreground">Project name</span>
<input
className="ui-input w-full"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Studio Prototype"
/>
</label>
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-foreground">Generation brief</span>
<textarea
className="ui-input min-h-36 w-full resize-y"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder="Describe the world, hero assets, camera mood, and export intent."
/>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-foreground">Style</span>
<select className="ui-input w-full" value={style} onChange={(event) => setStyle(event.target.value as StudioWorldStyle)}>
{STYLE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-foreground">Scale</span>
<select className="ui-input w-full" value={scale} onChange={(event) => setScale(event.target.value as StudioWorldScale)}>
{SCALE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-foreground">Focus</span>
<select className="ui-input w-full" value={focus} onChange={(event) => setFocus(event.target.value as StudioWorldFocus)}>
{FOCUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-foreground">Seed</span>
<input
className="ui-input w-full"
value={seed}
onChange={(event) => setSeed(event.target.value)}
placeholder="Optional deterministic seed"
/>
</label>
</div>
<div className="flex flex-wrap gap-2">
<button type="button" className="ui-btn-primary px-4 py-2 text-sm" onClick={() => void handleGenerate()} disabled={busy}>
{busy ? "Working..." : "Generate scene"}
</button>
<button type="button" className="ui-btn-secondary px-4 py-2 text-sm" onClick={() => void refreshProjects()} disabled={busy}>
Refresh library
</button>
</div>
</div>
</section>
<section className="ui-panel ui-depth-workspace min-h-0 overflow-hidden p-3">
{selectedProject ? (
<div className="flex h-full min-h-0 flex-col gap-3">
<StudioWorldPreview sceneDraft={selectedProject.sceneDraft} />
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_280px]">
<div className="ui-card max-h-52 overflow-auto p-4">
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
Scene notes
</div>
<ul className="mt-3 space-y-2 text-sm text-muted-foreground">
{selectedProject.sceneDraft.notes.map((note) => (
<li key={note} className="rounded-lg border border-border/50 bg-surface-1/50 px-3 py-2">
{note}
</li>
))}
</ul>
</div>
<div className="ui-card max-h-52 overflow-auto p-4">
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
Export targets
</div>
<div className="mt-3 space-y-2 text-sm text-muted-foreground">
<div className="rounded-lg border border-border/50 bg-surface-1/50 px-3 py-2">Direct GLB download.</div>
<div className="rounded-lg border border-border/50 bg-surface-1/50 px-3 py-2">GLB manifest download.</div>
<div className="rounded-lg border border-border/50 bg-surface-1/50 px-3 py-2">Publish to Claw3D office layout.</div>
<div className="rounded-lg border border-border/50 bg-surface-1/50 px-3 py-2">Future provider seam for remote model workers.</div>
</div>
</div>
</div>
</div>
) : (
<div className="flex h-full items-center justify-center rounded-2xl border border-dashed border-border/70 text-sm text-muted-foreground">
{loading ? "Loading studio projects..." : "Generate a scene to open the preview."}
</div>
)}
</section>
<aside className="ui-panel ui-depth-workspace min-h-0 overflow-auto p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.2em] text-muted-foreground">
Project library
</div>
<div className="mt-1 text-sm text-muted-foreground">
Generated drafts persist locally in Studio.
</div>
</div>
<div className="rounded-full border border-border/60 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
{projects.length}
</div>
</div>
<div className="mt-4 space-y-3">
{projects.map((project) => {
const isSelected = project.id === selectedProject?.id;
return (
<div
key={project.id}
className={`rounded-2xl border p-3 transition-colors ${
isSelected
? "border-primary/50 bg-primary/8"
: "border-border/60 bg-surface-1/35"
}`}
>
<button
type="button"
className="w-full text-left"
onClick={() => setSelectedProjectId(project.id)}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-foreground">{project.name}</div>
<div className="mt-1 text-xs text-muted-foreground">{project.latestJob.summary}</div>
</div>
<div className="rounded-full border border-border/60 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
{project.sceneDraft.assets.length}
</div>
</div>
<div className="mt-3 flex flex-wrap gap-1.5">
<span className="rounded-full bg-muted px-2 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">
{project.style}
</span>
<span className="rounded-full bg-muted px-2 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">
{project.focus}
</span>
<span className="rounded-full bg-muted px-2 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">
{project.scale}
</span>
</div>
</button>
<div className="mt-3 text-[11px] text-muted-foreground">
Updated {formatTimestamp(project.updatedAt)}.
</div>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
className="ui-btn-secondary px-3 py-1.5 text-xs"
onClick={() => void handleExportGlb(project)}
disabled={busy}
>
Export GLB
</button>
<button
type="button"
className="ui-btn-secondary px-3 py-1.5 text-xs"
onClick={() => void handleExportManifest(project.id)}
disabled={busy}
>
Export manifest
</button>
<button
type="button"
className="ui-btn-secondary px-3 py-1.5 text-xs"
onClick={() => void handleApplyToOffice(project.id)}
disabled={busy}
>
Apply to office
</button>
<button
type="button"
className="ui-btn-secondary px-3 py-1.5 text-xs text-red-300"
onClick={() => void handleDelete(project.id)}
disabled={busy}
>
Delete
</button>
</div>
</div>
);
})}
{!loading && projects.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/70 px-4 py-6 text-sm text-muted-foreground">
No studio drafts yet.
</div>
) : null}
</div>
</aside>
</div>
</div>
</div>
</div>
);
}
+271
View File
@@ -0,0 +1,271 @@
import type {
StudioGenerationInput,
StudioWorldAnimationKind,
StudioWorldAssetDraft,
StudioWorldAssetKind,
StudioWorldBiome,
StudioWorldDraft,
StudioWorldPalette,
StudioWorldScale,
StudioWorldStyle,
} from "@/lib/studio-world/types";
const hashString = (value: string) => {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) | 0;
}
return Math.abs(hash);
};
const createSeededRandom = (seed: number) => {
let state = seed >>> 0;
return () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 0x100000000;
};
};
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
const pick = <T>(items: readonly T[], random: () => number): T =>
items[Math.floor(random() * items.length)] ?? items[0];
const round2 = (value: number) => Math.round(value * 100) / 100;
const normalizePrompt = (value: string) =>
value
.trim()
.replace(/\s+/g, " ")
.slice(0, 220);
const STYLE_PALETTES: Record<StudioWorldStyle, StudioWorldPalette> = {
stylized: {
ground: "#395b4b",
structure: "#85b6ff",
prop: "#d6c38f",
accent: "#ff8c61",
glow: "#7be7ff",
fog: "#7aa7ff",
sky: "#14233c",
},
realistic: {
ground: "#4f5e46",
structure: "#9c9388",
prop: "#726556",
accent: "#d9974f",
glow: "#efe4b8",
fog: "#b0b7c8",
sky: "#7893b5",
},
cinematic: {
ground: "#2b3146",
structure: "#6c78a8",
prop: "#b38d71",
accent: "#ff5e7a",
glow: "#8fd9ff",
fog: "#7568d8",
sky: "#090d18",
},
"low-poly": {
ground: "#4c6d56",
structure: "#7bb2a6",
prop: "#d7a36a",
accent: "#ffcf5d",
glow: "#fff1a3",
fog: "#b9d8ff",
sky: "#203247",
},
};
const BIOME_NOTES: Record<StudioWorldBiome, string[]> = {
creative_plaza: [
"Central plaza layout supports showcase scenes and collaborative props.",
"Sight lines stay open so exported assets remain easy to stage in Claw3D.",
],
forest: [
"Organic prop spacing creates a traversal loop with layered silhouettes.",
"Trees and rocks remain modular so they can be reused as asset packs.",
],
desert: [
"Large negative space preserves readability for vehicles and hero props.",
"Beacon accents define landmarks and work as navigation anchors.",
],
coast: [
"Shoreline composition balances broad terrain planes with landmark structures.",
"Accent lighting suggests a path from beach props toward a hub space.",
],
neo_city: [
"Vertical massing creates skyline depth and supports cinematic camera moves.",
"Repeated modular towers and portals form a reusable sci-fi kit.",
],
fantasy: [
"Arches and beacons imply traversal goals and magical interaction points.",
"Pulse-driven accents help animation previews read at a glance.",
],
};
const scaleToBounds = (scale: StudioWorldScale) => {
if (scale === "small") {
return { width: 28, depth: 28, assetCount: 12 };
}
if (scale === "large") {
return { width: 58, depth: 58, assetCount: 28 };
}
return { width: 40, depth: 40, assetCount: 18 };
};
const detectBiome = (prompt: string, style: StudioWorldStyle): StudioWorldBiome => {
const normalized = prompt.toLowerCase();
if (/\bforest|wood|tree|grove|nature\b/.test(normalized)) return "forest";
if (/\bdesert|dune|sand|canyon\b/.test(normalized)) return "desert";
if (/\bcoast|beach|ocean|harbor|shore\b/.test(normalized)) return "coast";
if (/\bcity|cyber|neon|urban|street\b/.test(normalized)) return "neo_city";
if (/\bfantasy|magic|ruin|temple|myth\b/.test(normalized)) return "fantasy";
if (style === "cinematic") return "neo_city";
return "creative_plaza";
};
const buildAssetKindSet = (
biome: StudioWorldBiome,
focus: StudioGenerationInput["focus"],
): StudioWorldAssetKind[] => {
const base: StudioWorldAssetKind[] = ["platform", "arch", "crate", "beacon"];
if (biome === "forest") base.push("tree", "rock", "tree");
if (biome === "desert") base.push("rock", "tower", "arch");
if (biome === "coast") base.push("rock", "portal", "platform");
if (biome === "neo_city") base.push("tower", "portal", "tower");
if (biome === "fantasy") base.push("arch", "portal", "beacon");
if (focus === "assets") base.push("crate", "tree", "rock", "arch");
if (focus === "animation") base.push("beacon", "portal", "tower");
if (focus === "world") base.push("platform", "tower", "arch");
return base;
};
const resolveAnimation = (
kind: StudioWorldAssetKind,
focus: StudioGenerationInput["focus"],
random: () => number,
): StudioWorldAnimationKind => {
if (focus === "animation") {
if (kind === "portal" || kind === "beacon") return "pulse";
return random() > 0.45 ? "bob" : "spin";
}
if (kind === "portal") return "spin";
if (kind === "beacon") return "pulse";
return random() > 0.82 ? "bob" : "none";
};
const buildAssetName = (kind: StudioWorldAssetKind, index: number) =>
`${kind.replace(/_/g, " ")} ${index + 1}`;
const buildAssetColor = (
kind: StudioWorldAssetKind,
palette: StudioWorldPalette,
random: () => number,
) => {
if (kind === "platform" || kind === "tower" || kind === "arch") return palette.structure;
if (kind === "portal" || kind === "beacon") return random() > 0.5 ? palette.accent : palette.glow;
return palette.prop;
};
const buildAssetScale = (kind: StudioWorldAssetKind, random: () => number): [number, number, number] => {
if (kind === "platform") return [3 + random() * 4, 0.6 + random() * 0.4, 3 + random() * 4];
if (kind === "tower") return [1.2 + random() * 1.6, 4 + random() * 5, 1.2 + random() * 1.6];
if (kind === "arch") return [2.4 + random() * 1.8, 2 + random() * 1.3, 0.8 + random() * 0.5];
if (kind === "tree") return [0.9 + random() * 1.5, 2.8 + random() * 2.8, 0.9 + random() * 1.5];
if (kind === "rock") return [0.8 + random() * 2.2, 0.6 + random() * 1.4, 0.8 + random() * 2.2];
if (kind === "beacon") return [0.7 + random() * 0.6, 2 + random() * 1.4, 0.7 + random() * 0.6];
if (kind === "portal") return [1.6 + random() * 1.2, 2.8 + random() * 1.4, 0.45 + random() * 0.35];
return [0.9 + random() * 1.2, 0.9 + random() * 1.2, 0.9 + random() * 1.2];
};
const buildAssetPosition = (
index: number,
total: number,
bounds: { width: number; depth: number },
random: () => number,
): [number, number, number] => {
const radius = Math.min(bounds.width, bounds.depth) * (0.16 + 0.34 * (index / Math.max(total - 1, 1)));
const angle = index * 0.74 + random() * 0.9;
const x = Math.cos(angle) * radius + (random() - 0.5) * 4;
const z = Math.sin(angle) * radius + (random() - 0.5) * 4;
return [round2(x), 0, round2(z)];
};
const buildSceneAssets = (params: {
biome: StudioWorldBiome;
focus: StudioGenerationInput["focus"];
palette: StudioWorldPalette;
bounds: { width: number; depth: number; assetCount: number };
random: () => number;
}) => {
const kindSet = buildAssetKindSet(params.biome, params.focus);
const assets: StudioWorldAssetDraft[] = [];
for (let index = 0; index < params.bounds.assetCount; index += 1) {
const kind = pick(kindSet, params.random);
const animation = resolveAnimation(kind, params.focus, params.random);
const color = buildAssetColor(kind, params.palette, params.random);
assets.push({
id: `asset_${index + 1}`,
name: buildAssetName(kind, index),
kind,
position: buildAssetPosition(index, params.bounds.assetCount, params.bounds, params.random),
scale: buildAssetScale(kind, params.random),
rotationY: round2(params.random() * Math.PI * 2),
color,
emissive:
kind === "portal" || kind === "beacon"
? (params.random() > 0.5 ? params.palette.glow : params.palette.accent)
: null,
animation,
});
}
return assets;
};
export const resolveGenerationSeed = (input: StudioGenerationInput) => {
if (typeof input.seed === "number" && Number.isFinite(input.seed)) {
return Math.floor(Math.abs(input.seed));
}
return hashString(`${input.name}:${input.prompt}:${input.style}:${input.scale}:${input.focus}`);
};
export const buildStudioWorldDraft = (input: StudioGenerationInput): StudioWorldDraft => {
const seed = resolveGenerationSeed(input);
const random = createSeededRandom(seed);
const biome = detectBiome(input.prompt, input.style);
const bounds = scaleToBounds(input.scale);
const palette = STYLE_PALETTES[input.style];
const assets = buildSceneAssets({
biome,
focus: input.focus,
palette,
bounds,
random,
});
const normalizedPrompt = normalizePrompt(input.prompt);
const promptSummary = normalizedPrompt || "Untitled generated scene";
const notes = [
...BIOME_NOTES[biome],
`Primary focus: ${input.focus}.`,
`Generated with ${input.style} styling and seed ${seed}.`,
];
return {
biome,
palette,
worldBounds: {
width: bounds.width,
depth: bounds.depth,
},
camera: {
position: [bounds.width * 0.65, clamp(bounds.width * 0.7, 18, 40), bounds.depth * 0.65],
target: [0, 0, 0],
},
promptSummary,
notes,
assets,
};
};
+138
View File
@@ -0,0 +1,138 @@
import {
createEmptyOfficeMap,
type OfficeCollision,
type OfficeMap,
type OfficeMapObject,
type OfficeZone,
} from "@/lib/office/schema";
import type { StudioProjectRecord, StudioWorldAssetDraft } from "@/lib/studio-world/types";
const WORLD_SCALE_TO_CANVAS = 28;
const HALF_PADDING = 120;
const clampRotationToQuarterTurns = (rotationY: number) =>
(((Math.round(rotationY / (Math.PI / 2)) % 4) + 4) % 4) * 90;
const toCanvasX = (worldX: number, width: number) =>
Math.round(width / 2 + worldX * WORLD_SCALE_TO_CANVAS);
const toCanvasY = (worldZ: number, height: number) =>
Math.round(height / 2 + worldZ * WORLD_SCALE_TO_CANVAS);
const resolveObjectAssetId = (asset: StudioWorldAssetDraft): OfficeMapObject["assetId"] => {
if (asset.kind === "platform") return "floor_tile";
if (asset.kind === "tower") return "arcade_machine";
if (asset.kind === "portal") return "tv_wall";
if (asset.kind === "crate") return "coffee_station";
if (asset.kind === "beacon") return "arcade_machine";
if (asset.kind === "arch") return "meeting_table";
if (asset.kind === "tree") return "coffee_station";
if (asset.kind === "rock") return "desk_modern";
return "desk_modern";
};
const resolveLayerId = (asset: StudioWorldAssetDraft): OfficeMapObject["layerId"] => {
if (asset.kind === "platform") return "floor";
if (asset.kind === "portal" || asset.kind === "beacon") return "decor";
return "furniture";
};
const createCollisionForAsset = (
asset: StudioWorldAssetDraft,
mapWidth: number,
mapHeight: number,
): OfficeCollision | null => {
if (asset.kind === "platform") return null;
const width = Math.max(20, Math.round(asset.scale[0] * WORLD_SCALE_TO_CANVAS));
const depth = Math.max(20, Math.round(asset.scale[2] * WORLD_SCALE_TO_CANVAS));
const cx = toCanvasX(asset.position[0], mapWidth);
const cy = toCanvasY(asset.position[2], mapHeight);
return {
id: `collision_${asset.id}`,
blocked: true,
shape: {
points: [
{ x: cx - width / 2, y: cy - depth / 2 },
{ x: cx + width / 2, y: cy - depth / 2 },
{ x: cx + width / 2, y: cy + depth / 2 },
{ x: cx - width / 2, y: cy + depth / 2 },
],
},
};
};
const buildObjects = (
project: StudioProjectRecord,
width: number,
height: number,
): OfficeMapObject[] =>
project.sceneDraft.assets.map((asset, index) => ({
id: asset.id,
assetId: resolveObjectAssetId(asset),
layerId: resolveLayerId(asset),
x: toCanvasX(asset.position[0], width),
y: toCanvasY(asset.position[2], height),
rotation: clampRotationToQuarterTurns(asset.rotationY),
flipX: false,
flipY: false,
zIndex: 100 + index,
tags: [
"studio-generated",
`studio-kind:${asset.kind}`,
`studio-project:${project.id}`,
],
}));
const buildZone = (project: StudioProjectRecord, width: number, height: number): OfficeZone => {
const halfWidth = Math.round(project.sceneDraft.worldBounds.width * WORLD_SCALE_TO_CANVAS * 0.5);
const halfDepth = Math.round(project.sceneDraft.worldBounds.depth * WORLD_SCALE_TO_CANVAS * 0.5);
const centerX = Math.round(width / 2);
const centerY = Math.round(height / 2);
return {
id: "zone_studio_generated",
type: "hallway",
name: project.name,
ambienceTags: [project.style, project.focus],
shape: {
points: [
{ x: centerX - halfWidth, y: centerY - halfDepth },
{ x: centerX + halfWidth, y: centerY - halfDepth },
{ x: centerX + halfWidth, y: centerY + halfDepth },
{ x: centerX - halfWidth, y: centerY + halfDepth },
],
},
};
};
export const buildOfficeMapFromStudioProject = (
project: StudioProjectRecord,
officeVersionId: string,
): OfficeMap => {
const width =
Math.round(project.sceneDraft.worldBounds.width * WORLD_SCALE_TO_CANVAS + HALF_PADDING * 2);
const height =
Math.round(project.sceneDraft.worldBounds.depth * WORLD_SCALE_TO_CANVAS + HALF_PADDING * 2);
const map = createEmptyOfficeMap({
workspaceId: "default",
officeVersionId,
width,
height,
});
map.canvas.backgroundColor = project.sceneDraft.palette.sky;
map.theme = {
mood: project.style === "cinematic" ? "night" : project.style === "realistic" ? "focus" : "cozy",
enableThoughtBubbles: true,
};
map.objects = buildObjects(project, width, height);
map.zones = [buildZone(project, width, height)];
map.collisions = project.sceneDraft.assets
.map((asset) => createCollisionForAsset(asset, width, height))
.filter((entry): entry is OfficeCollision => Boolean(entry));
map.spawnPoints = [{ id: "spawn-main", x: Math.round(width / 2), y: Math.round(height / 2) }];
map.lightingOverlay = {
enabled: true,
baseDarkness: project.style === "cinematic" ? 0.34 : 0.18,
roomDarkness: {},
};
return map;
};
+220
View File
@@ -0,0 +1,220 @@
import fs from "node:fs";
import path from "node:path";
import { resolveStateDir } from "@/lib/clawdbot/paths";
import {
buildStudioWorldDraft,
resolveGenerationSeed,
} from "@/lib/studio-world/generator";
import type {
StudioGenerationInput,
StudioGenerationJobRecord,
StudioProjectRecord,
StudioProjectsStore,
} from "@/lib/studio-world/types";
const STORE_DIR = "claw3d";
const STORE_FILE = "studio-world-projects.json";
const STORE_VERSION = 1;
const ensureDirectory = (dirPath: string) => {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
};
const resolveStorePath = () => {
const stateDir = resolveStateDir();
const dir = path.join(stateDir, STORE_DIR);
ensureDirectory(dir);
return path.join(dir, STORE_FILE);
};
const defaultStore = (): StudioProjectsStore => ({
schemaVersion: STORE_VERSION,
projects: [],
});
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
const asString = (value: unknown, fallback = "") =>
typeof value === "string" ? value : fallback;
const asNumber = (value: unknown, fallback = 0) =>
typeof value === "number" && Number.isFinite(value) ? value : fallback;
const normalizeStore = (value: unknown): StudioProjectsStore => {
if (!isRecord(value)) {
return defaultStore();
}
const rawProjects = Array.isArray(value.projects) ? value.projects : [];
const projects = rawProjects
.map((entry): StudioProjectRecord | null => {
if (!isRecord(entry)) return null;
if (!isRecord(entry.latestJob)) return null;
if (!isRecord(entry.sceneDraft)) return null;
const id = asString(entry.id).trim();
const name = asString(entry.name).trim();
const prompt = asString(entry.prompt).trim();
const createdAt = asString(entry.createdAt).trim();
const updatedAt = asString(entry.updatedAt).trim();
if (!id || !name || !createdAt || !updatedAt) return null;
return {
id,
name,
prompt,
style:
entry.style === "realistic" ||
entry.style === "cinematic" ||
entry.style === "low-poly"
? entry.style
: "stylized",
scale:
entry.scale === "small" || entry.scale === "large"
? entry.scale
: "medium",
focus:
entry.focus === "assets" || entry.focus === "animation"
? entry.focus
: "world",
seed: asNumber(entry.seed, 0),
createdAt,
updatedAt,
latestJob: {
id: asString(entry.latestJob.id, "job"),
provider: asString(entry.latestJob.provider, "clean-room-procedural"),
status: "completed",
createdAt: asString(entry.latestJob.createdAt, createdAt),
finishedAt: asString(entry.latestJob.finishedAt, updatedAt),
summary: asString(entry.latestJob.summary, ""),
assetCount: asNumber(entry.latestJob.assetCount, 0),
},
sceneDraft: entry.sceneDraft as StudioProjectRecord["sceneDraft"],
};
})
.filter((entry): entry is StudioProjectRecord => Boolean(entry))
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
return {
schemaVersion: STORE_VERSION,
projects,
};
};
const readStore = (): StudioProjectsStore => {
const storePath = resolveStorePath();
if (!fs.existsSync(storePath)) {
return defaultStore();
}
const raw = fs.readFileSync(storePath, "utf8");
return normalizeStore(JSON.parse(raw));
};
const writeStore = (store: StudioProjectsStore) => {
const storePath = resolveStorePath();
fs.writeFileSync(storePath, JSON.stringify(store, null, 2), "utf8");
};
const slugify = (value: string) =>
value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48) || "studio-world";
const createProjectId = (name: string) =>
`${slugify(name)}-${Date.now().toString(36)}`;
const createJobId = () => `job-${Date.now().toString(36)}`;
const buildSummary = (params: {
input: StudioGenerationInput;
assetCount: number;
}) =>
`${params.input.style} ${params.input.focus} draft with ${params.assetCount} assets for ${params.input.scale} scope.`;
export const listStudioProjects = () => readStore().projects;
export const getStudioProject = (projectId: string) =>
readStore().projects.find((entry) => entry.id === projectId) ?? null;
export const createStudioProject = (input: StudioGenerationInput) => {
const store = readStore();
const createdAt = new Date().toISOString();
const seed = resolveGenerationSeed(input);
const sceneDraft = buildStudioWorldDraft(input);
const latestJob: StudioGenerationJobRecord = {
id: createJobId(),
provider: "clean-room-procedural",
status: "completed",
createdAt,
finishedAt: createdAt,
summary: buildSummary({ input, assetCount: sceneDraft.assets.length }),
assetCount: sceneDraft.assets.length,
};
const project: StudioProjectRecord = {
id: createProjectId(input.name),
name: input.name.trim() || "Untitled Studio World",
prompt: input.prompt.trim(),
style: input.style,
scale: input.scale,
focus: input.focus,
seed,
createdAt,
updatedAt: createdAt,
latestJob,
sceneDraft,
};
store.projects = [project, ...store.projects].sort((left, right) =>
right.updatedAt.localeCompare(left.updatedAt),
);
writeStore(store);
return project;
};
export const updateStudioProject = (
projectId: string,
patch: Partial<
Pick<StudioProjectRecord, "name" | "prompt" | "style" | "scale" | "focus" | "sceneDraft">
>,
) => {
const store = readStore();
const target = store.projects.find((entry) => entry.id === projectId);
if (!target) {
throw new Error("Studio project not found.");
}
if (typeof patch.name === "string") {
target.name = patch.name.trim() || target.name;
}
if (typeof patch.prompt === "string") {
target.prompt = patch.prompt.trim();
}
if (patch.style) {
target.style = patch.style;
}
if (patch.scale) {
target.scale = patch.scale;
}
if (patch.focus) {
target.focus = patch.focus;
}
if (patch.sceneDraft) {
target.sceneDraft = patch.sceneDraft;
}
target.updatedAt = new Date().toISOString();
store.projects.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
writeStore(store);
return target;
};
export const deleteStudioProject = (projectId: string) => {
const store = readStore();
const nextProjects = store.projects.filter((entry) => entry.id !== projectId);
const deleted = nextProjects.length !== store.projects.length;
if (deleted) {
store.projects = nextProjects;
writeStore(store);
}
return deleted;
};
+105
View File
@@ -0,0 +1,105 @@
export type StudioWorldStyle =
| "stylized"
| "realistic"
| "cinematic"
| "low-poly";
export type StudioWorldScale = "small" | "medium" | "large";
export type StudioWorldFocus = "world" | "assets" | "animation";
export type StudioWorldBiome =
| "creative_plaza"
| "forest"
| "desert"
| "coast"
| "neo_city"
| "fantasy";
export type StudioWorldAnimationKind = "none" | "bob" | "spin" | "pulse";
export type StudioWorldAssetKind =
| "platform"
| "tower"
| "arch"
| "tree"
| "rock"
| "beacon"
| "crate"
| "portal";
export type StudioWorldAssetDraft = {
id: string;
name: string;
kind: StudioWorldAssetKind;
position: [number, number, number];
scale: [number, number, number];
rotationY: number;
color: string;
emissive?: string | null;
animation: StudioWorldAnimationKind;
};
export type StudioWorldPalette = {
ground: string;
structure: string;
prop: string;
accent: string;
glow: string;
fog: string;
sky: string;
};
export type StudioWorldDraft = {
biome: StudioWorldBiome;
palette: StudioWorldPalette;
worldBounds: {
width: number;
depth: number;
};
camera: {
position: [number, number, number];
target: [number, number, number];
};
promptSummary: string;
notes: string[];
assets: StudioWorldAssetDraft[];
};
export type StudioGenerationInput = {
name: string;
prompt: string;
style: StudioWorldStyle;
scale: StudioWorldScale;
focus: StudioWorldFocus;
seed?: number | null;
};
export type StudioGenerationJobRecord = {
id: string;
provider: string;
status: "completed";
createdAt: string;
finishedAt: string;
summary: string;
assetCount: number;
};
export type StudioProjectRecord = {
id: string;
name: string;
prompt: string;
style: StudioWorldStyle;
scale: StudioWorldScale;
focus: StudioWorldFocus;
seed: number;
createdAt: string;
updatedAt: string;
latestJob: StudioGenerationJobRecord;
sceneDraft: StudioWorldDraft;
};
export type StudioProjectsStore = {
schemaVersion: number;
projects: StudioProjectRecord[];
};
+99
View File
@@ -0,0 +1,99 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { DELETE, GET, POST } from "@/app/api/studio-world/route";
const makeTempDir = (name: string) => fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`));
describe("studio world route", () => {
const priorStateDir = process.env.OPENCLAW_STATE_DIR;
let tempDir: string | null = null;
afterEach(() => {
process.env.OPENCLAW_STATE_DIR = priorStateDir;
if (tempDir) {
fs.rmSync(tempDir, { recursive: true, force: true });
tempDir = null;
}
});
it("creates, lists, exports, applies, and deletes a studio project", async () => {
tempDir = makeTempDir("studio-world-route");
process.env.OPENCLAW_STATE_DIR = tempDir;
const createResponse = await POST({
text: async () =>
JSON.stringify({
action: "generate",
input: {
name: "Forest Lab",
prompt: "A stylized forest lab with modular props and export-ready landmarks.",
style: "stylized",
scale: "medium",
focus: "world",
seed: 42,
},
}),
} as unknown as Request);
const createBody = (await createResponse.json()) as {
project?: {
id: string;
name: string;
sceneDraft: { assets: Array<unknown> };
};
};
expect(createResponse.status).toBe(200);
expect(createBody.project?.name).toBe("Forest Lab");
expect(createBody.project?.sceneDraft.assets.length).toBeGreaterThan(0);
const projectId = createBody.project?.id ?? "";
expect(projectId.length).toBeGreaterThan(0);
const listResponse = await GET(
new Request("http://localhost/api/studio-world"),
);
const listBody = (await listResponse.json()) as {
projects?: Array<{ id: string }>;
};
expect(listResponse.status).toBe(200);
expect(listBody.projects?.some((project) => project.id === projectId)).toBe(true);
const exportResponse = await GET(
new Request(`http://localhost/api/studio-world?action=export&projectId=${encodeURIComponent(projectId)}`),
);
const exportBody = (await exportResponse.json()) as {
exportManifest?: {
project?: { id?: string };
export?: { format?: string };
};
};
expect(exportResponse.status).toBe(200);
expect(exportBody.exportManifest?.project?.id).toBe(projectId);
expect(exportBody.exportManifest?.export?.format).toBe("glb_manifest");
const applyResponse = await POST({
text: async () =>
JSON.stringify({
action: "apply_to_office",
projectId,
}),
} as unknown as Request);
const applyBody = (await applyResponse.json()) as {
office?: { officeId?: string; officeVersionId?: string };
};
expect(applyResponse.status).toBe(200);
expect(applyBody.office?.officeId).toBe("studio-world");
expect((applyBody.office?.officeVersionId ?? "").length).toBeGreaterThan(0);
const deleteResponse = await DELETE(
new Request(`http://localhost/api/studio-world?projectId=${encodeURIComponent(projectId)}`),
);
const deleteBody = (await deleteResponse.json()) as { deleted?: boolean };
expect(deleteResponse.status).toBe(200);
expect(deleteBody.deleted).toBe(true);
});
});