Skip to main content
This example shows how to create custom elements by combining OpenPlans primitives, shapes, and the Three.js Group object. You’ll learn how to build reusable components like furniture, fixtures, and custom architectural features.

Creating a simple table

Let’s create a custom table element using cuboid shapes:
import { OpenPlans } from '@opengeometry/openplans';
import * as THREE from 'three';

const openPlans = new OpenPlans(document.getElementById('app'));
await openPlans.setupOpenGeometry();

// Create a group to hold the table parts
const table = new THREE.Group();
table.name = 'CustomTable';

// Table top
const tableTop = openPlans.cuboid({
  center: { x: 0, y: 0.75, z: 0 },
  width: 1.5,
  height: 0.05,
  depth: 0.8,
  color: 0x8B4513  // Brown
});

// Table legs (4 corners)
const legHeight = 0.75;
const legSize = 0.05;

const leg1 = openPlans.cuboid({
  center: { x: -0.7, y: legHeight / 2, z: -0.35 },
  width: legSize,
  height: legHeight,
  depth: legSize,
  color: 0x654321
});

const leg2 = openPlans.cuboid({
  center: { x: 0.7, y: legHeight / 2, z: -0.35 },
  width: legSize,
  height: legHeight,
  depth: legSize,
  color: 0x654321
});

const leg3 = openPlans.cuboid({
  center: { x: -0.7, y: legHeight / 2, z: 0.35 },
  width: legSize,
  height: legHeight,
  depth: legSize,
  color: 0x654321
});

const leg4 = openPlans.cuboid({
  center: { x: 0.7, y: legHeight / 2, z: 0.35 },
  width: legSize,
  height: legHeight,
  depth: legSize,
  color: 0x654321
});

// Add all parts to the table group
table.add(tableTop);
table.add(leg1);
table.add(leg2);
table.add(leg3);
table.add(leg4);

// Position the table in the room
table.position.set(1, 0, 0);

// Add to scene (access through OpenPlans internals)
openPlans.openThree.scene.add(table);

Creating a column

Use cylinder shapes to create architectural columns:
// Create a decorative column
const column = new THREE.Group();
column.name = 'DecorativeColumn';

// Base
const base = openPlans.cuboid({
  center: { x: 0, y: 0.1, z: 0 },
  width: 0.5,
  height: 0.2,
  depth: 0.5,
  color: 0xD3D3D3  // Light gray
});

// Column shaft (cylinder)
const shaft = openPlans.cylinder({
  center: { x: 0, y: 1.5, z: 0 },
  radius: 0.2,
  height: 2.6,
  radialSegments: 32,
  color: 0xFFFFFF  // White
});

// Capital (top)
const capital = openPlans.cuboid({
  center: { x: 0, y: 2.9, z: 0 },
  width: 0.5,
  height: 0.2,
  depth: 0.5,
  color: 0xD3D3D3
});

column.add(base);
column.add(shaft);
column.add(capital);

// Position column
column.position.set(-2, 0, -1);

openPlans.openThree.scene.add(column);

Creating a window with shutters

Extend the window element with custom shutters:
// Create base window
const window = openPlans.baseSingleWindow({
  windowPosition: [0, 0, 0],
  windowHeight: 1.5,
  sillHeight: 0.9,
  dimensions: {
    start: { x: -0.6, y: 0, z: 0 },
    end: { x: 0.6, y: 0, z: 0 },
    length: 1.2
  },
  windowColor: 0x87CEEB,
  frameColor: 0xFFFFFF
});

// Add shutters (left and right panels)
const shutterWidth = 0.55;
const shutterHeight = 1.5;
const shutterThickness = 0.03;
const shutterOffsetZ = 0.2;

const leftShutter = openPlans.cuboid({
  center: { x: -0.65, y: 0.9 + shutterHeight / 2, z: shutterOffsetZ },
  width: shutterWidth,
  height: shutterHeight,
  depth: shutterThickness,
  color: 0x2E8B57  // Sea green
});

const rightShutter = openPlans.cuboid({
  center: { x: 0.65, y: 0.9 + shutterHeight / 2, z: shutterOffsetZ },
  width: shutterWidth,
  height: shutterHeight,
  depth: shutterThickness,
  color: 0x2E8B57
});

// Group window with shutters
const windowWithShutters = new THREE.Group();
windowWithShutters.add(window);
windowWithShutters.add(leftShutter);
windowWithShutters.add(rightShutter);

// Position in room
windowWithShutters.position.set(2, 0, 0);

openPlans.openThree.scene.add(windowWithShutters);

Creating a reusable component class

For better code organization, create a reusable class:
class CustomChair {
  group: THREE.Group;
  openPlans: OpenPlans;

  constructor(openPlans: OpenPlans, position: [number, number, number]) {
    this.openPlans = openPlans;
    this.group = new THREE.Group();
    this.group.name = 'CustomChair';
    
    this.createChair();
    this.group.position.set(position[0], position[1], position[2]);
    
    openPlans.openThree.scene.add(this.group);
  }

  private createChair() {
    // Seat
    const seat = this.openPlans.cuboid({
      center: { x: 0, y: 0.45, z: 0 },
      width: 0.45,
      height: 0.05,
      depth: 0.45,
      color: 0x654321
    });

    // Backrest
    const backrest = this.openPlans.cuboid({
      center: { x: 0, y: 0.75, z: -0.2 },
      width: 0.45,
      height: 0.6,
      depth: 0.05,
      color: 0x654321
    });

    // Legs (simplified - 4 legs)
    const legPositions = [
      [-0.18, 0.225, -0.18],
      [0.18, 0.225, -0.18],
      [-0.18, 0.225, 0.18],
      [0.18, 0.225, 0.18]
    ];

    legPositions.forEach(pos => {
      const leg = this.openPlans.cuboid({
        center: { x: pos[0], y: pos[1], z: pos[2] },
        width: 0.04,
        height: 0.45,
        depth: 0.04,
        color: 0x3E2723
      });
      this.group.add(leg);
    });

    this.group.add(seat);
    this.group.add(backrest);
  }

  setPosition(x: number, y: number, z: number) {
    this.group.position.set(x, y, z);
  }

  setRotation(angleY: number) {
    this.group.rotation.y = angleY;
  }

  dispose() {
    this.group.removeFromParent();
    this.group.clear();
  }
}

// Usage:
const chair1 = new CustomChair(openPlans, [0.5, 0, 0.5]);
const chair2 = new CustomChair(openPlans, [-0.5, 0, 0.5]);
chair2.setRotation(Math.PI);  // Rotate 180 degrees

Creating a complete desk setup

Combine multiple custom elements:
class DeskSetup {
  group: THREE.Group;
  openPlans: OpenPlans;

  constructor(openPlans: OpenPlans, position: [number, number, number]) {
    this.openPlans = openPlans;
    this.group = new THREE.Group();
    this.group.name = 'DeskSetup';
    
    this.createDesk();
    this.group.position.set(position[0], position[1], position[2]);
    
    openPlans.openThree.scene.add(this.group);
  }

  private createDesk() {
    // Desk surface
    const desktop = this.openPlans.cuboid({
      center: { x: 0, y: 0.75, z: 0 },
      width: 1.4,
      height: 0.05,
      depth: 0.7,
      color: 0x8B7355
    });

    // Left drawer unit
    const leftDrawer = this.openPlans.cuboid({
      center: { x: -0.5, y: 0.4, z: 0 },
      width: 0.3,
      height: 0.6,
      depth: 0.6,
      color: 0x6B5D52
    });

    // Right drawer unit
    const rightDrawer = this.openPlans.cuboid({
      center: { x: 0.5, y: 0.4, z: 0 },
      width: 0.3,
      height: 0.6,
      depth: 0.6,
      color: 0x6B5D52
    });

    // Monitor (simplified)
    const monitor = this.openPlans.cuboid({
      center: { x: 0, y: 1.05, z: -0.15 },
      width: 0.5,
      height: 0.3,
      depth: 0.05,
      color: 0x1a1a1a
    });

    // Monitor stand
    const stand = this.openPlans.cuboid({
      center: { x: 0, y: 0.8, z: -0.15 },
      width: 0.15,
      height: 0.05,
      depth: 0.15,
      color: 0x333333
    });

    this.group.add(desktop);
    this.group.add(leftDrawer);
    this.group.add(rightDrawer);
    this.group.add(monitor);
    this.group.add(stand);
  }
}

// Create desk setup
const desk = new DeskSetup(openPlans, [1.5, 0, -1]);

Adding custom properties and methods

class CustomBookshelf {
  group: THREE.Group;
  openPlans: OpenPlans;
  shelves: THREE.Mesh[];
  numShelves: number;

  constructor(
    openPlans: OpenPlans,
    position: [number, number, number],
    options: {
      width?: number,
      height?: number,
      depth?: number,
      numShelves?: number,
      color?: number
    } = {}
  ) {
    this.openPlans = openPlans;
    this.numShelves = options.numShelves || 5;
    this.shelves = [];
    this.group = new THREE.Group();
    this.group.name = 'CustomBookshelf';

    const width = options.width || 1.2;
    const height = options.height || 2.0;
    const depth = options.depth || 0.35;
    const color = options.color || 0x8B4513;

    this.createBookshelf(width, height, depth, color);
    this.group.position.set(position[0], position[1], position[2]);
    
    openPlans.openThree.scene.add(this.group);
  }

  private createBookshelf(
    width: number,
    height: number,
    depth: number,
    color: number
  ) {
    const shelfThickness = 0.03;
    const sideThickness = 0.05;

    // Left side
    const leftSide = this.openPlans.cuboid({
      center: { x: -width / 2 + sideThickness / 2, y: height / 2, z: 0 },
      width: sideThickness,
      height: height,
      depth: depth,
      color: color
    });

    // Right side
    const rightSide = this.openPlans.cuboid({
      center: { x: width / 2 - sideThickness / 2, y: height / 2, z: 0 },
      width: sideThickness,
      height: height,
      depth: depth,
      color: color
    });

    this.group.add(leftSide);
    this.group.add(rightSide);

    // Create shelves
    const shelfSpacing = height / (this.numShelves);
    for (let i = 0; i <= this.numShelves; i++) {
      const shelfY = i * shelfSpacing;
      const shelf = this.openPlans.cuboid({
        center: { x: 0, y: shelfY, z: 0 },
        width: width - 2 * sideThickness,
        height: shelfThickness,
        depth: depth,
        color: color
      });
      this.shelves.push(shelf);
      this.group.add(shelf);
    }
  }

  setColor(color: number) {
    this.group.traverse((child) => {
      if (child instanceof THREE.Mesh) {
        (child.material as THREE.MeshBasicMaterial).color.setHex(color);
      }
    });
  }

  getShelfHeight(shelfIndex: number): number {
    if (shelfIndex < this.shelves.length) {
      return this.shelves[shelfIndex].position.y;
    }
    return 0;
  }
}

// Usage
const bookshelf = new CustomBookshelf(
  openPlans,
  [-2, 0, 1],
  {
    width: 1.5,
    height: 2.2,
    depth: 0.4,
    numShelves: 6,
    color: 0x654321
  }
);

// Change color later
bookshelf.setColor(0x8B4513);

Best practices

Create custom elements when:
  • You need to reuse the same complex shape multiple times
  • The element has its own behavior or state
  • You want to encapsulate positioning and configuration logic
Use primitives directly when:
  • Creating one-off simple shapes
  • Prototyping or testing
  • The element doesn’t need to be reusable
Add selection logic to your custom element class:
class SelectableElement {
  selected: boolean = false;
  originalColors: Map<THREE.Mesh, number> = new Map();

  select() {
    this.selected = true;
    this.group.traverse((child) => {
      if (child instanceof THREE.Mesh) {
        const material = child.material as THREE.MeshBasicMaterial;
        this.originalColors.set(child, material.color.getHex());
        material.color.setHex(0x4460FF);  // Highlight color
      }
    });
  }

  deselect() {
    this.selected = false;
    this.group.traverse((child) => {
      if (child instanceof THREE.Mesh) {
        const originalColor = this.originalColors.get(child);
        if (originalColor !== undefined) {
          (child.material as THREE.MeshBasicMaterial).color.setHex(originalColor);
        }
      }
    });
    this.originalColors.clear();
  }
}
Yes! Implement serialization methods:
class CustomElement {
  toJSON() {
    return {
      type: 'CustomChair',
      position: this.group.position.toArray(),
      rotation: this.group.rotation.toArray(),
      // ... other properties
    };
  }

  static fromJSON(openPlans: OpenPlans, data: any) {
    const element = new CustomChair(openPlans, data.position);
    element.setRotation(data.rotation[1]);
    return element;
  }
}

Next steps

Last modified on March 7, 2026