Files
skills/plugins/suaveplan-threejs/skills/threejs-geometry/SKILL.md
admin 9bf56a806b Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 01:10:00 +01:00

16 KiB

name, description
name description
threejs-geometry Three.js geometry creation and manipulation adapted for React Three Fiber and Suaveplan R3F packages. Covers declarative mesh/geometry JSX, BufferGeometry with React refs, InstancedMesh patterns for high-performance rendering, custom geometry construction, and @react-three/drei geometry helpers.

This skill covers geometry creation and manipulation in Three.js within the React Three Fiber (R3F) context used by the Suaveplan monorepo. All code targets React 19 + TypeScript + R3F and follows Suaveplan CLAUDE.md conventions.

The user needs to create, modify, or optimize 3D geometry in an R3F scene. This includes built-in primitives, custom BufferGeometry, instanced rendering, and parametric shapes.

Core Concepts

Three.js Geometry System

Three.js geometry is based on BufferGeometry, which stores vertex data in typed arrays:

  • position: Float32Array of [x, y, z] per vertex
  • normal: Float32Array of [nx, ny, nz] per vertex
  • uv: Float32Array of [u, v] per vertex
  • index: Uint16Array or Uint32Array of triangle vertex indices
  • color: Float32Array of [r, g, b] per vertex (optional)

R3F Geometry Approach

In R3F, geometries are declared as JSX children of <mesh>:

Three.js (Imperative) R3F (Declarative)
new BoxGeometry(1, 1, 1) <boxGeometry args={[1, 1, 1]} />
mesh.geometry = geometry <mesh><boxGeometry /></mesh>
geometry.setAttribute(...) Via ref or attach prop
new InstancedMesh(g, m, n) <instancedMesh args={[null, null, n]}>

Suaveplan R3F Packages

Package Purpose Import
@suaveplan/r3f Core utilities, geometry helpers import { useR3F } from '@suaveplan/r3f'
@suaveplan/r3f-performance Geometry LOD, draw call monitoring import { usePerformanceMonitor } from '@suaveplan/r3f-performance'

Built-in Geometry Primitives

All Three.js geometry classes are available as lowercase JSX elements. Constructor arguments pass via args.

Box

<mesh>
  <boxGeometry args={[
    1,    // width
    1,    // height
    1,    // depth
    1,    // widthSegments (optional)
    1,    // heightSegments (optional)
    1,    // depthSegments (optional)
  ]} />
  <meshStandardMaterial color="tomato" />
</mesh>

Sphere

<mesh>
  <sphereGeometry args={[
    0.5,  // radius
    32,   // widthSegments
    32,   // heightSegments
    0,    // phiStart (optional)
    Math.PI * 2,  // phiLength (optional)
    0,    // thetaStart (optional)
    Math.PI,      // thetaLength (optional)
  ]} />
  <meshStandardMaterial color="dodgerblue" />
</mesh>

Common Primitives Reference

Geometry JSX Key Args
Box <boxGeometry args={[w, h, d]} /> width, height, depth
Sphere <sphereGeometry args={[r, wSeg, hSeg]} /> radius, segments
Cylinder <cylinderGeometry args={[rTop, rBot, h, rSeg]} /> radii, height, segments
Cone <coneGeometry args={[r, h, rSeg]} /> radius, height
Torus <torusGeometry args={[r, tube, rSeg, tSeg]} /> radius, tube radius
Plane <planeGeometry args={[w, h, wSeg, hSeg]} /> width, height
Ring <ringGeometry args={[inner, outer, tSeg]} /> inner/outer radius
Circle <circleGeometry args={[r, seg]} /> radius, segments
TorusKnot <torusKnotGeometry args={[r, tube, tSeg, rSeg, p, q]} /> radius, tube, knot params
Dodecahedron <dodecahedronGeometry args={[r, detail]} /> radius, detail level
Icosahedron <icosahedronGeometry args={[r, detail]} /> radius, detail level
Octahedron <octahedronGeometry args={[r, detail]} /> radius, detail level
Tetrahedron <tetrahedronGeometry args={[r, detail]} /> radius, detail level

Custom BufferGeometry

Declarative with Refs

import { useRef, useEffect, useMemo } from 'react';
import { BufferAttribute } from 'three';
import type { BufferGeometry } from 'three';

interface CustomTriangleProps {
  size?: number;
  color?: string;
}

export function CustomTriangle({ size = 1, color = 'limegreen' }: CustomTriangleProps) {
  const geometryRef = useRef<BufferGeometry>(null);

  const { positions, normals, uvs } = useMemo(() => {
    const s = size;
    return {
      positions: new Float32Array([
        -s, -s, 0,   // bottom-left
         s, -s, 0,   // bottom-right
         0,  s, 0,   // top-center
      ]),
      normals: new Float32Array([
        0, 0, 1,
        0, 0, 1,
        0, 0, 1,
      ]),
      uvs: new Float32Array([
        0, 0,
        1, 0,
        0.5, 1,
      ]),
    };
  }, [size]);

  return (
    <mesh>
      <bufferGeometry ref={geometryRef}>
        <bufferAttribute
          attach="attributes-position"
          args={[positions, 3]}
        />
        <bufferAttribute
          attach="attributes-normal"
          args={[normals, 3]}
        />
        <bufferAttribute
          attach="attributes-uv"
          args={[uvs, 2]}
        />
      </bufferGeometry>
      <meshStandardMaterial color={color} side={2} />
    </mesh>
  );
}

Indexed Geometry

For geometry with shared vertices (reduces memory and improves performance):

import { useMemo } from 'react';

export function IndexedQuad({ size = 1 }: { size?: number }) {
  const { positions, indices, uvs } = useMemo(() => {
    const s = size / 2;
    return {
      positions: new Float32Array([
        -s, -s, 0,  // 0: bottom-left
         s, -s, 0,  // 1: bottom-right
         s,  s, 0,  // 2: top-right
        -s,  s, 0,  // 3: top-left
      ]),
      indices: new Uint16Array([
        0, 1, 2,  // first triangle
        0, 2, 3,  // second triangle
      ]),
      uvs: new Float32Array([
        0, 0,
        1, 0,
        1, 1,
        0, 1,
      ]),
    };
  }, [size]);

  return (
    <mesh>
      <bufferGeometry>
        <bufferAttribute attach="attributes-position" args={[positions, 3]} />
        <bufferAttribute attach="attributes-uv" args={[uvs, 2]} />
        <bufferAttribute attach="index" args={[indices, 1]} />
      </bufferGeometry>
      <meshStandardMaterial color="coral" />
    </mesh>
  );
}

Dynamic Geometry Updates

Animating Vertices in useFrame

import { useRef, useMemo } from 'react';
import { useFrame } from '@react-three/fiber';
import type { BufferGeometry } from 'three';

export function WavingPlane({ width = 5, segments = 32 }: { width?: number; segments?: number }) {
  const geometryRef = useRef<BufferGeometry>(null);

  useFrame(({ clock }) => {
    if (!geometryRef.current) return;

    const positions = geometryRef.current.attributes.position;
    const time = clock.elapsedTime;

    for (let i = 0; i < positions.count; i++) {
      const x = positions.getX(i);
      const z = positions.getZ(i);
      const y = Math.sin(x * 2 + time) * 0.3 + Math.cos(z * 2 + time * 0.7) * 0.2;
      positions.setY(i, y);
    }

    positions.needsUpdate = true;
    geometryRef.current.computeVertexNormals();
  });

  return (
    <mesh rotation={[-Math.PI / 2, 0, 0]}>
      <planeGeometry ref={geometryRef} args={[width, width, segments, segments]} />
      <meshStandardMaterial color="steelblue" wireframe />
    </mesh>
  );
}

InstancedMesh: High-Performance Rendering

InstancedMesh renders many copies of the same geometry with a single draw call. Each instance can have its own transform and color.

Basic Instancing

import { useRef, useEffect, useMemo } from 'react';
import { Object3D, Color, Matrix4 } from 'three';
import type { InstancedMesh } from 'three';

interface InstanceFieldProps {
  count: number;
  spread?: number;
}

export function InstanceField({ count, spread = 10 }: InstanceFieldProps) {
  const meshRef = useRef<InstancedMesh>(null);
  const dummy = useMemo(() => new Object3D(), []);
  const color = useMemo(() => new Color(), []);

  useEffect(() => {
    if (!meshRef.current) return;

    for (let i = 0; i < count; i++) {
      dummy.position.set(
        (Math.random() - 0.5) * spread,
        Math.random() * 2,
        (Math.random() - 0.5) * spread
      );
      dummy.rotation.set(
        Math.random() * Math.PI,
        Math.random() * Math.PI,
        0
      );
      dummy.scale.setScalar(0.2 + Math.random() * 0.8);
      dummy.updateMatrix();
      meshRef.current.setMatrixAt(i, dummy.matrix);

      color.setHSL(Math.random(), 0.7, 0.5);
      meshRef.current.setColorAt(i, color);
    }

    meshRef.current.instanceMatrix.needsUpdate = true;
    if (meshRef.current.instanceColor) {
      meshRef.current.instanceColor.needsUpdate = true;
    }
  }, [count, spread, dummy, color]);

  return (
    <instancedMesh ref={meshRef} args={[undefined, undefined, count]}>
      <boxGeometry args={[1, 1, 1]} />
      <meshStandardMaterial />
    </instancedMesh>
  );
}

Animated Instances

import { useRef, useMemo } from 'react';
import { useFrame } from '@react-three/fiber';
import { Object3D } from 'three';
import type { InstancedMesh } from 'three';

export function FloatingParticles({ count = 500 }: { count?: number }) {
  const meshRef = useRef<InstancedMesh>(null);
  const dummy = useMemo(() => new Object3D(), []);

  const offsets = useMemo(() => {
    return Array.from({ length: count }, () => ({
      x: (Math.random() - 0.5) * 20,
      y: Math.random() * 10,
      z: (Math.random() - 0.5) * 20,
      speed: 0.2 + Math.random() * 0.8,
      phase: Math.random() * Math.PI * 2,
    }));
  }, [count]);

  useFrame(({ clock }) => {
    if (!meshRef.current) return;
    const t = clock.elapsedTime;

    for (let i = 0; i < count; i++) {
      const { x, y, z, speed, phase } = offsets[i];
      dummy.position.set(
        x + Math.sin(t * speed + phase) * 0.5,
        y + Math.sin(t * speed * 0.7 + phase) * 0.3,
        z + Math.cos(t * speed + phase) * 0.5
      );
      dummy.scale.setScalar(0.05 + Math.sin(t * speed + phase) * 0.02);
      dummy.updateMatrix();
      meshRef.current.setMatrixAt(i, dummy.matrix);
    }

    meshRef.current.instanceMatrix.needsUpdate = true;
  });

  return (
    <instancedMesh ref={meshRef} args={[undefined, undefined, count]}>
      <sphereGeometry args={[1, 8, 8]} />
      <meshBasicMaterial color="white" transparent opacity={0.6} />
    </instancedMesh>
  );
}

Drei Geometry Helpers

@react-three/drei provides higher-level geometry components:

RoundedBox

import { RoundedBox } from '@react-three/drei';

<RoundedBox args={[1, 1, 1]} radius={0.1} smoothness={4}>
  <meshStandardMaterial color="royalblue" />
</RoundedBox>

Text3D

import { Text3D, Center } from '@react-three/drei';

<Center>
  <Text3D
    font="/fonts/helvetiker_regular.typeface.json"
    size={0.5}
    height={0.1}
    bevelEnabled
    bevelSize={0.02}
    bevelThickness={0.01}
  >
    Hello R3F
    <meshStandardMaterial color="gold" />
  </Text3D>
</Center>

Parametric Shapes

import { Extrude, Lathe } from '@react-three/drei';
import { Shape, Vector2 } from 'three';
import { useMemo } from 'react';

export function CustomExtrusion() {
  const shape = useMemo(() => {
    const s = new Shape();
    s.moveTo(0, 0);
    s.lineTo(1, 0);
    s.lineTo(1, 1);
    s.lineTo(0.5, 1.5);
    s.lineTo(0, 1);
    s.closePath();
    return s;
  }, []);

  return (
    <Extrude args={[shape, { depth: 0.5, bevelEnabled: true, bevelSize: 0.05 }]}>
      <meshStandardMaterial color="mediumpurple" />
    </Extrude>
  );
}

Geometry Merging for Performance

Merge static geometries to reduce draw calls:

import { useRef, useEffect } from 'react';
import { BoxGeometry, Matrix4 } from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
import type { BufferGeometry } from 'three';

export function MergedBoxes({ positions }: { positions: [number, number, number][] }) {
  const geometryRef = useRef<BufferGeometry>(null);

  useEffect(() => {
    const geometries = positions.map(([x, y, z]) => {
      const box = new BoxGeometry(1, 1, 1);
      box.applyMatrix4(new Matrix4().makeTranslation(x, y, z));
      return box;
    });

    const merged = mergeGeometries(geometries, false);
    if (merged && geometryRef.current) {
      geometryRef.current.copy(merged);
      geometries.forEach((g) => g.dispose());
      merged.dispose();
    }
  }, [positions]);

  return (
    <mesh>
      <bufferGeometry ref={geometryRef} />
      <meshStandardMaterial color="sandybrown" />
    </mesh>
  );
}

Geometry Bounding Volumes

Bounding volumes are essential for culling, collision detection, and raycasting:

import { useRef, useEffect } from 'react';
import type { Mesh } from 'three';

export function BoundedMesh() {
  const meshRef = useRef<Mesh>(null);

  useEffect(() => {
    if (!meshRef.current) return;
    const geometry = meshRef.current.geometry;

    // Compute bounding box
    geometry.computeBoundingBox();
    const box = geometry.boundingBox;
    // box.min, box.max, box.getCenter(), box.getSize()

    // Compute bounding sphere
    geometry.computeBoundingSphere();
    const sphere = geometry.boundingSphere;
    // sphere.center, sphere.radius
  }, []);

  return (
    <mesh ref={meshRef}>
      <torusKnotGeometry args={[1, 0.3, 128, 32]} />
      <meshStandardMaterial color="orchid" />
    </mesh>
  );
}

Level of Detail (LOD)

Use <Detailed> from drei or Three.js LOD for distance-based geometry switching:

import { Detailed } from '@react-three/drei';

export function LODObject() {
  return (
    <Detailed distances={[0, 10, 25, 50]}>
      {/* Closest: high detail */}
      <mesh><sphereGeometry args={[1, 64, 64]} /><meshStandardMaterial /></mesh>
      {/* Medium distance */}
      <mesh><sphereGeometry args={[1, 32, 32]} /><meshStandardMaterial /></mesh>
      {/* Far distance */}
      <mesh><sphereGeometry args={[1, 16, 16]} /><meshStandardMaterial /></mesh>
      {/* Very far: billboard or point */}
      <mesh><sphereGeometry args={[1, 8, 8]} /><meshStandardMaterial /></mesh>
    </Detailed>
  );
}

Testing Geometry

Vitest Tests

import { describe, it, expect } from 'vitest';
import { BoxGeometry, SphereGeometry } from 'three';

describe('Geometry fundamentals', () => {
  it('creates box with correct vertex count', () => {
    const geom = new BoxGeometry(1, 1, 1);
    expect(geom.attributes.position.count).toBe(24); // 6 faces x 4 vertices
  });

  it('creates indexed sphere', () => {
    const geom = new SphereGeometry(1, 16, 16);
    expect(geom.index).not.toBeNull();
    expect(geom.attributes.position.count).toBeGreaterThan(0);
  });

  it('computes bounding box', () => {
    const geom = new BoxGeometry(2, 3, 4);
    geom.computeBoundingBox();
    expect(geom.boundingBox!.max.x).toBeCloseTo(1);
    expect(geom.boundingBox!.max.y).toBeCloseTo(1.5);
    expect(geom.boundingBox!.max.z).toBeCloseTo(2);
  });
});

Storybook Stories

import type { Meta, StoryObj } from '@storybook/react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
import { InstanceField } from './instance-field';

const meta: Meta<typeof InstanceField> = {
  title: 'R3F/Geometry/InstanceField',
  component: InstanceField,
  decorators: [
    (Story) => (
      <Canvas camera={{ position: [5, 5, 5] }} style={{ height: 400 }}>
        <ambientLight intensity={0.5} />
        <directionalLight position={[5, 5, 5]} />
        <Story />
        <OrbitControls />
      </Canvas>
    ),
  ],
};
export default meta;

export const Default: StoryObj<typeof InstanceField> = {
  args: { count: 100, spread: 10 },
};

export const Dense: StoryObj<typeof InstanceField> = {
  args: { count: 1000, spread: 5 },
};

Checklist

When working with geometry in R3F:

  1. Use built-in geometry JSX elements with args for standard shapes
  2. Use useMemo for custom BufferGeometry data arrays
  3. Set needsUpdate = true on attributes after modifying vertex data
  4. Use InstancedMesh for rendering many identical objects (1 draw call)
  5. Pre-allocate Object3D and Color objects outside useFrame for instancing
  6. Merge static geometries with mergeGeometries to reduce draw calls
  7. Compute bounding volumes after geometry modifications
  8. Use LOD components for distance-based detail levels
  9. Index geometry when vertices are shared between triangles
  10. Keep geometry component files under 250 lines