This commit is contained in:
2023-12-20 16:42:21 +08:00
commit 1bf86099d8
49 changed files with 6606 additions and 0 deletions

18
.eslintrc.cjs Normal file
View File

@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

30
.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.glb
*.hdr
textures
models

3
.prettierrc Normal file
View File

@ -0,0 +1,3 @@
plugins:
- prettier-plugin-tailwindcss

30
README.md Normal file
View File

@ -0,0 +1,30 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default {
// other rules...
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
}
```
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

54
package.json Normal file
View File

@ -0,0 +1,54 @@
{
"name": "mofumofu",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,md}\""
},
"dependencies": {
"@chakra-ui/icons": "^2.1.1",
"@chakra-ui/react": "^2.8.2",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@react-three/drei": "^9.92.1",
"@react-three/fiber": "^8.15.12",
"@types/lodash-es": "^4.17.12",
"@types/uuid": "^9.0.7",
"ahooks": "^3.7.8",
"fabric": "^5.3.0",
"framer-motion": "^10.16.16",
"lodash-es": "^4.17.21",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^4.12.0",
"three": "^0.159.0",
"uuid": "^9.0.1",
"zustand": "^4.4.7"
},
"devDependencies": {
"@types/fabric": "^5.3.6",
"@types/node": "^20.10.4",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.17",
"@types/three": "^0.159.0",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.16",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.32",
"prettier": "^3.1.1",
"prettier-plugin-tailwindcss": "^0.5.9",
"sass": "^1.69.5",
"tailwindcss": "^3.3.6",
"typescript": "^5.3.3",
"vite": "^5.0.8"
}
}

5133
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
public/icon/cap_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

BIN
public/icon/cap_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

0
src/App.css Normal file
View File

34
src/App.tsx Normal file
View File

@ -0,0 +1,34 @@
import "./App.css";
import { ChakraProvider, CircularProgress } from "@chakra-ui/react";
import useModelStore from "@/store/useModelStore.ts";
import ThreeScene from "@/components/ThreeScene.tsx";
import RightPanel from "@/components/RightPanel.tsx";
function App() {
const modelLoading = useModelStore((state) => state.modelLoading);
return (
<ChakraProvider>
<div className={"relative flex h-screen w-screen"}>
{/*<ThreeDesigner />*/}
<div className={"flex-[5] shrink-0"}>
<ThreeScene />
</div>
<div className={"flex-[2] shrink-0 bg-blue-50 p-4"}>
<RightPanel />
</div>
{/* <AreaIndicator /> */}
{modelLoading !== 0 && modelLoading !== 100 && (
<div
className={
"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
}
>
<CircularProgress value={modelLoading} />
</div>
)}
</div>
</ChakraProvider>
);
}
export default App;

1
src/assets/react.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,11 @@
.modelAreaItem {
&:not(:first-child) {
margin-top: 8px;
}
}
.modelItem {
&:not(:first-child) {
margin-top: 8px;
}
}

View File

@ -0,0 +1,36 @@
import { models } from "@/constant/models.ts";
import useModelStore from "@/store/useModelStore.ts";
import styles from "./AreaIndicator.module.scss";
const AreaIndicator = () => {
const activeModel = useModelStore((state) => state.activeModel);
const activeArea = useModelStore((state) => state.activeArea);
const setActiveArea = useModelStore((state) => state.setActiveArea);
return (
<div
className={
"absolute left-2 top-1/2 flex -translate-y-1/2 flex-col items-center justify-center rounded bg-red-200 p-2 shadow-lg transition-shadow hover:shadow-2xl"
}
>
{models[activeModel].mesh.map((item, index) => (
<div
onClick={() => {
setActiveArea(index);
}}
className={`${styles.modelAreaItem} ${
activeArea === index ? "border-2 border-green-300" : ""
} h-12 w-12 overflow-hidden rounded transition-all`}
key={item.name}
>
<img
src={item.icon}
className={"h-full w-full object-cover"}
alt={""}
/>
</div>
))}
</div>
);
};
export default AreaIndicator;

View File

@ -0,0 +1,57 @@
import {
Button,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from "@chakra-ui/react";
import { useEffect, useRef } from "react";
import { fabric } from "fabric";
import { ICanvasOptions } from "fabric/fabric-impl"; // v6
const CanvasTexturesEditor = ({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const options: ICanvasOptions = {
backgroundImage: "/",
};
const canvas = new fabric.Canvas(canvasRef.current, options);
// make the fabric.Canvas instance available to your app
// updateCanvasContext(canvas);
return () => {
// updateCanvasContext(null);
canvas.dispose();
};
}, []);
return (
<Modal isOpen={open} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Modal Title</ModalHeader>
<ModalCloseButton />
<ModalBody>
<canvas className={"w-full"} height={300} ref={canvasRef}></canvas>
</ModalBody>
<ModalFooter>
<Button colorScheme="blue" mr={3} onClick={() => {}}>
Close
</Button>
<Button variant="ghost">Secondary Action</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default CanvasTexturesEditor;

View File

@ -0,0 +1,65 @@
import { Object3D, TextureLoader, Vector3 } from "three";
import useModelStore from "@/store/useModelStore.ts";
import { useState } from "react";
import { models } from "@/constant/models.ts";
import { textures } from "@/constant/textures.ts";
const CapMesh = ({
areaIndex,
mesh,
}: {
areaIndex: number;
mesh: Object3D;
}) => {
const logo = useTexture("/textures/archlogo.png");
const activeModel = useModelStore((state) => state.activeModel);
const activeTextures = useModelStore((state) => state.activeTextures);
const [pos, setPos] = useState<Vector3>(new Vector3(0, 0, 0));
return (
<mesh
onClick={(ev) => {
const { x, y, z } = ev.point;
setPos(
new Vector3(
x / models[activeModel].scale,
y / models[activeModel].scale,
z / models[activeModel].scale,
),
);
}}
castShadow
receiveShadow
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
geometry={mesh.geometry}
dispose={null}
>
{areaIndex > -1 ? (
<meshStandardMaterial
map={new TextureLoader().load(
textures[activeTextures[areaIndex]].path,
)}
/>
) : (
<meshStandardMaterial color="gray" />
)}
<Decal position={pos} scale={0.05} rotation={Math.PI}>
<meshPhysicalMaterial
transparent
polygonOffset
polygonOffsetFactor={-10}
map={logo}
map-flipY={false}
map-anisotropy={16}
iridescence={1}
iridescenceIOR={1}
iridescenceThicknessRange={[0, 1400]}
roughness={1}
clearcoat={0.5}
metalness={0.75}
toneMapped={false}
/>
</Decal>
</mesh>
);
};

View File

@ -0,0 +1,9 @@
const DebugPanel = () => {
return (
<div>DebugPanel</div>
)
}
export default DebugPanel

View File

@ -0,0 +1,90 @@
import { useState } from "react";
import { AddIcon } from "@chakra-ui/icons";
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalOverlay,
Image
} from "@chakra-ui/react";
import { pickFile } from "@/lib/upload";
import useModelStore, { StickerType } from "@/store/useModelStore";
import { v4 as uuidv4 } from 'uuid'
import { Vector3 } from "three";
const LogoPanel = () => {
const [open, setOpen] = useState(false);
const decals = useModelStore((state) => state.decals);
const setDecals = useModelStore((state) => state.setDecals);
const setActiveDecal = useModelStore((state) => state.setActiveDecal);
const activeDecal = useModelStore((state) => state.activeDecal);
return (
<>
<ul>
{decals
.filter((el) => el.type === StickerType.logo)
.map((decal) => (
<li
onClick={() => {
setActiveDecal(decal.id)
}}
key={decal.id}
className={`mb-2 flex h-12 w-full cursor-pointer items-center justify-between rounded bg-white px-2 shadow ${decal.id === activeDecal ? "border-2 border-green-100" : ""
}`}
>
<Image className={"w-12 h-12 object-contain"} src={decal.url} />
</li>
))}
</ul>
<div className="flex justify-center mt-4">
<Button
colorScheme="messenger"
onClick={() => {
pickFile({
accept: ["image/png"],
}).then((files) => {
if (!files.length) return
const id = uuidv4()
setDecals([...decals, {
id,
url: URL.createObjectURL(files[0]),
postion: new Vector3(0, 0, 0),
type: StickerType.logo
}])
setActiveDecal(id)
});
}}
leftIcon={<AddIcon />}
>
LOGO
</Button>
</div>
<Modal
isOpen={open}
onClose={() => {
setOpen(() => false);
}}
>
<ModalOverlay />
<ModalContent>
<ModalBody>
<div className="flex justify-center p-4">
<div
onClick={() => { }}
className={
"flex aspect-square w-24 cursor-pointer items-center justify-center rounded-lg border-2 border-gray-300"
}
>
<AddIcon />
</div>
</div>
</ModalBody>
</ModalContent>
</Modal>
</>
);
};
export default LogoPanel;

View File

@ -0,0 +1,56 @@
import { Tab, TabList, TabPanel, TabPanels, Tabs } from "@chakra-ui/react";
import { models } from "@/constant/models.ts";
import useModelStore from "@/store/useModelStore.ts";
import TextureSelectorPanel from "./TextureSelectorPanel";
import LogoPanel from "./LogoPanel";
import TextLabelPanel from "./TextLabelPanel";
const RightPanel = () => {
const activeModel = useModelStore((state) => state.activeModel);
const setActiveModel = useModelStore((state) => state.setActiveModel);
return (
<>
<Tabs variant={"soft-rounded"}>
<TabList>
<Tab></Tab>
<Tab></Tab>
<Tab></Tab>
<Tab>LOGO</Tab>
</TabList>
<TabPanels>
<TabPanel>
<div className={"grid w-full grid-cols-6 gap-4"}>
{models.map((model, index) => (
<div
key={model.path}
onClick={() => {
setActiveModel(index);
}}
className={`${
activeModel === index ? "border-2 border-green-300" : ""
} flex aspect-square items-center justify-center overflow-hidden rounded-xl bg-blue-100`}
>
<img
className="h-full w-full object-cover"
src={models[activeModel].icon}
/>
</div>
))}
</div>
</TabPanel>
<TabPanel>
<TextureSelectorPanel />
</TabPanel>
<TabPanel>
<TextLabelPanel />
</TabPanel>
<TabPanel>
<LogoPanel />
</TabPanel>
</TabPanels>
</Tabs>
</>
);
};
export default RightPanel;

View File

@ -0,0 +1,58 @@
import useModelStore, { DecalSticker } from "@/store/useModelStore";
import { Decal, useTexture } from "@react-three/drei";
import { useState } from "react";
/**
* logo 和文字标签
* @param param0
* @returns
*/
const Sticker = ({ decal }: { decal: DecalSticker }) => {
const sticker = useTexture(decal.url);
const [scale, setScale] = useState(0.05);
const setDecalDragging = useModelStore((state) => state.setDecalDragging);
const setActiveDecal = useModelStore((state) => state.setActiveDecal);
return (
<Decal
position={decal.postion}
scale={scale}
onPointerDown={(ev) => {
ev.stopPropagation();
setActiveDecal(decal.id)
setDecalDragging(true);
}}
onPointerUp={(ev) => {
ev.stopPropagation();
setDecalDragging(false);
}}
// onWheel={(ev) => {
// ev.stopPropagation()
// setDecalDragging(true)
// console.log(ev);
// // setScale((state) => state += 1)
// }}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
rotation={Math.PI}
>
<meshPhysicalMaterial
transparent
polygonOffset
polygonOffsetFactor={-10}
map={sticker}
map-flipY={false}
map-anisotropy={16}
iridescence={1}
iridescenceIOR={1}
iridescenceThicknessRange={[0, 1400]}
roughness={1}
clearcoat={0.5}
metalness={0.75}
toneMapped={false}
/>
</Decal>
);
};
export default Sticker;

View File

@ -0,0 +1,137 @@
import useModelStore, { StickerType } from "@/store/useModelStore";
import { AddIcon, DeleteIcon, EditIcon } from "@chakra-ui/icons";
import {
Button,
FormControl,
FormLabel,
IconButton,
Input,
} from "@chakra-ui/react";
import { useState } from "react";
import { fabric } from "fabric";
import { Vector3 } from "three";
import { v4 } from "uuid";
const TextLabelPanel = () => {
const decals = useModelStore((state) => state.decals);
const setDecals = useModelStore((state) => state.setDecals);
const setActiveDecal = useModelStore((state) => state.setActiveDecal);
const activeDecal = useModelStore((state) => state.activeDecal);
const [editing, setEditing] = useState(false);
const [text, setText] = useState("");
const [color, setColor] = useState("#ffffff");
return (
<>
{editing ? (
<div className={"rounded bg-white p-4"}>
<h2 className="font-bold"></h2>
<FormControl>
<FormLabel></FormLabel>
<Input
value={text}
onInput={(ev) => {
setText(() => (ev.target as HTMLInputElement).value);
}}
/>
</FormControl>
<FormControl>
<FormLabel></FormLabel>
<Input
type="color"
value={color}
onInput={(ev) => {
setColor(() => (ev.target as HTMLInputElement).value);
}}
/>
</FormControl>
<div className={"mt-2 flex justify-center"}>
<Button
colorScheme="messenger"
size={"sm"}
onClick={() => {
const canvas = document.createElement("canvas");
const fabricText = new fabric.Text(text, {
fill: color,
});
const fabricCanvas = new fabric.Canvas(canvas, {
width: fabricText.width,
height: fabricText.height,
});
fabricCanvas.add(fabricText);
const id = v4();
setActiveDecal(id);
setDecals([
...decals,
{
id,
postion: new Vector3(0, 0, 0),
text,
url: fabricCanvas.toDataURL(),
type: StickerType.text,
},
]);
setText(() => "");
setColor(() => "#ffffff");
setEditing(() => false);
}}
>
</Button>
</div>
</div>
) : (
<ul>
{decals
.filter((el) => el.type === StickerType.text)
.map((text) => (
<li
onClick={() => {
setActiveDecal(text.id);
}}
key={text.id}
className={`mt-2 flex h-12 w-full cursor-pointer items-center justify-between rounded bg-white px-2 shadow ${
activeDecal === text.id ? "border-2 border-green-100" : ""
}`}
>
<span>{text.text}</span>
<div className={"right"}>
<IconButton
onClick={(ev) => {
ev.stopPropagation();
}}
size={"xs"}
icon={<EditIcon />}
aria-label={""}
/>
<IconButton
className={"ml-1"}
onClick={(ev) => {
ev.stopPropagation();
}}
size={"xs"}
icon={<DeleteIcon />}
aria-label={""}
/>
</div>
</li>
))}
</ul>
)}
<div className="mt-4 flex justify-center">
<Button
colorScheme="messenger"
onClick={() => {
// setOpen(() => true)
setEditing(() => true);
}}
leftIcon={<AddIcon />}
>
</Button>
</div>
</>
);
};
export default TextLabelPanel;

View File

@ -0,0 +1,5 @@
.textureItem {
&:not(:first-child) {
margin-left: 8px;
}
}

View File

@ -0,0 +1,57 @@
import useModelStore from "@/store/useModelStore.ts";
import { textures } from "@/constant/textures.ts";
// import CanvasTexturesEditor from "@/components/CanvasTexturesEditor.tsx";
import { useState } from "react";
import { Divider, IconButton } from "@chakra-ui/react";
import { AddIcon } from "@chakra-ui/icons";
import TexturesEditor from "@/components/TexturesEditor.tsx";
const TextureSelector = () => {
const { activeTextures, setActiveTextures, activeArea } = useModelStore();
// eslint-disable-next-line no-empty-pattern
const [showEditor, setShowEditor] = useState(false);
return (
<>
<div
className={
"absolute bottom-2 left-1/2 flex -translate-x-1/2 items-center rounded-lg bg-red-200 px-4 py-2"
}
>
<span className={"text-xs"}></span>
{textures.map((item, index) => (
<div
onClick={() => {
// setActiveTexture(index);
setActiveTextures(activeArea, index);
}}
className={`${
activeTextures[activeArea] === index
? "border-2 border-green-300"
: ""
} mx-1 h-12 w-12 origin-bottom overflow-hidden rounded transition-all hover:mx-4 hover:scale-150`}
key={item.path}
>
<img className={"object-cover"} src={item.path} alt={""} />
</div>
))}
<Divider orientation="vertical" />
<IconButton
className={"ml-4"}
aria-label={""}
icon={<AddIcon />}
onClick={() => {
setShowEditor(() => true);
}}
></IconButton>
</div>
<TexturesEditor
isOpen={showEditor}
onClose={() => {
setShowEditor(() => false);
}}
/>
</>
);
};
export default TextureSelector;

View File

@ -0,0 +1,51 @@
import { models } from "@/constant/models";
import { textures } from "@/constant/textures";
import useModelStore from "@/store/useModelStore";
function TextureSelectorPanel() {
const activeTextures = useModelStore((state) => state.activeTextures);
const activeArea = useModelStore((state) => state.activeArea);
const setActiveArea = useModelStore((state) => state.setActiveArea);
const activeModel = useModelStore((state) => state.activeModel);
const setActiveTextures = useModelStore((state) => state.setActiveTextures);
return (
<>
<h2 className={"text-sm font-bold"}></h2>
<div className="mt-2 grid grid-cols-8 gap-3">
{models[activeModel].mesh.map((mesh, index) => (
<div
onClick={() => {
setActiveArea(index);
}}
className={`${
activeArea === index ? "border-2 border-green-300" : ""
} aspect-square cursor-pointer overflow-hidden rounded`}
key={mesh.name}
>
<img src={mesh.icon} className="h-full w-full object-cover" />
</div>
))}
</div>
<h2 className={"mt-3 text-sm font-bold"}></h2>
<div className="mt-2 grid grid-cols-6 gap-3">
{textures.map((texture, index) => (
<div
key={texture.path}
onClick={() => {
setActiveTextures(activeArea, index);
}}
className={`${
activeTextures[activeArea] === index
? "border-2 border-green-300"
: ""
} cursor-pointer overflow-hidden rounded`}
>
<img className={"h-full w-full object-cover"} src={texture.path} />
</div>
))}
</div>
</>
);
}
export default TextureSelectorPanel;

View File

@ -0,0 +1,27 @@
import {
Modal,
ModalBody,
ModalContent,
ModalHeader,
ModalOverlay,
} from "@chakra-ui/react";
const TexturesEditor = ({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) => {
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader></ModalHeader>
<ModalBody></ModalBody>
</ModalContent>
</Modal>
);
};
export default TexturesEditor;

View File

@ -0,0 +1,192 @@
import {
Color,
EquirectangularReflectionMapping,
Fog,
Group,
MeshStandardMaterial,
PerspectiveCamera,
RepeatWrapping,
Scene,
TextureLoader,
WebGLRenderer,
} from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { RGBELoader } from "three/addons/loaders/RGBELoader.js";
import { useEffect, useRef } from "react";
import { ThreeObj } from "@/typing/three.tsx";
import useModelStore from "@/store/useModelStore.ts";
import { textures } from "@/constant/textures.ts";
import { models } from "@/constant/models.ts";
const loadModel = (
modelMeta: {
name: string;
path: string;
scale: number;
},
onProgress?: (xhr: ProgressEvent) => void,
): Promise<Group> =>
new Promise((resolve, reject) => {
new GLTFLoader().load(
modelMeta.path,
(gltf) => {
console.log(gltf);
const model = gltf.scene;
const scale = modelMeta.scale;
model.scale.set(scale, scale, scale);
model.position.set(0, 0, 0);
resolve(model);
},
onProgress,
(error) => {
reject(error);
},
);
});
const initTexture = (
model: Group,
activeModel: number,
activeTextures: number[],
activeArea: number,
) =>
new Promise((resolve) => {
model.traverse((_child) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
if (_child.isMesh) {
const ttr = models[activeModel].mesh.find(
(el) => el.name === _child.name,
);
if (ttr) {
const texture = new TextureLoader().load(
textures[activeTextures[activeArea]].path,
);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
_child.material = new MeshStandardMaterial({
map: texture,
});
// renderer.render(scene, camera);
} else {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
_child.material = new MeshStandardMaterial({
color: 0x999999,
});
// renderer.render(scene, camera);
}
}
resolve("success");
});
});
const ThreeDesigner = () => {
const wrapRef = useRef<HTMLDivElement>(null);
const threeRef = useRef<ThreeObj | null>(null);
const activeTextures = useModelStore((state) => state.activeTextures);
const activeArea = useModelStore((state) => state.activeArea);
const activeModel = useModelStore((state) => state.activeModel);
const setModelLoading = useModelStore((state) => state.setModelLoading);
useEffect(() => {
if (wrapRef.current) {
// 场景
const scene = new Scene();
scene.background = new Color(0xf1f1f1);
scene.environment = new RGBELoader().load("/venice_sunset_1k.hdr");
scene.environment.mapping = EquirectangularReflectionMapping;
scene.fog = new Fog(0x333333, 10, 15);
// 渲染器
const renderer = new WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
// 摄像机
const camera = new PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000,
);
camera.position.z = 600;
// const mesh = new BoxGeometry();
// 添加控制
const controls = new OrbitControls(camera, renderer.domElement);
controls.addEventListener("change", () => {
renderer.render(scene, camera);
});
controls.minDistance = 2;
controls.maxDistance = 10;
controls.target.set(0, 0, -0.2);
controls.update();
// 添加光照
// const ambientLight = new AmbientLight(0xffffff, 1); //环境光的颜色以及强弱
// const pointLight = new PointLight(0xffffff, 1);
// pointLight.position.set(0, 0, 1000);
// scene.add(ambientLight);
// scene.add(pointLight);
loadModel(models[activeModel], (xhr) => {
setModelLoading((xhr.loaded / xhr.total) * 100);
console.log((xhr.loaded / xhr.total) * 100 + "% loaded");
}).then((model) => {
scene.add(model);
renderer.render(scene, camera);
initTexture(model, activeModel, activeTextures, activeArea).then(() => {
setTimeout(() => {
renderer.render(scene, camera);
}, 500);
});
});
wrapRef.current.appendChild(renderer.domElement);
renderer.render(scene, camera);
threeRef.current = {
scene,
renderer,
camera,
};
}
return () => {
// 销毁
threeRef.current?.renderer.domElement.remove();
threeRef.current?.camera.remove();
threeRef.current?.scene.remove();
threeRef.current = null;
};
}, [activeModel]);
/* activeTexture 变化时,切换texture */
useEffect(() => {
if (!threeRef.current) return;
const { scene, renderer, camera } = threeRef.current;
const texture = textures[activeTextures[activeArea]];
const area = models[activeModel].mesh[activeArea];
scene.traverse((child) => {
if (child.type === "Mesh") {
if (child.name === area.name) {
console.log(area.name);
new TextureLoader().load(texture.path, (texture) => {
texture.wrapT = RepeatWrapping;
texture.wrapS = RepeatWrapping;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
child.material = new MeshStandardMaterial({
map: texture,
});
renderer.render(scene, camera);
});
}
}
});
}, [activeTextures, activeArea, activeModel]);
return <div ref={wrapRef} className={"h-full w-full"}></div>;
};
export default ThreeDesigner;

View File

@ -0,0 +1,118 @@
import { Canvas } from "@react-three/fiber";
import { TextureLoader, Vector3 } from "three";
import { Environment, OrbitControls } from "@react-three/drei";
import useModelStore from "@/store/useModelStore.ts";
import { models } from "@/constant/models.ts";
import { useGLTF } from "@react-three/drei/core/useGLTF";
import { textures } from "@/constant/textures.ts";
import Sticker from "./Sticker";
import { useWhyDidYouUpdate } from "ahooks";
const CapModel = () => {
const activeModel = useModelStore((state) => state.activeModel);
const activeTextures = useModelStore((state) => state.activeTextures);
const decalDragging = useModelStore((state) => state.decalDragging);
const activeDecal = useModelStore((state) => state.activeDecal);
const setDecalPositon = useModelStore((state) => state.setDecalPositon);
useWhyDidYouUpdate('useWhyDidYouUpdateComponent', {
activeDecal, decalDragging, activeTextures, activeModel
});
const { nodes } = useGLTF(models[activeModel].path);
return (
<>
<group
scale={models[activeModel].scale}
onPointerMove={(ev) => {
ev.stopPropagation();
if (!decalDragging) return;
if (activeDecal) {
const { x, y, z } = ev.point;
const pos = new Vector3(
x / models[activeModel].scale,
y / models[activeModel].scale,
z / models[activeModel].scale,
);
setDecalPositon(activeDecal, pos);
}
}}
>
{Object.keys(nodes).map((keyName) => {
if (nodes[keyName].type === "Mesh") {
const areaIndex = models[activeModel].mesh.findIndex(
(el) => el.name === nodes[keyName].name,
);
return (
<mesh
key={keyName}
onDoubleClick={(ev) => {
ev.stopPropagation();
if (activeDecal) {
const { x, y, z } = ev.point;
const pos = new Vector3(
x / models[activeModel].scale,
y / models[activeModel].scale,
z / models[activeModel].scale,
);
console.log(pos);
setDecalPositon(activeDecal, pos);
}
}}
castShadow
receiveShadow
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
geometry={nodes[keyName].geometry}
dispose={null}
>
{areaIndex > -1 ? (
<>
<meshStandardMaterial
map={new TextureLoader().load(
textures[activeTextures[areaIndex]].path,
)}
/>
<Stickers />
</>
) : (
<meshStandardMaterial color="gray" />
)}
</mesh>
);
}
})}
</group>
<OrbitControls enabled={!decalDragging} />
</>
);
};
export const Stickers = () => {
const decals = useModelStore((state) => state.decals);
return (
<>
{decals.map((decal) => (
<Sticker decal={decal} key={decal.id} />
))}
</>
)
}
const ThreeScene = () => {
return (
<Canvas>
<ambientLight></ambientLight>
<pointLight position={[10, 10, 10]}></pointLight>
<perspectiveCamera />
<CapModel />
<Environment preset={"city"} background={false} />
</Canvas>
);
};
export default ThreeScene;

68
src/constant/models.ts Normal file
View File

@ -0,0 +1,68 @@
export const models = [
{
name: "版型1",
path: "/models/baseball_cap_3d.glb",
icon: "/icon/baseball_cap_3d.png",
scale: 18,
mesh: [
{
name: "Pattern2D_2289809_Node",
icon: "/icon/baseball_cap_3d.png",
label: "帽檐",
},
{
name: "Pattern2D_106097_Node",
icon: "/icon/baseball_cap_3d_logo.png",
label: "logo",
},
{
name: "Pattern2D_19139_Node",
icon: "/icon/baseball_cap_3d_lf.png",
label: "左前",
},
{
name: "Pattern2D_19150_Node",
icon: "/icon/baseball_cap_3d_rf.png",
label: "右前",
},
{
name: "Pattern2D_19137_Node",
icon: "/icon/baseball_cap_3d_lc.png",
label: "左中",
},
{
name: "Pattern2D_19142_Node",
icon: "/icon/baseball_cap_3d_rc.png",
label: "右中",
},
{
name: "Pattern2D_19148_Node",
icon: "/icon/baseball_cap_3d_lb.png",
label: "左后",
},
{
name: "Pattern2D_19144_Node",
icon: "/icon/baseball_cap_3d_rb.png",
label: "右后",
},
],
},
{
name: "版型2",
icon: "/icon/baseball_cap_3d_logo.png",
path: "/models/baseball_cap.glb",
scale: 0.02,
mesh: [
{
name: "baseballCap_1",
icon: "/icon/cap_1.png",
label: "帽檐",
},
{
name: "baseballCap_3",
icon: "/icon/cap_3.png",
label: "帽子",
},
],
},
];

14
src/constant/textures.ts Normal file
View File

@ -0,0 +1,14 @@
export const textures = [
{
path: "/textures/denim_.jpg",
},
{
path: "/textures/disturb.jpg",
},
{
path: "/textures/fabric_.jpg",
},
{
path: "/textures/quilt_.jpg",
},
];

4
src/index.css Normal file
View File

@ -0,0 +1,4 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

25
src/lib/upload.ts Normal file
View File

@ -0,0 +1,25 @@
export const pickFile = (options: {
accept: string[];
multiple?: boolean;
}): Promise<File[]> =>
new Promise((resolve, reject) => {
const { accept } = options;
const inputEl = document.createElement("input");
inputEl.type = "file";
inputEl.multiple = options?.multiple ?? false;
inputEl.accept = accept.join(",");
inputEl.click();
inputEl.onchange = (e) => {
const files = (e.target as HTMLInputElement).files;
const fileList = [];
if (files) {
for (const file of files) {
const customFile = file;
fileList.push(customFile);
}
resolve(fileList);
} else {
reject();
}
};
});

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

110
src/store/useModelStore.ts Normal file
View File

@ -0,0 +1,110 @@
import { create } from "zustand";
import { models } from "@/constant/models.ts";
import { Vector3 } from "three";
import { devtools } from "zustand/middleware";
import { v4 as uuidv4 } from "uuid";
export enum StickerType {
text,
logo,
}
export interface DecalSticker {
id: string;
postion: Vector3;
text?: string;
url: string;
type: StickerType;
}
interface ModelState {
modelLoading: number;
setModelLoading: (progress: number) => void;
activeModel: number;
setActiveModel: (index: number) => void;
activeArea: number;
setActiveArea: (index: number) => void;
activeTextures: number[];
setActiveTextures: (activeArea: number, activeTexture: number) => void;
decalDragging: boolean;
setDecalDragging: (enable: boolean) => void;
decals: DecalSticker[];
activeDecal?: string;
setDecalPositon: (id: string, postion: Vector3) => void;
setDecals: (decals: DecalSticker[]) => void;
setActiveDecal: (decalId: string) => void;
}
const useModelStore = create<ModelState>()(
devtools((set) => ({
modelLoading: 0,
setModelLoading: (progress: number) =>
set(() => ({ modelLoading: progress })),
activeModel: 0,
setActiveModel: (index: number) =>
set(() => ({
activeModel: index,
activeArea: 0,
activeTextures: Array(models[index].mesh.length).fill(0),
})),
activeArea: 0,
setActiveArea: (index: number) => set(() => ({ activeArea: index })),
activeTextures: Array(models[0].mesh.length).fill(0),
setActiveTextures: (activeArea: number, activeTexture: number) =>
set((state) => ({
activeTextures: state.activeTextures.length
? state.activeTextures.map((item, index) =>
index === activeArea ? activeTexture : item,
)
: Array(state.activeTextures.length).fill(0),
})),
// 是否正在拖拽sticker
decalDragging: false,
setDecalDragging: (enable: boolean) =>
set(() => ({ decalDragging: enable })),
decals: [
{
id: uuidv4(),
url: "/textures/archlogo.png",
postion: new Vector3(0, 0, 0),
type: StickerType.logo,
},
],
activeDecal: undefined,
setDecalPositon: (id: string, postion: Vector3) =>
set((state) => {
const _decals = state.decals.map((el) => {
if (el.id === id) {
return {
...el,
postion,
};
} else {
return el;
}
});
return {
decals: _decals,
};
}),
setDecals: (decals: DecalSticker[]) =>
set(() => ({
decals,
})),
setActiveDecal: (decalId: string) =>
set(() => ({
activeDecal: decalId,
})),
})),
);
export default useModelStore;

7
src/typing/three.tsx Normal file
View File

@ -0,0 +1,7 @@
import { Camera, Renderer, Scene } from "three";
export interface ThreeObj {
scene: Scene;
camera: Camera;
renderer: Renderer;
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

12
tailwind.config.js Normal file
View File

@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

40
tsconfig.json Normal file
View File

@ -0,0 +1,40 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Alias */
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"src"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

10
tsconfig.node.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

13
vite.config.ts Normal file
View File

@ -0,0 +1,13 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import {fileURLToPath, URL} from 'node:url'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url))
}
}
});