
TECH3
CLICK, PAINT ... CONFIGURE!
06 2025-2026
In dit onderdeel duiken we dieper in de interactieve kant van onze 3D-wereld. We leren hoe we mouse events kunnen gebruiken om objecten te manipuleren, hoe we materialen en texturen realistisch toepassen, en zien we een voorbeeld van een eenvoudige configurator.

CLICK, PAINT ... CONFIGURE
MOUSE EVENTS
01



01
MOUSE EVENTS
ONCLICK
Een klikactie wordt uitgevoerd door een functie te koppelen aan het onClick-attribuut van de mesh.
export default function Experience()
{
// ...
const eventHandler = () =>
{
console.log('the event occured')
}
// ...
}In je Experience definieer je een functie voor het onClick-event.
Koppel deze functie aan het onClick-attribuut van de mesh.
<mesh ref={ cube } position-x={ 2 } scale={ 1.5 } onClick={ eventHandler } >
//...
</mesh>
01
MOUSE EVENTS
DONE
Dat was het dan. Tot de volgende les ...


01
MOUSE EVENTS
ONCLICK
Om een visueel effect bij klikken te tonen, veranderen we de kleur van het materiaal. Dankzij onze referentie naar de mesh kan dit eenvoudig.
const eventHandler = () => {
if (!cubeRef.current) return;
const mat = cubeRef.current.material as THREE.MeshStandardMaterial;
mat.color.set(`hsl(${Math.random() * 360}, 100%, 75%)`)
}Pas het kleur aan via de reference van de cube.
Dit werkt ook voor mobiele apparaten door te tappen op je object.

01
MOUSE EVENTS
EVENT INFO
Door het event-argument in je functie op te nemen, kun je alle informatie over de klik opvragen.
const eventHandler = (event: React.MouseEvent) =>
{
console.log(event)
//...
}Voeg een event parameter toe aan je functie eventHandler
De console toont nuttige gegevens, zoals de afstand, de x- en y-coördinaten, en of toetsen zoals Shift of Ctrl zijn ingedrukt...

01
MOUSE EVENTS
EVENT INFO
Overzicht van de belangrijkste elementen:
-
Distance: Afstand van camera tot het raakpunt op het object.
-
Point: 3D-coördinaten van het raakpunt op het object.
-
UV: UV-coördinaten op het oppervlak van het object bij het raakpunt.
-
Object: Het 3D-object dat geraakt is (bv. mesh).
-
eventObject: Het object dat het event registreerde.
-
event.x: X-positie van de muis.
-
event.y: Y-positie van de muis.
-
shiftKey: True als Shift ingedrukt is.
-
ctrlKey: True als Ctrl ingedrukt is.
-
metaKey: True als Command (Mac) ingedrukt is.

01
MOUSE EVENTS
OTHER EVENTS
Naast onClick zijn er nog veel andere events beschikbaar.
-
onClick: Klik met links of tap op het object.
-
onContextMenu: Klik met rechts (open contextmenu).
-
onDoubleClick: Dubbele klik of tap.
-
onPointerDown: Klik of vinger neerzetten op object.
-
onPointerUp: Klik of tap loslaten.
-
onPointerOver: Cursor of vinger boven object.
-
onPointerEnter: Zelfde als onPointerOver.
-
onPointerOut: Cursor of vinger verlaat object.
-
onPointerLeave: Zelfde als onPointerOut.
-
onPointerMove: Beweging van cursor/vinger over object.
-
onPointerMissed: Klik buiten het object.


01
MOUSE EVENTS
MISSED
Het onPointerMissed-event registreert klikken die niet op een object plaatsvinden. Dit attribuut wordt op de Canvas toegepast, zodat het kan luisteren naar alle interacties met objecten.
Voeg de onPointerMissed attribuut to aan je Canvas
<Canvas
camera={ {
fov: 45,
near: 0.1,
far: 50,
position: [ - 4, 3, 6 ]
} }
onPointerMissed={ () => { console.log('You missed!') } }
>
<Experience />
</Canvas>Heeft een bepaald object geen klikevent dan zal deze ook bij de missed toebehoren.

01
MOUSE EVENTS
OCCLUDING
Wanneer we de scene draaien en de sphere voor de cube plaatsen, blijft een klik doorwerken. Dit ongewenste gedrag kan worden voorkomen met de stopPropagation-methode.
Voeg een onClick toe aan de sphere en call de stopPropagation method
<mesh position-x={ - 2 } onClick={ (event) => event.stopPropagation() }>
{/* ... */}
</mesh>
01
MOUSE EVENTS
CURSOR
Wanneer een object interactief is, is het belangrijk om dit visueel aan te geven. Hiervoor kunnen de events onPointerEnter en onPointerLeave worden gebruikt.
Voeg deze events toe aan je cube.
<mesh
ref={ cube }
position-x={ 2 }
scale={ 1.5 }
onClick={ eventHandler }
onPointerEnter={ () => { document.body.style.cursor = 'pointer' } }
onPointerLeave={ () => { document.body.style.cursor = 'default' } }
>Vergeet niet om je andere objecten buiten de calculation te halen.
<mesh
position-x={ - 2 }
onClick={ (event) => event.stopPropagation() }
onPointerEnter={ (event) => event.stopPropagation() }
>
01
MOUSE EVENTS
COMPLEX
Bij een object dat uit verschillende onderdelen bestaat, kan een klik-event onverwachte resultaten geven. We gaan dit testen en onderzoeken hoe we dit kunnen aanpakken.
Voeg een complex model toe via de useGLTF hook van Drei.
export default function Experience()
{
// ...
const christmasGlobe = useGLTF('./christmas_ball.glb')
return <>
{/* ... */}
<primitive
object={ christmasGlobe.scene }
scale={ 0.025 }
position-y={ -1 }
/>
</>
}
01
MOUSE EVENTS
COMPLEX
Voeg een onClick-event toe aan de primitive en test het door op het object te klikken.
<primitive
object={ hamburger.scene }
scale={ 0.25 }
position-y={ 0.5 }
onClick={ (event) =>
{
console.log('click')
} }
/>Geef het event-argument mee in je onClick-functie.
<primitive
object={ hamburger.scene }
scale={ 0.25 }
position-y={ 0.5 }
onClick={ (event) =>
{
console.log(event.object)
} }
/>
01
MOUSE EVENTS
STOP
Door stopPropagation toe te passen, zal slechts het eerst aangeraakte object worden geregistreerd. De ray passeert alle objecten, maar alleen het eerste wordt in de console weergegeven.
<primitive
object={ hamburger.scene }
scale={ 0.25 }
position-y={ 0.5 }
onClick={ (event) =>
{
console.log(event.object)
event.stopPropagation()
} }
/>
01
MOUSE EVENTS
EVENTS
De uitgevoerde events zijn CPU-intensief, dus optimalisatie is belangrijk. Gelukkig biedt Drei een helper om dit te vergemakkelijken.
import { meshBounds } from '@react-three/drei'
<mesh
ref={ cube }
raycast={ meshBounds }
position-x={ 2 }
scale={ 1.5 }
onClick={ eventHandler }
onPointerEnter={ () => { document.body.style.cursor = 'pointer' } }
onPointerLeave={ () => { document.body.style.cursor = 'default' } }
>
{/* ... */}
</mesh>Importeer meshBounds en geef het met het 'raycast' attribuut mee in de cube.
Let op: dit werkt alleen voor objecten die uit één geheel bestaan; bij complexe objecten functioneert het niet.

01
MOUSE EVENTS
BVH
Bij complexe geometrieën biedt Drei de Bvh helper (Bounding Volume Hierarchy). Deze helper genereert voor elke mesh bounds om interacties te versnellen, MAAR kan een korte freeze veroorzaken bij het starten van de experience.
import { Bvh } from '@react-three/drei'
<Canvas
camera={ {
fov: 45,
near: 0.1,
far: 200,
position: [ - 4, 3, 6 ]
} }
onPointerMissed={ () => { console.log('You missed!') } }
>
<Bvh>
<Experience />
</Bvh>
</Canvas>Importeer Bvh van drei en wrap onze experience hier rond.

01
MOUSE EVENTS
BEWARE!
Bepaalde mouse-events zijn CPU-intensief omdat ze op elk frame worden gecontroleerd. Gebruik deze daarom spaarzaam om performanceproblemen te voorkomen.
De mouse events in kwestie zijn:
- onPointerOver
- onPointerEnter
- onPointerOut
- onPointerLeave
- onPointerMove
CLICK, PAINT ... CONFIGURE
TEXTURES
02





02
TEXTURES
WHAT?
Een texture is een afbeelding die op een 3D-oppervlak wordt toegepast om details aan een object toe te voegen. Zie het als verf of een foto die om een geometrie wordt gewrapt. Bij textures gebruiken we verschillende PBR-maps.
-
Color / Diffuse Map: Basisafbeelding die de kleur van het materiaal bepaalt.
-
Normal Map: Simuleert kleine oneffenheden voor realistisch licht- en schaduweffect.
-
Roughness Map: Regelt de mate van glans of matte uitstraling van het oppervlak.
-
Displacement Map: Past geometrie aan voor echte hoogteverschillen; extra polygonen nodig.
-
Ambient Occlusion Map: Voegt diepe schaduwen toe in hoeken en kieren.
-
...




02
TEXTURES
USETEXTURE
Met de useTexture-hook van Drei kunnen we textures eenvoudig importeren en direct op een mesh toepassen, zonder extra configuratie.
Drei is een godsgeschenk...
Zoek een online texture, importeer deze via de useTexture-hook van Drei en wijs hem toe aan het materiaal van je mesh.
import { useTexture } from '@react-three/drei'
const { colorMap, normalMap, aoMap } = useTexture({
colorMap: './textures/brick/brick_color.png',
normalMap: './textures/brick/brick_normal.png',
aoMap: './textures/brick/brick_ao.png',
})
return (
<>
{/* Sphere with multiple maps */}
<mesh>
<sphereGeometry/>
<meshStandardMaterial
color={color || "white"}
map={colorMap} // base color
normalMap={normalMap} // bumps
aoMap={aoMap} // ambient occlusion
aoMapIntensity={1} // adjust strength
/>
</mesh>
</>
)


02
TEXTURES
WRAP - REPEAT
Soms kan een texture uitrekken of verkeerd uitgelijnd zijn. Dit kun je controleren en corrigeren met wrapS en wrapT, waarvoor de originele THREE.js-library nodig is.
Pas je texture aan met WrapS en WrapT zodat de stretch weg is.
const texture = useTexture('./textures/brick/brick_color.png')
texture.wrapS = texture.wrapT = THREE.RepeatWrapping
texture.repeat.set(3, 2)


02
TEXTURES
FILTERS
Filtering bepaalt hoe een texture wordt gesampled bij het vergroten of verkleinen. Zonder filtering kan de texture wazig of pixelachtig lijken.
| Type | Wanneer? | Opties |
|---|---|---|
| Minification Filter (minFilter) | Wanneer de texture verkleind is (Ver weg of klein op het scherm) | LinearFilter (default, smooth) / NearestFilter (pixelated) |
| Magnification Filter (magFilter) | Wanneer de texture vergroot is (te dicht bij camera) | LinearFilter (default, smooth) / NearestFilter (pixelated) |
Meest gebruikte filters:
-
THREE.LinearFilter: Smooth interpolatie (standaard).
-
THREE.NearestFilter: Scherpe, blokkerige weergave.
-
THREE.LinearMipMapLinearFilter: Smooth interpolatie met mipmaps.



02
TEXTURES
MULTIPLE
De hook ondersteunt meerdere textures tegelijk. Deze worden als array geretourneerd, waardoor je ze eenvoudig kunt destructuren en gebruiken.
Voeg volgende textures toe aan je hook en geef deze mee aan de material.
const [colorMap, normalMap, aoMap ] = useTexture([
'./textures/brick/brick_color.png',
'./textures/brick/brick_normal.png',
'./textures/brick/brick_ao.png',
])
<meshStandardMaterial
map={colorMap}
normalMap={normalMap}
aoMap={aoMap}
/>


02
TEXTURES
MAPS
Voor een StandardMaterial zijn de volgende texture maps beschikbaar:
| map | function |
|---|---|
| map | base color |
| normalMap | bumps |
| aoMap | ambient shadow |
| roughnessMap | rough/smooth areas |
| metalnessMap | metallic parts |
| displacementMap | geometry bumps (effectief) |



02
TEXTURES
DYNAMIC
Het is mogelijk om textures dynamisch te wijzigen, wat handig is voor bijvoorbeeld een configurator. Hiervoor gebruiken we een useState om het huidige materiaal op te slaan en te updaten. (Later via store)
Maak een switch bij de onClick zodat hij van material verandert.
import { useState } from 'react'
import { SRGBColorSpace } from "three"
const brick = useTexture('./textures/brick/brick_color.png')
const wood = useTexture('./textures/wood/wood_color.png')
const [current, setCurrent] = useState(brick)
brick.colorSpace = SRGBColorSpace;
wood.colorSpace = SRGBColorSpace;
return (
<>
<mesh onClick={() => setCurrent(current === brick ? wood : brick)}>
<boxGeometry />
<meshStandardMaterial color={color || "white"} map={current} />
</mesh>
<OrbitControls />
</>
)


02
TEXTURES
IMPORTANT
Bij het werken met textures zijn er een aantal cruciale aandachtspunten waar je rekening mee moet houden.
- De afbeelding moet in machten van 2 zijn (bijv. 512×512, 1024×1024) voor optimale performance.
- Wanneer objecten verwijderd worden, moet de texture worden vrijgegeven met de
.dispose()-methode. - Zorg dat de belichting correct is, aangezien het StandardMaterial hier invloed op heeft.
if (meshRef.current) {
const mesh = meshRef.current;
// Dispose geometry
mesh.geometry.dispose();
// Dispose all materials (in case it's an array)
if (Array.isArray(mesh.material)) {
mesh.material.forEach((m) => m.dispose());
} else {
mesh.material.dispose();
}
}
//of als je object een R3F component is kan je volgende uitvoeren
{isVisible && <mesh ... />}
useEffect(() => {
return () => {
meshRef.current?.geometry.dispose();
(meshRef.current?.material as any)?.dispose();
};
}, []);


02
TEXTURES
.GLB
GLB-bestanden exporteert Blender vaak met alle textures ingebed. Deze bestanden kunnen eenvoudig worden geladen met de useGLTF-hook, zonder aparte texture-import.
Variant 1: Als je image-based textures zoals PNG’s of JPG’s in Blender gebruikt en deze koppelt aan base color, normal, enz., worden deze ingesloten in het GLB-bestand.
Variant 2: Procedural of node-based materialen (zoals Noise, Voronoi, etc.) kunnen niet rechtstreeks naar GLB worden geëxporteerd. Meestal verschijnen deze als grijs, omdat de data niet wordt meegenomen. Om dit op te lossen, moeten de textures worden gebakken (baken). -> Zie online tutorials
CLICK, PAINT ... CONFIGURE
EXPORT BLENDER
03





03
EXPORT BLENDER
HOW
Als je een model in Blender hebt ontworpen, kun je dit exporteren naar GLB voor gebruik in 3D-applicaties. We laten nu zien hoe dit werkt.
Selecteer je model in kwestie en ga naar File -> Export -> GLTF 2.0 (.glb/.gltf)




03
EXPORT BLENDER
SETTINGS
In het volgende scherm zie je de exportopties. Aan de rechterzijde kun je verschillende instellingen aanpassen; vaak zijn de standaardwaarden voldoende. Plaats volgende aan







03
EXPORT BLENDER
NAME IT
Geef het bestand een naam en sla het op op de gewenste locatie. Het object is nu geëxporteerd. Test het vervolgens in een scene of upload het naar gltf.pmnd.rs voor preview.

CLICK, PAINT ... CONFIGURE
ZUSTAND
04



04
ZUSTAND
FRESH
We maken een nieuw project aan en hervatten ons werk aan de clicker-applicatie ... OH NO
We creëren eerst onze Counter-component en geven deze een stijl, zonder functionaliteit. Plaats de volgende code in Counter.tsx.
import "./counter.css";
const Counter = () => {
return (
<div className="counter-card">
<h1 className="counter-title">🧮 Counter ... again</h1>
<p className="counter-value">count placeholder</p>
<div className="button-group">
<button className="btn minus">
–
</button>
<button className="btn reset">
Reset
</button>
<button className="btn plus">
+
</button>
</div>
</div>
);
}
export default Counter;
04
ZUSTAND
FRESH
Voeg de styling toe in counter.css en pas deze eventueel ook toe in index.css.
counter.css
/* Neumorfische, moderne stijl met zachte schaduwen en ronde vormen */
.counter-card {
background: #f2f5f9;
box-shadow: 8px 8px 16px #d0d4da, -8px -8px 16px #ffffff;
border-radius: 24px;
padding: 40px;
text-align: center;
width: 300px;
transition: all 0.3s ease;
}
.counter-title {
font-size: 1.6rem;
font-weight: bold;
color: #333;
margin-bottom: 16px;
}
.counter-value {
font-size: 3.5rem;
font-weight: bold;
color: #2d6cdf;
margin: 20px 0;
text-shadow: 2px 2px 6px rgba(0, 0, 0, 0.1);
}
.button-group {
display: flex;
justify-content: space-around;
align-items: center;
gap: 12px;
}
.btn {
border: none;
outline: none;
border-radius: 16px;
padding: 12px 18px;
font-size: 1rem;
cursor: pointer;
color: white;
font-weight: bold;
transition: all 0.25s ease;
box-shadow: 3px 3px 6px rgba(0, 0, 0, 0.15);
}
.btn.plus {
background: linear-gradient(135deg, #3b82f6, #2563eb);
}
.btn.minus {
background: linear-gradient(135deg, #ef4444, #dc2626);
}
.btn.reset {
background: linear-gradient(135deg, #6b7280, #4b5563);
}
.btn:hover {
transform: scale(1.07);
box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.2);
}index.css
.app-container {
display: flex;
justify-content: center;
align-items: center;
background-color: #e9ecef;
height: 100vh;
}
04
ZUSTAND
STORE
Om de counter-variabele en bijbehorende functies te delen tussen de Counter-component en andere onderdelen van het project, gebruiken we Zustand. Deze library maakt globale state management mogelijk via een eenvoudige custom hook.
Maak een store aan genaamd counterStore in ./store/counterStore.ts en plaats er volgende vode in.
// Dit bestand bevat de Zustand store, die de gegevens (state) beheert.
import { create } from "zustand";
// Define the shape of our store
interface CounterStore {
count: number;
increase: () => void;
decrease: () => void;
reset: () => void;
}
// We maken een store met een tellerwaarde en twee functies om die waarde te wijzigen.
const useCounterStore = create<CounterStore>((set) => ({
count: 0,
increase: () => {
set((state) => ({ count: state.count + 1 }));
},
decrease: () => {
set((state) => ({ count: state.count - 1 }));
},
reset: () => set({ count: 0 }),
}));
export default useCounterStore;
Installeer eerst Zustand met volgende commando.
npm install zustand
04
ZUSTAND
STORE
Met de store actief kunnen we deze nu aanspreken binnen de Counter-component. Zo krijgen we toegang tot zowel de variabelen als de functies die in de store zijn gedefinieerd.
Haal de nodige zaken op uit de store en gebruik deze in de counter.
import useCounterStore from "../../store/counterStore";
import "./counter.css";
const Counter = () => {
const { count, increase, decrease , reset} = useCounterStore();
return (
<div className="counter-card">
<h1 className="counter-title">🧮 Counter ... again</h1>
<p className="counter-value">{count}</p>
<div className="button-group">
<button className="btn minus" onClick={decrease}>
–
</button>
<button className="btn reset" onClick={reset}>
Reset
</button>
<button className="btn plus" onClick={increase}>
+
</button>
</div>
</div>
);
}
export default Counter;
04
ZUSTAND
STORE
Test de counter om te zien of alles correct werkt. Vergelijk dit met de manier waarop we het eerder moesten doen zonder store. Dankzij de store zijn alle functies en variabelen nu overzichtelijk samengebracht, wat het beheer en onderhoud aanzienlijk vereenvoudigt.

CLICK, PAINT ... CONFIGURE
CUBE OF MAGIC!
05








LET'S GET TO IT
We gaan nu onze eigen configurator bouwen met als thema “Wizard Cube”. Het is geen realistisch voorbeeld, maar het behandelt wel alle belangrijke onderdelen.
We starten met een cube in onze Canvas. Maar wat hebben we precies nodig om een configurator te bouwen?

05
CUBE OF MAGIC!
Om een configurator te bouwen, hebben we o.a. het volgende nodig:
-
Een Canvas
-
Een UI voor interactie
-
Een Store om de state te beheren
-
GSAP voor vloeiende animaties
-
En voor jullie: een strakke website die het product promoot






LET'S GET TO IT
De starterfile bevat een eenvoudige cube. We starten met het ontwerpen en stylen van de UI, zodat we meteen een visuele representatie van onze configurator hebben.
Maak een overlay component aan genaamd Spellbook.tsx en plaats er volgende elementen in. (./ui/Spellbook/Spellbook.tsx)

05
CUBE OF MAGIC!
import React from "react";
import "./spellbook.css";
const directions = ["front", "back", "left", "right"];
const colors = ["#ffffff","#ff0000", "#00ff00", "#0000ff", "#5f5f5f"];
const materials = ["standard", "wood", "brick"];
const sizePresets = [
{ name: "Tiny Tot", width: 0.3, height: 0.3 },
{ name: "Average Adventurer", width: 1, height: 1 },
{ name: "Big Boi", width: 2, height: 2 },
{ name: "Sky Tower", width: 1, height: 3.5 },
{ name: "Flat Pancake", width: 2, height: 0.1 },
];
const Spellbook: React.FC = () => {
return (
<div className="ui-panel">
<h3 className="ui-title">🪄 Enchant Your Magical Cube!</h3>
<p className="ui-description">
Cast some spells below to transform your mystical 3D cube!
</p>
{/* Camera Controls */}
<section className="ui-section">
<h4 className="ui-subtitle">🔮 Look at it from...</h4>
<div className="ui-buttons">
{directions.map((v) => (
<button
key={v}
className={`ui-btn`}
>
{v === "front"
? "👁 Front"
: v === "back"
? "🍑 Back"
: v === "left"
? "⬅ Left"
: "➡ Right"}
</button>
))}
</div>
</section>
{/* Color Picker */}
<section className="ui-section">
<h4 className="ui-subtitle">🎨 Pick Your Potion Color</h4>
<div className="ui-color-group">
{colors.map((c) => (
<button
key={c}
className={`ui-color `}
style={{ ["--potion-color" as string]: c }}
/>
))}
</div>
</section>
{/* Material Picker */}
<section className="ui-section">
<h4 className="ui-subtitle">⚗️ Choose Material Magic</h4>
<div className="ui-buttons">
{materials.map((m) => (
<button
key={m}
className={`ui-btn`}
>
{m === "standard"
? "Normalis"
: m === "wood"
? "Lignifero"
: "Latericio"}
</button>
))}
</div>
</section>
{/* Size Presets */}
<section className="ui-section">
<h4 className="ui-subtitle">📏 Resize Spell</h4>
<div className="ui-buttons">
{sizePresets.map((preset) => (
<button
key={preset.name}
className={`ui-btn`}>
{preset.name}
</button>
))}
</div>
</section>
<p style={{ fontSize: "0.8rem", color: "rgba(97, 97, 97, 0.7)" }}>
🧙♂️ Tip: Click “Back” if your cube decides to run away!
</p>
</div>
);
};
export default Spellbook;






CSS
Voeg vervolgens deze CSS file genaamd spellbook.css

05
CUBE OF MAGIC!
/* --- Panel Layout --- */
.ui-panel {
width: 30%;
padding: 1.5rem;
background: rgba(255, 255, 255, 0.301); /* translucent glass base */
border-radius: 1rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.3);
font-family: "Inter", system-ui, sans-serif;
color: #2e2e2e;
transition: all 0.3s ease;
}
/* --- Typography --- */
.ui-title {
font-size: 1.2rem;
font-weight: 600;
text-align: center;
margin-bottom: 0.5rem;
color: #3d3d3d;
}
.ui-subtitle {
font-size: 0.9rem;
font-weight: 500;
color: rgba(73, 73, 73, 0.8);
}
/* --- Sections --- */
.ui-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.ui-description {
font-size: 0.9rem;
margin-bottom: 2rem;
color: rgba(85, 85, 85, 0.8);
text-align: center;
}
/* --- Buttons --- */
.ui-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.ui-btn {
flex: 1;
padding: 0.5rem 0.8rem;
font-size: 0.85rem;
border-radius: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.1);
color: #0a0a0a;
cursor: pointer;
transition: all 0.25s ease;
backdrop-filter: blur(8px);
}
.ui-btn:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateY(-1px);
}
.ui-btn.active {
background: rgba(0, 123, 255, 0.5);
border-color: rgba(0, 123, 255, 0.6);
color: #fff;
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.4);
}
/* --- Color Picker --- */
.ui-color-group {
display: flex;
gap: 0.8rem;
align-items: flex-end;
}
/* Potion bottle style */
.ui-color {
position: relative;
width: 32px;
height: 48px;
background: transparent;
border: none;
cursor: pointer;
outline: none;
box-shadow: none;
transition: transform 0.2s, box-shadow 0.2s;
display: flex;
align-items: flex-end;
justify-content: center;
}
.ui-color::before {
/* Bottle neck */
content: "";
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 12px;
height: 10px;
background: #e2c16b;
border-radius: 4px 4px 6px 6px;
box-shadow: 0 2px 4px rgba(0,0,0,0.08);
z-index: 2;
}
.ui-color::after {
/* Bottle base (liquid) */
content: "";
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 28px;
height: 36px;
border-radius: 0 0 16px 16px / 0 0 24px 24px;
background: var(--potion-color, #fff);
box-shadow:
0 2px 8px rgba(0,0,0,0.18),
0 0 12px 2px var(--potion-color, #fff);
z-index: 1;
border: 2px solid rgba(255,255,255,0.5);
opacity: 0.95;
}
/* Cork */
.ui-color .potion-cork {
position: absolute;
top: -6px;
left: 50%;
transform: translateX(-50%);
width: 10px;
height: 7px;
background: #a67c52;
border-radius: 3px 3px 5px 5px;
box-shadow: 0 1px 2px rgba(0,0,0,0.12);
z-index: 3;
}
/* Liquid color via inline style */
.ui-color[style]::after {
background: var(--potion-color, #fff);
box-shadow:
0 2px 8px rgba(0,0,0,0.18),
0 0 6px 2px var(--potion-color, #fff);
}
/* Magical glow for active */
.ui-color.active::after {
box-shadow:
0 0 10px 6px var(--potion-color, #fff),
0 2px 8px rgba(0,0,0,0.18);
border-color: #fff;
}
.ui-color:hover {
transform: scale(1.12) rotate(-4deg);
}
.ui-color:active {
transform: scale(0.98) rotate(2deg);
}
/* Hide default background */
.ui-color {
background: none !important;
border: none !important;
}






CSS
In onze index geven we ze beide een width en gebruiken we flexbox

05
CUBE OF MAGIC!
/*
RESET CSS KOMT HIER
*/
html,
body,
#root
{
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: linear-gradient(135deg, #f6d365 0%, #fda085 100%);
}
#app-root{
display: flex;
width: 100%;
height: 100%;
position: relative;
}
#canvas-wrap{
width: 70%;
height: 100%;
pointer-events: none;
}
#ui{
width: 30%;
height: 100%;
pointer-events: all;
}






MAIN
Voeg de UI toe aan main.tsx.
⚠️ Let op: deze mag niet binnen de Canvas geplaatst worden. HTML-elementen worden niet rechtstreeks door de Canvas geaccepteerd, behalve wanneer je gebruikmaakt van Drei-componenten zoals HTML of Text.

05
CUBE OF MAGIC!
<div id="app-root">
<div id="canvas-wrap">
<Canvas shadows camera={cameraSettings}>
<Experience />
</Canvas>
</div>
<Spellbook />
</div>





SPELLBOOK
Op ons configuratiepaneel zien we verschillende opties: we kunnen de camera draaien, kleuren en materialen aanpassen en de grootte wijzigen. Om deze states te beheren, gebruiken we Zustand, wat het proces aanzienlijk vereenvoudigt. We beginnen met het configureren van de camera.

05
CUBE OF MAGIC!
import { create } from "zustand";
export type CameraView = "front" | "back" | "left" | "right";
interface CameraStore {
view: CameraView;
setView: (newView: CameraView) => void;
}
const useCameraStore = create<CameraStore>((set) => ({
view: "front",
setView: (newView: CameraView) => set({ view: newView }),
}));
export default useCameraStore;Maak een store aan genaamd cameraStore.ts waar onze camera state in zal komen.






SPELLBOOK
Nu we beschikken over een view en setView (vergelijkbaar met useState), kunnen we deze overal binnen het project gebruiken.

05
CUBE OF MAGIC!
npm install gsapGebruik de store in Experience.js om de camera aan te passen. Voor vloeiende animaties maken we gebruik van GSAP. Klik op de knoppen om het effect te bekijken.
const { camera } = useThree();
const view = useCameraStore((state) => state.view);
const radius = 5;
// Define angles for each view (like a compass)
const angles: Record<CameraView, number> = {
front: 0,
right: Math.PI / 2,
back: Math.PI,
left: -Math.PI / 2,
};
useEffect(() => {
const currentAngle = Math.atan2(camera.position.x, camera.position.z); //GPT'd this -> Goed in wiskunde maar niet ZO goed
const targetAngle = angles[view];
const obj = { t: currentAngle };
gsap.to(obj, {
t: targetAngle,
duration: 1,
ease: "power2.inOut",
onUpdate: () => {
const x = radius * Math.sin(obj.t);
const z = radius * Math.cos(obj.t);
camera.position.set(x, 1, z);
camera.lookAt(0, 0, 0); // Kijken naar het centrum van de scene.
},
});
}, [view, camera]);





SPELLBOOK
Het effect verschijnt niet omdat de store-state niet wordt geactiveerd. Om dit te laten werken, moeten we de state aanroepen binnen het onClick-event van de button.

05
CUBE OF MAGIC!
We halen setView op uit de store en roepen deze aan binnen de onClick. Tegelijkertijd voegen we een active-ternary toe aan de className van de button.
const setView = useCameraStore((state) => state.setView);
const view = useCameraStore((state) => state.view);
<button
key={v}
className={`ui-btn ${view === v ? "active" : ""}`}
onClick={() => setView(v as CameraView)} // as CameraView -> typescript
>Yay! onze buttons werken... ideaal.






POTIONS, ...
Het volgende dat we aanpassen, zijn de kleuren. Omdat we nu eigenschappen van ons object wijzigen, plaatsen we deze in een aparte store voor separation of concerns.

05
CUBE OF MAGIC!
Maak een store aan genaamd propertyStore.ts en plaats er volgende code in.
import { create } from "zustand";
export type CubeMaterial = "standard" | "wood" | "brick";
// Typescript
interface PropertiesStore {
color: string;
setColor: (color: string) => void;
material: CubeMaterial;
setMaterial: (material: CubeMaterial) => void;
width: number;
setWidth: (width: number) => void;
height: number;
setHeight: (height: number) => void;
}
// Store zelf
const usePropertyStore = create<PropertiesStore>((set) => ({
color: "#ffffff",
setColor: (color: string) => set({ color }),
material: "standard",
setMaterial: (material: CubeMaterial) => set({ material }),
width: 1,
setWidth: (width: number) => set({ width }),
height: 1,
setHeight: (height: number) => set({ height }),
}));
export default usePropertyStore;





POTIONS, ...
We kunnen nu de properties uit de store ophalen. Zorg ervoor dat dit in de component zelf gebeurt en niet allemaal in Experience.tsx. Zo blijft het overzichtelijk en houden we Separation of Concerns in stand.

05
CUBE OF MAGIC!
Voeg volgende code in je Box component.
import * as THREE from 'three';
import gsap from "gsap";
import usePropertyStore from "../../store/propertyStore";
//...
const meshRef = useRef<THREE.Mesh>(null!);
const color = usePropertyStore((state) => state.color);
const currentColor = useRef(new THREE.Color(color));
// gsap tween
useEffect(() => {
if (!meshRef.current) return;
const newColor = new THREE.Color(color);
gsap.to(currentColor.current, {
r: newColor.r,
g: newColor.g,
b: newColor.b,
duration: 0.8,
onUpdate: () => {
if (meshRef.current) {
(meshRef.current.material as THREE.MeshStandardMaterial).color.setRGB(
currentColor.current.r,
currentColor.current.g,
currentColor.current.b
);
}
},
});
}, [color]);
return (
<mesh
ref={meshRef}
{...props}
>
<boxGeometry />
<meshStandardMaterial />
</mesh>
);
Nu past de kleur ook mooi vloeiend aan. Alleen nog twee dingen te doen!






POTIONS, ...
Vergeet niet in je UI nog de functie en variabele op te halen uit je propertyStore en te gebruiken anders zal het niet werken... Dit doen we nu altijd.

05
CUBE OF MAGIC!
Voeg volgende code in je UI component.
const {
color,
setColor,
material,
setMaterial,
width,
setWidth,
height,
setHeight,
} = usePropertyStore();





WIDTH | HEIGHT
Voor het aanpassen van width en height gebruiken we een vergelijkbare aanpak als bij de kleur. Omdat de code hierdoor wat rommelig begint te worden, is het tijd om een custom hook te maken.

05
CUBE OF MAGIC!
Maak een custom hook in hooks/useBoxTween.tsx en plaats er volgende code in.
import { useRef, useEffect } from "react";
import { Mesh, Color, MeshStandardMaterial } from "three";
import { gsap } from "gsap";
interface BoxAnimationProps {
color: string;
width: number;
height: number;
}
export const useBoxTween = (meshRef: React.RefObject<Mesh>, { color, width, height }: BoxAnimationProps) => {
const currentColor = useRef(new Color(color));
// Color tween
useEffect(() => {
if (!meshRef.current) return;
const newColor = new Color(color);
gsap.to(currentColor.current, {
r: newColor.r,
g: newColor.g,
b: newColor.b,
duration: 0.8,
onUpdate: () => {
if (meshRef.current) {
(meshRef.current.material as MeshStandardMaterial).color.setRGB(
currentColor.current.r,
currentColor.current.g,
currentColor.current.b
);
}
},
});
}, [color, meshRef]);
// Scale tween
useEffect(() => {
if (!meshRef.current) return;
gsap.to(meshRef.current.scale, {
x: width,
y: height,
z: width,
duration: 0.8,
ease: "power2.out",
});
}, [width, height, meshRef]);
};Onze hook verwacht onder andere een ref, kleur, width en height. Dit kan eventueel opgesplitst worden in twee hooks: één voor kleur en één voor schaal.






HOOK IT
De hook kan nu worden aangeroepen in de Box-component, waardoor de code overzichtelijk en schoon blijft. Goed gedaan!

05
CUBE OF MAGIC!
Maak gebruik van de custom Hook in Box.tsx
const Box: React.FC<BoxProps> = ({...props}: BoxProps) => {
const meshRef = useRef<THREE.Mesh>(null!);
const color = usePropertyStore((state) => state.color);
const width = usePropertyStore((state) => state.width);
const height = usePropertyStore((state) => state.height);
useBoxTween(meshRef, { color, width, height });
return (
<mesh
ref={meshRef}
visible
{...props}
>
<boxGeometry />
<meshStandardMaterial color={color} />
</mesh>
);
};
export default Box;





UI
Vergeet niet de onClick van elke knop en de className in de UI aan te passen. Zonder deze stappen functioneert de interface niet correct.

05
CUBE OF MAGIC!
Pas volgende aan in Spellbook.tsx
const {
color,
setColor,
material,
setMaterial,
width,
setWidth,
height,
setHeight,
} = usePropertyStore();
const handlePresetClick = (presetWidth: number, presetHeight: number) => {
setWidth(presetWidth);
setHeight(presetHeight);
};
//...
{colors.map((c) => (
<button
key={c}
onClick={() => setColor(c)}
className={`ui-color ${color === c ? "active" : ""}`}
style={{ backgroundColor: c }}
/>
))}
//...
{sizePresets.map((preset) => (
<button
key={preset.name}
className={`ui-btn ${
width === preset.width && height === preset.height ? "active" : ""
}`}
onClick={() => handlePresetClick(preset.width, preset.height)}
>
{preset.name}
</button>
))}






MATERIALS
Hetzelfde principe kan nu worden toegepast op materials. Hierbij lopen we echter tegen enkele uitdagingen aan: we moeten de texture paths doorgeven en tegelijkertijd ervoor zorgen dat de kleur van het materiaal niet wordt beïnvloed.

05
CUBE OF MAGIC!
Maak een object genaamd materialTextures aan en plaats hierin alle benodigde paden naar textures.
const materialTextures = {
standard: {}, // No textures for standard material
wood: {
map: "./textures/wood/wood_color.png",
roughnessMap: "./textures/wood/wood_rough.png",
normalMap: "./textures/wood/wood_normal.png",
},
brick: {
map: "./textures/brick/brick_color.png",
normalMap: "./textures/brick/brick_normal.png",
roughnessMap: "./textures/brick/brick_rough.png",
aoMap: "./textures/brick/brick_ao.png",
},
};





MATERIALS

05
CUBE OF MAGIC!
We moeten useTextures gebruiken en door de manier hoe we onze textures hebben defined kunnen we dit makkelijk gebruiken en spreaden op onze material. Zo hebben we ook geen problemen als er een bepaalde texture 'missing' is.
const valid = material && materialTextures[material]
const textures = useTexture(valid ? materialTextures[material] : {})
//...
return (
<mesh
ref={meshRef}
{...props}
>
<boxGeometry />
<meshStandardMaterial
key={material}
color={color}
{...textures} // empty {} -> safe
aoMapIntensity={1}
/>
</mesh>
);





MATERIALS
Voordat we het materiaal gebruiken, zetten we de color map om naar sRGB. Zo wordt de kleur niet zomaar met de texture vermenigvuldigd. Ook moeten we de textures laten herhalen, anders worden ze uitgerekt.

05
CUBE OF MAGIC!
Loop door alle textures en stel voor elke map de color space in en activeer texture repeating.
// srgb color space apply to all maps
if (textures) {
Object.entries(textures).forEach(([key, tex]) => {
if (tex && tex instanceof THREE.Texture) {
// ONLY baseColor map in SRGB
if (key === "map") {
tex.colorSpace = THREE.SRGBColorSpace
}
// these are fine to apply to all
tex.wrapS = tex.wrapT = THREE.RepeatWrapping
tex.repeat.set(width, height)
}
})
}





FULL BOX
Als alles goed is dan heb je volgende code van het 'model', in ons geval een box.

05
CUBE OF MAGIC!
import React, {useRef } from "react"
import * as THREE from 'three';
import usePropertyStore from "../../store/propertyStore";
import { useBoxTween } from "../../hooks/useBoxTween";
import { useTexture } from "@react-three/drei";
interface BoxProps {
position?: [number, number, number] | number
scale?: [number, number, number] | number
rotation?: [number, number, number] | number
visible?: boolean
ref?: React.Ref<THREE.Mesh>
receiveShadow?: boolean
castShadow?: boolean
onClick?: (e: React.MouseEvent) => void
}
const materialTextures = {
standard: {}, // No textures for standard material
wood: {
map: "./textures/wood/wood_color.png",
roughnessMap: "./textures/wood/wood_rough.png",
normalMap: "./textures/wood/wood_normal.png",
},
brick: {
map: "./textures/brick/brick_color.png",
normalMap: "./textures/brick/brick_normal.png",
roughnessMap: "./textures/brick/brick_rough.png",
aoMap: "./textures/brick/brick_ao.png",
},
};
const Box: React.FC<BoxProps> = ({ ...props }: BoxProps) => {
const meshRef = useRef<THREE.Mesh>(null!);
const color = usePropertyStore((state) => state.color);
const width = usePropertyStore((state) => state.width);
const height = usePropertyStore((state) => state.height);
const material = usePropertyStore((state) => state.material);
const valid = material && materialTextures[material]
const textures = useTexture(valid ? materialTextures[material] : {})
// srgb color space apply to all maps
if (textures) {
Object.entries(textures).forEach(([key, tex]) => {
if (tex && tex instanceof THREE.Texture) {
// ONLY baseColor map in SRGB
if (key === "map") {
tex.colorSpace = THREE.SRGBColorSpace
}
// these are fine to apply to all
tex.wrapS = tex.wrapT = THREE.RepeatWrapping
tex.repeat.set(width, height)
}
})
}
useBoxTween(meshRef, {color,width,height});
return (
<mesh
ref={meshRef}
{...props}
>
<boxGeometry />
<meshStandardMaterial
key={material}
color={color}
{...textures} // empty {} -> safe
aoMapIntensity={1}
/>
</mesh>
);
}
export default Box


YOUR TURN!
Ziezo, we hebben een leuke configurator en nu is het aan jullie om een configurator te maken die uiteraard veel realistischer is.

05
CUBE OF MAGIC!


TECH3/6 - Mouse Events, Textures & Configurator
By Niels Minne
TECH3/6 - Mouse Events, Textures & Configurator
- 137