
Builder Ux
- 56 installs
- 8 repo stars
- Updated August 4, 2026
- bbeierle12/skill-mcp-claude
Builder-ux is a Claude skill providing prefab, undo/redo, ghost-preview, and selection UX systems for Three.js building games.
About
Builder-ux is a Claude skill for building-game user experience systems in Three.js. It covers prefab and blueprint save/load, undo/redo command patterns, ghost preview placement, multi-select, and copy/paste building mechanics. A game developer uses it to make block placement feel responsive when implementing player-constructed structures.
- Prefab/blueprint save-load, undo/redo, ghost preview, and multi-select for Three.js builders
- Ships BlueprintManager, CommandHistory, GhostPreview, and SelectionManager scripts
- Command-pattern history with configurable max size for undo/redo
Builder Ux by the numbers
- 56 all-time installs (skills.sh)
- Ranked #152 of 247 Game Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
builder-ux capabilities & compatibility
- Capabilities
- undo redo · prefab system · ghost preview · multi select
- Use cases
- frontend · ui design
What builder-ux says it does
Builder user experience systems for Three.js building games.
Prefabs, blueprints, undo/redo, selection, and preview systems for building mechanics.
const history = new CommandHistory({ maxSize: 50 });
npx skills add https://github.com/bbeierle12/skill-mcp-claude --skill builder-uxAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 56 |
|---|---|
| repo stars | ★ 8 |
| Last updated | August 4, 2026 |
| Repository | bbeierle12/skill-mcp-claude ↗ |
What it does
Add prefab save/load, undo/redo, ghost preview, and multi-select to a Three.js building game.
Who is it for?
Implementing responsive placement UX in a Three.js building or sandbox game.
Skip if: Non-game or non-Three.js UI work.
When should I use this skill?
Implementing prefab/blueprint save/load, undo/redo, ghost preview placement, multi-select, or copy/paste building mechanics.
What you get
Responsive, intuitive building placement UX in a Three.js game.
- BlueprintManager
- CommandHistory undo/redo
- GhostPreview
By the numbers
- CommandHistory maxSize 50 example
- SelectionManager maxSelection 100 example
Files
Builder UX
Prefabs, blueprints, undo/redo, selection, and preview systems for building mechanics.
Quick Start
import { BlueprintManager } from './scripts/blueprint-manager.js';
import { CommandHistory, PlaceCommand, BatchCommand } from './scripts/command-history.js';
import { GhostPreview } from './scripts/ghost-preview.js';
import { SelectionManager } from './scripts/selection-manager.js';
// Initialize systems
const blueprints = new BlueprintManager();
const history = new CommandHistory({ maxSize: 50 });
const ghost = new GhostPreview(scene);
const selection = new SelectionManager({ maxSelection: 100 });
// Save selected pieces as blueprint
const { blueprint } = blueprints.save(selection.getSelection(), 'My Base');
// Load blueprint at new position
const { pieces } = blueprints.load(blueprint.id, newPosition, rotation);
// Place with undo support
history.execute(new PlaceCommand(pieceData, position, rotation, buildingSystem));
history.undo(); // Removes piece
history.redo(); // Restores piece
// Ghost preview with validity feedback
ghost.show('wall', cursorPosition, rotation);
ghost.setValid(canPlace); // Green/red color
ghost.updatePosition(newCursorPosition);
// Multi-select with box selection
selection.boxSelect(startNDC, endNDC, camera, allPieces);
selection.selectConnected(startPiece, getNeighbors); // Flood fill
// Batch operations
history.beginGroup('Delete selection');
for (const piece of selection.getSelection()) {
history.execute(new RemoveCommand(piece, buildingSystem));
}
history.endGroup();Reference
See references/builder-ux-advanced.md for:
- Command pattern with PlaceCommand, RemoveCommand, UpgradeCommand, BatchCommand
- Blueprint serialization format and versioning
- Ghost preview rendering with pulse animation
- Selection systems: single, additive, box, radius, connected
- Copy/paste and blueprint sharing
Scripts
| File | Lines | Purpose |
|---|---|---|
blueprint-manager.js | ~450 | Save, load, export/import building designs |
command-history.js | ~400 | Undo/redo stack with command pattern |
ghost-preview.js | ~380 | Transparent placement preview with snapping |
selection-manager.js | ~420 | Multi-select, box select, group operations |
Key Patterns
Command Pattern
Every building action becomes a command with execute() and undo(). This enables:
- Undo/redo for free
- Network replication (serialize command, send to server)
- Macro recording (save command sequences)
- Validation before execution
Blueprint System
Blueprints store relative positions, making them position and rotation independent. Key features:
- Auto-centering on save
- Rotation support on load
- Export/import for sharing
- Thumbnail generation
Ghost Preview
Shows placement intent before commitment:
- Green = valid placement
- Red = invalid (collision, no support)
- Orange = blocked (permissions)
- Pulse animation for visibility
- Grid/rotation snapping
Selection Manager
Supports multiple selection modes:
- Single click (replace selection)
- Shift+click (additive)
- Ctrl+click (toggle)
- Box select (screen space)
- Radius select (world space)
- Connected select (flood fill)
Integration
// Full integration example
function setupBuildingUX(scene, buildingSystem) {
const history = new CommandHistory({
maxSize: 100,
onChange: (status) => updateUndoRedoButtons(status)
});
const ghost = new GhostPreview(scene, {
validColor: 0x00ff00,
invalidColor: 0xff0000,
snapGrid: 2,
snapRotation: Math.PI / 4
});
const selection = new SelectionManager({
maxSelection: 200,
onSelectionChanged: (pieces) => updateSelectionUI(pieces)
});
const blueprints = new BlueprintManager({
storage: localStorage,
maxBlueprints: 50
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'z') history.undo();
if (e.ctrlKey && e.key === 'y') history.redo();
if (e.ctrlKey && e.key === 'c') copySelection();
if (e.ctrlKey && e.key === 'v') pasteBlueprint();
if (e.key === 'r') ghost.rotate(Math.PI / 4);
});
return { history, ghost, selection, blueprints };
}Design Philosophy
Building UX separates intent from execution. The ghost preview shows what will happen, the command executes it, and the history allows reversal. This separation enables blueprint placement (preview entire structure), batch undo (reverse multiple operations), and networked building (commands serialize for transmission).
{
"name": "builder-ux",
"description": "Builder user experience systems for Three.js building games. Use when implementing prefab/blueprint save/load, undo/redo command patterns, ghost preview placement, multi-select, or copy/paste building mechanics.",
"tags": [
"building-game",
"ui-design",
"code-generation"
],
"sub_skills": [],
"source": "claude-user",
"type": "template",
"depends_on": [],
"enhances": [
"3d-building-advanced",
"structural-physics",
"multiplayer-building"
],
"last_reviewed_at": "2026-05-26",
"review_score": 72,
"relevance_tier": "B"
}
Builder UX Advanced
The UX layer sits between player intent and game state. When a player moves their cursor, the ghost preview shows what will happen. When they click, a command executes. When they press Ctrl+Z, the command reverses. This separation of concerns enables sophisticated features like blueprints (preview and place multiple pieces), batch operations (select many, operate once), and networked building (commands serialize for transmission).
The Command Pattern
Every building action becomes a command object with execute() and undo() methods. This pattern provides undo/redo for free, enables networked replication (serialize command, send to server, execute there), supports macro recording (save command sequences), and allows validation before execution.
Command Base Class
/**
* Base command class for building operations
*/
export class BuildCommand {
constructor() {
this.timestamp = Date.now();
this.executed = false;
}
/**
* Execute the command
* @returns {Object} Execution result
*/
execute() {
throw new Error('execute() must be implemented');
}
/**
* Undo the command
* @returns {Object} Undo result
*/
undo() {
throw new Error('undo() must be implemented');
}
/**
* Check if command can be executed
* @returns {boolean} Whether command is valid
*/
canExecute() {
return true;
}
/**
* Serialize for networking/saving
* @returns {Object} Serialized command
*/
serialize() {
return {
type: this.constructor.name,
timestamp: this.timestamp,
data: this.getData()
};
}
/**
* Get command-specific data for serialization
* @returns {Object} Command data
*/
getData() {
return {};
}
}Common Building Commands
/**
* Place a building piece
*/
export class PlaceCommand extends BuildCommand {
constructor(piece, position, rotation, buildingSystem) {
super();
this.piece = piece;
this.position = position.clone();
this.rotation = rotation?.clone() ?? new THREE.Euler();
this.buildingSystem = buildingSystem;
this.placedPiece = null;
}
execute() {
this.placedPiece = this.buildingSystem.placePiece(
this.piece,
this.position,
this.rotation
);
this.executed = true;
return { success: true, piece: this.placedPiece };
}
undo() {
if (!this.placedPiece) return { success: false };
this.buildingSystem.removePiece(this.placedPiece.id);
this.executed = false;
return { success: true };
}
canExecute() {
return this.buildingSystem.canPlace(this.piece, this.position);
}
getData() {
return {
pieceType: this.piece.type,
position: { x: this.position.x, y: this.position.y, z: this.position.z },
rotation: { x: this.rotation.x, y: this.rotation.y, z: this.rotation.z }
};
}
}
/**
* Remove a building piece
*/
export class RemoveCommand extends BuildCommand {
constructor(piece, buildingSystem) {
super();
this.piece = piece;
this.buildingSystem = buildingSystem;
this.pieceData = null; // Store for undo
}
execute() {
// Store piece state for undo
this.pieceData = {
type: this.piece.type,
position: this.piece.position.clone(),
rotation: this.piece.rotation.clone(),
material: this.piece.material,
health: this.piece.health,
id: this.piece.id
};
this.buildingSystem.removePiece(this.piece.id);
this.executed = true;
return { success: true };
}
undo() {
if (!this.pieceData) return { success: false };
const restored = this.buildingSystem.placePiece(
{ type: this.pieceData.type, material: this.pieceData.material },
this.pieceData.position,
this.pieceData.rotation
);
// Restore original ID and health
restored.id = this.pieceData.id;
restored.health = this.pieceData.health;
this.piece = restored;
this.executed = false;
return { success: true, piece: restored };
}
getData() {
return {
pieceId: this.piece.id,
pieceData: this.pieceData
};
}
}
/**
* Upgrade a building piece (change material)
*/
export class UpgradeCommand extends BuildCommand {
constructor(piece, newMaterial, buildingSystem) {
super();
this.piece = piece;
this.newMaterial = newMaterial;
this.oldMaterial = piece.material;
this.buildingSystem = buildingSystem;
}
execute() {
this.oldMaterial = this.piece.material;
this.buildingSystem.upgradePiece(this.piece.id, this.newMaterial);
this.executed = true;
return { success: true };
}
undo() {
this.buildingSystem.upgradePiece(this.piece.id, this.oldMaterial);
this.executed = false;
return { success: true };
}
getData() {
return {
pieceId: this.piece.id,
oldMaterial: this.oldMaterial?.name,
newMaterial: this.newMaterial?.name
};
}
}
/**
* Batch command - execute multiple commands as one unit
*/
export class BatchCommand extends BuildCommand {
constructor(commands) {
super();
this.commands = commands;
this.executedCommands = [];
}
execute() {
this.executedCommands = [];
for (const command of this.commands) {
if (command.canExecute()) {
command.execute();
this.executedCommands.push(command);
}
}
this.executed = true;
return {
success: true,
executed: this.executedCommands.length,
total: this.commands.length
};
}
undo() {
// Undo in reverse order
for (let i = this.executedCommands.length - 1; i >= 0; i--) {
this.executedCommands[i].undo();
}
this.executedCommands = [];
this.executed = false;
return { success: true };
}
getData() {
return {
commands: this.commands.map(c => c.serialize())
};
}
}Command History Manager
/**
* CommandHistory - Manages undo/redo stack
*/
export class CommandHistory {
constructor(options = {}) {
this.maxSize = options.maxSize ?? 100;
this.undoStack = [];
this.redoStack = [];
// Callbacks
this.onExecute = options.onExecute ?? null;
this.onUndo = options.onUndo ?? null;
this.onRedo = options.onRedo ?? null;
this.onChange = options.onChange ?? null;
}
/**
* Execute a command and add to history
*/
execute(command) {
if (!command.canExecute()) {
return { success: false, reason: 'Command cannot be executed' };
}
const result = command.execute();
if (result.success) {
this.undoStack.push(command);
this.redoStack = []; // Clear redo on new action
// Enforce max size
while (this.undoStack.length > this.maxSize) {
this.undoStack.shift();
}
if (this.onExecute) this.onExecute(command, result);
if (this.onChange) this.onChange();
}
return result;
}
/**
* Undo last command
*/
undo() {
if (this.undoStack.length === 0) {
return { success: false, reason: 'Nothing to undo' };
}
const command = this.undoStack.pop();
const result = command.undo();
if (result.success) {
this.redoStack.push(command);
if (this.onUndo) this.onUndo(command, result);
if (this.onChange) this.onChange();
} else {
// Restore to stack if undo failed
this.undoStack.push(command);
}
return result;
}
/**
* Redo last undone command
*/
redo() {
if (this.redoStack.length === 0) {
return { success: false, reason: 'Nothing to redo' };
}
const command = this.redoStack.pop();
const result = command.execute();
if (result.success) {
this.undoStack.push(command);
if (this.onRedo) this.onRedo(command, result);
if (this.onChange) this.onChange();
} else {
// Restore to stack if redo failed
this.redoStack.push(command);
}
return result;
}
/**
* Check if undo is available
*/
canUndo() {
return this.undoStack.length > 0;
}
/**
* Check if redo is available
*/
canRedo() {
return this.redoStack.length > 0;
}
/**
* Clear all history
*/
clear() {
this.undoStack = [];
this.redoStack = [];
if (this.onChange) this.onChange();
}
/**
* Get history status for UI
*/
getStatus() {
return {
undoCount: this.undoStack.length,
redoCount: this.redoStack.length,
canUndo: this.canUndo(),
canRedo: this.canRedo(),
lastCommand: this.undoStack[this.undoStack.length - 1]?.constructor.name ?? null
};
}
}Blueprint System
Blueprints serialize building designs for save/load and sharing. A good blueprint format captures piece types, relative positions, rotations, and optionally materials/upgrades.
Blueprint Format
/**
* Blueprint data structure
* @typedef {Object} Blueprint
* @property {string} id - Unique identifier
* @property {string} name - User-given name
* @property {number} version - Format version
* @property {Object} bounds - Bounding box
* @property {Array} pieces - Piece definitions
* @property {Object} metadata - Additional info
*/
const BlueprintSchema = {
id: 'string',
name: 'string',
version: 1,
created: 'timestamp',
modified: 'timestamp',
bounds: {
min: { x: 0, y: 0, z: 0 },
max: { x: 0, y: 0, z: 0 }
},
pieces: [
{
type: 'string', // wall, floor, foundation, etc.
localPosition: { x: 0, y: 0, z: 0 }, // Relative to blueprint origin
rotation: { x: 0, y: 0, z: 0 },
material: 'string', // wood, stone, metal
variant: 'string' // optional sub-type
}
],
metadata: {
author: 'string',
description: 'string',
tags: ['string'],
pieceCount: 0,
thumbnail: 'base64' // Optional preview image
}
};Blueprint Manager
/**
* BlueprintManager - Save, load, and share building designs
*/
export class BlueprintManager {
constructor(options = {}) {
this.blueprints = new Map();
this.storage = options.storage ?? null; // LocalStorage, IndexedDB, etc.
this.maxBlueprints = options.maxBlueprints ?? 100;
this.maxPiecesPerBlueprint = options.maxPiecesPerBlueprint ?? 500;
}
/**
* Create blueprint from selected pieces
*/
save(pieces, name, metadata = {}) {
if (pieces.length === 0) {
return { success: false, reason: 'No pieces selected' };
}
if (pieces.length > this.maxPiecesPerBlueprint) {
return {
success: false,
reason: `Too many pieces (max ${this.maxPiecesPerBlueprint})`
};
}
// Calculate bounds and center
const bounds = this.calculateBounds(pieces);
const center = new THREE.Vector3(
(bounds.min.x + bounds.max.x) / 2,
bounds.min.y, // Keep base at y=0
(bounds.min.z + bounds.max.z) / 2
);
// Convert to relative positions
const blueprintPieces = pieces.map(piece => ({
type: piece.type,
localPosition: {
x: piece.position.x - center.x,
y: piece.position.y - center.y,
z: piece.position.z - center.z
},
rotation: {
x: piece.rotation?.x ?? 0,
y: piece.rotation?.y ?? 0,
z: piece.rotation?.z ?? 0
},
material: piece.material?.name ?? 'wood',
variant: piece.variant ?? null
}));
const blueprint = {
id: this.generateId(),
name: name || 'Untitled Blueprint',
version: 1,
created: Date.now(),
modified: Date.now(),
bounds: {
min: {
x: bounds.min.x - center.x,
y: bounds.min.y - center.y,
z: bounds.min.z - center.z
},
max: {
x: bounds.max.x - center.x,
y: bounds.max.y - center.y,
z: bounds.max.z - center.z
}
},
pieces: blueprintPieces,
metadata: {
author: metadata.author ?? 'Unknown',
description: metadata.description ?? '',
tags: metadata.tags ?? [],
pieceCount: pieces.length,
thumbnail: metadata.thumbnail ?? null
}
};
this.blueprints.set(blueprint.id, blueprint);
this.persistBlueprint(blueprint);
return { success: true, blueprint };
}
/**
* Load blueprint at position
*/
load(blueprintId, position, rotation = 0) {
const blueprint = this.blueprints.get(blueprintId);
if (!blueprint) {
return { success: false, reason: 'Blueprint not found' };
}
// Calculate rotated positions
const cos = Math.cos(rotation);
const sin = Math.sin(rotation);
const pieces = blueprint.pieces.map(piece => {
// Rotate local position around Y axis
const rotatedX = piece.localPosition.x * cos - piece.localPosition.z * sin;
const rotatedZ = piece.localPosition.x * sin + piece.localPosition.z * cos;
return {
type: piece.type,
position: new THREE.Vector3(
position.x + rotatedX,
position.y + piece.localPosition.y,
position.z + rotatedZ
),
rotation: new THREE.Euler(
piece.rotation.x,
piece.rotation.y + rotation,
piece.rotation.z
),
material: { name: piece.material },
variant: piece.variant
};
});
return { success: true, pieces, blueprint };
}
/**
* Preview blueprint (for ghost display)
*/
preview(blueprintId, position, rotation = 0) {
return this.load(blueprintId, position, rotation);
}
/**
* Calculate bounding box of pieces
*/
calculateBounds(pieces) {
const min = new THREE.Vector3(Infinity, Infinity, Infinity);
const max = new THREE.Vector3(-Infinity, -Infinity, -Infinity);
for (const piece of pieces) {
min.x = Math.min(min.x, piece.position.x);
min.y = Math.min(min.y, piece.position.y);
min.z = Math.min(min.z, piece.position.z);
max.x = Math.max(max.x, piece.position.x);
max.y = Math.max(max.y, piece.position.y);
max.z = Math.max(max.z, piece.position.z);
}
return { min, max };
}
/**
* Delete a blueprint
*/
delete(blueprintId) {
if (!this.blueprints.has(blueprintId)) {
return { success: false, reason: 'Blueprint not found' };
}
this.blueprints.delete(blueprintId);
this.removePersisted(blueprintId);
return { success: true };
}
/**
* List all blueprints
*/
list() {
return Array.from(this.blueprints.values()).map(bp => ({
id: bp.id,
name: bp.name,
pieceCount: bp.metadata.pieceCount,
created: bp.created,
modified: bp.modified,
tags: bp.metadata.tags
}));
}
/**
* Export blueprint as JSON string
*/
export(blueprintId) {
const blueprint = this.blueprints.get(blueprintId);
if (!blueprint) return null;
return JSON.stringify(blueprint, null, 2);
}
/**
* Import blueprint from JSON string
*/
import(jsonString) {
try {
const blueprint = JSON.parse(jsonString);
// Validate structure
if (!blueprint.pieces || !Array.isArray(blueprint.pieces)) {
return { success: false, reason: 'Invalid blueprint format' };
}
// Assign new ID to avoid conflicts
blueprint.id = this.generateId();
blueprint.modified = Date.now();
this.blueprints.set(blueprint.id, blueprint);
this.persistBlueprint(blueprint);
return { success: true, blueprint };
} catch (e) {
return { success: false, reason: 'Failed to parse blueprint' };
}
}
/**
* Generate unique ID
*/
generateId() {
return `bp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Persist blueprint to storage
*/
persistBlueprint(blueprint) {
if (this.storage) {
try {
const existing = JSON.parse(this.storage.getItem('blueprints') || '{}');
existing[blueprint.id] = blueprint;
this.storage.setItem('blueprints', JSON.stringify(existing));
} catch (e) {
console.warn('Failed to persist blueprint:', e);
}
}
}
/**
* Remove blueprint from storage
*/
removePersisted(blueprintId) {
if (this.storage) {
try {
const existing = JSON.parse(this.storage.getItem('blueprints') || '{}');
delete existing[blueprintId];
this.storage.setItem('blueprints', JSON.stringify(existing));
} catch (e) {
console.warn('Failed to remove blueprint:', e);
}
}
}
/**
* Load all blueprints from storage
*/
loadFromStorage() {
if (this.storage) {
try {
const existing = JSON.parse(this.storage.getItem('blueprints') || '{}');
for (const [id, blueprint] of Object.entries(existing)) {
this.blueprints.set(id, blueprint);
}
} catch (e) {
console.warn('Failed to load blueprints:', e);
}
}
}
}Ghost Preview System
Ghost previews show placement intent before commitment. The key is rendering a transparent version of the piece that updates instantly with cursor movement and provides validity feedback through color.
Ghost Preview Implementation
/**
* GhostPreview - Transparent placement preview
*/
export class GhostPreview {
constructor(scene, options = {}) {
this.scene = scene;
this.ghostMeshes = new Map(); // pieceType -> mesh
this.activeGhost = null;
this.isVisible = false;
// Appearance
this.validColor = options.validColor ?? 0x00ff00;
this.invalidColor = options.invalidColor ?? 0xff0000;
this.opacity = options.opacity ?? 0.5;
this.pulseEnabled = options.pulseEnabled ?? true;
this.pulseSpeed = options.pulseSpeed ?? 2;
// State
this.currentValidity = true;
this.pulsePhase = 0;
// Mesh factory
this.meshFactory = options.meshFactory ?? null;
}
/**
* Show ghost at position
*/
show(pieceType, position, rotation = new THREE.Euler()) {
// Get or create ghost mesh
let ghost = this.ghostMeshes.get(pieceType);
if (!ghost) {
ghost = this.createGhostMesh(pieceType);
this.ghostMeshes.set(pieceType, ghost);
}
// Position and rotate
ghost.position.copy(position);
ghost.rotation.copy(rotation);
// Add to scene if not already
if (!ghost.parent) {
this.scene.add(ghost);
}
ghost.visible = true;
this.activeGhost = ghost;
this.isVisible = true;
return ghost;
}
/**
* Hide current ghost
*/
hide() {
if (this.activeGhost) {
this.activeGhost.visible = false;
}
this.isVisible = false;
}
/**
* Update ghost position
*/
updatePosition(position, rotation) {
if (this.activeGhost) {
this.activeGhost.position.copy(position);
if (rotation) {
this.activeGhost.rotation.copy(rotation);
}
}
}
/**
* Set validity state (changes color)
*/
setValid(isValid) {
this.currentValidity = isValid;
if (this.activeGhost) {
const color = isValid ? this.validColor : this.invalidColor;
this.setGhostColor(this.activeGhost, color);
}
}
/**
* Update animation (call from render loop)
*/
update(deltaTime) {
if (!this.isVisible || !this.pulseEnabled) return;
this.pulsePhase += deltaTime * this.pulseSpeed;
const pulse = (Math.sin(this.pulsePhase) + 1) / 2;
const opacity = this.opacity * (0.5 + pulse * 0.5);
if (this.activeGhost) {
this.setGhostOpacity(this.activeGhost, opacity);
}
}
/**
* Create ghost mesh for piece type
*/
createGhostMesh(pieceType) {
let geometry;
// Use factory if provided
if (this.meshFactory) {
const baseMesh = this.meshFactory.create(pieceType);
geometry = baseMesh.geometry.clone();
} else {
// Default geometries
geometry = this.getDefaultGeometry(pieceType);
}
const material = new THREE.MeshBasicMaterial({
color: this.validColor,
transparent: true,
opacity: this.opacity,
side: THREE.DoubleSide,
depthWrite: false
});
const mesh = new THREE.Mesh(geometry, material);
mesh.renderOrder = 999; // Render on top
return mesh;
}
/**
* Get default geometry for piece type
*/
getDefaultGeometry(pieceType) {
switch (pieceType) {
case 'foundation':
return new THREE.BoxGeometry(4, 0.2, 4);
case 'wall':
return new THREE.BoxGeometry(4, 3, 0.2);
case 'floor':
return new THREE.BoxGeometry(4, 0.1, 4);
case 'pillar':
return new THREE.BoxGeometry(0.5, 3, 0.5);
case 'roof':
return new THREE.BoxGeometry(4, 0.2, 4);
case 'door':
return new THREE.BoxGeometry(1, 2.5, 0.2);
default:
return new THREE.BoxGeometry(1, 1, 1);
}
}
/**
* Set ghost mesh color
*/
setGhostColor(mesh, color) {
if (mesh.material) {
mesh.material.color.setHex(color);
}
// Handle multi-material meshes
if (mesh.children) {
mesh.children.forEach(child => {
if (child.material) {
child.material.color.setHex(color);
}
});
}
}
/**
* Set ghost mesh opacity
*/
setGhostOpacity(mesh, opacity) {
if (mesh.material) {
mesh.material.opacity = opacity;
}
if (mesh.children) {
mesh.children.forEach(child => {
if (child.material) {
child.material.opacity = opacity;
}
});
}
}
/**
* Show blueprint preview (multiple pieces)
*/
showBlueprint(pieces) {
this.hideAll();
const group = new THREE.Group();
group.name = 'blueprint-preview';
for (const piece of pieces) {
const ghost = this.createGhostMesh(piece.type);
ghost.position.copy(piece.position);
if (piece.rotation) {
ghost.rotation.copy(piece.rotation);
}
group.add(ghost);
}
this.scene.add(group);
this.activeGhost = group;
this.isVisible = true;
return group;
}
/**
* Hide all ghosts
*/
hideAll() {
if (this.activeGhost) {
this.scene.remove(this.activeGhost);
this.activeGhost = null;
}
this.isVisible = false;
}
/**
* Dispose of all ghost meshes
*/
dispose() {
this.hideAll();
for (const [type, mesh] of this.ghostMeshes) {
mesh.geometry.dispose();
mesh.material.dispose();
}
this.ghostMeshes.clear();
}
}Selection System
Multi-select enables batch operations. Box selection, shift-click to add, and group operations are standard patterns.
Selection Manager
/**
* SelectionManager - Handle piece selection and group operations
*/
export class SelectionManager {
constructor(options = {}) {
this.selected = new Set();
this.maxSelection = options.maxSelection ?? 500;
// Visual feedback
this.highlightColor = options.highlightColor ?? 0x00aaff;
this.selectionOutline = options.selectionOutline ?? true;
// Callbacks
this.onSelectionChanged = options.onSelectionChanged ?? null;
}
/**
* Select a single piece
*/
select(piece, additive = false) {
if (!additive) {
this.clearSelection();
}
if (this.selected.size >= this.maxSelection) {
return { success: false, reason: 'Selection limit reached' };
}
this.selected.add(piece);
this.highlightPiece(piece, true);
if (this.onSelectionChanged) {
this.onSelectionChanged(this.getSelection());
}
return { success: true, count: this.selected.size };
}
/**
* Deselect a piece
*/
deselect(piece) {
if (this.selected.has(piece)) {
this.selected.delete(piece);
this.highlightPiece(piece, false);
if (this.onSelectionChanged) {
this.onSelectionChanged(this.getSelection());
}
}
}
/**
* Toggle piece selection
*/
toggle(piece) {
if (this.selected.has(piece)) {
this.deselect(piece);
} else {
this.select(piece, true);
}
}
/**
* Clear all selection
*/
clearSelection() {
for (const piece of this.selected) {
this.highlightPiece(piece, false);
}
this.selected.clear();
if (this.onSelectionChanged) {
this.onSelectionChanged([]);
}
}
/**
* Select multiple pieces
*/
selectMultiple(pieces, additive = false) {
if (!additive) {
this.clearSelection();
}
let added = 0;
for (const piece of pieces) {
if (this.selected.size >= this.maxSelection) break;
if (!this.selected.has(piece)) {
this.selected.add(piece);
this.highlightPiece(piece, true);
added++;
}
}
if (this.onSelectionChanged) {
this.onSelectionChanged(this.getSelection());
}
return { success: true, added, total: this.selected.size };
}
/**
* Box selection - select pieces within screen rectangle
*/
boxSelect(startPoint, endPoint, camera, pieces, additive = false) {
// Create frustum from selection rectangle
const frustum = this.createSelectionFrustum(startPoint, endPoint, camera);
const toSelect = [];
for (const piece of pieces) {
if (frustum.containsPoint(piece.position)) {
toSelect.push(piece);
}
}
return this.selectMultiple(toSelect, additive);
}
/**
* Create frustum from screen rectangle
*/
createSelectionFrustum(start, end, camera) {
const frustum = new THREE.Frustum();
// Normalize coordinates
const minX = Math.min(start.x, end.x);
const maxX = Math.max(start.x, end.x);
const minY = Math.min(start.y, end.y);
const maxY = Math.max(start.y, end.y);
// Create selection box in NDC
const topLeft = new THREE.Vector3(minX, maxY, -1);
const topRight = new THREE.Vector3(maxX, maxY, -1);
const bottomLeft = new THREE.Vector3(minX, minY, -1);
const bottomRight = new THREE.Vector3(maxX, minY, -1);
// Unproject to world space
topLeft.unproject(camera);
topRight.unproject(camera);
bottomLeft.unproject(camera);
bottomRight.unproject(camera);
// Create planes (simplified - full implementation would create proper frustum)
const camPos = camera.position;
frustum.setFromProjectionMatrix(
new THREE.Matrix4().multiplyMatrices(
camera.projectionMatrix,
camera.matrixWorldInverse
)
);
return frustum;
}
/**
* Get current selection
*/
getSelection() {
return Array.from(this.selected);
}
/**
* Check if piece is selected
*/
isSelected(piece) {
return this.selected.has(piece);
}
/**
* Get selection count
*/
getCount() {
return this.selected.size;
}
/**
* Highlight piece visually
*/
highlightPiece(piece, highlight) {
if (!piece.mesh) return;
if (highlight) {
piece.mesh.userData.originalMaterial = piece.mesh.material;
if (this.selectionOutline) {
// Add outline effect
piece.mesh.material = piece.mesh.material.clone();
piece.mesh.material.emissive = new THREE.Color(this.highlightColor);
piece.mesh.material.emissiveIntensity = 0.3;
}
} else {
// Restore original material
if (piece.mesh.userData.originalMaterial) {
piece.mesh.material = piece.mesh.userData.originalMaterial;
delete piece.mesh.userData.originalMaterial;
}
}
}
/**
* Get selection bounds
*/
getSelectionBounds() {
if (this.selected.size === 0) return null;
const min = new THREE.Vector3(Infinity, Infinity, Infinity);
const max = new THREE.Vector3(-Infinity, -Infinity, -Infinity);
for (const piece of this.selected) {
min.x = Math.min(min.x, piece.position.x);
min.y = Math.min(min.y, piece.position.y);
min.z = Math.min(min.z, piece.position.z);
max.x = Math.max(max.x, piece.position.x);
max.y = Math.max(max.y, piece.position.y);
max.z = Math.max(max.z, piece.position.z);
}
return { min, max, center: min.clone().add(max).multiplyScalar(0.5) };
}
}Integration Checklist
When implementing builder UX:
- [ ] Implement command pattern for all building operations
- [ ] Create command history with configurable size limit
- [ ] Add undo/redo keyboard shortcuts (Ctrl+Z, Ctrl+Y)
- [ ] Implement ghost preview with validity feedback
- [ ] Add pulse animation for preview visibility
- [ ] Create blueprint save/load system
- [ ] Add blueprint export/import for sharing
- [ ] Implement selection manager with highlighting
- [ ] Add box selection for multi-select
- [ ] Support shift+click additive selection
- [ ] Create batch commands for group operations
- [ ] Test undo/redo with complex operations
- [ ] Network commands for multiplayer sync
Related References
structural-physicsskill - Validate placement in commandsmultiplayer-buildingskill - Serialize commands for networkperformance-at-scaleskill - Selection queries via spatial index
/**
* BlueprintManager - Save, load, and share building designs
*
* Serializes building structures into portable blueprints that can be
* saved, shared, and placed elsewhere. Used in games like Rust,
* Satisfactory, and No Man's Sky for building prefabs.
*
* Usage:
* const blueprints = new BlueprintManager();
* const { blueprint } = blueprints.save(selectedPieces, 'My Base');
* const { pieces } = blueprints.load(blueprint.id, newPosition);
*/
import * as THREE from 'three';
/**
* Blueprint format version for compatibility
*/
export const BLUEPRINT_VERSION = 1;
/**
* Blueprint validation result
* @typedef {Object} ValidationResult
* @property {boolean} valid - Whether blueprint is valid
* @property {Array} errors - List of validation errors
* @property {Array} warnings - List of warnings
*/
export class BlueprintManager {
/**
* Create blueprint manager
* @param {Object} options - Configuration options
*/
constructor(options = {}) {
this.blueprints = new Map();
this.maxBlueprints = options.maxBlueprints ?? 100;
this.maxPiecesPerBlueprint = options.maxPiecesPerBlueprint ?? 500;
// Storage backend
this.storage = options.storage ?? null;
this.storageKey = options.storageKey ?? 'building_blueprints';
// Thumbnail generation
this.thumbnailEnabled = options.thumbnailEnabled ?? true;
this.thumbnailSize = options.thumbnailSize ?? 128;
// Event callbacks
this.onSave = options.onSave ?? null;
this.onLoad = options.onLoad ?? null;
this.onDelete = options.onDelete ?? null;
// Load existing blueprints from storage
if (this.storage) {
this.loadFromStorage();
}
}
/**
* Save pieces as a blueprint
* @param {Array} pieces - Building pieces to save
* @param {string} name - Blueprint name
* @param {Object} metadata - Additional metadata
* @returns {Object} Save result with blueprint
*/
save(pieces, name, metadata = {}) {
// Validation
if (!pieces || pieces.length === 0) {
return { success: false, reason: 'No pieces provided' };
}
if (pieces.length > this.maxPiecesPerBlueprint) {
return {
success: false,
reason: `Exceeds maximum pieces (${this.maxPiecesPerBlueprint})`
};
}
if (this.blueprints.size >= this.maxBlueprints) {
return {
success: false,
reason: `Blueprint limit reached (${this.maxBlueprints})`
};
}
// Calculate bounds and origin
const bounds = this.calculateBounds(pieces);
const origin = this.calculateOrigin(bounds);
// Convert pieces to relative coordinates
const blueprintPieces = pieces.map(piece => this.serializePiece(piece, origin));
// Adjust bounds to be relative
const relativeBounds = {
min: {
x: bounds.min.x - origin.x,
y: bounds.min.y - origin.y,
z: bounds.min.z - origin.z
},
max: {
x: bounds.max.x - origin.x,
y: bounds.max.y - origin.y,
z: bounds.max.z - origin.z
}
};
// Create blueprint object
const blueprint = {
id: this.generateId(),
version: BLUEPRINT_VERSION,
name: name || 'Untitled Blueprint',
created: Date.now(),
modified: Date.now(),
bounds: relativeBounds,
size: {
x: relativeBounds.max.x - relativeBounds.min.x,
y: relativeBounds.max.y - relativeBounds.min.y,
z: relativeBounds.max.z - relativeBounds.min.z
},
pieces: blueprintPieces,
metadata: {
author: metadata.author ?? 'Unknown',
description: metadata.description ?? '',
tags: metadata.tags ?? [],
pieceCount: pieces.length,
materialCounts: this.countMaterials(pieces),
thumbnail: null
}
};
// Store blueprint
this.blueprints.set(blueprint.id, blueprint);
// Persist to storage
this.persistBlueprint(blueprint);
// Callback
if (this.onSave) {
this.onSave(blueprint);
}
return { success: true, blueprint };
}
/**
* Load blueprint and generate pieces at position
* @param {string} blueprintId - Blueprint to load
* @param {THREE.Vector3} position - World position to place at
* @param {number} rotation - Y-axis rotation in radians
* @returns {Object} Load result with pieces array
*/
load(blueprintId, position, rotation = 0) {
const blueprint = this.blueprints.get(blueprintId);
if (!blueprint) {
return { success: false, reason: 'Blueprint not found' };
}
// Validate version compatibility
if (blueprint.version > BLUEPRINT_VERSION) {
return {
success: false,
reason: `Blueprint version ${blueprint.version} not supported`
};
}
// Generate pieces with world positions
const pieces = blueprint.pieces.map(piece =>
this.deserializePiece(piece, position, rotation)
);
// Update last used time
blueprint.metadata.lastUsed = Date.now();
this.persistBlueprint(blueprint);
// Callback
if (this.onLoad) {
this.onLoad(blueprint, pieces);
}
return { success: true, pieces, blueprint };
}
/**
* Preview blueprint without placing (for ghost display)
* @param {string} blueprintId - Blueprint to preview
* @param {THREE.Vector3} position - Preview position
* @param {number} rotation - Y-axis rotation
* @returns {Object} Preview data
*/
preview(blueprintId, position, rotation = 0) {
const result = this.load(blueprintId, position, rotation);
if (result.success) {
return {
success: true,
pieces: result.pieces,
bounds: this.getTransformedBounds(result.blueprint, position, rotation)
};
}
return result;
}
/**
* Get blueprint by ID
* @param {string} blueprintId - Blueprint ID
* @returns {Object|null} Blueprint or null
*/
get(blueprintId) {
return this.blueprints.get(blueprintId) ?? null;
}
/**
* Delete a blueprint
* @param {string} blueprintId - Blueprint to delete
* @returns {Object} Delete result
*/
delete(blueprintId) {
const blueprint = this.blueprints.get(blueprintId);
if (!blueprint) {
return { success: false, reason: 'Blueprint not found' };
}
this.blueprints.delete(blueprintId);
this.removeFromStorage(blueprintId);
if (this.onDelete) {
this.onDelete(blueprint);
}
return { success: true };
}
/**
* Rename a blueprint
* @param {string} blueprintId - Blueprint to rename
* @param {string} newName - New name
* @returns {Object} Rename result
*/
rename(blueprintId, newName) {
const blueprint = this.blueprints.get(blueprintId);
if (!blueprint) {
return { success: false, reason: 'Blueprint not found' };
}
blueprint.name = newName;
blueprint.modified = Date.now();
this.persistBlueprint(blueprint);
return { success: true, blueprint };
}
/**
* Update blueprint metadata
* @param {string} blueprintId - Blueprint to update
* @param {Object} updates - Metadata updates
* @returns {Object} Update result
*/
updateMetadata(blueprintId, updates) {
const blueprint = this.blueprints.get(blueprintId);
if (!blueprint) {
return { success: false, reason: 'Blueprint not found' };
}
Object.assign(blueprint.metadata, updates);
blueprint.modified = Date.now();
this.persistBlueprint(blueprint);
return { success: true, blueprint };
}
/**
* List all blueprints
* @param {Object} options - Filter and sort options
* @returns {Array} Blueprint summaries
*/
list(options = {}) {
let blueprints = Array.from(this.blueprints.values());
// Filter by tag
if (options.tag) {
blueprints = blueprints.filter(bp =>
bp.metadata.tags.includes(options.tag)
);
}
// Filter by search term
if (options.search) {
const term = options.search.toLowerCase();
blueprints = blueprints.filter(bp =>
bp.name.toLowerCase().includes(term) ||
bp.metadata.description.toLowerCase().includes(term)
);
}
// Sort
const sortField = options.sortBy ?? 'modified';
const sortDir = options.sortDir ?? 'desc';
blueprints.sort((a, b) => {
let aVal = sortField === 'name' ? a.name : a[sortField];
let bVal = sortField === 'name' ? b.name : b[sortField];
if (typeof aVal === 'string') {
aVal = aVal.toLowerCase();
bVal = bVal.toLowerCase();
}
const comparison = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
return sortDir === 'desc' ? -comparison : comparison;
});
// Return summaries
return blueprints.map(bp => ({
id: bp.id,
name: bp.name,
pieceCount: bp.metadata.pieceCount,
size: bp.size,
tags: bp.metadata.tags,
created: bp.created,
modified: bp.modified,
thumbnail: bp.metadata.thumbnail
}));
}
/**
* Export blueprint as JSON string
* @param {string} blueprintId - Blueprint to export
* @returns {string|null} JSON string or null
*/
export(blueprintId) {
const blueprint = this.blueprints.get(blueprintId);
if (!blueprint) return null;
// Create export copy without internal fields
const exportData = {
...blueprint,
exportedAt: Date.now(),
exportVersion: BLUEPRINT_VERSION
};
return JSON.stringify(exportData, null, 2);
}
/**
* Import blueprint from JSON string
* @param {string} jsonString - JSON blueprint data
* @returns {Object} Import result
*/
import(jsonString) {
try {
const data = JSON.parse(jsonString);
// Validate structure
const validation = this.validateBlueprint(data);
if (!validation.valid) {
return {
success: false,
reason: 'Invalid blueprint format',
errors: validation.errors
};
}
// Generate new ID to avoid conflicts
const blueprint = {
...data,
id: this.generateId(),
imported: true,
importedAt: Date.now(),
modified: Date.now()
};
// Store
this.blueprints.set(blueprint.id, blueprint);
this.persistBlueprint(blueprint);
return { success: true, blueprint };
} catch (e) {
return { success: false, reason: `Parse error: ${e.message}` };
}
}
/**
* Validate blueprint structure
* @param {Object} data - Blueprint data to validate
* @returns {ValidationResult} Validation result
*/
validateBlueprint(data) {
const errors = [];
const warnings = [];
// Required fields
if (!data.pieces || !Array.isArray(data.pieces)) {
errors.push('Missing or invalid pieces array');
}
if (!data.name) {
warnings.push('Missing name, will use default');
}
// Version check
if (data.version && data.version > BLUEPRINT_VERSION) {
errors.push(`Unsupported version: ${data.version}`);
}
// Piece validation
if (data.pieces) {
for (let i = 0; i < data.pieces.length; i++) {
const piece = data.pieces[i];
if (!piece.type) {
errors.push(`Piece ${i}: missing type`);
}
if (!piece.localPosition) {
errors.push(`Piece ${i}: missing localPosition`);
}
}
if (data.pieces.length > this.maxPiecesPerBlueprint) {
errors.push(`Too many pieces: ${data.pieces.length} (max: ${this.maxPiecesPerBlueprint})`);
}
}
return {
valid: errors.length === 0,
errors,
warnings
};
}
/**
* Duplicate a blueprint
* @param {string} blueprintId - Blueprint to duplicate
* @param {string} newName - Name for duplicate
* @returns {Object} Duplicate result
*/
duplicate(blueprintId, newName) {
const original = this.blueprints.get(blueprintId);
if (!original) {
return { success: false, reason: 'Blueprint not found' };
}
const duplicate = {
...JSON.parse(JSON.stringify(original)),
id: this.generateId(),
name: newName || `${original.name} (Copy)`,
created: Date.now(),
modified: Date.now()
};
this.blueprints.set(duplicate.id, duplicate);
this.persistBlueprint(duplicate);
return { success: true, blueprint: duplicate };
}
/**
* Calculate bounding box of pieces
*/
calculateBounds(pieces) {
const min = new THREE.Vector3(Infinity, Infinity, Infinity);
const max = new THREE.Vector3(-Infinity, -Infinity, -Infinity);
for (const piece of pieces) {
const pos = piece.position;
min.x = Math.min(min.x, pos.x);
min.y = Math.min(min.y, pos.y);
min.z = Math.min(min.z, pos.z);
max.x = Math.max(max.x, pos.x);
max.y = Math.max(max.y, pos.y);
max.z = Math.max(max.z, pos.z);
}
return { min, max };
}
/**
* Calculate blueprint origin (center bottom)
*/
calculateOrigin(bounds) {
return new THREE.Vector3(
(bounds.min.x + bounds.max.x) / 2,
bounds.min.y, // Keep Y at base
(bounds.min.z + bounds.max.z) / 2
);
}
/**
* Serialize a piece for storage
*/
serializePiece(piece, origin) {
return {
type: piece.type,
localPosition: {
x: piece.position.x - origin.x,
y: piece.position.y - origin.y,
z: piece.position.z - origin.z
},
rotation: {
x: piece.rotation?.x ?? 0,
y: piece.rotation?.y ?? 0,
z: piece.rotation?.z ?? 0
},
material: piece.material?.name ?? 'wood',
variant: piece.variant ?? null,
customData: piece.customData ?? null
};
}
/**
* Deserialize a piece to world coordinates
*/
deserializePiece(piece, worldOrigin, rotation) {
// Rotate local position around Y axis
const cos = Math.cos(rotation);
const sin = Math.sin(rotation);
const rotatedX = piece.localPosition.x * cos - piece.localPosition.z * sin;
const rotatedZ = piece.localPosition.x * sin + piece.localPosition.z * cos;
return {
type: piece.type,
position: new THREE.Vector3(
worldOrigin.x + rotatedX,
worldOrigin.y + piece.localPosition.y,
worldOrigin.z + rotatedZ
),
rotation: new THREE.Euler(
piece.rotation.x,
piece.rotation.y + rotation,
piece.rotation.z
),
material: { name: piece.material },
variant: piece.variant,
customData: piece.customData
};
}
/**
* Get transformed bounds for a blueprint placement
*/
getTransformedBounds(blueprint, position, rotation) {
const cos = Math.cos(rotation);
const sin = Math.sin(rotation);
const bounds = blueprint.bounds;
// Rotate corners
const corners = [
{ x: bounds.min.x, z: bounds.min.z },
{ x: bounds.max.x, z: bounds.min.z },
{ x: bounds.max.x, z: bounds.max.z },
{ x: bounds.min.x, z: bounds.max.z }
];
const transformed = corners.map(c => ({
x: position.x + (c.x * cos - c.z * sin),
z: position.z + (c.x * sin + c.z * cos)
}));
return {
min: {
x: Math.min(...transformed.map(c => c.x)),
y: position.y + bounds.min.y,
z: Math.min(...transformed.map(c => c.z))
},
max: {
x: Math.max(...transformed.map(c => c.x)),
y: position.y + bounds.max.y,
z: Math.max(...transformed.map(c => c.z))
}
};
}
/**
* Count materials in piece collection
*/
countMaterials(pieces) {
const counts = {};
for (const piece of pieces) {
const material = piece.material?.name ?? 'unknown';
counts[material] = (counts[material] ?? 0) + 1;
}
return counts;
}
/**
* Generate unique ID
*/
generateId() {
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).substr(2, 8);
return `bp_${timestamp}_${random}`;
}
/**
* Persist blueprint to storage
*/
persistBlueprint(blueprint) {
if (!this.storage) return;
try {
const all = JSON.parse(this.storage.getItem(this.storageKey) || '{}');
all[blueprint.id] = blueprint;
this.storage.setItem(this.storageKey, JSON.stringify(all));
} catch (e) {
console.warn('Failed to persist blueprint:', e);
}
}
/**
* Remove blueprint from storage
*/
removeFromStorage(blueprintId) {
if (!this.storage) return;
try {
const all = JSON.parse(this.storage.getItem(this.storageKey) || '{}');
delete all[blueprintId];
this.storage.setItem(this.storageKey, JSON.stringify(all));
} catch (e) {
console.warn('Failed to remove blueprint from storage:', e);
}
}
/**
* Load all blueprints from storage
*/
loadFromStorage() {
if (!this.storage) return;
try {
const all = JSON.parse(this.storage.getItem(this.storageKey) || '{}');
for (const [id, blueprint] of Object.entries(all)) {
this.blueprints.set(id, blueprint);
}
} catch (e) {
console.warn('Failed to load blueprints from storage:', e);
}
}
/**
* Clear all blueprints
*/
clear() {
this.blueprints.clear();
if (this.storage) {
this.storage.removeItem(this.storageKey);
}
}
/**
* Get statistics
*/
getStats() {
let totalPieces = 0;
for (const bp of this.blueprints.values()) {
totalPieces += bp.metadata.pieceCount;
}
return {
count: this.blueprints.size,
maxBlueprints: this.maxBlueprints,
totalPieces,
averagePieces: this.blueprints.size > 0
? Math.round(totalPieces / this.blueprints.size)
: 0
};
}
}
export default BlueprintManager;
/**
* CommandHistory - Undo/redo system using command pattern
*
* Every building action becomes a command object with execute() and undo()
* methods. This enables undo/redo, networked replication (serialize commands
* and send to server), and macro recording (save command sequences).
*
* Usage:
* const history = new CommandHistory({ maxSize: 50 });
* history.execute(new PlaceCommand(piece, position, buildingSystem));
* history.undo();
* history.redo();
*/
import * as THREE from 'three';
/**
* Base command class - extend this for specific operations
*/
export class BuildCommand {
constructor() {
this.id = `cmd_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;
this.timestamp = Date.now();
this.executed = false;
}
/**
* Execute the command
* @returns {Object} Execution result { success: boolean, ... }
*/
execute() {
throw new Error('execute() must be implemented by subclass');
}
/**
* Undo the command (reverse execute)
* @returns {Object} Undo result { success: boolean, ... }
*/
undo() {
throw new Error('undo() must be implemented by subclass');
}
/**
* Check if command can be executed in current state
* @returns {boolean} Whether command is valid
*/
canExecute() {
return true;
}
/**
* Get command type name
* @returns {string} Command type
*/
getType() {
return this.constructor.name;
}
/**
* Get human-readable description
* @returns {string} Description
*/
getDescription() {
return this.getType();
}
/**
* Serialize command for networking/saving
* @returns {Object} Serialized data
*/
serialize() {
return {
type: this.getType(),
id: this.id,
timestamp: this.timestamp,
data: this.getData()
};
}
/**
* Get command-specific data for serialization
* Override in subclasses
* @returns {Object} Command data
*/
getData() {
return {};
}
}
/**
* Place a building piece
*/
export class PlaceCommand extends BuildCommand {
constructor(pieceData, position, rotation, buildingSystem) {
super();
this.pieceData = pieceData;
this.position = position.clone();
this.rotation = rotation?.clone() ?? new THREE.Euler();
this.buildingSystem = buildingSystem;
this.placedPiece = null;
}
execute() {
if (!this.canExecute()) {
return { success: false, reason: 'Cannot place here' };
}
this.placedPiece = this.buildingSystem.placePiece(
this.pieceData,
this.position,
this.rotation
);
this.executed = true;
return {
success: true,
piece: this.placedPiece,
pieceId: this.placedPiece?.id
};
}
undo() {
if (!this.placedPiece) {
return { success: false, reason: 'No piece to remove' };
}
this.buildingSystem.removePiece(this.placedPiece.id);
this.executed = false;
return { success: true, removedId: this.placedPiece.id };
}
canExecute() {
return this.buildingSystem.canPlace?.(this.pieceData, this.position) ?? true;
}
getDescription() {
return `Place ${this.pieceData.type}`;
}
getData() {
return {
pieceType: this.pieceData.type,
material: this.pieceData.material?.name,
position: { x: this.position.x, y: this.position.y, z: this.position.z },
rotation: { x: this.rotation.x, y: this.rotation.y, z: this.rotation.z },
placedPieceId: this.placedPiece?.id
};
}
}
/**
* Remove a building piece
*/
export class RemoveCommand extends BuildCommand {
constructor(piece, buildingSystem) {
super();
this.piece = piece;
this.buildingSystem = buildingSystem;
this.savedState = null;
}
execute() {
// Save full state for undo
this.savedState = {
id: this.piece.id,
type: this.piece.type,
position: this.piece.position.clone(),
rotation: this.piece.rotation?.clone() ?? new THREE.Euler(),
material: this.piece.material,
health: this.piece.health,
variant: this.piece.variant,
customData: this.piece.customData
};
this.buildingSystem.removePiece(this.piece.id);
this.executed = true;
return { success: true, removedId: this.piece.id };
}
undo() {
if (!this.savedState) {
return { success: false, reason: 'No saved state' };
}
// Restore piece
const restored = this.buildingSystem.placePiece(
{
type: this.savedState.type,
material: this.savedState.material,
variant: this.savedState.variant
},
this.savedState.position,
this.savedState.rotation
);
// Restore additional properties
if (restored) {
restored.health = this.savedState.health;
restored.customData = this.savedState.customData;
}
this.piece = restored;
this.executed = false;
return { success: true, piece: restored };
}
getDescription() {
return `Remove ${this.piece.type}`;
}
getData() {
return {
pieceId: this.piece.id,
pieceType: this.piece.type,
savedState: this.savedState
};
}
}
/**
* Upgrade a piece's material
*/
export class UpgradeCommand extends BuildCommand {
constructor(piece, newMaterial, buildingSystem) {
super();
this.piece = piece;
this.newMaterial = newMaterial;
this.oldMaterial = null;
this.buildingSystem = buildingSystem;
}
execute() {
this.oldMaterial = this.piece.material;
this.buildingSystem.upgradePiece(this.piece.id, this.newMaterial);
this.executed = true;
return {
success: true,
pieceId: this.piece.id,
oldMaterial: this.oldMaterial?.name,
newMaterial: this.newMaterial?.name
};
}
undo() {
if (!this.oldMaterial) {
return { success: false, reason: 'No previous material' };
}
this.buildingSystem.upgradePiece(this.piece.id, this.oldMaterial);
this.executed = false;
return { success: true, pieceId: this.piece.id };
}
getDescription() {
return `Upgrade to ${this.newMaterial?.name ?? 'unknown'}`;
}
getData() {
return {
pieceId: this.piece.id,
oldMaterial: this.oldMaterial?.name,
newMaterial: this.newMaterial?.name
};
}
}
/**
* Rotate a piece
*/
export class RotateCommand extends BuildCommand {
constructor(piece, newRotation, buildingSystem) {
super();
this.piece = piece;
this.newRotation = newRotation.clone();
this.oldRotation = null;
this.buildingSystem = buildingSystem;
}
execute() {
this.oldRotation = this.piece.rotation.clone();
this.piece.rotation.copy(this.newRotation);
if (this.buildingSystem.onPieceModified) {
this.buildingSystem.onPieceModified(this.piece);
}
this.executed = true;
return { success: true, pieceId: this.piece.id };
}
undo() {
if (!this.oldRotation) {
return { success: false, reason: 'No previous rotation' };
}
this.piece.rotation.copy(this.oldRotation);
if (this.buildingSystem.onPieceModified) {
this.buildingSystem.onPieceModified(this.piece);
}
this.executed = false;
return { success: true, pieceId: this.piece.id };
}
getDescription() {
return `Rotate ${this.piece.type}`;
}
}
/**
* Batch command - execute multiple commands as one undo unit
*/
export class BatchCommand extends BuildCommand {
constructor(commands, description = null) {
super();
this.commands = commands;
this.customDescription = description;
this.executedCommands = [];
}
execute() {
this.executedCommands = [];
for (const command of this.commands) {
if (command.canExecute()) {
const result = command.execute();
if (result.success) {
this.executedCommands.push(command);
}
}
}
this.executed = true;
return {
success: this.executedCommands.length > 0,
executed: this.executedCommands.length,
total: this.commands.length,
failed: this.commands.length - this.executedCommands.length
};
}
undo() {
// Undo in reverse order
for (let i = this.executedCommands.length - 1; i >= 0; i--) {
this.executedCommands[i].undo();
}
this.executedCommands = [];
this.executed = false;
return { success: true };
}
getDescription() {
return this.customDescription ?? `Batch (${this.commands.length} operations)`;
}
getData() {
return {
commands: this.commands.map(c => c.serialize()),
executedCount: this.executedCommands.length
};
}
}
/**
* CommandHistory - Manages the undo/redo stack
*/
export class CommandHistory {
constructor(options = {}) {
this.maxSize = options.maxSize ?? 100;
this.undoStack = [];
this.redoStack = [];
// Grouping support (for compound operations)
this.isGrouping = false;
this.groupCommands = [];
this.groupDescription = null;
// Event callbacks
this.onExecute = options.onExecute ?? null;
this.onUndo = options.onUndo ?? null;
this.onRedo = options.onRedo ?? null;
this.onChange = options.onChange ?? null;
}
/**
* Execute a command and add to history
* @param {BuildCommand} command - Command to execute
* @returns {Object} Execution result
*/
execute(command) {
if (!command.canExecute()) {
return { success: false, reason: 'Command cannot be executed' };
}
const result = command.execute();
if (result.success) {
if (this.isGrouping) {
// Add to current group
this.groupCommands.push(command);
} else {
// Add to undo stack
this.undoStack.push(command);
// Clear redo stack (new action invalidates redo)
this.redoStack = [];
// Enforce max size
this.enforceMaxSize();
}
if (this.onExecute) this.onExecute(command, result);
if (this.onChange) this.onChange(this.getStatus());
}
return result;
}
/**
* Undo the last command
* @returns {Object} Undo result
*/
undo() {
if (this.undoStack.length === 0) {
return { success: false, reason: 'Nothing to undo' };
}
const command = this.undoStack.pop();
const result = command.undo();
if (result.success) {
this.redoStack.push(command);
if (this.onUndo) this.onUndo(command, result);
if (this.onChange) this.onChange(this.getStatus());
} else {
// Restore to stack if undo failed
this.undoStack.push(command);
}
return result;
}
/**
* Redo the last undone command
* @returns {Object} Redo result
*/
redo() {
if (this.redoStack.length === 0) {
return { success: false, reason: 'Nothing to redo' };
}
const command = this.redoStack.pop();
const result = command.execute();
if (result.success) {
this.undoStack.push(command);
if (this.onRedo) this.onRedo(command, result);
if (this.onChange) this.onChange(this.getStatus());
} else {
// Restore to stack if redo failed
this.redoStack.push(command);
}
return result;
}
/**
* Begin a command group (multiple commands as one undo unit)
* @param {string} description - Group description
*/
beginGroup(description = null) {
if (this.isGrouping) {
console.warn('Already in a command group');
return;
}
this.isGrouping = true;
this.groupCommands = [];
this.groupDescription = description;
}
/**
* End the current command group
* @returns {Object} Result with grouped command
*/
endGroup() {
if (!this.isGrouping) {
return { success: false, reason: 'Not in a command group' };
}
this.isGrouping = false;
if (this.groupCommands.length === 0) {
return { success: true, commands: 0 };
}
// Create batch command from group
const batch = new BatchCommand(this.groupCommands, this.groupDescription);
batch.executedCommands = [...this.groupCommands]; // Already executed
batch.executed = true;
// Add batch to undo stack
this.undoStack.push(batch);
this.redoStack = [];
this.enforceMaxSize();
const count = this.groupCommands.length;
this.groupCommands = [];
this.groupDescription = null;
if (this.onChange) this.onChange(this.getStatus());
return { success: true, commands: count };
}
/**
* Cancel current command group (undo all grouped commands)
*/
cancelGroup() {
if (!this.isGrouping) return;
// Undo all grouped commands in reverse order
for (let i = this.groupCommands.length - 1; i >= 0; i--) {
this.groupCommands[i].undo();
}
this.isGrouping = false;
this.groupCommands = [];
this.groupDescription = null;
}
/**
* Check if undo is available
*/
canUndo() {
return this.undoStack.length > 0 && !this.isGrouping;
}
/**
* Check if redo is available
*/
canRedo() {
return this.redoStack.length > 0 && !this.isGrouping;
}
/**
* Clear all history
*/
clear() {
this.undoStack = [];
this.redoStack = [];
this.isGrouping = false;
this.groupCommands = [];
if (this.onChange) this.onChange(this.getStatus());
}
/**
* Get current history status (for UI)
*/
getStatus() {
return {
undoCount: this.undoStack.length,
redoCount: this.redoStack.length,
canUndo: this.canUndo(),
canRedo: this.canRedo(),
isGrouping: this.isGrouping,
groupSize: this.groupCommands.length,
lastUndo: this.undoStack.length > 0
? this.undoStack[this.undoStack.length - 1].getDescription()
: null,
lastRedo: this.redoStack.length > 0
? this.redoStack[this.redoStack.length - 1].getDescription()
: null
};
}
/**
* Get undo history descriptions (for UI menu)
*/
getUndoHistory(limit = 10) {
return this.undoStack
.slice(-limit)
.reverse()
.map(cmd => ({
id: cmd.id,
type: cmd.getType(),
description: cmd.getDescription(),
timestamp: cmd.timestamp
}));
}
/**
* Get redo history descriptions
*/
getRedoHistory(limit = 10) {
return this.redoStack
.slice(-limit)
.reverse()
.map(cmd => ({
id: cmd.id,
type: cmd.getType(),
description: cmd.getDescription(),
timestamp: cmd.timestamp
}));
}
/**
* Enforce maximum history size
*/
enforceMaxSize() {
while (this.undoStack.length > this.maxSize) {
this.undoStack.shift();
}
}
/**
* Serialize full history for saving
*/
serialize() {
return {
version: 1,
undoStack: this.undoStack.map(cmd => cmd.serialize()),
redoStack: this.redoStack.map(cmd => cmd.serialize())
};
}
}
export default CommandHistory;
/**
* GhostPreview - Transparent placement preview system
*
* Shows a semi-transparent preview of what will be placed before the
* player commits. Changes color based on placement validity (green = valid,
* red = invalid). Essential for good building UX.
*
* Usage:
* const ghost = new GhostPreview(scene);
* ghost.show('wall', cursorPosition, rotation);
* ghost.setValid(canPlaceHere);
* ghost.updatePosition(newPosition);
* ghost.hide();
*/
import * as THREE from 'three';
/**
* Ghost preview states
*/
export const GhostState = {
HIDDEN: 'hidden',
VALID: 'valid',
INVALID: 'invalid',
BLOCKED: 'blocked'
};
export class GhostPreview {
/**
* Create ghost preview system
* @param {THREE.Scene} scene - Scene to add ghosts to
* @param {Object} options - Configuration options
*/
constructor(scene, options = {}) {
this.scene = scene;
// Appearance
this.validColor = new THREE.Color(options.validColor ?? 0x00ff00);
this.invalidColor = new THREE.Color(options.invalidColor ?? 0xff0000);
this.blockedColor = new THREE.Color(options.blockedColor ?? 0xff8800);
this.opacity = options.opacity ?? 0.5;
this.wireframe = options.wireframe ?? false;
// Animation
this.pulseEnabled = options.pulseEnabled ?? true;
this.pulseSpeed = options.pulseSpeed ?? 3;
this.pulseAmount = options.pulseAmount ?? 0.3;
this.pulsePhase = 0;
// State
this.state = GhostState.HIDDEN;
this.currentType = null;
this.activeGhost = null;
// Mesh cache
this.meshCache = new Map();
// External mesh factory (optional)
this.meshFactory = options.meshFactory ?? null;
// Snap settings
this.snapEnabled = options.snapEnabled ?? true;
this.snapGrid = options.snapGrid ?? 1;
this.snapRotation = options.snapRotation ?? Math.PI / 4; // 45 degrees
// Group for blueprint previews
this.blueprintGroup = null;
}
/**
* Show ghost preview at position
* @param {string} pieceType - Type of piece to preview
* @param {THREE.Vector3} position - World position
* @param {THREE.Euler|number} rotation - Rotation (Euler or Y angle)
* @returns {THREE.Mesh} The ghost mesh
*/
show(pieceType, position, rotation = 0) {
// Hide current ghost if different type
if (this.activeGhost && this.currentType !== pieceType) {
this.hide();
}
// Get or create ghost mesh
let ghost = this.meshCache.get(pieceType);
if (!ghost) {
ghost = this.createGhostMesh(pieceType);
this.meshCache.set(pieceType, ghost);
}
// Apply position (with optional snapping)
const snappedPos = this.snapEnabled
? this.snapPosition(position)
: position;
ghost.position.copy(snappedPos);
// Apply rotation
if (typeof rotation === 'number') {
ghost.rotation.set(0, rotation, 0);
} else if (rotation instanceof THREE.Euler) {
ghost.rotation.copy(rotation);
}
// Add to scene if not already
if (!ghost.parent) {
this.scene.add(ghost);
}
ghost.visible = true;
this.activeGhost = ghost;
this.currentType = pieceType;
this.state = GhostState.VALID;
// Set initial valid color
this.setGhostColor(ghost, this.validColor);
return ghost;
}
/**
* Hide current ghost preview
*/
hide() {
if (this.activeGhost) {
this.activeGhost.visible = false;
}
if (this.blueprintGroup) {
this.blueprintGroup.visible = false;
}
this.state = GhostState.HIDDEN;
}
/**
* Update ghost position
* @param {THREE.Vector3} position - New position
* @param {THREE.Euler|number} rotation - Optional new rotation
*/
updatePosition(position, rotation = null) {
if (!this.activeGhost) return;
const snappedPos = this.snapEnabled
? this.snapPosition(position)
: position;
this.activeGhost.position.copy(snappedPos);
if (rotation !== null) {
if (typeof rotation === 'number') {
this.activeGhost.rotation.y = this.snapEnabled
? this.snapRotationValue(rotation)
: rotation;
} else if (rotation instanceof THREE.Euler) {
this.activeGhost.rotation.copy(rotation);
}
}
}
/**
* Set validity state (changes color)
* @param {boolean} isValid - Whether placement is valid
* @param {string} reason - Optional reason for invalidity
*/
setValid(isValid, reason = null) {
if (!this.activeGhost && !this.blueprintGroup) return;
const target = this.blueprintGroup ?? this.activeGhost;
if (isValid) {
this.state = GhostState.VALID;
this.setGhostColor(target, this.validColor);
} else {
this.state = reason === 'blocked' ? GhostState.BLOCKED : GhostState.INVALID;
const color = this.state === GhostState.BLOCKED
? this.blockedColor
: this.invalidColor;
this.setGhostColor(target, color);
}
}
/**
* Update animation (call from render loop)
* @param {number} deltaTime - Time since last frame
*/
update(deltaTime) {
if (this.state === GhostState.HIDDEN || !this.pulseEnabled) return;
this.pulsePhase += deltaTime * this.pulseSpeed;
// Calculate pulsing opacity
const pulse = (Math.sin(this.pulsePhase) + 1) / 2;
const opacity = this.opacity * (1 - this.pulseAmount + pulse * this.pulseAmount);
const target = this.blueprintGroup ?? this.activeGhost;
if (target) {
this.setGhostOpacity(target, opacity);
}
}
/**
* Show blueprint preview (multiple pieces)
* @param {Array} pieces - Pieces to preview
* @param {THREE.Vector3} position - Blueprint origin position
* @param {number} rotation - Y-axis rotation
* @returns {THREE.Group} The preview group
*/
showBlueprint(pieces, position, rotation = 0) {
this.hide();
// Create group for blueprint
this.blueprintGroup = new THREE.Group();
this.blueprintGroup.name = 'blueprint-preview';
// Create ghost for each piece
for (const piece of pieces) {
const ghost = this.createGhostMesh(piece.type);
ghost.position.copy(piece.position);
if (piece.rotation) {
ghost.rotation.copy(piece.rotation);
}
this.blueprintGroup.add(ghost);
}
// Position and rotate the group
const snappedPos = this.snapEnabled
? this.snapPosition(position)
: position;
this.blueprintGroup.position.copy(snappedPos);
this.blueprintGroup.rotation.y = rotation;
this.scene.add(this.blueprintGroup);
this.state = GhostState.VALID;
this.setGhostColor(this.blueprintGroup, this.validColor);
return this.blueprintGroup;
}
/**
* Update blueprint preview position
* @param {THREE.Vector3} position - New position
* @param {number} rotation - New Y rotation
*/
updateBlueprintPosition(position, rotation = null) {
if (!this.blueprintGroup) return;
const snappedPos = this.snapEnabled
? this.snapPosition(position)
: position;
this.blueprintGroup.position.copy(snappedPos);
if (rotation !== null) {
this.blueprintGroup.rotation.y = this.snapEnabled
? this.snapRotationValue(rotation)
: rotation;
}
}
/**
* Rotate current ghost by increment
* @param {number} angle - Angle to rotate by (radians)
*/
rotate(angle) {
const target = this.blueprintGroup ?? this.activeGhost;
if (!target) return;
target.rotation.y += angle;
if (this.snapEnabled) {
target.rotation.y = this.snapRotationValue(target.rotation.y);
}
}
/**
* Create ghost mesh for piece type
*/
createGhostMesh(pieceType) {
let geometry;
// Use factory if provided
if (this.meshFactory) {
try {
const baseMesh = this.meshFactory.createMesh(pieceType);
geometry = baseMesh.geometry.clone();
} catch (e) {
geometry = this.getDefaultGeometry(pieceType);
}
} else {
geometry = this.getDefaultGeometry(pieceType);
}
// Create semi-transparent material
const material = new THREE.MeshBasicMaterial({
color: this.validColor,
transparent: true,
opacity: this.opacity,
side: THREE.DoubleSide,
depthWrite: false,
wireframe: this.wireframe
});
const mesh = new THREE.Mesh(geometry, material);
mesh.renderOrder = 999; // Render on top
mesh.userData.isGhost = true;
return mesh;
}
/**
* Get default geometry for piece type
*/
getDefaultGeometry(pieceType) {
const geometries = {
foundation: () => new THREE.BoxGeometry(4, 0.2, 4),
wall: () => new THREE.BoxGeometry(4, 3, 0.2),
floor: () => new THREE.BoxGeometry(4, 0.1, 4),
ceiling: () => new THREE.BoxGeometry(4, 0.1, 4),
pillar: () => new THREE.BoxGeometry(0.5, 3, 0.5),
roof: () => this.createRoofGeometry(),
ramp: () => this.createRampGeometry(),
stairs: () => this.createStairsGeometry(),
door: () => new THREE.BoxGeometry(1.2, 2.5, 0.2),
window: () => new THREE.BoxGeometry(2, 1.5, 0.2),
fence: () => new THREE.BoxGeometry(4, 1.5, 0.1),
halfWall: () => new THREE.BoxGeometry(4, 1.5, 0.2)
};
const factory = geometries[pieceType] ?? (() => new THREE.BoxGeometry(1, 1, 1));
return factory();
}
/**
* Create angled roof geometry
*/
createRoofGeometry() {
const geometry = new THREE.BufferGeometry();
const vertices = new Float32Array([
// Triangle face 1
-2, 0, -2, 2, 0, -2, 0, 1.5, 0,
// Triangle face 2
2, 0, -2, 2, 0, 2, 0, 1.5, 0,
// Triangle face 3
2, 0, 2, -2, 0, 2, 0, 1.5, 0,
// Triangle face 4
-2, 0, 2, -2, 0, -2, 0, 1.5, 0,
// Bottom face
-2, 0, -2, -2, 0, 2, 2, 0, 2,
-2, 0, -2, 2, 0, 2, 2, 0, -2
]);
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
geometry.computeVertexNormals();
return geometry;
}
/**
* Create ramp geometry
*/
createRampGeometry() {
const geometry = new THREE.BufferGeometry();
const vertices = new Float32Array([
// Ramp surface
-2, 0, 2, 2, 0, 2, 2, 2, -2,
-2, 0, 2, 2, 2, -2, -2, 2, -2,
// Bottom
-2, 0, 2, -2, 0, -2, 2, 0, -2,
-2, 0, 2, 2, 0, -2, 2, 0, 2,
// Sides
-2, 0, 2, -2, 2, -2, -2, 0, -2,
2, 0, 2, 2, 0, -2, 2, 2, -2,
// Back
-2, 2, -2, 2, 2, -2, 2, 0, -2,
-2, 2, -2, 2, 0, -2, -2, 0, -2
]);
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
geometry.computeVertexNormals();
return geometry;
}
/**
* Create stairs geometry (simplified)
*/
createStairsGeometry() {
const group = new THREE.Group();
const stepCount = 6;
const stepHeight = 0.5;
const stepDepth = 0.5;
const width = 2;
for (let i = 0; i < stepCount; i++) {
const stepGeo = new THREE.BoxGeometry(width, stepHeight, stepDepth);
const step = new THREE.Mesh(stepGeo);
step.position.set(0, i * stepHeight + stepHeight / 2, -i * stepDepth);
group.add(step);
}
// Merge into single geometry
const geometry = new THREE.BoxGeometry(width, stepCount * stepHeight, stepCount * stepDepth);
return geometry;
}
/**
* Set color of ghost mesh or group
*/
setGhostColor(target, color) {
if (target.material) {
target.material.color.copy(color);
}
if (target.children) {
target.children.forEach(child => {
if (child.material) {
child.material.color.copy(color);
}
});
}
}
/**
* Set opacity of ghost mesh or group
*/
setGhostOpacity(target, opacity) {
if (target.material) {
target.material.opacity = opacity;
}
if (target.children) {
target.children.forEach(child => {
if (child.material) {
child.material.opacity = opacity;
}
});
}
}
/**
* Snap position to grid
*/
snapPosition(position) {
return new THREE.Vector3(
Math.round(position.x / this.snapGrid) * this.snapGrid,
Math.round(position.y / this.snapGrid) * this.snapGrid,
Math.round(position.z / this.snapGrid) * this.snapGrid
);
}
/**
* Snap rotation to increments
*/
snapRotationValue(rotation) {
return Math.round(rotation / this.snapRotation) * this.snapRotation;
}
/**
* Get current ghost position
*/
getPosition() {
const target = this.blueprintGroup ?? this.activeGhost;
return target?.position.clone() ?? null;
}
/**
* Get current ghost rotation
*/
getRotation() {
const target = this.blueprintGroup ?? this.activeGhost;
return target?.rotation.clone() ?? null;
}
/**
* Get current state
*/
getState() {
return this.state;
}
/**
* Check if ghost is currently valid
*/
isValid() {
return this.state === GhostState.VALID;
}
/**
* Check if ghost is visible
*/
isVisible() {
return this.state !== GhostState.HIDDEN;
}
/**
* Configure snap settings
*/
setSnapSettings(grid, rotation) {
if (grid !== undefined) this.snapGrid = grid;
if (rotation !== undefined) this.snapRotation = rotation;
}
/**
* Enable or disable snapping
*/
setSnapEnabled(enabled) {
this.snapEnabled = enabled;
}
/**
* Dispose of all resources
*/
dispose() {
this.hide();
// Dispose cached meshes
for (const [type, mesh] of this.meshCache) {
if (mesh.geometry) mesh.geometry.dispose();
if (mesh.material) mesh.material.dispose();
if (mesh.parent) mesh.parent.remove(mesh);
}
this.meshCache.clear();
// Dispose blueprint group
if (this.blueprintGroup) {
this.blueprintGroup.traverse(child => {
if (child.geometry) child.geometry.dispose();
if (child.material) child.material.dispose();
});
if (this.blueprintGroup.parent) {
this.blueprintGroup.parent.remove(this.blueprintGroup);
}
this.blueprintGroup = null;
}
}
}
export default GhostPreview;
/**
* SelectionManager - Multi-selection and group operations
*
* Handles piece selection including single click, shift+click additive,
* and box/lasso selection. Enables batch operations like copy, delete,
* upgrade on multiple pieces at once.
*
* Usage:
* const selection = new SelectionManager({ maxSelection: 100 });
* selection.select(piece);
* selection.boxSelect(startPoint, endPoint, camera, allPieces);
* const selected = selection.getSelection();
* selection.clearSelection();
*/
import * as THREE from 'three';
/**
* Selection modes
*/
export const SelectionMode = {
SINGLE: 'single',
ADDITIVE: 'additive',
SUBTRACTIVE: 'subtractive',
TOGGLE: 'toggle'
};
export class SelectionManager {
/**
* Create selection manager
* @param {Object} options - Configuration options
*/
constructor(options = {}) {
this.selected = new Set();
this.maxSelection = options.maxSelection ?? 500;
// Visual feedback
this.highlightEnabled = options.highlightEnabled ?? true;
this.highlightColor = new THREE.Color(options.highlightColor ?? 0x00aaff);
this.highlightIntensity = options.highlightIntensity ?? 0.4;
this.outlineEnabled = options.outlineEnabled ?? true;
// Selection box visualization
this.boxHelper = null;
this.boxMaterial = new THREE.LineBasicMaterial({
color: options.boxColor ?? 0x00aaff,
linewidth: 2
});
// Hover state
this.hoveredPiece = null;
this.hoverColor = new THREE.Color(options.hoverColor ?? 0xffff00);
// Callbacks
this.onSelectionChanged = options.onSelectionChanged ?? null;
this.onHoverChanged = options.onHoverChanged ?? null;
}
/**
* Select a single piece
* @param {Object} piece - Piece to select
* @param {string} mode - Selection mode
* @returns {Object} Selection result
*/
select(piece, mode = SelectionMode.SINGLE) {
if (!piece) {
return { success: false, reason: 'No piece provided' };
}
switch (mode) {
case SelectionMode.SINGLE:
return this.selectSingle(piece);
case SelectionMode.ADDITIVE:
return this.selectAdditive(piece);
case SelectionMode.SUBTRACTIVE:
return this.deselectPiece(piece);
case SelectionMode.TOGGLE:
return this.togglePiece(piece);
default:
return this.selectSingle(piece);
}
}
/**
* Select single piece, clearing previous selection
*/
selectSingle(piece) {
this.clearSelection(false); // Don't notify yet
this.selected.add(piece);
this.applyHighlight(piece, true);
this.notifyChange();
return { success: true, count: 1 };
}
/**
* Add piece to selection
*/
selectAdditive(piece) {
if (this.selected.has(piece)) {
return { success: true, count: this.selected.size, alreadySelected: true };
}
if (this.selected.size >= this.maxSelection) {
return { success: false, reason: 'Selection limit reached' };
}
this.selected.add(piece);
this.applyHighlight(piece, true);
this.notifyChange();
return { success: true, count: this.selected.size };
}
/**
* Remove piece from selection
*/
deselectPiece(piece) {
if (!this.selected.has(piece)) {
return { success: true, count: this.selected.size, wasSelected: false };
}
this.selected.delete(piece);
this.applyHighlight(piece, false);
this.notifyChange();
return { success: true, count: this.selected.size };
}
/**
* Toggle piece selection state
*/
togglePiece(piece) {
if (this.selected.has(piece)) {
return this.deselectPiece(piece);
} else {
return this.selectAdditive(piece);
}
}
/**
* Select multiple pieces
* @param {Array} pieces - Pieces to select
* @param {boolean} additive - Add to existing selection
* @returns {Object} Selection result
*/
selectMultiple(pieces, additive = false) {
if (!additive) {
this.clearSelection(false);
}
let added = 0;
let skipped = 0;
for (const piece of pieces) {
if (this.selected.size >= this.maxSelection) {
skipped += pieces.length - added - skipped;
break;
}
if (!this.selected.has(piece)) {
this.selected.add(piece);
this.applyHighlight(piece, true);
added++;
} else {
skipped++;
}
}
this.notifyChange();
return {
success: true,
added,
skipped,
total: this.selected.size
};
}
/**
* Box/rectangle selection
* @param {Object} startNDC - Start point in normalized device coordinates
* @param {Object} endNDC - End point in normalized device coordinates
* @param {THREE.Camera} camera - Active camera
* @param {Array} pieces - All selectable pieces
* @param {boolean} additive - Add to existing selection
* @returns {Object} Selection result
*/
boxSelect(startNDC, endNDC, camera, pieces, additive = false) {
// Normalize box coordinates
const minX = Math.min(startNDC.x, endNDC.x);
const maxX = Math.max(startNDC.x, endNDC.x);
const minY = Math.min(startNDC.y, endNDC.y);
const maxY = Math.max(startNDC.y, endNDC.y);
// Find pieces within box
const toSelect = [];
for (const piece of pieces) {
const screenPos = this.worldToNDC(piece.position, camera);
if (screenPos &&
screenPos.x >= minX && screenPos.x <= maxX &&
screenPos.y >= minY && screenPos.y <= maxY) {
toSelect.push(piece);
}
}
return this.selectMultiple(toSelect, additive);
}
/**
* Sphere/radius selection
* @param {THREE.Vector3} center - Center point in world space
* @param {number} radius - Selection radius
* @param {Array} pieces - All selectable pieces
* @param {boolean} additive - Add to existing selection
* @returns {Object} Selection result
*/
radiusSelect(center, radius, pieces, additive = false) {
const radiusSquared = radius * radius;
const toSelect = [];
for (const piece of pieces) {
const distSquared = center.distanceToSquared(piece.position);
if (distSquared <= radiusSquared) {
toSelect.push(piece);
}
}
return this.selectMultiple(toSelect, additive);
}
/**
* Select all pieces of a specific type
* @param {string} type - Piece type to select
* @param {Array} pieces - All selectable pieces
* @param {boolean} additive - Add to existing selection
* @returns {Object} Selection result
*/
selectByType(type, pieces, additive = false) {
const toSelect = pieces.filter(p => p.type === type);
return this.selectMultiple(toSelect, additive);
}
/**
* Select all pieces of a specific material
* @param {string} material - Material name to select
* @param {Array} pieces - All selectable pieces
* @param {boolean} additive - Add to existing selection
* @returns {Object} Selection result
*/
selectByMaterial(material, pieces, additive = false) {
const toSelect = pieces.filter(p =>
p.material?.name?.toLowerCase() === material.toLowerCase()
);
return this.selectMultiple(toSelect, additive);
}
/**
* Select connected/adjacent pieces (flood fill)
* @param {Object} startPiece - Starting piece
* @param {Function} getNeighbors - Function to get neighboring pieces
* @param {number} maxDepth - Maximum connection depth
* @returns {Object} Selection result
*/
selectConnected(startPiece, getNeighbors, maxDepth = Infinity) {
const visited = new Set();
const queue = [{ piece: startPiece, depth: 0 }];
const toSelect = [];
while (queue.length > 0) {
const { piece, depth } = queue.shift();
if (visited.has(piece.id)) continue;
if (depth > maxDepth) continue;
visited.add(piece.id);
toSelect.push(piece);
if (toSelect.length >= this.maxSelection) break;
// Get neighbors and add to queue
const neighbors = getNeighbors(piece);
for (const neighbor of neighbors) {
if (!visited.has(neighbor.id)) {
queue.push({ piece: neighbor, depth: depth + 1 });
}
}
}
return this.selectMultiple(toSelect, false);
}
/**
* Invert selection within a set of pieces
* @param {Array} pieces - All selectable pieces
* @returns {Object} Selection result
*/
invertSelection(pieces) {
const currentlySelected = new Set(this.selected);
this.clearSelection(false);
const toSelect = pieces.filter(p => !currentlySelected.has(p));
return this.selectMultiple(toSelect, false);
}
/**
* Clear all selection
* @param {boolean} notify - Whether to trigger callback
*/
clearSelection(notify = true) {
for (const piece of this.selected) {
this.applyHighlight(piece, false);
}
this.selected.clear();
if (notify) {
this.notifyChange();
}
}
/**
* Get current selection as array
* @returns {Array} Selected pieces
*/
getSelection() {
return Array.from(this.selected);
}
/**
* Get selection count
* @returns {number} Number of selected pieces
*/
getCount() {
return this.selected.size;
}
/**
* Check if a piece is selected
* @param {Object} piece - Piece to check
* @returns {boolean} Whether piece is selected
*/
isSelected(piece) {
return this.selected.has(piece);
}
/**
* Check if selection is empty
* @returns {boolean} Whether selection is empty
*/
isEmpty() {
return this.selected.size === 0;
}
/**
* Set hover state for a piece
* @param {Object} piece - Piece being hovered (null to clear)
*/
setHovered(piece) {
// Clear previous hover
if (this.hoveredPiece && this.hoveredPiece !== piece) {
if (!this.isSelected(this.hoveredPiece)) {
this.applyHighlight(this.hoveredPiece, false);
} else {
// Restore selection highlight
this.applyHighlight(this.hoveredPiece, true);
}
}
this.hoveredPiece = piece;
// Apply hover highlight
if (piece && !this.isSelected(piece)) {
this.applyHoverHighlight(piece);
}
if (this.onHoverChanged) {
this.onHoverChanged(piece);
}
}
/**
* Get bounding box of selection
* @returns {Object|null} Bounding box with min, max, center, size
*/
getBounds() {
if (this.selected.size === 0) return null;
const min = new THREE.Vector3(Infinity, Infinity, Infinity);
const max = new THREE.Vector3(-Infinity, -Infinity, -Infinity);
for (const piece of this.selected) {
const pos = piece.position;
min.x = Math.min(min.x, pos.x);
min.y = Math.min(min.y, pos.y);
min.z = Math.min(min.z, pos.z);
max.x = Math.max(max.x, pos.x);
max.y = Math.max(max.y, pos.y);
max.z = Math.max(max.z, pos.z);
}
const center = new THREE.Vector3().addVectors(min, max).multiplyScalar(0.5);
const size = new THREE.Vector3().subVectors(max, min);
return { min, max, center, size };
}
/**
* Get center of selection
* @returns {THREE.Vector3|null} Center point
*/
getCenter() {
const bounds = this.getBounds();
return bounds?.center ?? null;
}
/**
* Get selection statistics
* @returns {Object} Selection statistics
*/
getStats() {
const byType = {};
const byMaterial = {};
for (const piece of this.selected) {
// Count by type
const type = piece.type ?? 'unknown';
byType[type] = (byType[type] ?? 0) + 1;
// Count by material
const material = piece.material?.name ?? 'unknown';
byMaterial[material] = (byMaterial[material] ?? 0) + 1;
}
return {
count: this.selected.size,
maxSelection: this.maxSelection,
byType,
byMaterial,
bounds: this.getBounds()
};
}
/**
* Apply visual highlight to piece
*/
applyHighlight(piece, highlighted) {
if (!this.highlightEnabled) return;
if (!piece.mesh) return;
if (highlighted) {
// Store original material if not already stored
if (!piece.mesh.userData.originalEmissive) {
piece.mesh.userData.originalEmissive = piece.mesh.material.emissive?.clone();
piece.mesh.userData.originalEmissiveIntensity = piece.mesh.material.emissiveIntensity;
}
// Apply highlight
if (piece.mesh.material.emissive) {
piece.mesh.material.emissive.copy(this.highlightColor);
piece.mesh.material.emissiveIntensity = this.highlightIntensity;
}
} else {
// Restore original material
if (piece.mesh.userData.originalEmissive !== undefined) {
if (piece.mesh.material.emissive) {
piece.mesh.material.emissive.copy(piece.mesh.userData.originalEmissive);
piece.mesh.material.emissiveIntensity = piece.mesh.userData.originalEmissiveIntensity;
}
delete piece.mesh.userData.originalEmissive;
delete piece.mesh.userData.originalEmissiveIntensity;
}
}
}
/**
* Apply hover highlight (different from selection)
*/
applyHoverHighlight(piece) {
if (!this.highlightEnabled) return;
if (!piece.mesh) return;
// Store original if not stored
if (!piece.mesh.userData.originalEmissive) {
piece.mesh.userData.originalEmissive = piece.mesh.material.emissive?.clone();
piece.mesh.userData.originalEmissiveIntensity = piece.mesh.material.emissiveIntensity;
}
// Apply hover color (dimmer than selection)
if (piece.mesh.material.emissive) {
piece.mesh.material.emissive.copy(this.hoverColor);
piece.mesh.material.emissiveIntensity = this.highlightIntensity * 0.5;
}
}
/**
* Convert world position to normalized device coordinates
*/
worldToNDC(position, camera) {
const vector = position.clone().project(camera);
// Check if behind camera
if (vector.z > 1) return null;
return { x: vector.x, y: vector.y };
}
/**
* Notify selection change
*/
notifyChange() {
if (this.onSelectionChanged) {
this.onSelectionChanged(this.getSelection(), this.getStats());
}
}
/**
* Create visual selection box for drawing
* @param {THREE.Scene} scene - Scene to add box to
* @returns {Object} Box controller
*/
createSelectionBox(scene) {
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(8 * 3); // 8 corners
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const indices = new Uint16Array([
0, 1, 1, 2, 2, 3, 3, 0, // Bottom
4, 5, 5, 6, 6, 7, 7, 4, // Top
0, 4, 1, 5, 2, 6, 3, 7 // Verticals
]);
geometry.setIndex(new THREE.BufferAttribute(indices, 1));
const box = new THREE.LineSegments(geometry, this.boxMaterial);
box.visible = false;
scene.add(box);
return {
show: () => { box.visible = true; },
hide: () => { box.visible = false; },
update: (start, end, camera) => {
// Update box geometry based on screen coordinates
// This would project the 2D box into 3D space
box.visible = true;
},
dispose: () => {
scene.remove(box);
geometry.dispose();
}
};
}
/**
* Dispose of resources
*/
dispose() {
this.clearSelection(false);
this.boxMaterial.dispose();
}
}
export default SelectionManager;
Related skills
FAQ
What systems does builder-ux provide?
Blueprint save/load, command-history undo/redo, ghost preview, and multi-select selection managers.
What engine is it for?
Three.js building games.