import { BaseBoxShapeUtil, Geometry2d, HTMLContainer, Rectangle2d, TLBaseShape, } from "tldraw" import React, { useState } from "react" import { getWorkerApiUrl } from "@/lib/clientConfig" import { StandardizedToolWrapper } from "@/components/StandardizedToolWrapper" import { usePinnedToView } from "@/hooks/usePinnedToView" import { useMaximize } from "@/hooks/useMaximize" // Blender render presets type BlenderPreset = 'abstract' | 'geometric' | 'landscape' | 'text3d' | 'particles' // Individual render entry in the history interface GeneratedRender { id: string prompt: string preset: BlenderPreset imageUrl: string timestamp: number renderTime?: number seed?: number } type IBlenderGen = TLBaseShape< "BlenderGen", { w: number h: number prompt: string preset: BlenderPreset complexity: number // 1-10 text3dContent: string // For text3d preset seed: number | null // For reproducibility renderHistory: GeneratedRender[] isLoading: boolean loadingPrompt: string | null progress: number // 0-100 error: string | null tags: string[] pinnedToView: boolean } > export class BlenderGenShape extends BaseBoxShapeUtil { static override type = "BlenderGen" as const // Blender theme color: Orange/3D static readonly PRIMARY_COLOR = "#E87D0D" MIN_WIDTH = 320 as const MIN_HEIGHT = 400 as const DEFAULT_WIDTH = 420 as const DEFAULT_HEIGHT = 500 as const getDefaultProps(): IBlenderGen["props"] { return { w: this.DEFAULT_WIDTH, h: this.DEFAULT_HEIGHT, prompt: "", preset: "abstract", complexity: 5, text3dContent: "", seed: null, renderHistory: [], isLoading: false, loadingPrompt: null, progress: 0, error: null, tags: ['3d', 'blender', 'render'], pinnedToView: false, } } getGeometry(shape: IBlenderGen): Geometry2d { return new Rectangle2d({ width: Math.max(shape.props.w, 1), height: Math.max(shape.props.h, 1), isFilled: true, }) } component(shape: IBlenderGen) { const editor = this.editor const isSelected = editor.getSelectedShapeIds().includes(shape.id) usePinnedToView(editor, shape.id, shape.props.pinnedToView) const { isMaximized, toggleMaximize } = useMaximize({ editor: editor, shapeId: shape.id, currentW: shape.props.w, currentH: shape.props.h, shapeType: 'BlenderGen', }) const handlePinToggle = () => { editor.updateShape({ id: shape.id, type: "BlenderGen", props: { pinnedToView: !shape.props.pinnedToView }, }) } const presets: { id: BlenderPreset; label: string; icon: string; description: string }[] = [ { id: 'abstract', label: 'Abstract', icon: '🔮', description: 'Random geometric shapes' }, { id: 'geometric', label: 'Geometric', icon: '📐', description: 'Grid-based patterns' }, { id: 'landscape', label: 'Landscape', icon: '🏔️', description: 'Procedural terrain' }, { id: 'text3d', label: '3D Text', icon: 'Aa', description: 'Metallic 3D text' }, { id: 'particles', label: 'Particles', icon: '✨', description: 'Particle effects' }, ] const generateRender = async () => { const prompt = shape.props.preset === 'text3d' ? shape.props.text3dContent || 'BLENDER' : shape.props.prompt || shape.props.preset editor.updateShape({ id: shape.id, type: "BlenderGen", props: { error: null, isLoading: true, loadingPrompt: prompt, progress: 0, }, }) try { const workerUrl = getWorkerApiUrl() const url = `${workerUrl}/api/blender/render` const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ preset: shape.props.preset, text: shape.props.preset === 'text3d' ? (shape.props.text3dContent || 'BLENDER') : undefined, complexity: shape.props.complexity, seed: shape.props.seed, resolution: "1920x1080", samples: 64, }) }) if (!response.ok) { const errorText = await response.text() throw new Error(`HTTP error! status: ${response.status} - ${errorText}`) } const data = await response.json() if (data.imageUrl) { const currentShape = editor.getShape(shape.id) const currentHistory = currentShape?.props.renderHistory || [] const newRender: GeneratedRender = { id: `render-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, prompt: prompt, preset: shape.props.preset, imageUrl: data.imageUrl, timestamp: Date.now(), renderTime: data.renderTime, seed: data.seed, } editor.updateShape({ id: shape.id, type: "BlenderGen", props: { renderHistory: [newRender, ...currentHistory], isLoading: false, loadingPrompt: null, progress: 100, error: null, }, }) } else if (data.error) { throw new Error(data.error) } else { throw new Error("No image returned from Blender API") } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) console.error("BlenderGen: Error:", errorMessage) editor.updateShape({ id: shape.id, type: "BlenderGen", props: { isLoading: false, loadingPrompt: null, progress: 0, error: `Render failed: ${errorMessage}`, }, }) } } const handleGenerate = () => { if (!shape.props.isLoading) { generateRender() } } const [isMinimized, setIsMinimized] = useState(false) const handleClose = () => { editor.deleteShape(shape.id) } const handleMinimize = () => { setIsMinimized(!isMinimized) } const handleTagsChange = (newTags: string[]) => { editor.updateShape({ id: shape.id, type: "BlenderGen", props: { tags: newTags }, }) } return ( 🎬 Blender 3D Rendering... ) : undefined } >
{/* Preset Selection */}
{presets.map((preset) => ( ))}
{/* Text3D Input (shown only for text3d preset) */} {shape.props.preset === 'text3d' && (
{ editor.updateShape({ id: shape.id, type: "BlenderGen", props: { text3dContent: e.target.value }, }) }} onPointerDown={(e) => e.stopPropagation()} onKeyDown={(e) => { e.stopPropagation() if (e.key === 'Enter' && !shape.props.isLoading) { handleGenerate() } }} />
)} {/* Complexity Slider */}
{shape.props.complexity}/10
{ editor.updateShape({ id: shape.id, type: "BlenderGen", props: { complexity: parseInt(e.target.value) }, }) }} onPointerDown={(e) => e.stopPropagation()} style={{ width: '100%', accentColor: BlenderGenShape.PRIMARY_COLOR, }} />
{/* Render Button */} {/* Render History */}
{/* Loading State */} {shape.props.isLoading && (
Rendering 3D scene... This may take 30-60 seconds
{shape.props.loadingPrompt && (
Preset: {shape.props.preset} {shape.props.preset === 'text3d' && ` - "${shape.props.loadingPrompt}"`}
)}
)} {/* Render History */} {shape.props.renderHistory.map((render, index) => (
{render.prompt} { const newHistory = shape.props.renderHistory.filter(r => r.id !== render.id) editor.updateShape({ id: shape.id, type: "BlenderGen", props: { renderHistory: newHistory }, }) }} />
Preset: {render.preset} {render.seed && Seed: {render.seed}} {render.renderTime && Time: {render.renderTime}s}
))} {/* Empty State */} {shape.props.renderHistory.length === 0 && !shape.props.isLoading && !shape.props.error && (
🎬 Select a preset and click Render
)}
{/* Error Display */} {shape.props.error && (
⚠️ {shape.props.error}
)}
) } override indicator(shape: IBlenderGen) { return ( ) } }