
Terrain Integration
- 58 installs
- 8 repo stars
- Updated August 4, 2026
- bbeierle12/skill-mcp-claude
terrain-integration is a Claude skill that adds slope handling, foundation anchoring, and auto-leveling for building placement on terrain in Three.js games.
About
This skill adds terrain interaction to Three.js building games, covering slope analysis, foundation anchoring in the Valheim pattern, terrain modification, auto-leveling, and pillar generation. A developer uses it when implementing where and how structures attach to uneven ground, and it integrates with the structural-physics skill for stability.
- Terrain interaction systems for Three.js building games
- Slope handling, foundation anchoring, and auto-leveling
- Integrates with structural-physics for ground-based stability
Terrain Integration by the numbers
- 58 all-time installs (skills.sh)
- Ranked #147 of 247 Game Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
terrain-integration capabilities & compatibility
- Capabilities
- structural physics
- Pricing
- Free
What terrain-integration says it does
Terrain interaction systems for Three.js building games.
Foundation placement, slope handling, and terrain modification for building systems.
Integrates with structural-physics for ground-based stability.
npx skills add https://github.com/bbeierle12/skill-mcp-claude --skill terrain-integrationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 58 |
|---|---|
| repo stars | ★ 8 |
| Last updated | August 4, 2026 |
| Repository | bbeierle12/skill-mcp-claude ↗ |
What it does
Add slope handling, foundation anchoring, and auto-leveling for structures on terrain in Three.js building games.
Who is it for?
Placing foundations on uneven terrain with slope analysis and auto-leveling in Three.js.
Skip if: Non-terrain physics or 2D games.
When should I use this skill?
Implementing slope handling, foundation anchoring, terrain modification, or pillar generation.
What you get
Foundations place correctly on sloped terrain with auto-leveling and support pillars.
- terrain analyzer
- foundation placer with auto-leveling
By the numbers
- 3 foundation modes (valheim, rust, ark)
Files
Terrain Integration
Foundation placement, slope handling, and terrain modification for building systems.
Quick Start
import { TerrainAnalyzer } from './scripts/terrain-analyzer.js';
import { FoundationPlacer } from './scripts/foundation-placer.js';
// Analyze terrain at build location
const analyzer = new TerrainAnalyzer(terrainMesh);
const slopeData = analyzer.analyzeSlope(position, { radius: 4 });
// slopeData: { angle: 15, normal: Vector3, canBuild: true }
// Place foundation with auto-leveling
const placer = new FoundationPlacer({
mode: 'valheim', // or 'rust', 'ark'
maxSlope: 30,
autoLevel: true
});
const result = placer.place(foundationPiece, position, analyzer);
// result: { valid: true, height: 2.3, pillarsNeeded: 2 }Reference
See references/terrain-integration-advanced.md for:
- Slope analysis algorithms and thresholds
- Foundation anchoring patterns (Valheim ground contact rule)
- Auto-leveling strategies
- Terrain modification networking
- Pillar generation for uneven terrain
Scripts
scripts/terrain-analyzer.js- Slope detection, buildability checks, terrain samplingscripts/foundation-placer.js- Foundation placement with Valheim/Rust/ARK modesscripts/terrain-modifier.js- Flatten, raise, lower terrain operationsscripts/pillar-generator.js- Auto-generate support pillars for slopes
Foundation Modes
- Valheim: Ground contact = 100% stability, foundations must touch terrain
- Rust: Foundations snap to grid, terrain ignored after placement
- ARK: Flexible placement with auto-pillars, moderate slope tolerance
Integration
Works with structural-physics for stability calculations. Grounded foundations feed into the support graph as root nodes with maximum stability.
// Integration example
const grounded = placer.place(foundation, pos, analyzer);
if (grounded.valid) {
foundation.isGrounded = true;
foundation.stability = 1.0; // Feed to structural-physics
validator.addPiece(foundation);
}{
"name": "terrain-integration",
"description": "Terrain interaction systems for Three.js building games. Use when implementing slope handling, foundation anchoring, terrain modification, auto-leveling, or pillar/support generation.",
"tags": [
"building-game",
"3d",
"code-generation"
],
"sub_skills": [],
"source": "claude-user",
"type": "template",
"depends_on": [],
"enhances": [
"3d-building-advanced",
"structural-physics"
],
"last_reviewed_at": null,
"review_score": null,
"relevance_tier": null
}
Terrain Integration Advanced
Building games must reconcile procedural or sculpted terrain with grid-based building systems. This reference covers the techniques used by Valheim, Rust, ARK, and similar games to handle slopes, anchor foundations, and optionally modify terrain to accommodate structures.
The Terrain Challenge
Grid-based building assumes flat surfaces. Real terrain has slopes, bumps, and irregular features. Games solve this tension through three approaches: strict ground contact rules (Valheim), terrain-ignorant snapping (Rust), or hybrid systems with auto-pillars (ARK/Conan).
Valheim's Ground Contact Rule
Valheim enforces a simple rule: foundations must touch the ground to gain 100% stability. This creates emergent building constraints:
// Valheim-style ground contact check
function checkGroundContact(foundation, terrain) {
const corners = foundation.getCornerPositions();
let contactCount = 0;
let totalDistance = 0;
for (const corner of corners) {
const groundY = terrain.getHeightAt(corner.x, corner.z);
const distance = corner.y - groundY;
if (distance <= 0.1) { // Within 10cm of ground
contactCount++;
}
totalDistance += Math.abs(distance);
}
return {
hasContact: contactCount >= 1,
fullContact: contactCount === corners.length,
avgDistance: totalDistance / corners.length,
stability: contactCount >= 1 ? 1.0 : 0.0
};
}The consequence: players must work with terrain, using pillars or multiple foundation heights to build on slopes. This creates varied, organic-looking structures.
Rust's Grid Independence
Rust foundations ignore terrain after placement. A foundation placed at height Y stays at height Y regardless of ground level. This simplifies building but creates floating structures on uneven ground.
// Rust-style: terrain only affects initial placement height
function placeFoundationRust(foundation, position, terrain) {
// Snap to grid
const snapped = snapToGrid(position, GRID_SIZE);
// Initial height from terrain (one-time)
const groundY = terrain.getHeightAt(snapped.x, snapped.z);
snapped.y = Math.max(groundY, snapped.y);
// From here, terrain is irrelevant
foundation.position.copy(snapped);
foundation.isGrounded = true; // Always grounded if placed
return { valid: true, position: snapped };
}ARK's Auto-Pillar System
ARK generates pillars automatically when foundations are placed above terrain. This maintains visual grounding while allowing flexible placement.
// ARK-style auto-pillar generation
function placeWithAutoPillars(foundation, position, terrain) {
const groundY = terrain.getHeightAt(position.x, position.z);
const gapHeight = position.y - groundY;
if (gapHeight <= 0.2) {
// Close enough to ground, no pillars needed
return { foundation, pillars: [] };
}
// Calculate pillar count
const pillarHeight = 2.0; // Standard pillar height
const pillarsNeeded = Math.ceil(gapHeight / pillarHeight);
// Generate pillar stack
const pillars = [];
for (let i = 0; i < pillarsNeeded; i++) {
pillars.push({
type: 'pillar',
position: new THREE.Vector3(
position.x,
groundY + (i * pillarHeight),
position.z
),
autoGenerated: true
});
}
return { foundation, pillars };
}Slope Analysis
Before placing any foundation, analyze the terrain slope to determine buildability.
Slope Calculation
/**
* TerrainAnalyzer - Analyzes terrain for building suitability
*/
export class TerrainAnalyzer {
constructor(terrain, options = {}) {
this.terrain = terrain;
this.sampleResolution = options.sampleResolution ?? 0.5;
this.heightCache = new Map();
}
/**
* Get terrain height at position (with caching)
*/
getHeightAt(x, z) {
const key = `${Math.round(x * 10)},${Math.round(z * 10)}`;
if (this.heightCache.has(key)) {
return this.heightCache.get(key);
}
const height = this._sampleHeight(x, z);
this.heightCache.set(key, height);
return height;
}
_sampleHeight(x, z) {
// Raycast down to find terrain
const raycaster = new THREE.Raycaster(
new THREE.Vector3(x, 1000, z),
new THREE.Vector3(0, -1, 0)
);
const intersects = raycaster.intersectObject(this.terrain, true);
return intersects.length > 0 ? intersects[0].point.y : 0;
}
/**
* Analyze slope at a position
* @param {Vector3} center - Center point to analyze
* @param {Object} options - Analysis options
* @returns {Object} Slope analysis results
*/
analyzeSlope(center, options = {}) {
const radius = options.radius ?? 2;
const samples = options.samples ?? 8;
// Sample heights in a circle around center
const heights = [];
const centerHeight = this.getHeightAt(center.x, center.z);
for (let i = 0; i < samples; i++) {
const angle = (i / samples) * Math.PI * 2;
const x = center.x + Math.cos(angle) * radius;
const z = center.z + Math.sin(angle) * radius;
heights.push({
x, z,
height: this.getHeightAt(x, z),
angle
});
}
// Calculate slope metrics
const minHeight = Math.min(...heights.map(h => h.height));
const maxHeight = Math.max(...heights.map(h => h.height));
const heightDiff = maxHeight - minHeight;
// Slope angle (degrees)
const slopeAngle = Math.atan2(heightDiff, radius * 2) * (180 / Math.PI);
// Calculate normal vector
const normal = this.calculateNormal(center, heights);
// Determine dominant slope direction
let maxSlope = 0;
let slopeDirection = new THREE.Vector3();
for (const sample of heights) {
const slope = Math.abs(sample.height - centerHeight) / radius;
if (slope > maxSlope) {
maxSlope = slope;
slopeDirection.set(
sample.x - center.x,
0,
sample.z - center.z
).normalize();
}
}
return {
angle: slopeAngle,
normal,
minHeight,
maxHeight,
heightDiff,
slopeDirection,
canBuild: slopeAngle <= (options.maxSlope ?? 45),
samples: heights
};
}
/**
* Calculate terrain normal at position
*/
calculateNormal(center, samples) {
// Use cross product of height differences
const north = samples.find(s => s.angle < 0.1 || s.angle > Math.PI * 2 - 0.1);
const east = samples.find(s => Math.abs(s.angle - Math.PI / 2) < 0.1);
const south = samples.find(s => Math.abs(s.angle - Math.PI) < 0.1);
const west = samples.find(s => Math.abs(s.angle - Math.PI * 1.5) < 0.1);
if (!north || !south || !east || !west) {
return new THREE.Vector3(0, 1, 0); // Default up
}
const dx = east.height - west.height;
const dz = north.height - south.height;
const normal = new THREE.Vector3(-dx, 2, -dz).normalize();
return normal;
}
/**
* Check if area is flat enough for building
*/
isBuildable(center, size, maxSlope = 30) {
const halfSize = size / 2;
const corners = [
{ x: center.x - halfSize, z: center.z - halfSize },
{ x: center.x + halfSize, z: center.z - halfSize },
{ x: center.x + halfSize, z: center.z + halfSize },
{ x: center.x - halfSize, z: center.z + halfSize }
];
const heights = corners.map(c => this.getHeightAt(c.x, c.z));
const maxDiff = Math.max(...heights) - Math.min(...heights);
const slope = Math.atan2(maxDiff, size) * (180 / Math.PI);
return {
buildable: slope <= maxSlope,
slope,
heightVariation: maxDiff,
suggestedHeight: Math.max(...heights)
};
}
/**
* Find best foundation height for position
*/
findOptimalHeight(center, foundationSize) {
const analysis = this.isBuildable(center, foundationSize);
if (analysis.buildable) {
// Use highest corner height to avoid clipping
return analysis.suggestedHeight;
}
// For steep slopes, return average + offset
const avgHeight = (analysis.suggestedHeight +
this.getHeightAt(center.x, center.z)) / 2;
return avgHeight + analysis.heightVariation * 0.5;
}
/**
* Clear height cache (call after terrain modification)
*/
clearCache() {
this.heightCache.clear();
}
}Slope Thresholds by Game Type
| Game Type | Max Slope | Behavior |
|---|---|---|
| Survival (Valheim) | 20-30° | Strict, must work with terrain |
| Base Builder (Rust) | 45° | Moderate, some floating OK |
| Sandbox (ARK) | 60° | Permissive, auto-pillars fill gaps |
| Creative (Minecraft) | 90° | No limits, floating allowed |
Foundation Placement
The FoundationPlacer Class
/**
* FoundationPlacer - Handles foundation placement with terrain awareness
*/
export class FoundationPlacer {
constructor(options = {}) {
this.mode = options.mode ?? 'valheim';
this.maxSlope = options.maxSlope ?? 30;
this.autoLevel = options.autoLevel ?? true;
this.gridSize = options.gridSize ?? 4;
this.pillarThreshold = options.pillarThreshold ?? 0.5;
}
/**
* Attempt to place a foundation
*/
place(foundation, position, analyzer) {
// Snap to grid first
const snapped = this.snapToGrid(position);
// Analyze terrain at placement location
const slope = analyzer.analyzeSlope(snapped, {
radius: this.gridSize / 2,
maxSlope: this.maxSlope
});
// Mode-specific placement logic
switch (this.mode) {
case 'valheim':
return this.placeValheim(foundation, snapped, slope, analyzer);
case 'rust':
return this.placeRust(foundation, snapped, slope, analyzer);
case 'ark':
return this.placeArk(foundation, snapped, slope, analyzer);
default:
return this.placeValheim(foundation, snapped, slope, analyzer);
}
}
/**
* Valheim-style: Must touch ground for stability
*/
placeValheim(foundation, position, slope, analyzer) {
if (!slope.canBuild) {
return {
valid: false,
reason: `Slope too steep (${slope.angle.toFixed(1)}° > ${this.maxSlope}°)`,
slope: slope.angle
};
}
// Find height where foundation touches ground
const groundHeight = this.findGroundContactHeight(position, analyzer);
// Check if foundation would be buried
const centerGround = analyzer.getHeightAt(position.x, position.z);
if (groundHeight - centerGround > 1.0) {
return {
valid: false,
reason: 'Foundation would be too elevated from center',
heightDiff: groundHeight - centerGround
};
}
foundation.position.set(position.x, groundHeight, position.z);
foundation.isGrounded = true;
return {
valid: true,
height: groundHeight,
slope: slope.angle,
stability: 1.0,
groundContact: true
};
}
/**
* Rust-style: Grid-based, terrain mostly ignored
*/
placeRust(foundation, position, slope, analyzer) {
// Get ground height at center only
const groundHeight = analyzer.getHeightAt(position.x, position.z);
const placementHeight = Math.max(groundHeight, position.y);
foundation.position.set(position.x, placementHeight, position.z);
foundation.isGrounded = true; // Always grounded in Rust mode
return {
valid: true,
height: placementHeight,
slope: slope.angle,
stability: 1.0,
groundContact: Math.abs(placementHeight - groundHeight) < 0.5
};
}
/**
* ARK-style: Auto-generate pillars for gaps
*/
placeArk(foundation, position, slope, analyzer) {
const groundHeight = analyzer.getHeightAt(position.x, position.z);
const gapHeight = position.y - groundHeight;
// Determine if pillars needed
const pillars = [];
if (gapHeight > this.pillarThreshold) {
const pillarCount = Math.ceil(gapHeight / 2.0);
for (let i = 0; i < pillarCount; i++) {
pillars.push({
type: 'pillar',
position: new THREE.Vector3(
position.x,
groundHeight + (i * 2.0),
position.z
),
autoGenerated: true,
material: foundation.material
});
}
}
foundation.position.copy(position);
foundation.isGrounded = true;
return {
valid: true,
height: position.y,
slope: slope.angle,
stability: 1.0,
pillars,
pillarsGenerated: pillars.length
};
}
/**
* Find height where foundation corners touch ground
*/
findGroundContactHeight(center, analyzer) {
const halfSize = this.gridSize / 2;
const corners = [
{ x: center.x - halfSize, z: center.z - halfSize },
{ x: center.x + halfSize, z: center.z - halfSize },
{ x: center.x + halfSize, z: center.z + halfSize },
{ x: center.x - halfSize, z: center.z + halfSize }
];
const heights = corners.map(c => analyzer.getHeightAt(c.x, c.z));
// Foundation sits on highest corner (lowest would bury corners)
return Math.max(...heights);
}
/**
* Snap position to building grid
*/
snapToGrid(position) {
return new THREE.Vector3(
Math.round(position.x / this.gridSize) * this.gridSize,
position.y,
Math.round(position.z / this.gridSize) * this.gridSize
);
}
}Auto-Leveling Systems
When terrain is too uneven, some games flatten it automatically around foundations.
Terrain Modifier
/**
* TerrainModifier - Modify terrain heightmap for building
*/
export class TerrainModifier {
constructor(terrain, options = {}) {
this.terrain = terrain;
this.heightmap = terrain.userData.heightmap; // Assumes heightmap stored
this.resolution = options.resolution ?? 1;
this.maxModification = options.maxModification ?? 5; // Max height change
this.modificationHistory = []; // For undo
}
/**
* Flatten terrain in an area
*/
flatten(center, radius, targetHeight = null) {
const affected = this.getAffectedVertices(center, radius);
const currentHeight = targetHeight ?? this.getAverageHeight(affected);
const modifications = [];
for (const vertex of affected) {
const distance = Math.sqrt(
Math.pow(vertex.x - center.x, 2) +
Math.pow(vertex.z - center.z, 2)
);
// Smooth falloff at edges
const influence = 1 - Math.pow(distance / radius, 2);
const newHeight = THREE.MathUtils.lerp(
vertex.originalHeight,
currentHeight,
Math.max(0, influence)
);
// Clamp to max modification
const clampedHeight = THREE.MathUtils.clamp(
newHeight,
vertex.originalHeight - this.maxModification,
vertex.originalHeight + this.maxModification
);
modifications.push({
vertex,
oldHeight: vertex.height,
newHeight: clampedHeight
});
this.setVertexHeight(vertex, clampedHeight);
}
this.modificationHistory.push({
type: 'flatten',
center: center.clone(),
radius,
modifications
});
this.updateTerrainMesh();
return {
success: true,
verticesModified: modifications.length,
targetHeight: currentHeight
};
}
/**
* Raise terrain in an area
*/
raise(center, radius, amount) {
const affected = this.getAffectedVertices(center, radius);
const modifications = [];
for (const vertex of affected) {
const distance = Math.sqrt(
Math.pow(vertex.x - center.x, 2) +
Math.pow(vertex.z - center.z, 2)
);
const influence = 1 - (distance / radius);
const raise = amount * Math.max(0, influence);
const newHeight = Math.min(
vertex.height + raise,
vertex.originalHeight + this.maxModification
);
modifications.push({
vertex,
oldHeight: vertex.height,
newHeight
});
this.setVertexHeight(vertex, newHeight);
}
this.modificationHistory.push({
type: 'raise',
center: center.clone(),
radius,
amount,
modifications
});
this.updateTerrainMesh();
return { success: true, verticesModified: modifications.length };
}
/**
* Lower terrain in an area
*/
lower(center, radius, amount) {
return this.raise(center, radius, -amount);
}
/**
* Undo last modification
*/
undo() {
const last = this.modificationHistory.pop();
if (!last) return { success: false, reason: 'Nothing to undo' };
for (const mod of last.modifications) {
this.setVertexHeight(mod.vertex, mod.oldHeight);
}
this.updateTerrainMesh();
return { success: true, type: last.type };
}
/**
* Get vertices within radius of center
*/
getAffectedVertices(center, radius) {
const vertices = [];
const geometry = this.terrain.geometry;
const position = geometry.attributes.position;
for (let i = 0; i < position.count; i++) {
const x = position.getX(i);
const z = position.getZ(i);
const distance = Math.sqrt(
Math.pow(x - center.x, 2) +
Math.pow(z - center.z, 2)
);
if (distance <= radius) {
vertices.push({
index: i,
x, z,
height: position.getY(i),
originalHeight: this.heightmap?.[i] ?? position.getY(i)
});
}
}
return vertices;
}
/**
* Get average height of vertices
*/
getAverageHeight(vertices) {
if (vertices.length === 0) return 0;
const sum = vertices.reduce((acc, v) => acc + v.height, 0);
return sum / vertices.length;
}
/**
* Set height of a vertex
*/
setVertexHeight(vertex, height) {
const position = this.terrain.geometry.attributes.position;
position.setY(vertex.index, height);
vertex.height = height;
}
/**
* Update terrain mesh after modifications
*/
updateTerrainMesh() {
const geometry = this.terrain.geometry;
geometry.attributes.position.needsUpdate = true;
geometry.computeVertexNormals();
geometry.computeBoundingSphere();
// Update collision mesh if separate
if (this.terrain.userData.collisionMesh) {
this.terrain.userData.collisionMesh.geometry.copy(geometry);
}
}
/**
* Serialize modifications for saving/networking
*/
serialize() {
return this.modificationHistory.map(mod => ({
type: mod.type,
center: { x: mod.center.x, y: mod.center.y, z: mod.center.z },
radius: mod.radius,
amount: mod.amount,
modifications: mod.modifications.map(m => ({
index: m.vertex.index,
oldHeight: m.oldHeight,
newHeight: m.newHeight
}))
}));
}
/**
* Apply serialized modifications (for loading/networking)
*/
deserialize(data) {
const position = this.terrain.geometry.attributes.position;
for (const mod of data) {
for (const m of mod.modifications) {
position.setY(m.index, m.newHeight);
}
}
this.updateTerrainMesh();
}
}Pillar Generation
For slopes too steep to flatten, auto-generate support pillars.
/**
* PillarGenerator - Auto-generate support structures for uneven terrain
*/
export class PillarGenerator {
constructor(options = {}) {
this.pillarHeight = options.pillarHeight ?? 2.0;
this.pillarWidth = options.pillarWidth ?? 0.5;
this.minGap = options.minGap ?? 0.3; // Below this, no pillar needed
this.maxPillars = options.maxPillars ?? 10; // Safety limit
}
/**
* Generate pillars for a foundation
*/
generateForFoundation(foundation, analyzer) {
const corners = this.getFoundationCorners(foundation);
const pillars = [];
for (const corner of corners) {
const groundY = analyzer.getHeightAt(corner.x, corner.z);
const gap = foundation.position.y - groundY;
if (gap > this.minGap) {
const cornerPillars = this.generatePillarStack(
corner,
groundY,
foundation.position.y,
foundation.material
);
pillars.push(...cornerPillars);
}
}
// Check center if foundation is large
if (foundation.width >= 4 || foundation.depth >= 4) {
const centerGround = analyzer.getHeightAt(
foundation.position.x,
foundation.position.z
);
const centerGap = foundation.position.y - centerGround;
if (centerGap > this.minGap) {
const centerPillars = this.generatePillarStack(
foundation.position,
centerGround,
foundation.position.y,
foundation.material
);
pillars.push(...centerPillars);
}
}
return {
pillars,
totalHeight: pillars.reduce((sum, p) => sum + this.pillarHeight, 0),
resourceCost: this.calculateResourceCost(pillars, foundation.material)
};
}
/**
* Generate a vertical stack of pillars
*/
generatePillarStack(position, bottomY, topY, material) {
const gap = topY - bottomY;
const count = Math.min(
Math.ceil(gap / this.pillarHeight),
this.maxPillars
);
const pillars = [];
for (let i = 0; i < count; i++) {
const pillarY = bottomY + (i * this.pillarHeight);
// Last pillar might be partial height
const isLast = i === count - 1;
const height = isLast
? (topY - pillarY)
: this.pillarHeight;
pillars.push({
id: `pillar_${Date.now()}_${i}`,
type: 'pillar',
position: new THREE.Vector3(position.x, pillarY, position.z),
height,
width: this.pillarWidth,
material,
autoGenerated: true,
supports: i === count - 1 ? 'foundation' : 'pillar'
});
}
return pillars;
}
/**
* Get corner positions of foundation
*/
getFoundationCorners(foundation) {
const hw = (foundation.width ?? 4) / 2;
const hd = (foundation.depth ?? 4) / 2;
const pos = foundation.position;
return [
new THREE.Vector3(pos.x - hw, pos.y, pos.z - hd),
new THREE.Vector3(pos.x + hw, pos.y, pos.z - hd),
new THREE.Vector3(pos.x + hw, pos.y, pos.z + hd),
new THREE.Vector3(pos.x - hw, pos.y, pos.z + hd)
];
}
/**
* Calculate resource cost for pillars
*/
calculateResourceCost(pillars, material) {
const baseCost = {
WOOD: { wood: 20 },
STONE: { stone: 30 },
METAL: { metal: 10 }
};
const cost = baseCost[material?.name] ?? baseCost.WOOD;
const total = {};
for (const resource of Object.keys(cost)) {
total[resource] = cost[resource] * pillars.length;
}
return total;
}
}Networking Terrain Modifications
Terrain changes must synchronize across clients in multiplayer.
/**
* Network messages for terrain modification
*/
const TerrainNetworkMessages = {
FLATTEN_REQUEST: 'terrain:flatten:request',
FLATTEN_RESULT: 'terrain:flatten:result',
RAISE_REQUEST: 'terrain:raise:request',
LOWER_REQUEST: 'terrain:lower:request',
MODIFICATION_SYNC: 'terrain:modification:sync',
FULL_SYNC: 'terrain:full:sync'
};
/**
* TerrainNetworkManager - Sync terrain modifications
*/
export class TerrainNetworkManager {
constructor(modifier, connection, options = {}) {
this.modifier = modifier;
this.connection = connection;
this.isServer = options.isServer ?? false;
this.pendingModifications = new Map();
this.setupHandlers();
}
setupHandlers() {
if (this.isServer) {
this.connection.on(TerrainNetworkMessages.FLATTEN_REQUEST,
this.handleFlattenRequest.bind(this));
this.connection.on(TerrainNetworkMessages.RAISE_REQUEST,
this.handleRaiseRequest.bind(this));
} else {
this.connection.on(TerrainNetworkMessages.MODIFICATION_SYNC,
this.handleModificationSync.bind(this));
this.connection.on(TerrainNetworkMessages.FULL_SYNC,
this.handleFullSync.bind(this));
}
}
/**
* Client: Request terrain flatten
*/
requestFlatten(center, radius) {
const requestId = `flatten_${Date.now()}`;
this.pendingModifications.set(requestId, {
type: 'flatten',
center,
radius,
timestamp: Date.now()
});
this.connection.send(TerrainNetworkMessages.FLATTEN_REQUEST, {
requestId,
center: { x: center.x, y: center.y, z: center.z },
radius
});
return requestId;
}
/**
* Server: Handle flatten request
*/
handleFlattenRequest(data, clientId) {
const center = new THREE.Vector3(data.center.x, data.center.y, data.center.z);
// Validate request
if (!this.validateModificationRequest(center, data.radius, clientId)) {
this.connection.sendTo(clientId, TerrainNetworkMessages.FLATTEN_RESULT, {
requestId: data.requestId,
success: false,
reason: 'Modification not allowed'
});
return;
}
// Apply modification
const result = this.modifier.flatten(center, data.radius);
// Broadcast to all clients
this.connection.broadcast(TerrainNetworkMessages.MODIFICATION_SYNC, {
type: 'flatten',
center: data.center,
radius: data.radius,
targetHeight: result.targetHeight
});
// Confirm to requesting client
this.connection.sendTo(clientId, TerrainNetworkMessages.FLATTEN_RESULT, {
requestId: data.requestId,
success: true,
...result
});
}
/**
* Client: Apply synced modification
*/
handleModificationSync(data) {
const center = new THREE.Vector3(data.center.x, data.center.y, data.center.z);
switch (data.type) {
case 'flatten':
this.modifier.flatten(center, data.radius, data.targetHeight);
break;
case 'raise':
this.modifier.raise(center, data.radius, data.amount);
break;
case 'lower':
this.modifier.lower(center, data.radius, data.amount);
break;
}
}
/**
* Client: Handle full terrain sync (on join)
*/
handleFullSync(data) {
this.modifier.deserialize(data.modifications);
}
/**
* Server: Validate modification is allowed
*/
validateModificationRequest(center, radius, clientId) {
// Check build permissions
// Check resource costs
// Check modification limits
return true; // Implement based on game rules
}
}Integration Checklist
When implementing terrain integration:
- [ ] Choose foundation mode (Valheim/Rust/ARK) based on game type
- [ ] Implement slope analysis with appropriate thresholds
- [ ] Add foundation placement with ground contact checks
- [ ] Decide on auto-leveling (yes/no, degree of modification)
- [ ] Implement pillar generation for gaps
- [ ] Add terrain modification if enabled (flatten/raise/lower)
- [ ] Network terrain changes in multiplayer
- [ ] Cache terrain heights for performance
- [ ] Clear cache when terrain is modified
- [ ] Test on extreme slopes and terrain features
- [ ] Integrate with structural-physics for stability values
Related References
structural-physicsskill - Stability calculations for grounded piecesperformance-at-scaleskill - Spatial queries for terrain samplingmultiplayer-buildingskill - Networking patterns for terrain sync
/**
* FoundationPlacer - Handles foundation placement with terrain awareness
*
* Supports multiple placement modes inspired by different games:
* - Valheim: Ground contact required for stability
* - Rust: Grid-based, terrain mostly ignored
* - ARK: Flexible with auto-pillar generation
*
* Usage:
* const placer = new FoundationPlacer({ mode: 'valheim' });
* const result = placer.place(foundation, position, terrainAnalyzer);
*/
import * as THREE from 'three';
/**
* Placement modes
*/
export const PlacementMode = {
VALHEIM: 'valheim', // Strict ground contact
RUST: 'rust', // Grid-based, terrain ignored
ARK: 'ark', // Flexible with auto-pillars
CREATIVE: 'creative' // No restrictions
};
/**
* Placement result
* @typedef {Object} PlacementResult
* @property {boolean} valid - Whether placement is allowed
* @property {string} reason - Rejection reason if invalid
* @property {number} height - Final placement height
* @property {number} slope - Terrain slope at location
* @property {number} stability - Initial stability value
* @property {boolean} groundContact - Whether foundation touches ground
* @property {Array} pillars - Auto-generated pillars (ARK mode)
*/
export class FoundationPlacer {
/**
* Create foundation placer
* @param {Object} options - Configuration options
*/
constructor(options = {}) {
this.mode = options.mode ?? PlacementMode.VALHEIM;
this.maxSlope = options.maxSlope ?? 30;
this.autoLevel = options.autoLevel ?? true;
this.gridSize = options.gridSize ?? 4;
this.gridEnabled = options.gridEnabled ?? true;
this.pillarThreshold = options.pillarThreshold ?? 0.5;
this.maxPillarHeight = options.maxPillarHeight ?? 10;
this.contactTolerance = options.contactTolerance ?? 0.15;
this.buryTolerance = options.buryTolerance ?? 0.5;
// Validation callbacks
this.onValidate = options.onValidate ?? null;
this.onPillarsGenerated = options.onPillarsGenerated ?? null;
}
/**
* Attempt to place a foundation
* @param {Object} foundation - Foundation piece to place
* @param {THREE.Vector3} position - Desired position
* @param {TerrainAnalyzer} analyzer - Terrain analyzer instance
* @returns {PlacementResult} Placement result
*/
place(foundation, position, analyzer) {
// Snap to grid if enabled
const snapped = this.gridEnabled
? this.snapToGrid(position)
: position.clone();
// Analyze terrain at placement location
const foundationSize = foundation.width ?? this.gridSize;
const slope = analyzer.analyzeSlope(snapped, {
radius: foundationSize / 2,
maxSlope: this.maxSlope
});
// Custom validation hook
if (this.onValidate) {
const customResult = this.onValidate(foundation, snapped, slope);
if (customResult && !customResult.valid) {
return customResult;
}
}
// Check for water
if (analyzer.isOnWater && analyzer.isOnWater(snapped.x, snapped.z)) {
if (!foundation.allowWater) {
return {
valid: false,
reason: 'Cannot build on water',
position: snapped
};
}
}
// Mode-specific placement logic
switch (this.mode) {
case PlacementMode.VALHEIM:
return this.placeValheim(foundation, snapped, slope, analyzer);
case PlacementMode.RUST:
return this.placeRust(foundation, snapped, slope, analyzer);
case PlacementMode.ARK:
return this.placeArk(foundation, snapped, slope, analyzer);
case PlacementMode.CREATIVE:
return this.placeCreative(foundation, snapped, slope, analyzer);
default:
return this.placeValheim(foundation, snapped, slope, analyzer);
}
}
/**
* Valheim-style placement: Must touch ground for stability
*/
placeValheim(foundation, position, slope, analyzer) {
// Check slope limit
if (!slope.canBuild) {
return {
valid: false,
reason: `Slope too steep (${slope.angle.toFixed(1)}° exceeds ${this.maxSlope}° limit)`,
slope: slope.angle,
position
};
}
// Find height where foundation touches ground
const contactResult = this.findGroundContactHeight(
position,
foundation,
analyzer
);
if (!contactResult.hasContact) {
return {
valid: false,
reason: 'Foundation must touch the ground',
position,
slope: slope.angle
};
}
// Check if foundation would be too buried
const centerGround = analyzer.getHeightAt(position.x, position.z);
const buryDepth = centerGround - contactResult.height;
if (buryDepth > this.buryTolerance) {
return {
valid: false,
reason: `Foundation would be buried (${buryDepth.toFixed(1)}m below center)`,
position,
buryDepth
};
}
// Apply final position
foundation.position.set(position.x, contactResult.height, position.z);
foundation.isGrounded = true;
return {
valid: true,
height: contactResult.height,
slope: slope.angle,
stability: 1.0,
groundContact: true,
contactPoints: contactResult.contactPoints,
position: foundation.position.clone()
};
}
/**
* Rust-style placement: Grid-based, terrain mostly ignored
*/
placeRust(foundation, position, slope, analyzer) {
// Rust is more permissive with slopes
const effectiveMaxSlope = this.maxSlope * 1.5;
if (slope.angle > effectiveMaxSlope) {
return {
valid: false,
reason: `Slope too steep for foundation`,
slope: slope.angle,
position
};
}
// Get ground height at center only
const groundHeight = analyzer.getHeightAt(position.x, position.z);
// Place at ground level or requested height, whichever is higher
const placementHeight = Math.max(groundHeight, position.y);
foundation.position.set(position.x, placementHeight, position.z);
foundation.isGrounded = true;
// Calculate actual ground contact
const heightAboveGround = placementHeight - groundHeight;
const hasContact = heightAboveGround < this.contactTolerance;
return {
valid: true,
height: placementHeight,
slope: slope.angle,
stability: 1.0, // Always full stability in Rust mode
groundContact: hasContact,
heightAboveGround,
position: foundation.position.clone()
};
}
/**
* ARK-style placement: Flexible with auto-pillars
*/
placeArk(foundation, position, slope, analyzer) {
// ARK is very permissive
const effectiveMaxSlope = this.maxSlope * 2;
if (slope.angle > effectiveMaxSlope) {
return {
valid: false,
reason: `Slope too extreme for building`,
slope: slope.angle,
position
};
}
const groundHeight = analyzer.getHeightAt(position.x, position.z);
const gapHeight = position.y - groundHeight;
// Generate pillars if needed
let pillars = [];
if (gapHeight > this.pillarThreshold) {
if (gapHeight > this.maxPillarHeight) {
return {
valid: false,
reason: `Too high above ground (${gapHeight.toFixed(1)}m exceeds ${this.maxPillarHeight}m limit)`,
gapHeight,
position
};
}
pillars = this.generatePillars(
position,
groundHeight,
position.y,
foundation
);
if (this.onPillarsGenerated) {
this.onPillarsGenerated(pillars, foundation);
}
}
foundation.position.copy(position);
foundation.isGrounded = true;
return {
valid: true,
height: position.y,
slope: slope.angle,
stability: 1.0,
groundContact: gapHeight <= this.pillarThreshold,
pillars,
pillarsGenerated: pillars.length,
resourceCost: this.calculatePillarCost(pillars, foundation.material),
position: foundation.position.clone()
};
}
/**
* Creative mode: No restrictions
*/
placeCreative(foundation, position, slope, analyzer) {
foundation.position.copy(position);
foundation.isGrounded = true;
return {
valid: true,
height: position.y,
slope: slope.angle,
stability: 1.0,
groundContact: false,
position: foundation.position.clone()
};
}
/**
* Find height where foundation corners touch ground
*/
findGroundContactHeight(center, foundation, analyzer) {
const corners = this.getFoundationCorners(center, foundation);
const cornerHeights = corners.map(c => ({
position: c,
groundHeight: analyzer.getHeightAt(c.x, c.z)
}));
// Find highest corner ground level
const maxGroundHeight = Math.max(
...cornerHeights.map(c => c.groundHeight)
);
// Count corners that would touch at this height
const contactPoints = cornerHeights.filter(c =>
Math.abs(c.groundHeight - maxGroundHeight) < this.contactTolerance
);
return {
height: maxGroundHeight,
hasContact: contactPoints.length >= 1,
contactPoints: contactPoints.length,
cornerHeights
};
}
/**
* Get corner positions of foundation
*/
getFoundationCorners(center, foundation) {
const width = foundation.width ?? this.gridSize;
const depth = foundation.depth ?? this.gridSize;
const hw = width / 2;
const hd = depth / 2;
// Apply rotation if foundation has one
const corners = [
new THREE.Vector3(-hw, 0, -hd),
new THREE.Vector3(hw, 0, -hd),
new THREE.Vector3(hw, 0, hd),
new THREE.Vector3(-hw, 0, hd)
];
if (foundation.rotation) {
const euler = new THREE.Euler(0, foundation.rotation.y ?? 0, 0);
corners.forEach(c => c.applyEuler(euler));
}
// Offset to center position
corners.forEach(c => c.add(center));
return corners;
}
/**
* Generate pillar stack for ARK mode
*/
generatePillars(position, bottomY, topY, foundation) {
const pillarHeight = 2.0;
const gap = topY - bottomY;
const count = Math.ceil(gap / pillarHeight);
const pillars = [];
for (let i = 0; i < count; i++) {
const pillarY = bottomY + (i * pillarHeight);
const isLast = i === count - 1;
const height = isLast ? (topY - pillarY) : pillarHeight;
pillars.push({
id: `pillar_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
type: 'pillar',
position: new THREE.Vector3(position.x, pillarY, position.z),
height,
material: foundation.material,
autoGenerated: true,
parentFoundation: foundation.id
});
}
return pillars;
}
/**
* Calculate resource cost for pillars
*/
calculatePillarCost(pillars, material) {
const costPerPillar = {
wood: { wood: 20 },
stone: { stone: 30, wood: 5 },
metal: { metal: 10, stone: 10 }
};
const materialKey = material?.name?.toLowerCase() ?? 'wood';
const baseCost = costPerPillar[materialKey] ?? costPerPillar.wood;
const total = {};
for (const resource of Object.keys(baseCost)) {
total[resource] = baseCost[resource] * pillars.length;
}
return total;
}
/**
* Snap position to building grid
*/
snapToGrid(position) {
return new THREE.Vector3(
Math.round(position.x / this.gridSize) * this.gridSize,
position.y,
Math.round(position.z / this.gridSize) * this.gridSize
);
}
/**
* Preview placement without committing
*/
preview(foundation, position, analyzer) {
// Clone foundation to avoid modifying original
const previewFoundation = {
...foundation,
id: `preview_${foundation.id}`,
position: new THREE.Vector3()
};
const result = this.place(previewFoundation, position, analyzer);
return {
...result,
preview: true,
previewPosition: previewFoundation.position.clone()
};
}
/**
* Get valid placement positions in an area (for build assist)
*/
findValidPlacements(foundation, center, searchRadius, analyzer) {
const validPositions = [];
const step = this.gridSize;
for (let dx = -searchRadius; dx <= searchRadius; dx += step) {
for (let dz = -searchRadius; dz <= searchRadius; dz += step) {
const testPos = new THREE.Vector3(
center.x + dx,
center.y,
center.z + dz
);
const result = this.preview(foundation, testPos, analyzer);
if (result.valid) {
validPositions.push({
position: result.previewPosition,
slope: result.slope,
pillarsNeeded: result.pillarsGenerated ?? 0
});
}
}
}
// Sort by slope (flattest first)
validPositions.sort((a, b) => a.slope - b.slope);
return validPositions;
}
/**
* Check if foundation can connect to existing structure
*/
canConnect(newFoundation, existingPieces, snapDistance = 0.5) {
const newCorners = this.getFoundationCorners(
newFoundation.position,
newFoundation
);
for (const existing of existingPieces) {
if (existing.type !== 'foundation') continue;
const existingCorners = this.getFoundationCorners(
existing.position,
existing
);
// Check if any corners are close enough to snap
for (const nc of newCorners) {
for (const ec of existingCorners) {
const distance = nc.distanceTo(ec);
if (distance < snapDistance) {
return {
canConnect: true,
snapPoint: ec.clone(),
distance
};
}
}
}
}
return { canConnect: false };
}
/**
* Update placement mode
*/
setMode(mode) {
if (!Object.values(PlacementMode).includes(mode)) {
throw new Error(`Invalid placement mode: ${mode}`);
}
this.mode = mode;
}
/**
* Get current configuration
*/
getConfig() {
return {
mode: this.mode,
maxSlope: this.maxSlope,
autoLevel: this.autoLevel,
gridSize: this.gridSize,
gridEnabled: this.gridEnabled,
pillarThreshold: this.pillarThreshold,
maxPillarHeight: this.maxPillarHeight,
contactTolerance: this.contactTolerance
};
}
}
export default FoundationPlacer;
/**
* PillarGenerator - Auto-generate support structures for uneven terrain
*
* Creates pillar stacks to fill gaps between foundations and terrain,
* commonly used in ARK-style building systems.
*
* Usage:
* const generator = new PillarGenerator();
* const { pillars, cost } = generator.generateForFoundation(foundation, analyzer);
*/
import * as THREE from 'three';
/**
* Pillar generation strategies
*/
export const PillarStrategy = {
CORNERS: 'corners', // Pillars at foundation corners only
CENTER: 'center', // Single pillar at center
GRID: 'grid', // Grid pattern of pillars
ADAPTIVE: 'adaptive' // Based on terrain variation
};
/**
* Pillar piece definition
* @typedef {Object} PillarPiece
* @property {string} id - Unique identifier
* @property {string} type - Always 'pillar'
* @property {THREE.Vector3} position - World position
* @property {number} height - Pillar height
* @property {number} width - Pillar width
* @property {Object} material - Material properties
* @property {boolean} autoGenerated - Whether auto-generated
* @property {string} parentFoundation - ID of foundation this supports
*/
export class PillarGenerator {
/**
* Create pillar generator
* @param {Object} options - Configuration options
*/
constructor(options = {}) {
this.pillarHeight = options.pillarHeight ?? 2.0;
this.pillarWidth = options.pillarWidth ?? 0.5;
this.minGap = options.minGap ?? 0.3;
this.maxPillarsPerStack = options.maxPillarsPerStack ?? 10;
this.maxTotalPillars = options.maxTotalPillars ?? 50;
this.strategy = options.strategy ?? PillarStrategy.ADAPTIVE;
// Resource costs per pillar by material
this.resourceCosts = options.resourceCosts ?? {
wood: { wood: 20 },
stone: { stone: 30, wood: 5 },
metal: { metal: 10, stone: 5 },
thatch: { thatch: 10, fiber: 5 }
};
}
/**
* Generate pillars for a foundation
* @param {Object} foundation - Foundation piece
* @param {TerrainAnalyzer} analyzer - Terrain analyzer
* @returns {Object} Generated pillars and metadata
*/
generateForFoundation(foundation, analyzer) {
const positions = this.getPillarPositions(foundation);
const allPillars = [];
const stackInfo = [];
for (const pos of positions) {
const groundY = analyzer.getHeightAt(pos.x, pos.z);
const gap = foundation.position.y - groundY;
if (gap > this.minGap) {
const stack = this.generatePillarStack(
pos,
groundY,
foundation.position.y,
foundation
);
if (stack.pillars.length > 0) {
allPillars.push(...stack.pillars);
stackInfo.push({
position: pos.clone(),
groundY,
gap,
pillarCount: stack.pillars.length
});
}
}
}
// Enforce maximum total pillars
const limitedPillars = allPillars.slice(0, this.maxTotalPillars);
const wasLimited = allPillars.length > this.maxTotalPillars;
return {
pillars: limitedPillars,
stacks: stackInfo,
totalPillars: limitedPillars.length,
totalHeight: this.calculateTotalHeight(limitedPillars),
resourceCost: this.calculateResourceCost(limitedPillars, foundation.material),
wasLimited,
originalCount: allPillars.length
};
}
/**
* Get positions where pillars should be placed
*/
getPillarPositions(foundation) {
switch (this.strategy) {
case PillarStrategy.CORNERS:
return this.getCornerPositions(foundation);
case PillarStrategy.CENTER:
return [foundation.position.clone()];
case PillarStrategy.GRID:
return this.getGridPositions(foundation);
case PillarStrategy.ADAPTIVE:
default:
return this.getAdaptivePositions(foundation);
}
}
/**
* Get corner positions of foundation
*/
getCornerPositions(foundation) {
const width = foundation.width ?? 4;
const depth = foundation.depth ?? 4;
const hw = width / 2;
const hd = depth / 2;
const pos = foundation.position;
const corners = [
new THREE.Vector3(pos.x - hw, pos.y, pos.z - hd),
new THREE.Vector3(pos.x + hw, pos.y, pos.z - hd),
new THREE.Vector3(pos.x + hw, pos.y, pos.z + hd),
new THREE.Vector3(pos.x - hw, pos.y, pos.z + hd)
];
// Apply rotation if present
if (foundation.rotation?.y) {
const euler = new THREE.Euler(0, foundation.rotation.y, 0);
const center = pos.clone();
corners.forEach(c => {
c.sub(center);
c.applyEuler(euler);
c.add(center);
});
}
return corners;
}
/**
* Get grid of pillar positions
*/
getGridPositions(foundation) {
const width = foundation.width ?? 4;
const depth = foundation.depth ?? 4;
const pos = foundation.position;
const spacing = 2; // Pillar every 2 units
const positions = [];
const hw = width / 2;
const hd = depth / 2;
for (let x = -hw; x <= hw; x += spacing) {
for (let z = -hd; z <= hd; z += spacing) {
positions.push(new THREE.Vector3(
pos.x + x,
pos.y,
pos.z + z
));
}
}
return positions;
}
/**
* Get adaptive positions based on foundation size
*/
getAdaptivePositions(foundation) {
const width = foundation.width ?? 4;
const depth = foundation.depth ?? 4;
const area = width * depth;
// Small foundations: corners only
if (area <= 16) {
return this.getCornerPositions(foundation);
}
// Medium foundations: corners + center
if (area <= 36) {
const corners = this.getCornerPositions(foundation);
corners.push(foundation.position.clone());
return corners;
}
// Large foundations: grid pattern
return this.getGridPositions(foundation);
}
/**
* Generate a vertical stack of pillars
*/
generatePillarStack(position, bottomY, topY, foundation) {
const gap = topY - bottomY;
if (gap <= this.minGap) {
return { pillars: [], height: 0 };
}
const count = Math.min(
Math.ceil(gap / this.pillarHeight),
this.maxPillarsPerStack
);
const pillars = [];
let currentY = bottomY;
for (let i = 0; i < count; i++) {
const isLast = i === count - 1;
const remainingGap = topY - currentY;
const height = isLast
? Math.min(remainingGap, this.pillarHeight)
: this.pillarHeight;
// Skip very short pillars
if (height < 0.1) continue;
const pillar = this.createPillar(
new THREE.Vector3(position.x, currentY, position.z),
height,
foundation,
i
);
pillars.push(pillar);
currentY += height;
}
return {
pillars,
height: currentY - bottomY
};
}
/**
* Create a single pillar piece
*/
createPillar(position, height, foundation, index) {
const timestamp = Date.now();
const random = Math.random().toString(36).substr(2, 6);
return {
id: `pillar_${timestamp}_${index}_${random}`,
type: 'pillar',
position: position.clone(),
height,
width: this.pillarWidth,
material: foundation.material ?? { name: 'wood' },
autoGenerated: true,
parentFoundation: foundation.id,
isGrounded: index === 0, // First pillar touches ground
createdAt: timestamp
};
}
/**
* Calculate total height of all pillars
*/
calculateTotalHeight(pillars) {
return pillars.reduce((sum, p) => sum + p.height, 0);
}
/**
* Calculate resource cost for pillars
*/
calculateResourceCost(pillars, material) {
const materialKey = material?.name?.toLowerCase() ?? 'wood';
const baseCost = this.resourceCosts[materialKey] ?? this.resourceCosts.wood;
const total = {};
for (const resource of Object.keys(baseCost)) {
total[resource] = 0;
}
for (const pillar of pillars) {
// Scale cost by pillar height
const heightMultiplier = pillar.height / this.pillarHeight;
for (const [resource, amount] of Object.entries(baseCost)) {
total[resource] += Math.ceil(amount * heightMultiplier);
}
}
return total;
}
/**
* Validate pillars against resource availability
*/
validateResources(pillars, foundation, inventory) {
const cost = this.calculateResourceCost(pillars, foundation.material);
const missing = {};
let hasEnough = true;
for (const [resource, needed] of Object.entries(cost)) {
const available = inventory[resource] ?? 0;
if (available < needed) {
hasEnough = false;
missing[resource] = needed - available;
}
}
return {
valid: hasEnough,
cost,
missing: hasEnough ? null : missing
};
}
/**
* Generate pillars with terrain variation analysis
*/
generateWithAnalysis(foundation, analyzer) {
const positions = this.getCornerPositions(foundation);
const analysis = {
corners: [],
minGap: Infinity,
maxGap: 0,
avgGap: 0
};
// Analyze each corner
for (const pos of positions) {
const groundY = analyzer.getHeightAt(pos.x, pos.z);
const gap = foundation.position.y - groundY;
analysis.corners.push({
position: pos,
groundY,
gap,
needsPillar: gap > this.minGap
});
analysis.minGap = Math.min(analysis.minGap, gap);
analysis.maxGap = Math.max(analysis.maxGap, gap);
}
analysis.avgGap = analysis.corners.reduce((sum, c) => sum + c.gap, 0) /
analysis.corners.length;
analysis.variation = analysis.maxGap - analysis.minGap;
// Determine optimal strategy based on analysis
let strategy = this.strategy;
if (strategy === PillarStrategy.ADAPTIVE) {
if (analysis.variation < 0.5) {
// Uniform terrain: corners only
strategy = PillarStrategy.CORNERS;
} else if (analysis.variation < 2) {
// Moderate variation: corners + maybe center
strategy = PillarStrategy.CORNERS;
} else {
// High variation: full grid for stability
strategy = PillarStrategy.GRID;
}
}
// Generate with determined strategy
const originalStrategy = this.strategy;
this.strategy = strategy;
const result = this.generateForFoundation(foundation, analyzer);
this.strategy = originalStrategy;
return {
...result,
analysis,
strategyUsed: strategy
};
}
/**
* Optimize pillar placement to minimize count
*/
optimizePillars(pillars, foundation) {
if (pillars.length <= 4) {
return { pillars, optimized: false };
}
// Group pillars by similar height
const heightGroups = new Map();
for (const pillar of pillars) {
const roundedHeight = Math.round(pillar.position.y * 2) / 2;
if (!heightGroups.has(roundedHeight)) {
heightGroups.set(roundedHeight, []);
}
heightGroups.get(roundedHeight).push(pillar);
}
// Keep only corner pillars from each height group
const optimized = [];
const corners = new Set(['NW', 'NE', 'SE', 'SW']);
for (const [height, group] of heightGroups) {
if (group.length <= 4) {
optimized.push(...group);
} else {
// Keep 4 most corner-like pillars
const sorted = group.sort((a, b) => {
const distA = this.distanceFromCenter(a.position, foundation.position);
const distB = this.distanceFromCenter(b.position, foundation.position);
return distB - distA; // Furthest from center first
});
optimized.push(...sorted.slice(0, 4));
}
}
return {
pillars: optimized,
optimized: optimized.length < pillars.length,
originalCount: pillars.length,
optimizedCount: optimized.length
};
}
/**
* Calculate distance from center
*/
distanceFromCenter(position, center) {
const dx = position.x - center.x;
const dz = position.z - center.z;
return Math.sqrt(dx * dx + dz * dz);
}
/**
* Create visual preview of pillar placement
*/
createPreviewMeshes(pillars, options = {}) {
const color = options.color ?? 0x00ff00;
const opacity = options.opacity ?? 0.5;
const group = new THREE.Group();
group.name = 'pillar-preview';
const material = new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity,
wireframe: options.wireframe ?? false
});
for (const pillar of pillars) {
const geometry = new THREE.BoxGeometry(
this.pillarWidth,
pillar.height,
this.pillarWidth
);
const mesh = new THREE.Mesh(geometry, material);
mesh.position.copy(pillar.position);
mesh.position.y += pillar.height / 2; // Center pivot
group.add(mesh);
}
return group;
}
/**
* Serialize pillars for networking/saving
*/
serializePillars(pillars) {
return pillars.map(p => ({
id: p.id,
type: p.type,
position: { x: p.position.x, y: p.position.y, z: p.position.z },
height: p.height,
width: p.width,
material: p.material?.name ?? 'wood',
autoGenerated: p.autoGenerated,
parentFoundation: p.parentFoundation
}));
}
/**
* Deserialize pillars from saved data
*/
deserializePillars(data) {
return data.map(p => ({
...p,
position: new THREE.Vector3(p.position.x, p.position.y, p.position.z),
material: { name: p.material }
}));
}
/**
* Update configuration
*/
setOptions(options) {
if (options.pillarHeight !== undefined) this.pillarHeight = options.pillarHeight;
if (options.pillarWidth !== undefined) this.pillarWidth = options.pillarWidth;
if (options.minGap !== undefined) this.minGap = options.minGap;
if (options.maxPillarsPerStack !== undefined) this.maxPillarsPerStack = options.maxPillarsPerStack;
if (options.maxTotalPillars !== undefined) this.maxTotalPillars = options.maxTotalPillars;
if (options.strategy !== undefined) this.strategy = options.strategy;
}
}
export default PillarGenerator;
/**
* TerrainAnalyzer - Analyzes terrain for building suitability
*
* Provides slope detection, buildability checks, and terrain height sampling
* for foundation placement systems.
*
* Usage:
* const analyzer = new TerrainAnalyzer(terrainMesh);
* const slope = analyzer.analyzeSlope(position, { radius: 4 });
* const buildable = analyzer.isBuildable(position, 4, 30);
*/
import * as THREE from 'three';
/**
* Terrain analysis results
* @typedef {Object} SlopeAnalysis
* @property {number} angle - Slope angle in degrees
* @property {THREE.Vector3} normal - Terrain normal at position
* @property {number} minHeight - Lowest height in sample area
* @property {number} maxHeight - Highest height in sample area
* @property {number} heightDiff - Height variation across area
* @property {THREE.Vector3} slopeDirection - Direction of steepest slope
* @property {boolean} canBuild - Whether slope is within buildable limit
* @property {Array} samples - Raw height samples
*/
/**
* Buildability check results
* @typedef {Object} BuildabilityResult
* @property {boolean} buildable - Whether area is suitable for building
* @property {number} slope - Calculated slope angle
* @property {number} heightVariation - Height difference across area
* @property {number} suggestedHeight - Recommended foundation height
*/
export class TerrainAnalyzer {
/**
* Create terrain analyzer
* @param {THREE.Mesh} terrain - Terrain mesh to analyze
* @param {Object} options - Configuration options
*/
constructor(terrain, options = {}) {
this.terrain = terrain;
this.sampleResolution = options.sampleResolution ?? 0.5;
this.cacheEnabled = options.cacheEnabled ?? true;
this.cacheSize = options.cacheSize ?? 10000;
this.heightCache = new Map();
// Raycaster for height queries
this.raycaster = new THREE.Raycaster();
this.rayOrigin = new THREE.Vector3();
this.rayDirection = new THREE.Vector3(0, -1, 0);
// Optional heightmap for faster queries
this.heightmap = terrain.userData?.heightmap ?? null;
this.heightmapResolution = terrain.userData?.heightmapResolution ?? null;
this.terrainBounds = terrain.userData?.bounds ?? this.computeBounds();
}
/**
* Compute terrain bounding box
*/
computeBounds() {
if (!this.terrain.geometry.boundingBox) {
this.terrain.geometry.computeBoundingBox();
}
return this.terrain.geometry.boundingBox.clone();
}
/**
* Get terrain height at position
* @param {number} x - X coordinate
* @param {number} z - Z coordinate
* @returns {number} Height at position
*/
getHeightAt(x, z) {
// Check cache first
if (this.cacheEnabled) {
const key = this.getCacheKey(x, z);
if (this.heightCache.has(key)) {
return this.heightCache.get(key);
}
}
// Try heightmap if available
let height;
if (this.heightmap) {
height = this.sampleHeightmap(x, z);
} else {
height = this.raycastHeight(x, z);
}
// Cache result
if (this.cacheEnabled) {
this.cacheHeight(x, z, height);
}
return height;
}
/**
* Generate cache key for position
*/
getCacheKey(x, z) {
const precision = 1 / this.sampleResolution;
return `${Math.round(x * precision)},${Math.round(z * precision)}`;
}
/**
* Cache height value with LRU eviction
*/
cacheHeight(x, z, height) {
const key = this.getCacheKey(x, z);
// Simple LRU: clear half of cache when full
if (this.heightCache.size >= this.cacheSize) {
const keysToDelete = Array.from(this.heightCache.keys())
.slice(0, this.cacheSize / 2);
keysToDelete.forEach(k => this.heightCache.delete(k));
}
this.heightCache.set(key, height);
}
/**
* Sample height from heightmap array
*/
sampleHeightmap(x, z) {
if (!this.heightmap || !this.heightmapResolution) {
return this.raycastHeight(x, z);
}
const bounds = this.terrainBounds;
const res = this.heightmapResolution;
// Normalize coordinates to heightmap space
const nx = (x - bounds.min.x) / (bounds.max.x - bounds.min.x);
const nz = (z - bounds.min.z) / (bounds.max.z - bounds.min.z);
// Clamp to valid range
const cx = Math.max(0, Math.min(1, nx));
const cz = Math.max(0, Math.min(1, nz));
// Get heightmap indices
const ix = Math.floor(cx * (res - 1));
const iz = Math.floor(cz * (res - 1));
// Bilinear interpolation
const fx = (cx * (res - 1)) - ix;
const fz = (cz * (res - 1)) - iz;
const i00 = iz * res + ix;
const i10 = iz * res + Math.min(ix + 1, res - 1);
const i01 = Math.min(iz + 1, res - 1) * res + ix;
const i11 = Math.min(iz + 1, res - 1) * res + Math.min(ix + 1, res - 1);
const h00 = this.heightmap[i00] ?? 0;
const h10 = this.heightmap[i10] ?? 0;
const h01 = this.heightmap[i01] ?? 0;
const h11 = this.heightmap[i11] ?? 0;
const h0 = h00 * (1 - fx) + h10 * fx;
const h1 = h01 * (1 - fx) + h11 * fx;
return h0 * (1 - fz) + h1 * fz;
}
/**
* Get height via raycast (slower but works with any mesh)
*/
raycastHeight(x, z) {
this.rayOrigin.set(x, 10000, z);
this.raycaster.set(this.rayOrigin, this.rayDirection);
const intersects = this.raycaster.intersectObject(this.terrain, true);
if (intersects.length > 0) {
return intersects[0].point.y;
}
// Fallback: return minimum terrain height
return this.terrainBounds?.min.y ?? 0;
}
/**
* Analyze slope at a position
* @param {THREE.Vector3} center - Center point to analyze
* @param {Object} options - Analysis options
* @returns {SlopeAnalysis} Slope analysis results
*/
analyzeSlope(center, options = {}) {
const radius = options.radius ?? 2;
const samples = options.samples ?? 8;
const maxSlope = options.maxSlope ?? 45;
// Sample heights in a circle
const heights = [];
const centerHeight = this.getHeightAt(center.x, center.z);
for (let i = 0; i < samples; i++) {
const angle = (i / samples) * Math.PI * 2;
const x = center.x + Math.cos(angle) * radius;
const z = center.z + Math.sin(angle) * radius;
heights.push({
x, z,
height: this.getHeightAt(x, z),
angle
});
}
// Calculate slope metrics
const minHeight = Math.min(centerHeight, ...heights.map(h => h.height));
const maxHeight = Math.max(centerHeight, ...heights.map(h => h.height));
const heightDiff = maxHeight - minHeight;
// Slope angle from height difference over diameter
const slopeAngle = Math.atan2(heightDiff, radius * 2) * (180 / Math.PI);
// Calculate normal vector
const normal = this.calculateNormal(center, heights, radius);
// Find steepest slope direction
let maxSlopeValue = 0;
const slopeDirection = new THREE.Vector3();
for (const sample of heights) {
const slope = Math.abs(sample.height - centerHeight) / radius;
if (slope > maxSlopeValue) {
maxSlopeValue = slope;
slopeDirection.set(
sample.x - center.x,
0,
sample.z - center.z
).normalize();
// Point downhill
if (sample.height < centerHeight) {
slopeDirection.negate();
}
}
}
return {
angle: slopeAngle,
normal,
minHeight,
maxHeight,
heightDiff,
centerHeight,
slopeDirection,
canBuild: slopeAngle <= maxSlope,
samples: heights
};
}
/**
* Calculate terrain normal at position
*/
calculateNormal(center, samples, radius) {
// Find samples at cardinal directions
const north = this.findClosestSample(samples, 0);
const east = this.findClosestSample(samples, Math.PI / 2);
const south = this.findClosestSample(samples, Math.PI);
const west = this.findClosestSample(samples, Math.PI * 1.5);
if (!north || !south || !east || !west) {
return new THREE.Vector3(0, 1, 0);
}
// Calculate gradients
const dx = (east.height - west.height) / (radius * 2);
const dz = (south.height - north.height) / (radius * 2);
// Normal from gradients
const normal = new THREE.Vector3(-dx, 1, -dz).normalize();
return normal;
}
/**
* Find sample closest to target angle
*/
findClosestSample(samples, targetAngle) {
let closest = null;
let minDiff = Infinity;
for (const sample of samples) {
let diff = Math.abs(sample.angle - targetAngle);
// Handle wrap-around
diff = Math.min(diff, Math.PI * 2 - diff);
if (diff < minDiff) {
minDiff = diff;
closest = sample;
}
}
return closest;
}
/**
* Check if area is suitable for building
* @param {THREE.Vector3} center - Center of build area
* @param {number} size - Size of build area
* @param {number} maxSlope - Maximum allowed slope in degrees
* @returns {BuildabilityResult} Buildability assessment
*/
isBuildable(center, size, maxSlope = 30) {
const halfSize = size / 2;
// Sample corners and center
const points = [
{ x: center.x, z: center.z },
{ x: center.x - halfSize, z: center.z - halfSize },
{ x: center.x + halfSize, z: center.z - halfSize },
{ x: center.x + halfSize, z: center.z + halfSize },
{ x: center.x - halfSize, z: center.z + halfSize }
];
const heights = points.map(p => this.getHeightAt(p.x, p.z));
const minH = Math.min(...heights);
const maxH = Math.max(...heights);
const heightVariation = maxH - minH;
// Calculate effective slope from corner to corner
const diagonal = Math.sqrt(2) * size;
const slope = Math.atan2(heightVariation, diagonal) * (180 / Math.PI);
return {
buildable: slope <= maxSlope,
slope,
heightVariation,
minHeight: minH,
maxHeight: maxH,
suggestedHeight: maxH,
cornerHeights: heights.slice(1) // Exclude center
};
}
/**
* Find optimal foundation height for position
* @param {THREE.Vector3} center - Foundation center
* @param {number} size - Foundation size
* @returns {number} Recommended foundation height
*/
findOptimalHeight(center, size) {
const result = this.isBuildable(center, size);
if (result.buildable) {
// Use max corner height to avoid terrain clipping
return result.maxHeight;
}
// For steep slopes, elevate above highest point
return result.maxHeight + (result.heightVariation * 0.25);
}
/**
* Get terrain heights along a line (for walls, fences)
* @param {THREE.Vector3} start - Start point
* @param {THREE.Vector3} end - End point
* @param {number} segments - Number of sample points
* @returns {Array} Height samples along line
*/
getHeightsAlongLine(start, end, segments = 10) {
const samples = [];
for (let i = 0; i <= segments; i++) {
const t = i / segments;
const x = start.x + (end.x - start.x) * t;
const z = start.z + (end.z - start.z) * t;
samples.push({
t,
x, z,
height: this.getHeightAt(x, z)
});
}
return samples;
}
/**
* Find flat areas within a region (for build suggestions)
* @param {THREE.Vector3} center - Search center
* @param {number} searchRadius - Radius to search
* @param {number} buildSize - Required build area size
* @param {number} maxSlope - Maximum acceptable slope
* @returns {Array} List of suitable build locations
*/
findFlatAreas(center, searchRadius, buildSize, maxSlope = 20) {
const gridStep = buildSize;
const candidates = [];
for (let dx = -searchRadius; dx <= searchRadius; dx += gridStep) {
for (let dz = -searchRadius; dz <= searchRadius; dz += gridStep) {
const distance = Math.sqrt(dx * dx + dz * dz);
if (distance > searchRadius) continue;
const testPos = new THREE.Vector3(
center.x + dx,
0,
center.z + dz
);
const result = this.isBuildable(testPos, buildSize, maxSlope);
if (result.buildable) {
candidates.push({
position: testPos,
slope: result.slope,
height: result.suggestedHeight,
distanceFromCenter: distance
});
}
}
}
// Sort by slope (flatter first), then distance
candidates.sort((a, b) => {
const slopeDiff = a.slope - b.slope;
if (Math.abs(slopeDiff) > 1) return slopeDiff;
return a.distanceFromCenter - b.distanceFromCenter;
});
return candidates;
}
/**
* Get terrain type/material at position (if terrain has material data)
*/
getTerrainType(x, z) {
if (!this.terrain.userData?.materialMap) {
return 'default';
}
// Sample material map similar to heightmap
const materialMap = this.terrain.userData.materialMap;
const res = this.terrain.userData.materialMapResolution ?? this.heightmapResolution;
if (!res) return 'default';
const bounds = this.terrainBounds;
const nx = (x - bounds.min.x) / (bounds.max.x - bounds.min.x);
const nz = (z - bounds.min.z) / (bounds.max.z - bounds.min.z);
const ix = Math.floor(Math.max(0, Math.min(1, nx)) * (res - 1));
const iz = Math.floor(Math.max(0, Math.min(1, nz)) * (res - 1));
return materialMap[iz * res + ix] ?? 'default';
}
/**
* Check if position is on water (if terrain has water data)
*/
isOnWater(x, z) {
if (this.terrain.userData?.waterLevel === undefined) {
return false;
}
const height = this.getHeightAt(x, z);
return height < this.terrain.userData.waterLevel;
}
/**
* Clear height cache
* Call after terrain modifications
*/
clearCache() {
this.heightCache.clear();
}
/**
* Clear cache in a specific region
* More efficient than full clear for local modifications
*/
clearCacheRegion(center, radius) {
const precision = 1 / this.sampleResolution;
const keysToDelete = [];
for (const [key, _] of this.heightCache) {
const [kx, kz] = key.split(',').map(Number);
const x = kx / precision;
const z = kz / precision;
const dist = Math.sqrt(
Math.pow(x - center.x, 2) +
Math.pow(z - center.z, 2)
);
if (dist <= radius) {
keysToDelete.push(key);
}
}
keysToDelete.forEach(k => this.heightCache.delete(k));
}
/**
* Get debug visualization of analyzed area
*/
createDebugVisualization(center, radius, samples = 16) {
const analysis = this.analyzeSlope(center, { radius, samples });
const geometry = new THREE.BufferGeometry();
// Create points for each sample
const positions = [];
const colors = [];
// Center point
positions.push(center.x, analysis.centerHeight + 0.1, center.z);
colors.push(0, 1, 0); // Green for center
// Sample points
for (const sample of analysis.samples) {
positions.push(sample.x, sample.height + 0.1, sample.z);
// Color by relative height (red = high, blue = low)
const normalized = (sample.height - analysis.minHeight) /
(analysis.heightDiff || 1);
colors.push(normalized, 0, 1 - normalized);
}
geometry.setAttribute('position',
new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute('color',
new THREE.Float32BufferAttribute(colors, 3));
const material = new THREE.PointsMaterial({
size: 0.5,
vertexColors: true
});
return new THREE.Points(geometry, material);
}
}
export default TerrainAnalyzer;
/**
* TerrainModifier - Modify terrain heightmap for building
*
* Provides terrain modification operations including flatten, raise, and lower.
* Supports undo/redo, serialization for save/load, and networking.
*
* Usage:
* const modifier = new TerrainModifier(terrainMesh);
* modifier.flatten(position, 5, targetHeight);
* modifier.raise(position, 3, 2);
* modifier.undo();
*/
import * as THREE from 'three';
/**
* Modification types
*/
export const ModificationType = {
FLATTEN: 'flatten',
RAISE: 'raise',
LOWER: 'lower',
SMOOTH: 'smooth',
LEVEL: 'level'
};
/**
* Falloff functions for edge blending
*/
export const FalloffMode = {
LINEAR: 'linear',
SMOOTH: 'smooth', // Smoothstep
SHARP: 'sharp', // Quadratic
CONSTANT: 'constant' // No falloff
};
export class TerrainModifier {
/**
* Create terrain modifier
* @param {THREE.Mesh} terrain - Terrain mesh to modify
* @param {Object} options - Configuration options
*/
constructor(terrain, options = {}) {
this.terrain = terrain;
this.geometry = terrain.geometry;
// Configuration
this.resolution = options.resolution ?? 1;
this.maxModification = options.maxModification ?? 10;
this.falloffMode = options.falloffMode ?? FalloffMode.SMOOTH;
this.maxHistorySize = options.maxHistorySize ?? 50;
// Store original heights for reference
this.originalHeights = this.captureHeights();
// Modification history for undo/redo
this.history = [];
this.historyIndex = -1;
// Bounds for coordinate mapping
this.bounds = this.computeBounds();
// Event callbacks
this.onModified = options.onModified ?? null;
this.onUndo = options.onUndo ?? null;
this.onRedo = options.onRedo ?? null;
}
/**
* Compute terrain bounding box
*/
computeBounds() {
if (!this.geometry.boundingBox) {
this.geometry.computeBoundingBox();
}
return this.geometry.boundingBox.clone();
}
/**
* Capture current height values
*/
captureHeights() {
const position = this.geometry.attributes.position;
const heights = new Float32Array(position.count);
for (let i = 0; i < position.count; i++) {
heights[i] = position.getY(i);
}
return heights;
}
/**
* Flatten terrain in an area
* @param {THREE.Vector3} center - Center of area to flatten
* @param {number} radius - Radius of effect
* @param {number} targetHeight - Height to flatten to (null = average)
* @returns {Object} Modification result
*/
flatten(center, radius, targetHeight = null) {
const affected = this.getAffectedVertices(center, radius);
if (affected.length === 0) {
return { success: false, reason: 'No vertices in range' };
}
// Calculate target height if not specified
const finalTarget = targetHeight ?? this.getAverageHeight(affected);
// Record state for undo
const modifications = [];
for (const vertex of affected) {
const influence = this.calculateInfluence(vertex, center, radius);
const newHeight = THREE.MathUtils.lerp(
vertex.height,
finalTarget,
influence
);
// Clamp to modification limits
const clampedHeight = this.clampHeight(vertex, newHeight);
if (Math.abs(clampedHeight - vertex.height) > 0.001) {
modifications.push({
index: vertex.index,
oldHeight: vertex.height,
newHeight: clampedHeight
});
this.setVertexHeight(vertex.index, clampedHeight);
}
}
if (modifications.length > 0) {
this.recordHistory({
type: ModificationType.FLATTEN,
center: center.clone(),
radius,
targetHeight: finalTarget,
modifications
});
this.updateMesh();
}
if (this.onModified) {
this.onModified({
type: ModificationType.FLATTEN,
center,
radius,
verticesModified: modifications.length
});
}
return {
success: true,
type: ModificationType.FLATTEN,
verticesModified: modifications.length,
targetHeight: finalTarget,
center: center.clone()
};
}
/**
* Raise terrain in an area
* @param {THREE.Vector3} center - Center of area
* @param {number} radius - Radius of effect
* @param {number} amount - Amount to raise
* @returns {Object} Modification result
*/
raise(center, radius, amount) {
const affected = this.getAffectedVertices(center, radius);
if (affected.length === 0) {
return { success: false, reason: 'No vertices in range' };
}
const modifications = [];
for (const vertex of affected) {
const influence = this.calculateInfluence(vertex, center, radius);
const raise = amount * influence;
const newHeight = vertex.height + raise;
const clampedHeight = this.clampHeight(vertex, newHeight);
if (Math.abs(clampedHeight - vertex.height) > 0.001) {
modifications.push({
index: vertex.index,
oldHeight: vertex.height,
newHeight: clampedHeight
});
this.setVertexHeight(vertex.index, clampedHeight);
}
}
if (modifications.length > 0) {
this.recordHistory({
type: ModificationType.RAISE,
center: center.clone(),
radius,
amount,
modifications
});
this.updateMesh();
}
if (this.onModified) {
this.onModified({
type: ModificationType.RAISE,
center,
radius,
amount,
verticesModified: modifications.length
});
}
return {
success: true,
type: ModificationType.RAISE,
verticesModified: modifications.length,
amount,
center: center.clone()
};
}
/**
* Lower terrain in an area
*/
lower(center, radius, amount) {
return this.raise(center, radius, -amount);
}
/**
* Smooth terrain in an area
*/
smooth(center, radius, strength = 0.5) {
const affected = this.getAffectedVertices(center, radius);
if (affected.length === 0) {
return { success: false, reason: 'No vertices in range' };
}
// Calculate average height of neighbors for each vertex
const targetHeights = new Map();
for (const vertex of affected) {
const neighbors = this.getNeighborVertices(vertex, 1);
if (neighbors.length > 0) {
const avgHeight = neighbors.reduce((sum, n) => sum + n.height, 0) / neighbors.length;
targetHeights.set(vertex.index, avgHeight);
}
}
const modifications = [];
for (const vertex of affected) {
const targetHeight = targetHeights.get(vertex.index);
if (targetHeight === undefined) continue;
const influence = this.calculateInfluence(vertex, center, radius);
const newHeight = THREE.MathUtils.lerp(
vertex.height,
targetHeight,
influence * strength
);
const clampedHeight = this.clampHeight(vertex, newHeight);
if (Math.abs(clampedHeight - vertex.height) > 0.001) {
modifications.push({
index: vertex.index,
oldHeight: vertex.height,
newHeight: clampedHeight
});
this.setVertexHeight(vertex.index, clampedHeight);
}
}
if (modifications.length > 0) {
this.recordHistory({
type: ModificationType.SMOOTH,
center: center.clone(),
radius,
strength,
modifications
});
this.updateMesh();
}
return {
success: true,
type: ModificationType.SMOOTH,
verticesModified: modifications.length
};
}
/**
* Level terrain to match foundation footprint
*/
levelForFoundation(foundation, padding = 1) {
const width = (foundation.width ?? 4) + padding * 2;
const depth = (foundation.depth ?? 4) + padding * 2;
const center = foundation.position.clone();
const targetHeight = foundation.position.y;
// Use rectangular area instead of circular
const affected = this.getAffectedVerticesRect(
center,
width,
depth,
foundation.rotation?.y ?? 0
);
if (affected.length === 0) {
return { success: false, reason: 'No vertices in footprint' };
}
const modifications = [];
for (const vertex of affected) {
const influence = this.calculateRectInfluence(
vertex, center, width, depth, padding
);
const newHeight = THREE.MathUtils.lerp(
vertex.height,
targetHeight,
influence
);
const clampedHeight = this.clampHeight(vertex, newHeight);
if (Math.abs(clampedHeight - vertex.height) > 0.001) {
modifications.push({
index: vertex.index,
oldHeight: vertex.height,
newHeight: clampedHeight
});
this.setVertexHeight(vertex.index, clampedHeight);
}
}
if (modifications.length > 0) {
this.recordHistory({
type: ModificationType.LEVEL,
center: center.clone(),
width,
depth,
targetHeight,
modifications
});
this.updateMesh();
}
return {
success: true,
type: ModificationType.LEVEL,
verticesModified: modifications.length,
targetHeight
};
}
/**
* Get vertices within radius of center
*/
getAffectedVertices(center, radius) {
const vertices = [];
const position = this.geometry.attributes.position;
const radiusSq = radius * radius;
for (let i = 0; i < position.count; i++) {
const x = position.getX(i);
const z = position.getZ(i);
const dx = x - center.x;
const dz = z - center.z;
const distSq = dx * dx + dz * dz;
if (distSq <= radiusSq) {
vertices.push({
index: i,
x, z,
height: position.getY(i),
distance: Math.sqrt(distSq)
});
}
}
return vertices;
}
/**
* Get vertices within rectangular area
*/
getAffectedVerticesRect(center, width, depth, rotation = 0) {
const vertices = [];
const position = this.geometry.attributes.position;
const hw = width / 2;
const hd = depth / 2;
// Create rotation matrix for rotated rectangles
const cos = Math.cos(-rotation);
const sin = Math.sin(-rotation);
for (let i = 0; i < position.count; i++) {
const x = position.getX(i);
const z = position.getZ(i);
// Translate to center
const dx = x - center.x;
const dz = z - center.z;
// Rotate to local space
const localX = dx * cos - dz * sin;
const localZ = dx * sin + dz * cos;
// Check if within rectangle
if (Math.abs(localX) <= hw && Math.abs(localZ) <= hd) {
vertices.push({
index: i,
x, z,
localX, localZ,
height: position.getY(i)
});
}
}
return vertices;
}
/**
* Get neighboring vertices
*/
getNeighborVertices(vertex, distance = 1) {
const neighbors = [];
const position = this.geometry.attributes.position;
const threshold = this.resolution * distance * 1.5;
for (let i = 0; i < position.count; i++) {
if (i === vertex.index) continue;
const x = position.getX(i);
const z = position.getZ(i);
const dx = x - vertex.x;
const dz = z - vertex.z;
const dist = Math.sqrt(dx * dx + dz * dz);
if (dist <= threshold) {
neighbors.push({
index: i,
x, z,
height: position.getY(i)
});
}
}
return neighbors;
}
/**
* Calculate influence based on distance and falloff
*/
calculateInfluence(vertex, center, radius) {
const normalized = vertex.distance / radius;
switch (this.falloffMode) {
case FalloffMode.LINEAR:
return 1 - normalized;
case FalloffMode.SMOOTH:
// Smoothstep
const t = 1 - normalized;
return t * t * (3 - 2 * t);
case FalloffMode.SHARP:
// Quadratic falloff
return Math.pow(1 - normalized, 2);
case FalloffMode.CONSTANT:
return 1;
default:
return 1 - normalized;
}
}
/**
* Calculate influence for rectangular area
*/
calculateRectInfluence(vertex, center, width, depth, padding) {
const hw = (width - padding * 2) / 2;
const hd = (depth - padding * 2) / 2;
// Distance from inner rectangle edge
const edgeDistX = Math.max(0, Math.abs(vertex.localX) - hw);
const edgeDistZ = Math.max(0, Math.abs(vertex.localZ) - hd);
const edgeDist = Math.sqrt(edgeDistX * edgeDistX + edgeDistZ * edgeDistZ);
if (edgeDist === 0) return 1; // Inside inner rectangle
const normalized = edgeDist / padding;
return Math.max(0, 1 - normalized);
}
/**
* Get average height of vertices
*/
getAverageHeight(vertices) {
if (vertices.length === 0) return 0;
const sum = vertices.reduce((acc, v) => acc + v.height, 0);
return sum / vertices.length;
}
/**
* Clamp height to modification limits
*/
clampHeight(vertex, newHeight) {
const original = this.originalHeights[vertex.index];
return THREE.MathUtils.clamp(
newHeight,
original - this.maxModification,
original + this.maxModification
);
}
/**
* Set height of a single vertex
*/
setVertexHeight(index, height) {
const position = this.geometry.attributes.position;
position.setY(index, height);
}
/**
* Update terrain mesh after modifications
*/
updateMesh() {
this.geometry.attributes.position.needsUpdate = true;
this.geometry.computeVertexNormals();
this.geometry.computeBoundingBox();
this.geometry.computeBoundingSphere();
// Update collision mesh if separate
if (this.terrain.userData?.collisionMesh) {
const collisionGeo = this.terrain.userData.collisionMesh.geometry;
collisionGeo.attributes.position.copy(this.geometry.attributes.position);
collisionGeo.attributes.position.needsUpdate = true;
}
}
/**
* Record modification to history
*/
recordHistory(entry) {
// Remove any redo history
this.history = this.history.slice(0, this.historyIndex + 1);
// Add new entry
this.history.push(entry);
this.historyIndex = this.history.length - 1;
// Limit history size
if (this.history.length > this.maxHistorySize) {
this.history.shift();
this.historyIndex--;
}
}
/**
* Undo last modification
*/
undo() {
if (this.historyIndex < 0) {
return { success: false, reason: 'Nothing to undo' };
}
const entry = this.history[this.historyIndex];
// Revert modifications
for (const mod of entry.modifications) {
this.setVertexHeight(mod.index, mod.oldHeight);
}
this.historyIndex--;
this.updateMesh();
if (this.onUndo) {
this.onUndo(entry);
}
return {
success: true,
type: entry.type,
verticesReverted: entry.modifications.length
};
}
/**
* Redo undone modification
*/
redo() {
if (this.historyIndex >= this.history.length - 1) {
return { success: false, reason: 'Nothing to redo' };
}
this.historyIndex++;
const entry = this.history[this.historyIndex];
// Reapply modifications
for (const mod of entry.modifications) {
this.setVertexHeight(mod.index, mod.newHeight);
}
this.updateMesh();
if (this.onRedo) {
this.onRedo(entry);
}
return {
success: true,
type: entry.type,
verticesModified: entry.modifications.length
};
}
/**
* Check if undo is available
*/
canUndo() {
return this.historyIndex >= 0;
}
/**
* Check if redo is available
*/
canRedo() {
return this.historyIndex < this.history.length - 1;
}
/**
* Get modification history summary
*/
getHistorySummary() {
return this.history.map((entry, i) => ({
index: i,
type: entry.type,
verticesModified: entry.modifications.length,
isCurrent: i === this.historyIndex
}));
}
/**
* Reset terrain to original state
*/
reset() {
const position = this.geometry.attributes.position;
for (let i = 0; i < position.count; i++) {
position.setY(i, this.originalHeights[i]);
}
this.history = [];
this.historyIndex = -1;
this.updateMesh();
return { success: true, verticesReset: position.count };
}
/**
* Serialize all modifications for saving
*/
serialize() {
return {
version: 1,
history: this.history.map(entry => ({
type: entry.type,
center: entry.center ? {
x: entry.center.x,
y: entry.center.y,
z: entry.center.z
} : null,
radius: entry.radius,
amount: entry.amount,
targetHeight: entry.targetHeight,
modifications: entry.modifications.map(m => ({
index: m.index,
oldHeight: m.oldHeight,
newHeight: m.newHeight
}))
})),
historyIndex: this.historyIndex
};
}
/**
* Deserialize and apply modifications
*/
deserialize(data) {
if (data.version !== 1) {
throw new Error(`Unsupported serialization version: ${data.version}`);
}
// Reset first
this.reset();
// Apply all modifications up to saved history index
for (let i = 0; i <= data.historyIndex; i++) {
const entry = data.history[i];
for (const mod of entry.modifications) {
this.setVertexHeight(mod.index, mod.newHeight);
}
}
// Restore history
this.history = data.history.map(entry => ({
...entry,
center: entry.center
? new THREE.Vector3(entry.center.x, entry.center.y, entry.center.z)
: null
}));
this.historyIndex = data.historyIndex;
this.updateMesh();
return {
success: true,
modificationsApplied: data.historyIndex + 1
};
}
/**
* Get delta since last sync (for networking)
*/
getDeltaSince(lastSyncIndex) {
if (lastSyncIndex >= this.historyIndex) {
return { hasChanges: false };
}
const entries = this.history.slice(lastSyncIndex + 1, this.historyIndex + 1);
return {
hasChanges: true,
fromIndex: lastSyncIndex,
toIndex: this.historyIndex,
entries: entries.map(e => this.serializeEntry(e))
};
}
/**
* Serialize single history entry
*/
serializeEntry(entry) {
return {
type: entry.type,
center: entry.center ? {
x: entry.center.x,
y: entry.center.y,
z: entry.center.z
} : null,
radius: entry.radius,
modifications: entry.modifications.map(m => ({
index: m.index,
newHeight: m.newHeight
}))
};
}
/**
* Apply delta from network
*/
applyDelta(delta) {
for (const entry of delta.entries) {
for (const mod of entry.modifications) {
this.setVertexHeight(mod.index, mod.newHeight);
}
}
this.updateMesh();
return { success: true, entriesApplied: delta.entries.length };
}
}
export default TerrainModifier;
Related skills
FAQ
What placement modes are supported?
Valheim, Rust, and Ark modes for foundation placement.
Does it work with stability?
It integrates with structural-physics for ground-based stability.