How to make 3D AI Cup Configurator on Three.js and React.js [Part 2]
12 min read
Then, according to the 2nd point from the plan above, we will implement the remaining functionality. Go to components/Configurator/Canvas2D/Actions.tsx and write logic in the click handler for each of the Actions:
.../Canvas2D/Actions.tsxtsx
const actions: CanvasAction[] = useMemo(() =>[{component: {props: {onClick: () => {if (!canvas) return// Remove all objects from fabric canvascanvas.remove(...canvas.getObjects())// Clear canvas background.setBackgroundImage(null as unknown as Image,canvas.renderAll.bind(canvas))// Patterns are set as the background color, so don't forget to clear them too.setBackgroundColor('#f2f2f2', () => {}).renderAll()},},element: Button,},icon: {props: {className: '',size: 16,},element: XIcon,},name: 'Clear canvas',},{component: {props: {onClick: () => {if (!canvas) return// Clear canvas backgroundcanvas.setBackgroundImage(null as unknown as Image,canvas.renderAll.bind(canvas)).setBackgroundColor(// The patterns are the background, so clean it up as well.'#f2f2f2',() => {}).renderAll()},},element: Button,},icon: {props: {className: '',size: 16,},element: XSquareIcon,},name: 'Remove background',},{component: {props: {onClick: () => {if (!canvas) returnconst activeObject = canvas.getActiveObject()if (!activeObject) returncanvas.sendToBack(activeObject).discardActiveObject()},},element: Button,},icon: {props: {className: '',size: 16,},element: ArrowDownFromLineIcon,},name: 'Send selection to back',},{component: {props: {onClick: () => {if (!canvas) returnconst activeObject = canvas.getActiveObject()if (!activeObject) returncanvas.bringToFront(activeObject).discardActiveObject()},},element: Button,},icon: {props: {className: 'rotate-180',size: 16,},element: ArrowDownFromLineIcon,},name: 'Bring selection to front',},{component: {props: {onClick: () => {if (!canvas) returndownloadCanvasAsImage(canvas.lowerCanvasEl)},},element: Button,},icon: {props: {className: '',size: 16,},element: FileDownIcon,},name: 'Save canvas as image',},] satisfies CanvasAction[],[canvas])
Don’t forget to check the import for downloadCanvasAsImage() from lib/utils.ts for the “Save canvas as image” action:
lib/utils.tsts
export function downloadCanvasAsImage(canvas: HTMLCanvasElement) {let downloadLink = document.createElement('a')downloadLink.setAttribute('download', 'cup-configuration.png')canvas.toBlob(function (blob) {if (!blob) returnlet url = URL.createObjectURL(blob)downloadLink.setAttribute('href', url)downloadLink.click()})}
After that try to clear the standard Canvas2D image by clicking on “Clear canvas” action and if you succeeded and the image is gone, then go to the 3rd point of the plan.
According to the 3rd point of the algorithm for creating 3D cup configurator, go to the component in the components/Cup.tsx directory and add the necessary logic for animating the appearance of the mug, display the mug itself and the texture of the print on the mug based on the information from Canvas2D. Then the component will look as follows:
tsx
// ...imports and typesexport default function Cup() {const { materials, nodes } = useGLTF(cupModelPath, true) as GLTFResultconst props = useTexture({displacementMap: `${baseTexturesPath}/displacement.jpg`,roughnessMap: `${baseTexturesPath}/roughness.jpg`,normalMap: `${baseTexturesPath}/normalDX.jpg`,map: `${baseTexturesPath}/color.jpg`,})const canvas = useStore(({ fabricCanvas }) => fabricCanvas)const texture = canvas && new CanvasTexture(canvas.lowerCanvasEl)const cupDrawAreaRef = useRef<CupDrawAreaMesh>(null)const groupRef = useRef<Group>(null)useFrame(() => {// Prepare texture settingsif (texture) {texture.flipY = falsetexture.anisotropy = 2}// Assign texture to the target materialif (cupDrawAreaRef.current) {cupDrawAreaRef.current.material.map = texture;(cupDrawAreaRef.current.material.map as Texture).needsUpdate = true}// Scale cup animationif (groupRef.current && groupRef.current.scale.x < 1) {groupRef.current.scale.x += 0.01groupRef.current.scale.y += 0.01groupRef.current.scale.z += 0.01}})return (<group {...props} dispose={null} ref={groupRef} scale={0}>// ...</group>)
Also in components/Configurator/Canvas3D.tsx I propose to remove the comment for the Sparkles component from 43 to 49 lines and thus add a little charm to the scene with the mug and then our application at this stage will look like this:
Let’s continue the implementation of 3D cup configurator and follow the 4th point of the development plan described in the first part. And let’s start with the Text panel.
Text panel implementation
First of all, let’s implement the function of adding and deleting text on Canvas2D. In the CreateDeleteTextButtons.tsx component add logic so that the component looks as follows:
tsx
'use client'import { Separator } from '@/components/ui/separator'import { Button } from '@/components/ui/button'import { useFabricCanvas } from '@/lib/hooks'import { useStore } from '@/lib/store'import { fabric } from 'fabric'export default function CreateDeleteTextButtons() {const { textSettings } = useStore(({ panels }) => ({textSettings: panels.text.data.textSettings,}))const { canvas } = useFabricCanvas()const handleCreateText = () => {if (!canvas || textSettings.text === undefined) returnconst textbox = new fabric.Textbox(textSettings.text, textSettings)// Apply some adjustmentscanvas.centerObject(textbox).setActiveObject(textbox).add(textbox)// Set focus on created textboxtextbox.setSelectionEnd(textSettings.text.length)textbox.enterEditing()}const handleRemoveText = () => {if (!canvas) returnconst selectedObject = canvas.getActiveObject()if (selectedObject instanceof fabric.Textbox) {canvas.remove(selectedObject)}}return (<><ButtonclassName="h-auto w-full px-4 py-2 text-xs 2xl:text-sm"onClick={handleCreateText}>Add Text</Button><Separator className="mx-1 h-4 2xl:mx-2 2xl:h-6" orientation="vertical" /><ButtonclassName="h-auto w-full px-4 py-2 text-xs 2xl:text-sm"onClick={handleRemoveText}>Delete Selected</Button></>)}
Let’s add the ability to change text alignment and style settings by adding logic to the ToggleAlignAndStyleTextGroups/index.tsx component:
tsx
'use client'import type { AlignValue } from '@/lib/types'import { Separator } from '@/components/ui/separator'import { useStore } from '@/lib/store'import ToggleAlignGroup from './ToggleAlignGroup'import ToggleStyleGroup from './ToggleStyleGroup'export default function ToggleAlignAndStyleTextGroups() {const { setTextSettings, textSettings } = useStore(({ setTextSettings, panels }) => ({textSettings: panels.text.data.textSettings,setTextSettings,}))const handleAlignValueChange = (value: AlignValue) => {if (!value) returnsetTextSettings({textAlign: value,})}const handleStyleValueChange = (styles: string[]) => {setTextSettings({fontStyle: styles.includes('italic') ? 'italic' : 'normal',fontWeight: styles.includes('bold') ? 'bold' : 'normal',underline: styles.includes('underline'),})}const getFontStyleValue = (): string[] => {const styles = []if (textSettings.fontWeight === 'bold') {styles.push('bold')}if (textSettings.fontStyle === 'italic') {styles.push('italic')}if (textSettings.underline) {styles.push('underline')}return styles}return (<><ToggleAlignGroupvalue={(textSettings.textAlign ?? 'left') as AlignValue}onValueChange={handleAlignValueChange}/><Separator className="mx-4 h-4 2xl:mx-6 2xl:h-6" orientation="vertical" /><ToggleStyleGrouponValueChange={handleStyleValueChange}value={getFontStyleValue()}/></>)}
Let’s add logic to FontSizeSlider.tsx:
tsx
'use client'import { Slider } from '@/components/ui/slider'import { useStore } from '@/lib/store'export default function FontSizeSlider() {const { setTextSettings, textSettings } = useStore(({ setTextSettings, panels }) => ({textSettings: panels.text.data.textSettings,setTextSettings,}))const handleFontSizeChange = (fontSizeValue: [number]) => {setTextSettings({fontSize: fontSizeValue[0],})}return (<Slidervalue={[textSettings.fontSize ?? 14]}onValueChange={handleFontSizeChange}max={100}step={1}min={1}/>)}
Also to LineHeightSlider.tsx:
tsx
'use client'import { Slider } from '@/components/ui/slider'import { useStore } from '@/lib/store'export default function LineHeightSlider() {const { setTextSettings, textSettings } = useStore(({ setTextSettings, panels }) => ({textSettings: panels.text.data.textSettings,setTextSettings,}))const handleLineHeightChange = (lineHeightValue: [number]) => {setTextSettings({lineHeight: lineHeightValue[0],})}return (<SlideronValueChange={handleLineHeightChange}value={[textSettings.lineHeight ?? 1]}step={0.1}min={0.1}max={2}/>)}
Also to FontFamilySelect.tsx:
tsx
'use client'import {SelectContent,SelectTrigger,SelectGroup,SelectValue,SelectItem,Select,} from '@/components/ui/select'import { useStore } from '@/lib/store'export default function FontFamilySelect() {const { setTextSettings, textSettings } = useStore(({ setTextSettings, panels }) => ({textSettings: panels.text.data.textSettings,setTextSettings,}))return (<SelectonValueChange={(fontFamily) => {setTextSettings({ fontFamily })}}value={textSettings.fontFamily}><SelectTrigger className="h-8 w-full 2xl:h-10"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem className="font-lato" value="Lato">Lato</SelectItem><SelectItem className="font-caveat" value="Caveat">Caveat</SelectItem><SelectItem className="font-merienda" value="Merienda">Merienda</SelectItem><SelectItem className="font-lemonada" value="Lemonada">Lemonada</SelectItem><SelectItem className="font-dancing-script" value="Dancing Script">Dancing Script</SelectItem></SelectGroup></SelectContent></Select>)}
Next, the BackgroundColorListItems.tsx component:
tsx
'use client'import { CheckIcon } from 'lucide-react'import { useStore } from '@/lib/store'import { cn } from '@/lib/utils'const fillColors = ['#ff000000','#1fbc9c','#1ca085','#2ecc70','#27af60','#3398db','#2980b9','#a463bf','#8e43ad','#3d556e','#222f3d','#f2c511','#f39c19','#e84b3c',] as constexport default function BackgroundColorListItems() {const { setTextSettings, textSettings } = useStore(({ setTextSettings, panels }) => ({textSettings: panels.text.data.textSettings,setTextSettings,}))const updateBackgroundColor = (color: string) => {setTextSettings({backgroundColor: color,})}return fillColors.map((color, index) => (<liclassName="relative h-7 w-7 shrink-0 cursor-pointer rounded-full border border-zinc-300 2xl:h-10 2xl:w-10"style={{backgroundColor: color,}}onClick={() => updateBackgroundColor(color)}key={index}><divclassName={cn(color === textSettings.backgroundColor ? 'flex' : 'hidden','h-full w-full items-center justify-center rounded-full bg-black/30','absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2')}><CheckIcon className="text-gray-200" size={16} /></div></li>))}
And the FontColorListItems.tsx component:
tsx
'use client'import { CheckIcon } from 'lucide-react'import { useStore } from '@/lib/store'import { cn } from '@/lib/utils'const fontColors = ['#ffffff','#ed120f','#e98860','#ffee58','#00e676','#1e88e5','#8e43ad','#111111',] as constexport default function FontColorListItems() {const { setTextSettings, textSettings } = useStore(({ setTextSettings, panels }) => ({textSettings: panels.text.data.textSettings,setTextSettings,}))const updateFontColor = (color: string) => {setTextSettings({ fill: color })}return fontColors.map((color, index) => (<liclassName="relative h-7 w-7 shrink-0 cursor-pointer rounded-full border border-zinc-300 2xl:h-10 2xl:w-10"style={{backgroundColor: color,}}onClick={() => updateFontColor(color)}key={index}><divclassName={cn(color === textSettings.fill ? 'flex' : 'hidden','h-full w-full items-center justify-center rounded-full bg-black/30','absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2')}><CheckIcon className="text-gray-200" size={16} /></div></li>))}
Also HexColorInput.tsx:
tsx
'use client'import { Button } from '@/components/ui/button'import { Input } from '@/components/ui/input'import { ElementRef, useRef } from 'react'import { useStore } from '@/lib/store'type Props = { name: 'backgroundColor' | 'fill' }export default function HexColorInput({ name }: Props) {const { setTextSettings, textSettings } = useStore(({ setTextSettings, panels }) => ({textSettings: panels.text.data.textSettings,setTextSettings,}))const inputRef = useRef<ElementRef<'input'>>(null)const handleClick = () => {let inputValue = inputRef.current?.valueinputValue ??= ''const inputName = nameconst validHexColorRegexp = /^#[0-9A-F]{6}[0-9a-f]{0,2}$/i// Check if user entered a valid hex stringif (validHexColorRegexp.test(inputValue)) {setTextSettings({[inputName]: inputValue,})}}return (<><InputclassName="h-8 placeholder:text-xs 2xl:h-9 2xl:placeholder:text-sm"placeholder="HEX string e.g. #ffffff"ref={inputRef}type="string"name={name}/><ButtonclassName="h-auto px-4 py-2 text-xs 2xl:text-sm"onClick={handleClick}>Apply</Button></>)}
As well as TextSettingsPanel/index.tsx:
tsx
'use client'import { useFabricCanvas } from '@/lib/hooks'import { ReactNode, useEffect } from 'react'import { useStore } from '@/lib/store'import { fabric } from 'fabric'type Props = {children: ReactNode}export default function TextSettingsPanel({ children }: Props) {const { canvas } = useFabricCanvas()const { setTextSettings, textSettings } = useStore(({ setTextSettings, panels }) => ({textSettings: panels.text.data.textSettings,setTextSettings,}))// Rerender textSettings on changeuseEffect(() => {if (!canvas) returnconst handleUpdateActiveTextbox = () => {// Get selected canvas objectconst activeObj = canvas.getActiveObject()// Check if this is fabric Textif (activeObj instanceof fabric.IText) {// Apply updates to textactiveObj.set({...textSettings,text: activeObj.text,})// Render updates on fabric canvascanvas.renderAll()}}handleUpdateActiveTextbox()}, [canvas, textSettings])// Handle canvas eventsuseEffect(() => {const handleCurrentSelection = (e: fabric.IEvent<MouseEvent>) => {// When no active objects on canvasif (!e.selected?.length) returnconst selection = e.selected[0]// Continue if selection is fabric.Textboxif (selection instanceof fabric.Textbox) {/* We don't need all the text props, so choose only* those that what we need. */const {backgroundColor,fontWeight,fontFamily,lineHeight,textAlign,underline,fontStyle,fontSize,height,width,text,fill,} = selectionsetTextSettings({backgroundColor,fontWeight,fontFamily,lineHeight,textAlign,underline,fontStyle,fontSize,height,width,text,fill,})}}canvas?.on('selection:created', handleCurrentSelection)?.on('selection:updated', handleCurrentSelection)}, [canvas, setTextSettings])return (<div className="grid h-full w-full grid-cols-2 gap-4 2xl:gap-8">{children}</div>)}
And after that, try changing all the text settings to check that all components have the expected behavior. In my case, I created the following text:
Images panel implementation
Let’s continue with the implementation and move on to writing the logic for the Images panel. Let’s start with the UploadImageButton.tsx component:
tsx
'use client'import type { ElementRef } from 'react'import { useFabricCanvas } from '@/lib/hooks'import { PaperclipIcon } from 'lucide-react'import { Card } from '@/components/ui/card'import { useEffect, useRef } from 'react'import { fabric } from 'fabric'export default function UploadImageButton() {const inputRef = useRef<ElementRef<'input'>>(null)const { canvas } = useFabricCanvas()/* Listen to the input for the image load event,* then display it on the canvas */useEffect(() => {const inputElement = inputRef.currentif (!inputElement) returnconst handleUploadedImage = (e: Event) => {const eventTarget = e.target as HTMLInputElementconst fileList = eventTarget.filesif (!fileList || fileList.length === 0) returnconst reader = new FileReader()const loadedImageFile = fileList[0]reader.onload = (e) => {const data = e.target!.resultfabric.Image.fromURL(data as string, (img) => {if (!canvas) returnconst scaleTo = canvas.width && img.width && canvas.width / img.widthconst imageObject = img.scale(scaleTo || 0.5)canvas.centerObject(imageObject).add(imageObject).renderAll()})}reader.readAsDataURL(loadedImageFile)}inputElement.addEventListener('change', handleUploadedImage)return () => inputElement.removeEventListener('change', handleUploadedImage)}, [canvas])return (<Card className="relative flex h-full w-full items-center justify-center"><inputclassName="absolute h-full w-full cursor-pointer opacity-0"ref={inputRef}type="file"/><PaperclipIcon className="h-6 w-6 2xl:h-8 2xl:w-8" /></Card>)}
And the SetImageButton.tsx component:
tsx
'use client'import ImageButton from '@/components/ImageButton'import { useFabricCanvas } from '@/lib/hooks'import { fabric } from 'fabric'export default function SetImageButton({imageUrl,index,}: {imageUrl: stringindex: number}) {const { canvas } = useFabricCanvas()const handleClick = () => {if (!canvas) returnfabric.Image.fromURL(imageUrl, (image) => {image.scale(0.5)canvas.centerObject(image)canvas.add(image)})}return <ImageButton onClick={handleClick} imageUrl={imageUrl} index={index} />}
Now let’s try loading the pre-made images onto Canvas2D and then our own and adjust the layers using the appropriate actions. This is what I got:
Wow! That looks amazing! It’s time for a brief summary of what’s been done.
Summury
In this part we continued building the configurator functionality and implemented components for the large Text panel and for the Image panel. In the final article we will create the remaining components and finish creating the application by deploying the source code on Github and Vercel.