├── 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 |
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 |
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 |
--------------------------------------------------------------------------------