├── src ├── app │ ├── favicon.ico │ ├── layout.tsx │ ├── page.tsx │ ├── globals.css │ └── components │ │ ├── bottom-bar.tsx │ │ └── painting-board.tsx ├── lib │ └── utils.ts └── components │ └── ui │ ├── popover.tsx │ ├── tooltip.tsx │ ├── slider.tsx │ └── button.tsx ├── postcss.config.mjs ├── public ├── vercel.svg ├── window.svg ├── file.svg ├── globe.svg └── next.svg ├── next.config.ts ├── components.json ├── eslint.config.mjs ├── .gitignore ├── tsconfig.json ├── package.json └── README.md /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aykutkardas/miniature-painting/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | { 15 | rules: { 16 | "@typescript-eslint/no-explicit-any": "off", 17 | }, 18 | }, 19 | ]; 20 | 21 | export default eslintConfig; 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | const geistSans = Geist({ 6 | variable: "--font-geist-sans", 7 | subsets: ["latin"], 8 | }); 9 | 10 | const geistMono = Geist_Mono({ 11 | variable: "--font-geist-mono", 12 | subsets: ["latin"], 13 | }); 14 | 15 | export const metadata: Metadata = { 16 | title: "Miniature Painting", 17 | description: "Miniature Painting", 18 | }; 19 | 20 | export default function RootLayout({ 21 | children, 22 | }: Readonly<{ 23 | children: React.ReactNode; 24 | }>) { 25 | return ( 26 | 27 | 30 | {children} 31 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import PaintingBoard from "./components/painting-board"; 2 | import { Github } from "lucide-react"; 3 | 4 | export default function Home() { 5 | return ( 6 |
16 | 17 |
18 | 24 | 25 | 26 | 32 | @aykutkardas 33 | 34 |
35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "miniature-painting", 3 | "version": "0.1.0", 4 | "author": "Aykut Kardas ", 5 | "private": true, 6 | "scripts": { 7 | "dev": "next dev --turbopack", 8 | "build": "next build", 9 | "start": "next start", 10 | "lint": "next lint" 11 | }, 12 | "dependencies": { 13 | "@radix-ui/react-popover": "^1.1.13", 14 | "@radix-ui/react-slider": "^1.3.4", 15 | "@radix-ui/react-slot": "^1.2.2", 16 | "@radix-ui/react-tooltip": "^1.2.6", 17 | "@react-three/drei": "^10.0.7", 18 | "@react-three/fiber": "^9.1.2", 19 | "@types/three": "^0.176.0", 20 | "class-variance-authority": "^0.7.1", 21 | "clsx": "^2.1.1", 22 | "color2k": "^2.0.3", 23 | "lucide-react": "^0.509.0", 24 | "next": "15.3.2", 25 | "react": "^19.0.0", 26 | "react-color": "^2.19.3", 27 | "react-dom": "^19.0.0", 28 | "tailwind-merge": "^3.2.0", 29 | "three": "^0.176.0" 30 | }, 31 | "devDependencies": { 32 | "@eslint/eslintrc": "^3", 33 | "@tailwindcss/postcss": "^4", 34 | "@types/node": "^20", 35 | "@types/react": "^19", 36 | "@types/react-color": "^3.0.13", 37 | "@types/react-dom": "^19", 38 | "eslint": "^9", 39 | "eslint-config-next": "15.3.2", 40 | "tailwindcss": "^4", 41 | "tw-animate-css": "^1.2.9", 42 | "typescript": "^5" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 37 | -------------------------------------------------------------------------------- /src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Popover({ 9 | ...props 10 | }: React.ComponentProps) { 11 | return 12 | } 13 | 14 | function PopoverTrigger({ 15 | ...props 16 | }: React.ComponentProps) { 17 | return 18 | } 19 | 20 | function PopoverContent({ 21 | className, 22 | align = "center", 23 | sideOffset = 4, 24 | ...props 25 | }: React.ComponentProps) { 26 | return ( 27 | 28 | 38 | 39 | ) 40 | } 41 | 42 | function PopoverAnchor({ 43 | ...props 44 | }: React.ComponentProps) { 45 | return 46 | } 47 | 48 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } 49 | -------------------------------------------------------------------------------- /src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function TooltipProvider({ 9 | delayDuration = 0, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 18 | ) 19 | } 20 | 21 | function Tooltip({ 22 | ...props 23 | }: React.ComponentProps) { 24 | return ( 25 | 26 | 27 | 28 | ) 29 | } 30 | 31 | function TooltipTrigger({ 32 | ...props 33 | }: React.ComponentProps) { 34 | return 35 | } 36 | 37 | function TooltipContent({ 38 | className, 39 | sideOffset = 0, 40 | children, 41 | ...props 42 | }: React.ComponentProps) { 43 | return ( 44 | 45 | 54 | {children} 55 | 56 | 57 | 58 | ) 59 | } 60 | 61 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 62 | -------------------------------------------------------------------------------- /src/components/ui/slider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SliderPrimitive from "@radix-ui/react-slider" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Slider({ 9 | className, 10 | defaultValue, 11 | value, 12 | min = 0, 13 | max = 100, 14 | ...props 15 | }: React.ComponentProps) { 16 | const _values = React.useMemo( 17 | () => 18 | Array.isArray(value) 19 | ? value 20 | : Array.isArray(defaultValue) 21 | ? defaultValue 22 | : [min, max], 23 | [value, defaultValue, min, max] 24 | ) 25 | 26 | return ( 27 | 39 | 45 | 51 | 52 | {Array.from({ length: _values.length }, (_, index) => ( 53 | 58 | ))} 59 | 60 | ) 61 | } 62 | 63 | export { Slider } 64 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 16 | outline: 17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: 21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 22 | link: "text-primary underline-offset-4 hover:underline", 23 | }, 24 | size: { 25 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 28 | icon: "size-9", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ) 37 | 38 | function Button({ 39 | className, 40 | variant, 41 | size, 42 | asChild = false, 43 | ...props 44 | }: React.ComponentProps<"button"> & 45 | VariantProps & { 46 | asChild?: boolean 47 | }) { 48 | const Comp = asChild ? Slot : "button" 49 | 50 | return ( 51 | 56 | ) 57 | } 58 | 59 | export { Button, buttonVariants } 60 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "tw-animate-css"; 3 | 4 | @custom-variant dark (&:is(.dark *)); 5 | 6 | @theme inline { 7 | --color-background: var(--background); 8 | --color-foreground: var(--foreground); 9 | --font-sans: var(--font-geist-sans); 10 | --font-mono: var(--font-geist-mono); 11 | --color-sidebar-ring: var(--sidebar-ring); 12 | --color-sidebar-border: var(--sidebar-border); 13 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 14 | --color-sidebar-accent: var(--sidebar-accent); 15 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 16 | --color-sidebar-primary: var(--sidebar-primary); 17 | --color-sidebar-foreground: var(--sidebar-foreground); 18 | --color-sidebar: var(--sidebar); 19 | --color-chart-5: var(--chart-5); 20 | --color-chart-4: var(--chart-4); 21 | --color-chart-3: var(--chart-3); 22 | --color-chart-2: var(--chart-2); 23 | --color-chart-1: var(--chart-1); 24 | --color-ring: var(--ring); 25 | --color-input: var(--input); 26 | --color-border: var(--border); 27 | --color-destructive: var(--destructive); 28 | --color-accent-foreground: var(--accent-foreground); 29 | --color-accent: var(--accent); 30 | --color-muted-foreground: var(--muted-foreground); 31 | --color-muted: var(--muted); 32 | --color-secondary-foreground: var(--secondary-foreground); 33 | --color-secondary: var(--secondary); 34 | --color-primary-foreground: var(--primary-foreground); 35 | --color-primary: var(--primary); 36 | --color-popover-foreground: var(--popover-foreground); 37 | --color-popover: var(--popover); 38 | --color-card-foreground: var(--card-foreground); 39 | --color-card: var(--card); 40 | --radius-sm: calc(var(--radius) - 4px); 41 | --radius-md: calc(var(--radius) - 2px); 42 | --radius-lg: var(--radius); 43 | --radius-xl: calc(var(--radius) + 4px); 44 | } 45 | 46 | :root { 47 | --radius: 0.625rem; 48 | --background: oklch(1 0 0); 49 | --foreground: oklch(0.145 0 0); 50 | --card: oklch(1 0 0); 51 | --card-foreground: oklch(0.145 0 0); 52 | --popover: oklch(1 0 0); 53 | --popover-foreground: oklch(0.145 0 0); 54 | --primary: oklch(0.205 0 0); 55 | --primary-foreground: oklch(0.985 0 0); 56 | --secondary: oklch(0.97 0 0); 57 | --secondary-foreground: oklch(0.205 0 0); 58 | --muted: oklch(0.97 0 0); 59 | --muted-foreground: oklch(0.556 0 0); 60 | --accent: oklch(0.97 0 0); 61 | --accent-foreground: oklch(0.205 0 0); 62 | --destructive: oklch(0.577 0.245 27.325); 63 | --border: oklch(0.922 0 0); 64 | --input: oklch(0.922 0 0); 65 | --ring: oklch(0.708 0 0); 66 | --chart-1: oklch(0.646 0.222 41.116); 67 | --chart-2: oklch(0.6 0.118 184.704); 68 | --chart-3: oklch(0.398 0.07 227.392); 69 | --chart-4: oklch(0.828 0.189 84.429); 70 | --chart-5: oklch(0.769 0.188 70.08); 71 | --sidebar: oklch(0.985 0 0); 72 | --sidebar-foreground: oklch(0.145 0 0); 73 | --sidebar-primary: oklch(0.205 0 0); 74 | --sidebar-primary-foreground: oklch(0.985 0 0); 75 | --sidebar-accent: oklch(0.97 0 0); 76 | --sidebar-accent-foreground: oklch(0.205 0 0); 77 | --sidebar-border: oklch(0.922 0 0); 78 | --sidebar-ring: oklch(0.708 0 0); 79 | } 80 | 81 | .dark { 82 | --background: oklch(0.145 0 0); 83 | --foreground: oklch(0.985 0 0); 84 | --card: oklch(0.205 0 0); 85 | --card-foreground: oklch(0.985 0 0); 86 | --popover: oklch(0.205 0 0); 87 | --popover-foreground: oklch(0.985 0 0); 88 | --primary: oklch(0.922 0 0); 89 | --primary-foreground: oklch(0.205 0 0); 90 | --secondary: oklch(0.269 0 0); 91 | --secondary-foreground: oklch(0.985 0 0); 92 | --muted: oklch(0.269 0 0); 93 | --muted-foreground: oklch(0.708 0 0); 94 | --accent: oklch(0.269 0 0); 95 | --accent-foreground: oklch(0.985 0 0); 96 | --destructive: oklch(0.704 0.191 22.216); 97 | --border: oklch(1 0 0 / 10%); 98 | --input: oklch(1 0 0 / 15%); 99 | --ring: oklch(0.556 0 0); 100 | --chart-1: oklch(0.488 0.243 264.376); 101 | --chart-2: oklch(0.696 0.17 162.48); 102 | --chart-3: oklch(0.769 0.188 70.08); 103 | --chart-4: oklch(0.627 0.265 303.9); 104 | --chart-5: oklch(0.645 0.246 16.439); 105 | --sidebar: oklch(0.205 0 0); 106 | --sidebar-foreground: oklch(0.985 0 0); 107 | --sidebar-primary: oklch(0.488 0.243 264.376); 108 | --sidebar-primary-foreground: oklch(0.985 0 0); 109 | --sidebar-accent: oklch(0.269 0 0); 110 | --sidebar-accent-foreground: oklch(0.985 0 0); 111 | --sidebar-border: oklch(1 0 0 / 10%); 112 | --sidebar-ring: oklch(0.556 0 0); 113 | } 114 | 115 | @layer base { 116 | * { 117 | @apply border-border outline-ring/50; 118 | } 119 | body { 120 | @apply bg-background text-foreground; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/app/components/bottom-bar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type React from "react"; 4 | 5 | import { useRef, useState, useEffect } from "react"; 6 | import { 7 | Hand, 8 | Minus, 9 | Plus, 10 | Undo, 11 | Redo, 12 | Brush, 13 | Trash, 14 | RotateCcw, 15 | Camera, 16 | } from "lucide-react"; 17 | import { cn } from "@/lib/utils"; 18 | import { SketchPicker } from "react-color"; 19 | 20 | import { Button } from "@/components/ui/button"; 21 | import { 22 | Popover, 23 | PopoverContent, 24 | PopoverTrigger, 25 | } from "@/components/ui/popover"; 26 | import { Slider } from "@/components/ui/slider"; 27 | import { 28 | Tooltip, 29 | TooltipContent, 30 | TooltipProvider, 31 | TooltipTrigger, 32 | } from "@/components/ui/tooltip"; 33 | import { STORAGE_KEY } from "./painting-board"; 34 | 35 | export const COLOR_STORAGE_KEY = "paint-color-history"; 36 | 37 | interface ColorStorage { 38 | selectedColor: string; 39 | colorHistory: string[]; 40 | } 41 | 42 | interface BottomBarProps { 43 | selectedColor: string; 44 | setSelectedColor: (color: string) => void; 45 | brushSize: number; 46 | setBrushSize: (size: number) => void; 47 | onUndo: () => void; 48 | onRedo: () => void; 49 | isSpacePressed: boolean; 50 | setIsSpacePressed: (isSpacePressed: boolean) => void; 51 | onResetCamera: () => void; 52 | onExportImage: () => void; 53 | onImportModel: () => void; 54 | } 55 | 56 | export default function BottomBar({ 57 | selectedColor, 58 | setSelectedColor, 59 | brushSize, 60 | setBrushSize, 61 | onUndo, 62 | onRedo, 63 | isSpacePressed, 64 | setIsSpacePressed, 65 | onResetCamera, 66 | onExportImage, 67 | }: BottomBarProps) { 68 | const [position, setPosition] = useState({ x: 100, y: 100 }); 69 | const [isDragging, setIsDragging] = useState(false); 70 | const [colorHistory, setColorHistory] = useState([]); 71 | const [tempColor, setTempColor] = useState(selectedColor); 72 | 73 | // Load colors from localStorage on component mount 74 | useEffect(() => { 75 | const savedColors = localStorage.getItem(COLOR_STORAGE_KEY); 76 | if (savedColors) { 77 | try { 78 | const { selectedColor: savedColor, colorHistory: savedHistory } = 79 | JSON.parse(savedColors) as ColorStorage; 80 | setSelectedColor(savedColor); 81 | setColorHistory(savedHistory); 82 | setTempColor(savedColor); 83 | } catch (error) { 84 | console.error("Error loading saved colors:", error); 85 | } 86 | } 87 | }, []); 88 | 89 | // Update color history when selectedColor changes 90 | useEffect(() => { 91 | setColorHistory((prev) => { 92 | // Remove the color if it already exists in history 93 | const filtered = prev.filter((color) => color !== selectedColor); 94 | // Add new color to the beginning 95 | const updated = [selectedColor, ...filtered]; 96 | // Keep only last 10 colors 97 | return updated.slice(0, 10); 98 | }); 99 | }, [selectedColor]); 100 | 101 | // Save colors to localStorage whenever they change 102 | useEffect(() => { 103 | const colorStorage: ColorStorage = { 104 | selectedColor, 105 | colorHistory, 106 | }; 107 | localStorage.setItem(COLOR_STORAGE_KEY, JSON.stringify(colorStorage)); 108 | }, [selectedColor, colorHistory]); 109 | 110 | // Update tempColor when selectedColor changes 111 | useEffect(() => { 112 | setTempColor(selectedColor); 113 | }, [selectedColor]); 114 | 115 | const dragRef = useRef<{ 116 | startX: number; 117 | startY: number; 118 | startPosX: number; 119 | startPosY: number; 120 | }>({ 121 | startX: 0, 122 | startY: 0, 123 | startPosX: 0, 124 | startPosY: 0, 125 | }); 126 | 127 | const handleMouseDown = (e: React.MouseEvent) => { 128 | setIsDragging(true); 129 | dragRef.current = { 130 | startX: e.clientX, 131 | startY: e.clientY, 132 | startPosX: position.x, 133 | startPosY: position.y, 134 | }; 135 | }; 136 | 137 | const handleMouseMove = (e: React.MouseEvent) => { 138 | if (!isDragging) return; 139 | 140 | const dx = e.clientX - dragRef.current.startX; 141 | const dy = e.clientY - dragRef.current.startY; 142 | 143 | setPosition({ 144 | x: dragRef.current.startPosX + dx, 145 | y: dragRef.current.startPosY + dy, 146 | }); 147 | }; 148 | 149 | const handleMouseUp = () => { 150 | setIsDragging(false); 151 | }; 152 | 153 | return ( 154 |
163 |
164 | 165 | 166 | 181 | 182 | Reset Model 183 | 184 |
185 | 186 |
187 | 188 | 189 | 198 | 199 | Reset Camera 200 | 201 | 202 | 203 | 204 | 212 | 213 | Take Photo 214 | 215 |
216 |
217 | 218 | 219 | 227 | 228 | Undo (⌘Z) 229 | 230 | 231 | 232 | 233 | 241 | 242 | Redo (⌘⇧Z) 243 | 244 |
245 | 246 |
247 | 248 | 249 | 250 | 258 | 259 | View Mode (Space) 260 | 261 | 262 | 263 | 264 | 272 | 273 | Paint Mode (Space) 274 | 275 | 276 |
277 | 278 |
279 |
280 |
281 | 282 | setBrushSize(value[0])} 288 | className="w-24" 289 | /> 290 | 291 |
292 | {brushSize}px 293 |
294 | 295 |
296 | 297 | 298 |
302 | 303 | 304 | setTempColor(color.hex)} 308 | onChangeComplete={(color) => { 309 | setSelectedColor(color.hex); 310 | }} 311 | /> 312 | 313 | 314 |
315 | {Array.from({ length: 9 }).map((_, index) => ( 316 |
326 | colorHistory[index + 1] && 327 | setSelectedColor(colorHistory[index + 1]) 328 | } 329 | /> 330 | ))} 331 |
332 |
333 |
334 |
335 | ); 336 | } 337 | -------------------------------------------------------------------------------- /src/app/components/painting-board.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | useRef, 5 | useState, 6 | useEffect, 7 | Suspense, 8 | useImperativeHandle, 9 | forwardRef, 10 | } from "react"; 11 | import { Canvas, useThree } from "@react-three/fiber"; 12 | import { OrbitControls, useGLTF } from "@react-three/drei"; 13 | import * as THREE from "three"; 14 | import BottomBar from "./bottom-bar"; 15 | export const STORAGE_KEY = "paint-canvas"; 16 | export const COLOR_STORAGE_KEY = "paint-color"; 17 | 18 | type GLTFResult = { 19 | scene: THREE.Group; 20 | nodes: { [key: string]: THREE.Mesh }; 21 | }; 22 | 23 | type CanvasRefType = { 24 | canvas: HTMLCanvasElement; 25 | ctx: CanvasRenderingContext2D; 26 | texture: THREE.CanvasTexture; 27 | }; 28 | 29 | type PaintableModelProps = { 30 | url: string; 31 | selectedColor: string; 32 | brushRadius: number; 33 | isSpacePressed: boolean; 34 | canvasRef: React.RefObject; 35 | }; 36 | 37 | function PaintableModel( 38 | { 39 | url, 40 | selectedColor, 41 | brushRadius, 42 | isSpacePressed, 43 | canvasRef, 44 | }: PaintableModelProps, 45 | ref: React.ForwardedRef<{ undo: () => void; redo: () => void }> 46 | ) { 47 | const { scene } = useGLTF(url) as unknown as GLTFResult; 48 | const meshRef = useRef(null); 49 | const { camera, gl } = useThree(); 50 | const [texture, setTexture] = useState(null); 51 | const painting = useRef(false); 52 | const history = useRef([]); 53 | const redoStack = useRef([]); 54 | 55 | // Add step counter refs 56 | const lastUndoTime = useRef(0); 57 | const lastRedoTime = useRef(0); 58 | const undoStepCount = useRef(1); 59 | const redoStepCount = useRef(1); 60 | const CLICK_TIMEOUT = 3000; // 3 seconds 61 | 62 | // Center the model when it's loaded 63 | useEffect(() => { 64 | if (meshRef.current) { 65 | const box = new THREE.Box3().setFromObject(meshRef.current); 66 | const center = box.getCenter(new THREE.Vector3()); 67 | const size = box.getSize(new THREE.Vector3()); 68 | 69 | // Center the model 70 | meshRef.current.position.x = -center.x; 71 | meshRef.current.position.y = -center.y; 72 | meshRef.current.position.z = -center.z; 73 | 74 | // Adjust camera position based on model size 75 | const maxDim = Math.max(size.x, size.y, size.z); 76 | camera.position.z = maxDim * 2; 77 | } 78 | }, [scene, camera]); 79 | 80 | useEffect(() => { 81 | const size = 1024; 82 | const canvas = document.createElement("canvas"); 83 | canvas.width = canvas.height = size; 84 | const ctx = canvas.getContext("2d"); 85 | if (!ctx) return; 86 | 87 | // Restore from localStorage 88 | const saved = localStorage.getItem(STORAGE_KEY); 89 | if (saved) { 90 | const img = new Image(); 91 | img.onload = () => { 92 | ctx.drawImage(img, 0, 0); 93 | const canvasTexture = new THREE.CanvasTexture(canvas); 94 | setTexture(canvasTexture); 95 | canvasRef.current = { canvas, ctx, texture: canvasTexture }; 96 | }; 97 | img.src = saved; 98 | } else { 99 | ctx.fillStyle = "#ffffff"; 100 | ctx.fillRect(0, 0, size, size); 101 | const canvasTexture = new THREE.CanvasTexture(canvas); 102 | setTexture(canvasTexture); 103 | canvasRef.current = { canvas, ctx, texture: canvasTexture }; 104 | } 105 | }, []); 106 | 107 | useEffect(() => { 108 | if (texture && meshRef.current) { 109 | meshRef.current.traverse((child) => { 110 | if ((child as THREE.Mesh).isMesh) { 111 | const mesh = child as THREE.Mesh; 112 | 113 | if (mesh.geometry && !mesh.geometry.attributes.normal) { 114 | mesh.geometry.computeVertexNormals(); 115 | } 116 | 117 | mesh.material = new THREE.MeshStandardMaterial({ 118 | color: "white", 119 | map: texture, 120 | roughness: 0.2, 121 | metalness: 0.0, 122 | }); 123 | } 124 | }); 125 | } 126 | }, [texture]); 127 | 128 | const handlePointerMove = (e: THREE.Event & PointerEvent) => { 129 | if (e.buttons === 1) { 130 | paintAt(e); 131 | } 132 | }; 133 | 134 | const paintAt = (event: THREE.Event & PointerEvent) => { 135 | if ( 136 | !painting.current || 137 | isSpacePressed || 138 | !canvasRef.current || 139 | !meshRef.current 140 | ) 141 | return; 142 | 143 | const mouse = new THREE.Vector2(); 144 | const raycaster = new THREE.Raycaster(); 145 | const bounds = gl.domElement.getBoundingClientRect(); 146 | 147 | mouse.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1; 148 | mouse.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1; 149 | 150 | raycaster.setFromCamera(mouse, camera); 151 | const intersects = raycaster.intersectObject(meshRef.current, true); 152 | 153 | if (intersects.length > 0 && intersects[0].uv) { 154 | // Save current state for undo 155 | const dataUrl = canvasRef.current.canvas.toDataURL(); 156 | history.current.push(dataUrl); 157 | redoStack.current = []; // clear redo on new paint 158 | 159 | const uv = intersects[0].uv; 160 | const x = uv.x * canvasRef.current.canvas.width; 161 | const y = (1 - uv.y) * canvasRef.current.canvas.height; 162 | 163 | const uvRadius = brushRadius / canvasRef.current.canvas.width; 164 | const pixelRadius = uvRadius * canvasRef.current.canvas.width; 165 | 166 | const { ctx, texture } = canvasRef.current; 167 | ctx.fillStyle = selectedColor; 168 | ctx.beginPath(); 169 | ctx.arc(x, y, pixelRadius, 0, Math.PI * 2); 170 | ctx.fill(); 171 | texture.needsUpdate = true; 172 | 173 | // Save to localStorage 174 | localStorage.setItem(STORAGE_KEY, canvasRef.current.canvas.toDataURL()); 175 | } 176 | }; 177 | 178 | const undo = () => { 179 | if (!canvasRef.current || history.current.length === 0) return; 180 | 181 | const now = Date.now(); 182 | if (now - lastUndoTime.current < CLICK_TIMEOUT) { 183 | // Increase step count if clicked within timeout 184 | undoStepCount.current += 2; 185 | } else { 186 | // Reset step count if timeout passed 187 | undoStepCount.current = 1; 188 | } 189 | lastUndoTime.current = now; 190 | 191 | // Perform multiple undos based on step count 192 | for (let i = 0; i < undoStepCount.current; i++) { 193 | if (history.current.length === 0) break; 194 | const dataUrl = history.current.pop(); 195 | if (!dataUrl) break; 196 | redoStack.current.push(canvasRef.current.canvas.toDataURL()); 197 | const img = new Image(); 198 | img.onload = () => { 199 | const { ctx, texture } = canvasRef.current!; 200 | ctx.clearRect(0, 0, 1024, 1024); 201 | ctx.drawImage(img, 0, 0); 202 | texture.needsUpdate = true; 203 | localStorage.setItem( 204 | STORAGE_KEY, 205 | canvasRef.current!.canvas.toDataURL() 206 | ); 207 | }; 208 | img.src = dataUrl; 209 | } 210 | }; 211 | 212 | const redo = () => { 213 | if (!canvasRef.current || redoStack.current.length === 0) return; 214 | 215 | const now = Date.now(); 216 | if (now - lastRedoTime.current < CLICK_TIMEOUT) { 217 | // Increase step count if clicked within timeout 218 | redoStepCount.current += 2; 219 | } else { 220 | // Reset step count if timeout passed 221 | redoStepCount.current = 1; 222 | } 223 | lastRedoTime.current = now; 224 | 225 | // Perform multiple redos based on step count 226 | for (let i = 0; i < redoStepCount.current; i++) { 227 | if (redoStack.current.length === 0) break; 228 | const dataUrl = redoStack.current.pop(); 229 | if (!dataUrl) break; 230 | history.current.push(canvasRef.current.canvas.toDataURL()); 231 | const img = new Image(); 232 | img.onload = () => { 233 | const { ctx, texture } = canvasRef.current!; 234 | ctx.clearRect(0, 0, 1024, 1024); 235 | ctx.drawImage(img, 0, 0); 236 | texture.needsUpdate = true; 237 | localStorage.setItem( 238 | STORAGE_KEY, 239 | canvasRef.current!.canvas.toDataURL() 240 | ); 241 | }; 242 | img.src = dataUrl; 243 | } 244 | }; 245 | 246 | // Reset step counts after timeout 247 | useEffect(() => { 248 | const resetUndoSteps = () => { 249 | if (Date.now() - lastUndoTime.current >= CLICK_TIMEOUT) { 250 | undoStepCount.current = 1; 251 | } 252 | }; 253 | 254 | const resetRedoSteps = () => { 255 | if (Date.now() - lastRedoTime.current >= CLICK_TIMEOUT) { 256 | redoStepCount.current = 1; 257 | } 258 | }; 259 | 260 | const interval = setInterval(() => { 261 | resetUndoSteps(); 262 | resetRedoSteps(); 263 | }, 1000); // Check every second 264 | 265 | return () => clearInterval(interval); 266 | }, []); 267 | 268 | // Expose undo/redo functions to parent component 269 | useImperativeHandle(ref, () => ({ 270 | undo, 271 | redo, 272 | })); 273 | 274 | return ( 275 | { 278 | if (!isSpacePressed) { 279 | painting.current = true; 280 | paintAt(e as unknown as PointerEvent); 281 | } 282 | }} 283 | onPointerMove={(e) => handlePointerMove(e as unknown as PointerEvent)} 284 | onPointerUp={() => (painting.current = false)} 285 | onPointerLeave={() => (painting.current = false)} 286 | > 287 | 288 | 289 | ); 290 | } 291 | 292 | // Create a forwardRef wrapper for PaintableModel 293 | const PaintableModelWithRef = forwardRef(PaintableModel); 294 | 295 | function ExportHandler({ 296 | onExport, 297 | }: { 298 | onExport: ( 299 | renderer: THREE.WebGLRenderer, 300 | scene: THREE.Scene, 301 | camera: THREE.Camera 302 | ) => void; 303 | }) { 304 | const { gl, scene, camera } = useThree(); 305 | 306 | useEffect(() => { 307 | const handleKey = (e: KeyboardEvent) => { 308 | if ((e.metaKey || e.ctrlKey) && e.key === "s") { 309 | e.preventDefault(); 310 | onExport(gl, scene, camera); 311 | } 312 | }; 313 | window.addEventListener("keydown", handleKey); 314 | return () => window.removeEventListener("keydown", handleKey); 315 | }, [gl, scene, camera, onExport]); 316 | 317 | return null; 318 | } 319 | 320 | export default function PaintingBoard() { 321 | const [selectedColor, setSelectedColor] = useState("#ff0000"); 322 | const [brushRadius, setBrushRadius] = useState(8); 323 | const [isSpacePressed, setIsSpacePressed] = useState(false); 324 | const [modelUrl, setModelUrl] = useState( 325 | "https://v3.fal.media/files/panda/BUZ_xt9BFOVvsX6dP3QFW_model.glb" 326 | ); 327 | const modelRef = useRef<{ undo: () => void; redo: () => void }>(null); 328 | const controlsRef = useRef(null); 329 | const canvasRef = useRef(null); 330 | const fileInputRef = useRef(null); 331 | const rendererRef = useRef(null); 332 | 333 | // Add cursor style based on mode and brush size 334 | useEffect(() => { 335 | const cursorStyle = isSpacePressed 336 | ? "default" 337 | : `url("data:image/svg+xml,%3Csvg width='${brushRadius * 2}' height='${ 338 | brushRadius * 2 339 | }' viewBox='0 0 ${brushRadius * 2} ${ 340 | brushRadius * 2 341 | }' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='${brushRadius}' cy='${brushRadius}' r='${ 342 | brushRadius - 1 343 | }' stroke='white' stroke-width='1' fill='none'/%3E%3C/svg%3E") ${brushRadius} ${brushRadius}, auto`; 344 | 345 | document.body.style.cursor = cursorStyle; 346 | 347 | return () => { 348 | document.body.style.cursor = "default"; 349 | }; 350 | }, [isSpacePressed, brushRadius]); 351 | 352 | const resetCamera = () => { 353 | if (controlsRef.current) { 354 | controlsRef.current.reset(); 355 | } 356 | }; 357 | 358 | useEffect(() => { 359 | const handleKey = (e: KeyboardEvent) => { 360 | if (!isSpacePressed) { 361 | if ((e.metaKey || e.ctrlKey) && e.key === "z") { 362 | e.preventDefault(); 363 | if (e.shiftKey) { 364 | modelRef.current?.redo(); 365 | } else { 366 | modelRef.current?.undo(); 367 | } 368 | } else if ((e.metaKey || e.ctrlKey) && e.key === "y") { 369 | e.preventDefault(); 370 | modelRef.current?.redo(); 371 | } 372 | } 373 | }; 374 | window.addEventListener("keydown", handleKey); 375 | return () => window.removeEventListener("keydown", handleKey); 376 | }, [isSpacePressed, modelRef]); 377 | 378 | useEffect(() => { 379 | const handleKeyDown = (e: KeyboardEvent) => { 380 | if (e.code === "Space") { 381 | e.preventDefault(); // Prevent page scroll 382 | setIsSpacePressed((prev) => !prev); // Toggle the state 383 | } 384 | }; 385 | window.addEventListener("keydown", handleKeyDown); 386 | return () => { 387 | window.removeEventListener("keydown", handleKeyDown); 388 | }; 389 | }, []); 390 | 391 | const handleFileImport = (event: React.ChangeEvent) => { 392 | const file = event.target.files?.[0]; 393 | if (!file) return; 394 | 395 | // Reset the canvas and storage 396 | localStorage.removeItem(STORAGE_KEY); 397 | localStorage.removeItem(COLOR_STORAGE_KEY); 398 | 399 | // Create a URL for the file 400 | const fileUrl = URL.createObjectURL(file); 401 | setModelUrl(fileUrl); 402 | 403 | // Reset camera position 404 | if (controlsRef.current) { 405 | controlsRef.current.reset(); 406 | } 407 | }; 408 | 409 | const exportImage = ( 410 | renderer: THREE.WebGLRenderer, 411 | scene: THREE.Scene, 412 | camera: THREE.Camera 413 | ) => { 414 | // Render the scene 415 | renderer.render(scene, camera); 416 | 417 | // Get the image data 418 | const imageData = renderer.domElement.toDataURL("image/png"); 419 | 420 | // Create download link 421 | const link = document.createElement("a"); 422 | link.href = imageData; 423 | link.download = `miniature-painting-${new Date() 424 | .toISOString() 425 | .slice(0, 19) 426 | .replace(/:/g, "-")}.png`; 427 | document.body.appendChild(link); 428 | link.click(); 429 | document.body.removeChild(link); 430 | }; 431 | 432 | return ( 433 |
434 | 441 | { 444 | rendererRef.current = gl; 445 | }} 446 | > 447 | {/* Ambient light for base illumination */} 448 | 449 | 450 | {/* Main directional lights from different angles */} 451 | 457 | 463 | 469 | 470 | {/* Fill lights for better detail visibility */} 471 | 472 | 473 | 474 | 475 | 476 | 484 | 485 | 495 | 496 | 497 | modelRef.current?.undo()} 503 | onRedo={() => modelRef.current?.redo()} 504 | isSpacePressed={isSpacePressed} 505 | setIsSpacePressed={setIsSpacePressed} 506 | onResetCamera={resetCamera} 507 | onExportImage={() => { 508 | const event = new KeyboardEvent("keydown", { 509 | key: "s", 510 | metaKey: true, 511 | }); 512 | window.dispatchEvent(event); 513 | }} 514 | onImportModel={() => fileInputRef.current?.click()} 515 | /> 516 |
517 | ); 518 | } 519 | --------------------------------------------------------------------------------