Skip to content

Commit 7bb7622

Browse files
feat: add AI mesh generation functionality with streaming response and minimal UI toggle
1 parent 7740f69 commit 7bb7622

File tree

9 files changed

+505
-45
lines changed

9 files changed

+505
-45
lines changed

package-lock.json

Lines changed: 34 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"dependencies": {
1212
"@ai-sdk/openai": "^2.0.27",
1313
"@ai-sdk/react": "^2.0.37",
14+
"@ai-sdk/xai": "^2.0.16",
1415
"@base-ui-components/react": "^1.0.0-beta.2",
1516
"@react-three/drei": "^10.6.1",
1617
"@react-three/fiber": "^9.3.0",
Lines changed: 92 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,100 @@
1-
import { generateObject } from 'ai';
1+
import { streamObject } from 'ai';
2+
import { openai } from '@ai-sdk/openai';
3+
import { xai } from "@ai-sdk/xai"
24
import { z } from 'zod';
35

4-
// Allow streaming responses up to 30 seconds
6+
// Allow streaming responses up to 30 seconds (Vercel Edge limit hint)
57
export const maxDuration = 30;
68

9+
// OpenAI structured output currently rejects tuple item arrays like [ {type:number}, ... ] at root of items.
10+
// Use object-based vectors instead of tuples to stay within supported JSON Schema subset.
11+
const vector3 = z.object({ x: z.number(), y: z.number(), z: z.number() });
12+
// Allow color either as {r,g,b} 0-255 integers or as a #RRGGBB hex string.
13+
const colorSchema = z.union([
14+
z.object({ r: z.number().int().min(0).max(255), g: z.number().int().min(0).max(255), b: z.number().int().min(0).max(255) }),
15+
z.string().regex(/^#?[0-9a-fA-F]{6}$/),
16+
vector3, // fallback if model uses x,y,z 0-1 floats
17+
]);
18+
const meshSchema = z.object({
19+
mesh: z.object({
20+
name: z.string().optional(),
21+
vertices: z.array(vector3).default([]),
22+
faces: z.array(z.array(z.number()).min(3).max(4)).default([]),
23+
material: z.object({
24+
color: colorSchema.optional(),
25+
roughness: z.number().min(0).max(1).optional(),
26+
metalness: z.number().min(0).max(1).optional(),
27+
emissive: colorSchema.optional(),
28+
emissiveIntensity: z.number().min(0).max(1).optional(),
29+
opacity: z.number().min(0).max(1).optional(),
30+
transparent: z.boolean().optional(),
31+
}).optional(),
32+
})
33+
});
34+
35+
interface PostBody {
36+
prompt?: string;
37+
model?: 'gpt-5' | 'gpt-5-mini' | 'gpt-5-nano';
38+
// optional: 'low' | 'medium' | 'high' detail preference. Defaults to 'medium'.
39+
detail?: 'low' | 'medium' | 'high';
40+
// optional: max desired vertex count. We'll clamp to a safe server-side cap.
41+
maxVertices?: number;
42+
// optional freeform style hints (e.g. "architectural", "organic", "mechanical").
43+
style?: string;
44+
}
45+
746
export async function POST(req: Request) {
8-
const { object } = await generateObject({
9-
model: 'openai/gpt-5-mini',
10-
schema: z.object({
11-
recipe: z.object({
12-
name: z.string(),
13-
ingredients: z.array(z.object({ name: z.string(), amount: z.string() })),
14-
steps: z.array(z.string()),
15-
}),
16-
}),
17-
prompt: 'Generate a lasagna recipe.',
47+
const body: PostBody = await req.json().catch(() => ({}));
48+
const userPrompt = (body.prompt || '').slice(0, 800); // guard length
49+
// const model = openai(body.model || 'gpt-5-mini');
50+
const model = xai("grok-4-0709")
51+
52+
// Instruction prompt guiding the model to emit ONLY the structured object.
53+
// Note: by default prefer moderate detail; allow higher detail when requested by client.
54+
const systemInstructions = `
55+
You are a 3D mesh generator specializing in high-fidelity, realistic models. Output ONLY a JSON object that matches the schema.
56+
57+
Rules:
58+
- Provide a concise, descriptive mesh.name (e.g., "Ancient Oak Tree with Bark Texture Details", "Ergonomic Modern Office Chair").
59+
- Aim for detail: incorporate intricate features like surface variations, asymmetries for organic shapes, or precise engineering for man-made objects.
60+
- Use realistic proportions, scales, and architectures based on real-world references.
61+
- Optimize topology for quality: favor quads for smooth surfaces to support subdivision and texturing; use triangles only where necessary; ensure manifold, watertight meshes without self-intersections or holes.
62+
- Target a higher vertex count (e.g., 100-1000+ vertices depending on complexity) for smoother curves, finer details, and better subdivision potential.
63+
- Use 0-based indices in faces.
64+
- Center the mesh at the origin (0,0,0) and normalize scale to fit within a unit bounding box unless the request specifies otherwise.
65+
- Ensure symmetry where logically appropriate (e.g., for vehicles or furniture).
66+
- Do not include extra narration, metadata, or prose—only the JSON object that matches the schema.
67+
68+
User's request: ${userPrompt}
69+
`;
70+
71+
const { partialObjectStream, } = await streamObject({
72+
model: model,
73+
schema: meshSchema,
74+
prompt: systemInstructions,
75+
temperature: 0.2
76+
});
77+
78+
// Stream each partial object state as NDJSON so the client can incrementally update.
79+
const stream = new ReadableStream({
80+
async start(controller) {
81+
const encoder = new TextEncoder();
82+
try {
83+
for await (const partial of partialObjectStream) {
84+
controller.enqueue(encoder.encode(JSON.stringify(partial) + '\n'));
85+
}
86+
} catch (err: any) {
87+
controller.enqueue(encoder.encode(JSON.stringify({ error: err?.message || 'stream_error' }) + '\n'));
88+
} finally {
89+
controller.close();
90+
}
91+
}
1892
});
1993

20-
return result.toUIMessageStreamResponse();
94+
return new Response(stream, {
95+
headers: {
96+
'Content-Type': 'application/x-ndjson; charset=utf-8',
97+
'Cache-Control': 'no-cache',
98+
}
99+
});
21100
}

src/features/layout/components/editor-layout.tsx

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { TopToolbar } from '@/features/toolbar';
77
import { EditToolsToolbar } from '@/features/toolbar';
88
import { SculptToolsToolbar } from '@/features/toolbar/components/sculpt-tools-toolbar';
99
import { useToolStore } from '@/stores/tool-store';
10+
import { useWorkspaceStore } from '@/stores/workspace-store';
1011
import { ToolIndicator } from '@/features/tools';
1112
import { EditorViewport } from '@/features/viewport';
1213
import { PropertiesPanel } from '@/features/properties-panel/components/properties-panel';
@@ -25,19 +26,20 @@ const EditorLayout: React.FC = () => {
2526
const setShaderOpen = useShaderEditorStore((s) => s.setOpen);
2627
const editPalette = useToolStore((s) => s.editPalette);
2728
const timelineOpen = useAnimationStore((s) => s.timelinePanelOpen);
29+
const minimalUi = useWorkspaceStore((s) => s.minimalUi ?? false);
2830
const activeClipId = useAnimationStore((s) => s.activeClipId);
2931
const uvOpen = useUVEditorStore((s) => s.open);
3032
const setUVOpen = useUVEditorStore((s) => s.setOpen);
3133
const createClip = useAnimationStore((s) => s.createClip);
3234
React.useEffect(() => {
3335
if (!activeClipId) {
34-
try { createClip('Clip'); } catch {}
36+
try { createClip('Clip'); } catch { }
3537
}
3638
}, [activeClipId, createClip]);
3739
return (
3840
<div className="w-screen h-screen overflow-hidden bg-[#0e1116] text-gray-200">
3941
{/* Top OS-like Menu Bar */}
40-
<MenuBar onOpenShaderEditor={() => setShaderOpen(true)} />
42+
<MenuBar onOpenShaderEditor={() => setShaderOpen(true)} />
4143

4244
{/* Main content area uses flex so bottom bar reduces viewport height */}
4345
<div className="flex flex-col w-full h-[calc(100vh-32px)]">{/* 32px menu height */}
@@ -61,19 +63,23 @@ const EditorLayout: React.FC = () => {
6163
</div>
6264

6365
{/* Left Scene Hierarchy Panel - shrink when timeline open */}
64-
<div className="absolute left-4 z-20" style={{ top: timelineOpen ? 80 : 128 }}>
65-
<div style={{ height: timelineOpen ? '44dvh' : '60dvh' }}>
66-
<SceneHierarchyPanel />
66+
{!minimalUi && (
67+
<div className="absolute left-4 z-20" style={{ top: timelineOpen ? 80 : 128 }}>
68+
<div style={{ height: timelineOpen ? '44dvh' : '60dvh' }}>
69+
<SceneHierarchyPanel />
70+
</div>
6771
</div>
68-
</div>
72+
)}
6973

7074
{/* Right Properties Panel - shrink when timeline open */}
71-
<div className="absolute right-4 z-20" style={{ top: timelineOpen ? 80 : 128 }}>
72-
<div style={{ height: timelineOpen ? '44dvh' : '60dvh' }}>
73-
<PropertiesPanel />
75+
{!minimalUi && (
76+
<div className="absolute right-4 z-20" style={{ top: timelineOpen ? 80 : 128 }}>
77+
<div style={{ height: timelineOpen ? '44dvh' : '60dvh' }}>
78+
<PropertiesPanel />
79+
</div>
7480
</div>
75-
</div>
76-
81+
)}
82+
7783
{/* Tool Indicator - shows when tools are active */}
7884
<ToolIndicator />
7985

src/features/menu/components/menu-bar.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { useShapeCreationStore } from '@/stores/shape-creation-store';
1111
import { WorkspaceData, exportToT3D } from '@/utils/t3d-exporter';
1212
import { openImportDialog } from '@/utils/t3d-importer';
1313
import { openGLTFImportDialog, type ImportSummary } from '@/utils/gltf-importer';
14-
import { Box, Download, FolderOpen, Save, Heart, Check, Orbit } from 'lucide-react';
14+
import { Box, Download, FolderOpen, Save, Heart, Check, Minimize2 } from 'lucide-react';
1515
import DonateDialog from '@/components/donate-dialog';
1616
import { useUVEditorStore } from '@/stores/uv-editor-store';
1717
import ExportDialog from '@/features/export/components/export-dialog';
@@ -449,13 +449,13 @@ const MenuBar: React.FC<Props> = ({ onOpenShaderEditor }) => {
449449
</div>
450450

451451
<div className="ml-auto flex items-center gap-2 text-[11px] text-gray-400">
452-
{/* Auto Orbit toggle (cycles 0 -> 1 -> 3 -> 5) */}
452+
{/* Minimal UI toggle (no label, icon only) */}
453453
<button
454454
className="inline-flex items-center rounded p-1 text-gray-400 hover:text-gray-200 hover:bg-white/5"
455-
onClick={() => useViewportStore.getState().toggleAutoOrbitInterval()}
456-
title="Auto orbit"
455+
onClick={() => useWorkspaceStore.getState().toggleMinimalUi?.()}
456+
title="Toggle minimal UI"
457457
>
458-
<Orbit className="w-4 h-4" />
458+
<Minimize2 className="w-4 h-4" />
459459
</button>
460460
{/* SPONSORING AREA */}
461461
<button

0 commit comments

Comments
 (0)