Building Interactive Drawing Tools with React Three Fiber

Jeremy Atkinson
Developer

Learn how to create a complete 2D drawing application using React Three Fiber with multiple tools, snap-to-grid, real-time measurements, and interactive object management.

Building Interactive Drawing Tools with React Three Fiber

Creating interactive drawing tools in a web browser traditionally requires complex 2D canvas manipulation or SVG handling. However, React Three Fiber (R3F) offers a unique approach - leveraging 3D graphics capabilities to build sophisticated 2D drawing applications with excellent performance and developer experience.

In this tutorial, we'll build a complete drawing application featuring multiple tools, snap-to-grid functionality, real-time measurements, and object management - all within a 2D orthographic view.

Demo

"use client"
import { Canvas, useFrame, useThree } from '@react-three/fiber'
import { Sphere, Line, Text } from '@react-three/drei'
import { Leva, useControls, button } from 'leva'
import { StrictMode, useRef, useState, useCallback, useEffect } from 'react'
import * as THREE from 'three'

// Utility function to calculate distance between two points
const calculateDistance = (p1, p2) => {
  return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2))
}

export default function App() {
  return (
    <StrictMode>
      <Leva flat />
      <Canvas orthographic camera={{ zoom: 50, near: 0.1, far: 200, position: [0, 0, 15] }}>
        <Experience />
      </Canvas>
    </StrictMode>
  )
}

export function Experience() {
  const [drawnObjects, setDrawnObjects] = useState([])
  const [currentTool, setCurrentTool] = useState('line')
  const [isDrawing, setIsDrawing] = useState(false)
  const [drawingState, setDrawingState] = useState(null)
  const [cursorPosition, setCursorPosition] = useState(null)

  const { tool, snapEnabled, snapValue, showLengths } = useControls('Drawing Tools', {
    tool: { 
      value: 'line', 
      options: { Line: 'line', Shape: 'shape', Rectangle: 'rectangle', Delete: 'delete' },
      onChange: (value) => {
        setCurrentTool(value)
        setIsDrawing(false)
        setDrawingState(null)
      }
    },
    snapEnabled: true,
    snapValue: { value: 0.5, min: 0.1, max: 2, step: 0.1 },
    showLengths: false,
    clear: button(() => {
      setDrawnObjects([])
      setIsDrawing(false)
      setDrawingState(null)
    })
  })

  const snapToGrid = useCallback((point) => {
    if (!snapEnabled) return point
    return {
      x: Math.round(point.x / snapValue) * snapValue,
      y: Math.round(point.y / snapValue) * snapValue,
      z: 0
    }
  }, [snapEnabled, snapValue])

  const handleObjectDelete = useCallback((objectId) => {
    setDrawnObjects(prev => prev.filter(obj => obj.id !== objectId))
  }, [])

  const handleCanvasClick = useCallback((point) => {
    if (currentTool === 'delete') return // Don't handle canvas clicks in delete mode
    
    const snappedPoint = snapToGrid(point)
    
    if (currentTool === 'line') {
      if (!isDrawing) {
        setIsDrawing(true)
        setDrawingState({ start: snappedPoint, end: snappedPoint })
      } else {
        setDrawnObjects(prev => [...prev, {
          type: 'line',
          id: Date.now(),
          start: drawingState.start,
          end: snappedPoint
        }])
        setIsDrawing(false)
        setDrawingState(null)
      }
    } else if (currentTool === 'shape') {
      if (!isDrawing) {
        setIsDrawing(true)
        setDrawingState({ points: [snappedPoint] })
      } else {
        const newPoints = [...drawingState.points, snappedPoint]
        setDrawingState({ points: newPoints })
      }
    } else if (currentTool === 'rectangle') {
      if (!isDrawing) {
        setIsDrawing(true)
        setDrawingState({ corner1: snappedPoint, corner2: snappedPoint })
      } else {
        setDrawnObjects(prev => [...prev, {
          type: 'rectangle',
          id: Date.now(),
          corner1: drawingState.corner1,
          corner2: snappedPoint
        }])
        setIsDrawing(false)
        setDrawingState(null)
      }
    }
  }, [currentTool, isDrawing, drawingState, snapToGrid])

  const handleMouseMove = useCallback((point) => {
    const snappedPoint = snapToGrid(point)
    setCursorPosition(snappedPoint)
    
    if (!isDrawing || !drawingState) return
    
    if (currentTool === 'line') {
      setDrawingState(prev => ({ ...prev, end: snappedPoint }))
    } else if (currentTool === 'rectangle') {
      setDrawingState(prev => ({ ...prev, corner2: snappedPoint }))
    }
  }, [isDrawing, drawingState, currentTool, snapToGrid])

  const handleShapeComplete = useCallback(() => {
    if (currentTool === 'shape' && isDrawing && drawingState?.points.length >= 3) {
      setDrawnObjects(prev => [...prev, {
        type: 'shape',
        id: Date.now(),
        points: drawingState.points
      }])
      setIsDrawing(false)
      setDrawingState(null)
    }
  }, [currentTool, isDrawing, drawingState])

  useEffect(() => {
    const handleKeyDown = (e) => {
      if (e.key === 'Escape') {
        if (currentTool === 'shape') {
          handleShapeComplete()
        } else {
          setIsDrawing(false)
          setDrawingState(null)
        }
      }
    }
    
    window.addEventListener('keydown', handleKeyDown)
    return () => window.removeEventListener('keydown', handleKeyDown)
  }, [currentTool, handleShapeComplete])

  return (
    <>
      <directionalLight position={[1, 2, 3]} intensity={1.5} />
      <ambientLight intensity={0.5} />
      
      <DrawingCanvas 
        onCanvasClick={handleCanvasClick}
        onMouseMove={handleMouseMove}
        snapEnabled={snapEnabled}
        snapValue={snapValue}
      />
      
      {/* Render completed objects */}
      {drawnObjects.map(obj => (
        <DrawnObject 
          key={obj.id} 
          object={obj} 
          isDeleteMode={currentTool === 'delete'}
          showLengths={showLengths}
          onDelete={() => handleObjectDelete(obj.id)}
        />
      ))}
      
      {/* Render current drawing preview */}
      {isDrawing && drawingState && (
        <DrawingPreview 
          tool={currentTool} 
          state={drawingState} 
          cursorPosition={cursorPosition}
          showLengths={showLengths}
          onShapeComplete={handleShapeComplete}
        />
      )}
      
      <GridHelper snapValue={snapValue} visible={snapEnabled} />
    </>
  )
}

export function DrawingCanvas({ onCanvasClick, onMouseMove, snapEnabled, snapValue }) {
  const cursorRef = useRef()
  const planeRef = useRef()

  return (
    <mesh
      ref={planeRef}
      position={[0, 0, 0]}
      onPointerMove={(e) => {
        const localPoint = planeRef.current.worldToLocal(e.point.clone())
        
        if (snapEnabled) {
          localPoint.x = Math.round(localPoint.x / snapValue) * snapValue
          localPoint.y = Math.round(localPoint.y / snapValue) * snapValue
        }
        
        if (cursorRef.current) {
          cursorRef.current.position.set(localPoint.x, localPoint.y, 0.01)
          cursorRef.current.visible = true
        }
        
        // Drawing coordinates are directly X,Y with Z=0
        const drawingPoint = { x: localPoint.x, y: localPoint.y, z: 0 }
        onMouseMove(drawingPoint)
      }}
      onPointerOut={() => {
        if (cursorRef.current) {
          cursorRef.current.visible = false
        }
      }}
      onClick={(e) => {
        const localPoint = planeRef.current.worldToLocal(e.point.clone())
        
        if (snapEnabled) {
          localPoint.x = Math.round(localPoint.x / snapValue) * snapValue
          localPoint.y = Math.round(localPoint.y / snapValue) * snapValue
        }
        
        // Drawing coordinates are directly X,Y with Z=0
        const drawingPoint = { x: localPoint.x, y: localPoint.y, z: 0 }
        onCanvasClick(drawingPoint)
      }}
    >
      <planeGeometry args={[20, 20]} />
      <meshBasicMaterial transparent opacity={0.1} color="lightblue" />
      
      <CursorIndicator ref={cursorRef} />
    </mesh>
  )
}

export const CursorIndicator = ({ color = "orange", size = 0.1, ...props }) => {
  const ref = useRef()
  
  useFrame(() => {
    if (ref.current) {
      ref.current.rotation.x += 0.01
      ref.current.rotation.y += 0.01
    }
  })
  
  return (
    <mesh ref={ref} raycast={() => null} visible={false} {...props}>
      <sphereGeometry args={[size]} />
      <meshBasicMaterial color={color} />
    </mesh>
  )
}

export function DrawnObject({ object, isDeleteMode, showLengths, onDelete }) {
  const [isHovered, setIsHovered] = useState(false)
  
  const commonProps = isDeleteMode ? {
    onPointerEnter: () => setIsHovered(true),
    onPointerLeave: () => setIsHovered(false),
    onClick: (e) => {
      e.stopPropagation()
      onDelete()
    },
    style: { cursor: 'pointer' }
  } : {}
  
  const color = isDeleteMode && isHovered ? 'red' : undefined
  const opacity = isDeleteMode && isHovered ? 0.8 : 1
  
  switch (object.type) {
    case 'line':
      return <DrawnLine start={object.start} end={object.end} color={color} opacity={opacity} showLengths={showLengths} {...commonProps} />
    case 'shape':
      return <DrawnShape points={object.points} color={color} opacity={opacity} showLengths={showLengths} {...commonProps} />
    case 'rectangle':
      return <DrawnRectangle corner1={object.corner1} corner2={object.corner2} color={color} opacity={opacity} showLengths={showLengths} {...commonProps} />
    default:
      return null
  }
}

export function DrawingPreview({ tool, state, cursorPosition, showLengths, onShapeComplete }) {
  switch (tool) {
    case 'line':
      return state.start && state.end ? (
        <DrawnLine start={state.start} end={state.end} color="orange" opacity={0.7} showLengths={showLengths} />
      ) : null
    case 'shape':
      return (
        <>
          {state.points?.map((point, i) => (
            <mesh key={i} position={[point.x, point.y, 0]}>
              <sphereGeometry args={[0.05]} />
              <meshBasicMaterial color="orange" />
            </mesh>
          ))}
          {state.points?.length > 1 && (
            <>
              <Line 
                points={state.points.map(p => [p.x, p.y, 0])}
                color="orange"
                lineWidth={2}
              />
              {/* Show lengths on all drawn segments */}
              {showLengths && state.points.slice(0, -1).map((point, i) => {
                const nextPoint = state.points[i + 1]
                const segmentLength = calculateDistance(point, nextPoint)
                const midpoint = {
                  x: (point.x + nextPoint.x) / 2,
                  y: (point.y + nextPoint.y) / 2
                }
                return (
                  <Text
                    key={i}
                    position={[midpoint.x, midpoint.y, 0.1]}
                    fontSize={0.2}
                    color="black"
                    anchorX="center"
                    anchorY="middle"
                  >
                    {segmentLength.toFixed(2)}
                  </Text>
                )
              })}
            </>
          )}
          {/* Preview line from last point to cursor */}
          {state.points?.length > 0 && cursorPosition && (
            <>
              <Line 
                points={[
                  [state.points[state.points.length - 1].x, state.points[state.points.length - 1].y, 0],
                  [cursorPosition.x, cursorPosition.y, 0]
                ]}
                color="orange"
                lineWidth={1}
                transparent
                opacity={0.5}
              />
              {showLengths && (() => {
                const lastPoint = state.points[state.points.length - 1]
                const previewLength = calculateDistance(lastPoint, cursorPosition)
                const midpoint = {
                  x: (lastPoint.x + cursorPosition.x) / 2,
                  y: (lastPoint.y + cursorPosition.y) / 2
                }
                return (
                  <Text
                    position={[midpoint.x, midpoint.y, 0.1]}
                    fontSize={0.2}
                    color="orange"
                    anchorX="center"
                    anchorY="middle"
                  >
                    {previewLength.toFixed(2)}
                  </Text>
                )
              })()}
            </>
          )}
          {state.points?.length >= 3 && (
            <mesh 
              position={[state.points[0].x, state.points[0].y, 0]}
              onClick={onShapeComplete}
            >
              <sphereGeometry args={[0.1]} />
              <meshBasicMaterial color="green" />
            </mesh>
          )}
        </>
      )
    case 'rectangle':
      return state.corner1 && state.corner2 ? (
        <DrawnRectangle corner1={state.corner1} corner2={state.corner2} color="orange" opacity={0.7} showLengths={showLengths} />
      ) : null
    default:
      return null
  }
}

export function DrawnLine({ start, end, color = "black", opacity = 1, showLengths = false, ...props }) {
  const points = [[start.x, start.y, 0], [end.x, end.y, 0]]
  const length = calculateDistance(start, end)
  const midpoint = {
    x: (start.x + end.x) / 2,
    y: (start.y + end.y) / 2
  }
  
  return (
    <group {...props}>
      <Line points={points} color={color} lineWidth={3} />
      <mesh position={[start.x, start.y, 0]}>
        <sphereGeometry args={[0.05]} />
        <meshBasicMaterial color={color} transparent opacity={opacity} />
      </mesh>
      <mesh position={[end.x, end.y, 0]}>
        <sphereGeometry args={[0.05]} />
        <meshBasicMaterial color={color} transparent opacity={opacity} />
      </mesh>
      {showLengths && (
        <Text
          position={[midpoint.x, midpoint.y, 0.1]}
          fontSize={0.2}
          color="black"
          anchorX="center"
          anchorY="middle"
        >
          {length.toFixed(2)}
        </Text>
      )}
    </group>
  )
}

export function DrawnShape({ points, color = "crimson", opacity = 1, showLengths = false, ...props }) {
  if (points.length < 3) return null
  
  const shape = new THREE.Shape()
  shape.moveTo(points[0].x, points[0].y)
  points.forEach(p => shape.lineTo(p.x, p.y))
  shape.lineTo(points[0].x, points[0].y)
  
  // Create segments for length display
  const segments = []
  for (let i = 0; i < points.length; i++) {
    const start = points[i]
    const end = points[(i + 1) % points.length] // Wrap around to close the shape
    segments.push({ start, end })
  }
  
  return (
    <group {...props}>
      <mesh position={[0, 0, 0]}>
        <shapeGeometry args={[shape]} />
        <meshBasicMaterial 
          color={color} 
          transparent 
          opacity={opacity} 
          side={THREE.DoubleSide} 
        />
      </mesh>
      {/* Edge lines for definition */}
      {segments.map((segment, i) => (
        <Line 
          key={`edge-${i}`}
          points={[[segment.start.x, segment.start.y, 0.01], [segment.end.x, segment.end.y, 0.01]]}
          color="black"
          lineWidth={1}
        />
      ))}
      {showLengths && segments.map((segment, i) => {
        const length = calculateDistance(segment.start, segment.end)
        const midpoint = {
          x: (segment.start.x + segment.end.x) / 2,
          y: (segment.start.y + segment.end.y) / 2
        }
        return (
          <Text
            key={i}
            position={[midpoint.x, midpoint.y, 0.1]}
            fontSize={0.2}
            color="black"
            anchorX="center"
            anchorY="middle"
          >
            {length.toFixed(2)}
          </Text>
        )
      })}
    </group>
  )
}

export function DrawnRectangle({ corner1, corner2, color = "blue", opacity = 1, showLengths = false, ...props }) {
  const width = Math.abs(corner2.x - corner1.x)
  const height = Math.abs(corner2.y - corner1.y)
  const centerX = (corner1.x + corner2.x) / 2
  const centerY = (corner1.y + corner2.y) / 2
  
  // Calculate the four corners
  const minX = Math.min(corner1.x, corner2.x)
  const maxX = Math.max(corner1.x, corner2.x)
  const minY = Math.min(corner1.y, corner2.y)
  const maxY = Math.max(corner1.y, corner2.y)
  
  // Define only two sides to avoid redundant labels
  const sides = [
    { start: { x: minX, y: minY }, end: { x: maxX, y: minY }, length: width }, // bottom
    { start: { x: maxX, y: minY }, end: { x: maxX, y: maxY }, length: height }, // right
  ]
  
  return (
    <group {...props}>
      <mesh position={[centerX, centerY, 0]}>
        <planeGeometry args={[width, height]} />
        <meshBasicMaterial 
          color={color} 
          transparent 
          opacity={opacity} 
          side={THREE.DoubleSide} 
        />
      </mesh>
      {/* Edge lines for definition - all four sides */}
      <Line 
        points={[
          [minX, minY, 0.01], [maxX, minY, 0.01], // bottom
          [maxX, maxY, 0.01], [minX, maxY, 0.01], // top
          [minX, minY, 0.01] // close the rectangle
        ]}
        color="black"
        lineWidth={1}
      />
      {showLengths && sides.map((side, i) => {
        const midpoint = {
          x: (side.start.x + side.end.x) / 2,
          y: (side.start.y + side.end.y) / 2
        }
        return (
          <Text
            key={i}
            position={[midpoint.x, midpoint.y, 0.1]}
            fontSize={0.2}
            color="black"
            anchorX="center"
            anchorY="middle"
          >
            {side.length.toFixed(2)}
          </Text>
        )
      })}
    </group>
  )
}

export function GridHelper({ snapValue, visible }) {
  if (!visible) return null
  
  const lines = []
  const size = 20
  const divisions = size / snapValue
  
  for (let i = -divisions; i <= divisions; i++) {
    const pos = i * snapValue
    lines.push(
      <Line 
        key={`h${i}`}
        points={[[-size/2, pos, 0], [size/2, pos, 0]]}
        color="gray"
        lineWidth={0.5}
      />,
      <Line 
        key={`v${i}`}
        points={[[pos, -size/2, 0], [pos, size/2, 0]]}
        color="gray"
        lineWidth={0.5}
      />
    )
  }
  
  return <group>{lines}</group>
}

Why React Three Fiber for 2D Drawing?

While it might seem counterintuitive to use a 3D library for 2D drawing, R3F provides several advantages:

  • Declarative approach: Components map naturally to drawing objects
  • Built-in interaction: Pointer events work seamlessly
  • Performance: WebGL acceleration handles complex scenes efficiently
  • Coordinate system: Precise mathematical positioning
  • Text rendering: Built-in text components with proper positioning

Core Architecture

Our drawing tool consists of several key components:

1. Tool Management System

const [currentTool, setCurrentTool] = useState('line')
const [isDrawing, setIsDrawing] = useState(false)
const [drawingState, setDrawingState] = useState(null)
const [drawnObjects, setDrawnObjects] = useState([])

Each tool maintains its own state during the drawing process, then creates a permanent object when completed.

2. Interactive Canvas

The drawing surface is a plane mesh that captures pointer events:

<mesh
  ref={planeRef}
  position={[0, 0, 0]}
  onPointerMove={handlePointerMove}
  onPointerOut={handlePointerOut}
  onClick={handleCanvasClick}
>
  <planeGeometry args={[20, 20]} />
  <meshBasicMaterial transparent opacity={0.1} color="lightblue" />
</mesh>

3. Coordinate Transformation

Converting from screen space to drawing coordinates requires careful handling:

const localPoint = planeRef.current.worldToLocal(e.point.clone())

if (snapEnabled) {
  localPoint.x = Math.round(localPoint.x / snapValue) * snapValue
  localPoint.y = Math.round(localPoint.y / snapValue) * snapValue
}

const drawingPoint = { x: localPoint.x, y: localPoint.y, z: 0 }

Drawing Tools Implementation

Line Tool

The line tool demonstrates the basic drawing pattern:

  1. First click: Set start point and enter drawing mode
  2. Mouse move: Update preview line endpoint
  3. Second click: Finalize line and exit drawing mode
if (currentTool === 'line') {
  if (!isDrawing) {
    setIsDrawing(true)
    setDrawingState({ start: snappedPoint, end: snappedPoint })
  } else {
    setDrawnObjects(prev => [...prev, {
      type: 'line',
      id: Date.now(),
      start: drawingState.start,
      end: snappedPoint
    }])
    setIsDrawing(false)
    setDrawingState(null)
  }
}

Shape Tool (Polygon)

The shape tool handles multi-point drawing with completion logic:

if (currentTool === 'shape') {
  if (!isDrawing) {
    setIsDrawing(true)
    setDrawingState({ points: [snappedPoint] })
  } else {
    const newPoints = [...drawingState.points, snappedPoint]
    setDrawingState({ points: newPoints })
  }
}

Shapes can be completed by:

  • Clicking the starting point (green indicator)
  • Pressing the ESC key

Rectangle Tool

Rectangles use a corner-to-corner approach:

if (currentTool === 'rectangle') {
  if (!isDrawing) {
    setIsDrawing(true)
    setDrawingState({ corner1: snappedPoint, corner2: snappedPoint })
  } else {
    setDrawnObjects(prev => [...prev, {
      type: 'rectangle',
      id: Date.now(),
      corner1: drawingState.corner1,
      corner2: snappedPoint
    }])
    setIsDrawing(false)
    setDrawingState(null)
  }
}

Advanced Features

Snap-to-Grid

Grid snapping ensures precise positioning:

const snapToGrid = useCallback((point) => {
  if (!snapEnabled) return point
  return {
    x: Math.round(point.x / snapValue) * snapValue,
    y: Math.round(point.y / snapValue) * snapValue,
    z: 0
  }
}, [snapEnabled, snapValue])

The grid is visualized using line components:

for (let i = -divisions; i <= divisions; i++) {
  const pos = i * snapValue
  lines.push(
    <Line 
      key={`h${i}`}
      points={[[-size/2, pos, 0], [size/2, pos, 0]]}
      color="gray"
      lineWidth={0.5}
    />
  )
}

Real-Time Measurements

Length calculations use the distance formula:

const calculateDistance = (p1, p2) => {
  return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2))
}

Measurements are displayed at segment midpoints:

const midpoint = {
  x: (start.x + end.x) / 2,
  y: (start.y + end.y) / 2
}

return (
  <Text
    position={[midpoint.x, midpoint.y, 0.1]}
    fontSize={0.2}
    color="black"
    anchorX="center"
    anchorY="middle"
  >
    {length.toFixed(2)}
  </Text>
)

Delete Tool

Objects become clickable when the delete tool is active:

const commonProps = isDeleteMode ? {
  onPointerEnter: () => setIsHovered(true),
  onPointerLeave: () => setIsHovered(false),
  onClick: (e) => {
    e.stopPropagation()
    onDelete()
  }
} : {}

const color = isDeleteMode && isHovered ? 'red' : undefined

Camera Configuration

For 2D drawing, we use an orthographic camera positioned directly above:

<Canvas 
  orthographic 
  camera={{ zoom: 50, near: 0.1, far: 200, position: [0, 0, 15] }}
>

This eliminates perspective distortion and provides true-to-scale measurements.

Rendering Strategy

Each drawing object type has its own component:

switch (object.type) {
  case 'line':
    return <DrawnLine {...object} />
  case 'shape':
    return <DrawnShape {...object} />  
  case 'rectangle':
    return <DrawnRectangle {...object} />
}

This modular approach makes it easy to add visual enhancements like edge lines:

{/* Edge lines for definition */}
<Line 
  points={[
    [minX, minY, 0.01], [maxX, minY, 0.01],
    [maxX, maxY, 0.01], [minX, maxY, 0.01],
    [minX, minY, 0.01]
  ]}
  color="black"
  lineWidth={1}
/>

Key Takeaways

Building drawing tools with React Three Fiber demonstrates several important concepts:

  1. State management: Complex interactions require careful state coordination
  2. Event handling: Pointer events need proper propagation control
  3. Coordinate systems: Understanding world vs local coordinates is crucial
  4. Performance: Declarative updates handle dynamic scenes efficiently
  5. User experience: Visual feedback makes tools intuitive to use

The resulting application provides a professional drawing experience with features comparable to desktop CAD software, all running in a web browser with smooth 60fps performance.

Next Steps

This foundation could be extended with additional features:

  • Import/Export: Save drawings as JSON or export to SVG
  • Layers: Organize objects on separate layers
  • Styling: Line weights, colors, and fill patterns
  • Dimensions: Automatic dimensioning with leader lines
  • Templates: Predefined shapes and symbols

React Three Fiber's flexibility makes these enhancements straightforward to implement while maintaining good performance and code organization.

Jeremy Atkinson

Jeremy is a structural engineer, researcher, and developer from BC. He works on Calcs.app and writes at Kinson.io

Comments (0)

Loading comments...