// Foliage tools for Unreal Engine
import { UnrealBridge } from '../unreal-bridge.js';
import { bestEffortInterpretedText, coerceBoolean, coerceNumber, coerceString, interpretStandardResult } from '../utils/result-helpers.js';
export class FoliageTools {
constructor(private bridge: UnrealBridge) {}
// NOTE: We intentionally avoid issuing Unreal console commands here because
// they have proven unreliable and generate engine warnings (failed FindConsoleObject).
// Instead, we validate inputs and return structured results. Actual foliage
// authoring should be implemented via Python APIs in future iterations.
// Add foliage type via Python (creates FoliageType asset properly)
async addFoliageType(params: {
name: string;
meshPath: string;
density?: number;
radius?: number;
minScale?: number;
maxScale?: number;
alignToNormal?: boolean;
randomYaw?: boolean;
groundSlope?: number;
}) {
// Basic validation to prevent bad inputs like 'undefined' and empty strings
const errors: string[] = [];
const name = String(params?.name ?? '').trim();
const meshPath = String(params?.meshPath ?? '').trim();
if (!name || name.toLowerCase() === 'undefined' || name.toLowerCase() === 'any') {
errors.push(`Invalid foliage type name: '${params?.name}'`);
}
if (!meshPath || meshPath.toLowerCase() === 'undefined') {
errors.push(`Invalid meshPath: '${params?.meshPath}'`);
}
if (params?.density !== undefined) {
if (typeof params.density !== 'number' || !isFinite(params.density) || params.density < 0) {
errors.push(`Invalid density: '${params.density}' (must be non-negative finite number)`);
}
}
if (params?.minScale !== undefined || params?.maxScale !== undefined) {
const minS = params?.minScale ?? 1;
const maxS = params?.maxScale ?? 1;
if (typeof minS !== 'number' || typeof maxS !== 'number' || minS <= 0 || maxS <= 0 || maxS < minS) {
errors.push(`Invalid scale range: min=${params?.minScale}, max=${params?.maxScale}`);
}
}
if (errors.length > 0) {
return { success: false, error: errors.join('; ') };
}
const py = `
import unreal, json
name = ${JSON.stringify(name)}
mesh_path = ${JSON.stringify(meshPath)}
fallback_mesh = '/Engine/EngineMeshes/Sphere'
package_path = '/Game/Foliage/Types'
res = {'success': False, 'created': False, 'asset_path': '', 'used_mesh': '', 'exists_after': False, 'method': '', 'note': ''}
try:
# Ensure package directory
try:
if not unreal.EditorAssetLibrary.does_directory_exist(package_path):
unreal.EditorAssetLibrary.make_directory(package_path)
except Exception as e:
res['note'] += f"; make_directory failed: {e}"
# Load mesh or fallback
mesh = None
try:
if unreal.EditorAssetLibrary.does_asset_exist(mesh_path):
mesh = unreal.EditorAssetLibrary.load_asset(mesh_path)
except Exception as e:
res['note'] += f"; could not check/load mesh_path: {e}"
if not mesh:
mesh = unreal.EditorAssetLibrary.load_asset(fallback_mesh)
res['note'] += '; fallback_mesh_used'
if mesh:
res['used_mesh'] = str(mesh.get_path_name())
# Create FoliageType asset using proper UE5 API
asset = None
try:
asset_path = f"{package_path}/{name}"
# Check if asset already exists
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
asset = unreal.EditorAssetLibrary.load_asset(asset_path)
res['note'] += '; loaded_existing'
else:
# Create FoliageType_InstancedStaticMesh using proper API
try:
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
# Try to create factory and set mesh property
factory = None
try:
factory = unreal.FoliageType_InstancedStaticMeshFactory()
# Try different property names for different UE versions
try:
factory.set_editor_property('mesh', mesh)
except:
try:
factory.set_editor_property('static_mesh', mesh)
except:
try:
factory.set_editor_property('source_mesh', mesh)
except:
pass # Factory will use default or no mesh
except:
res['note'] += '; factory_creation_failed'
factory = None
# Create the asset with or without factory
if factory:
asset = asset_tools.create_asset(
asset_name=name,
package_path=package_path,
asset_class=unreal.FoliageType_InstancedStaticMesh,
factory=factory
)
else:
# Try without factory
asset = asset_tools.create_asset(
asset_name=name,
package_path=package_path,
asset_class=unreal.FoliageType_InstancedStaticMesh,
factory=None
)
if asset:
# Configure foliage properties
asset.set_editor_property('mesh', mesh)
if ${params.density !== undefined ? params.density : 1.0} >= 0:
asset.set_editor_property('density', ${params.density !== undefined ? params.density : 1.0})
if ${params.randomYaw === false ? 'False' : 'True'}:
asset.set_editor_property('random_yaw', True)
if ${params.alignToNormal === false ? 'False' : 'True'}:
asset.set_editor_property('align_to_normal', True)
# Set scale range
min_scale = ${params.minScale || 0.8}
max_scale = ${params.maxScale || 1.2}
asset.set_editor_property('scale_x', (min_scale, max_scale))
asset.set_editor_property('scale_y', (min_scale, max_scale))
asset.set_editor_property('scale_z', (min_scale, max_scale))
res['note'] += '; created_with_factory'
else:
res['note'] += '; factory_creation_failed'
except AttributeError:
# Fallback if factory doesn't exist - use base FoliageType
try:
asset = asset_tools.create_asset(
asset_name=name,
package_path=package_path,
asset_class=unreal.FoliageType,
factory=None
)
if asset:
res['note'] += '; created_base_foliage_type'
except Exception as e2:
res['note'] += f"; base_creation_failed: {e2}"
except Exception as e:
res['note'] += f"; factory_creation_failed: {e}"
asset = None
except Exception as e:
res['note'] += f"; create_asset failed: {e}"
asset = None
if asset and mesh:
try:
# Set the mesh property (different property names in different UE versions)
try:
asset.set_editor_property('mesh', mesh)
except:
try:
asset.set_editor_property('static_mesh', mesh)
except:
pass
# Save the asset
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
res['asset_path'] = str(asset.get_path_name())
res['created'] = True
res['method'] = 'FoliageType_InstancedStaticMesh'
except Exception as e:
res['note'] += f"; set/save asset failed: {e}"
elif not asset:
res['note'] += "; asset creation returned None"
elif not mesh:
res['note'] += "; mesh object is None, cannot assign to foliage type"
# Verify existence
res['exists_after'] = unreal.EditorAssetLibrary.does_asset_exist(res['asset_path']) if res['asset_path'] else False
res['success'] = res['exists_after'] or res['created']
except Exception as e:
res['success'] = False
res['note'] += f"; fatal: {e}"
print('RESULT:' + json.dumps(res))
`.trim();
const pyResp = await this.bridge.executePython(py);
const interpreted = interpretStandardResult(pyResp, {
successMessage: `Foliage type '${name}' processed`,
failureMessage: 'Add foliage type failed'
});
if (!interpreted.success) {
return {
success: false,
error: coerceString(interpreted.payload.note) ?? interpreted.error ?? 'Add foliage type failed',
note: coerceString(interpreted.payload.note) ?? bestEffortInterpretedText(interpreted)
};
}
const payload = interpreted.payload as Record<string, unknown>;
const created = coerceBoolean(payload.created, false) ?? false;
const exists = coerceBoolean(payload.exists_after, false) ?? created;
const method = coerceString(payload.method) ?? 'Unknown';
const assetPath = coerceString(payload.asset_path);
const usedMesh = coerceString(payload.used_mesh);
const note = coerceString(payload.note);
return {
success: true,
created,
exists,
method,
assetPath,
usedMesh,
note,
message: exists
? `Foliage type '${name}' ready (${method})`
: `Created foliage '${name}' but verification did not find it yet`
};
}
// Paint foliage by placing HISM instances (editor-only)
async paintFoliage(params: {
foliageType: string;
position: [number, number, number];
brushSize?: number;
paintDensity?: number;
eraseMode?: boolean;
}) {
const errors: string[] = [];
const foliageType = String(params?.foliageType ?? '').trim();
const pos = Array.isArray(params?.position) ? params.position : [0,0,0];
if (!foliageType || foliageType.toLowerCase() === 'undefined' || foliageType.toLowerCase() === 'any') {
errors.push(`Invalid foliageType: '${params?.foliageType}'`);
}
if (!Array.isArray(pos) || pos.length !== 3 || pos.some(v => typeof v !== 'number' || !isFinite(v))) {
errors.push(`Invalid position: '${JSON.stringify(params?.position)}'`);
}
if (params?.brushSize !== undefined) {
if (typeof params.brushSize !== 'number' || !isFinite(params.brushSize) || params.brushSize < 0) {
errors.push(`Invalid brushSize: '${params.brushSize}' (must be non-negative finite number)`);
}
}
if (params?.paintDensity !== undefined) {
if (typeof params.paintDensity !== 'number' || !isFinite(params.paintDensity) || params.paintDensity < 0) {
errors.push(`Invalid paintDensity: '${params.paintDensity}' (must be non-negative finite number)`);
}
}
if (errors.length > 0) {
return { success: false, error: errors.join('; ') };
}
const brush = Number.isFinite(params.brushSize as number) ? (params.brushSize as number) : 300;
const py = `
import unreal, json, random, math
res = {'success': False, 'added': 0, 'actor': '', 'component': '', 'used_mesh': '', 'note': ''}
foliage_type_name = ${JSON.stringify(foliageType)}
px, py, pz = ${pos[0]}, ${pos[1]}, ${pos[2]}
radius = float(${brush}) / 2.0
try:
actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
if not actor_subsystem:
raise RuntimeError('EditorActorSubsystem unavailable. Enable Editor Scripting Utilities plugin.')
all_actors = actor_subsystem.get_all_level_actors()
# Find or create a container actor using modern EditorActorSubsystem
label = f"FoliageContainer_{foliage_type_name}"
container = None
for a in all_actors:
try:
if a and a.get_actor_label() == label:
container = a
break
except Exception:
pass
if not container:
container = actor_subsystem.spawn_actor_from_class(
unreal.StaticMeshActor,
unreal.Vector(px, py, pz)
)
if not container:
raise RuntimeError('Failed to spawn foliage container actor via EditorActorSubsystem')
try:
container.set_actor_label(label)
except Exception:
pass
# Resolve mesh from FoliageType asset
mesh = None
fol_asset_path = f"/Game/Foliage/Types/{foliage_type_name}.{foliage_type_name}"
if unreal.EditorAssetLibrary.does_asset_exist(fol_asset_path):
try:
ft_asset = unreal.EditorAssetLibrary.load_asset(fol_asset_path)
mesh = ft_asset.get_editor_property('mesh')
except Exception:
mesh = None
if not mesh:
mesh = unreal.EditorAssetLibrary.load_asset('/Engine/EngineMeshes/Sphere')
res['note'] += '; used_fallback_mesh'
if mesh:
res['used_mesh'] = str(mesh.get_path_name())
# Since HISM components and add_component don't work in this version,
# spawn individual StaticMeshActors for each instance
target_count = max(5, int(radius / 20.0))
added = 0
for i in range(target_count):
ang = random.random() * math.tau
r = random.random() * radius
x, y, z = px + math.cos(ang) * r, py + math.sin(ang) * r, pz
try:
# Spawn static mesh actor at position using modern subsystem
inst_actor = actor_subsystem.spawn_actor_from_class(
unreal.StaticMeshActor,
unreal.Vector(x, y, z),
unreal.Rotator(0, random.random()*360.0, 0)
)
if inst_actor and mesh:
# Set mesh on the actor's component
try:
mesh_comp = inst_actor.static_mesh_component
if mesh_comp:
mesh_comp.set_static_mesh(mesh)
inst_actor.set_actor_label(f"{foliage_type_name}_instance_{i}")
# Group under the container for organization
inst_actor.attach_to_actor(container, "", unreal.AttachmentRule.KEEP_WORLD, unreal.AttachmentRule.KEEP_WORLD, unreal.AttachmentRule.KEEP_WORLD, False)
added += 1
except Exception as e:
res['note'] += f"; instance_{i} setup failed: {e}"
except Exception as e:
res['note'] += f"; spawn instance_{i} failed: {e}"
res['added'] = added
res['actor'] = container.get_actor_label()
res['component'] = 'StaticMeshActors' # Using actors instead of components
res['success'] = True
except Exception as e:
res['success'] = False
res['note'] += f"; fatal: {e}"
print('RESULT:' + json.dumps(res))
`.trim();
const pyResp = await this.bridge.executePython(py);
const interpreted = interpretStandardResult(pyResp, {
successMessage: `Painted foliage for '${foliageType}'`,
failureMessage: 'Paint foliage failed'
});
if (!interpreted.success) {
return {
success: false,
error: coerceString(interpreted.payload.note) ?? interpreted.error ?? 'Paint foliage failed',
note: coerceString(interpreted.payload.note) ?? bestEffortInterpretedText(interpreted)
};
}
const payload = interpreted.payload as Record<string, unknown>;
const added = coerceNumber(payload.added) ?? 0;
const actor = coerceString(payload.actor);
const component = coerceString(payload.component);
const usedMesh = coerceString(payload.used_mesh);
const note = coerceString(payload.note);
return {
success: true,
added,
actor,
component,
usedMesh,
note,
message: `Painted ${added} instances for '${foliageType}' around (${pos[0]}, ${pos[1]}, ${pos[2]})`
};
}
// Create instanced mesh
async createInstancedMesh(params: {
name: string;
meshPath: string;
instances: Array<{
position: [number, number, number];
rotation?: [number, number, number];
scale?: [number, number, number];
}>;
enableCulling?: boolean;
cullDistance?: number;
}) {
const commands: string[] = [];
commands.push(`CreateInstancedStaticMesh ${params.name} ${params.meshPath}`);
for (const instance of params.instances) {
const rot = instance.rotation || [0, 0, 0];
const scale = instance.scale || [1, 1, 1];
commands.push(`AddInstance ${params.name} ${instance.position.join(' ')} ${rot.join(' ')} ${scale.join(' ')}`);
}
if (params.enableCulling !== undefined) {
commands.push(`SetInstanceCulling ${params.name} ${params.enableCulling}`);
}
if (params.cullDistance !== undefined) {
commands.push(`SetInstanceCullDistance ${params.name} ${params.cullDistance}`);
}
await this.bridge.executeConsoleCommands(commands);
return { success: true, message: `Instanced mesh ${params.name} created with ${params.instances.length} instances` };
}
// Set foliage LOD
async setFoliageLOD(params: {
foliageType: string;
lodDistances?: number[];
screenSize?: number[];
}) {
const commands: string[] = [];
if (params.lodDistances) {
commands.push(`SetFoliageLODDistances ${params.foliageType} ${params.lodDistances.join(' ')}`);
}
if (params.screenSize) {
commands.push(`SetFoliageLODScreenSize ${params.foliageType} ${params.screenSize.join(' ')}`);
}
await this.bridge.executeConsoleCommands(commands);
return { success: true, message: 'Foliage LOD settings updated' };
}
// Create procedural foliage
async createProceduralFoliage(params: {
volumeName: string;
position: [number, number, number];
size: [number, number, number];
foliageTypes: string[];
seed?: number;
tileSize?: number;
}) {
const commands: string[] = [];
commands.push(`CreateProceduralFoliageVolume ${params.volumeName} ${params.position.join(' ')} ${params.size.join(' ')}`);
for (const type of params.foliageTypes) {
commands.push(`AddProceduralFoliageType ${params.volumeName} ${type}`);
}
if (params.seed !== undefined) {
commands.push(`SetProceduralSeed ${params.volumeName} ${params.seed}`);
}
if (params.tileSize !== undefined) {
commands.push(`SetProceduralTileSize ${params.volumeName} ${params.tileSize}`);
}
commands.push(`GenerateProceduralFoliage ${params.volumeName}`);
await this.bridge.executeConsoleCommands(commands);
return { success: true, message: `Procedural foliage volume ${params.volumeName} created` };
}
// Set foliage collision
async setFoliageCollision(params: {
foliageType: string;
collisionEnabled?: boolean;
collisionProfile?: string;
generateOverlapEvents?: boolean;
}) {
const commands: string[] = [];
if (params.collisionEnabled !== undefined) {
commands.push(`SetFoliageCollision ${params.foliageType} ${params.collisionEnabled}`);
}
if (params.collisionProfile) {
commands.push(`SetFoliageCollisionProfile ${params.foliageType} ${params.collisionProfile}`);
}
if (params.generateOverlapEvents !== undefined) {
commands.push(`SetFoliageOverlapEvents ${params.foliageType} ${params.generateOverlapEvents}`);
}
await this.bridge.executeConsoleCommands(commands);
return { success: true, message: 'Foliage collision settings updated' };
}
// Create grass system
async createGrassSystem(params: {
name: string;
grassTypes: Array<{
meshPath: string;
density: number;
minScale?: number;
maxScale?: number;
}>;
windStrength?: number;
windSpeed?: number;
}) {
const commands: string[] = [];
commands.push(`CreateGrassSystem ${params.name}`);
for (const grassType of params.grassTypes) {
const minScale = grassType.minScale || 0.8;
const maxScale = grassType.maxScale || 1.2;
commands.push(`AddGrassType ${params.name} ${grassType.meshPath} ${grassType.density} ${minScale} ${maxScale}`);
}
if (params.windStrength !== undefined) {
commands.push(`SetGrassWindStrength ${params.name} ${params.windStrength}`);
}
if (params.windSpeed !== undefined) {
commands.push(`SetGrassWindSpeed ${params.name} ${params.windSpeed}`);
}
await this.bridge.executeConsoleCommands(commands);
return { success: true, message: `Grass system ${params.name} created` };
}
// Remove foliage instances
async removeFoliageInstances(params: {
foliageType: string;
position: [number, number, number];
radius: number;
}) {
const command = `RemoveFoliageInRadius ${params.foliageType} ${params.position.join(' ')} ${params.radius}`;
return this.bridge.executeConsoleCommand(command);
}
// Select foliage instances
async selectFoliageInstances(params: {
foliageType: string;
position?: [number, number, number];
radius?: number;
selectAll?: boolean;
}) {
let command: string;
if (params.selectAll) {
command = `SelectAllFoliage ${params.foliageType}`;
} else if (params.position && params.radius) {
command = `SelectFoliageInRadius ${params.foliageType} ${params.position.join(' ')} ${params.radius}`;
} else {
command = `SelectFoliageType ${params.foliageType}`;
}
return this.bridge.executeConsoleCommand(command);
}
// Update foliage instances
async updateFoliageInstances(params: {
foliageType: string;
updateTransforms?: boolean;
updateMesh?: boolean;
newMeshPath?: string;
}) {
const commands: string[] = [];
if (params.updateTransforms) {
commands.push(`UpdateFoliageTransforms ${params.foliageType}`);
}
if (params.updateMesh && params.newMeshPath) {
commands.push(`UpdateFoliageMesh ${params.foliageType} ${params.newMeshPath}`);
}
commands.push(`RefreshFoliage ${params.foliageType}`);
await this.bridge.executeConsoleCommands(commands);
return { success: true, message: 'Foliage instances updated' };
}
// Create foliage spawner
async createFoliageSpawner(params: {
name: string;
spawnArea: 'Landscape' | 'StaticMesh' | 'BSP' | 'Foliage' | 'All';
excludeAreas?: Array<[number, number, number, number]>; // [x, y, z, radius]
}) {
const commands: string[] = [];
commands.push(`CreateFoliageSpawner ${params.name} ${params.spawnArea}`);
if (params.excludeAreas) {
for (const area of params.excludeAreas) {
commands.push(`AddFoliageExclusionArea ${params.name} ${area.join(' ')}`);
}
}
await this.bridge.executeConsoleCommands(commands);
return { success: true, message: `Foliage spawner ${params.name} created` };
}
// Optimize foliage
async optimizeFoliage(params: {
mergeInstances?: boolean;
generateClusters?: boolean;
clusterSize?: number;
reduceDrawCalls?: boolean;
}) {
const commands = [];
if (params.mergeInstances) {
commands.push('MergeFoliageInstances');
}
if (params.generateClusters) {
const size = params.clusterSize || 100;
commands.push(`GenerateFoliageClusters ${size}`);
}
if (params.reduceDrawCalls) {
commands.push('OptimizeFoliageDrawCalls');
}
commands.push('RebuildFoliageTree');
await this.bridge.executeConsoleCommands(commands);
return { success: true, message: 'Foliage optimized' };
}
}