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.

  1. De afbeelding moet in machten van 2 zijn (bijv. 512×512, 1024×1024) voor optimale performance.
  2. Wanneer objecten verwijderd worden, moet de texture worden vrijgegeven met de .dispose()-methode.
  3. 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 gsap

Gebruik 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