Snap parts together with sockets

A socket is a named point on a part where another part belongs: the doorway in a wall, the window opening, the top of a storey. This guide builds a two-storey house front from six separate GLBs in the London terraced houses pack, placing each one on a socket rather than typing in coordinates. The code below is the code we ran to make the picture.

A two-storey London house front built from a ground-floor wall, door, bay window, upper wall and two sash windows, each placed on a socket

Where the sockets are

They are in the pack's manifests/catalogue-v2.json, on each model's entry, not inside the GLB. Each one has an id, a position in metres in the part's own space, and the kinds of part it accepts. The Ground-floor front wall with bay opening (right hand) wall has four:

"sockets": [
  { "id": "stack-bottom", "position": [0, 0, 0],      "accepts": ["london-residential"] },
  { "id": "stack-top",    "position": [0, 3, 0],      "accepts": ["london-residential"] },
  { "id": "door-front",   "position": [1.6, 0, 0],    "accepts": ["door"] },
  { "id": "window-front", "position": [-0.65, 0.65, 0], "accepts": ["window"] }
]

The upper wall has window-0 and window-1 at ±1.25 m, 0.8 m up, and the roof has mount-chimney. (The pack's catalogue.json lists socket names only; the positions are in the manifest.)

The code

Unzip the pack next to your script. It loads the GLBs directly with three.js, so it needs the meshopt decoder (see fixing meshopt errors).

// Tutorial example: build a house front by snapping parts onto named sockets.
// Assumes the pack is unzipped next to this file (models/, manifests/catalogue-v2.json).
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';

const loader = new GLTFLoader().setMeshoptDecoder(MeshoptDecoder);
const catalogue = (await (await fetch('manifests/catalogue-v2.json')).json()).assets;
const entry = (id) => { const v = catalogue[id]; return v[Math.max(...Object.keys(v).map(Number))]; };

// Load one part. Remember which model it is so we can look up its sockets.
export async function part(id, level = 'medium') {
  const { scene } = await loader.loadAsync(`models/${id}/${level}-${id}.glb`);
  scene.userData.part = id;
  return scene;
}

// A socket is a named point in the part's own space (metres): put the child there.
export function attach(parent, socketId, child) {
  const socket = entry(parent.userData.part).sockets.find((s) => s.id === socketId);
  if (!socket) throw new Error(`${parent.userData.part} has no socket ${socketId}`);
  child.position.fromArray(socket.position);
  parent.add(child);
  return child;
}

export async function houseFront(level = 'medium') {
  const ground = await part('london-residential-front-bay-right', level);
  attach(ground, 'door-front', await part('london-residential-door', level));
  attach(ground, 'window-front', await part('london-residential-bay', level));
  const upper = attach(ground, 'stack-top', await part('london-residential-upper-wall', level));
  attach(upper, 'window-0', await part('london-residential-sash', level));
  attach(upper, 'window-1', await part('london-residential-sash', level));
  return ground; // move or rotate this one object and everything snapped to it follows
}

Add it with scene.add(await houseFront()). The parts land exactly where the pack's own assembled house puts them: door at (1.6, 0, 0), bay at (−0.65, 0.65, 0), upper wall at 3 m and the sashes at (±1.25, 3.8, 0). We checked each world position against the assembly file.

Why attach rather than position

Sockets carry a position but no rotation in this pack: parts share the kit's frame (front facing +Z, up +Y), so a door needs no turning to fit its wall. Loading GLBs this way gives you their own PBR materials; for colourways and weathering, use the pack's assemblies and the kit runtime (colourways guide).

See the London terraced houses pack