"""
Material and shader operations for Blender terrain visualization.
This module contains functions for creating and configuring Blender materials,
shaders, and background planes for terrain rendering.
"""
import logging
import numpy as np
from typing import Optional, Dict, Tuple
import bpy
logger = logging.getLogger(__name__)
# =============================================================================
# ROAD COLOR PRESETS
# =============================================================================
# Named color presets for roads (polished stone/mineral appearance, dielectric)
ROAD_COLORS = {
"obsidian": (0.02, 0.02, 0.02), # Near-black volcanic glass
"azurite": (0.04, 0.09, 0.16), # Deep blue mineral (#0A1628)
"azurite-light": (0.06, 0.15, 0.25), # Richer azurite (#0F2540)
"malachite": (0.02, 0.08, 0.05), # Deep green copper mineral
"hematite": (0.08, 0.06, 0.06), # Dark iron red-gray
}
# =============================================================================
# BASE MATERIAL PRESETS
# =============================================================================
# Named material presets for mesh bases (edge extrusion, backgrounds, etc.)
BASE_MATERIALS = {
"clay": (0.7, 0.25, 0.15), # Earth red clay (wet, unfired)
"obsidian": (0.02, 0.02, 0.02), # Dark volcanic glass (dielectric)
"chrome": (0.9, 0.9, 0.92), # Metallic chrome
"plastic": (0.95, 0.95, 0.95), # Glossy white plastic
"gold": (1.0, 0.766, 0.336), # Metallic gold
"ivory": (0.95, 0.93, 0.88), # Off-white with warm tone
}
# =============================================================================
# UNIFIED COLOR PRESETS (all colors available everywhere)
# =============================================================================
# Combined color presets from both ROAD_COLORS and BASE_MATERIALS.
# All color-accepting options (--road-color, --edge-base-material, --test-material)
# can use any of these colors.
ALL_COLORS = {**ROAD_COLORS, **BASE_MATERIALS}
# =============================================================================
# TERRAIN MATERIAL PRESETS
# =============================================================================
# Named material presets for terrain surface rendering.
# These control how vertex colors (elevation/score colormaps) are displayed.
# All presets are dielectric (non-metallic) to preserve color accuracy.
TERRAIN_MATERIALS = {
# === DIELECTRIC PRESETS (preserve color accuracy) ===
"matte": {
# Pure diffuse - colors appear exactly as computed, no specular
# Best for: 3D print preview, technical visualization
"roughness": 1.0,
"metallic": 0.0,
"specular_ior_level": 0.0,
},
"eggshell": {
# Very subtle sheen - almost matte but with slight depth
# Best for: print-quality renders, subtle topography
"roughness": 0.85,
"metallic": 0.0,
"specular_ior_level": 0.2,
},
"satin": {
# Soft highlights reveal terrain form while preserving color accuracy
# Best for: general-purpose visualization, presentations
"roughness": 0.7,
"metallic": 0.0,
"specular_ior_level": 0.3,
},
"ceramic": {
# Glossy surface like a ceramic sculpture or museum model
# Best for: presentation renders, architectural model aesthetic
"roughness": 0.35,
"metallic": 0.0,
"specular_ior_level": 0.5,
"ior": 1.5,
},
"lacquered": {
# Very glossy like varnished wood relief map
# Best for: artistic renders, vintage map aesthetic
"roughness": 0.25,
"metallic": 0.0,
"specular_ior_level": 0.5,
"ior": 1.45,
},
"clearcoat": {
# Matte color underneath glossy clear layer - automotive paint look
# Best for: premium presentation renders
"roughness": 0.6,
"metallic": 0.0,
"specular_ior_level": 0.3,
"coat_weight": 1.0,
"coat_roughness": 0.1,
},
"velvet": {
# Soft fabric-like appearance with gentle edge highlights
# Best for: organic/natural terrain visualization
"roughness": 0.8,
"metallic": 0.0,
"specular_ior_level": 0.15,
"sheen_weight": 0.3,
"sheen_roughness": 0.5,
},
# === MATERIAL-STYLE PRESETS (from color materials) ===
# These apply the shader properties of named materials while preserving vertex colors.
# Note: metallic materials will shift colors toward metallic reflection behavior.
"clay": {
# Matte gray clay look - same as matte but named for consistency
"roughness": 1.0,
"metallic": 0.0,
"specular_ior_level": 0.0,
},
"plastic": {
# Glossy plastic finish
"roughness": 0.2,
"metallic": 0.0,
"specular_ior_level": 0.5,
},
"ivory": {
# Slightly glossy with warm character
"roughness": 0.3,
"metallic": 0.0,
"specular_ior_level": 0.4,
},
"obsidian": {
# Glossy volcanic glass - very smooth dielectric
"roughness": 0.03,
"metallic": 0.0,
"specular_ior_level": 0.5,
"ior": 1.5,
},
"chrome": {
# Mirror-like metallic finish (will shift vertex colors toward metallic)
"roughness": 0.05,
"metallic": 1.0,
"specular_ior_level": 0.5,
},
"gold": {
# Metallic gold finish (will shift vertex colors toward metallic gold tones)
"roughness": 0.1,
"metallic": 1.0,
"specular_ior_level": 0.5,
},
"mineral": {
# Polished stone/mineral appearance - good generic glossy dielectric
"roughness": 0.15,
"metallic": 0.0,
"specular_ior_level": 0.5,
"ior": 1.55,
},
}
# Default terrain material preset
DEFAULT_TERRAIN_MATERIAL = "satin"
# =============================================================================
# HELP TEXT GENERATORS
# =============================================================================
[docs]
def get_all_colors_help() -> str:
"""Generate help text listing all available color presets (unified)."""
return ", ".join(ALL_COLORS.keys())
[docs]
def get_all_colors_choices() -> list[str]:
"""Return list of all valid color preset names for argparse choices."""
return list(ALL_COLORS.keys())
[docs]
def get_terrain_materials_help() -> str:
"""Generate help text listing all available terrain material presets."""
return ", ".join(TERRAIN_MATERIALS.keys())
[docs]
def get_terrain_materials_choices() -> list[str]:
"""Return list of valid terrain material preset names for argparse choices."""
return list(TERRAIN_MATERIALS.keys())
# Legacy aliases for backward compatibility
[docs]
def get_road_colors_help() -> str:
"""Generate help text listing all available color presets."""
return get_all_colors_help()
[docs]
def get_base_materials_help() -> str:
"""Generate help text listing all available color presets."""
return get_all_colors_help()
[docs]
def get_road_colors_choices() -> list[str]:
"""Return list of all valid color preset names for argparse choices."""
return get_all_colors_choices()
[docs]
def get_base_materials_choices() -> list[str]:
"""Return list of all valid color preset names for argparse choices."""
return get_all_colors_choices()
[docs]
def get_terrain_material_params(material: str) -> Dict:
"""
Get terrain material parameters by preset name.
Args:
material: Preset name (case-insensitive). One of:
- "matte": Pure diffuse, no specular (3D print preview)
- "eggshell": Very subtle sheen (print-quality renders)
- "satin": Soft highlights (general-purpose, default)
- "ceramic": Glossy museum model look
- "lacquered": Very glossy varnished wood look
- "clearcoat": Glossy clear layer over matte color
- "velvet": Soft fabric-like with edge highlights
Returns:
Dict of Principled BSDF parameters.
Raises:
ValueError: If material name is not recognized.
Examples:
>>> params = get_terrain_material_params("satin")
>>> params["roughness"]
0.7
"""
material_lower = material.lower()
if material_lower not in TERRAIN_MATERIALS:
valid = ", ".join(TERRAIN_MATERIALS.keys())
raise ValueError(f"Unknown terrain material: {material}. Valid options: {valid}")
return TERRAIN_MATERIALS[material_lower].copy()
[docs]
def get_color(color: str | Tuple[float, float, float]) -> Tuple[float, float, float]:
"""
Resolve a color preset name to RGB color tuple.
Accepts either a preset color name (case-insensitive) or an RGB tuple.
Color names map to predefined RGB values from ALL_COLORS (combined
road colors and base materials).
For terrain material presets (like "satin", "matte") that don't have
associated colors, returns clay gray (0.5, 0.48, 0.45) as a neutral default.
Args:
color: Either a preset name from ALL_COLORS or an RGB tuple (0-1 range).
Available presets: {get_all_colors_help()}
Returns:
RGB tuple (0-1 range) representing the color.
Raises:
ValueError: If color is a string but not in ALL_COLORS or TERRAIN_MATERIALS.
Examples:
>>> get_color("clay")
(0.5, 0.48, 0.45)
>>> get_color("azurite") # Road color preset
(0.04, 0.09, 0.16)
>>> get_color("GOLD") # Case-insensitive
(1.0, 0.766, 0.336)
>>> get_color("satin") # Terrain material - returns clay gray
(0.5, 0.48, 0.45)
>>> get_color((0.6, 0.55, 0.5)) # Custom RGB
(0.6, 0.55, 0.5)
"""
if isinstance(color, str):
color_lower = color.lower()
if color_lower in ALL_COLORS:
return ALL_COLORS[color_lower]
# For terrain material presets without colors (satin, matte, etc.),
# return clay gray as neutral default
if color_lower in TERRAIN_MATERIALS:
return ALL_COLORS["clay"] # (0.5, 0.48, 0.45)
raise ValueError(
f"Unknown color preset: {color}. "
f"Valid options: {get_all_colors_help()}"
)
else:
# Assume it's already an RGB tuple - pass through
return color
# Legacy alias for backward compatibility
[docs]
def get_base_material_color(material: str | Tuple[float, float, float]) -> Tuple[float, float, float]:
"""Resolve a color preset name to RGB. Alias for get_color()."""
return get_color(material)
[docs]
def apply_colormap_material(
material: bpy.types.Material,
terrain_material: Optional[str] = None,
) -> None:
"""
Create a physically-based material for terrain visualization using vertex colors.
Uses pure Principled BSDF for proper lighting response - no emission.
Terrain responds realistically to sun direction and casts proper shadows.
Args:
material: Blender material to configure
terrain_material: Optional preset name for material appearance. One of:
- "matte": Pure diffuse, no specular (3D print preview)
- "eggshell": Very subtle sheen (print-quality renders)
- "satin": Soft highlights (general-purpose, recommended)
- "ceramic": Glossy museum model look
- "lacquered": Very glossy varnished wood look
- "clearcoat": Glossy clear layer over matte color
- "velvet": Soft fabric-like with edge highlights
If None, uses DEFAULT_TERRAIN_MATERIAL ("satin").
"""
# Get material parameters from preset
preset_name = terrain_material or DEFAULT_TERRAIN_MATERIAL
try:
params = get_terrain_material_params(preset_name)
except ValueError:
logger.warning(f"Unknown terrain material '{terrain_material}', using {DEFAULT_TERRAIN_MATERIAL}")
params = get_terrain_material_params(DEFAULT_TERRAIN_MATERIAL)
preset_name = DEFAULT_TERRAIN_MATERIAL
logger.info(f"Setting up {preset_name} material nodes for {material.name}")
# Clear existing nodes
material.node_tree.nodes.clear()
nodes = material.node_tree.nodes
links = material.node_tree.links
try:
# Create shader nodes - pure Principled BSDF (no emission)
output = nodes.new("ShaderNodeOutputMaterial")
principled = nodes.new("ShaderNodeBsdfPrincipled")
vertex_color = nodes.new("ShaderNodeVertexColor")
# Position nodes
output.location = (400, 300)
principled.location = (200, 300)
vertex_color.location = (0, 300)
# Set vertex color layer
vertex_color.layer_name = "TerrainColors"
# Apply material parameters from preset
principled.inputs["Roughness"].default_value = params["roughness"]
principled.inputs["Metallic"].default_value = params["metallic"]
principled.inputs["Specular IOR Level"].default_value = params["specular_ior_level"]
# Optional parameters
if "ior" in params:
principled.inputs["IOR"].default_value = params["ior"]
if "coat_weight" in params:
principled.inputs["Coat Weight"].default_value = params["coat_weight"]
if "coat_roughness" in params:
principled.inputs["Coat Roughness"].default_value = params["coat_roughness"]
if "sheen_weight" in params:
principled.inputs["Sheen Weight"].default_value = params["sheen_weight"]
if "sheen_roughness" in params:
principled.inputs["Sheen Roughness"].default_value = params["sheen_roughness"]
# Create connections
logger.debug("Creating node connections")
# Vertex color drives base color directly
links.new(vertex_color.outputs["Color"], principled.inputs["Base Color"])
# Connect to output
links.new(principled.outputs["BSDF"], output.inputs["Surface"])
logger.info(f"Material setup completed: {preset_name} (roughness={params['roughness']})")
except Exception as e:
logger.error(f"Error setting up material: {str(e)}")
raise
[docs]
def apply_water_shader(
material: bpy.types.Material,
water_color: Tuple[float, float, float] = (0.0, 0.153, 0.298),
terrain_material: Optional[str] = None,
) -> None:
"""
Apply water shader to material, coloring water areas based on vertex alpha channel.
Uses alpha channel to mix between water color and elevation colors.
Water pixels (alpha=1.0) render as water color; land pixels (alpha=0.0) show elevation colors.
Args:
material: Blender material to configure
water_color: RGB tuple for water (default: University of Michigan blue #00274C)
terrain_material: Optional preset name for land material appearance.
See apply_colormap_material() for available presets.
If None, uses DEFAULT_TERRAIN_MATERIAL.
"""
# Get material parameters from preset
preset_name = terrain_material or DEFAULT_TERRAIN_MATERIAL
try:
params = get_terrain_material_params(preset_name)
except ValueError:
logger.warning(f"Unknown terrain material '{terrain_material}', using {DEFAULT_TERRAIN_MATERIAL}")
params = get_terrain_material_params(DEFAULT_TERRAIN_MATERIAL)
preset_name = DEFAULT_TERRAIN_MATERIAL
logger.info(f"Setting up water shader ({preset_name} terrain) for {material.name}")
# Clear existing nodes
material.node_tree.nodes.clear()
nodes = material.node_tree.nodes
links = material.node_tree.links
try:
# Create nodes - pure Principled BSDF (no emission)
output = nodes.new("ShaderNodeOutputMaterial")
principled = nodes.new("ShaderNodeBsdfPrincipled")
vertex_color = nodes.new("ShaderNodeVertexColor")
mix_rgb = nodes.new("ShaderNodeMixRGB") # Mix colors based on alpha
# Position nodes
output.location = (600, 300)
principled.location = (400, 300)
mix_rgb.location = (200, 300)
vertex_color.location = (0, 300)
# Set vertex color layer
vertex_color.layer_name = "TerrainColors"
# Configure water color
water_rgba = (*water_color, 1.0)
# Apply terrain material parameters from preset
principled.inputs["Roughness"].default_value = params["roughness"]
principled.inputs["Metallic"].default_value = params["metallic"]
principled.inputs["Specular IOR Level"].default_value = params["specular_ior_level"]
# Optional parameters
if "ior" in params:
principled.inputs["IOR"].default_value = params["ior"]
if "coat_weight" in params:
principled.inputs["Coat Weight"].default_value = params["coat_weight"]
if "coat_roughness" in params:
principled.inputs["Coat Roughness"].default_value = params["coat_roughness"]
if "sheen_weight" in params:
principled.inputs["Sheen Weight"].default_value = params["sheen_weight"]
if "sheen_roughness" in params:
principled.inputs["Sheen Roughness"].default_value = params["sheen_roughness"]
# Configure mix RGB for blending land and water colors
# Factor from alpha: 0 = land (use vertex color), 1 = water (use water color)
mix_rgb.inputs["Color1"].default_value = water_rgba # Water color
mix_rgb.inputs["Color2"].default_value = (
0.5,
0.5,
0.5,
1.0,
) # Default (overridden by vertex color)
# Create connections
logger.debug("Creating node connections with water shader (no emission)")
# Use alpha channel from vertex color to control water/land mixing
# Alpha > 0.5 = water, Alpha < 0.5 = land
links.new(vertex_color.outputs["Alpha"], mix_rgb.inputs["Fac"])
# Use vertex color as the land color input (Color2)
links.new(vertex_color.outputs["Color"], mix_rgb.inputs["Color2"])
# Use mixed color for principled shader base color
links.new(mix_rgb.outputs["Color"], principled.inputs["Base Color"])
# Connect directly to output (no emission mixing)
links.new(principled.outputs["BSDF"], output.inputs["Surface"])
logger.info(f"Water shader setup completed: {preset_name} terrain")
except Exception as e:
logger.error(f"Error setting up water shader: {str(e)}")
raise
def create_background_plane(
terrain_obj: bpy.types.Object,
depth: float = -2.0,
scale_factor: float = 2.0,
material_params: Optional[Dict] = None,
) -> bpy.types.Object:
"""
Create a large emissive plane beneath the terrain for background illumination.
Args:
terrain_obj: The terrain Blender object used for size reference
depth: Z-coordinate for the plane position
scale_factor: Scale multiplier for plane size relative to terrain
material_params: Optional dict to override default material parameters
Returns:
bpy.types.Object: The created background plane object
Raises:
ValueError: If terrain_obj is None or has invalid bounds
RuntimeError: If mesh or material creation fails
"""
logger.info("Creating background plane...")
if terrain_obj is None:
raise ValueError("Terrain object cannot be None")
default_material_params = {
"base_color": (1, 1, 1, 1),
"emission_color": (1, 1, 1, 1),
"emission_strength": 0.1, # Subtle fill light
"roughness": 0.5, # Mid roughness - diffuse reflection
"metallic": 0.0,
"ior": 1.0,
}
try:
# Calculate terrain bounds
bound_box = np.array(terrain_obj.bound_box)
terrain_min = np.min(bound_box, axis=0)
terrain_max = np.max(bound_box, axis=0)
terrain_size = terrain_max - terrain_min
terrain_center = (terrain_max + terrain_min) / 2
logger.debug(f"Terrain bounds - min: {terrain_min}, max: {terrain_max}")
logger.debug(f"Terrain size: {terrain_size}")
# Calculate plane dimensions
plane_size = max(terrain_size[0], terrain_size[1]) * scale_factor
half_size = plane_size / 2
logger.debug(f"Plane size: {plane_size}")
# Create plane geometry
try:
plane_mesh = bpy.data.meshes.new("BackgroundPlane")
plane_obj = bpy.data.objects.new("BackgroundPlane", plane_mesh)
# Define vertices
vertices = [
(terrain_center[0] - half_size, terrain_center[1] - half_size, depth),
(terrain_center[0] + half_size, terrain_center[1] - half_size, depth),
(terrain_center[0] + half_size, terrain_center[1] + half_size, depth),
(terrain_center[0] - half_size, terrain_center[1] + half_size, depth),
]
faces = [(0, 1, 2, 3)]
# Create mesh
plane_mesh.from_pydata(vertices, [], faces)
plane_mesh.update()
# Link to scene
bpy.context.scene.collection.objects.link(plane_obj)
except Exception as e:
logger.error(f"Failed to create plane mesh: {str(e)}")
raise RuntimeError("Mesh creation failed") from e
# Create material
try:
# Merge default params with any provided overrides
params = default_material_params.copy()
if material_params:
params.update(material_params)
mat = bpy.data.materials.new(name="BackgroundPlaneMaterial")
mat.use_nodes = True
nodes = mat.node_tree.nodes
nodes.clear()
# Create shader nodes
output = nodes.new("ShaderNodeOutputMaterial")
principled = nodes.new("ShaderNodeBsdfPrincipled")
# Configure material properties using merged params
principled.inputs["Base Color"].default_value = params["base_color"]
principled.inputs["Emission Color"].default_value = params["emission_color"]
principled.inputs["Emission Strength"].default_value = params["emission_strength"]
principled.inputs["Roughness"].default_value = params["roughness"]
principled.inputs["Metallic"].default_value = params["metallic"]
principled.inputs["IOR"].default_value = params["ior"]
# Position nodes
output.location = (300, 0)
principled.location = (0, 0)
# Connect nodes
mat.node_tree.links.new(principled.outputs["BSDF"], output.inputs["Surface"])
# Assign material
plane_obj.data.materials.append(mat)
except Exception as e:
logger.error(f"Failed to create plane material: {str(e)}")
raise RuntimeError("Material creation failed") from e
logger.info(f"Successfully created background plane:")
logger.info(f" Size: {plane_size:.2f}")
logger.info(f" Depth: {depth}")
logger.info(f" Center: ({terrain_center[0]:.2f}, {terrain_center[1]:.2f})")
return plane_obj
except Exception as e:
logger.error(f"Background plane creation failed: {str(e)}")
raise
[docs]
def apply_terrain_with_obsidian_roads(
material: bpy.types.Material,
terrain_style: Optional[str] = None,
road_color: str | Tuple[float, float, float] = "obsidian",
terrain_material: Optional[str] = None,
) -> None:
"""
Create a material with glossy roads and terrain colors/test material.
Reads from two vertex color layers:
- "TerrainColors": The actual terrain colors (used for non-road areas)
- "RoadMask": R channel marks road pixels (R > 0.5 = road)
Roads render with glossy dielectric properties (like polished stone).
Non-road areas use either vertex colors or a test material.
Args:
material: Blender material to configure
terrain_style: Optional test material for terrain ("chrome", "clay", etc.)
If None, uses vertex colors with terrain_material preset.
road_color: Road color - either a preset name from ROAD_COLORS
("obsidian", "azurite", "azurite-light", "malachite", "hematite")
or an RGB tuple (0-1 range). Default: "obsidian" (near-black).
terrain_material: Optional preset name for terrain material appearance when
using vertex colors (terrain_style=None). One of:
"matte", "eggshell", "satin", "ceramic", "lacquered",
"clearcoat", "velvet". Default: DEFAULT_TERRAIN_MATERIAL.
"""
# Resolve road color (accepts any color from ALL_COLORS or RGB tuple)
if isinstance(road_color, str):
if road_color.lower() in ALL_COLORS:
road_rgb = ALL_COLORS[road_color.lower()]
road_color_name = road_color.lower()
else:
logger.warning(f"Unknown color preset '{road_color}', using obsidian")
road_rgb = ALL_COLORS["obsidian"]
road_color_name = "obsidian"
else:
road_rgb = road_color
road_color_name = f"RGB{road_color}"
logger.info(f"Setting up terrain + {road_color_name} roads material for {material.name}")
if terrain_style:
logger.info(f" Terrain style: {terrain_style}")
else:
logger.info(" Terrain style: vertex colors (score-based)")
material.node_tree.nodes.clear()
nodes = material.node_tree.nodes
links = material.node_tree.links
try:
# === OUTPUT ===
output = nodes.new("ShaderNodeOutputMaterial")
output.location = (800, 0)
# === ROAD MASK INPUT ===
road_mask = nodes.new("ShaderNodeVertexColor")
road_mask.layer_name = "RoadMask"
road_mask.location = (-400, -200)
# Separate RGB to extract just the R channel for mix factor
# (Color output averages RGB → ~0.33 for R=1,G=0,B=0, we need 1.0)
separate_rgb = nodes.new("ShaderNodeSeparateColor")
separate_rgb.location = (-200, -200)
# === MIXER (roads vs terrain) ===
mix_shader = nodes.new("ShaderNodeMixShader")
mix_shader.location = (600, 0)
# === ROAD SHADER ===
# Use material properties from TERRAIN_MATERIALS if available, otherwise default to mineral
road_shader = nodes.new("ShaderNodeBsdfPrincipled")
road_shader.location = (200, -200)
road_shader.inputs["Base Color"].default_value = (*road_rgb, 1.0)
# Get shader properties from TERRAIN_MATERIALS if the color has a matching material preset
if road_color_name in TERRAIN_MATERIALS:
road_params = TERRAIN_MATERIALS[road_color_name]
road_shader.inputs["Roughness"].default_value = road_params["roughness"]
road_shader.inputs["Metallic"].default_value = road_params["metallic"]
road_shader.inputs["Specular IOR Level"].default_value = road_params["specular_ior_level"]
if "ior" in road_params:
road_shader.inputs["IOR"].default_value = road_params["ior"]
logger.info(f" Road material: {road_color_name} (metallic={road_params['metallic']})")
else:
# Default: polished mineral appearance for colors without specific material presets
road_shader.inputs["Roughness"].default_value = 0.15 # Polished stone finish
road_shader.inputs["Metallic"].default_value = 0.0 # Dielectric
road_shader.inputs["IOR"].default_value = 1.55 # Mineral-like
road_shader.inputs["Specular IOR Level"].default_value = 0.6 # Good reflectivity
logger.info(f" Road material: mineral (default)")
# === TERRAIN SHADER ===
if terrain_style and terrain_style.lower() != "none":
# Use test material for terrain
terrain_shader = nodes.new("ShaderNodeBsdfPrincipled")
terrain_shader.location = (200, 200)
_configure_principled_for_style(terrain_shader, terrain_style)
else:
# Use vertex colors with terrain material preset
terrain_colors = nodes.new("ShaderNodeVertexColor")
terrain_colors.layer_name = "TerrainColors"
terrain_colors.location = (-400, 200)
# Get terrain material parameters from preset
preset_name = terrain_material or DEFAULT_TERRAIN_MATERIAL
try:
params = get_terrain_material_params(preset_name)
except ValueError:
logger.warning(f"Unknown terrain material '{terrain_material}', using {DEFAULT_TERRAIN_MATERIAL}")
params = get_terrain_material_params(DEFAULT_TERRAIN_MATERIAL)
preset_name = DEFAULT_TERRAIN_MATERIAL
# Principled BSDF with terrain material preset
terrain_principled = nodes.new("ShaderNodeBsdfPrincipled")
terrain_principled.location = (0, 300)
terrain_principled.inputs["Roughness"].default_value = params["roughness"]
terrain_principled.inputs["Metallic"].default_value = params["metallic"]
terrain_principled.inputs["Specular IOR Level"].default_value = params["specular_ior_level"]
# Optional parameters
if "ior" in params:
terrain_principled.inputs["IOR"].default_value = params["ior"]
if "coat_weight" in params:
terrain_principled.inputs["Coat Weight"].default_value = params["coat_weight"]
if "coat_roughness" in params:
terrain_principled.inputs["Coat Roughness"].default_value = params["coat_roughness"]
if "sheen_weight" in params:
terrain_principled.inputs["Sheen Weight"].default_value = params["sheen_weight"]
if "sheen_roughness" in params:
terrain_principled.inputs["Sheen Roughness"].default_value = params["sheen_roughness"]
# Connect terrain vertex colors directly to principled shader
links.new(terrain_colors.outputs["Color"], terrain_principled.inputs["Base Color"])
terrain_shader = terrain_principled
logger.info(f" Terrain material: {preset_name}")
# === CONNECT EVERYTHING ===
# Road mask R channel controls mixing (R=1 → road, R=0 → terrain)
links.new(road_mask.outputs["Color"], separate_rgb.inputs["Color"])
links.new(separate_rgb.outputs["Red"], mix_shader.inputs[0]) # Use R channel as mix factor
# Input 1 = terrain (when mask is 0)
# Input 2 = road (when mask is 1)
links.new(terrain_shader.outputs["BSDF"], mix_shader.inputs[1])
links.new(road_shader.outputs["BSDF"], mix_shader.inputs[2])
# Connect to output
links.new(mix_shader.outputs["Shader"], output.inputs["Surface"])
logger.info(f"✓ Terrain + {road_color_name} roads material applied")
except Exception as e:
logger.error(f"Error setting up terrain + roads material: {str(e)}")
raise
def _configure_principled_for_style(shader_node, style: str) -> None:
"""
Configure a Principled BSDF node for a specific test material style.
Supports all colors from ALL_COLORS. Some materials have special shader
settings (chrome/gold are metallic, obsidian is glossy glass, etc.),
while others use the color with standard dielectric settings.
Args:
shader_node: Blender Principled BSDF shader node to configure.
style: Material/color name (case-insensitive) from ALL_COLORS.
"""
style_lower = style.lower()
# Materials with special shader settings
if style_lower == "obsidian":
# Obsidian is volcanic glass (dielectric), NOT metal
# Reflects via Fresnel like glass, dark from iron/magnesium impurities
shader_node.inputs["Base Color"].default_value = (0.02, 0.02, 0.02, 1.0)
shader_node.inputs["Roughness"].default_value = 0.03 # Very smooth but not perfect mirror
shader_node.inputs["Metallic"].default_value = 0.0 # Glass, not metal
shader_node.inputs["IOR"].default_value = 1.5 # Glass-like
shader_node.inputs["Specular IOR Level"].default_value = 0.5 # Standard dielectric
elif style_lower == "chrome":
shader_node.inputs["Base Color"].default_value = (0.9, 0.9, 0.92, 1.0)
shader_node.inputs["Roughness"].default_value = 0.05
shader_node.inputs["Metallic"].default_value = 1.0
elif style_lower == "clay":
shader_node.inputs["Base Color"].default_value = (0.7, 0.25, 0.15, 1.0)
shader_node.inputs["Roughness"].default_value = 1.0
shader_node.inputs["Metallic"].default_value = 0.0
shader_node.inputs["Specular IOR Level"].default_value = 0.0
elif style_lower == "plastic":
shader_node.inputs["Base Color"].default_value = (0.95, 0.95, 0.95, 1.0)
shader_node.inputs["Roughness"].default_value = 0.2
shader_node.inputs["Metallic"].default_value = 0.0
shader_node.inputs["Specular IOR Level"].default_value = 0.5
elif style_lower == "gold":
shader_node.inputs["Base Color"].default_value = (1.0, 0.766, 0.336, 1.0)
shader_node.inputs["Roughness"].default_value = 0.1
shader_node.inputs["Metallic"].default_value = 1.0
elif style_lower == "ivory":
# Off-white with warm tone, slightly glossy
shader_node.inputs["Base Color"].default_value = (0.95, 0.93, 0.88, 1.0)
shader_node.inputs["Roughness"].default_value = 0.3
shader_node.inputs["Metallic"].default_value = 0.0
shader_node.inputs["Specular IOR Level"].default_value = 0.4
elif style_lower in ALL_COLORS:
# Use color from ALL_COLORS with polished stone/mineral appearance
# (dielectric, slightly glossy - good for road colors like azurite, malachite)
rgb = ALL_COLORS[style_lower]
shader_node.inputs["Base Color"].default_value = (*rgb, 1.0)
shader_node.inputs["Roughness"].default_value = 0.15 # Polished finish
shader_node.inputs["Metallic"].default_value = 0.0 # Dielectric
shader_node.inputs["IOR"].default_value = 1.55 # Mineral-like
shader_node.inputs["Specular IOR Level"].default_value = 0.5
else:
# Unknown style - default to gray
shader_node.inputs["Base Color"].default_value = (0.5, 0.5, 0.5, 1.0)
shader_node.inputs["Roughness"].default_value = 0.5
# Keep for backwards compatibility
[docs]
def apply_glassy_road_material(material: bpy.types.Material) -> None:
"""Deprecated: Use apply_terrain_with_obsidian_roads() instead."""
apply_terrain_with_obsidian_roads(material, terrain_style=None)
# =============================================================================
# TEST MATERIALS - For visualization testing without vertex colors
# =============================================================================
[docs]
def apply_test_material(material: bpy.types.Material, style: str) -> None:
"""
Apply a test material to the entire terrain mesh.
Test materials ignore vertex colors and apply a uniform material style
for testing lighting, shadows, and mesh geometry.
Args:
material: Blender material to configure
style: Material style name - any color from ALL_COLORS
({get_all_colors_help()}) or "terrain" for normal vertex colors.
Raises:
ValueError: If style is not recognized
"""
style_lower = style.lower()
if style_lower == "terrain":
apply_colormap_material(material)
elif style_lower in ALL_COLORS:
_apply_test_material_generic(material, style_lower)
else:
raise ValueError(
f"Unknown test material style: {style}. "
f"Valid options: {get_all_colors_help()}, terrain"
)
def _apply_test_material_generic(material: bpy.types.Material, style: str) -> None:
"""Apply a test material using the unified color/style system."""
logger.info(f"Applying {style} test material to {material.name}")
material.node_tree.nodes.clear()
nodes = material.node_tree.nodes
links = material.node_tree.links
output = nodes.new("ShaderNodeOutputMaterial")
principled = nodes.new("ShaderNodeBsdfPrincipled")
principled.location = (0, 0)
output.location = (300, 0)
# Configure shader based on style (handles special cases and generic colors)
_configure_principled_for_style(principled, style)
links.new(principled.outputs["BSDF"], output.inputs["Surface"])
logger.info(f"✓ {style.capitalize()} test material applied")
def _apply_test_material_obsidian(material: bpy.types.Material) -> None:
"""Obsidian volcanic glass - dark dielectric with glass-like reflections."""
logger.info(f"Applying obsidian test material to {material.name}")
material.node_tree.nodes.clear()
nodes = material.node_tree.nodes
links = material.node_tree.links
output = nodes.new("ShaderNodeOutputMaterial")
principled = nodes.new("ShaderNodeBsdfPrincipled")
principled.location = (0, 0)
output.location = (300, 0)
# Obsidian is volcanic glass (dielectric), NOT metal
# Dark color from iron/magnesium impurities, reflects via Fresnel like glass
principled.inputs["Base Color"].default_value = (0.02, 0.02, 0.02, 1.0)
principled.inputs["Roughness"].default_value = 0.03 # Very smooth but not perfect mirror
principled.inputs["Metallic"].default_value = 0.0 # Glass, not metal
principled.inputs["IOR"].default_value = 1.5 # Glass-like
principled.inputs["Specular IOR Level"].default_value = 0.5 # Standard dielectric
links.new(principled.outputs["BSDF"], output.inputs["Surface"])
logger.info("✓ Obsidian test material applied")
def _apply_test_material_chrome(material: bpy.types.Material) -> None:
"""Metallic chrome - highly reflective silver metal."""
logger.info(f"Applying chrome test material to {material.name}")
material.node_tree.nodes.clear()
nodes = material.node_tree.nodes
links = material.node_tree.links
output = nodes.new("ShaderNodeOutputMaterial")
principled = nodes.new("ShaderNodeBsdfPrincipled")
principled.location = (0, 0)
output.location = (300, 0)
# Bright chrome metal
principled.inputs["Base Color"].default_value = (0.9, 0.9, 0.92, 1.0)
principled.inputs["Roughness"].default_value = 0.05 # Slight roughness for realism
principled.inputs["Metallic"].default_value = 1.0
links.new(principled.outputs["BSDF"], output.inputs["Surface"])
logger.info("✓ Chrome test material applied")
def _apply_test_material_clay(material: bpy.types.Material) -> None:
"""Matte clay - diffuse gray, no reflections, shows form clearly."""
logger.info(f"Applying clay test material to {material.name}")
material.node_tree.nodes.clear()
nodes = material.node_tree.nodes
links = material.node_tree.links
output = nodes.new("ShaderNodeOutputMaterial")
principled = nodes.new("ShaderNodeBsdfPrincipled")
principled.location = (0, 0)
output.location = (300, 0)
# Neutral gray clay
principled.inputs["Base Color"].default_value = (0.5, 0.48, 0.45, 1.0)
principled.inputs["Roughness"].default_value = 1.0 # Fully matte
principled.inputs["Metallic"].default_value = 0.0
principled.inputs["Specular IOR Level"].default_value = 0.0 # No specular
links.new(principled.outputs["BSDF"], output.inputs["Surface"])
logger.info("✓ Clay test material applied")
def _apply_test_material_plastic(material: bpy.types.Material) -> None:
"""Glossy white plastic - shows highlights and shadows well."""
logger.info(f"Applying plastic test material to {material.name}")
material.node_tree.nodes.clear()
nodes = material.node_tree.nodes
links = material.node_tree.links
output = nodes.new("ShaderNodeOutputMaterial")
principled = nodes.new("ShaderNodeBsdfPrincipled")
principled.location = (0, 0)
output.location = (300, 0)
# White glossy plastic
principled.inputs["Base Color"].default_value = (0.95, 0.95, 0.95, 1.0)
principled.inputs["Roughness"].default_value = 0.2 # Smooth but not mirror
principled.inputs["Metallic"].default_value = 0.0
principled.inputs["Specular IOR Level"].default_value = 0.5
links.new(principled.outputs["BSDF"], output.inputs["Surface"])
logger.info("✓ Plastic test material applied")
def _apply_test_material_gold(material: bpy.types.Material) -> None:
"""Metallic gold - warm reflective metal."""
logger.info(f"Applying gold test material to {material.name}")
material.node_tree.nodes.clear()
nodes = material.node_tree.nodes
links = material.node_tree.links
output = nodes.new("ShaderNodeOutputMaterial")
principled = nodes.new("ShaderNodeBsdfPrincipled")
principled.location = (0, 0)
output.location = (300, 0)
# Gold metal color
principled.inputs["Base Color"].default_value = (1.0, 0.766, 0.336, 1.0)
principled.inputs["Roughness"].default_value = 0.1
principled.inputs["Metallic"].default_value = 1.0
links.new(principled.outputs["BSDF"], output.inputs["Surface"])
logger.info("✓ Gold test material applied")
def _apply_test_material_ivory(material: bpy.types.Material) -> None:
"""Ivory - off-white with warm tone, slightly glossy."""
logger.info(f"Applying ivory test material to {material.name}")
material.node_tree.nodes.clear()
nodes = material.node_tree.nodes
links = material.node_tree.links
output = nodes.new("ShaderNodeOutputMaterial")
principled = nodes.new("ShaderNodeBsdfPrincipled")
principled.location = (0, 0)
output.location = (300, 0)
# Ivory - warm off-white
principled.inputs["Base Color"].default_value = (0.95, 0.93, 0.88, 1.0)
principled.inputs["Roughness"].default_value = 0.3 # Slightly glossy
principled.inputs["Metallic"].default_value = 0.0
principled.inputs["Specular IOR Level"].default_value = 0.4
links.new(principled.outputs["BSDF"], output.inputs["Surface"])
logger.info("✓ Ivory test material applied")