Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ const UnifiedStudio = lazyWithRetry(() => import("./pages/UnifiedStudio"));
const CADStudio = lazyWithRetry(() => import("./pages/CADStudio"));
const CADToCatalog = lazyWithRetry(() => import("./pages/CADToCatalog"));
const TextToCAD = lazyWithRetry(() => import("./pages/TextToCAD"));
const SketchToCAD = lazyWithRetry(() => import("./pages/SketchToCAD"));
const Generations = lazyWithRetry(() => import("./pages/Generations"));
const Credits = lazyWithRetry(() => import("./pages/Credits"));
const Pricing = lazyWithRetry(() => import("./pages/Pricing"));
Expand Down Expand Up @@ -230,6 +231,7 @@ const App = () => (
<Route path="/studio-cad" element={<ProtectedRoute><CADGate><CADStudio /></CADGate></ProtectedRoute>} />
<Route path="/cad-to-catalog" element={<ProtectedRoute><CADGate><CADToCatalog /></CADGate></ProtectedRoute>} />
<Route path="/text-to-cad" element={<ProtectedRoute><CADGate><TextToCAD /></CADGate></ProtectedRoute>} />
<Route path="/sketch-to-cad" element={<ProtectedRoute><CADGate><SketchToCAD /></CADGate></ProtectedRoute>} />

{/* Admin routes */}
<Route path="/admin" element={<AdminRouteGuard><AdminLayout /></AdminRouteGuard>}>
Expand Down
Binary file added src/assets/examples/sketch-allowed-1.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/examples/sketch-allowed-2.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/examples/sketch-notallowed-1.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/examples/sketch-notallowed-2.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
168 changes: 168 additions & 0 deletions src/components/sketch-to-cad/SketchLeftPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { useRef, useCallback } from "react";
import { ImagePlus } from "lucide-react";
import creditCoinIcon from "@/assets/icons/credit-coin.png";
import { useEstimatedCost } from "@/hooks/use-estimated-cost";
import { SKETCH_TO_CAD_WORKFLOW } from "@/lib/sketch-to-cad-workflows";

interface SketchLeftPanelProps {
previewUrl: string | null;
description: string;
onDescriptionChange: (d: string) => void;
isGenerating: boolean;
hasModel: boolean;
onRegenerate: () => void;
onNewSketch: (file: File) => void;
onGlbUpload: (file: File) => void;
onReset?: () => void;
creditBlock?: React.ReactNode;
}

export default function SketchLeftPanel({
previewUrl,
description,
onDescriptionChange,
isGenerating,
hasModel,
onRegenerate,
onNewSketch,
onGlbUpload,
onReset,
creditBlock,
}: SketchLeftPanelProps) {
const sketchInputRef = useRef<HTMLInputElement>(null);
const glbInputRef = useRef<HTMLInputElement>(null);
const { cost: estimatedCost, loading: costLoading } = useEstimatedCost({ workflowName: SKETCH_TO_CAD_WORKFLOW });

const handleSketchInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file && file.type.startsWith('image/')) onNewSketch(file);
e.target.value = '';
}, [onNewSketch]);

const handleGlbInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) onGlbUpload(file);
e.target.value = '';
}, [onGlbUpload]);

return (
<div className="flex flex-col bg-card border-r border-border h-full min-w-0 overflow-hidden">
{/* Header */}
<div className="px-4 lg:px-6 pt-6 pb-5 border-b border-border min-w-0">
<h1 className="font-display text-xl lg:text-2xl tracking-[0.15em] text-foreground uppercase truncate">
Sketch to 3D
</h1>
</div>

{/* Body */}
<div
className="flex-1 overflow-y-auto px-4 lg:px-6 py-6 space-y-6 scrollbar-thin min-w-0"
style={{ scrollbarWidth: "thin" }}
>
{/* Sketch thumbnail */}
<section>
<h3 className="font-mono text-[10px] uppercase tracking-[0.2em] text-muted-foreground mb-3">
Sketch
</h3>
<div className="relative border border-border bg-muted/10 overflow-hidden" style={{ minHeight: 120 }}>
{previewUrl ? (
<img
src={previewUrl}
alt="Your sketch"
className="w-full object-contain max-h-[200px]"
/>
) : (
<div className="flex items-center justify-center h-24">
<ImagePlus className="w-6 h-6 text-muted-foreground/30" />
</div>
)}
</div>
<input
ref={sketchInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleSketchInput}
/>
<button
onClick={() => sketchInputRef.current?.click()}
className="mt-2 w-full font-mono text-[10px] uppercase tracking-[0.15em] text-muted-foreground hover:text-foreground border border-border hover:border-foreground/30 py-2 transition-colors"
>
Upload New Sketch
</button>
</section>

{/* Description */}
<section>
<h3 className="font-mono text-[10px] uppercase tracking-[0.2em] text-muted-foreground mb-3">
Description
</h3>
<textarea
value={description}
onChange={(e) => onDescriptionChange(e.target.value)}
placeholder="Optional hints (e.g. rose gold, add diamond accents)"
rows={3}
disabled={isGenerating}
className="w-full px-3 py-2.5 text-[12px] text-foreground placeholder:text-muted-foreground/40 resize-none font-body leading-relaxed focus:outline-none focus:ring-1 focus:ring-ring bg-muted/20 border border-border disabled:opacity-50"
/>
</section>

{/* Credit block */}
{creditBlock && <div>{creditBlock}</div>}

{/* Regenerate */}
{!creditBlock && (
<section>
<button
onClick={onRegenerate}
disabled={isGenerating || !previewUrl}
className="w-full py-3 text-[11px] font-bold uppercase tracking-[0.2em] transition-all duration-200 bg-primary text-primary-foreground disabled:opacity-30 disabled:cursor-not-allowed hover:opacity-90 active:scale-[0.99] flex items-center justify-center gap-2"
>
{isGenerating ? "Generating\u2026" : (
<>
Regenerate
<span className="inline-flex items-center gap-1 opacity-80">
<span className="font-mono font-semibold">&le;</span>
<img src={creditCoinIcon} alt="" className="w-4 h-4" />
<span className="font-mono font-semibold">
{costLoading ? "\u2026" : (estimatedCost !== null ? estimatedCost : "\u2014")}
</span>
</span>
</>
)}
</button>
</section>
)}

{/* Load GLB part */}
<section>
<input
ref={glbInputRef}
type="file"
accept=".glb,.gltf"
className="hidden"
onChange={handleGlbInput}
/>
<button
onClick={() => glbInputRef.current?.click()}
className="w-full font-mono text-[10px] uppercase tracking-[0.15em] text-muted-foreground hover:text-foreground border border-border hover:border-foreground/30 py-2 transition-colors"
>
Load GLB / Merge Part
</button>
</section>

{/* Reset */}
{onReset && hasModel && (
<section>
<button
onClick={onReset}
className="w-full font-mono text-[10px] uppercase tracking-[0.15em] text-muted-foreground/50 hover:text-foreground py-2 transition-colors"
>
Reset Model
</button>
</section>
)}
</div>
</div>
);
}
161 changes: 161 additions & 0 deletions src/components/sketch-to-cad/SketchUploadScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { useRef, useCallback } from "react";
import { motion } from "framer-motion";
import { Diamond, ImagePlus } from "lucide-react";
import creditCoinIcon from "@/assets/icons/credit-coin.png";
import { useEstimatedCost } from "@/hooks/use-estimated-cost";
import { SKETCH_TO_CAD_WORKFLOW } from "@/lib/sketch-to-cad-workflows";

interface SketchUploadScreenProps {
sketchFile: File | null;
previewUrl: string | null;
isGenerating: boolean;
description: string;
onDescriptionChange: (d: string) => void;
onSketchSelect: (file: File) => void;
onGenerate: () => void;
creditBlock?: React.ReactNode;
}

export default function SketchUploadScreen({
sketchFile,
previewUrl,
isGenerating,
description,
onDescriptionChange,
onSketchSelect,
onGenerate,
creditBlock,
}: SketchUploadScreenProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const { cost: estimatedCost, loading: costLoading } = useEstimatedCost({ workflowName: SKETCH_TO_CAD_WORKFLOW });

const handleFile = useCallback((file: File) => {
if (!file.type.startsWith('image/')) return;
onSketchSelect(file);
}, [onSketchSelect]);

const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) handleFile(file);
e.target.value = '';
}, [handleFile]);

const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
const file = e.dataTransfer.files?.[0];
if (file) handleFile(file);
}, [handleFile]);

const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
}, []);

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && sketchFile && !isGenerating) onGenerate();
};

return (
<div className="flex-1 flex items-center justify-center bg-background" onKeyDown={handleKeyDown} tabIndex={-1}>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: "easeOut" }}
className="w-full max-w-[680px] px-6"
>
{/* Title */}
<div className="text-center mb-6">
<h1 className="font-display text-4xl md:text-5xl tracking-[0.2em] text-foreground uppercase mb-2">
Sketch to 3D
</h1>
<p className="font-mono text-[11px] text-muted-foreground tracking-[0.15em] uppercase">
Upload a jewelry sketch to generate a 3D model
</p>
</div>

{/* Drop zone / Preview */}
<div
className={`relative mb-3 border-2 border-dashed transition-colors duration-200 ${
sketchFile
? "border-primary/40 bg-muted/10"
: "border-border hover:border-foreground/30 bg-muted/10 cursor-pointer"
}`}
style={{ minHeight: 220 }}
onClick={() => !sketchFile && fileInputRef.current?.click()}
onDrop={handleDrop}
onDragOver={handleDragOver}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleInputChange}
/>

{previewUrl ? (
<div className="relative flex items-center justify-center p-4">
<img
src={previewUrl}
alt="Sketch preview"
className="max-h-[320px] max-w-full object-contain"
/>
<button
onClick={(e) => { e.stopPropagation(); fileInputRef.current?.click(); }}
className="absolute bottom-3 right-3 font-mono text-[10px] uppercase tracking-[0.15em] text-muted-foreground hover:text-foreground bg-card/90 border border-border px-3 py-1.5 transition-colors"
>
Change
</button>
</div>
) : (
<div className="flex flex-col items-center justify-center gap-3 py-14">
<div className="w-16 h-16 border border-border flex items-center justify-center">
<ImagePlus className="w-7 h-7 text-muted-foreground/50" />
</div>
<p className="font-mono text-[11px] uppercase tracking-[0.15em] text-muted-foreground/60 text-center">
Drop your sketch here, or click to browse
</p>
<p className="font-mono text-[10px] text-muted-foreground/40 text-center">
PNG, JPG, WEBP
</p>
</div>
)}
</div>

{/* Optional description */}
<textarea
value={description}
onChange={(e) => onDescriptionChange(e.target.value)}
placeholder="Optional: describe the sketch or add specific requirements (e.g. add diamond accents, rose gold finish)"
rows={2}
className="w-full mb-3 px-4 py-3 text-[13px] text-foreground placeholder:text-muted-foreground/40 resize-none font-body leading-relaxed transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-ring bg-muted/20 border border-border"
/>

{/* Credit block */}
{creditBlock && <div className="mb-3">{creditBlock}</div>}

{/* Generate button */}
{!creditBlock && (
<button
onClick={onGenerate}
disabled={isGenerating || !sketchFile}
className="w-full py-4 text-[13px] font-bold uppercase tracking-[0.2em] transition-all duration-200 bg-primary text-primary-foreground disabled:opacity-30 disabled:cursor-not-allowed hover:opacity-90 active:scale-[0.99] flex items-center justify-center gap-2"
>
{isGenerating ? "Generating\u2026" : (
<>
<Diamond className="w-4 h-4" />
Generate 3D Model
<span className="inline-flex items-center gap-1 ml-1 opacity-80">
<span className="text-[13px] font-mono font-semibold">&le;</span>
<img src={creditCoinIcon} alt="" className="w-5 h-5" />
<span className="text-[13px] font-mono font-semibold">
{costLoading ? "\u2026" : (estimatedCost !== null ? estimatedCost : "\u2014")}
</span>
</span>
</>
)}
</button>
)}
</motion.div>
</div>
);
}
2 changes: 1 addition & 1 deletion src/components/text-to-cad/CADCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,7 @@ const LoadedModel = forwardRef<
} else {
// ── Flat mesh classification (default when magic texturing is off) ──
// gem → blue (#4a90d9), metal → green (#77dd77), flat shading, no maps
const gemRe = /diamond|gem|stone|crystal|jewel|brill|ruby|emerald|sapphire|topaz|opal|garnet|amethyst|pearl|cz|cubic|solitaire|pave|prong_stone|accent_stone|center_stone|main_stone/i;
const gemRe = /^stone_|diamond|gem|stone|crystal|jewel|brill|ruby|emerald|sapphire|topaz|opal|garnet|amethyst|pearl|cz|cubic|solitaire|pave|prong_stone|accent_stone|center_stone|main_stone/i;
const metalRe = /band|ring|shank|prong|setting|mount|bezel|basket|gallery|shoulder|bridge|head|collet|metal|gold|silver|platinum|frame|base/i;

list.forEach((md) => {
Expand Down
2 changes: 2 additions & 0 deletions src/components/text-to-cad/GenerationProgress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const NODE_LABELS: Record<string, string> = {
success_original_glb: "Your 3D design is ready",
failed_final: "Could not complete generation",
_loading: "Loading model into viewport",
generate_from_sketch: "Generating from sketch",
validate_against_sketch: "Validating against sketch",
};

const TERMINAL_NODES = new Set(["success_final", "success_original_glb", "failed_final"]);
Expand Down
Loading